refactor(listing): 卖家发布定价改用整数分与基点
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]。
|
||||
|
||||
@@ -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 分成池。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 路径;卖家发布主链路已整数化。
|
||||
- 新增直接写旧价格字段的路径时,必须同步规范字段。
|
||||
@@ -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<string, unknown>) {
|
||||
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() {
|
||||
<div className="seller-price-grid">
|
||||
<div>
|
||||
<span>参考比例</span>
|
||||
<strong>{ratioText(breakdown.seller_reference_ratio)}</strong>
|
||||
<strong>
|
||||
{ratioText(breakdown.seller_reference_ratio_bps, breakdown.seller_reference_ratio)}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>卖家比例</span>
|
||||
<strong>{ratioText(breakdown.seller_ratio)}</strong>
|
||||
<strong>{ratioText(breakdown.seller_ratio_bps, breakdown.seller_ratio)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>纯币基础价</span>
|
||||
<strong>{formatMoney(numberValue(breakdown.seller_coin_base_price) * 100)}</strong>
|
||||
<strong>
|
||||
{formatMoney(
|
||||
moneyCent(breakdown.seller_coin_base_price_cent, breakdown.seller_coin_base_price)
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>消耗品价值</span>
|
||||
<strong>{formatMoney(numberValue(breakdown.consumable_price) * 100)}</strong>
|
||||
<strong>
|
||||
{formatMoney(moneyCent(breakdown.consumable_price_cent, breakdown.consumable_price))}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>买家比例</span>
|
||||
<strong>{ratioText(breakdown.buyer_ratio)}</strong>
|
||||
<strong>{ratioText(breakdown.buyer_ratio_bps, breakdown.buyer_ratio)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>平台加价</span>
|
||||
<strong>{formatMoney(numberValue(breakdown.platform_markup_amount) * 100)}</strong>
|
||||
<strong>
|
||||
{formatMoney(
|
||||
moneyCent(
|
||||
breakdown.platform_markup_amount_cent,
|
||||
breakdown.platform_markup_amount
|
||||
)
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown> {
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => updateForm('depositAmountYuan', pricing.recommendedDeposit)}
|
||||
disabled={pricing.recommendedDeposit <= 0}
|
||||
onClick={() =>
|
||||
updateForm('depositAmountYuan', pricing.recommendedDepositCent / 100)
|
||||
}
|
||||
disabled={pricing.recommendedDepositCent <= 0}
|
||||
>
|
||||
使用推荐 ¥{pricing.recommendedDeposit}
|
||||
使用推荐 {formatMoney(pricing.recommendedDepositCent)}
|
||||
</Button>
|
||||
</div>
|
||||
<small>{options.price_config.deposit_hint}</small>
|
||||
@@ -1222,7 +1260,7 @@ export function SellerListingFormPage() {
|
||||
{pricing.depositBreakdown.map(item => (
|
||||
<Tag key={`${item.label}-${item.count}`} bordered={false}>
|
||||
{item.label}
|
||||
{item.count > 1 ? ` x${item.count}` : ''} ¥{item.amount}
|
||||
{item.count > 1 ? ` x${item.count}` : ''} {formatMoney(item.amountCent)}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
@@ -1230,7 +1268,7 @@ export function SellerListingFormPage() {
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`押金不能低于智能推荐 ¥${pricing.recommendedDeposit}`}
|
||||
message={`押金不能低于智能推荐 ${formatMoney(pricing.recommendedDepositCent)}`}
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
@@ -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"
|
||||
/>
|
||||
<Button
|
||||
disabled={pricing.referenceRatio <= 0}
|
||||
disabled={pricing.referenceRatioBPS <= 0}
|
||||
onClick={() => updateForm('accelerated_sale_ratio', '')}
|
||||
>
|
||||
参考比例
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pricing.maxAcceleratedSaleRatio <= 0}
|
||||
disabled={pricing.maxAcceleratedSaleRatioBPS <= 0}
|
||||
onClick={() =>
|
||||
updateForm('accelerated_sale_ratio', pricing.maxAcceleratedSaleRatio)
|
||||
updateForm(
|
||||
'accelerated_sale_ratio',
|
||||
pricing.maxAcceleratedSaleRatioBPS / 10000
|
||||
)
|
||||
}
|
||||
>
|
||||
最高
|
||||
</Button>
|
||||
</div>
|
||||
<small>
|
||||
{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 || '请先完善赛季保险、体力和负重。'}
|
||||
</small>
|
||||
</label>
|
||||
@@ -1334,7 +1375,7 @@ export function SellerListingFormPage() {
|
||||
<Card className="seller-price-panel">
|
||||
<Typography.Text className="seller-eyebrow">BUYER PRICE · 买家挂牌价</Typography.Text>
|
||||
<div className="seller-price-panel__main">
|
||||
<strong>{formatMoney(Math.round(pricing.finalPrice * 100))}</strong>
|
||||
<strong>{formatMoney(pricing.finalPriceCent)}</strong>
|
||||
<span>
|
||||
{pricing.platformPricing.ruleType === 'fixed_markup'
|
||||
? '固定加价'
|
||||
@@ -1347,40 +1388,40 @@ export function SellerListingFormPage() {
|
||||
<div>
|
||||
<span>参考比例</span>
|
||||
<strong>
|
||||
{pricing.referenceRatio > 0
|
||||
? `1:${formatNumber(pricing.referenceRatio)}`
|
||||
{pricing.referenceRatioBPS > 0
|
||||
? `1:${formatRatioBPS(pricing.referenceRatioBPS)}`
|
||||
: '--'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>卖家比例</span>
|
||||
<strong>
|
||||
{pricing.finalRatio > 0 ? `1:${formatNumber(pricing.finalRatio)}` : '--'}
|
||||
{pricing.finalRatioBPS > 0 ? `1:${formatRatioBPS(pricing.finalRatioBPS)}` : '--'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>买家比例</span>
|
||||
<strong>
|
||||
{pricing.platformPricing.buyerRatio > 0
|
||||
? `1:${formatNumber(pricing.platformPricing.buyerRatio)}`
|
||||
{pricing.platformPricing.buyerRatioBPS > 0
|
||||
? `1:${formatRatioBPS(pricing.platformPricing.buyerRatioBPS)}`
|
||||
: '--'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>纯币基础价</span>
|
||||
<strong>{formatMoney(Math.round(pricing.coinBasePrice * 100))}</strong>
|
||||
<strong>{formatMoney(pricing.coinBasePriceCent)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>消耗品价值</span>
|
||||
<strong>{formatMoney(Math.round(pricing.consumablePrice * 100))}</strong>
|
||||
<strong>{formatMoney(pricing.consumablePriceCent)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>卖家到手价</span>
|
||||
<strong>{formatMoney(Math.round(pricing.sellerPrice * 100))}</strong>
|
||||
<strong>{formatMoney(pricing.sellerPriceCent)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>推荐押金</span>
|
||||
<strong>{formatMoney(Math.round(pricing.recommendedDeposit * 100))}</strong>
|
||||
<strong>{formatMoney(pricing.recommendedDepositCent)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
@@ -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<string, unknown>).seller_total_price)
|
||||
const record = breakdown as Record<string, unknown>
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface PublishDraft {
|
||||
|
||||
export interface DepositBreakdownItem {
|
||||
label: string
|
||||
amount: number
|
||||
amountCent: number
|
||||
count: number
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, number>
|
||||
quantityModes: Record<string, ChargeMode>
|
||||
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<ListingPublishOptions['ratio_config'], 'insurance_base_ratios'>,
|
||||
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<ListingPublishOptions['ratio_config'], 'config_items'>,
|
||||
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<ListingPublishOptions['ratio_config'], 'coin_corrections'>,
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user