feat(distribution): 分站可下调收号比例且不超过主站上限
让分销站能按自身能力下调收号比例扩大利润,同时把主站回收比例作为硬边界,避免分站把比例加得比主站更高。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,7 +42,7 @@ func (h *Handler) Update(c *gin.Context) {
|
||||
response.BadRequest(c, listingBindErrorMessage(err))
|
||||
return
|
||||
}
|
||||
item, err := h.service.Update(c.Request.Context(), ownerID, id, req)
|
||||
item, err := h.service.Update(c.Request.Context(), ownerID, id, req, strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code")))
|
||||
if err != nil {
|
||||
writeListingError(c, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAcceleratedSaleRatioIncrement = 10.0
|
||||
maxSiteReceiveRatioDelta = 20.0
|
||||
minSiteReceiveRatio = 0.01
|
||||
)
|
||||
|
||||
// ReceivePricingView 是收号定价快照,供分销收号管理等读侧展示。
|
||||
type ReceivePricingView struct {
|
||||
SellerRatio float64
|
||||
SellerTotalPriceCent int64
|
||||
BuyerRatio float64
|
||||
}
|
||||
|
||||
// ReceivePricingFromAssetSummary 读取账号资产里的主站收号/挂牌比例。
|
||||
func ReceivePricingFromAssetSummary(raw datatypes.JSON) ReceivePricingView {
|
||||
summary := decodeAssetSummary(raw)
|
||||
breakdown := ensurePriceBreakdown(summary)
|
||||
sellerTotal := readSummaryNumber(breakdown["seller_total_price"])
|
||||
return ReceivePricingView{
|
||||
SellerRatio: readSummaryNumber(breakdown["seller_ratio"]),
|
||||
SellerTotalPriceCent: yuanToCent(sellerTotal),
|
||||
BuyerRatio: readSummaryNumber(breakdown["buyer_ratio"]),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRequest, receiveRatioDelta float64, onDistributionSite bool) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
options, err := s.publishRatioOptions(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config, err := s.externalSalePriceConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, onDistributionSite)
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.PublishOptionsDTO, config salePriceConfig, receiveRatioDelta float64, onDistributionSite bool) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
if req.AssetSummary == nil {
|
||||
req.AssetSummary = map[string]any{}
|
||||
}
|
||||
coinM := coinMFromHafCoinAmount(req.HafCoinAmount)
|
||||
referenceRatio := calculateSellerReferenceRatio(coinM, req.AssetSummary, options)
|
||||
if referenceRatio <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
requestedRatio := readSummaryNumber(breakdown["seller_ratio"])
|
||||
if requestedRatio <= 0 {
|
||||
requestedRatio = readSummaryNumber(breakdown["accelerated_sale_ratio"])
|
||||
}
|
||||
sellerRatio := clampSaleRatio(referenceRatio, requestedRatio)
|
||||
delta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta)
|
||||
if onDistributionSite {
|
||||
sellerRatio = ApplySiteReceiveRatioDelta(referenceRatio, delta)
|
||||
}
|
||||
coinWan := coinM * 100
|
||||
sellerCoinBasePrice := roundMoney(coinWan / sellerRatio)
|
||||
consumablePrice := consumableValue(req.AssetSummary)
|
||||
sellerTotalPrice := roundMoney(sellerCoinBasePrice + consumablePrice)
|
||||
dailyLoss := readSummaryNumber(req.AssetSummary["daily_loss_m"])
|
||||
pricing := calculateExternalPlatformPricing(coinM, sellerRatio, sellerCoinBasePrice, 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
|
||||
breakdown["site_receive_ratio_delta"] = delta
|
||||
breakdown["buyer_coin_base_price"] = pricing.BuyerCoinBasePrice
|
||||
breakdown["buyer_total_price"] = pricing.BuyerTotalPrice
|
||||
breakdown["buyer_ratio"] = pricing.BuyerRatio
|
||||
breakdown["platform_markup_amount"] = pricing.PlatformMarkupPrice
|
||||
breakdown["platform_rule_type"] = pricing.RuleType
|
||||
req.AssetSummary["price_breakdown"] = breakdown
|
||||
req.AssetSummary["publish_ratio"] = pricing.BuyerRatio
|
||||
req.PriceCent = yuanToCent(pricing.BuyerTotalPrice)
|
||||
}
|
||||
|
||||
// NormalizeSiteReceiveRatioDelta 将分站收号下调点数限制在 [0, 20]。
|
||||
func NormalizeSiteReceiveRatioDelta(delta float64) float64 {
|
||||
if delta < 0 || math.IsNaN(delta) || math.IsInf(delta, 0) {
|
||||
return 0
|
||||
}
|
||||
if delta > maxSiteReceiveRatioDelta {
|
||||
return maxSiteReceiveRatioDelta
|
||||
}
|
||||
return roundRatio(delta)
|
||||
}
|
||||
|
||||
// ApplySiteReceiveRatioDelta 在主站回收比例上限内下调分站收号比例。
|
||||
func ApplySiteReceiveRatioDelta(referenceRatio, delta float64) float64 {
|
||||
if referenceRatio <= 0 {
|
||||
return 0
|
||||
}
|
||||
siteRatio := roundRatio(referenceRatio - NormalizeSiteReceiveRatioDelta(delta))
|
||||
if siteRatio < minSiteReceiveRatio {
|
||||
return minSiteReceiveRatio
|
||||
}
|
||||
if siteRatio > referenceRatio {
|
||||
return referenceRatio
|
||||
}
|
||||
return siteRatio
|
||||
}
|
||||
|
||||
func (s *Service) publishRatioOptions(ctx context.Context) (systemconfig.PublishOptionsDTO, error) {
|
||||
options := systemconfig.DefaultPublishOptions()
|
||||
if s == nil || s.config == nil {
|
||||
return options, nil
|
||||
}
|
||||
value, err := s.config.FindValue(ctx, publishOptionsConfigKey)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return options, nil
|
||||
}
|
||||
var parsed systemconfig.PublishOptionsDTO
|
||||
if err := json.Unmarshal([]byte(value), &parsed); err != nil {
|
||||
return options, nil
|
||||
}
|
||||
if len(parsed.RatioConfig.InsuranceBaseRatios) == 0 {
|
||||
parsed.RatioConfig.InsuranceBaseRatios = options.RatioConfig.InsuranceBaseRatios
|
||||
}
|
||||
if len(parsed.RatioConfig.ConfigItems) == 0 {
|
||||
parsed.RatioConfig.ConfigItems = options.RatioConfig.ConfigItems
|
||||
}
|
||||
if len(parsed.RatioConfig.CoinCorrections) == 0 {
|
||||
parsed.RatioConfig.CoinCorrections = options.RatioConfig.CoinCorrections
|
||||
}
|
||||
if len(parsed.LevelOptions) == 0 {
|
||||
parsed.LevelOptions = options.LevelOptions
|
||||
}
|
||||
if len(parsed.SkinGroups) == 0 {
|
||||
parsed.SkinGroups = options.SkinGroups
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func calculateSellerReferenceRatio(coinM float64, summary map[string]any, options systemconfig.PublishOptionsDTO) float64 {
|
||||
if coinM <= 0 || summary == nil {
|
||||
return 0
|
||||
}
|
||||
insurance := readSummaryString(summary["season_insurance"])
|
||||
stamina := readSummaryString(summary["stamina_level"])
|
||||
load := readSummaryString(summary["load_level"])
|
||||
if insurance == "" || stamina == "" || load == "" {
|
||||
return 0
|
||||
}
|
||||
baseRatio := insuranceBaseRatio(options.RatioConfig.InsuranceBaseRatios, insurance)
|
||||
if baseRatio <= 0 {
|
||||
return 0
|
||||
}
|
||||
dailyLoss := readSummaryNumber(summary["daily_loss_m"])
|
||||
return baseRatio +
|
||||
calculateRatioConfigPenalty(options, summary, stamina, load) +
|
||||
coinCorrection(options.RatioConfig.CoinCorrections, coinM) +
|
||||
calculateDailyLossRatioAdjustment(dailyLoss)
|
||||
}
|
||||
|
||||
func clampSaleRatio(referenceRatio, requestedRatio float64) float64 {
|
||||
maxRatio := roundRatio(referenceRatio + maxAcceleratedSaleRatioIncrement)
|
||||
if requestedRatio <= 0 {
|
||||
return referenceRatio
|
||||
}
|
||||
if requestedRatio < referenceRatio {
|
||||
return referenceRatio
|
||||
}
|
||||
if requestedRatio > maxRatio {
|
||||
return maxRatio
|
||||
}
|
||||
return roundRatio(requestedRatio)
|
||||
}
|
||||
|
||||
func insuranceBaseRatio(items []systemconfig.PublishInsuranceBaseRatio, insurance string) float64 {
|
||||
for _, item := range items {
|
||||
if item.Insurance == insurance {
|
||||
return item.Ratio
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func calculateRatioConfigPenalty(options systemconfig.PublishOptionsDTO, summary map[string]any, stamina, load string) float64 {
|
||||
selectedSkins := flattenSkinNames(summary)
|
||||
penalty := 0.0
|
||||
for _, item := range options.RatioConfig.ConfigItems {
|
||||
if ratioConfigItemMatched(item, options, selectedSkins, stamina, load) {
|
||||
continue
|
||||
}
|
||||
penalty += item.MissingPenalty
|
||||
}
|
||||
return penalty
|
||||
}
|
||||
|
||||
func ratioConfigItemMatched(
|
||||
item systemconfig.PublishRatioConfigItem,
|
||||
options systemconfig.PublishOptionsDTO,
|
||||
selectedSkins []string,
|
||||
stamina, load string,
|
||||
) bool {
|
||||
switch item.Kind {
|
||||
case "skin_group":
|
||||
return hasSelectedSkinGroup(options.SkinGroups, item.GroupKey, selectedSkins)
|
||||
case "max_stamina":
|
||||
return isMaxLevel(stamina, options.LevelOptions)
|
||||
case "max_load":
|
||||
return isMaxLevel(load, options.LevelOptions)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasSelectedSkinGroup(groups []systemconfig.PublishOptionGroup, groupKey string, selectedSkins []string) bool {
|
||||
if groupKey == "" || len(selectedSkins) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, group := range groups {
|
||||
if group.Key != groupKey {
|
||||
continue
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(group.Options))
|
||||
for _, skin := range group.Options {
|
||||
allowed[skin] = struct{}{}
|
||||
}
|
||||
for _, skin := range selectedSkins {
|
||||
if _, ok := allowed[skin]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isMaxLevel(value string, levelOptions []string) bool {
|
||||
current := readLevelNumber(value)
|
||||
maxLevel := 0
|
||||
for _, option := range levelOptions {
|
||||
if n := readLevelNumber(option); n > maxLevel {
|
||||
maxLevel = n
|
||||
}
|
||||
}
|
||||
if current > 0 && maxLevel > 0 {
|
||||
return current >= maxLevel
|
||||
}
|
||||
if len(levelOptions) == 0 {
|
||||
return false
|
||||
}
|
||||
return value == levelOptions[len(levelOptions)-1]
|
||||
}
|
||||
|
||||
func readLevelNumber(value string) int {
|
||||
digits := ""
|
||||
for _, r := range value {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits += string(r)
|
||||
}
|
||||
}
|
||||
if digits == "" {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, r := range digits {
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func coinCorrection(items []systemconfig.PublishCoinCorrection, coinM float64) float64 {
|
||||
bestThreshold := -1.0
|
||||
correction := 0.0
|
||||
for _, item := range items {
|
||||
if coinM > item.ThresholdM && item.ThresholdM >= bestThreshold {
|
||||
bestThreshold = item.ThresholdM
|
||||
correction = item.Correction
|
||||
}
|
||||
}
|
||||
return correction
|
||||
}
|
||||
|
||||
func calculateDailyLossRatioAdjustment(dailyLossM float64) float64 {
|
||||
if dailyLossM <= 0 {
|
||||
return 0
|
||||
}
|
||||
adjustment := (dailyLossM - 10) / 10
|
||||
if adjustment < 0 {
|
||||
return 0
|
||||
}
|
||||
floored := float64(int(adjustment))
|
||||
if floored > 4 {
|
||||
return 4
|
||||
}
|
||||
return floored
|
||||
}
|
||||
|
||||
func coinMFromHafCoinAmount(amount int64) float64 {
|
||||
if amount <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(amount) / 1_000_000
|
||||
}
|
||||
|
||||
func flattenSkinNames(summary map[string]any) []string {
|
||||
if summary == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := summary["skin_groups"]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
switch groups := raw.(type) {
|
||||
case map[string]any:
|
||||
for _, value := range groups {
|
||||
names = append(names, stringSlice(value)...)
|
||||
}
|
||||
case map[string][]string:
|
||||
for _, value := range groups {
|
||||
names = append(names, value...)
|
||||
}
|
||||
case map[string][]any:
|
||||
for _, value := range groups {
|
||||
names = append(names, stringSlice(value)...)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func stringSlice(value any) []string {
|
||||
switch items := value.(type) {
|
||||
case []string:
|
||||
return items
|
||||
case []any:
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
text := strings.TrimSpace(readSummaryString(item))
|
||||
if text != "" {
|
||||
result = append(result, text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readSummaryString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
)
|
||||
|
||||
func fullReceivePricingRequest(sellerRatio float64) CreateRequest {
|
||||
return CreateRequest{
|
||||
HafCoinAmount: 100_000_000,
|
||||
PriceCent: 100,
|
||||
AssetSummary: map[string]any{
|
||||
"season_insurance": "3*3",
|
||||
"stamina_level": "7级",
|
||||
"load_level": "7级",
|
||||
"daily_loss_m": 10,
|
||||
"price_breakdown": map[string]any{
|
||||
"seller_ratio": sellerRatio,
|
||||
"seller_total_price": 1.0,
|
||||
"buyer_total_price": 1.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSellerReferenceRatioUsesMainSiteConfig(t *testing.T) {
|
||||
options := systemconfig.DefaultPublishOptions()
|
||||
summary := map[string]any{
|
||||
"season_insurance": "3*3",
|
||||
"stamina_level": "7级",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMainSiteReceivePricingOverwritesClientRatio(t *testing.T) {
|
||||
req := fullReceivePricingRequest(10)
|
||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, false)
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) != 43 {
|
||||
t.Fatalf("seller_ratio = %v, want 43", breakdown["seller_ratio"])
|
||||
}
|
||||
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["buyer_ratio"]) != 38 {
|
||||
t.Fatalf("buyer_ratio = %v, want 38", breakdown["buyer_ratio"])
|
||||
}
|
||||
if req.PriceCent != 26320 {
|
||||
t.Fatalf("PriceCent = %d, want 26320", req.PriceCent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t *testing.T) {
|
||||
req := fullReceivePricingRequest(50)
|
||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, false)
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) != 50 {
|
||||
t.Fatalf("accelerated seller_ratio = %v, want 50", breakdown["seller_ratio"])
|
||||
}
|
||||
if readSummaryNumber(breakdown["seller_reference_ratio"]) != 43 {
|
||||
t.Fatalf("seller_reference_ratio = %v, want 43", breakdown["seller_reference_ratio"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionReceiveRatioCannotExceedMainSite(t *testing.T) {
|
||||
req := fullReceivePricingRequest(50)
|
||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, true)
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) != 43 {
|
||||
t.Fatalf("distribution seller_ratio = %v, want main-site ceiling 43", breakdown["seller_ratio"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionReceiveRatioDeltaLowersBelowMainSite(t *testing.T) {
|
||||
req := fullReceivePricingRequest(50)
|
||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 3, true)
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) != 40 {
|
||||
t.Fatalf("distribution seller_ratio = %v, want 40 after lowering 3 points", breakdown["seller_ratio"])
|
||||
}
|
||||
if readSummaryNumber(breakdown["seller_reference_ratio"]) != 43 {
|
||||
t.Fatalf("seller_reference_ratio = %v, want 43", breakdown["seller_reference_ratio"])
|
||||
}
|
||||
if readSummaryNumber(breakdown["site_receive_ratio_delta"]) != 3 {
|
||||
t.Fatalf("site_receive_ratio_delta = %v, want 3", breakdown["site_receive_ratio_delta"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySiteReceiveRatioDeltaCapsAtMainSite(t *testing.T) {
|
||||
if got := ApplySiteReceiveRatioDelta(43, -5); got != 43 {
|
||||
t.Fatalf("negative delta must keep main-site ratio, got %v", got)
|
||||
}
|
||||
if got := ApplySiteReceiveRatioDelta(43, 3); got != 40 {
|
||||
t.Fatalf("delta 3 = %v, want 40", got)
|
||||
}
|
||||
if got := NormalizeSiteReceiveRatioDelta(-2); got != 0 {
|
||||
t.Fatalf("normalize negative = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampSaleRatioRejectsBelowReference(t *testing.T) {
|
||||
if got := clampSaleRatio(43, 10); got != 43 {
|
||||
t.Fatalf("clamp below reference = %v, want 43", got)
|
||||
}
|
||||
if got := clampSaleRatio(43, 80); got != 53 {
|
||||
t.Fatalf("clamp above max = %v, want 53", got)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, siteCode ...string) (*ListingDTO, error) {
|
||||
@@ -11,6 +12,14 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest,
|
||||
if !req.AgreedVirtualAssetSale || !req.AgreedSellerAgreement {
|
||||
return nil, ErrAgreementRequired
|
||||
}
|
||||
effectiveSiteCode := ""
|
||||
if len(siteCode) > 0 {
|
||||
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
||||
}
|
||||
delta, onDistribution := s.repo.ResolveReceiveRatioDelta(ctx, 0, effectiveSiteCode)
|
||||
if err := s.applyMainSiteReceivePricing(ctx, &req, delta, onDistribution); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, err := s.publishRules(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -29,17 +38,21 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
effectiveSiteCode := ""
|
||||
if len(siteCode) > 0 {
|
||||
effectiveSiteCode = siteCode[0]
|
||||
}
|
||||
return s.repo.Create(ctx, ownerID, req, reviewRequired, cooldown, effectiveSiteCode)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) {
|
||||
func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req UpdateRequest, siteCode ...string) (*ListingDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
effectiveSiteCode := ""
|
||||
if len(siteCode) > 0 {
|
||||
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
||||
}
|
||||
delta, onDistribution := s.repo.ResolveReceiveRatioDelta(ctx, id, effectiveSiteCode)
|
||||
if err := s.applyMainSiteReceivePricing(ctx, &req, delta, onDistribution); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, err := s.publishRules(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1008,3 +1008,139 @@ func TestListMineSiteIsolation(t *testing.T) {
|
||||
t.Fatalf("expected 2 listings, got %d", len(allItems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDistributionListingUsesMainSiteReceiveRatio(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
|
||||
t.Fatalf("migrate err: %v", err)
|
||||
}
|
||||
svc := NewService(NewRepository(db, nil), stubConfigReader{})
|
||||
|
||||
user := model.User{Phone: "18800000021", Nickname: "分销收号卖家", RealnameStatus: "verified", Status: "active"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
site := model.DistributionSite{
|
||||
SiteCode: "dist_ratio_1",
|
||||
SiteName: "收号比例站",
|
||||
Domain: "ratio.example.com",
|
||||
Status: "active",
|
||||
DisplayAccountEnabled: true,
|
||||
}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dto, err := svc.Create(context.Background(), user.ID, CreateRequest{
|
||||
Title: "分销收号商品",
|
||||
ServerRegion: "QQ",
|
||||
LoginPlatform: "PC",
|
||||
RankLevel: "钻石",
|
||||
HafCoinAmount: 100_000_000,
|
||||
PriceCent: 100,
|
||||
DepositAmountCent: 15000,
|
||||
ScreenshotURLS: []string{"https://example.com/ratio.png"},
|
||||
AssetSummary: map[string]any{
|
||||
"fire_level": 50,
|
||||
"season_insurance": "3*3",
|
||||
"stamina_level": "7级",
|
||||
"load_level": "7级",
|
||||
"daily_loss_m": 10,
|
||||
"online_time": map[string]any{
|
||||
"start": "09:00",
|
||||
"end": "23:00",
|
||||
},
|
||||
"price_breakdown": map[string]any{
|
||||
"seller_ratio": 10.0,
|
||||
"seller_total_price": 1.0,
|
||||
"buyer_total_price": 1.0,
|
||||
},
|
||||
},
|
||||
AgreedVirtualAssetSale: true,
|
||||
AgreedSellerAgreement: true,
|
||||
}, "dist_ratio_1")
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
pricing := ReceivePricingFromAssetSummary(mustMarshalSummary(t, dto.AssetSummary))
|
||||
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 dto.PriceCent != 26320 {
|
||||
t.Fatalf("listing PriceCent = %d, want 26320 from main-site sale price", dto.PriceCent)
|
||||
}
|
||||
if dto.DistributionSiteCode != "dist_ratio_1" {
|
||||
t.Fatalf("DistributionSiteCode = %s, want dist_ratio_1", dto.DistributionSiteCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDistributionListingAppliesSiteReceiveRatioDelta(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
|
||||
t.Fatalf("migrate err: %v", err)
|
||||
}
|
||||
svc := NewService(NewRepository(db, nil), stubConfigReader{})
|
||||
|
||||
user := model.User{Phone: "18800000022", Nickname: "分销下调卖家", RealnameStatus: "verified", Status: "active"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
site := model.DistributionSite{
|
||||
SiteCode: "dist_ratio_down",
|
||||
SiteName: "下调收号站",
|
||||
Domain: "ratio-down.example.com",
|
||||
Status: "active",
|
||||
DisplayAccountEnabled: true,
|
||||
ReceiveRatioDelta: 3,
|
||||
}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dto, err := svc.Create(context.Background(), user.ID, CreateRequest{
|
||||
Title: "分销下调收号",
|
||||
ServerRegion: "QQ",
|
||||
LoginPlatform: "PC",
|
||||
RankLevel: "钻石",
|
||||
HafCoinAmount: 100_000_000,
|
||||
PriceCent: 100,
|
||||
DepositAmountCent: 15000,
|
||||
ScreenshotURLS: []string{"https://example.com/ratio-down.png"},
|
||||
AssetSummary: map[string]any{
|
||||
"fire_level": 50,
|
||||
"season_insurance": "3*3",
|
||||
"stamina_level": "7级",
|
||||
"load_level": "7级",
|
||||
"daily_loss_m": 10,
|
||||
"online_time": map[string]any{
|
||||
"start": "09:00",
|
||||
"end": "23:00",
|
||||
},
|
||||
"price_breakdown": map[string]any{
|
||||
"seller_ratio": 50.0,
|
||||
},
|
||||
},
|
||||
AgreedVirtualAssetSale: true,
|
||||
AgreedSellerAgreement: true,
|
||||
}, "dist_ratio_down")
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
pricing := ReceivePricingFromAssetSummary(mustMarshalSummary(t, dto.AssetSummary))
|
||||
if pricing.SellerRatio != 40 {
|
||||
t.Fatalf("seller_ratio = %v, want 40 after site delta 3 below main-site 43", pricing.SellerRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshalSummary(t *testing.T, summary map[string]any) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func (r *Repository) ReceiveRatioDeltaBySiteCode(ctx context.Context, siteCode string) float64 {
|
||||
siteCode = strings.TrimSpace(siteCode)
|
||||
if r == nil || r.db == nil || siteCode == "" {
|
||||
return 0
|
||||
}
|
||||
var delta float64
|
||||
_ = r.db.WithContext(ctx).Model(&model.DistributionSite{}).
|
||||
Select("COALESCE(receive_ratio_delta, 0)").
|
||||
Where("site_code = ? AND status = 'active'", siteCode).
|
||||
Limit(1).
|
||||
Scan(&delta)
|
||||
return NormalizeSiteReceiveRatioDelta(delta)
|
||||
}
|
||||
|
||||
func (r *Repository) ResolveReceiveRatioDelta(ctx context.Context, listingID uint64, siteCode string) (float64, bool) {
|
||||
if code := strings.TrimSpace(siteCode); code != "" {
|
||||
return r.ReceiveRatioDeltaBySiteCode(ctx, code), true
|
||||
}
|
||||
if r == nil || r.db == nil || listingID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
var siteID *uint64
|
||||
if err := r.db.WithContext(ctx).Model(&model.RentalListing{}).
|
||||
Select("distribution_site_id").
|
||||
Where("id = ?", listingID).
|
||||
Limit(1).
|
||||
Scan(&siteID).Error; err != nil || siteID == nil || *siteID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
var delta float64
|
||||
_ = r.db.WithContext(ctx).Model(&model.DistributionSite{}).
|
||||
Select("COALESCE(receive_ratio_delta, 0)").
|
||||
Where("id = ? AND status = 'active'", *siteID).
|
||||
Limit(1).
|
||||
Scan(&delta)
|
||||
return NormalizeSiteReceiveRatioDelta(delta), true
|
||||
}
|
||||
Reference in New Issue
Block a user