feat(distribution): 分站可下调收号比例且不超过主站上限

让分销站能按自身能力下调收号比例扩大利润,同时把主站回收比例作为硬边界,避免分站把比例加得比主站更高。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-15 20:35:30 +08:00
co-authored by Cursor
parent 1c86bef765
commit c9d9160ff4
27 changed files with 1159 additions and 106 deletions
+1
View File
@@ -17,6 +17,7 @@ type DistributionSite struct {
FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"`
ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"`
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
+50 -43
View File
@@ -16,6 +16,7 @@ type SiteDTO struct {
FooterInfoJSON string `json:"footer_info_json"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
ListingQrcodeURL string `json:"listing_qrcode_url"`
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
SettlementHoldDays int `json:"settlement_hold_days"`
@@ -24,32 +25,34 @@ type SiteDTO struct {
}
type CreateSiteRequest struct {
SiteCode string `json:"site_code" binding:"required,max=64"`
SiteName string `json:"site_name" binding:"required,max=128"`
Domain string `json:"domain" binding:"required,max=255"`
DisplayAccountEnabled *bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
ListingQrcodeURL *string `json:"listing_qrcode_url"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
SettlementHoldDays *int `json:"settlement_hold_days"`
SiteCode string `json:"site_code" binding:"required,max=64"`
SiteName string `json:"site_name" binding:"required,max=128"`
Domain string `json:"domain" binding:"required,max=255"`
DisplayAccountEnabled *bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
ListingQrcodeURL *string `json:"listing_qrcode_url"`
ReceiveRatioDelta *float64 `json:"receive_ratio_delta"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
SettlementHoldDays *int `json:"settlement_hold_days"`
}
type UpdateSiteRequest struct {
SiteName *string `json:"site_name"`
Domain *string `json:"domain"`
Status *string `json:"status"`
DisplayAccountEnabled *bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
ListingQrcodeURL *string `json:"listing_qrcode_url"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
SettlementHoldDays *int `json:"settlement_hold_days"`
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
BannersJSON *string `json:"banners_json"`
AnnouncementsJSON *string `json:"announcements_json"`
FooterInfoJSON *string `json:"footer_info_json"`
SiteName *string `json:"site_name"`
Domain *string `json:"domain"`
Status *string `json:"status"`
DisplayAccountEnabled *bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
ListingQrcodeURL *string `json:"listing_qrcode_url"`
ReceiveRatioDelta *float64 `json:"receive_ratio_delta"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
SettlementHoldDays *int `json:"settlement_hold_days"`
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
BannersJSON *string `json:"banners_json"`
AnnouncementsJSON *string `json:"announcements_json"`
FooterInfoJSON *string `json:"footer_info_json"`
}
type RuleDTO struct {
@@ -244,6 +247,7 @@ type PublicSiteInfoDTO struct {
AnnouncementsJSON string `json:"announcements_json"`
FooterInfoJSON string `json:"footer_info_json"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
}
type UpdateAccountDisplayRequest struct {
@@ -353,26 +357,29 @@ type OnboardSiteResponse struct {
// ReceivedListingDTO 是分销站点收到的账号资产信息
type ReceivedListingDTO struct {
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
SiteID uint64 `json:"site_id"`
AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"`
OwnerPhone string `json:"owner_phone"`
OwnerNickname string `json:"owner_nickname"`
Title string `json:"title"`
GameName string `json:"game_name"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RankLevel string `json:"rank_level"`
HafCoinAmount int64 `json:"haf_coin_amount"`
PriceCent int64 `json:"price_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
Status string `json:"status"`
ReviewStatus string `json:"review_status"`
ReviewReason string `json:"review_reason"`
PublishedAt *time.Time `json:"published_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
SiteID uint64 `json:"site_id"`
AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"`
OwnerPhone string `json:"owner_phone"`
OwnerNickname string `json:"owner_nickname"`
Title string `json:"title"`
GameName string `json:"game_name"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RankLevel string `json:"rank_level"`
HafCoinAmount int64 `json:"haf_coin_amount"`
SellerRatio float64 `json:"seller_ratio"`
SellerTotalPriceCent int64 `json:"seller_total_price_cent"`
BuyerRatio float64 `json:"buyer_ratio"`
PriceCent int64 `json:"price_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
Status string `json:"status"`
ReviewStatus string `json:"review_status"`
ReviewReason string `json:"review_reason"`
PublishedAt *time.Time `json:"published_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ReceivedListingQuery struct {
@@ -68,6 +68,7 @@ func setupHandlerTestEnv(t *testing.T) *handlerTestEnv {
siteProtected.POST("/sites/:id/withdrawals", h.ApplySiteWithdrawal)
siteProtected.GET("/sites/:id/withdrawals", h.ListSiteWithdrawals)
siteProtected.GET("/sites/:id/analytics", h.GetDashboardAnalytics)
siteProtected.PUT("/sites/:id", h.UpdateSite)
siteProtected.PUT("/sites/:id/branding", h.UpdateSiteBranding)
siteProtected.POST("/sites/:id/sync-accounts", h.SyncSiteAccounts)
siteProtected.GET("/received-listings", h.ListReceivedListings)
@@ -404,6 +405,33 @@ func TestHandler_SiteOperatorEndpoints(t *testing.T) {
t.Fatalf("branding expected 200, got %d: %s", wBrand.Code, wBrand.Body.String())
}
delta := 2.5
wRatio := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d", siteA.ID), UpdateSiteRequest{ReceiveRatioDelta: &delta}, adminA.ID, nil)
if wRatio.Code != http.StatusOK {
t.Fatalf("receive ratio expected 200, got %d: %s", wRatio.Code, wRatio.Body.String())
}
var ratioResp struct {
Code string `json:"code"`
Data SiteDTO `json:"data"`
}
json.Unmarshal(wRatio.Body.Bytes(), &ratioResp)
if ratioResp.Data.ReceiveRatioDelta != 2.5 {
t.Fatalf("receive_ratio_delta = %v, want 2.5", ratioResp.Data.ReceiveRatioDelta)
}
hackedName := "越权改名"
wHackName := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d", siteA.ID), UpdateSiteRequest{SiteName: &hackedName}, adminA.ID, nil)
if wHackName.Code != http.StatusOK {
t.Fatalf("operator privileged field expected 200 with ignore, got %d: %s", wHackName.Code, wHackName.Body.String())
}
json.Unmarshal(wHackName.Body.Bytes(), &ratioResp)
if ratioResp.Data.SiteName != siteA.SiteName {
t.Fatalf("operator must not rename site, got %q", ratioResp.Data.SiteName)
}
wCrossRatio := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d", siteA.ID), UpdateSiteRequest{ReceiveRatioDelta: &delta}, adminB.ID, nil)
if wCrossRatio.Code != http.StatusForbidden {
t.Fatalf("cross-site receive ratio expected 403, got %d: %s", wCrossRatio.Code, wCrossRatio.Body.String())
}
// 6. 同步主站商品货架
opOwner := model.User{Phone: "13800000077", Status: "active"}
env.db.Create(&opOwner)
@@ -20,6 +20,7 @@ import (
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -64,6 +65,13 @@ func normalizeSettlementHoldDays(days int) (int, error) {
return days, nil
}
func normalizeReceiveRatioDelta(delta float64) (float64, error) {
if delta < 0 {
return 0, ErrInvalidAmount
}
return listingpkg.NormalizeSiteReceiveRatioDelta(delta), nil
}
type Repository struct {
db *gorm.DB
redis *redis.Client
@@ -256,6 +264,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
if req.ListingQrcodeURL != nil {
row.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL)
}
if req.ReceiveRatioDelta != nil {
delta, err := normalizeReceiveRatioDelta(*req.ReceiveRatioDelta)
if err != nil {
return nil, err
}
row.ReceiveRatioDelta = delta
}
if req.TaxEnabled != nil {
row.TaxEnabled = *req.TaxEnabled
}
@@ -293,6 +308,27 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.ID); err != nil {
return err
}
globalAccess := adminID == 0
if !globalAccess {
global, accessErr := r.hasGlobalDistributionAccessWithDB(ctx, tx, adminID)
if accessErr != nil {
return accessErr
}
globalAccess = global
}
if req.ReceiveRatioDelta != nil {
delta, err := normalizeReceiveRatioDelta(*req.ReceiveRatioDelta)
if err != nil {
return err
}
row.ReceiveRatioDelta = delta
}
if !globalAccess {
if err := tx.Save(&row).Error; err != nil {
return err
}
return nil
}
if req.SiteName != nil {
row.SiteName = strings.TrimSpace(*req.SiteName)
}
@@ -474,6 +510,7 @@ func siteDTO(row model.DistributionSite) SiteDTO {
DisplayAccountEnabled: row.DisplayAccountEnabled,
DefaultSupportGroupID: row.DefaultSupportGroupID,
ListingQrcodeURL: row.ListingQrcodeURL,
ReceiveRatioDelta: row.ReceiveRatioDelta,
TaxEnabled: row.TaxEnabled,
TaxRateBPS: row.TaxRateBPS,
SettlementHoldDays: row.SettlementHoldDays,
@@ -2097,6 +2134,7 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string)
BannersJSON: site.BannersJSON,
AnnouncementsJSON: site.AnnouncementsJSON,
FooterInfoJSON: site.FooterInfoJSON,
ReceiveRatioDelta: site.ReceiveRatioDelta,
}
if r.redis != nil {
if bytes, err := json.Marshal(res); err == nil {
@@ -2347,28 +2385,29 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u
}
type receivedListingRow struct {
ID uint64 `gorm:"column:id"`
ListingNo string `gorm:"column:listing_no"`
AccountID uint64 `gorm:"column:account_id"`
OwnerID uint64 `gorm:"column:owner_id"`
OwnerUsername string `gorm:"column:owner_username"`
OwnerPhone string `gorm:"column:owner_phone"`
GameName string `gorm:"column:game_name"`
Title string `gorm:"column:title"`
ServerRegion string `gorm:"column:server_region"`
LoginPlatform string `gorm:"column:login_platform"`
RankLevel string `gorm:"column:rank_level"`
HafCoinAmount int64 `gorm:"column:haf_coin_amount"`
PriceCent int64 `gorm:"column:price_cent"`
DepositAmountCent int64 `gorm:"column:deposit_amount_cent"`
Status string `gorm:"column:status"`
ReviewStatus string `gorm:"column:review_status"`
ReviewReason string `gorm:"column:review_reason"`
InTransaction bool `gorm:"column:in_transaction"`
DistributionSiteID uint64 `gorm:"column:distribution_site_id"`
DisplayStatus string `gorm:"column:display_status"`
CreatedAt time.Time `gorm:"column:created_at"`
PublishedAt *time.Time `gorm:"column:published_at"`
ID uint64 `gorm:"column:id"`
ListingNo string `gorm:"column:listing_no"`
AccountID uint64 `gorm:"column:account_id"`
OwnerID uint64 `gorm:"column:owner_id"`
OwnerUsername string `gorm:"column:owner_username"`
OwnerPhone string `gorm:"column:owner_phone"`
GameName string `gorm:"column:game_name"`
Title string `gorm:"column:title"`
ServerRegion string `gorm:"column:server_region"`
LoginPlatform string `gorm:"column:login_platform"`
RankLevel string `gorm:"column:rank_level"`
HafCoinAmount int64 `gorm:"column:haf_coin_amount"`
AssetSummary datatypes.JSON `gorm:"column:asset_summary"`
PriceCent int64 `gorm:"column:price_cent"`
DepositAmountCent int64 `gorm:"column:deposit_amount_cent"`
Status string `gorm:"column:status"`
ReviewStatus string `gorm:"column:review_status"`
ReviewReason string `gorm:"column:review_reason"`
InTransaction bool `gorm:"column:in_transaction"`
DistributionSiteID uint64 `gorm:"column:distribution_site_id"`
DisplayStatus string `gorm:"column:display_status"`
CreatedAt time.Time `gorm:"column:created_at"`
PublishedAt *time.Time `gorm:"column:published_at"`
}
var rows []receivedListingRow
@@ -2377,6 +2416,7 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u
COALESCE(u.nickname, '') AS owner_username,
COALESCE(u.phone, '') AS owner_phone,
ga.game_name, ga.title, ga.server_region, ga.login_platform, ga.rank_level, ga.haf_coin_amount,
ga.asset_summary,
rl.price_cent, rl.deposit_amount_cent, rl.status, rl.review_status, rl.review_reason,
rl.in_transaction, COALESCE(rl.distribution_site_id, 0) AS distribution_site_id,
COALESCE(dsa.display_status, 'enabled') AS display_status,
@@ -2396,27 +2436,31 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u
if r.DisplayStatus == "disabled" {
status = "offline"
}
pricing := listingpkg.ReceivePricingFromAssetSummary(r.AssetSummary)
items = append(items, ReceivedListingDTO{
ListingID: r.ID,
ListingNo: r.ListingNo,
SiteID: r.DistributionSiteID,
AccountID: r.AccountID,
OwnerID: r.OwnerID,
OwnerPhone: maskedPhone,
OwnerNickname: r.OwnerUsername,
Title: r.Title,
GameName: r.GameName,
ServerRegion: r.ServerRegion,
LoginPlatform: r.LoginPlatform,
RankLevel: r.RankLevel,
HafCoinAmount: r.HafCoinAmount,
PriceCent: r.PriceCent,
DepositAmountCent: r.DepositAmountCent,
Status: status,
ReviewStatus: r.ReviewStatus,
ReviewReason: r.ReviewReason,
PublishedAt: r.PublishedAt,
CreatedAt: r.CreatedAt,
ListingID: r.ID,
ListingNo: r.ListingNo,
SiteID: r.DistributionSiteID,
AccountID: r.AccountID,
OwnerID: r.OwnerID,
OwnerPhone: maskedPhone,
OwnerNickname: r.OwnerUsername,
Title: r.Title,
GameName: r.GameName,
ServerRegion: r.ServerRegion,
LoginPlatform: r.LoginPlatform,
RankLevel: r.RankLevel,
HafCoinAmount: r.HafCoinAmount,
SellerRatio: pricing.SellerRatio,
SellerTotalPriceCent: pricing.SellerTotalPriceCent,
BuyerRatio: pricing.BuyerRatio,
PriceCent: r.PriceCent,
DepositAmountCent: r.DepositAmountCent,
Status: status,
ReviewStatus: r.ReviewStatus,
ReviewReason: r.ReviewReason,
PublishedAt: r.PublishedAt,
CreatedAt: r.CreatedAt,
})
}
@@ -1,6 +1,7 @@
package distribution
import (
"encoding/json"
"errors"
"testing"
"time"
@@ -8,6 +9,7 @@ import (
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
@@ -666,7 +668,19 @@ func TestDistributionBrandingAndMarkup(t *testing.T) {
t.Fatalf("cleared listing_qrcode_url = %q, want empty", qrCleared.ListingQrcodeURL)
}
// Verify PublicSiteInfo contains branding
delta := 3.5
ratioUpdated, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ReceiveRatioDelta: &delta})
if err != nil {
t.Fatalf("UpdateSite receive ratio failed: %v", err)
}
if ratioUpdated.ReceiveRatioDelta != 3.5 {
t.Fatalf("receive_ratio_delta = %v, want 3.5", ratioUpdated.ReceiveRatioDelta)
}
negative := -1.0
if _, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ReceiveRatioDelta: &negative}); !errors.Is(err, ErrInvalidAmount) {
t.Fatalf("negative receive ratio err = %v, want ErrInvalidAmount", err)
}
pubInfo, err := repo.GetPublicSiteInfo(t.Context(), "brand_site", "")
if err != nil {
t.Fatalf("GetPublicSiteInfo failed: %v", err)
@@ -674,6 +688,9 @@ func TestDistributionBrandingAndMarkup(t *testing.T) {
if pubInfo.LogoURL != "https://cdn.example.com/logo.png" {
t.Fatalf("expected LogoURL, got: %+v", pubInfo)
}
if pubInfo.ReceiveRatioDelta != 3.5 {
t.Fatalf("public receive_ratio_delta = %v, want 3.5", pubInfo.ReceiveRatioDelta)
}
faviconOnly := "https://cdn.example.com/favicon2.ico"
partial, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{FaviconURL: &faviconOnly})
@@ -746,12 +763,24 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
t.Fatal(err)
}
summary, err := json.Marshal(map[string]any{
"price_breakdown": map[string]any{
"seller_ratio": 43.0,
"seller_total_price": 232.6,
"buyer_ratio": 38.0,
},
})
if err != nil {
t.Fatal(err)
}
ga := model.GameAccount{
OwnerID: user.ID,
GameName: "delta_force",
ServerRegion: "cn",
LoginPlatform: "pc",
Title: "满配三角洲账号",
HafCoinAmount: 100_000_000,
AssetSummary: datatypes.JSON(summary),
}
if err := db.Create(&ga).Error; err != nil {
t.Fatal(err)
@@ -786,6 +815,9 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
if item.ListingNo != "RL-99001" || item.GameName != "delta_force" || item.OwnerNickname != "seller01" {
t.Fatalf("unexpected received listing: %+v", item)
}
if item.SellerRatio != 43 || item.SellerTotalPriceCent != 23260 || item.BuyerRatio != 38 {
t.Fatalf("expected main-site receive pricing, got ratio=%v price=%d buyer=%v", item.SellerRatio, item.SellerTotalPriceCent, item.BuyerRatio)
}
if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "inactive"); !errors.Is(err, ErrInvalidListingStatus) {
t.Fatalf("inactive status err = %v", err)
@@ -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
}
+1
View File
@@ -617,6 +617,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
distributionProtectedRoutes.GET("/auth/me", distributionHandler.Me)
distributionProtectedRoutes.POST("/auth/logout", distributionHandler.Logout)
distributionProtectedRoutes.POST("/sites/:id/sync-accounts", distributionHandler.SyncSiteAccounts)
distributionProtectedRoutes.PUT("/sites/:id", distributionHandler.UpdateSite)
distributionProtectedRoutes.PUT("/sites/:id/branding", distributionHandler.UpdateSiteBranding)
distributionProtectedRoutes.GET("/sites/:id/wallet", distributionHandler.GetSiteWallet)
distributionProtectedRoutes.POST("/sites/:id/withdrawals", distributionHandler.ApplySiteWithdrawal)
@@ -0,0 +1,10 @@
-- +goose Up
-- 分站收号比例相对主站的下调点数。主站回收比例为上限,分站只能下调(比例更低、号主底价更高),不能高于主站。
ALTER TABLE distribution_sites
ADD COLUMN receive_ratio_delta DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '分站收号比例相对主站下调点数,0 表示与主站一致';
-- +goose Down
ALTER TABLE distribution_sites
DROP COLUMN receive_ratio_delta;