From 5840b7da40d3b60406efe9bf21fd4dde1392a0c5 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 11 Oct 2026 17:44:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(listing):=20=E5=A4=96=E9=83=A8=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=8C=89=E5=90=8E=E5=8F=B0=E5=AE=9E=E6=97=B6=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=BD=92=E7=B1=BB=E7=9A=AE=E8=82=A4=E4=B8=8E=E6=8A=98?= =?UTF-8?q?=E7=AE=97=E6=B6=88=E8=80=97=E5=93=81=EF=BC=8C=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E6=96=B0=E5=A2=9E=E6=B6=88=E8=80=97=E5=93=81=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 皮肤归类:删除代码内硬编码的 externalSkinCatalog 快照,改为读取 listing.publish_options。 修复运营在后台新增皮肤后,上传的商品被归入 other 的问题(详情页显示“其他皮肤”、 按刀皮/金皮/红皮/武器皮分组筛选搜不到) - 消耗品折算:externalResources 改为按 publish_options.quantity_items 生成条目与单价, 修复 AWM 子弹按 0.6 元/发旧价折算等价格漂移(线上已调为 0.7 元/发) - 上传契约:ExternalUploadInventory 新增 level6Ammo/barrettAmmo/gridCard9/redPacket45/ coffeeBean/bulletPart 六个字段,字段名与消耗品 key 对齐;后续后台再新增消耗品, 按同样 key 登记即可被上传链路识别 - 数量为 0 的消耗品不再写入资源清单,与网站自身发布流程(filter quantity > 0)保持一致 - 客服调整外部商品同样改用实时单价;Service 增加 externalPublishOptions 统一读配置并带默认值 - 验证:新增回归测试 TestExternalAccountToCreateRequestFollowsPublishOptions; 并以线上 mobile-home-config 的真实配置 + 上传工具产出的 JSON 做跨仓库联调, 确认“重塑→近战皮肤、骇爪-骇客少女→干员金皮”,9 项消耗品全部按线上单价生成 --- backend/internal/modules/listing/dto.go | 9 ++ .../modules/listing/external_adjustment.go | 11 ++- .../listing/external_adjustment_test.go | 5 +- .../listing/external_publish_options_test.go | 83 ++++++++++++++++ .../modules/listing/external_upload.go | 94 +++++++++++-------- .../modules/listing/service_config.go | 29 ++++++ .../internal/modules/listing/service_test.go | 7 +- 7 files changed, 192 insertions(+), 46 deletions(-) create mode 100644 backend/internal/modules/listing/external_publish_options_test.go diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 40f3a30..057645f 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -238,6 +238,15 @@ type ExternalUploadInventory struct { Level6Helmets int `json:"level6Helmets"` Level6Armor int `json:"level6Armor"` Skins []string `json:"skins"` + + // 以下字段名与 publish_options.quantity_items 的 key 对齐: + // 后台新增消耗品时,只要按同样的 key 在此登记即可被上传链路识别并计入价格。 + Level6Ammo int `json:"level6Ammo"` + BarrettAmmo int `json:"barrettAmmo"` + GridCard9 int `json:"gridCard9"` + RedPacket45 int `json:"redPacket45"` + CoffeeBean int `json:"coffeeBean"` + BulletPart int `json:"bulletPart"` } type ExternalUploadMeta struct { diff --git a/backend/internal/modules/listing/external_adjustment.go b/backend/internal/modules/listing/external_adjustment.go index 37b2e70..c3b2684 100644 --- a/backend/internal/modules/listing/external_adjustment.go +++ b/backend/internal/modules/listing/external_adjustment.go @@ -7,6 +7,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/systemconfig" "gorm.io/gorm" ) @@ -56,7 +57,9 @@ func (s *Service) AdjustExternalListing( if err != nil { return nil, err } - return s.repo.AdjustExternalListing(ctx, adminID, listingID, req, config, meta) + // 消耗品单价同样取自后台实时配置,避免调整后的价格与网站现行单价不一致。 + quantityItems := s.externalPublishOptions(ctx).QuantityItems + return s.repo.AdjustExternalListing(ctx, adminID, listingID, req, config, quantityItems, meta) } func (r *Repository) AdjustExternalListing( @@ -65,6 +68,7 @@ func (r *Repository) AdjustExternalListing( listingID uint64, req AdminExternalListingAdjustRequest, config salePriceConfig, + quantityItems []systemconfig.PublishQuantityItem, meta AuditMeta, ) (*ListingDTO, error) { if r == nil || r.db == nil { @@ -105,7 +109,7 @@ func (r *Repository) AdjustExternalListing( } before := externalAdjustmentAuditSnapshot(account, listing, summary, breakdown) - adjustment, err := calculateExternalListingAdjustment(req, summary, referenceRatio, config) + adjustment, err := calculateExternalListingAdjustment(req, summary, referenceRatio, config, quantityItems) if err != nil { return err } @@ -179,6 +183,7 @@ func calculateExternalListingAdjustment( summary map[string]any, referenceRatio float64, config salePriceConfig, + quantityItems []systemconfig.PublishQuantityItem, ) (externalAdjustmentResult, error) { coinM := float64(req.HafCoinAmount) / 1000000 coinWan := float64(req.HafCoinAmount) / 10000 @@ -207,7 +212,7 @@ func calculateExternalListingAdjustment( AWMBullets: req.AWMBullets, Level6Helmets: req.Level6Helmets, Level6Armor: req.Level6Armor, - }) + }, quantityItems) resources := mergeExternalCoreResources(summary, coreResources) consumablePrice := consumableValue(map[string]any{"resources": resources}) sellerTotalPrice := roundMoney(sellerCoinPrice + consumablePrice) diff --git a/backend/internal/modules/listing/external_adjustment_test.go b/backend/internal/modules/listing/external_adjustment_test.go index 8b61f8d..397dfe7 100644 --- a/backend/internal/modules/listing/external_adjustment_test.go +++ b/backend/internal/modules/listing/external_adjustment_test.go @@ -9,6 +9,7 @@ import ( "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/systemconfig" "gorm.io/gorm" ) @@ -26,6 +27,7 @@ func TestCalculateExternalListingAdjustmentByDiscountAmount(t *testing.T) { map[string]any{}, 40, defaultSalePriceConfig(), + systemconfig.DefaultPublishOptions().QuantityItems, ) if err != nil { t.Fatalf("按降价金额计算失败: %v", err) @@ -52,6 +54,7 @@ func TestCalculateExternalListingAdjustmentBySaleRatio(t *testing.T) { map[string]any{}, 40, defaultSalePriceConfig(), + systemconfig.DefaultPublishOptions().QuantityItems, ) if err != nil { t.Fatalf("按发布比例计算失败: %v", err) @@ -232,7 +235,7 @@ func newExternalAdjustmentFixture( Level6Helmets: 1, Level6Armor: 1, }, - }, defaultSalePriceConfig()) + }, defaultSalePriceConfig(), systemconfig.DefaultPublishOptions()) createReq.AssetSummary["resources"] = append( createReq.AssetSummary["resources"].([]any), map[string]any{"key": "giftMed", "label": "赠送医疗物资", "price": "1元/个", "quantity": 4, "mode": "赠送"}, diff --git a/backend/internal/modules/listing/external_publish_options_test.go b/backend/internal/modules/listing/external_publish_options_test.go new file mode 100644 index 0000000..634ca3c --- /dev/null +++ b/backend/internal/modules/listing/external_publish_options_test.go @@ -0,0 +1,83 @@ +package listing + +import ( + "testing" + + "hfb_sys/backend/internal/modules/systemconfig" +) + +// 外部上传必须按后台实时配置归类皮肤、按配置单价折算消耗品。 +// 历史缺陷:皮肤分组与消耗品单价是代码里的快照,运营在后台新增皮肤或改价后, +// 上传的商品会被归到 other(详情页显示“其他皮肤”、按分组筛选搜不到)且按旧价折算。 +func TestExternalAccountToCreateRequestFollowsPublishOptions(t *testing.T) { + options := systemconfig.PublishOptionsDTO{ + SkinGroups: []systemconfig.PublishOptionGroup{ + {Key: "melee", Title: "近战皮肤", Options: []string{"重塑", "挽歌"}}, + {Key: "operatorGold", Title: "干员金皮", Options: []string{"骇爪-骇客少女"}}, + }, + QuantityItems: []systemconfig.PublishQuantityItem{ + {Key: "awmAmmo", Label: "AWM子弹", Price: "0.7元/发"}, + {Key: "barrettAmmo", Label: "巴雷特子弹", Price: "0.3元/发"}, + {Key: "armor6", Label: "6甲", Price: "2.5元/个"}, + }, + } + + req := externalAccountToCreateRequest("客服1", 0, ExternalAccountData{ + LoginMethod: "QQ扫码", + Rank: "黑鹰", + SafeSlots: 9, + Deposit: 400, + Currency: ExternalUploadCurrency{HafuCoin: 100, RecycleRatio: 40, RecycleRent: 250}, + Inventory: ExternalUploadInventory{ + AWMBullets: 10, + BarrettAmmo: 3, + Skins: []string{"重塑", "骇爪-骇客少女", "未知皮肤X"}, + }, + }, defaultSalePriceConfig(), options) + + groups, ok := req.AssetSummary["skin_groups"].(map[string][]string) + if !ok { + t.Fatalf("skin_groups = %#v", req.AssetSummary["skin_groups"]) + } + if got := groups["melee"]; len(got) != 1 || got[0] != "重塑" { + t.Fatalf("后台新增的近战皮肤未按配置归类: melee = %#v", got) + } + if got := groups["operatorGold"]; len(got) != 1 || got[0] != "骇爪-骇客少女" { + t.Fatalf("后台新增的干员金皮未按配置归类: operatorGold = %#v", got) + } + if got := groups["other"]; len(got) != 1 || got[0] != "未知皮肤X" { + t.Fatalf("未配置的皮肤应归入 other: other = %#v", got) + } + + resources, ok := req.AssetSummary["resources"].([]any) + if !ok { + t.Fatalf("resources = %#v", req.AssetSummary["resources"]) + } + byKey := map[string]map[string]any{} + for _, raw := range resources { + row, ok := raw.(map[string]any) + if !ok { + t.Fatalf("资源条目格式异常: %#v", raw) + } + key, _ := row["key"].(string) + byKey[key] = row + } + // 未填写的 6甲 不应出现在资源清单里(与网站自身发布流程一致)。 + if _, exists := byKey["armor6"]; exists { + t.Fatalf("数量为 0 的消耗品不应写入资源清单: %#v", byKey["armor6"]) + } + if got, _ := byKey["barrettAmmo"]["quantity"].(int); got != 3 { + t.Fatalf("新增消耗品未随上传写入: barrettAmmo = %#v", byKey["barrettAmmo"]) + } + if got, _ := byKey["barrettAmmo"]["label"].(string); got != "巴雷特子弹" { + t.Fatalf("资源名称应取自配置: %#v", byKey["barrettAmmo"]) + } + if got, _ := byKey["awmAmmo"]["price"].(string); got != "0.7元/发" { + t.Fatalf("资源单价应取自配置: %#v", byKey["awmAmmo"]) + } + + // AWM 10 发 × 0.7 + 巴雷特 3 发 × 0.3 = 7.9,旧快照价 0.6 会算成 6.9。 + if got := consumableValue(map[string]any{"resources": resources}); got != 7.9 { + t.Fatalf("消耗品折算价 = %v, want 7.9", got) + } +} diff --git a/backend/internal/modules/listing/external_upload.go b/backend/internal/modules/listing/external_upload.go index 83832ca..16ab47d 100644 --- a/backend/internal/modules/listing/external_upload.go +++ b/backend/internal/modules/listing/external_upload.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "time" + + "hfb_sys/backend/internal/modules/systemconfig" ) var externalOnlineTimePattern = regexp.MustCompile(`^\D*(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D+(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D*$`) @@ -37,11 +39,13 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe if err != nil { return nil, err } + // 皮肤分组与消耗品单价取自后台实时配置,避免代码里的快照与线上选项脱节。 + publishOptions := s.externalPublishOptions(ctx) clientUploadTime := parseClientUploadTime(req.UploadTime) results := make([]ExternalUploadResult, 0, len(items)) resp := &ExternalUploadResponse{Total: len(items)} for index, item := range items { - createReq := externalAccountToCreateRequest(uploaderName, req.UploadTime, item, salePriceConfig) + createReq := externalAccountToCreateRequest(uploaderName, req.UploadTime, item, salePriceConfig, publishOptions) if err := validateRequest(createReq, rules); err != nil { if len(items) == 1 { return nil, err @@ -244,6 +248,7 @@ func externalAccountToCreateRequest( uploadTime int64, item ExternalAccountData, salePriceConfig salePriceConfig, + publishOptions systemconfig.PublishOptionsDTO, ) CreateRequest { hafCoinM := item.Currency.HafuCoin pureCoinPrice := item.Currency.RecycleRent @@ -254,7 +259,7 @@ func externalAccountToCreateRequest( skins := cleanStrings(item.Inventory.Skins) remark := strings.TrimSpace(item.Remark) onlineTimeText := normalizeExternalOnlineTimeText(item.OwnerOnlineTime) - resources := externalResources(item.Inventory) + resources := externalResources(item.Inventory, publishOptions.QuantityItems) consumablePrice := consumableValue(map[string]any{"resources": resources}) pricing := calculateExternalPlatformPricing( hafCoinM, @@ -273,7 +278,7 @@ func externalAccountToCreateRequest( "stamina_level": staminaLevel, "load_level": loadLevel, "resources": resources, - "skin_groups": externalSkinGroups(skins), + "skin_groups": externalSkinGroups(skins, publishOptions.SkinGroups), "online_time_text": onlineTimeText, // 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。 "early_morning_response": "否", @@ -420,60 +425,71 @@ func levelText(value int) string { return strconv.Itoa(value) + "级" } -func externalResources(inventory ExternalUploadInventory) []any { - resources := []any{ - map[string]any{"key": "awmAmmo", "label": "AWM子弹", "price": "0.6元/发", "quantity": inventory.AWMBullets, "mode": "收费"}, - map[string]any{"key": "helmet6", "label": "6头", "price": "1.5元/个", "quantity": inventory.Level6Helmets, "mode": "收费"}, - map[string]any{"key": "armor6", "label": "6甲", "price": "2.5元/个", "quantity": inventory.Level6Armor, "mode": "收费"}, +// inventoryQuantities 把上传契约的库存字段映射回 publish_options.quantity_items 的 key。 +// 历史字段名(awmBullets / level6Helmets / level6Armor)保持不变,以兼容既有上传客户端。 +func inventoryQuantities(inventory ExternalUploadInventory) map[string]int { + return map[string]int{ + "awmAmmo": inventory.AWMBullets, + "helmet6": inventory.Level6Helmets, + "armor6": inventory.Level6Armor, + "level6Ammo": inventory.Level6Ammo, + "barrettAmmo": inventory.BarrettAmmo, + "gridCard9": inventory.GridCard9, + "redPacket45": inventory.RedPacket45, + "coffeeBean": inventory.CoffeeBean, + "bulletPart": inventory.BulletPart, + } +} + +// externalResources 按后台实时配置的消耗品清单生成资源数组:条目顺序、名称与单价取自配置, +// 数量取自上传数据;未填(<=0)的条目与网站自身发布流程保持一致地省略。 +func externalResources(inventory ExternalUploadInventory, items []systemconfig.PublishQuantityItem) []any { + quantities := inventoryQuantities(inventory) + resources := make([]any, 0, len(items)) + for _, item := range items { + quantity := quantities[item.Key] + if quantity <= 0 { + continue + } + resources = append(resources, map[string]any{ + "key": item.Key, + "label": item.Label, + "price": item.Price, + "quantity": quantity, + "mode": "收费", + }) } return resources } -// 与 systemconfig 默认 publish_options 皮肤分组保持一致,避免详情页出现英文分组名。 -var externalSkinCatalog = []struct { - key string - names []string -}{ - {"melee", []string{"坠星者", "暗星", "龙牙", "信条", "怜悯", "赤枭", "影锋", "黑海", "北极星", "电锯惊魂", "处刑者"}}, - {"operatorGold", []string{ - "露娜-古墓丽影联动", "蛊-不羁人生", "红狼-电锯惊魂", "露娜-金牌射手", - "牧羊人-街头之星", "蜂医-危险物质", "蜂医-送葬人", "无名-夜鹰", - "威龙-壮志凌云", "威龙-蛟龙特战队", "威龙-铁面判官", "威龙-吴彦祖", - }}, - {"operatorRed", []string{"凌霄成卫", "蚀金玫瑰", "水墨云图", "午夜邮差", "天际线", "维什戴尔"}}, - {"weapon", []string{ - "M7棱镜攻势S2", "腾龙气象感应", "MP7电玩高手S2", "M250电玩高手S2", - "AS Val悬赏令", "K416命运", "M4A1棱镜攻势", "SCAR-H电玩高手", - "QBZ95王牌之剑", "AUG气象感应", "Vector美杜莎", "KC17-造物纪元", - }}, -} - -func externalSkinGroups(skins []string) map[string][]string { - groups := map[string][]string{} +// externalSkinGroups 依据后台实时配置的皮肤分组归类上传的皮肤名, +// 避免代码里的字典快照与运营在后台维护的选项脱节(新增皮肤会被误判为“其他皮肤”)。 +func externalSkinGroups(skins []string, groups []systemconfig.PublishOptionGroup) map[string][]string { + result := map[string][]string{} for _, skin := range skins { skin = strings.TrimSpace(skin) if skin == "" { continue } - key := classifyExternalSkin(skin) - groups[key] = append(groups[key], skin) + key := classifyExternalSkin(skin, groups) + result[key] = append(result[key], skin) } - return groups + return result } -func classifyExternalSkin(skin string) string { +func classifyExternalSkin(skin string, groups []systemconfig.PublishOptionGroup) string { // 先精确匹配,再做包含匹配,兼容“红狼-电锯惊魂 / 电锯惊魂”这类写法。 - for _, group := range externalSkinCatalog { - for _, name := range group.names { + for _, group := range groups { + for _, name := range group.Options { if skin == name { - return group.key + return group.Key } } } - for _, group := range externalSkinCatalog { - for _, name := range group.names { + for _, group := range groups { + for _, name := range group.Options { if strings.Contains(skin, name) || strings.Contains(name, skin) { - return group.key + return group.Key } } } diff --git a/backend/internal/modules/listing/service_config.go b/backend/internal/modules/listing/service_config.go index 061fe91..c042f8b 100644 --- a/backend/internal/modules/listing/service_config.go +++ b/backend/internal/modules/listing/service_config.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" "time" + + "hfb_sys/backend/internal/modules/systemconfig" ) func (s *Service) reviewRequired(ctx context.Context) (bool, error) { @@ -23,6 +25,33 @@ func (s *Service) reviewRequired(ctx context.Context) (bool, error) { return required, nil } +// externalPublishOptions 读取发布页的实时配置,供外部上传的皮肤归类与消耗品折算使用。 +// +// 外部上传只收平铺的皮肤名与数量,需要按后台配置(而不是代码里的快照)还原分组与单价, +// 否则运营在后台新增皮肤/改价后,上传的商品会落进“其他皮肤”且按旧价折算。 +func (s *Service) externalPublishOptions(ctx context.Context) systemconfig.PublishOptionsDTO { + options := systemconfig.DefaultPublishOptions() + if s == nil || s.config == nil { + return options + } + value, err := s.config.FindValue(ctx, publishOptionsConfigKey) + if err != nil || strings.TrimSpace(value) == "" { + return options + } + var parsed systemconfig.PublishOptionsDTO + if err := json.Unmarshal([]byte(value), &parsed); err != nil { + return options + } + // 配置里只覆盖了一部分时,其余字段沿用默认值,避免整份配置失效。 + if len(parsed.SkinGroups) > 0 { + options.SkinGroups = parsed.SkinGroups + } + if len(parsed.QuantityItems) > 0 { + options.QuantityItems = parsed.QuantityItems + } + return options +} + func (s *Service) publishRules(ctx context.Context) (publishRules, error) { rules := publishRules{FireLevelMin: defaultFireLevelMin} if s.config == nil { diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 61159d4..d703b01 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -9,6 +9,7 @@ import ( "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/systemconfig" ) type stubConfigReader map[string]string @@ -503,7 +504,7 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) { Level6Armor: 17, Skins: []string{"信条", "电锯惊魂"}, }, - }, defaultSalePriceConfig()) + }, defaultSalePriceConfig(), systemconfig.DefaultPublishOptions()) if req.Description != "有语音封禁\n封禁历史:2026/01/23封禁3天" { t.Fatalf("Description = %q", req.Description) @@ -613,7 +614,7 @@ func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing AWMBullets: 10, Level6Armor: 2, }, - }, defaultSalePriceConfig()) + }, defaultSalePriceConfig(), systemconfig.DefaultPublishOptions()) breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any) if !ok { @@ -637,7 +638,7 @@ func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing } func TestExternalSkinGroupsClassifiesKnownSkins(t *testing.T) { - groups := externalSkinGroups([]string{"暗星", "凌霄成卫", "蛊-不羁人生", "未知皮肤X"}) + groups := externalSkinGroups([]string{"暗星", "凌霄成卫", "蛊-不羁人生", "未知皮肤X"}, systemconfig.DefaultPublishOptions().SkinGroups) if got := groups["melee"]; len(got) != 1 || got[0] != "暗星" { t.Fatalf("melee = %#v", got) }