fix(listing): 外部上传按后台实时配置归类皮肤与折算消耗品,并补齐新增消耗品字段

- 皮肤归类:删除代码内硬编码的 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 项消耗品全部按线上单价生成
This commit is contained in:
yml2213
2026-10-11 17:44:27 +08:00
parent d21ddd9e96
commit 5840b7da40
7 changed files with 192 additions and 46 deletions
+9
View File
@@ -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 {
@@ -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)
@@ -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": "赠送"},
@@ -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)
}
}
@@ -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
}
}
}
@@ -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 {
@@ -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)
}