531 lines
15 KiB
Go
531 lines
15 KiB
Go
package listing
|
||
|
||
import (
|
||
"encoding/json"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"hfb_jysh/backend/internal/modules/systemconfig"
|
||
"hfb_jysh/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
|
||
}
|
||
}
|
||
}
|