Files
hfb_sys/backend/internal/modules/listing/receive_pricing.go
T
yml2213andCursor a36332de1d fix(listing): 主站加速出售买家价不再误锚到参考比例
分站解耦只应改分站收号侧;主站加速出售仍跟卖家比例联动买家价,并同步分销后台列表与发布页文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 16:09:41 +08:00

422 lines
12 KiB
Go

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 ""
}
}