分站解耦只应改分站收号侧;主站加速出售仍跟卖家比例联动买家价,并同步分销后台列表与发布页文案。 Co-authored-by: Cursor <cursoragent@cursor.com>
422 lines
12 KiB
Go
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 ""
|
|
}
|
|
}
|