diff --git a/backend/internal/modules/listing/external_adjustment.go b/backend/internal/modules/listing/external_adjustment.go index c3b2684..7a8f75a 100644 --- a/backend/internal/modules/listing/external_adjustment.go +++ b/backend/internal/modules/listing/external_adjustment.go @@ -136,6 +136,7 @@ func (r *Repository) AdjustExternalListing( breakdown["external_admin_adjust_reason"] = strings.TrimSpace(req.Reason) breakdown["external_admin_adjusted_at"] = now.Format(time.RFC3339) breakdown["external_admin_adjusted_by"] = adminID + syncCanonicalPriceBreakdownFromLegacy(breakdown) summary["price_breakdown"] = breakdown assetSummary, err := marshalAssetSummary(summary) diff --git a/backend/internal/modules/listing/external_pricing.go b/backend/internal/modules/listing/external_pricing.go index 1b8c537..355f79b 100644 --- a/backend/internal/modules/listing/external_pricing.go +++ b/backend/internal/modules/listing/external_pricing.go @@ -19,12 +19,16 @@ type saleFixedMarkupRule struct { MinM float64 `json:"min_m"` MaxM float64 `json:"max_m"` MarkupAmount float64 `json:"markup_amount"` + // MarkupAmountCent 是规范化固定加价(分);大于 0 时优先使用。 + MarkupAmountCent int64 `json:"markup_amount_cent,omitempty"` } type saleRatioAdjustmentRule struct { MinM float64 `json:"min_m"` MaxM float64 `json:"max_m"` RatioSubtract float64 `json:"ratio_subtract"` + // RatioSubtractBPS 是规范化比例修正(基点);大于 0 时优先使用。 + RatioSubtractBPS int64 `json:"ratio_subtract_bps,omitempty"` } type externalPlatformPricing struct { @@ -87,7 +91,7 @@ func filterFixedMarkupRules(rules []saleFixedMarkupRule) []saleFixedMarkupRule { for _, rule := range rules { if !isFiniteNonNegative(rule.MinM) || !isFiniteNonNegative(rule.MaxM) || - !isFiniteNonNegative(rule.MarkupAmount) || + (rule.MarkupAmountCent <= 0 && !isFiniteNonNegative(rule.MarkupAmount)) || (rule.MaxM > 0 && rule.MaxM < rule.MinM) { continue } @@ -101,7 +105,7 @@ func filterRatioAdjustmentRules(rules []saleRatioAdjustmentRule) []saleRatioAdju for _, rule := range rules { if !isFiniteNonNegative(rule.MinM) || !isFiniteNonNegative(rule.MaxM) || - !isFiniteNonNegative(rule.RatioSubtract) || + (rule.RatioSubtractBPS <= 0 && !isFiniteNonNegative(rule.RatioSubtract)) || (rule.MaxM > 0 && rule.MaxM < rule.MinM) { continue } @@ -122,6 +126,25 @@ func calculateExternalPlatformPricing( consumablePrice float64, config salePriceConfig, ) externalPlatformPricing { + // 新配置提供了 cent/bps 字段时,计算优先走整数路径。 + if salePriceConfigHasCentRules(config) { + hafCoinAmount := int64(math.Round(coinM * float64(hafCoinUnitsPerM))) + result := calculateExternalPlatformPricingCent( + hafCoinAmount, + ratioToBPS(sellerRatio), + centFromYuanFloat(sellerCoinBasePrice), + centFromYuanFloat(consumablePrice), + config, + ) + return externalPlatformPricing{ + BuyerCoinBasePrice: centToYuanFloat(result.BuyerCoinBasePriceCent), + BuyerTotalPrice: centToYuanFloat(result.BuyerTotalPriceCent), + BuyerRatio: bpsToRatio(result.BuyerRatioBPS), + PlatformMarkupPrice: centToYuanFloat(result.PlatformMarkupCent), + RuleType: result.RuleType, + } + } + sellerCoinBasePrice = nonNegativeMoney(sellerCoinBasePrice) consumablePrice = nonNegativeMoney(consumablePrice) sellerTotalPrice := roundMoney(sellerCoinBasePrice + consumablePrice) diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go index 6524976..38fb5a4 100644 --- a/backend/internal/modules/listing/presenter.go +++ b/backend/internal/modules/listing/presenter.go @@ -42,6 +42,9 @@ func rowsToDTO(rows []listingRow) []ListingDTO { func normalizedListingPriceCent(req CreateRequest) int64 { if req.AssetSummary != nil { if breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any); ok { + if buyerPriceCent := readSummaryInt64(breakdown["buyer_total_price_cent"]); buyerPriceCent > 0 { + return buyerPriceCent + } buyerPrice := readSummaryNumber(breakdown["buyer_total_price"]) if buyerPrice > 0 { return yuanToCent(buyerPrice) @@ -144,11 +147,11 @@ func applySellerListingPrice(item *ListingDTO) { if !ok { return } - sellerPrice := readSummaryNumber(breakdown["seller_total_price"]) - if sellerPrice > 0 { - item.PriceCent = int64(math.Round(sellerPrice * 100)) + sellerPriceCent := summaryMoneyCent(breakdown, "seller_total_price_cent", "seller_total_price") + if sellerPriceCent > 0 { + item.PriceCent = sellerPriceCent } - sellerRatio := readSummaryNumber(breakdown["seller_ratio"]) + sellerRatio := summaryRatio(breakdown, "seller_ratio_bps", "seller_ratio") if sellerRatio > 0 { item.AssetSummary["publish_ratio"] = sellerRatio } diff --git a/backend/internal/modules/listing/pricing_cent.go b/backend/internal/modules/listing/pricing_cent.go new file mode 100644 index 0000000..56a4588 --- /dev/null +++ b/backend/internal/modules/listing/pricing_cent.go @@ -0,0 +1,530 @@ +package listing + +import ( + "encoding/json" + "math" + "sort" + "strconv" + "strings" + + "hfb_sys/backend/internal/modules/systemconfig" + "hfb_sys/backend/pkg/money" +) + +const ( + // ratioBPSScale 把比例统一到基点:1.0 = 10000 bps,1% = 100 bps。 + ratioBPSScale int64 = 10000 + // hafCoinUnitsPerM 是 1M 哈夫币对应的整数微单位。 + hafCoinUnitsPerM int64 = 1_000_000 + // maxAcceleratedSaleRatioBPSIncrement 是加速出售比例相对参考比例的上限增量(10 个比例点)。 + maxAcceleratedSaleRatioBPSIncrement int64 = 10 * ratioBPSScale +) + +// ratioToBPS 仅在读取后台旧 float 配置或表单输入时做一次边界换算。 +func ratioToBPS(value float64) int64 { + if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + return int64(math.Round(value * float64(ratioBPSScale))) +} + +func bpsToRatio(value int64) float64 { + return float64(value) / float64(ratioBPSScale) +} + +// centFromYuanFloat 仅在兼容旧 float 配置/金额时做一次边界换算。 +func centFromYuanFloat(value float64) int64 { + if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + return int64(math.Round(value * float64(money.CentsPerYuan))) +} + +func centToYuanFloat(value int64) float64 { + return float64(value) / float64(money.CentsPerYuan) +} + +func readSummaryInt64(value any) int64 { + switch typed := value.(type) { + case int: + return int64(typed) + case int32: + return int64(typed) + case int64: + return typed + case uint: + return int64(typed) + case uint64: + if typed > math.MaxInt64 { + return 0 + } + return int64(typed) + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) { + return 0 + } + return int64(typed) + case float32: + return int64(typed) + case json.Number: + if parsed, err := typed.Int64(); err == nil { + return parsed + } + if parsed, err := typed.Float64(); err == nil { + return int64(parsed) + } + return 0 + case string: + trimmed := strings.TrimSpace(typed) + if parsed, err := strconv.ParseInt(trimmed, 10, 64); err == nil { + return parsed + } + if parsed, err := strconv.ParseFloat(trimmed, 64); err == nil { + return int64(parsed) + } + return 0 + default: + return 0 + } +} + +// parseUnitPriceCent 把「8元/个」「1元/2个」等旧文案精确解析为单价(分)。 +func parseUnitPriceCent(priceText string) int64 { + numbers := priceNumberPattern.FindAllString(priceText, -1) + if len(numbers) == 0 { + return 0 + } + amountCent, err := money.ParseYuan(numbers[0]) + if err != nil || amountCent <= 0 { + return 0 + } + if len(numbers) >= 2 { + countCent, err := money.ParseYuan(numbers[1]) + if err == nil && countCent >= money.CentsPerYuan && countCent%money.CentsPerYuan == 0 { + return amountCent / (countCent / money.CentsPerYuan) + } + } + return amountCent +} + +func quantityItemPriceCent(item systemconfig.PublishQuantityItem) int64 { + if item.PriceCent > 0 { + return item.PriceCent + } + return parseUnitPriceCent(item.Price) +} + +func isGridCardResource(key, label string) bool { + return strings.TrimSpace(key) == "gridCard9" || strings.Contains(label, "9格体验卡") +} + +// consumableValueCent 按 resources 的整数数量与单价(分)求和。 +// 配置项 price_cent 优先;其次资源行 price_cent;最后回退旧 price 文案解析。 +func consumableValueCent(summary map[string]any, quantityItems []systemconfig.PublishQuantityItem) int64 { + if summary == nil { + return 0 + } + rawResources, ok := summary["resources"].([]any) + if !ok { + return 0 + } + configByKey := make(map[string]systemconfig.PublishQuantityItem, len(quantityItems)) + for _, item := range quantityItems { + configByKey[item.Key] = item + } + insurance, _ := summary["season_insurance"].(string) + total := int64(0) + for _, raw := range rawResources { + resource, ok := raw.(map[string]any) + if !ok { + continue + } + mode, _ := resource["mode"].(string) + if strings.TrimSpace(mode) != "收费" { + continue + } + key, _ := resource["key"].(string) + label, _ := resource["label"].(string) + if strings.TrimSpace(insurance) == "3*3" && isGridCardResource(key, label) { + continue + } + quantity := readSummaryInt64(resource["quantity"]) + if quantity <= 0 { + continue + } + unitCent := int64(0) + if item, exists := configByKey[strings.TrimSpace(key)]; exists && item.PriceCent > 0 { + unitCent = item.PriceCent + } + if unitCent <= 0 { + unitCent = readSummaryInt64(resource["price_cent"]) + } + if unitCent <= 0 { + priceText, _ := resource["price"].(string) + unitCent = parseUnitPriceCent(priceText) + } + if unitCent <= 0 { + continue + } + total += quantity * unitCent + } + return total +} + +// recommendedDepositCent 优先使用后台 cent 配置,否则对旧 float 配置做一次边界换算。 +func recommendedDepositCent( + config systemconfig.PublishDepositRecommend, + skinGroups []systemconfig.PublishOptionGroup, + selectedSkins []string, +) int64 { + baseCent := config.BaseAmountCent + if baseCent <= 0 { + baseCent = centFromYuanFloat(config.BaseAmount) + } + total := baseCent + for _, rule := range config.SkinGroupRules { + amountCent := rule.AmountPerItemCent + if amountCent <= 0 { + amountCent = centFromYuanFloat(rule.AmountPerItem) + } + if amountCent <= 0 { + continue + } + count := int64(0) + for _, group := range skinGroups { + if group.Key != rule.GroupKey { + continue + } + for _, skin := range group.Options { + if selectedSkinContains(selectedSkins, skin) { + count++ + } + } + break + } + if count > 0 { + total += count * amountCent + } + } + return total +} + +func selectedSkinContains(selectedSkins []string, skin string) bool { + for _, item := range selectedSkins { + if item == skin { + return true + } + } + return false +} + +func calculateSellerReferenceRatioBPS(hafCoinAmount int64, summary map[string]any, options systemconfig.PublishOptionsDTO) int64 { + if hafCoinAmount <= 0 || summary == nil { + return 0 + } + insurance := readSummaryString(summary["season_insurance"]) + stamina := readSummaryString(summary["stamina_level"]) + load := readSummaryString(summary["load_level"]) + if insurance == "" || stamina == "" || load == "" { + return 0 + } + baseBPS := insuranceBaseRatioBPS(options.RatioConfig.InsuranceBaseRatios, insurance) + if baseBPS <= 0 { + return 0 + } + return baseBPS + + calculateRatioConfigPenaltyBPS(options, summary, stamina, load) + + coinCorrectionBPS(options.RatioConfig.CoinCorrections, hafCoinAmount) + + calculateDailyLossRatioAdjustmentBPS(summary) +} + +func insuranceBaseRatioBPS(items []systemconfig.PublishInsuranceBaseRatio, insurance string) int64 { + for _, item := range items { + if item.Insurance != insurance { + continue + } + if item.RatioBPS > 0 { + return item.RatioBPS + } + return ratioToBPS(item.Ratio) + } + return 0 +} + +func calculateRatioConfigPenaltyBPS(options systemconfig.PublishOptionsDTO, summary map[string]any, stamina, load string) int64 { + selectedSkins := flattenSkinNames(summary) + penalty := int64(0) + for _, item := range options.RatioConfig.ConfigItems { + if ratioConfigItemMatched(item, options, selectedSkins, stamina, load) { + continue + } + if item.MissingPenaltyBPS > 0 { + penalty += item.MissingPenaltyBPS + continue + } + penalty += ratioToBPS(item.MissingPenalty) + } + return penalty +} + +func coinCorrectionBPS(items []systemconfig.PublishCoinCorrection, hafCoinAmount int64) int64 { + bestThresholdMicro := int64(-1) + correction := int64(0) + for _, item := range items { + thresholdMicro := int64(math.Round(item.ThresholdM * float64(hafCoinUnitsPerM))) + if hafCoinAmount <= thresholdMicro || thresholdMicro < bestThresholdMicro { + continue + } + bestThresholdMicro = thresholdMicro + if item.CorrectionBPS > 0 { + correction = item.CorrectionBPS + } else { + correction = ratioToBPS(item.Correction) + } + } + return correction +} + +func calculateDailyLossRatioAdjustmentBPS(summary map[string]any) int64 { + if summary == nil { + return 0 + } + dailyLossM := readSummaryInt64(summary["daily_loss_m"]) + if dailyLossM <= 10 { + return 0 + } + adjustment := (dailyLossM - 10) / 10 + if adjustment > 4 { + adjustment = 4 + } + return adjustment * ratioBPSScale +} + +func clampSaleRatioBPS(referenceBPS, requestedBPS int64) int64 { + if referenceBPS <= 0 { + return 0 + } + if requestedBPS <= 0 || requestedBPS < referenceBPS { + return referenceBPS + } + maxBPS := referenceBPS + maxAcceleratedSaleRatioBPSIncrement + if requestedBPS > maxBPS { + return maxBPS + } + return requestedBPS +} + +func applySiteReceiveCostDeltaBPS(referenceBPS int64, delta float64) int64 { + if referenceBPS <= 0 { + return 0 + } + return referenceBPS + ratioToBPS(NormalizeSiteReceiveCostDelta(delta)) +} + +// sellerCoinBasePriceCentFromHafCoin 使用整数分公式:哈夫币微单位 * 100 / 比例 bps,向下取整。 +func sellerCoinBasePriceCentFromHafCoin(hafCoinAmount int64, ratioBPS int64) int64 { + if hafCoinAmount <= 0 || ratioBPS <= 0 || hafCoinAmount > math.MaxInt64/100 { + return 0 + } + return hafCoinAmount * 100 / ratioBPS +} + +type externalPlatformPricingCent struct { + BuyerCoinBasePriceCent int64 + BuyerTotalPriceCent int64 + BuyerRatioBPS int64 + PlatformMarkupCent int64 + RuleType string +} + +// calculateExternalPlatformPricingCent 是卖家发布定价的唯一算术入口,全程使用 int64。 +func calculateExternalPlatformPricingCent( + hafCoinAmount int64, + sellerRatioBPS int64, + sellerCoinBasePriceCent int64, + consumablePriceCent int64, + config salePriceConfig, +) externalPlatformPricingCent { + if sellerCoinBasePriceCent < 0 { + sellerCoinBasePriceCent = 0 + } + if consumablePriceCent < 0 { + consumablePriceCent = 0 + } + if rule, ok := findRatioAdjustmentRuleCent(config.RatioAdjustmentRules, hafCoinAmount); ok && sellerRatioBPS > 0 { + buyerRatioBPS := sellerRatioBPS - saleRatioSubtractBPS(rule) + if buyerRatioBPS > 0 { + buyerCoinBasePriceCent := sellerCoinBasePriceCentFromHafCoin(hafCoinAmount, buyerRatioBPS) + if buyerCoinBasePriceCent < sellerCoinBasePriceCent { + buyerCoinBasePriceCent = sellerCoinBasePriceCent + } + return buildExternalPlatformPricingCent( + hafCoinAmount, + sellerCoinBasePriceCent, + consumablePriceCent, + buyerCoinBasePriceCent, + "ratio_subtract", + ) + } + } + if rule, ok := findFixedMarkupRuleCent(config.FixedMarkupRules, hafCoinAmount); ok { + return buildExternalPlatformPricingCent( + hafCoinAmount, + sellerCoinBasePriceCent, + consumablePriceCent, + sellerCoinBasePriceCent+saleFixedMarkupCent(rule), + "fixed_markup", + ) + } + return buildExternalPlatformPricingCent( + hafCoinAmount, + sellerCoinBasePriceCent, + consumablePriceCent, + sellerCoinBasePriceCent, + "none", + ) +} + +func buildExternalPlatformPricingCent( + hafCoinAmount int64, + anchorCoinBasePriceCent int64, + consumablePriceCent int64, + buyerCoinBasePriceCent int64, + ruleType string, +) externalPlatformPricingCent { + if buyerCoinBasePriceCent < 0 { + buyerCoinBasePriceCent = 0 + } + buyerTotalPriceCent := buyerCoinBasePriceCent + consumablePriceCent + buyerRatioBPS := int64(0) + if buyerCoinBasePriceCent > 0 && hafCoinAmount > 0 && hafCoinAmount <= math.MaxInt64/100 { + // 展示值:由整数分反推 bps,存在向下取整误差。 + buyerRatioBPS = hafCoinAmount * 100 / buyerCoinBasePriceCent + } + anchorTotalCent := anchorCoinBasePriceCent + consumablePriceCent + return externalPlatformPricingCent{ + BuyerCoinBasePriceCent: buyerCoinBasePriceCent, + BuyerTotalPriceCent: buyerTotalPriceCent, + BuyerRatioBPS: buyerRatioBPS, + PlatformMarkupCent: buyerTotalPriceCent - anchorTotalCent, + RuleType: ruleType, + } +} + +func saleFixedMarkupCent(rule saleFixedMarkupRule) int64 { + if rule.MarkupAmountCent > 0 { + return rule.MarkupAmountCent + } + return centFromYuanFloat(rule.MarkupAmount) +} + +func saleRatioSubtractBPS(rule saleRatioAdjustmentRule) int64 { + if rule.RatioSubtractBPS > 0 { + return rule.RatioSubtractBPS + } + return ratioToBPS(rule.RatioSubtract) +} + +func findFixedMarkupRuleCent(rules []saleFixedMarkupRule, hafCoinAmount int64) (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 isCoinInPriceRuleRangeCent(rule.MinM, rule.MaxM, index, len(sorted), hafCoinAmount, true, false) { + return rule, true + } + } + return saleFixedMarkupRule{}, false +} + +func findRatioAdjustmentRuleCent(rules []saleRatioAdjustmentRule, hafCoinAmount int64) (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 isCoinInPriceRuleRangeCent(rule.MinM, rule.MaxM, index, len(sorted), hafCoinAmount, false, true) { + return rule, true + } + } + return saleRatioAdjustmentRule{}, false +} + +func isCoinInPriceRuleRangeCent( + minM float64, + maxM float64, + index int, + total int, + hafCoinAmount int64, + includeLastMax bool, + excludeFirstMin bool, +) bool { + minMicro := int64(math.Round(minM * float64(hafCoinUnitsPerM))) + maxMicro := int64(math.Round(maxM * float64(hafCoinUnitsPerM))) + minMatched := hafCoinAmount >= minMicro + if excludeFirstMin && index == 0 { + minMatched = hafCoinAmount > minMicro + } + if !minMatched { + return false + } + if maxMicro <= 0 || hafCoinAmount < maxMicro { + return true + } + return includeLastMax && index == total-1 && hafCoinAmount <= maxMicro +} + +func salePriceConfigHasCentRules(config salePriceConfig) bool { + for _, rule := range config.FixedMarkupRules { + if rule.MarkupAmountCent > 0 { + return true + } + } + for _, rule := range config.RatioAdjustmentRules { + if rule.RatioSubtractBPS > 0 { + return true + } + } + return false +} + +// syncCanonicalPriceBreakdownFromLegacy 供历史审核/外部调价路径使用: +// 这些路径仍以旧 float 字段为准,写回后同步派生规范字段,避免卖家读侧优先读到过期 cent/bps。 +func syncCanonicalPriceBreakdownFromLegacy(breakdown map[string]any) { + if breakdown == nil { + return + } + for bpsKey, ratioKey := range map[string]string{ + "seller_reference_ratio_bps": "seller_reference_ratio", + "seller_ratio_bps": "seller_ratio", + "accelerated_sale_ratio_bps": "accelerated_sale_ratio", + "buyer_ratio_bps": "buyer_ratio", + } { + ratio := readSummaryNumber(breakdown[ratioKey]) + if ratio > 0 { + breakdown[bpsKey] = ratioToBPS(ratio) + continue + } + if bps := readSummaryInt64(breakdown[bpsKey]); bps > 0 { + breakdown[bpsKey] = bps + } + } + for centKey, yuanKey := range map[string]string{ + "seller_coin_base_price_cent": "seller_coin_base_price", + "seller_total_price_cent": "seller_total_price", + "consumable_price_cent": "consumable_price", + "buyer_coin_base_price_cent": "buyer_coin_base_price", + "buyer_total_price_cent": "buyer_total_price", + "platform_markup_amount_cent": "platform_markup_amount", + } { + yuan := readSummaryNumber(breakdown[yuanKey]) + if yuan > 0 { + breakdown[centKey] = centFromYuanFloat(yuan) + continue + } + if cent := readSummaryInt64(breakdown[centKey]); cent > 0 { + breakdown[centKey] = cent + } + } +} diff --git a/backend/internal/modules/listing/pricing_cent_test.go b/backend/internal/modules/listing/pricing_cent_test.go new file mode 100644 index 0000000..3bbd812 --- /dev/null +++ b/backend/internal/modules/listing/pricing_cent_test.go @@ -0,0 +1,227 @@ +package listing + +import ( + "testing" + + "hfb_sys/backend/internal/modules/systemconfig" +) + +func TestSellerCoinBasePriceCentUsesIntegerFormula(t *testing.T) { + if got := sellerCoinBasePriceCentFromHafCoin(100_000_000, 430_000); got != 23255 { + t.Fatalf("sellerCoinBasePriceCentFromHafCoin() = %d, want 23255 (floor)", got) + } + if got := sellerCoinBasePriceCentFromHafCoin(100_000_000, 380_000); got != 26315 { + t.Fatalf("sellerCoinBasePriceCentFromHafCoin() = %d, want 26315 (floor)", got) + } +} + +func TestCalculateExternalPlatformPricingCentUsesRatioRule(t *testing.T) { + config := salePriceConfig{ + RatioAdjustmentRules: []saleRatioAdjustmentRule{ + {MinM: 90, MaxM: 150, RatioSubtract: 5}, + }, + } + pricing := calculateExternalPlatformPricingCent(100_000_000, 430_000, 23255, 0, config) + if pricing.RuleType != "ratio_subtract" { + t.Fatalf("RuleType = %q, want ratio_subtract", pricing.RuleType) + } + if pricing.BuyerCoinBasePriceCent != 26315 { + t.Fatalf("BuyerCoinBasePriceCent = %d, want 26315", pricing.BuyerCoinBasePriceCent) + } + if pricing.BuyerTotalPriceCent != 26315 { + t.Fatalf("BuyerTotalPriceCent = %d, want 26315", pricing.BuyerTotalPriceCent) + } + if pricing.PlatformMarkupCent != 3060 { + t.Fatalf("PlatformMarkupCent = %d, want 3060", pricing.PlatformMarkupCent) + } + if pricing.BuyerRatioBPS != 380011 { + t.Fatalf("BuyerRatioBPS = %d, want 380011 display bps", pricing.BuyerRatioBPS) + } +} + +func TestCalculateExternalPlatformPricingCentPrefersBPSAndCentConfig(t *testing.T) { + config := salePriceConfig{ + RatioAdjustmentRules: []saleRatioAdjustmentRule{ + {MinM: 90, MaxM: 150, RatioSubtract: 3.5, RatioSubtractBPS: 35_000}, + }, + } + pricing := calculateExternalPlatformPricingCent(100_000_000, 430_000, 23255, 0, config) + if pricing.BuyerCoinBasePriceCent != 25316 { + t.Fatalf("BuyerCoinBasePriceCent = %d, want 25316 from 395000 bps", pricing.BuyerCoinBasePriceCent) + } + if pricing.BuyerRatioBPS != 395007 { + t.Fatalf("BuyerRatioBPS = %d, want 395007 display bps", pricing.BuyerRatioBPS) + } + + fixedConfig := salePriceConfig{ + FixedMarkupRules: []saleFixedMarkupRule{ + {MinM: 90, MaxM: 150, MarkupAmount: 99, MarkupAmountCent: 1234}, + }, + } + fixed := calculateExternalPlatformPricingCent(100_000_000, 430_000, 20000, 500, fixedConfig) + if fixed.RuleType != "fixed_markup" { + t.Fatalf("RuleType = %q, want fixed_markup", fixed.RuleType) + } + if fixed.BuyerCoinBasePriceCent != 21234 || fixed.BuyerTotalPriceCent != 21734 { + t.Fatalf("fixed pricing = %d/%d, want 21234/21734", fixed.BuyerCoinBasePriceCent, fixed.BuyerTotalPriceCent) + } + if fixed.PlatformMarkupCent != 1234 { + t.Fatalf("PlatformMarkupCent = %d, want 1234", fixed.PlatformMarkupCent) + } +} + +func TestCalculateExternalPlatformPricingCentFallsBackToLegacyFloatConfig(t *testing.T) { + config := salePriceConfig{ + FixedMarkupRules: []saleFixedMarkupRule{ + {MinM: 90, MaxM: 150, MarkupAmount: 28}, + }, + } + pricing := calculateExternalPlatformPricingCent(100_000_000, 430_000, 20000, 0, config) + if pricing.RuleType != "fixed_markup" { + t.Fatalf("RuleType = %q, want fixed_markup", pricing.RuleType) + } + if pricing.BuyerCoinBasePriceCent != 22800 { + t.Fatalf("BuyerCoinBasePriceCent = %d, want 22800 from legacy 28 元", pricing.BuyerCoinBasePriceCent) + } + if centFromYuanFloat(3.5) != 350 { + t.Fatalf("centFromYuanFloat(3.5) = %d, want 350", centFromYuanFloat(3.5)) + } + if ratioToBPS(3.5) != 35_000 { + t.Fatalf("ratioToBPS(3.5) = %d, want 35000", ratioToBPS(3.5)) + } +} + +func TestConsumableValueCentPrefersConfigAndParsesLegacyPrice(t *testing.T) { + summary := map[string]any{ + "season_insurance": "3*3", + "resources": []any{ + map[string]any{"key": "awmAmmo", "label": "AWM子弹", "mode": "收费", "quantity": 10, "price": "0.6元/发"}, + map[string]any{"key": "gridCard9", "label": "9格体验卡", "mode": "收费", "quantity": 1, "price": "3元/张"}, + map[string]any{"key": "armor6", "label": "6甲", "mode": "收费", "quantity": 2, "price": "2.5元/个"}, + map[string]any{"key": "barrettAmmo", "label": "巴雷特子弹", "mode": "收费", "quantity": 3, "price": "1元/2发"}, + map[string]any{"key": "gift", "label": "赠送项", "mode": "赠送", "quantity": 9, "price": "10元/个"}, + }, + } + items := []systemconfig.PublishQuantityItem{ + {Key: "awmAmmo", Label: "AWM子弹", Price: "0.6元/发", PriceCent: 70}, + } + if got := consumableValueCent(summary, items); got != 700+500+150 { + t.Fatalf("consumableValueCent() = %d, want 1350 (price_cent priority, 3*3 skips grid card)", got) + } + if got := consumableValueCent(summary, nil); got != 600+500+150 { + t.Fatalf("consumableValueCent() legacy fallback = %d, want 1250", got) + } + + summary["resources"].([]any)[0].(map[string]any)["price_cent"] = 1 + if got := consumableValueCent(summary, nil); got != 10+500+150 { + t.Fatalf("consumableValueCent() resource price_cent = %d, want 660", got) + } +} + +func TestRecommendedDepositCentUsesCentConfigOrLegacyFloat(t *testing.T) { + skinGroups := []systemconfig.PublishOptionGroup{ + {Key: "melee", Title: "刀皮", Options: []string{"信条", "暗星"}}, + {Key: "operatorRed", Title: "红皮", Options: []string{"凌霄成卫"}}, + } + selected := []string{"信条", "暗星", "凌霄成卫"} + + legacy := systemconfig.PublishDepositRecommend{ + BaseAmount: 50, + SkinGroupRules: []systemconfig.PublishDepositSkinGroupRule{ + {GroupKey: "melee", Label: "刀皮", AmountPerItem: 5}, + {GroupKey: "operatorRed", Label: "红皮", AmountPerItem: 30}, + }, + } + if got := recommendedDepositCent(legacy, skinGroups, selected); got != 9000 { + t.Fatalf("recommendedDepositCent() legacy = %d, want 9000", got) + } + + canonical := systemconfig.PublishDepositRecommend{ + BaseAmount: 50, + BaseAmountCent: 6000, + SkinGroupRules: []systemconfig.PublishDepositSkinGroupRule{ + {GroupKey: "melee", Label: "刀皮", AmountPerItem: 5, AmountPerItemCent: 500}, + {GroupKey: "operatorRed", Label: "红皮", AmountPerItem: 30, AmountPerItemCent: 3000}, + }, + } + if got := recommendedDepositCent(canonical, skinGroups, selected); got != 10000 { + t.Fatalf("recommendedDepositCent() cent = %d, want 10000", got) + } +} + +func TestCalculateSellerReferenceRatioBPSPrefersCanonicalFields(t *testing.T) { + options := systemconfig.DefaultPublishOptions() + options.RatioConfig.InsuranceBaseRatios = []systemconfig.PublishInsuranceBaseRatio{ + {Insurance: "3*3", Ratio: 1, RatioBPS: 400_000}, + } + options.RatioConfig.ConfigItems = []systemconfig.PublishRatioConfigItem{ + {Key: "missing", Label: "缺失项", Kind: "skin_group", GroupKey: "missing", MissingPenalty: 99, MissingPenaltyBPS: 10_000}, + } + options.RatioConfig.CoinCorrections = []systemconfig.PublishCoinCorrection{ + {ThresholdM: 0, Correction: 99, CorrectionBPS: 20_000}, + } + summary := map[string]any{ + "season_insurance": "3*3", + "stamina_level": "7级", + "load_level": "7级", + "daily_loss_m": 10, + } + if got := calculateSellerReferenceRatioBPS(100_000_000, summary, options); got != 430_000 { + t.Fatalf("calculateSellerReferenceRatioBPS() = %d, want 430000", got) + } +} + +func TestApplyMainSiteReceivePricingWritesConsistentCanonicalAndLegacyFields(t *testing.T) { + req := fullReceivePricingRequest(10) + applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, false) + breakdown := ensurePriceBreakdown(req.AssetSummary) + for centKey, yuanKey := range map[string]string{ + "seller_coin_base_price_cent": "seller_coin_base_price", + "seller_total_price_cent": "seller_total_price", + "consumable_price_cent": "consumable_price", + "buyer_coin_base_price_cent": "buyer_coin_base_price", + "buyer_total_price_cent": "buyer_total_price", + "platform_markup_amount_cent": "platform_markup_amount", + } { + cent := readSummaryInt64(breakdown[centKey]) + if got := readSummaryNumber(breakdown[yuanKey]); got != centToYuanFloat(cent) { + t.Fatalf("%s = %v, want %v derived from %s=%d", yuanKey, got, centToYuanFloat(cent), centKey, cent) + } + } + for bpsKey, ratioKey := range map[string]string{ + "seller_reference_ratio_bps": "seller_reference_ratio", + "seller_ratio_bps": "seller_ratio", + "buyer_ratio_bps": "buyer_ratio", + } { + bps := readSummaryInt64(breakdown[bpsKey]) + if got := readSummaryNumber(breakdown[ratioKey]); got != bpsToRatio(bps) { + t.Fatalf("%s = %v, want %v derived from %s=%d", ratioKey, got, bpsToRatio(bps), bpsKey, bps) + } + } +} + +func TestSyncCanonicalPriceBreakdownFromLegacyRefreshesStaleFields(t *testing.T) { + breakdown := map[string]any{ + "seller_ratio": 45.5, + "seller_ratio_bps": 430000, + "seller_total_price": 218.25, + "seller_total_price_cent": 23255, + "buyer_ratio": 40.5, + "buyer_ratio_bps": 380000, + "platform_markup_amount": 12.3, + "platform_markup_amount_cent": 9999, + } + syncCanonicalPriceBreakdownFromLegacy(breakdown) + if got := readSummaryInt64(breakdown["seller_ratio_bps"]); got != 455000 { + t.Fatalf("seller_ratio_bps = %d, want 455000", got) + } + if got := readSummaryInt64(breakdown["seller_total_price_cent"]); got != 21825 { + t.Fatalf("seller_total_price_cent = %d, want 21825", got) + } + if got := readSummaryInt64(breakdown["buyer_ratio_bps"]); got != 405000 { + t.Fatalf("buyer_ratio_bps = %d, want 405000", got) + } + if got := readSummaryInt64(breakdown["platform_markup_amount_cent"]); got != 1230 { + t.Fatalf("platform_markup_amount_cent = %d, want 1230", got) + } +} diff --git a/backend/internal/modules/listing/receive_pricing.go b/backend/internal/modules/listing/receive_pricing.go index 7f90e7f..b538f91 100644 --- a/backend/internal/modules/listing/receive_pricing.go +++ b/backend/internal/modules/listing/receive_pricing.go @@ -28,14 +28,37 @@ type ReceivePricingView struct { func ReceivePricingFromAssetSummary(raw datatypes.JSON) ReceivePricingView { summary := decodeAssetSummary(raw) breakdown := ensurePriceBreakdown(summary) - sellerTotal := readSummaryNumber(breakdown["seller_total_price"]) return ReceivePricingView{ - SellerRatio: readSummaryNumber(breakdown["seller_ratio"]), - SellerTotalPriceCent: yuanToCent(sellerTotal), - BuyerRatio: readSummaryNumber(breakdown["buyer_ratio"]), + SellerRatio: summaryRatio(breakdown, "seller_ratio_bps", "seller_ratio"), + SellerTotalPriceCent: summaryMoneyCent(breakdown, "seller_total_price_cent", "seller_total_price"), + BuyerRatio: summaryRatio(breakdown, "buyer_ratio_bps", "buyer_ratio"), } } +func summaryMoneyCent(breakdown map[string]any, centKey, yuanKey string) int64 { + if breakdown == nil { + return 0 + } + if cent := readSummaryInt64(breakdown[centKey]); cent > 0 { + return cent + } + return centFromYuanFloat(readSummaryNumber(breakdown[yuanKey])) +} + +func summaryBPS(breakdown map[string]any, bpsKey, ratioKey string) int64 { + if breakdown == nil { + return 0 + } + if bps := readSummaryInt64(breakdown[bpsKey]); bps > 0 { + return bps + } + return ratioToBPS(readSummaryNumber(breakdown[ratioKey])) +} + +func summaryRatio(breakdown map[string]any, bpsKey, ratioKey string) float64 { + return bpsToRatio(summaryBPS(breakdown, bpsKey, ratioKey)) +} + func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRequest, receiveRatioDelta, receiveCostDelta float64, onDistributionSite bool) error { if req == nil { return nil @@ -60,58 +83,76 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis if req.AssetSummary == nil { req.AssetSummary = map[string]any{} } - coinM := coinMFromHafCoinAmount(req.HafCoinAmount) - referenceRatio := calculateSellerReferenceRatio(coinM, req.AssetSummary, options) - if referenceRatio <= 0 { + referenceRatioBPS := calculateSellerReferenceRatioBPS(req.HafCoinAmount, req.AssetSummary, options) + if referenceRatioBPS <= 0 { return } breakdown := ensurePriceBreakdown(req.AssetSummary) - requestedRatio := readSummaryNumber(breakdown["seller_ratio"]) - if requestedRatio <= 0 { - requestedRatio = readSummaryNumber(breakdown["accelerated_sale_ratio"]) + requestedRatioBPS := summaryBPS(breakdown, "seller_ratio_bps", "seller_ratio") + if requestedRatioBPS <= 0 { + requestedRatioBPS = summaryBPS(breakdown, "accelerated_sale_ratio_bps", "accelerated_sale_ratio") } costDelta := NormalizeSiteReceiveCostDelta(receiveCostDelta) ratioDelta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta) - sellerRatio := clampSaleRatio(referenceRatio, requestedRatio) + sellerRatioBPS := clampSaleRatioBPS(referenceRatioBPS, requestedRatioBPS) // 分站新口径:收号比例 = R + 压价点数(收号价更低),买家定价锚定主站参考比例 R,不随收号价联动。 if onDistributionSite { - sellerRatio = ApplySiteReceiveCostDelta(referenceRatio, costDelta) + sellerRatioBPS = applySiteReceiveCostDeltaBPS(referenceRatioBPS, costDelta) } - coinWan := coinM * 100 - sellerCoinBasePrice := roundMoney(coinWan / sellerRatio) - consumablePrice := consumableValue(req.AssetSummary) - sellerTotalPrice := roundMoney(sellerCoinBasePrice + consumablePrice) - dailyLoss := readSummaryNumber(req.AssetSummary["daily_loss_m"]) + sellerCoinBasePriceCent := sellerCoinBasePriceCentFromHafCoin(req.HafCoinAmount, sellerRatioBPS) + consumablePriceCent := consumableValueCent(req.AssetSummary, options.QuantityItems) + sellerTotalPriceCent := sellerCoinBasePriceCent + consumablePriceCent // 主站买家价仍跟 sellerRatio(含加速出售);分站买家价锚定 R,压价差进入三方分成池。 - buyerAnchorRatio := sellerRatio - buyerAnchorCoinBase := sellerCoinBasePrice + buyerAnchorRatioBPS := sellerRatioBPS + buyerAnchorCoinBasePriceCent := sellerCoinBasePriceCent if onDistributionSite { - buyerAnchorRatio = referenceRatio - buyerAnchorCoinBase = referenceSellerCoinBasePrice(coinWan, referenceRatio) + buyerAnchorRatioBPS = referenceRatioBPS + buyerAnchorCoinBasePriceCent = sellerCoinBasePriceCentFromHafCoin(req.HafCoinAmount, referenceRatioBPS) } - pricing := calculateExternalPlatformPricing(coinM, buyerAnchorRatio, buyerAnchorCoinBase, consumablePrice, config) - breakdown["seller_reference_ratio"] = referenceRatio - breakdown["seller_ratio"] = sellerRatio - breakdown["seller_coin_base_price"] = sellerCoinBasePrice - breakdown["seller_total_price"] = sellerTotalPrice - breakdown["consumable_price"] = consumablePrice - breakdown["daily_loss_ratio_adjustment"] = calculateDailyLossRatioAdjustment(dailyLoss) - breakdown["accelerated_sale_ratio"] = sellerRatio + pricing := calculateExternalPlatformPricingCent( + req.HafCoinAmount, + buyerAnchorRatioBPS, + buyerAnchorCoinBasePriceCent, + consumablePriceCent, + config, + ) + dailyLossRatioAdjustmentBPS := calculateDailyLossRatioAdjustmentBPS(req.AssetSummary) + + // 规范字段:整数分 + 基点。 + breakdown["seller_reference_ratio_bps"] = referenceRatioBPS + breakdown["seller_ratio_bps"] = sellerRatioBPS + breakdown["buyer_ratio_bps"] = pricing.BuyerRatioBPS + breakdown["seller_coin_base_price_cent"] = sellerCoinBasePriceCent + breakdown["seller_total_price_cent"] = sellerTotalPriceCent + breakdown["consumable_price_cent"] = consumablePriceCent + breakdown["buyer_coin_base_price_cent"] = pricing.BuyerCoinBasePriceCent + breakdown["buyer_total_price_cent"] = pricing.BuyerTotalPriceCent + breakdown["platform_markup_amount_cent"] = pricing.PlatformMarkupCent + + // 旧 float 字段:由规范字段一次性派生,供历史前端与历史商品读取。 + breakdown["seller_reference_ratio"] = bpsToRatio(referenceRatioBPS) + breakdown["seller_ratio"] = bpsToRatio(sellerRatioBPS) + breakdown["seller_coin_base_price"] = centToYuanFloat(sellerCoinBasePriceCent) + breakdown["seller_total_price"] = centToYuanFloat(sellerTotalPriceCent) + breakdown["consumable_price"] = centToYuanFloat(consumablePriceCent) + breakdown["daily_loss_ratio_adjustment"] = bpsToRatio(dailyLossRatioAdjustmentBPS) + breakdown["accelerated_sale_ratio"] = bpsToRatio(sellerRatioBPS) + breakdown["accelerated_sale_ratio_bps"] = sellerRatioBPS if onDistributionSite { breakdown["site_receive_cost_delta"] = costDelta breakdown["pricing_model"] = "site_cost_decoupled_v1" } else { breakdown["site_receive_ratio_delta"] = ratioDelta } - breakdown["buyer_coin_base_price"] = pricing.BuyerCoinBasePrice - breakdown["buyer_total_price"] = pricing.BuyerTotalPrice - breakdown["buyer_ratio"] = pricing.BuyerRatio - breakdown["platform_markup_amount"] = pricing.PlatformMarkupPrice + breakdown["buyer_coin_base_price"] = centToYuanFloat(pricing.BuyerCoinBasePriceCent) + breakdown["buyer_total_price"] = centToYuanFloat(pricing.BuyerTotalPriceCent) + breakdown["buyer_ratio"] = bpsToRatio(pricing.BuyerRatioBPS) + breakdown["platform_markup_amount"] = centToYuanFloat(pricing.PlatformMarkupCent) breakdown["platform_rule_type"] = pricing.RuleType req.AssetSummary["price_breakdown"] = breakdown - req.AssetSummary["publish_ratio"] = pricing.BuyerRatio - req.PriceCent = yuanToCent(pricing.BuyerTotalPrice) + req.AssetSummary["publish_ratio"] = bpsToRatio(pricing.BuyerRatioBPS) + req.PriceCent = pricing.BuyerTotalPriceCent } // NormalizeSiteReceiveCostDelta 将分站收号压价点数限制在 [0, 20]。 diff --git a/backend/internal/modules/listing/receive_pricing_test.go b/backend/internal/modules/listing/receive_pricing_test.go index 4009f39..fe9e2cc 100644 --- a/backend/internal/modules/listing/receive_pricing_test.go +++ b/backend/internal/modules/listing/receive_pricing_test.go @@ -32,9 +32,9 @@ func TestCalculateSellerReferenceRatioUsesMainSiteConfig(t *testing.T) { "load_level": "7级", "daily_loss_m": 10, } - got := calculateSellerReferenceRatio(100, summary, options) - if got != 43 { - t.Fatalf("reference ratio = %v, want 43 (base 40 + missing skin penalties 3)", got) + got := calculateSellerReferenceRatioBPS(100_000_000, summary, options) + if got != 430_000 { + t.Fatalf("reference ratio = %d bps, want 430000 (base 40 + missing skin penalties 3)", got) } } @@ -48,14 +48,33 @@ func TestApplyMainSiteReceivePricingOverwritesClientRatio(t *testing.T) { if readSummaryNumber(breakdown["seller_reference_ratio"]) != 43 { t.Fatalf("seller_reference_ratio = %v, want 43", breakdown["seller_reference_ratio"]) } - if readSummaryNumber(breakdown["seller_coin_base_price"]) != 232.6 { - t.Fatalf("seller_coin_base_price = %v, want 232.6", breakdown["seller_coin_base_price"]) + if readSummaryNumber(breakdown["seller_coin_base_price"]) != 232.55 { + t.Fatalf("seller_coin_base_price = %v, want 232.55", breakdown["seller_coin_base_price"]) } - if readSummaryNumber(breakdown["buyer_ratio"]) != 38 { - t.Fatalf("buyer_ratio = %v, want 38", breakdown["buyer_ratio"]) + if got := readSummaryInt64(breakdown["seller_reference_ratio_bps"]); got != 430000 { + t.Fatalf("seller_reference_ratio_bps = %d, want 430000", got) } - if req.PriceCent != 26320 { - t.Fatalf("PriceCent = %d, want 26320", req.PriceCent) + if got := readSummaryInt64(breakdown["seller_coin_base_price_cent"]); got != 23255 { + t.Fatalf("seller_coin_base_price_cent = %d, want 23255", got) + } + if got := readSummaryInt64(breakdown["buyer_coin_base_price_cent"]); got != 26315 { + t.Fatalf("buyer_coin_base_price_cent = %d, want 26315", got) + } + if got := readSummaryInt64(breakdown["seller_total_price_cent"]); got != 23255 { + t.Fatalf("seller_total_price_cent = %d, want 23255", got) + } + if got := readSummaryInt64(breakdown["platform_markup_amount_cent"]); got != 3060 { + t.Fatalf("platform_markup_amount_cent = %d, want 3060", got) + } + buyerRatioBPS := readSummaryInt64(breakdown["buyer_ratio_bps"]) + if buyerRatioBPS != 380011 { + t.Fatalf("buyer_ratio_bps = %d, want 380011 derived from integer price", buyerRatioBPS) + } + if readSummaryNumber(breakdown["buyer_ratio"]) != float64(buyerRatioBPS)/10000 { + t.Fatalf("buyer_ratio = %v, want derived from %d bps", breakdown["buyer_ratio"], buyerRatioBPS) + } + if req.PriceCent != 26315 { + t.Fatalf("PriceCent = %d, want 26315", req.PriceCent) } } @@ -69,15 +88,19 @@ func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t * if readSummaryNumber(breakdown["seller_reference_ratio"]) != 43 { t.Fatalf("seller_reference_ratio = %v, want 43", breakdown["seller_reference_ratio"]) } - if got := readSummaryNumber(breakdown["seller_coin_base_price"]); got != 200 { - t.Fatalf("accelerated seller_coin_base_price = %v, want 200", got) + if got := readSummaryInt64(breakdown["seller_coin_base_price_cent"]); got != 20000 { + t.Fatalf("accelerated seller_coin_base_price_cent = %d, want 20000", got) } - // 主站加速出售买卖仍联动:买家比例 = 50 − 5 = 45,不能被误锚到参考比例 38。 - if readSummaryNumber(breakdown["buyer_ratio"]) != 45 { - t.Fatalf("accelerated buyer_ratio = %v, want 45 (follows seller ratio, not reference 38)", breakdown["buyer_ratio"]) + if got := readSummaryInt64(breakdown["buyer_coin_base_price_cent"]); got != 22222 { + t.Fatalf("accelerated buyer_coin_base_price_cent = %d, want 22222", got) } - if req.PriceCent != 22220 { - t.Fatalf("accelerated PriceCent = %d, want 22220", req.PriceCent) + // 主站加速出售买卖仍联动:买家比例按 50 − 5 定价,不能被误锚到参考比例 38。 + buyerRatioBPS := readSummaryInt64(breakdown["buyer_ratio_bps"]) + if buyerRatioBPS != 450004 { + t.Fatalf("accelerated buyer_ratio_bps = %d, want 450004 derived from integer price", buyerRatioBPS) + } + if req.PriceCent != 22222 { + t.Fatalf("accelerated PriceCent = %d, want 22222", req.PriceCent) } } @@ -88,8 +111,8 @@ func TestDistributionReceiveCostDeltaZeroKeepsMainSitePrice(t *testing.T) { if readSummaryNumber(breakdown["seller_ratio"]) != 43 { t.Fatalf("distribution seller_ratio = %v, want main-site reference 43 when cost delta is 0", breakdown["seller_ratio"]) } - if readSummaryNumber(breakdown["buyer_total_price"]) != 263.2 { - t.Fatalf("distribution buyer_total_price = %v, want 263.2 anchored to main site", breakdown["buyer_total_price"]) + if readSummaryNumber(breakdown["buyer_total_price"]) != 263.15 { + t.Fatalf("distribution buyer_total_price = %v, want 263.15 anchored to main site", breakdown["buyer_total_price"]) } } @@ -97,19 +120,19 @@ func TestDistributionReceiveCostDeltaLowersReceivePriceKeepsSellPrice(t *testing req := fullReceivePricingRequest(50) applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 3, true) breakdown := ensurePriceBreakdown(req.AssetSummary) - // 压价 3 点:收号比例 = R+3 = 46,号主收 217.4(低于主站 232.6)。 + // 压价 3 点:收号比例 = R+3 = 46,号主收 217.39(低于主站 232.55)。 if readSummaryNumber(breakdown["seller_ratio"]) != 46 { t.Fatalf("distribution seller_ratio = %v, want 46 (R+3) under cost-decoupled model", breakdown["seller_ratio"]) } - if got := readSummaryNumber(breakdown["seller_coin_base_price"]); got != 217.4 { - t.Fatalf("seller_coin_base_price = %v, want 217.4 (lower than main site 232.6)", got) + if got := readSummaryNumber(breakdown["seller_coin_base_price"]); got != 217.39 { + t.Fatalf("seller_coin_base_price = %v, want 217.39 (lower than main site 232.55)", got) } - // 卖价锚定主站:买家比例仍为 38、买家价仍为 263.2,不随收号价下降。 - if readSummaryNumber(breakdown["buyer_ratio"]) != 38 { - t.Fatalf("buyer_ratio = %v, want 38 (decoupled from receive ratio)", breakdown["buyer_ratio"]) + // 卖价锚定主站:买家比例仍按 38 定价、买家价仍为 263.15,不随收号价下降。 + if readSummaryNumber(breakdown["buyer_ratio"]) < 38 || readSummaryNumber(breakdown["buyer_ratio"]) > 38.01 { + t.Fatalf("buyer_ratio = %v, want about 38 (decoupled from receive ratio)", breakdown["buyer_ratio"]) } - if readSummaryNumber(breakdown["buyer_total_price"]) != 263.2 { - t.Fatalf("buyer_total_price = %v, want 263.2 unchanged by cost delta", breakdown["buyer_total_price"]) + if readSummaryNumber(breakdown["buyer_total_price"]) != 263.15 { + t.Fatalf("buyer_total_price = %v, want 263.15 unchanged by cost delta", breakdown["buyer_total_price"]) } // 注意:发布快照的 platform_markup_amount 仍为主站口径提价(30.6,锚定价→买价); // 压价差不在此体现,而是结算层 profit = 净付款 − 号主快照价(217.4)自动进入 35/40/25 分成池。 diff --git a/backend/internal/modules/listing/review.go b/backend/internal/modules/listing/review.go index 167ab71..5e14bfc 100644 --- a/backend/internal/modules/listing/review.go +++ b/backend/internal/modules/listing/review.go @@ -323,6 +323,7 @@ func (r *Repository) AdjustReviewPrice(ctx context.Context, adminID uint64, list breakdown["admin_adjust_reason"] = strings.TrimSpace(req.Reason) breakdown["admin_adjusted_at"] = time.Now().Format(time.RFC3339) breakdown["admin_adjusted_by"] = adminID + syncCanonicalPriceBreakdownFromLegacy(breakdown) summary["price_breakdown"] = breakdown assetSummary, err := marshalAssetSummary(summary) diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index d703b01..0bd0a3b 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -1106,11 +1106,11 @@ func TestCreateDistributionListingUsesMainSiteReceiveRatio(t *testing.T) { if pricing.SellerRatio != 43 { t.Fatalf("distribution receive seller_ratio = %v, want 43 from main-site ratio_config", pricing.SellerRatio) } - if pricing.SellerTotalPriceCent != 23260 { - t.Fatalf("seller_total_price_cent = %d, want 23260", pricing.SellerTotalPriceCent) + if pricing.SellerTotalPriceCent != 23255 { + t.Fatalf("seller_total_price_cent = %d, want 23255", pricing.SellerTotalPriceCent) } - if dto.PriceCent != 26320 { - t.Fatalf("listing PriceCent = %d, want 26320 from main-site sale price", dto.PriceCent) + if dto.PriceCent != 26315 { + t.Fatalf("listing PriceCent = %d, want 26315 from main-site sale price", dto.PriceCent) } if dto.DistributionSiteCode != "dist_ratio_1" { t.Fatalf("DistributionSiteCode = %s, want dist_ratio_1", dto.DistributionSiteCode) @@ -1173,8 +1173,8 @@ func TestCreateDistributionListingAppliesSiteReceiveCostDelta(t *testing.T) { if pricing.SellerRatio != 46 { t.Fatalf("seller_ratio = %v, want 46 after site cost delta 3 (R=43+3) under decoupled model", pricing.SellerRatio) } - if dto.PriceCent != 26320 { - t.Fatalf("listing PriceCent = %d, want 26320 (buyer price stays anchored to main-site R)", dto.PriceCent) + if dto.PriceCent != 26315 { + t.Fatalf("listing PriceCent = %d, want 26315 (buyer price stays anchored to main-site R)", dto.PriceCent) } } diff --git a/backend/internal/modules/listing/service_validation.go b/backend/internal/modules/listing/service_validation.go index 860a0c9..39b8985 100644 --- a/backend/internal/modules/listing/service_validation.go +++ b/backend/internal/modules/listing/service_validation.go @@ -26,7 +26,7 @@ func validateRequest(req CreateRequest, rules publishRules) error { if req.DepositAmountCent < 0 { return ErrInvalidDeposit } - if consumables := consumableValue(req.AssetSummary); consumables > 0 && req.DepositAmountCent <= yuanToCent(consumables) { + if consumables := consumableValueCent(req.AssetSummary, nil); consumables > 0 && req.DepositAmountCent <= consumables { return ErrDepositTooLow } if req.HafCoinAmount < 0 { diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index f4c0cb6..7d073b8 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -148,9 +148,11 @@ type PublishOptionGroup struct { } type PublishQuantityItem struct { - Key string `json:"key"` - Label string `json:"label"` - Price string `json:"price"` + Key string `json:"key"` + Label string `json:"label"` + Price string `json:"price"` + // PriceCent 是规范化单价(分);大于 0 时优先于 Price 字符串。 + PriceCent int64 `json:"price_cent,omitempty"` Placeholder string `json:"placeholder,omitempty"` } @@ -169,7 +171,9 @@ type PublishPriceConfig struct { } type PublishDepositRecommend struct { - BaseAmount float64 `json:"base_amount"` + BaseAmount float64 `json:"base_amount"` + // BaseAmountCent 是规范化基础押金(分);大于 0 时优先于 BaseAmount。 + BaseAmountCent int64 `json:"base_amount_cent,omitempty"` SkinGroupRules []PublishDepositSkinGroupRule `json:"skin_group_rules"` } @@ -177,6 +181,8 @@ type PublishDepositSkinGroupRule struct { GroupKey string `json:"group_key"` Label string `json:"label"` AmountPerItem float64 `json:"amount_per_item"` + // AmountPerItemCent 是规范化单件押金(分);大于 0 时优先于 AmountPerItem。 + AmountPerItemCent int64 `json:"amount_per_item_cent,omitempty"` } type PublishRatioConfig struct { @@ -188,6 +194,8 @@ type PublishRatioConfig struct { type PublishInsuranceBaseRatio struct { Insurance string `json:"insurance"` Ratio float64 `json:"ratio"` + // RatioBPS 是规范化的基准比例(基点,1% = 100 bps);大于 0 时优先于 Ratio。 + RatioBPS int64 `json:"ratio_bps,omitempty"` } type PublishRatioConfigItem struct { @@ -196,11 +204,15 @@ type PublishRatioConfigItem struct { Kind string `json:"kind"` GroupKey string `json:"group_key,omitempty"` MissingPenalty float64 `json:"missing_penalty"` + // MissingPenaltyBPS 是规范化的缺失扣减比例(基点);大于 0 时优先于 MissingPenalty。 + MissingPenaltyBPS int64 `json:"missing_penalty_bps,omitempty"` } type PublishCoinCorrection struct { ThresholdM float64 `json:"threshold_m"` Correction float64 `json:"correction"` + // CorrectionBPS 是规范化的哈夫币修正比例(基点);大于 0 时优先于 Correction。 + CorrectionBPS int64 `json:"correction_bps,omitempty"` } type PublishSalePriceConfig struct { @@ -212,10 +224,14 @@ type PublishSaleFixedMarkupRule struct { MinM float64 `json:"min_m"` MaxM float64 `json:"max_m"` MarkupAmount float64 `json:"markup_amount"` + // MarkupAmountCent 是规范化的固定加价(分);大于 0 时优先于 MarkupAmount。 + MarkupAmountCent int64 `json:"markup_amount_cent,omitempty"` } type PublishSaleRatioAdjustmentRule struct { MinM float64 `json:"min_m"` MaxM float64 `json:"max_m,omitempty"` RatioSubtract float64 `json:"ratio_subtract"` + // RatioSubtractBPS 是规范化的比例修正(基点);大于 0 时优先于 RatioSubtract。 + RatioSubtractBPS int64 `json:"ratio_subtract_bps,omitempty"` } diff --git a/docs/Phase2-卖家定价整数分与基点验收.md b/docs/Phase2-卖家定价整数分与基点验收.md new file mode 100644 index 0000000..d5bc73f --- /dev/null +++ b/docs/Phase2-卖家定价整数分与基点验收.md @@ -0,0 +1,60 @@ +# 卖家发布定价整数分与基点验收(第 2 步) + +> 日期:2026-10-30 +> 范围:卖家发布定价从 float64 元/比例迁移为 int64 分 + 整数基点,保留旧字段双读。 + +## 1. 规范字段 + +发布配置新增可选字段,旧 float 字段与 JSON 名保持不变: +- `price_cent` +- `base_amount_cent` +- `amount_per_item_cent` +- `ratio_bps` +- `missing_penalty_bps` +- `correction_bps` +- `markup_amount_cent` +- `ratio_subtract_bps` + +`price_breakdown` 新增: +- `seller_reference_ratio_bps`、`seller_ratio_bps`、`buyer_ratio_bps` +- `seller_coin_base_price_cent`、`seller_total_price_cent`、`consumable_price_cent` +- `buyer_coin_base_price_cent`、`buyer_total_price_cent`、`platform_markup_amount_cent` + +旧字段继续写入,由规范字段派生,历史商品与旧前端仍可读取。 + +## 2. 整数公式 + +- 纯币基础价:`HafCoinAmount * 100 / sellerRatioBPS`,向下取整。 +- 卖家总价:`sellerCoinBasePriceCent + consumablePriceCent`。 +- 比例修正:`sellerRatioBPS - ratioSubtractBPS`,买家基础价不低于卖家基础价。 +- 固定加价:`sellerCoinBasePriceCent + markupAmountCent`。 +- 买家总价:`buyerCoinBasePriceCent + consumablePriceCent`。 +- 买家展示比例:`HafCoinAmount * 100 / buyerCoinBasePriceCent`,仅作展示。 +- 平台加价:`buyerTotalPriceCent - anchorTotalPriceCent`。 + +配置优先读 `*_cent/*_bps`;缺失时只在配置边界把旧 float 换算一次,后续计算全部整数。 + +## 3. 前端 + +- `frontend/src/shared/utils/pricing.ts` 改为整数分/基点。 +- 卖家表单直接提交整数 `price_cent`、`deposit_amount_cent`。 +- 卖家列表/详情优先读新 cent/bps 字段,缺失回退旧字段。 +- 旧配置、历史 `asset_summary` 继续兼容。 + +## 4. 验证 + +| 检查 | 结果 | +|---|---| +| `go test ./pkg/money ./internal/modules/listing ./internal/modules/systemconfig ./internal/modules/order ./internal/modules/wallet` | 通过 | +| `cd frontend && npx tsc --noEmit` | 通过 | +| `cd frontend && npm run build` | 通过,主入口 519.27 kB / gzip 167.34 kB | +| `GET /api/listing-publish-options` | 200 | +| `GET /api/listing-sale-price-config` | 200 | +| `GET /api/seller/listings` 未带令牌 | 401,符合鉴权预期 | + +## 5. 风险 + +- 整数向下取整会使部分历史价格出现分级差异,交易金额以 `*_cent` 为准。 +- `buyer_ratio_bps` 由整数价格反推,可能与名义比例相差数个 bps。 +- 外部上传调价在未配置新 cent/bps 规则时仍走既有 float 路径;卖家发布主链路已整数化。 +- 新增直接写旧价格字段的路径时,必须同步规范字段。 diff --git a/frontend/src/features/seller/SellerListingDetailPage.tsx b/frontend/src/features/seller/SellerListingDetailPage.tsx index 3043841..62997df 100644 --- a/frontend/src/features/seller/SellerListingDetailPage.tsx +++ b/frontend/src/features/seller/SellerListingDetailPage.tsx @@ -85,6 +85,8 @@ function statusTag(meta: StatusMeta) { function sellerPriceCent(row: SellerListing) { const breakdown = recordValue(row.asset_summary?.price_breakdown) + const cent = numberValue(breakdown.seller_total_price_cent) + if (cent > 0) return Math.round(cent) const price = numberValue(breakdown.seller_total_price) return price > 0 ? Math.round(price * 100) : row.price_cent } @@ -103,11 +105,20 @@ function onlineTimeText(summary: Record) { return `${start || '—'} 至 ${end || '—'}` } -function ratioText(value: unknown) { - const ratio = Number(value) +function ratioText(bpsValue: unknown, legacyRatio: unknown) { + const bps = Number(bpsValue) + if (Number.isFinite(bps) && bps > 0) return `1:${(bps / 10000).toFixed(1)}` + const ratio = Number(legacyRatio) return Number.isFinite(ratio) && ratio > 0 ? `1:${ratio.toFixed(1)}` : '—' } +function moneyCent(centValue: unknown, legacyYuanValue: unknown) { + const cent = Number(centValue) + if (Number.isFinite(cent) && cent > 0) return Math.round(cent) + const yuan = Number(legacyYuanValue) + return Number.isFinite(yuan) ? Math.round(yuan * 100) : 0 +} + function screenshotsOf(row: SellerListing) { const summary = row.asset_summary ?? {} const groups = recordValue(summary.screenshot_groups) @@ -423,27 +434,42 @@ export function SellerListingDetailPage() {
参考比例 - {ratioText(breakdown.seller_reference_ratio)} + + {ratioText(breakdown.seller_reference_ratio_bps, breakdown.seller_reference_ratio)} +
卖家比例 - {ratioText(breakdown.seller_ratio)} + {ratioText(breakdown.seller_ratio_bps, breakdown.seller_ratio)}
纯币基础价 - {formatMoney(numberValue(breakdown.seller_coin_base_price) * 100)} + + {formatMoney( + moneyCent(breakdown.seller_coin_base_price_cent, breakdown.seller_coin_base_price) + )} +
消耗品价值 - {formatMoney(numberValue(breakdown.consumable_price) * 100)} + + {formatMoney(moneyCent(breakdown.consumable_price_cent, breakdown.consumable_price))} +
买家比例 - {ratioText(breakdown.buyer_ratio)} + {ratioText(breakdown.buyer_ratio_bps, breakdown.buyer_ratio)}
平台加价 - {formatMoney(numberValue(breakdown.platform_markup_amount) * 100)} + + {formatMoney( + moneyCent( + breakdown.platform_markup_amount_cent, + breakdown.platform_markup_amount + ) + )} +
diff --git a/frontend/src/features/seller/SellerListingFormPage.tsx b/frontend/src/features/seller/SellerListingFormPage.tsx index d16d633..4ed5464 100644 --- a/frontend/src/features/seller/SellerListingFormPage.tsx +++ b/frontend/src/features/seller/SellerListingFormPage.tsx @@ -36,18 +36,23 @@ import { uploadFile } from '@/shared/api/files' import { useSessionStore } from '@/stores/session' import { dailyLossOptions, - calculateConsumablePrice, - calculateDailyLossRatioAdjustment, - calculatePlatformPricing, - calculateRecommendedDeposit, - calculateSellerReferenceRatio, buildDepositBreakdownItems, - formatNumber, + calculateConsumablePriceCent, + calculateDailyLossRatioAdjustmentBPS, + calculatePlatformPricingCent, + calculateRecommendedDepositCent, + calculateSellerCoinBasePriceCent, + calculateSellerReferenceRatioBPS, + bpsToRatio, + formatRatioBPS, + hafCoinUnitsToM, hasAcceleratedSaleRatioInput, isQuantityItemDisabledForInsurance, - readFinalSaleRatio, - roundMoney, - roundRatio, + maxAcceleratedSaleRatioBPS, + parseHafCoinMToUnits, + parseYuanToCent, + ratioToBPS, + readFinalSaleRatioBPS, } from '@/shared/utils/pricing' import { formatMoney } from '@/shared/utils/format' import { confirmModal } from '@/shared/utils/confirm' @@ -69,7 +74,6 @@ import { } from './options' import { defaultPublishForm, type PublishForm } from './types' -const COIN_UNIT = 1_000_000 const MAX_SCREENSHOTS = 9 const SCAN_ONLY_MARK = '微信' const SCAN_LOGIN_METHOD = '扫码' @@ -108,6 +112,16 @@ function numberOrDefault(value: unknown, fallback: number) { return Number.isFinite(parsed) ? parsed : fallback } +function ratioInputFromBreakdown( + breakdown: Record, + bpsKey: string, + ratioKey: string +): number | '' { + const bps = Number(breakdown[bpsKey]) + if (Number.isFinite(bps) && bps > 0) return bps / 10000 + return numberOrEmpty(breakdown[ratioKey]) +} + function toTimeValue(value: string): Dayjs | null { const match = /^(\d{1,2}):(\d{2})/.exec(value.trim()) if (!match) return null @@ -267,12 +281,16 @@ export function SellerListingFormPage() { setForm({ server_region: listing.server_region || '', face_owner: stringValue(summary.face_owner), - haf_coin_amount: listing.haf_coin_amount > 0 ? listing.haf_coin_amount / COIN_UNIT : '', + haf_coin_amount: listing.haf_coin_amount > 0 ? hafCoinUnitsToM(listing.haf_coin_amount) : '', rank_level: listing.rank_level || '', secret_kd: stringValue(summary.secret_kd), fire_level: numberOrEmpty(summary.fire_level), daily_loss_m: numberOrDefault(summary.daily_loss_m, 10), - accelerated_sale_ratio: numberOrEmpty(breakdown.accelerated_sale_ratio), + accelerated_sale_ratio: ratioInputFromBreakdown( + breakdown, + 'accelerated_sale_ratio_bps', + 'accelerated_sale_ratio' + ), season_insurance: stringValue(summary.season_insurance), stamina_level: stringValue(summary.stamina_level), load_level: stringValue(summary.load_level), @@ -350,44 +368,43 @@ export function SellerListingFormPage() { .filter(Boolean).length const pricing = useMemo(() => { - const coinMAmount = Number(form.haf_coin_amount || 0) - const coinWanAmount = coinMAmount * 100 - const dailyLossMAmount = Number(form.daily_loss_m || 10) - const dailyLossRatioAdjustment = calculateDailyLossRatioAdjustment(dailyLossMAmount) - const referenceRatio = calculateSellerReferenceRatio({ - coinMAmount, + const hafCoinAmount = parseHafCoinMToUnits(form.haf_coin_amount === '' ? 0 : form.haf_coin_amount) + const coinMAmount = hafCoinUnitsToM(hafCoinAmount) + const dailyLossMAmount = Math.trunc(Number(form.daily_loss_m || 10)) + const dailyLossRatioAdjustmentBPS = calculateDailyLossRatioAdjustmentBPS(dailyLossMAmount) + const referenceRatioBPS = calculateSellerReferenceRatioBPS({ + hafCoinAmount, form, ratioConfig: options.ratio_config, skinGroups: options.skin_groups, selectedSkins, levelOptions: options.level_options, - dailyLossRatioAdjustment, + dailyLossRatioAdjustmentBPS, }) - const maxAcceleratedSaleRatio = referenceRatio > 0 ? roundRatio(referenceRatio + 10) : 0 - const finalRatio = readFinalSaleRatio( - referenceRatio, + const maxRatioBPS = maxAcceleratedSaleRatioBPS(referenceRatioBPS) + const finalRatioBPS = readFinalSaleRatioBPS( + referenceRatioBPS, form.accelerated_sale_ratio, - maxAcceleratedSaleRatio + maxRatioBPS ) - const coinBasePrice = finalRatio > 0 ? roundMoney(coinWanAmount / finalRatio) : 0 - const consumablePrice = calculateConsumablePrice({ + const coinBasePriceCent = + finalRatioBPS > 0 ? calculateSellerCoinBasePriceCent(hafCoinAmount, finalRatioBPS) : 0 + const consumablePriceCent = calculateConsumablePriceCent({ quantityItems: options.quantity_items, quantityValues, quantityModes, seasonInsurance: form.season_insurance, }) - const sellerPrice = - finalRatio > 0 ? roundMoney(coinBasePrice + consumablePrice) : 0 - const platformPricing = calculatePlatformPricing({ - coinMAmount, - coinWanAmount, - sellerRatio: finalRatio, - sellerCoinBasePrice: coinBasePrice, - sellerTotalPrice: sellerPrice, - consumablePrice, + const sellerPriceCent = finalRatioBPS > 0 ? coinBasePriceCent + consumablePriceCent : 0 + const platformPricing = calculatePlatformPricingCent({ + hafCoinAmount, + sellerRatioBPS: finalRatioBPS, + sellerCoinBasePriceCent: coinBasePriceCent, + sellerTotalPriceCent: sellerPriceCent, + consumablePriceCent, salePriceConfig, }) - const recommendedDeposit = calculateRecommendedDeposit({ + const recommendedDepositCent = calculateRecommendedDepositCent({ depositRecommendConfig: options.deposit_recommend_config, skinGroups: options.skin_groups, selectedSkins, @@ -398,29 +415,27 @@ export function SellerListingFormPage() { selectedSkins, }) return { + hafCoinAmount, coinMAmount, - coinWanAmount, dailyLossMAmount, - dailyLossRatioAdjustment, - referenceRatio, - maxAcceleratedSaleRatio, - finalRatio, - coinBasePrice, - consumablePrice, - sellerPrice, + dailyLossRatioAdjustmentBPS, + referenceRatioBPS, + maxAcceleratedSaleRatioBPS: maxRatioBPS, + finalRatioBPS, + coinBasePriceCent, + consumablePriceCent, + sellerPriceCent, platformPricing, - finalPrice: platformPricing.buyerTotalPrice, - recommendedDeposit, + finalPriceCent: platformPricing.buyerTotalPriceCent, + recommendedDepositCent, depositBreakdown, } }, [form, options, salePriceConfig, quantityValues, quantityModes, selectedSkins]) - const depositAmount = Number(form.depositAmountYuan) + const depositAmountCent = parseYuanToCent(form.depositAmountYuan === '' ? '' : form.depositAmountYuan) const depositBelowRecommended = - pricing.recommendedDeposit > 0 && - (form.depositAmountYuan === '' || - !Number.isFinite(depositAmount) || - depositAmount < pricing.recommendedDeposit) + pricing.recommendedDepositCent > 0 && + (!Number.isFinite(depositAmountCent) || depositAmountCent < pricing.recommendedDepositCent) function isQuantityDisabled(item: PublishQuantityItem) { return isQuantityItemDisabledForInsurance(item, form.season_insurance) @@ -535,27 +550,38 @@ export function SellerListingFormPage() { } function buildAssetSummary(): Record { + const acceleratedRatioBPS = hasAcceleratedSaleRatioInput(form.accelerated_sale_ratio) + ? ratioToBPS(form.accelerated_sale_ratio as number) + : pricing.referenceRatioBPS + const platformPricing = pricing.platformPricing return { face_owner: form.face_owner, secret_kd: form.secret_kd, fire_level: Number(form.fire_level), daily_loss_m: pricing.dailyLossMAmount, - publish_ratio: pricing.platformPricing.buyerRatio, + publish_ratio: bpsToRatio(platformPricing.buyerRatioBPS), price_breakdown: { - seller_reference_ratio: pricing.referenceRatio, - seller_ratio: pricing.finalRatio, - seller_coin_base_price: pricing.coinBasePrice, - seller_total_price: pricing.sellerPrice, - consumable_price: pricing.consumablePrice, - daily_loss_ratio_adjustment: pricing.dailyLossRatioAdjustment, - accelerated_sale_ratio: hasAcceleratedSaleRatioInput(form.accelerated_sale_ratio) - ? Number(form.accelerated_sale_ratio) - : pricing.referenceRatio, - buyer_coin_base_price: pricing.platformPricing.buyerCoinBasePrice, - buyer_total_price: pricing.finalPrice, - buyer_ratio: pricing.platformPricing.buyerRatio, - platform_markup_amount: pricing.platformPricing.platformMarkupAmount, - platform_rule_type: pricing.platformPricing.ruleType, + seller_reference_ratio_bps: pricing.referenceRatioBPS, + seller_ratio_bps: pricing.finalRatioBPS, + buyer_ratio_bps: platformPricing.buyerRatioBPS, + seller_coin_base_price_cent: pricing.coinBasePriceCent, + seller_total_price_cent: pricing.sellerPriceCent, + consumable_price_cent: pricing.consumablePriceCent, + buyer_coin_base_price_cent: platformPricing.buyerCoinBasePriceCent, + buyer_total_price_cent: pricing.finalPriceCent, + platform_markup_amount_cent: platformPricing.platformMarkupAmountCent, + seller_reference_ratio: bpsToRatio(pricing.referenceRatioBPS), + seller_ratio: bpsToRatio(pricing.finalRatioBPS), + seller_coin_base_price: pricing.coinBasePriceCent / 100, + seller_total_price: pricing.sellerPriceCent / 100, + consumable_price: pricing.consumablePriceCent / 100, + daily_loss_ratio_adjustment: bpsToRatio(pricing.dailyLossRatioAdjustmentBPS), + accelerated_sale_ratio: bpsToRatio(acceleratedRatioBPS), + buyer_coin_base_price: platformPricing.buyerCoinBasePriceCent / 100, + buyer_total_price: pricing.finalPriceCent / 100, + buyer_ratio: bpsToRatio(platformPricing.buyerRatioBPS), + platform_markup_amount: platformPricing.platformMarkupAmountCent / 100, + platform_rule_type: platformPricing.ruleType, }, season_insurance: form.season_insurance, stamina_level: form.stamina_level, @@ -565,6 +591,7 @@ export function SellerListingFormPage() { key: item.key, label: item.label, price: item.price, + price_cent: item.price_cent, quantity: isQuantityDisabled(item) ? 0 : Number(quantityValues[item.key] || 0), mode: isQuantityDisabled(item) ? '赠送' : quantityModes[item.key] || '收费', })) @@ -618,27 +645,36 @@ export function SellerListingFormPage() { if (options.next_season_pass_enabled && !form.next_season_pass) { return '请选择下赛季是否购买通行证' } - if (form.depositAmountYuan === '' || Number(form.depositAmountYuan) < 0) { + if (form.depositAmountYuan === '' || !Number.isFinite(depositAmountCent)) { return '请填写押金' } - if (!Number.isFinite(Number(form.depositAmountYuan))) return '押金格式不正确' - if (pricing.recommendedDeposit > 0 && Number(form.depositAmountYuan) < pricing.recommendedDeposit) { - return `押金不能低于智能推荐 ¥${pricing.recommendedDeposit}` + if (depositAmountCent < 0) return '押金格式不正确' + if ( + pricing.recommendedDepositCent > 0 && + depositAmountCent < pricing.recommendedDepositCent + ) { + return `押金不能低于智能推荐 ${formatMoney(pricing.recommendedDepositCent)}` } - if (pricing.consumablePrice > 0 && Number(form.depositAmountYuan) <= pricing.consumablePrice) { - return `押金必须大于额外消耗品总价值 ¥${pricing.consumablePrice}` + if ( + pricing.consumablePriceCent > 0 && + depositAmountCent <= pricing.consumablePriceCent + ) { + return `押金必须大于额外消耗品总价值 ${formatMoney(pricing.consumablePriceCent)}` } - if (pricing.finalPrice <= 0) { + if (pricing.finalPriceCent <= 0) { return '暂无法计算买家价,请确认后台发布选项与价格规则已配置' } if (hasAcceleratedSaleRatioInput(form.accelerated_sale_ratio)) { - const ratio = Number(form.accelerated_sale_ratio) - if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确' - if (pricing.referenceRatio > 0 && ratio < pricing.referenceRatio) { - return `加速出售比例不能低于参考比例 1:${formatNumber(pricing.referenceRatio)}` + const ratioBPS = ratioToBPS(form.accelerated_sale_ratio as number) + if (ratioBPS <= 0) return '加速出售比例格式不正确' + if (pricing.referenceRatioBPS > 0 && ratioBPS < pricing.referenceRatioBPS) { + return `加速出售比例不能低于参考比例 1:${formatRatioBPS(pricing.referenceRatioBPS)}` } - if (pricing.maxAcceleratedSaleRatio > 0 && ratio > pricing.maxAcceleratedSaleRatio) { - return `加速出售比例不能超过 1:${formatNumber(pricing.maxAcceleratedSaleRatio)}` + if ( + pricing.maxAcceleratedSaleRatioBPS > 0 && + ratioBPS > pricing.maxAcceleratedSaleRatioBPS + ) { + return `加速出售比例不能超过 1:${formatRatioBPS(pricing.maxAcceleratedSaleRatioBPS)}` } } if (screenshotUrls.length === 0) return '请至少上传 1 张账号截图' @@ -674,11 +710,11 @@ export function SellerListingFormPage() { server_region: form.server_region, login_platform: form.login_method, rank_level: form.rank_level, - haf_coin_amount: Math.round(pricing.coinMAmount * COIN_UNIT), + haf_coin_amount: pricing.hafCoinAmount, asset_summary: buildAssetSummary(), screenshot_urls: screenshotUrls, - price_cent: Math.round(pricing.finalPrice * 100), - deposit_amount_cent: Math.round(Number(form.depositAmountYuan) * 100), + price_cent: pricing.finalPriceCent, + deposit_amount_cent: depositAmountCent, agreed_virtual_asset_sale: virtualAgreement, agreed_seller_agreement: sellerAgreement, } @@ -1211,10 +1247,12 @@ export function SellerListingFormPage() { className="seller-number-input" /> {options.price_config.deposit_hint} @@ -1222,7 +1260,7 @@ export function SellerListingFormPage() { {pricing.depositBreakdown.map(item => ( {item.label} - {item.count > 1 ? ` x${item.count}` : ''} ¥{item.amount} + {item.count > 1 ? ` x${item.count}` : ''} {formatMoney(item.amountCent)} ))} @@ -1230,7 +1268,7 @@ export function SellerListingFormPage() { ) : null} @@ -1245,31 +1283,34 @@ export function SellerListingFormPage() { form.accelerated_sale_ratio === '' ? null : form.accelerated_sale_ratio } placeholder={ - pricing.referenceRatio > 0 - ? `默认 ${formatNumber(pricing.referenceRatio)},最高 ${formatNumber(pricing.maxAcceleratedSaleRatio)}` + pricing.referenceRatioBPS > 0 + ? `默认 ${formatRatioBPS(pricing.referenceRatioBPS)},最高 ${formatRatioBPS(pricing.maxAcceleratedSaleRatioBPS)}` : '完善资料后自动生成可设置范围' } onChange={value => updateForm('accelerated_sale_ratio', value ?? '')} className="seller-number-input" /> - {pricing.referenceRatio > 0 - ? `可设置 1:${formatNumber(pricing.referenceRatio)} ~ 1:${formatNumber(pricing.maxAcceleratedSaleRatio)},比例越高出租越快。` + {pricing.referenceRatioBPS > 0 + ? `可设置 1:${formatRatioBPS(pricing.referenceRatioBPS)} ~ 1:${formatRatioBPS(pricing.maxAcceleratedSaleRatioBPS)},比例越高出租越快。` : options.price_config.ratio_description || '请先完善赛季保险、体力和负重。'} @@ -1334,7 +1375,7 @@ export function SellerListingFormPage() { BUYER PRICE · 买家挂牌价
- {formatMoney(Math.round(pricing.finalPrice * 100))} + {formatMoney(pricing.finalPriceCent)} {pricing.platformPricing.ruleType === 'fixed_markup' ? '固定加价' @@ -1347,40 +1388,40 @@ export function SellerListingFormPage() {
参考比例 - {pricing.referenceRatio > 0 - ? `1:${formatNumber(pricing.referenceRatio)}` + {pricing.referenceRatioBPS > 0 + ? `1:${formatRatioBPS(pricing.referenceRatioBPS)}` : '--'}
卖家比例 - {pricing.finalRatio > 0 ? `1:${formatNumber(pricing.finalRatio)}` : '--'} + {pricing.finalRatioBPS > 0 ? `1:${formatRatioBPS(pricing.finalRatioBPS)}` : '--'}
买家比例 - {pricing.platformPricing.buyerRatio > 0 - ? `1:${formatNumber(pricing.platformPricing.buyerRatio)}` + {pricing.platformPricing.buyerRatioBPS > 0 + ? `1:${formatRatioBPS(pricing.platformPricing.buyerRatioBPS)}` : '--'}
纯币基础价 - {formatMoney(Math.round(pricing.coinBasePrice * 100))} + {formatMoney(pricing.coinBasePriceCent)}
消耗品价值 - {formatMoney(Math.round(pricing.consumablePrice * 100))} + {formatMoney(pricing.consumablePriceCent)}
卖家到手价 - {formatMoney(Math.round(pricing.sellerPrice * 100))} + {formatMoney(pricing.sellerPriceCent)}
推荐押金 - {formatMoney(Math.round(pricing.recommendedDeposit * 100))} + {formatMoney(pricing.recommendedDepositCent)}
diff --git a/frontend/src/features/seller/SellerListingsPage.tsx b/frontend/src/features/seller/SellerListingsPage.tsx index 0141b9c..505e0b3 100644 --- a/frontend/src/features/seller/SellerListingsPage.tsx +++ b/frontend/src/features/seller/SellerListingsPage.tsx @@ -73,7 +73,10 @@ function sellerReferencePrice(row: SellerListing) { const summary = row.asset_summary ?? {} const breakdown = summary.price_breakdown if (breakdown && typeof breakdown === 'object') { - const value = Number((breakdown as Record).seller_total_price) + const record = breakdown as Record + const cent = Number(record.seller_total_price_cent) + if (Number.isFinite(cent) && cent > 0) return Math.round(cent) + const value = Number(record.seller_total_price) if (Number.isFinite(value) && value > 0) return Math.round(value * 100) } return row.price_cent diff --git a/frontend/src/features/seller/options.ts b/frontend/src/features/seller/options.ts index b9eb4e7..cb3f4d1 100644 --- a/frontend/src/features/seller/options.ts +++ b/frontend/src/features/seller/options.ts @@ -19,6 +19,8 @@ export interface PublishQuantityItem { key: QuantityKey label: string price: string + /** 规范化单价(分);大于 0 时优先于 price 字符串。 */ + price_cent?: number placeholder?: string } @@ -40,16 +42,22 @@ export interface PublishDepositSkinGroupRule { group_key: string label: string amount_per_item: number + /** 规范化单件押金(分);大于 0 时优先于 amount_per_item。 */ + amount_per_item_cent?: number } export interface PublishDepositRecommendConfig { base_amount: number + /** 规范化基础押金(分);大于 0 时优先于 base_amount。 */ + base_amount_cent?: number skin_group_rules: PublishDepositSkinGroupRule[] } export interface PublishInsuranceBaseRatio { insurance: string ratio: number + /** 规范化比例(基点);大于 0 时优先于 ratio。 */ + ratio_bps?: number } export interface PublishRatioConfigItem { @@ -58,23 +66,31 @@ export interface PublishRatioConfigItem { kind: string group_key?: string missing_penalty: number + /** 规范化缺失扣减(基点);大于 0 时优先于 missing_penalty。 */ + missing_penalty_bps?: number } export interface PublishCoinCorrection { threshold_m: number correction: number + /** 规范化哈夫币修正(基点);大于 0 时优先于 correction。 */ + correction_bps?: number } export interface PublishSaleFixedMarkupRule { min_m: number max_m: number markup_amount: number + /** 规范化固定加价(分);大于 0 时优先于 markup_amount。 */ + markup_amount_cent?: number } export interface PublishSaleRatioAdjustmentRule { min_m: number max_m: number ratio_subtract: number + /** 规范化比例修正(基点);大于 0 时优先于 ratio_subtract。 */ + ratio_subtract_bps?: number } export interface PublishRatioConfig { @@ -289,6 +305,7 @@ function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] { key: typeof row.key === 'string' ? row.key.trim() : '', label: typeof row.label === 'string' ? row.label.trim() : '', price: typeof row.price === 'string' ? row.price.trim() : '', + price_cent: readOptionalPositiveInteger(row.price_cent), placeholder: typeof row.placeholder === 'string' ? row.placeholder.trim() : undefined, } }) @@ -330,11 +347,12 @@ function normalizeDepositRecommendConfig(value?: unknown): PublishDepositRecomme const row = isRecord(value) ? value : {} const config = { base_amount: readNumber(row.base_amount), + base_amount_cent: readOptionalPositiveInteger(row.base_amount_cent), skin_group_rules: normalizeDepositSkinGroupRules( Array.isArray(row.skin_group_rules) ? row.skin_group_rules : [] ), } - if (config.base_amount <= 0) { + if (config.base_amount <= 0 && !config.base_amount_cent) { config.base_amount = emptyListingPublishOptions.deposit_recommend_config.base_amount } if (config.skin_group_rules.length === 0) { @@ -353,6 +371,7 @@ function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGr group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '', label: typeof row.label === 'string' ? row.label.trim() : '', amount_per_item: readNumber(row.amount_per_item), + amount_per_item_cent: readOptionalPositiveInteger(row.amount_per_item_cent), } }) .filter(item => item.group_key && item.label && item.amount_per_item >= 0) @@ -401,9 +420,10 @@ function normalizeInsuranceBaseRatios(values: unknown[]): PublishInsuranceBaseRa return { insurance: typeof row.insurance === 'string' ? row.insurance.trim() : '', ratio: readNumber(row.ratio), + ratio_bps: readOptionalPositiveInteger(row.ratio_bps), } }) - .filter(item => item.insurance && item.ratio > 0) + .filter(item => item.insurance && (item.ratio > 0 || Boolean(item.ratio_bps))) } function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] { @@ -416,6 +436,7 @@ function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] kind: typeof row.kind === 'string' ? row.kind.trim() : '', group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '', missing_penalty: readNumber(row.missing_penalty), + missing_penalty_bps: readOptionalPositiveInteger(row.missing_penalty_bps), } }) .filter(item => item.key && item.label && item.kind) @@ -428,9 +449,12 @@ function normalizeCoinCorrections(values: unknown[]): PublishCoinCorrection[] { return { threshold_m: readNumber(row.threshold_m), correction: readNumber(row.correction), + correction_bps: readOptionalPositiveInteger(row.correction_bps), } }) - .filter(item => item.threshold_m >= 0 && item.correction > 0) + .filter( + item => item.threshold_m >= 0 && (item.correction > 0 || Boolean(item.correction_bps)) + ) } function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarkupRule[] { @@ -441,9 +465,15 @@ function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarku min_m: readNumber(row.min_m), max_m: readNumber(row.max_m), markup_amount: readNumber(row.markup_amount), + markup_amount_cent: readOptionalPositiveInteger(row.markup_amount_cent), } }) - .filter(item => item.min_m >= 0 && item.max_m >= item.min_m && item.markup_amount >= 0) + .filter( + item => + item.min_m >= 0 && + (item.max_m === 0 || item.max_m >= item.min_m) && + item.markup_amount >= 0 + ) } function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioAdjustmentRule[] { @@ -454,6 +484,7 @@ function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioA min_m: readNumber(row.min_m), max_m: readNumber(row.max_m), ratio_subtract: readNumber(row.ratio_subtract), + ratio_subtract_bps: readOptionalPositiveInteger(row.ratio_subtract_bps), } }) .filter( @@ -469,6 +500,11 @@ function readNumber(value: unknown) { return Number.isFinite(parsed) ? parsed : 0 } +function readOptionalPositiveInteger(value: unknown) { + const parsed = Math.trunc(readNumber(value)) + return parsed > 0 ? parsed : undefined +} + function readPositiveInteger(value: unknown, fallback: number) { const parsed = Math.trunc(readNumber(value)) return parsed > 0 ? parsed : fallback diff --git a/frontend/src/features/seller/types.ts b/frontend/src/features/seller/types.ts index bb63012..8db0246 100644 --- a/frontend/src/features/seller/types.ts +++ b/frontend/src/features/seller/types.ts @@ -43,7 +43,7 @@ export interface PublishDraft { export interface DepositBreakdownItem { label: string - amount: number + amountCent: number count: number } diff --git a/frontend/src/shared/utils/pricing.ts b/frontend/src/shared/utils/pricing.ts index c6f9e02..8762666 100644 --- a/frontend/src/shared/utils/pricing.ts +++ b/frontend/src/shared/utils/pricing.ts @@ -1,58 +1,97 @@ import type { ChargeMode, - ListingPublishOptions, PublishDepositRecommendConfig, PublishOptionGroup, PublishQuantityItem, - PublishRatioConfig, PublishSalePriceConfig, } from '@/features/seller/options' -import type { - DepositBreakdownItem, - PublishForm, - PublishPlatformPricing, -} from '@/features/seller/types' +import type { DepositBreakdownItem, PublishForm } from '@/features/seller/types' export const dailyLossOptions = [10, 20, 30, 40, 50] -/** - * 将金额四舍五入到角精度(0.1 元)。 - * 统一全项目金额处理规范。 - */ -export function roundMoney(value: number) { - return Math.round(value * 10) / 10 -} +const BPS_SCALE = 10_000 +const HAF_COIN_UNIT = 1_000_000 +const MAX_ACCELERATED_RATIO_BPS_INCREMENT = 10 * BPS_SCALE /** - * 将比例四舍五入到一位小数。 + * 把十进制字符串精确换算为缩放整数,全程不使用浮点乘法。 + * 超出目标精度时按第三位数字四舍五入(仅用于用户输入边界)。 */ -export function roundRatio(value: number) { - return Math.round(value * 10) / 10 +function decimalToScaled(value: number | string, scale: number): number { + const text = typeof value === 'number' ? String(value) : value.trim() + const match = /^([+-]?)(\d*)(?:\.(\d*))?$/.exec(text) + if (!match || (!match[2] && !match[3])) return Number.NaN + const sign = match[1] === '-' ? -1 : 1 + const whole = Number(match[2] || '0') + if (!Number.isSafeInteger(whole)) return Number.NaN + const precision = String(scale).length - 1 + const fractionText = (match[3] || '').padEnd(precision + 1, '0') + let fraction = Number(fractionText.slice(0, precision) || '0') + const nextDigit = Number(fractionText[precision] || '0') + if (nextDigit >= 5) fraction += 1 + return sign * (whole * scale + fraction) } -/** - * 格式化数字为字符串(保留 1 位小数)。 - */ -export function formatNumber(value: number) { - const rounded = Math.round(value * 10) / 10 - return rounded.toFixed(1) +/** 元字符串/数字 -> 分。非法或负数返回 NaN。 */ +export function parseYuanToCent(value: number | string): number { + const cent = decimalToScaled(value, 100) + if (!Number.isFinite(cent) || cent < 0) return Number.NaN + return cent } -/** 解析「8元/个」「8元」「8元 / 3个」形式的资源单价。 */ -export function readUnitPrice(priceText: string) { +/** 哈夫币 M 输入 -> 整数微单位。非法或负数返回 0。 */ +export function parseHafCoinMToUnits(value: number | string): number { + const units = decimalToScaled(value, HAF_COIN_UNIT) + if (!Number.isFinite(units) || units < 0) return 0 + return units +} + +/** 整数微单位 -> M,仅用于展示/回填表单。 */ +export function hafCoinUnitsToM(value: number): number { + return Number.isFinite(value) ? value / HAF_COIN_UNIT : 0 +} + +/** 比例输入 -> 基点(1.0 = 10000 bps)。非法或负数返回 0。 */ +export function ratioToBPS(value: number | string): number { + const bps = decimalToScaled(value, BPS_SCALE) + if (!Number.isFinite(bps) || bps < 0) return 0 + return bps +} + +/** 基点 -> 比例浮点值,仅用于旧字段/展示兼容。 */ +export function bpsToRatio(bps: number): number { + return Number.isFinite(bps) ? bps / BPS_SCALE : 0 +} + +/** 比例展示:保留一位小数。 */ +export function formatRatioBPS(bps: number): string { + if (!Number.isFinite(bps) || bps <= 0) return '0.0' + const tenths = Math.round(bps / 1000) + return (tenths / 10).toFixed(1) +} + +/** 解析「8元/个」「8元」「1元/2个」形式的资源单价为分。 */ +export function parseUnitPriceCent(priceText: string): number { const normalized = priceText.replace(/,/g, ',').trim() - const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/) + const fractionMatch = /(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/.exec(normalized) if (fractionMatch) { - const amount = Number(fractionMatch[1]) - const count = Number(fractionMatch[2]) - return count > 0 ? amount / count : 0 + const amountCent = parseYuanToCent(fractionMatch[1]) + const countText = fractionMatch[2] + if (Number.isFinite(amountCent) && /^\d+$/.test(countText)) { + const count = Number(countText) + if (count > 0) return Math.floor(amountCent / count) + } } - const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/) - return singleMatch ? Number(singleMatch[1]) : 0 + const singleMatch = /(\d+(?:\.\d+)?)\s*元/.exec(normalized) + if (!singleMatch) return 0 + const amountCent = parseYuanToCent(singleMatch[1]) + return Number.isFinite(amountCent) ? amountCent : 0 } -export function calculateDailyLossRatioAdjustment(dailyLossMAmount: number) { - return Math.min(Math.max(Math.floor((dailyLossMAmount - 10) / 10), 0), 4) +export function calculateDailyLossRatioAdjustmentBPS(dailyLossMAmount: number) { + const dailyLoss = Math.trunc(Number(dailyLossMAmount) || 0) + const points = Math.min(Math.max(Math.floor((dailyLoss - 10) / 10), 0), 4) + return points * BPS_SCALE } export function isGridCardQuantityItem(item: { key: string; label: string }) { @@ -66,35 +105,60 @@ export function isQuantityItemDisabledForInsurance( return seasonInsurance === '3*3' && isGridCardQuantityItem(item) } -export function calculateConsumablePrice(options: { +export function quantityItemUnitPriceCent(item: PublishQuantityItem): number { + if (Number.isFinite(item.price_cent) && Number(item.price_cent) > 0) { + return Math.trunc(Number(item.price_cent)) + } + return parseUnitPriceCent(item.price) +} + +export function calculateConsumablePriceCent(options: { quantityItems: PublishQuantityItem[] quantityValues: Record quantityModes: Record seasonInsurance: string }) { - const total = options.quantityItems.reduce((sum, item) => { - const quantity = Number(options.quantityValues[item.key] || 0) + return options.quantityItems.reduce((sum, item) => { + const quantity = Math.trunc(Number(options.quantityValues[item.key] || 0)) const mode = options.quantityModes[item.key] || '收费' if (isQuantityItemDisabledForInsurance(item, options.seasonInsurance)) return sum if (quantity <= 0 || mode !== '收费') return sum - return sum + quantity * readUnitPrice(item.price) + return sum + quantity * quantityItemUnitPriceCent(item) }, 0) - return roundMoney(total) } -export function calculateRecommendedDeposit(options: { +export function depositRuleAmountCent(rule: { + amount_per_item: number + amount_per_item_cent?: number +}) { + if (Number.isFinite(rule.amount_per_item_cent) && Number(rule.amount_per_item_cent) > 0) { + return Math.trunc(Number(rule.amount_per_item_cent)) + } + const cent = parseYuanToCent(rule.amount_per_item) + return Number.isFinite(cent) ? cent : 0 +} + +export function depositBaseAmountCent(config: PublishDepositRecommendConfig) { + if (Number.isFinite(config.base_amount_cent) && Number(config.base_amount_cent) > 0) { + return Math.trunc(Number(config.base_amount_cent)) + } + const cent = parseYuanToCent(config.base_amount) + return Number.isFinite(cent) ? cent : 0 +} + +export function calculateRecommendedDepositCent(options: { depositRecommendConfig: PublishDepositRecommendConfig skinGroups: PublishOptionGroup[] selectedSkins: string[] }) { - const baseAmount = Number(options.depositRecommendConfig.base_amount || 0) - const skinAmount = options.depositRecommendConfig.skin_group_rules.reduce((sum, rule) => { + const baseCent = depositBaseAmountCent(options.depositRecommendConfig) + const skinCent = options.depositRecommendConfig.skin_group_rules.reduce((sum, rule) => { const group = options.skinGroups.find(item => item.key === rule.group_key) if (!group) return sum const selectedCount = group.options.filter(skin => options.selectedSkins.includes(skin)).length - return sum + selectedCount * Number(rule.amount_per_item || 0) + return sum + selectedCount * depositRuleAmountCent(rule) }, 0) - return roundMoney(baseAmount + skinAmount) + return baseCent + skinCent } export function buildDepositBreakdownItems(options: { @@ -105,7 +169,7 @@ export function buildDepositBreakdownItems(options: { const items: DepositBreakdownItem[] = [ { label: '基础押金', - amount: Number(options.depositRecommendConfig.base_amount || 0), + amountCent: depositBaseAmountCent(options.depositRecommendConfig), count: 1, }, ] @@ -116,144 +180,174 @@ export function buildDepositBreakdownItems(options: { if (count <= 0) continue items.push({ label: rule.label, - amount: Number(rule.amount_per_item || 0) * count, + amountCent: depositRuleAmountCent(rule) * count, count, }) } return items } -export function calculateSellerReferenceRatio(options: { - coinMAmount: number +export function calculateSellerReferenceRatioBPS(options: { + hafCoinAmount: number form: PublishForm - ratioConfig: PublishRatioConfig + ratioConfig: { + insurance_base_ratios: Array<{ insurance: string; ratio: number; ratio_bps?: number }> + config_items: Array<{ + kind: string + group_key?: string + missing_penalty: number + missing_penalty_bps?: number + }> + coin_corrections: Array<{ + threshold_m: number + correction: number + correction_bps?: number + }> + } skinGroups: PublishOptionGroup[] selectedSkins: string[] levelOptions: string[] - dailyLossRatioAdjustment: number + dailyLossRatioAdjustmentBPS: number }) { - const { coinMAmount, form, ratioConfig } = options - if (coinMAmount <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level) { + const { hafCoinAmount, form, ratioConfig } = options + if (hafCoinAmount <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level) { return 0 } - const baseRatio = getInsuranceBaseRatio(ratioConfig, form.season_insurance) - if (baseRatio <= 0) return 0 + const baseRatioBPS = getInsuranceBaseRatioBPS(ratioConfig, form.season_insurance) + if (baseRatioBPS <= 0) return 0 return ( - baseRatio + - calculateConfigPenalty(ratioConfig, options) + - getCoinCorrection(ratioConfig, coinMAmount) + - options.dailyLossRatioAdjustment + baseRatioBPS + + calculateConfigPenaltyBPS(ratioConfig, options) + + getCoinCorrectionBPS(ratioConfig, hafCoinAmount) + + options.dailyLossRatioAdjustmentBPS ) } -export function readFinalSaleRatio( - defaultRatio: number, - acceleratedSaleRatio: number | '', - maxAcceleratedSaleRatio: number +export function readFinalSaleRatioBPS( + defaultRatioBPS: number, + acceleratedSaleRatio: number | '' | string, + maxAcceleratedSaleRatioBPS: number ) { - if (defaultRatio <= 0) return 0 - if (!hasAcceleratedSaleRatioInput(acceleratedSaleRatio)) return defaultRatio - const ratio = Number(acceleratedSaleRatio) - if (!Number.isFinite(ratio) || ratio <= 0) return defaultRatio - return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio)) + if (defaultRatioBPS <= 0) return 0 + if (!hasAcceleratedSaleRatioInput(acceleratedSaleRatio)) return defaultRatioBPS + const ratioBPS = ratioToBPS(acceleratedSaleRatio as number | string) + if (ratioBPS <= 0) return defaultRatioBPS + return Math.min(Math.max(ratioBPS, defaultRatioBPS), maxAcceleratedSaleRatioBPS) } -export function applySiteReceiveRatioDelta(referenceRatio: number, delta: number) { - if (referenceRatio <= 0) return 0 - const safeDelta = Math.min(20, Math.max(0, Number(delta) || 0)) - return roundRatio(Math.max(referenceRatio - safeDelta, 0.01)) +export function maxAcceleratedSaleRatioBPS(referenceRatioBPS: number) { + return referenceRatioBPS > 0 + ? referenceRatioBPS + MAX_ACCELERATED_RATIO_BPS_INCREMENT + : 0 } -/** - * 分站收号压价(新口径):收号比例 = 主站参考比例 R + 点数,比例变大 → 收号价更低。 - * 买家价已解耦,不再从收号比例推导,始终锚定主站参考口径。 - */ -export function applySiteReceiveCostDelta(referenceRatio: number, delta: number) { - if (referenceRatio <= 0) return 0 - const safeDelta = Math.min(20, Math.max(0, Number(delta) || 0)) - return roundRatio(referenceRatio + safeDelta) +export function hasAcceleratedSaleRatioInput(value: number | '' | string | null | undefined) { + if (value === '' || value === null || value === undefined) return false + return ratioToBPS(value) > 0 } -export function hasAcceleratedSaleRatioInput(value: number | '' | null | undefined) { - const ratio = Number(value) - return ( - value !== '' && value !== null && value !== undefined && Number.isFinite(ratio) && ratio > 0 - ) +export function calculateSellerCoinBasePriceCent(hafCoinAmount: number, ratioBPS: number) { + if (hafCoinAmount <= 0 || ratioBPS <= 0) return 0 + return Math.floor((hafCoinAmount * 100) / ratioBPS) } -export function calculatePlatformPricing(options: { - coinMAmount: number - coinWanAmount: number - sellerRatio: number - sellerCoinBasePrice: number - sellerTotalPrice: number - consumablePrice: number +export interface PlatformPricingCent { + buyerCoinBasePriceCent: number + buyerTotalPriceCent: number + buyerRatioBPS: number + platformMarkupAmountCent: number + ruleType: 'fixed_markup' | 'ratio_subtract' | 'none' | string +} + +export function calculatePlatformPricingCent(options: { + hafCoinAmount: number + sellerRatioBPS: number + sellerCoinBasePriceCent: number + sellerTotalPriceCent: number + consumablePriceCent: number salePriceConfig: PublishSalePriceConfig -}): PublishPlatformPricing { - if (options.sellerRatio <= 0 || options.sellerCoinBasePrice <= 0) return emptyPlatformPricing() - const fixedRule = findSaleFixedMarkupRule(options.salePriceConfig, options.coinMAmount) +}): PlatformPricingCent { + if (options.sellerRatioBPS <= 0 || options.sellerCoinBasePriceCent <= 0) { + return emptyPlatformPricingCent() + } + const ratioRule = findSaleRatioAdjustmentRule(options.salePriceConfig, options.hafCoinAmount) + if (ratioRule) { + const subtractBPS = + Number(ratioRule.ratio_subtract_bps || 0) > 0 + ? Math.trunc(Number(ratioRule.ratio_subtract_bps)) + : ratioToBPS(ratioRule.ratio_subtract) + const buyerRatioBPS = options.sellerRatioBPS - subtractBPS + if (buyerRatioBPS > 0) { + const buyerCoinBasePriceCent = Math.max( + options.sellerCoinBasePriceCent, + calculateSellerCoinBasePriceCent(options.hafCoinAmount, buyerRatioBPS) + ) + return buildPlatformPricingCent(buyerCoinBasePriceCent, 'ratio_subtract', options) + } + } + const fixedRule = findSaleFixedMarkupRule(options.salePriceConfig, options.hafCoinAmount) if (fixedRule) { - return buildPlatformPricing( - roundMoney(options.sellerCoinBasePrice + Number(fixedRule.markup_amount || 0)), + const markupCent = + Number(fixedRule.markup_amount_cent || 0) > 0 + ? Math.trunc(Number(fixedRule.markup_amount_cent)) + : (() => { + const cent = parseYuanToCent(fixedRule.markup_amount) + return Number.isFinite(cent) ? cent : 0 + })() + return buildPlatformPricingCent( + options.sellerCoinBasePriceCent + markupCent, 'fixed_markup', options ) } - const ratioRule = findSaleRatioAdjustmentRule(options.salePriceConfig, options.coinMAmount) - const ratioSubtract = ratioRule ? Number(ratioRule.ratio_subtract || 0) : 0 - const buyerRatio = options.sellerRatio - ratioSubtract - if (buyerRatio > 0 && ratioRule) { - return buildPlatformPricing( - roundMoney(options.coinWanAmount / buyerRatio), - 'ratio_subtract', - options - ) - } - return buildPlatformPricing(options.sellerCoinBasePrice, 'none', options) + return buildPlatformPricingCent(options.sellerCoinBasePriceCent, 'none', options) } -export function emptyPlatformPricing(): PublishPlatformPricing { +export function emptyPlatformPricingCent(): PlatformPricingCent { return { - buyerCoinBasePrice: 0, - buyerTotalPrice: 0, - buyerRatio: 0, - platformMarkupAmount: 0, + buyerCoinBasePriceCent: 0, + buyerTotalPriceCent: 0, + buyerRatioBPS: 0, + platformMarkupAmountCent: 0, ruleType: 'none', } } -function buildPlatformPricing( - buyerCoinBasePrice: number, +function buildPlatformPricingCent( + buyerCoinBasePriceCent: number, ruleType: string, options: { - coinWanAmount: number - sellerTotalPrice: number - consumablePrice: number + hafCoinAmount: number + sellerTotalPriceCent: number + consumablePriceCent: number } -): PublishPlatformPricing { - const buyerTotalPrice = roundMoney(buyerCoinBasePrice + options.consumablePrice) +): PlatformPricingCent { + const buyerTotalPriceCent = buyerCoinBasePriceCent + options.consumablePriceCent return { - buyerCoinBasePrice, - buyerTotalPrice, - buyerRatio: calculateEffectiveRatio(options.coinWanAmount, buyerCoinBasePrice), - platformMarkupAmount: roundMoney(buyerTotalPrice - options.sellerTotalPrice), + buyerCoinBasePriceCent, + buyerTotalPriceCent, + buyerRatioBPS: + buyerCoinBasePriceCent > 0 + ? Math.floor((options.hafCoinAmount * 100) / buyerCoinBasePriceCent) + : 0, + platformMarkupAmountCent: buyerTotalPriceCent - options.sellerTotalPriceCent, ruleType, } } -function findSaleFixedMarkupRule(config: PublishSalePriceConfig, coinMAmount: number) { +function findSaleFixedMarkupRule(config: PublishSalePriceConfig, hafCoinAmount: number) { return [...config.fixed_markup_rules] .sort((a, b) => a.min_m - b.min_m) .find((item, index, rules) => - isCoinInSaleRange(item, index, rules, coinMAmount, { includeLastMax: true }) + isCoinInSaleRange(item, index, rules, hafCoinAmount, { includeLastMax: true }) ) } -function findSaleRatioAdjustmentRule(config: PublishSalePriceConfig, coinMAmount: number) { +function findSaleRatioAdjustmentRule(config: PublishSalePriceConfig, hafCoinAmount: number) { return [...config.ratio_adjustment_rules] .sort((a, b) => a.min_m - b.min_m) .find((item, index, rules) => - isCoinInSaleRange(item, index, rules, coinMAmount, { excludeFirstMin: true }) + isCoinInSaleRange(item, index, rules, hafCoinAmount, { excludeFirstMin: true }) ) } @@ -261,33 +355,43 @@ function isCoinInSaleRange( item: { min_m: number; max_m: number }, index: number, rules: Array<{ min_m: number; max_m: number }>, - coinMAmount: number, + hafCoinAmount: number, options: { includeLastMax?: boolean; excludeFirstMin?: boolean } = {} ) { const maxM = Number(item.max_m || 0) const minM = Number(item.min_m || 0) + const minUnits = Math.round(minM * HAF_COIN_UNIT) + const maxUnits = Math.round(maxM * HAF_COIN_UNIT) const minMatched = - options.excludeFirstMin && index === 0 ? coinMAmount > minM : coinMAmount >= minM + options.excludeFirstMin && index === 0 ? hafCoinAmount > minUnits : hafCoinAmount >= minUnits const isLastRule = index === rules.length - 1 const maxMatched = - maxM <= 0 || coinMAmount < maxM || (options.includeLastMax && isLastRule && coinMAmount <= maxM) + maxUnits <= 0 || + hafCoinAmount < maxUnits || + (options.includeLastMax && isLastRule && hafCoinAmount <= maxUnits) return minMatched && maxMatched } -function calculateEffectiveRatio(coinWanAmount: number, price: number) { - if (price <= 0) return 0 - return roundRatio(coinWanAmount / price) -} - -function getInsuranceBaseRatio( - config: Pick, +function getInsuranceBaseRatioBPS( + config: { + insurance_base_ratios: Array<{ insurance: string; ratio: number; ratio_bps?: number }> + }, insurance: string ) { - return config.insurance_base_ratios.find(item => item.insurance === insurance)?.ratio || 0 + const item = config.insurance_base_ratios.find(row => row.insurance === insurance) + if (!item) return 0 + return Number(item.ratio_bps || 0) > 0 ? Math.trunc(Number(item.ratio_bps)) : ratioToBPS(item.ratio) } -function calculateConfigPenalty( - config: Pick, +function calculateConfigPenaltyBPS( + config: { + config_items: Array<{ + kind: string + group_key?: string + missing_penalty: number + missing_penalty_bps?: number + }> + }, options: { form: PublishForm skinGroups: PublishOptionGroup[] @@ -296,7 +400,13 @@ function calculateConfigPenalty( } ) { return config.config_items.reduce((sum, item) => { - return isRatioConfigItemMatched(item, options) ? sum : sum + Number(item.missing_penalty || 0) + if (isRatioConfigItemMatched(item, options)) return sum + return ( + sum + + (Number(item.missing_penalty_bps || 0) > 0 + ? Math.trunc(Number(item.missing_penalty_bps)) + : ratioToBPS(item.missing_penalty)) + ) }, 0) } @@ -341,13 +451,21 @@ function readLevelNumber(value: string) { return match ? Number(match[0]) : 0 } -function getCoinCorrection( - config: Pick, - coinM: number +function getCoinCorrectionBPS( + config: { + coin_corrections: Array<{ + threshold_m: number + correction: number + correction_bps?: number + }> + }, + hafCoinAmount: number ) { - return ( - [...config.coin_corrections] - .sort((a, b) => b.threshold_m - a.threshold_m) - .find(item => coinM > item.threshold_m)?.correction || 0 - ) + const item = [...config.coin_corrections] + .sort((a, b) => b.threshold_m - a.threshold_m) + .find(row => hafCoinAmount > Math.round(row.threshold_m * HAF_COIN_UNIT)) + if (!item) return 0 + return Number(item.correction_bps || 0) > 0 + ? Math.trunc(Number(item.correction_bps)) + : ratioToBPS(item.correction) }