merge(main): 同步主站最新 UI 与审核优化代码并保持分销多租户隔离

This commit is contained in:
yml2213
2026-09-07 17:37:31 +08:00
51 changed files with 11152 additions and 4950 deletions
@@ -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)
}
})
}
}
+10 -32
View File
@@ -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) {
+51 -86
View File
@@ -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)
}
}