package listing import ( "context" "encoding/json" "math" "strings" "hfb_sys/backend/internal/modules/systemconfig" "gorm.io/datatypes" ) const ( maxAcceleratedSaleRatioIncrement = 10.0 maxSiteReceiveRatioDelta = 20.0 minSiteReceiveRatio = 0.01 ) // ReceivePricingView 是收号定价快照,供分销收号管理等读侧展示。 type ReceivePricingView struct { SellerRatio float64 SellerTotalPriceCent int64 BuyerRatio float64 } // ReceivePricingFromAssetSummary 读取账号资产里的主站收号/挂牌比例。 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"]), } } func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRequest, receiveRatioDelta, receiveCostDelta float64, onDistributionSite bool) error { if req == nil { return nil } options, err := s.publishRatioOptions(ctx) if err != nil { return err } config, err := s.externalSalePriceConfig(ctx) if err != nil { return err } applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, receiveCostDelta, onDistributionSite) return nil } func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.PublishOptionsDTO, config salePriceConfig, receiveRatioDelta, receiveCostDelta float64, onDistributionSite bool) { if req == nil { return } if req.AssetSummary == nil { req.AssetSummary = map[string]any{} } coinM := coinMFromHafCoinAmount(req.HafCoinAmount) referenceRatio := calculateSellerReferenceRatio(coinM, req.AssetSummary, options) if referenceRatio <= 0 { return } breakdown := ensurePriceBreakdown(req.AssetSummary) requestedRatio := readSummaryNumber(breakdown["seller_ratio"]) if requestedRatio <= 0 { requestedRatio = readSummaryNumber(breakdown["accelerated_sale_ratio"]) } costDelta := NormalizeSiteReceiveCostDelta(receiveCostDelta) ratioDelta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta) sellerRatio := clampSaleRatio(referenceRatio, requestedRatio) // 分站新口径:收号比例 = R + 压价点数(收号价更低),买家定价锚定主站参考比例 R,不随收号价联动。 if onDistributionSite { sellerRatio = ApplySiteReceiveCostDelta(referenceRatio, costDelta) } coinWan := coinM * 100 sellerCoinBasePrice := roundMoney(coinWan / sellerRatio) consumablePrice := consumableValue(req.AssetSummary) sellerTotalPrice := roundMoney(sellerCoinBasePrice + consumablePrice) dailyLoss := readSummaryNumber(req.AssetSummary["daily_loss_m"]) // 主站买家价仍跟 sellerRatio(含加速出售);分站买家价锚定 R,压价差进入三方分成池。 buyerAnchorRatio := sellerRatio buyerAnchorCoinBase := sellerCoinBasePrice if onDistributionSite { buyerAnchorRatio = referenceRatio buyerAnchorCoinBase = referenceSellerCoinBasePrice(coinWan, referenceRatio) } 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 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["platform_rule_type"] = pricing.RuleType req.AssetSummary["price_breakdown"] = breakdown req.AssetSummary["publish_ratio"] = pricing.BuyerRatio req.PriceCent = yuanToCent(pricing.BuyerTotalPrice) } // NormalizeSiteReceiveCostDelta 将分站收号压价点数限制在 [0, 20]。 // 点数越大,分站收号比例越高(相对主站参考比例),收号价越低;0 表示与主站同价收号。 func NormalizeSiteReceiveCostDelta(delta float64) float64 { if delta < 0 || math.IsNaN(delta) || math.IsInf(delta, 0) { return 0 } if delta > maxSiteReceiveRatioDelta { return maxSiteReceiveRatioDelta } return roundRatio(delta) } // ApplySiteReceiveCostDelta 按新口径计算分站收号比例:R + 压价点数(收号价更低)。 // 与旧 ApplySiteReceiveRatioDelta 相反方向,买家价已解耦、不再从收号比例推导。 func ApplySiteReceiveCostDelta(referenceRatio, delta float64) float64 { if referenceRatio <= 0 { return 0 } siteRatio := roundRatio(referenceRatio + NormalizeSiteReceiveCostDelta(delta)) return siteRatio } // referenceSellerCoinBasePrice 计算主站参考比例下的号主基础价,用作买家定价锚点。 func referenceSellerCoinBasePrice(coinWan, referenceRatio float64) float64 { if referenceRatio <= 0 { return 0 } return roundMoney(coinWan / referenceRatio) } // NormalizeSiteReceiveRatioDelta 将分站收号下调点数限制在 [0, 20]。 func NormalizeSiteReceiveRatioDelta(delta float64) float64 { if delta < 0 || math.IsNaN(delta) || math.IsInf(delta, 0) { return 0 } if delta > maxSiteReceiveRatioDelta { return maxSiteReceiveRatioDelta } return roundRatio(delta) } // ApplySiteReceiveRatioDelta 在主站回收比例上限内下调分站收号比例。 func ApplySiteReceiveRatioDelta(referenceRatio, delta float64) float64 { if referenceRatio <= 0 { return 0 } siteRatio := roundRatio(referenceRatio - NormalizeSiteReceiveRatioDelta(delta)) if siteRatio < minSiteReceiveRatio { return minSiteReceiveRatio } if siteRatio > referenceRatio { return referenceRatio } return siteRatio } func (s *Service) publishRatioOptions(ctx context.Context) (systemconfig.PublishOptionsDTO, error) { options := systemconfig.DefaultPublishOptions() if s == nil || s.config == nil { return options, nil } value, err := s.config.FindValue(ctx, publishOptionsConfigKey) if err != nil { return options, err } if strings.TrimSpace(value) == "" { return options, nil } var parsed systemconfig.PublishOptionsDTO if err := json.Unmarshal([]byte(value), &parsed); err != nil { return options, nil } if len(parsed.RatioConfig.InsuranceBaseRatios) == 0 { parsed.RatioConfig.InsuranceBaseRatios = options.RatioConfig.InsuranceBaseRatios } if len(parsed.RatioConfig.ConfigItems) == 0 { parsed.RatioConfig.ConfigItems = options.RatioConfig.ConfigItems } if len(parsed.RatioConfig.CoinCorrections) == 0 { parsed.RatioConfig.CoinCorrections = options.RatioConfig.CoinCorrections } if len(parsed.LevelOptions) == 0 { parsed.LevelOptions = options.LevelOptions } if len(parsed.SkinGroups) == 0 { parsed.SkinGroups = options.SkinGroups } return parsed, nil } func calculateSellerReferenceRatio(coinM float64, summary map[string]any, options systemconfig.PublishOptionsDTO) float64 { if coinM <= 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 } baseRatio := insuranceBaseRatio(options.RatioConfig.InsuranceBaseRatios, insurance) if baseRatio <= 0 { return 0 } dailyLoss := readSummaryNumber(summary["daily_loss_m"]) return baseRatio + calculateRatioConfigPenalty(options, summary, stamina, load) + coinCorrection(options.RatioConfig.CoinCorrections, coinM) + calculateDailyLossRatioAdjustment(dailyLoss) } func clampSaleRatio(referenceRatio, requestedRatio float64) float64 { maxRatio := roundRatio(referenceRatio + maxAcceleratedSaleRatioIncrement) if requestedRatio <= 0 { return referenceRatio } if requestedRatio < referenceRatio { return referenceRatio } if requestedRatio > maxRatio { return maxRatio } return roundRatio(requestedRatio) } func insuranceBaseRatio(items []systemconfig.PublishInsuranceBaseRatio, insurance string) float64 { for _, item := range items { if item.Insurance == insurance { return item.Ratio } } return 0 } func calculateRatioConfigPenalty(options systemconfig.PublishOptionsDTO, summary map[string]any, stamina, load string) float64 { selectedSkins := flattenSkinNames(summary) penalty := 0.0 for _, item := range options.RatioConfig.ConfigItems { if ratioConfigItemMatched(item, options, selectedSkins, stamina, load) { continue } penalty += item.MissingPenalty } return penalty } func ratioConfigItemMatched( item systemconfig.PublishRatioConfigItem, options systemconfig.PublishOptionsDTO, selectedSkins []string, stamina, load string, ) bool { switch item.Kind { case "skin_group": return hasSelectedSkinGroup(options.SkinGroups, item.GroupKey, selectedSkins) case "max_stamina": return isMaxLevel(stamina, options.LevelOptions) case "max_load": return isMaxLevel(load, options.LevelOptions) default: return false } } func hasSelectedSkinGroup(groups []systemconfig.PublishOptionGroup, groupKey string, selectedSkins []string) bool { if groupKey == "" || len(selectedSkins) == 0 { return false } for _, group := range groups { if group.Key != groupKey { continue } allowed := make(map[string]struct{}, len(group.Options)) for _, skin := range group.Options { allowed[skin] = struct{}{} } for _, skin := range selectedSkins { if _, ok := allowed[skin]; ok { return true } } } return false } func isMaxLevel(value string, levelOptions []string) bool { current := readLevelNumber(value) maxLevel := 0 for _, option := range levelOptions { if n := readLevelNumber(option); n > maxLevel { maxLevel = n } } if current > 0 && maxLevel > 0 { return current >= maxLevel } if len(levelOptions) == 0 { return false } return value == levelOptions[len(levelOptions)-1] } func readLevelNumber(value string) int { digits := "" for _, r := range value { if r >= '0' && r <= '9' { digits += string(r) } } if digits == "" { return 0 } n := 0 for _, r := range digits { n = n*10 + int(r-'0') } return n } func coinCorrection(items []systemconfig.PublishCoinCorrection, coinM float64) float64 { bestThreshold := -1.0 correction := 0.0 for _, item := range items { if coinM > item.ThresholdM && item.ThresholdM >= bestThreshold { bestThreshold = item.ThresholdM correction = item.Correction } } return correction } func calculateDailyLossRatioAdjustment(dailyLossM float64) float64 { if dailyLossM <= 0 { return 0 } adjustment := (dailyLossM - 10) / 10 if adjustment < 0 { return 0 } floored := float64(int(adjustment)) if floored > 4 { return 4 } return floored } func coinMFromHafCoinAmount(amount int64) float64 { if amount <= 0 { return 0 } return float64(amount) / 1_000_000 } func flattenSkinNames(summary map[string]any) []string { if summary == nil { return nil } raw, ok := summary["skin_groups"] if !ok || raw == nil { return nil } var names []string switch groups := raw.(type) { case map[string]any: for _, value := range groups { names = append(names, stringSlice(value)...) } case map[string][]string: for _, value := range groups { names = append(names, value...) } case map[string][]any: for _, value := range groups { names = append(names, stringSlice(value)...) } } return names } func stringSlice(value any) []string { switch items := value.(type) { case []string: return items case []any: result := make([]string, 0, len(items)) for _, item := range items { text := strings.TrimSpace(readSummaryString(item)) if text != "" { result = append(result, text) } } return result default: return nil } } func readSummaryString(value any) string { switch typed := value.(type) { case string: return strings.TrimSpace(typed) default: return "" } }