merge(main): 同步主站最新 UI 与审核优化代码并保持分销多租户隔离
This commit is contained in:
@@ -271,6 +271,13 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue
|
||||
db = db.Where(`(d.business_no LIKE ? OR d.payee_name LIKE ? OR d.payee_phone LIKE ?
|
||||
OR d.uploader_name LIKE ? OR d.account_name LIKE ? OR d.account_no LIKE ?)`, like, like, like, like, like, like)
|
||||
}
|
||||
if query.Operator != "" {
|
||||
like := "%" + query.Operator + "%"
|
||||
db = db.Where("(d.operator_name LIKE ? OR CAST(d.operator_id AS CHAR) LIKE ?)", like, like)
|
||||
}
|
||||
if query.OperatorID > 0 {
|
||||
db = db.Where("d.operator_id = ?", query.OperatorID)
|
||||
}
|
||||
if !query.StartDate.IsZero() && !query.EndDate.IsZero() {
|
||||
if query.DateType == "paid" {
|
||||
db = db.Where("d.paid_at >= ? AND d.paid_at <= ?", query.StartDate, query.EndDate)
|
||||
|
||||
@@ -203,4 +203,24 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
||||
if paidWithdrawals.Total != 1 || paidWithdrawals.Items[0].WithdrawalID != 10 {
|
||||
t.Fatalf("按打款日期查询结果 = %+v", paidWithdrawals.Items)
|
||||
}
|
||||
|
||||
operatorFiltered, err := repo.Disbursements(t.Context(), DisbursementQuery{
|
||||
Operator: "财务甲",
|
||||
DateType: "created",
|
||||
StartDate: at(1),
|
||||
EndDate: time.Date(2026, 7, 10, 23, 59, 59, 0, loc),
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("按操作人查询出款失败: %v", err)
|
||||
}
|
||||
if operatorFiltered.Total != 7 || len(operatorFiltered.Items) != 7 {
|
||||
t.Fatalf("按操作人查询结果 = %+v", operatorFiltered.Items)
|
||||
}
|
||||
for _, item := range operatorFiltered.Items {
|
||||
if item.OperatorName != "财务甲" {
|
||||
t.Fatalf("操作人筛选返回了其他操作人: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ type DisbursementQuery struct {
|
||||
DateType string
|
||||
BusinessNo string
|
||||
Keyword string
|
||||
Operator string
|
||||
OperatorID uint64
|
||||
UserID uint64
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
|
||||
@@ -2,6 +2,7 @@ package adminfinance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -222,9 +223,13 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
|
||||
DateType: c.DefaultQuery("date_type", "created"),
|
||||
BusinessNo: strings.TrimSpace(c.Query("business_no")),
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
Operator: strings.TrimSpace(c.Query("operator")),
|
||||
StartDate: start,
|
||||
EndDate: end,
|
||||
}
|
||||
if query.Operator == "" {
|
||||
query.Operator = strings.TrimSpace(c.Query("operator_name"))
|
||||
}
|
||||
if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "pickup" && query.SourceType != "pickup_adjustment" && query.SourceType != "withdrawal" && query.SourceType != "manual_offline" {
|
||||
response.BadRequest(c, "出款类型不正确")
|
||||
return query, false
|
||||
@@ -237,6 +242,14 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
|
||||
response.BadRequest(c, "日期类型不正确")
|
||||
return query, false
|
||||
}
|
||||
if raw := c.Query("operator_id"); raw != "" {
|
||||
value, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || value == 0 {
|
||||
response.BadRequest(c, "操作人 ID 不正确")
|
||||
return query, false
|
||||
}
|
||||
query.OperatorID = value
|
||||
}
|
||||
if raw := c.Query("user_id"); raw != "" {
|
||||
value, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || value == 0 {
|
||||
@@ -280,6 +293,45 @@ func parseOperatingExpenseQuery(c *gin.Context) (OperatingExpenseQuery, bool) {
|
||||
return query, true
|
||||
}
|
||||
|
||||
func parseFlexibleDateTime(raw string, loc *time.Location, isEnd bool) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, errors.New("empty time string")
|
||||
}
|
||||
formats := []string{
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
time.RFC3339,
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, fmtStr := range formats {
|
||||
if t, err := time.ParseInLocation(fmtStr, raw, loc); err == nil {
|
||||
switch fmtStr {
|
||||
case "2006-01-02":
|
||||
if isEnd {
|
||||
return t.AddDate(0, 0, 1).Add(-time.Nanosecond), nil
|
||||
}
|
||||
return t, nil
|
||||
case "2006-01-02 15:04", "2006-01-02T15:04":
|
||||
if isEnd {
|
||||
return t.Add(time.Minute).Add(-time.Nanosecond), nil
|
||||
}
|
||||
return t, nil
|
||||
case "2006-01-02 15:04:05", "2006-01-02T15:04:05":
|
||||
if isEnd {
|
||||
return t.Add(time.Second - time.Nanosecond), nil
|
||||
}
|
||||
return t, nil
|
||||
default:
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("invalid time format: %s", raw)
|
||||
}
|
||||
|
||||
func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) {
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
today := timeutil.ShanghaiNow()
|
||||
@@ -288,24 +340,49 @@ func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Ti
|
||||
|
||||
start := defaultStart
|
||||
end := defaultEnd
|
||||
if raw := c.Query("start_date"); raw != "" {
|
||||
parsed, err := time.ParseInLocation("2006-01-02", raw, loc)
|
||||
|
||||
rawStart := strings.TrimSpace(c.Query("start_time"))
|
||||
usingStartTime := rawStart != ""
|
||||
if rawStart == "" {
|
||||
rawStart = strings.TrimSpace(c.Query("start_date"))
|
||||
}
|
||||
if rawStart != "" {
|
||||
parsed, err := parseFlexibleDateTime(rawStart, loc, false)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "开始日期不正确")
|
||||
if usingStartTime {
|
||||
response.BadRequest(c, "开始时间不正确")
|
||||
} else {
|
||||
response.BadRequest(c, "开始日期不正确")
|
||||
}
|
||||
return start, end, false
|
||||
}
|
||||
start = parsed
|
||||
}
|
||||
if raw := c.Query("end_date"); raw != "" {
|
||||
parsed, err := time.ParseInLocation("2006-01-02", raw, loc)
|
||||
|
||||
rawEnd := strings.TrimSpace(c.Query("end_time"))
|
||||
usingEndTime := rawEnd != ""
|
||||
if rawEnd == "" {
|
||||
rawEnd = strings.TrimSpace(c.Query("end_date"))
|
||||
}
|
||||
if rawEnd != "" {
|
||||
parsed, err := parseFlexibleDateTime(rawEnd, loc, true)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "结束日期不正确")
|
||||
if usingEndTime {
|
||||
response.BadRequest(c, "结束时间不正确")
|
||||
} else {
|
||||
response.BadRequest(c, "结束日期不正确")
|
||||
}
|
||||
return start, end, false
|
||||
}
|
||||
end = parsed.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||
end = parsed
|
||||
}
|
||||
|
||||
if start.After(end) {
|
||||
response.BadRequest(c, "开始日期不能晚于结束日期")
|
||||
if usingStartTime || usingEndTime {
|
||||
response.BadRequest(c, "开始时间不能晚于结束时间")
|
||||
} else {
|
||||
response.BadRequest(c, "开始日期不能晚于结束日期")
|
||||
}
|
||||
return start, end, false
|
||||
}
|
||||
return start, end, true
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package adminfinance
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestParseFlexibleDateTime(t *testing.T) {
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
|
||||
// 1. Test YYYY-MM-DD
|
||||
start, err := parseFlexibleDateTime("2026-09-01", loc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for date start: %v", err)
|
||||
}
|
||||
if start.Year() != 2026 || start.Month() != 9 || start.Day() != 1 || start.Hour() != 0 || start.Minute() != 0 || start.Second() != 0 {
|
||||
t.Fatalf("unexpected start date: %v", start)
|
||||
}
|
||||
|
||||
end, err := parseFlexibleDateTime("2026-09-01", loc, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for date end: %v", err)
|
||||
}
|
||||
if end.Year() != 2026 || end.Month() != 9 || end.Day() != 1 || end.Hour() != 23 || end.Minute() != 59 || end.Second() != 59 {
|
||||
t.Fatalf("unexpected end date: %v", end)
|
||||
}
|
||||
|
||||
// 2. Test YYYY-MM-DD HH:mm
|
||||
startMinute, err := parseFlexibleDateTime("2026-09-01 14:30", loc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for minute start: %v", err)
|
||||
}
|
||||
if startMinute.Hour() != 14 || startMinute.Minute() != 30 || startMinute.Second() != 0 {
|
||||
t.Fatalf("unexpected start minute: %v", startMinute)
|
||||
}
|
||||
|
||||
endMinute, err := parseFlexibleDateTime("2026-09-01 14:30", loc, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for minute end: %v", err)
|
||||
}
|
||||
if endMinute.Hour() != 14 || endMinute.Minute() != 30 || endMinute.Second() != 59 {
|
||||
t.Fatalf("unexpected end minute: %v", endMinute)
|
||||
}
|
||||
|
||||
// 3. Test YYYY-MM-DD HH:mm:ss
|
||||
startSec, err := parseFlexibleDateTime("2026-09-01 14:30:15", loc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for second start: %v", err)
|
||||
}
|
||||
if startSec.Hour() != 14 || startSec.Minute() != 30 || startSec.Second() != 15 {
|
||||
t.Fatalf("unexpected start sec: %v", startSec)
|
||||
}
|
||||
|
||||
endSec, err := parseFlexibleDateTime("2026-09-01 14:30:15", loc, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for second end: %v", err)
|
||||
}
|
||||
if endSec.Hour() != 14 || endSec.Minute() != 30 || endSec.Second() != 15 {
|
||||
t.Fatalf("unexpected end sec: %v", endSec)
|
||||
}
|
||||
|
||||
// 4. Test invalid
|
||||
if _, err := parseFlexibleDateTime("invalid-date", loc, false); err == nil {
|
||||
t.Fatalf("expected error for invalid date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDateRangeGin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantStatus int
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
name: "default range",
|
||||
query: "",
|
||||
wantStatus: http.StatusOK,
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "date range",
|
||||
query: "?start_date=2026-09-01&end_date=2026-09-07",
|
||||
wantStatus: http.StatusOK,
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "datetime range with minute",
|
||||
query: "?start_date=2026-09-01%2009:30&end_date=2026-09-07%2018:45",
|
||||
wantStatus: http.StatusOK,
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "datetime range with second",
|
||||
query: "?start_date=2026-09-01%2009:30:00&end_date=2026-09-07%2018:45:59",
|
||||
wantStatus: http.StatusOK,
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "start_time and end_time params",
|
||||
query: "?start_time=2026-09-01%2009:30:00&end_time=2026-09-07%2018:45:59",
|
||||
wantStatus: http.StatusOK,
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "start after end",
|
||||
query: "?start_date=2026-09-07&end_date=2026-09-01",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "invalid date",
|
||||
query: "?start_date=invalid",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantOk: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/test"+tc.query, nil)
|
||||
c.Request = req
|
||||
|
||||
_, _, ok := parseDateRange(c, 29)
|
||||
if ok != tc.wantOk {
|
||||
t.Fatalf("parseDateRange() ok = %v, wantOk = %v", ok, tc.wantOk)
|
||||
}
|
||||
if !ok && w.Code != tc.wantStatus {
|
||||
t.Fatalf("HTTP status = %d, wantStatus = %d", w.Code, tc.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -43,33 +43,6 @@ type ListingDTO struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PublicListingListItemDTO 是首页商品卡片的最小公开数据。
|
||||
// 号主身份、账号内部 ID、审核和结算字段仅限号主或后台接口返回。
|
||||
type PublicListingListItemDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
ListingNo string `json:"listing_no"`
|
||||
Title string `json:"title"`
|
||||
GameName string `json:"game_name"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
AssetSummary map[string]any `json:"asset_summary,omitempty"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
IsAccelerated bool `json:"is_accelerated_sale"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PublicListingDetailDTO 是公开商品详情数据,不包含号主身份或运营内部状态。
|
||||
type PublicListingDetailDTO struct {
|
||||
PublicListingListItemDTO
|
||||
Description string `json:"description"`
|
||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
@@ -120,6 +93,11 @@ type TransferOwnerRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type AdminUpdateScreenshotsRequest struct {
|
||||
ScreenshotURLs []string `json:"screenshot_urls" binding:"required,min=1"`
|
||||
ScreenshotGroups map[string]any `json:"screenshot_groups"`
|
||||
}
|
||||
|
||||
type AdminListQuery struct {
|
||||
OwnerID uint64
|
||||
Keyword string
|
||||
@@ -187,11 +165,11 @@ type NumberRange struct {
|
||||
}
|
||||
|
||||
type PublicListResult struct {
|
||||
Items []PublicListingListItemDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
ZoneCounts map[string]int64 `json:"zone_counts"`
|
||||
Items []ListingDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
ZoneCounts map[string]int64 `json:"zone_counts"`
|
||||
}
|
||||
|
||||
type AdminActionRequest struct {
|
||||
|
||||
@@ -182,3 +182,27 @@ func (h *Handler) Reject(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateScreenshots(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req AdminUpdateScreenshotsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "截图参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateScreenshots(c.Request.Context(), adminID, id, req, auditMeta(c))
|
||||
if err != nil {
|
||||
writeListingError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,17 @@ func (h *Handler) Screenshot(c *gin.Context) {
|
||||
writeListingError(c, err)
|
||||
return
|
||||
}
|
||||
h.writePublicObject(c, filemodule.ImageVariantFallbackKeys(key, filemodule.ImageVariantMedium)...)
|
||||
variant := c.Query("variant")
|
||||
var fallbackKeys []string
|
||||
switch variant {
|
||||
case "thumb":
|
||||
fallbackKeys = filemodule.ImageVariantFallbackKeys(key, filemodule.ImageVariantThumb)
|
||||
case "raw":
|
||||
fallbackKeys = []string{key}
|
||||
default:
|
||||
fallbackKeys = filemodule.ImageVariantFallbackKeys(key, filemodule.ImageVariantMedium)
|
||||
}
|
||||
h.writePublicObject(c, fallbackKeys...)
|
||||
}
|
||||
|
||||
func (h *Handler) writePublicObject(c *gin.Context, keys ...string) {
|
||||
|
||||
@@ -65,104 +65,69 @@ func sellerListings(items []ListingDTO) []ListingDTO {
|
||||
}
|
||||
|
||||
func applyPublicListingURLs(item *ListingDTO) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
item.SourceChannel = ""
|
||||
item.IsExternalUpload = false
|
||||
rawScreenshotURLs := item.ScreenshotURLS
|
||||
item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus)
|
||||
if item.AssetSummary != nil {
|
||||
delete(item.AssetSummary, "import_meta")
|
||||
delete(item.AssetSummary, "price_breakdown")
|
||||
// 截图分组可能保留原始对象 URL,公开详情统一使用受控图片接口。
|
||||
delete(item.AssetSummary, "screenshot_groups")
|
||||
applyPublicScreenshotGroups(item.ID, item.AssetSummary, rawScreenshotURLs, item.ScreenshotURLS)
|
||||
}
|
||||
}
|
||||
|
||||
func publicListingListItems(items []ListingDTO) []PublicListingListItemDTO {
|
||||
result := make([]PublicListingListItemDTO, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, item.toPublicListItem())
|
||||
func applyPublicScreenshotGroups(listingID uint64, assetSummary map[string]any, rawURLs []string, publicURLs []string) {
|
||||
rawGroups, ok := assetSummary["screenshot_groups"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (item ListingDTO) toPublicListItem() PublicListingListItemDTO {
|
||||
return PublicListingListItemDTO{
|
||||
ID: item.ID,
|
||||
ListingNo: item.ListingNo,
|
||||
Title: item.Title,
|
||||
GameName: item.GameName,
|
||||
ServerRegion: item.ServerRegion,
|
||||
LoginPlatform: item.LoginPlatform,
|
||||
RankLevel: item.RankLevel,
|
||||
HafCoinAmount: item.HafCoinAmount,
|
||||
AssetSummary: publicListAssetSummary(item.AssetSummary),
|
||||
CoverURL: item.CoverURL,
|
||||
PriceCent: item.PriceCent,
|
||||
DepositAmountCent: item.DepositAmountCent,
|
||||
IsAccelerated: item.IsAccelerated,
|
||||
PublishedAt: item.PublishedAt,
|
||||
CreatedAt: item.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (item ListingDTO) toPublicDetail() PublicListingDetailDTO {
|
||||
listItem := item.toPublicListItem()
|
||||
listItem.AssetSummary = publicDetailAssetSummary(item.AssetSummary)
|
||||
return PublicListingDetailDTO{
|
||||
PublicListingListItemDTO: listItem,
|
||||
Description: item.Description,
|
||||
ScreenshotURLS: item.ScreenshotURLS,
|
||||
}
|
||||
}
|
||||
|
||||
// publicListAssetSummary 仅保留首页卡片和筛选展示所需资产字段。
|
||||
func publicListAssetSummary(summary map[string]any) map[string]any {
|
||||
return selectPublicAssetSummary(summary, []string{
|
||||
"season_insurance",
|
||||
"stamina_level",
|
||||
"load_level",
|
||||
"resources",
|
||||
"skin_groups",
|
||||
"total_asset_wan",
|
||||
"online_time",
|
||||
"can_change_game_name",
|
||||
"all_hero",
|
||||
"daily_loss_m",
|
||||
"publish_ratio",
|
||||
})
|
||||
}
|
||||
|
||||
// publicDetailAssetSummary 仅保留公开详情明确展示的资产字段。
|
||||
func publicDetailAssetSummary(summary map[string]any) map[string]any {
|
||||
return selectPublicAssetSummary(summary, []string{
|
||||
"season_insurance",
|
||||
"stamina_level",
|
||||
"load_level",
|
||||
"resources",
|
||||
"skin_groups",
|
||||
"total_asset_wan",
|
||||
"online_time",
|
||||
"can_change_game_name",
|
||||
"all_hero",
|
||||
"daily_loss_m",
|
||||
"publish_ratio",
|
||||
"secret_kd",
|
||||
"fire_level",
|
||||
"common_regions",
|
||||
"ban_record",
|
||||
})
|
||||
}
|
||||
|
||||
func selectPublicAssetSummary(summary map[string]any, keys []string) map[string]any {
|
||||
if len(summary) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]any, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := summary[key]; ok {
|
||||
result[key] = value
|
||||
urlMap := make(map[string]string, len(rawURLs))
|
||||
keyMap := make(map[string]string, len(rawURLs))
|
||||
for i, rawURL := range rawURLs {
|
||||
if i < len(publicURLs) {
|
||||
urlMap[rawURL] = publicURLs[i]
|
||||
if key := extractListingObjectKey(rawURL); key != "" {
|
||||
keyMap[key] = publicURLs[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
newGroups := make(map[string]any, len(rawGroups))
|
||||
for groupKey, groupVal := range rawGroups {
|
||||
switch list := groupVal.(type) {
|
||||
case []string:
|
||||
converted := make([]string, len(list))
|
||||
for i, u := range list {
|
||||
if pub, found := urlMap[u]; found {
|
||||
converted[i] = pub
|
||||
} else if key := extractListingObjectKey(u); key != "" && keyMap[key] != "" {
|
||||
converted[i] = keyMap[key]
|
||||
} else {
|
||||
converted[i] = u
|
||||
}
|
||||
}
|
||||
newGroups[groupKey] = converted
|
||||
case []any:
|
||||
converted := make([]string, len(list))
|
||||
for i, v := range list {
|
||||
if s, ok := v.(string); ok {
|
||||
if pub, found := urlMap[s]; found {
|
||||
converted[i] = pub
|
||||
} else if key := extractListingObjectKey(s); key != "" && keyMap[key] != "" {
|
||||
converted[i] = keyMap[key]
|
||||
} else {
|
||||
converted[i] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
newGroups[groupKey] = converted
|
||||
default:
|
||||
newGroups[groupKey] = groupVal
|
||||
}
|
||||
}
|
||||
assetSummary["screenshot_groups"] = newGroups
|
||||
}
|
||||
|
||||
func applySellerListingPrice(item *ListingDTO) {
|
||||
|
||||
@@ -54,7 +54,7 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu
|
||||
items = items[start:end]
|
||||
}
|
||||
return &PublicListResult{
|
||||
Items: publicListingListItems(items),
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
@@ -85,7 +85,7 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery,
|
||||
return nil, err
|
||||
}
|
||||
return &PublicListResult{
|
||||
Items: publicListingListItems(publicListings(rowsToDTO(rows))),
|
||||
Items: publicListings(rowsToDTO(rows)),
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
@@ -228,14 +228,13 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 {
|
||||
return copied
|
||||
}
|
||||
|
||||
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
|
||||
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
|
||||
dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyPublicListingURLs(dto)
|
||||
item := dto.toPublicDetail()
|
||||
return &item, nil
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
|
||||
|
||||
@@ -386,3 +386,52 @@ func (r *Repository) Reject(ctx context.Context, listingID uint64, req ReviewReq
|
||||
})
|
||||
return dto, err
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateScreenshots(ctx context.Context, adminID uint64, listingID uint64, req AdminUpdateScreenshotsRequest, meta AuditMeta) (*ListingDTO, error) {
|
||||
var dto *ListingDTO
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
listing, account, err := r.findForReviewUpdate(tx, listingID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if listing.Status == "rented" || listing.InTransaction {
|
||||
return ErrListingLocked
|
||||
}
|
||||
|
||||
screenshots, err := marshalScreenshots(req.ScreenshotURLs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account.ScreenshotURLS = screenshots
|
||||
|
||||
if req.ScreenshotGroups != nil {
|
||||
summary := decodeAssetSummary(account.AssetSummary)
|
||||
if summary == nil {
|
||||
summary = map[string]any{}
|
||||
}
|
||||
summary["screenshot_groups"] = req.ScreenshotGroups
|
||||
assetSummary, err := marshalAssetSummary(summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account.AssetSummary = assetSummary
|
||||
}
|
||||
|
||||
if err := tx.Save(account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := appendAuditLog(tx, adminID, "listing.update_screenshots", "listing", listing.ID, meta, map[string]any{
|
||||
"listing_id": listing.ID,
|
||||
"account_id": account.ID,
|
||||
"screenshot_urls": req.ScreenshotURLs,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dto = toDTO(*account, *listing)
|
||||
return nil
|
||||
})
|
||||
return dto, err
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, e
|
||||
return s.repo.ListMine(ctx, ownerID)
|
||||
}
|
||||
|
||||
func (s *Service) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
|
||||
func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
|
||||
@@ -81,3 +81,14 @@ func (s *Service) Reject(ctx context.Context, id uint64, req ReviewRequest) (*Li
|
||||
}
|
||||
return s.repo.Reject(ctx, id, req)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateScreenshots(ctx context.Context, adminID uint64, id uint64, req AdminUpdateScreenshotsRequest, meta AuditMeta) (*ListingDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if len(req.ScreenshotURLs) == 0 {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
return s.repo.UpdateScreenshots(ctx, adminID, id, req, meta)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -347,75 +346,9 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
|
||||
if _, ok := item.AssetSummary["price_breakdown"]; ok {
|
||||
t.Fatal("expected price_breakdown hidden from public listing")
|
||||
}
|
||||
if _, ok := item.AssetSummary["screenshot_groups"]; ok {
|
||||
t.Fatal("expected screenshot_groups hidden from public listing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicListingDTOsDoNotSerializeSensitiveFields(t *testing.T) {
|
||||
item := ListingDTO{
|
||||
ID: 1,
|
||||
ListingNo: "SP000001",
|
||||
AccountID: 2,
|
||||
OwnerID: 3,
|
||||
OwnerPhone: "13800001234",
|
||||
OwnerNickname: "号主",
|
||||
SourceChannel: "外部渠道",
|
||||
IsExternalUpload: true,
|
||||
Title: "测试账号",
|
||||
Description: "公开描述",
|
||||
ScreenshotURLS: []string{"/api/listings/1/screenshots/0"},
|
||||
Status: "published",
|
||||
ReviewStatus: "approved",
|
||||
HandoffMode: "platform",
|
||||
SettlementMode: "platform_managed",
|
||||
ManagedAdminID: uint64Pointer(4),
|
||||
ReviewReason: "内部审核备注",
|
||||
ListingGroupConversationID: 5,
|
||||
AssetSummary: map[string]any{
|
||||
"season_insurance": "3*3",
|
||||
"contact_phone": "13900005678",
|
||||
"remark": "首页不应携带",
|
||||
},
|
||||
}
|
||||
|
||||
listRaw, err := json.Marshal(PublicListResult{Items: publicListingListItems([]ListingDTO{item})})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal public list error = %v", err)
|
||||
}
|
||||
detailRaw, err := json.Marshal(item.toPublicDetail())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal public detail error = %v", err)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"account_id",
|
||||
"owner_id",
|
||||
"owner_phone",
|
||||
"owner_nickname",
|
||||
"source_channel",
|
||||
"is_external_upload",
|
||||
"status",
|
||||
"review_status",
|
||||
"handoff_mode",
|
||||
"settlement_mode",
|
||||
"managed_admin_id",
|
||||
"review_reason",
|
||||
"listing_group_conversation_id",
|
||||
} {
|
||||
if strings.Contains(string(listRaw), field) || strings.Contains(string(detailRaw), field) {
|
||||
t.Fatalf("public response contains sensitive field %q", field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(listRaw), "description") || strings.Contains(string(listRaw), "screenshot_urls") {
|
||||
t.Fatalf("public list contains detail-only fields: %s", listRaw)
|
||||
}
|
||||
if strings.Contains(string(listRaw), "contact_phone") || strings.Contains(string(listRaw), "首页不应携带") {
|
||||
t.Fatalf("public list contains non-display asset fields: %s", listRaw)
|
||||
}
|
||||
if strings.Contains(string(detailRaw), "contact_phone") {
|
||||
t.Fatalf("public detail contains non-public asset field: %s", detailRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func uint64Pointer(value uint64) *uint64 {
|
||||
return &value
|
||||
@@ -746,7 +679,6 @@ func containsString(values []string, target string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestListPublicWithDistributionSiteCode(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
|
||||
@@ -792,3 +724,99 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) {
|
||||
t.Fatalf("expected 2 listings for main site, got %d", len(allRes.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPublicScreenshotGroups(t *testing.T) {
|
||||
rawURLs := []string{
|
||||
"/api/files/object?key=listing/coin.png",
|
||||
"/api/files/object?key=listing/skin1.png",
|
||||
}
|
||||
publicURLs := []string{
|
||||
"/api/listings/88/screenshots/0",
|
||||
"/api/listings/88/screenshots/1",
|
||||
}
|
||||
summary := map[string]any{
|
||||
"screenshot_groups": map[string]any{
|
||||
"coin": []any{"/api/files/object?key=listing/coin.png"},
|
||||
"skin": []string{"/api/files/object?key=listing/skin1.png"},
|
||||
},
|
||||
}
|
||||
applyPublicScreenshotGroups(88, summary, rawURLs, publicURLs)
|
||||
groups := summary["screenshot_groups"].(map[string]any)
|
||||
coinList := groups["coin"].([]string)
|
||||
if len(coinList) != 1 || coinList[0] != "/api/listings/88/screenshots/0" {
|
||||
t.Fatalf("expected /api/listings/88/screenshots/0, got %#v", coinList)
|
||||
}
|
||||
skinList := groups["skin"].([]string)
|
||||
if len(skinList) != 1 || skinList[0] != "/api/listings/88/screenshots/1" {
|
||||
t.Fatalf("expected /api/listings/88/screenshots/1, got %#v", skinList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateScreenshots(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
|
||||
t.Fatalf("failed to migrate test db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.AuditLog{}); err != nil {
|
||||
t.Fatalf("failed to migrate audit log: %v", err)
|
||||
}
|
||||
repo := NewRepository(db, nil)
|
||||
account := model.GameAccount{
|
||||
OwnerID: 1,
|
||||
GameName: "delta_force",
|
||||
ServerRegion: "QQ",
|
||||
LoginPlatform: "QQ账号密码",
|
||||
Title: "待审核商品",
|
||||
Status: "draft",
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatalf("failed to create account: %v", err)
|
||||
}
|
||||
listing := model.RentalListing{
|
||||
ListingNo: "202609070001",
|
||||
AccountID: account.ID,
|
||||
OwnerID: 1,
|
||||
Status: "draft",
|
||||
ReviewStatus: "pending",
|
||||
}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatalf("failed to create listing: %v", err)
|
||||
}
|
||||
|
||||
meta := AuditMeta{IP: "127.0.0.1", UserAgent: "TestAgent", RequestID: "req-1"}
|
||||
req := AdminUpdateScreenshotsRequest{
|
||||
ScreenshotURLs: []string{
|
||||
"/api/files/object?key=edited1.png",
|
||||
"/api/files/object?key=edited2.png",
|
||||
},
|
||||
ScreenshotGroups: map[string]any{
|
||||
"coin": []string{"/api/files/object?key=edited1.png"},
|
||||
"gameId": []string{"/api/files/object?key=edited2.png"},
|
||||
},
|
||||
}
|
||||
|
||||
updated, err := repo.UpdateScreenshots(t.Context(), 999, listing.ID, req, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScreenshots failed: %v", err)
|
||||
}
|
||||
|
||||
if len(updated.ScreenshotURLS) != 2 || updated.ScreenshotURLS[0] != "/api/files/object?key=edited1.png" {
|
||||
t.Fatalf("unexpected screenshot_urls: %#v", updated.ScreenshotURLS)
|
||||
}
|
||||
|
||||
groups, ok := updated.AssetSummary["screenshot_groups"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("screenshot_groups not found in AssetSummary: %#v", updated.AssetSummary)
|
||||
}
|
||||
coinList, ok := groups["coin"].([]string)
|
||||
if !ok {
|
||||
if anyList, isAny := groups["coin"].([]any); isAny {
|
||||
for _, v := range anyList {
|
||||
coinList = append(coinList, v.(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(coinList) != 1 || coinList[0] != "/api/files/object?key=edited1.png" {
|
||||
t.Fatalf("unexpected coin screenshots: %#v", coinList)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user