From 4cc35a158775efdc5b20932ec964a73c1277f6c2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 25 Jul 2026 15:30:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=A4=96=E9=83=A8=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=8A=A0=E4=BB=B7=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/listing/external_pricing.go | 241 ++++++++++++++++++ .../modules/listing/external_upload.go | 46 +++- .../internal/modules/listing/service_test.go | 61 ++++- 3 files changed, 332 insertions(+), 16 deletions(-) create mode 100644 backend/internal/modules/listing/external_pricing.go diff --git a/backend/internal/modules/listing/external_pricing.go b/backend/internal/modules/listing/external_pricing.go new file mode 100644 index 0000000..1b8c537 --- /dev/null +++ b/backend/internal/modules/listing/external_pricing.go @@ -0,0 +1,241 @@ +package listing + +import ( + "context" + "encoding/json" + "math" + "sort" + "strings" +) + +const salePriceConfigKey = "listing.sale_price_config" + +type salePriceConfig struct { + FixedMarkupRules []saleFixedMarkupRule `json:"fixed_markup_rules"` + RatioAdjustmentRules []saleRatioAdjustmentRule `json:"ratio_adjustment_rules"` +} + +type saleFixedMarkupRule struct { + MinM float64 `json:"min_m"` + MaxM float64 `json:"max_m"` + MarkupAmount float64 `json:"markup_amount"` +} + +type saleRatioAdjustmentRule struct { + MinM float64 `json:"min_m"` + MaxM float64 `json:"max_m"` + RatioSubtract float64 `json:"ratio_subtract"` +} + +type externalPlatformPricing struct { + BuyerCoinBasePrice float64 + BuyerTotalPrice float64 + BuyerRatio float64 + PlatformMarkupPrice float64 + RuleType string +} + +func (s *Service) externalSalePriceConfig(ctx context.Context) (salePriceConfig, error) { + config := defaultSalePriceConfig() + if s.config == nil { + return config, nil + } + value, err := s.config.FindValue(ctx, salePriceConfigKey) + if err != nil { + return config, err + } + if strings.TrimSpace(value) == "" { + return config, nil + } + if err := json.Unmarshal([]byte(value), &config); err != nil { + return defaultSalePriceConfig(), nil + } + return normalizeSalePriceConfig(config), nil +} + +func defaultSalePriceConfig() salePriceConfig { + return salePriceConfig{ + FixedMarkupRules: []saleFixedMarkupRule{ + {MinM: 10, MaxM: 30, MarkupAmount: 28}, + {MinM: 30, MaxM: 50, MarkupAmount: 31}, + {MinM: 50, MaxM: 70, MarkupAmount: 34}, + {MinM: 70, MaxM: 90, MarkupAmount: 38}, + }, + RatioAdjustmentRules: []saleRatioAdjustmentRule{ + {MinM: 90, MaxM: 150, RatioSubtract: 5}, + {MinM: 150, MaxM: 230, RatioSubtract: 4}, + {MinM: 230, MaxM: 310, RatioSubtract: 3.5}, + {MinM: 310, MaxM: 390, RatioSubtract: 3}, + {MinM: 390, MaxM: 470, RatioSubtract: 0}, + {MinM: 470, MaxM: 550, RatioSubtract: 0}, + {MinM: 550, RatioSubtract: 0}, + }, + } +} + +func normalizeSalePriceConfig(config salePriceConfig) salePriceConfig { + config.FixedMarkupRules = filterFixedMarkupRules(config.FixedMarkupRules) + config.RatioAdjustmentRules = filterRatioAdjustmentRules(config.RatioAdjustmentRules) + if len(config.FixedMarkupRules) == 0 && len(config.RatioAdjustmentRules) == 0 { + return defaultSalePriceConfig() + } + return config +} + +func filterFixedMarkupRules(rules []saleFixedMarkupRule) []saleFixedMarkupRule { + result := make([]saleFixedMarkupRule, 0, len(rules)) + for _, rule := range rules { + if !isFiniteNonNegative(rule.MinM) || + !isFiniteNonNegative(rule.MaxM) || + !isFiniteNonNegative(rule.MarkupAmount) || + (rule.MaxM > 0 && rule.MaxM < rule.MinM) { + continue + } + result = append(result, rule) + } + return result +} + +func filterRatioAdjustmentRules(rules []saleRatioAdjustmentRule) []saleRatioAdjustmentRule { + result := make([]saleRatioAdjustmentRule, 0, len(rules)) + for _, rule := range rules { + if !isFiniteNonNegative(rule.MinM) || + !isFiniteNonNegative(rule.MaxM) || + !isFiniteNonNegative(rule.RatioSubtract) || + (rule.MaxM > 0 && rule.MaxM < rule.MinM) { + continue + } + result = append(result, rule) + } + return result +} + +func isFiniteNonNegative(value float64) bool { + return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0) +} + +// calculateExternalPlatformPricing 将外部回收租金视为纯币价格;额外物品与平台加价均单独计算。 +func calculateExternalPlatformPricing( + coinM float64, + sellerRatio float64, + sellerCoinBasePrice float64, + consumablePrice float64, + config salePriceConfig, +) externalPlatformPricing { + sellerCoinBasePrice = nonNegativeMoney(sellerCoinBasePrice) + consumablePrice = nonNegativeMoney(consumablePrice) + sellerTotalPrice := roundMoney(sellerCoinBasePrice + consumablePrice) + coinWan := coinM * 100 + sellerRatio = roundRatio(sellerRatio) + if sellerRatio <= 0 && coinWan > 0 && sellerCoinBasePrice > 0 { + sellerRatio = roundRatio(coinWan / sellerCoinBasePrice) + } + + if rule, ok := findRatioAdjustmentRule(config.RatioAdjustmentRules, coinM); ok && sellerRatio > 0 { + buyerRatio := sellerRatio - rule.RatioSubtract + if buyerRatio > 0 && coinWan > 0 { + buyerCoinBasePrice := roundMoney(coinWan / buyerRatio) + if buyerCoinBasePrice < sellerCoinBasePrice { + buyerCoinBasePrice = sellerCoinBasePrice + } + return buildExternalPlatformPricing( + coinWan, + sellerTotalPrice, + consumablePrice, + buyerCoinBasePrice, + "ratio_subtract", + ) + } + } + + if rule, ok := findFixedMarkupRule(config.FixedMarkupRules, coinM); ok { + return buildExternalPlatformPricing( + coinWan, + sellerTotalPrice, + consumablePrice, + roundMoney(sellerCoinBasePrice+rule.MarkupAmount), + "fixed_markup", + ) + } + + return buildExternalPlatformPricing( + coinWan, + sellerTotalPrice, + consumablePrice, + sellerCoinBasePrice, + "none", + ) +} + +func nonNegativeMoney(value float64) float64 { + if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + return roundMoney(value) +} + +func buildExternalPlatformPricing( + coinWan float64, + sellerTotalPrice float64, + consumablePrice float64, + buyerCoinBasePrice float64, + ruleType string, +) externalPlatformPricing { + buyerCoinBasePrice = nonNegativeMoney(buyerCoinBasePrice) + buyerTotalPrice := roundMoney(buyerCoinBasePrice + consumablePrice) + return externalPlatformPricing{ + BuyerCoinBasePrice: buyerCoinBasePrice, + BuyerTotalPrice: buyerTotalPrice, + BuyerRatio: roundRatio(coinWan / buyerCoinBasePrice), + PlatformMarkupPrice: roundMoney(buyerTotalPrice - sellerTotalPrice), + RuleType: ruleType, + } +} + +func findFixedMarkupRule(rules []saleFixedMarkupRule, coinM float64) (saleFixedMarkupRule, bool) { + sorted := append([]saleFixedMarkupRule(nil), rules...) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].MinM < sorted[j].MinM + }) + for index, rule := range sorted { + if isCoinInPriceRuleRange(rule.MinM, rule.MaxM, index, len(sorted), coinM, true, false) { + return rule, true + } + } + return saleFixedMarkupRule{}, false +} + +func findRatioAdjustmentRule(rules []saleRatioAdjustmentRule, coinM float64) (saleRatioAdjustmentRule, bool) { + sorted := append([]saleRatioAdjustmentRule(nil), rules...) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].MinM < sorted[j].MinM + }) + for index, rule := range sorted { + if isCoinInPriceRuleRange(rule.MinM, rule.MaxM, index, len(sorted), coinM, false, true) { + return rule, true + } + } + return saleRatioAdjustmentRule{}, false +} + +func isCoinInPriceRuleRange( + minM float64, + maxM float64, + index int, + total int, + coinM float64, + includeLastMax bool, + excludeFirstMin bool, +) bool { + minMatched := coinM >= minM + if excludeFirstMin && index == 0 { + minMatched = coinM > minM + } + if !minMatched { + return false + } + if maxM <= 0 || coinM < maxM { + return true + } + return includeLastMax && index == total-1 && coinM <= maxM +} diff --git a/backend/internal/modules/listing/external_upload.go b/backend/internal/modules/listing/external_upload.go index fd0a232..3394faf 100644 --- a/backend/internal/modules/listing/external_upload.go +++ b/backend/internal/modules/listing/external_upload.go @@ -33,11 +33,15 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe if err != nil { return nil, err } + salePriceConfig, err := s.externalSalePriceConfig(ctx) + if err != nil { + return nil, err + } 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) + createReq := externalAccountToCreateRequest(uploaderName, req.UploadTime, item, salePriceConfig) if err := validateRequest(createReq, rules); err != nil { if len(items) == 1 { return nil, err @@ -235,9 +239,14 @@ func parseClientUploadTime(value int64) *time.Time { return &parsed } -func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item ExternalAccountData) CreateRequest { +func externalAccountToCreateRequest( + uploaderName string, + uploadTime int64, + item ExternalAccountData, + salePriceConfig salePriceConfig, +) CreateRequest { hafCoinM := item.Currency.HafuCoin - price := item.Currency.RecycleRent + pureCoinPrice := item.Currency.RecycleRent ratio := item.Currency.RecycleRatio insurance := insuranceFromSafeSlots(item.SafeSlots) staminaLevel := levelText(item.DailyConsumption.Stamina) @@ -245,16 +254,25 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item skins := cleanStrings(item.Inventory.Skins) remark := strings.TrimSpace(item.Remark) onlineTimeText := normalizeExternalOnlineTimeText(item.OwnerOnlineTime) + resources := externalResources(item.Inventory) + consumablePrice := consumableValue(map[string]any{"resources": resources}) + pricing := calculateExternalPlatformPricing( + hafCoinM, + ratio, + pureCoinPrice, + consumablePrice, + salePriceConfig, + ) assetSummary := map[string]any{ "face_owner": "", "secret_kd": item.SecretKD, "fire_level": item.Level, "daily_loss_m": item.DailyLossM, - "publish_ratio": ratio, + "publish_ratio": pricing.BuyerRatio, "season_insurance": insurance, "stamina_level": staminaLevel, "load_level": loadLevel, - "resources": externalResources(item.Inventory), + "resources": resources, "skin_groups": externalSkinGroups(skins), "online_time_text": onlineTimeText, "ban_record": normalizeBanRecord(item.BanRecord), @@ -268,14 +286,14 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item "price_breakdown": map[string]any{ "seller_reference_ratio": ratio, "seller_ratio": ratio, - "seller_coin_base_price": price, - "seller_total_price": price, - "consumable_price": consumableValue(map[string]any{"resources": externalResources(item.Inventory)}), - "buyer_coin_base_price": price, - "buyer_total_price": price, - "buyer_ratio": ratio, - "platform_markup_amount": 0, - "platform_rule_type": "external_upload", + "seller_coin_base_price": roundMoney(pureCoinPrice), + "seller_total_price": roundMoney(pureCoinPrice + consumablePrice), + "consumable_price": consumablePrice, + "buyer_coin_base_price": pricing.BuyerCoinBasePrice, + "buyer_total_price": pricing.BuyerTotalPrice, + "buyer_ratio": pricing.BuyerRatio, + "platform_markup_amount": pricing.PlatformMarkupPrice, + "platform_rule_type": pricing.RuleType, }, } if start, end, ok := parseExternalOnlineTimeRange(onlineTimeText); ok { @@ -293,7 +311,7 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item HafCoinAmount: int64(math.Round(hafCoinM * 1000000)), AssetSummary: assetSummary, ScreenshotURLS: []string{defaultUploadScreenshot}, - PriceCent: yuanToCent(price), + PriceCent: yuanToCent(pricing.BuyerTotalPrice), DepositAmountCent: yuanToCent(item.Deposit), } } diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 370d890..19e8b01 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -498,7 +498,7 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) { Level6Armor: 17, Skins: []string{"信条", "电锯惊魂"}, }, - }) + }, defaultSalePriceConfig()) if req.Description != "有语音封禁\n封禁历史:2026/01/23封禁3天" { t.Fatalf("Description = %q", req.Description) @@ -515,7 +515,7 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) { if req.HafCoinAmount != 197100000 { t.Fatalf("expected haf coin amount 197100000, got %d", req.HafCoinAmount) } - if req.PriceCent != 45800 || req.DepositAmountCent != 40000 { + if req.PriceCent != 59590 || req.DepositAmountCent != 40000 { t.Fatalf("unexpected price/deposit cent %d/%d", req.PriceCent, req.DepositAmountCent) } if req.AssetSummary["season_insurance"] != "3*3" { @@ -547,6 +547,63 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) { if len(req.ScreenshotURLS) != 1 || req.ScreenshotURLS[0] != defaultUploadScreenshot { t.Fatalf("expected default screenshot, got %#v", req.ScreenshotURLS) } + breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any) + if !ok { + t.Fatalf("price_breakdown = %#v", req.AssetSummary["price_breakdown"]) + } + if breakdown["seller_coin_base_price"] != 458.0 || + breakdown["consumable_price"] != 90.5 || + breakdown["seller_total_price"] != 548.5 || + breakdown["buyer_coin_base_price"] != 505.4 || + breakdown["buyer_total_price"] != 595.9 || + breakdown["buyer_ratio"] != 39.0 || + breakdown["platform_markup_amount"] != 47.4 || + breakdown["platform_rule_type"] != "ratio_subtract" { + t.Fatalf("unexpected price breakdown: %#v", breakdown) + } + if req.AssetSummary["publish_ratio"] != 39.0 { + t.Fatalf("publish_ratio = %#v", req.AssetSummary["publish_ratio"]) + } +} + +func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) { + req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{ + LoginMethod: "QQ账号密码", + Rank: "黑鹰", + Level: 60, + SafeSlots: 9, + Deposit: 400, + Currency: ExternalUploadCurrency{ + HafuCoin: 282, + RecycleRatio: 42.8, + RecycleRent: 659, + }, + DailyConsumption: ExternalDailyConsumption{Stamina: 7, Weight: 7}, + Inventory: ExternalUploadInventory{ + AWMBullets: 10, + Level6Armor: 2, + }, + }, defaultSalePriceConfig()) + + breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any) + if !ok { + t.Fatalf("price_breakdown = %#v", req.AssetSummary["price_breakdown"]) + } + if breakdown["seller_coin_base_price"] != 659.0 || + breakdown["consumable_price"] != 11.0 || + breakdown["seller_total_price"] != 670.0 { + t.Fatalf("回收租金未按纯币价与额外物品拆分:%#v", breakdown) + } + if breakdown["buyer_coin_base_price"] != 717.6 || + breakdown["buyer_total_price"] != 728.6 || + breakdown["buyer_ratio"] != 39.3 || + breakdown["platform_markup_amount"] != 58.6 || + breakdown["platform_rule_type"] != "ratio_subtract" { + t.Fatalf("外部上传未按比例加价:%#v", breakdown) + } + if req.PriceCent != 72860 { + t.Fatalf("PriceCent = %d, want 72860", req.PriceCent) + } } func TestExternalSkinGroupsClassifiesKnownSkins(t *testing.T) {