feat(listing): 优化优质号主发布与极品专区体验
This commit is contained in:
@@ -51,19 +51,20 @@ type ListingDTO struct {
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ServerRegion string `json:"server_region" binding:"required"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
AssetSummary map[string]any `json:"asset_summary"`
|
||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
IsPremium bool `json:"is_premium"`
|
||||
CompensationPlan string `json:"compensation_plan" binding:"omitempty,oneof=normal insurance"`
|
||||
InsuranceFeeBps int `json:"-"` // 服务端按配置和用户资格生成,不接受前端指定。
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ServerRegion string `json:"server_region" binding:"required"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
AssetSummary map[string]any `json:"asset_summary"`
|
||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
IsPremium bool `json:"is_premium"`
|
||||
CompensationPlan string `json:"compensation_plan" binding:"omitempty,oneof=normal insurance"`
|
||||
InsuranceFeeBps int `json:"-"` // 服务端按配置和用户资格生成,不接受前端指定。
|
||||
OwnerIsQualityOwner bool `json:"-"` // 服务端鉴权结果,用于校验优质号主专享比例。
|
||||
|
||||
AgreedVirtualAssetSale bool `json:"agreed_virtual_asset_sale"`
|
||||
AgreedSellerAgreement bool `json:"agreed_seller_agreement"`
|
||||
|
||||
@@ -59,6 +59,8 @@ func writeListingError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "分销站点无效或已停用")
|
||||
case errors.Is(err, ErrPremiumOwnerRequired):
|
||||
response.Error(c, http.StatusForbidden, "quality_owner_required", "仅优质号主可以发布到极品专区")
|
||||
case errors.Is(err, ErrQualityRatioPremiumOnly):
|
||||
response.BadRequest(c, "优质号主专享比例仅可用于极品专区")
|
||||
case isUploadValidationError(err):
|
||||
response.BadRequest(c, err.Error())
|
||||
case isFireLevelTooLow(err):
|
||||
|
||||
@@ -34,6 +34,9 @@ func TestApplyPublishEntitlementsPremiumInsurance(t *testing.T) {
|
||||
if req.InsuranceFeeBps != 400 {
|
||||
t.Fatalf("极品专区保险费率 = %d, want 400", req.InsuranceFeeBps)
|
||||
}
|
||||
if !req.OwnerIsQualityOwner {
|
||||
t.Fatal("优质号主发布请求未标记专享比例资格")
|
||||
}
|
||||
|
||||
ordinary := CreateRequest{CompensationPlan: CompensationPlanInsurance}
|
||||
if err := service.applyPublishEntitlements(t.Context(), owner.ID, &ordinary); err != nil {
|
||||
|
||||
@@ -49,7 +49,7 @@ func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRe
|
||||
return err
|
||||
}
|
||||
applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, receiveCostDelta, onDistributionSite)
|
||||
return nil
|
||||
return validateQualityOwnerPricingMode(req, onDistributionSite)
|
||||
}
|
||||
|
||||
func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.PublishOptionsDTO, config salePriceConfig, receiveRatioDelta, receiveCostDelta float64, onDistributionSite bool) {
|
||||
@@ -71,9 +71,16 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
||||
if requestedRatio <= 0 {
|
||||
requestedRatio = readSummaryNumber(breakdown["accelerated_sale_ratio"])
|
||||
}
|
||||
minSaleRatio := referenceRatio
|
||||
if req.OwnerIsQualityOwner {
|
||||
minSaleRatio = roundRatio(referenceRatio - NormalizeQualityOwnerRatioDiscount(options.RatioConfig.QualityOwnerRatioDiscount))
|
||||
if minSaleRatio < minSiteReceiveRatio {
|
||||
minSaleRatio = minSiteReceiveRatio
|
||||
}
|
||||
}
|
||||
costDelta := NormalizeSiteReceiveCostDelta(receiveCostDelta)
|
||||
ratioDelta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta)
|
||||
sellerRatio := clampSaleRatio(referenceRatio, requestedRatio)
|
||||
sellerRatio := clampSaleRatio(referenceRatio, requestedRatio, minSaleRatio)
|
||||
// 分站新口径:收号比例 = R + 压价点数(收号价更低),买家定价锚定主站参考比例 R,不随收号价联动。
|
||||
if onDistributionSite {
|
||||
sellerRatio = ApplySiteReceiveCostDelta(referenceRatio, costDelta)
|
||||
@@ -98,6 +105,10 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
||||
breakdown["consumable_price"] = consumablePrice
|
||||
breakdown["daily_loss_ratio_adjustment"] = calculateDailyLossRatioAdjustment(dailyLoss)
|
||||
breakdown["accelerated_sale_ratio"] = sellerRatio
|
||||
if sellerRatio < referenceRatio {
|
||||
breakdown["quality_owner_ratio_discount"] = roundRatio(referenceRatio - sellerRatio)
|
||||
breakdown["pricing_mode"] = "quality_owner_exclusive"
|
||||
}
|
||||
if onDistributionSite {
|
||||
breakdown["site_receive_cost_delta"] = costDelta
|
||||
breakdown["pricing_model"] = "site_cost_decoupled_v1"
|
||||
@@ -114,6 +125,18 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
||||
req.PriceCent = yuanToCent(pricing.BuyerTotalPrice)
|
||||
}
|
||||
|
||||
func validateQualityOwnerPricingMode(req *CreateRequest, onDistributionSite bool) error {
|
||||
if req == nil || onDistributionSite || !req.OwnerIsQualityOwner || req.IsPremium {
|
||||
return nil
|
||||
}
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) <
|
||||
readSummaryNumber(breakdown["seller_reference_ratio"]) {
|
||||
return ErrQualityRatioPremiumOnly
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeSiteReceiveCostDelta 将分站收号压价点数限制在 [0, 20]。
|
||||
// 点数越大,分站收号比例越高(相对主站参考比例),收号价越低;0 表示与主站同价收号。
|
||||
func NormalizeSiteReceiveCostDelta(delta float64) float64 {
|
||||
@@ -136,6 +159,17 @@ func ApplySiteReceiveCostDelta(referenceRatio, delta float64) float64 {
|
||||
return siteRatio
|
||||
}
|
||||
|
||||
// NormalizeQualityOwnerRatioDiscount 限制优质号主专享比例下调值,默认 1、最大 10。
|
||||
func NormalizeQualityOwnerRatioDiscount(value float64) float64 {
|
||||
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0
|
||||
}
|
||||
if value > maxAcceleratedSaleRatioIncrement {
|
||||
return maxAcceleratedSaleRatioIncrement
|
||||
}
|
||||
return roundRatio(value)
|
||||
}
|
||||
|
||||
// referenceSellerCoinBasePrice 计算主站参考比例下的号主基础价,用作买家定价锚点。
|
||||
func referenceSellerCoinBasePrice(coinWan, referenceRatio float64) float64 {
|
||||
if referenceRatio <= 0 {
|
||||
@@ -182,7 +216,7 @@ func (s *Service) publishRatioOptions(ctx context.Context) (systemconfig.Publish
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return options, nil
|
||||
}
|
||||
var parsed systemconfig.PublishOptionsDTO
|
||||
parsed := options
|
||||
if err := json.Unmarshal([]byte(value), &parsed); err != nil {
|
||||
return options, nil
|
||||
}
|
||||
@@ -225,13 +259,13 @@ func calculateSellerReferenceRatio(coinM float64, summary map[string]any, option
|
||||
calculateDailyLossRatioAdjustment(dailyLoss)
|
||||
}
|
||||
|
||||
func clampSaleRatio(referenceRatio, requestedRatio float64) float64 {
|
||||
func clampSaleRatio(referenceRatio, requestedRatio, minRatio float64) float64 {
|
||||
maxRatio := roundRatio(referenceRatio + maxAcceleratedSaleRatioIncrement)
|
||||
if requestedRatio <= 0 {
|
||||
return referenceRatio
|
||||
}
|
||||
if requestedRatio < referenceRatio {
|
||||
return referenceRatio
|
||||
if requestedRatio < minRatio {
|
||||
return minRatio
|
||||
}
|
||||
if requestedRatio > maxRatio {
|
||||
return maxRatio
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
@@ -81,6 +82,63 @@ func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMainSiteReceivePricingAllowsQualityOwnerExclusiveRatio(t *testing.T) {
|
||||
req := fullReceivePricingRequest(42)
|
||||
req.OwnerIsQualityOwner = true
|
||||
options := systemconfig.DefaultPublishOptions()
|
||||
applyMainSiteReceivePricing(&req, options, defaultSalePriceConfig(), 0, 0, false)
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) != 42 {
|
||||
t.Fatalf("quality owner seller_ratio = %v, want 42", breakdown["seller_ratio"])
|
||||
}
|
||||
if readSummaryNumber(breakdown["quality_owner_ratio_discount"]) != 1 {
|
||||
t.Fatalf(
|
||||
"quality owner discount = %v, want 1",
|
||||
breakdown["quality_owner_ratio_discount"],
|
||||
)
|
||||
}
|
||||
|
||||
ordinary := fullReceivePricingRequest(42)
|
||||
applyMainSiteReceivePricing(&ordinary, options, defaultSalePriceConfig(), 0, 0, false)
|
||||
ordinaryBreakdown := ensurePriceBreakdown(ordinary.AssetSummary)
|
||||
if readSummaryNumber(ordinaryBreakdown["seller_ratio"]) != 43 {
|
||||
t.Fatalf("ordinary seller_ratio = %v, want reference 43", ordinaryBreakdown["seller_ratio"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityOwnerExclusiveRatioUsesConfiguredDiscount(t *testing.T) {
|
||||
req := fullReceivePricingRequest(40)
|
||||
req.OwnerIsQualityOwner = true
|
||||
options := systemconfig.DefaultPublishOptions()
|
||||
options.RatioConfig.QualityOwnerRatioDiscount = 3
|
||||
applyMainSiteReceivePricing(&req, options, defaultSalePriceConfig(), 0, 0, false)
|
||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||
if readSummaryNumber(breakdown["seller_ratio"]) != 40 {
|
||||
t.Fatalf("configured quality owner seller_ratio = %v, want 40", breakdown["seller_ratio"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityOwnerExclusiveRatioRequiresPremiumZone(t *testing.T) {
|
||||
req := fullReceivePricingRequest(42)
|
||||
req.OwnerIsQualityOwner = true
|
||||
applyMainSiteReceivePricing(
|
||||
&req,
|
||||
systemconfig.DefaultPublishOptions(),
|
||||
defaultSalePriceConfig(),
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
if err := validateQualityOwnerPricingMode(&req, false); !errors.Is(err, ErrQualityRatioPremiumOnly) {
|
||||
t.Fatalf("error = %v, want ErrQualityRatioPremiumOnly", err)
|
||||
}
|
||||
|
||||
req.IsPremium = true
|
||||
if err := validateQualityOwnerPricingMode(&req, false); err != nil {
|
||||
t.Fatalf("极品专区发布不应拒绝优质号主专享比例: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionReceiveCostDeltaZeroKeepsMainSitePrice(t *testing.T) {
|
||||
req := fullReceivePricingRequest(50)
|
||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, true)
|
||||
@@ -146,10 +204,10 @@ func TestApplySiteReceiveRatioDeltaCapsAtMainSite(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClampSaleRatioRejectsBelowReference(t *testing.T) {
|
||||
if got := clampSaleRatio(43, 10); got != 43 {
|
||||
if got := clampSaleRatio(43, 10, 43); got != 43 {
|
||||
t.Fatalf("clamp below reference = %v, want 43", got)
|
||||
}
|
||||
if got := clampSaleRatio(43, 80); got != 53 {
|
||||
if got := clampSaleRatio(43, 80, 43); got != 53 {
|
||||
t.Fatalf("clamp above max = %v, want 53", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ var (
|
||||
ErrSaleRatioOutOfRange = errors.New("sale ratio out of range")
|
||||
ErrDistributionSiteInvalid = errors.New("invalid distribution site")
|
||||
ErrPremiumOwnerRequired = errors.New("premium listing requires quality owner")
|
||||
ErrQualityRatioPremiumOnly = errors.New("quality owner exclusive ratio requires premium zone")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
|
||||
@@ -18,6 +18,9 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest,
|
||||
if len(siteCode) > 0 {
|
||||
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, 0, effectiveSiteCode)
|
||||
if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil {
|
||||
return nil, err
|
||||
@@ -32,9 +35,6 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest,
|
||||
if err := validateRequiredOnlineTime(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cooldown, err := s.publishCooldown(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -54,6 +54,9 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd
|
||||
if len(siteCode) > 0 {
|
||||
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, id, effectiveSiteCode)
|
||||
if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil {
|
||||
return nil, err
|
||||
@@ -68,9 +71,6 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd
|
||||
if err := validateRequiredOnlineTime(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reviewRequired, err := s.reviewRequired(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -94,6 +94,7 @@ func (s *Service) applyPublishEntitlements(ctx context.Context, ownerID uint64,
|
||||
if req.IsPremium && !isQualityOwner {
|
||||
return ErrPremiumOwnerRequired
|
||||
}
|
||||
req.OwnerIsQualityOwner = isQualityOwner
|
||||
|
||||
req.InsuranceFeeBps = 0
|
||||
if req.CompensationPlan != CompensationPlanInsurance {
|
||||
|
||||
@@ -182,9 +182,10 @@ type PublishDepositSkinGroupRule struct {
|
||||
}
|
||||
|
||||
type PublishRatioConfig struct {
|
||||
InsuranceBaseRatios []PublishInsuranceBaseRatio `json:"insurance_base_ratios"`
|
||||
ConfigItems []PublishRatioConfigItem `json:"config_items"`
|
||||
CoinCorrections []PublishCoinCorrection `json:"coin_corrections"`
|
||||
QualityOwnerRatioDiscount float64 `json:"quality_owner_ratio_discount"`
|
||||
InsuranceBaseRatios []PublishInsuranceBaseRatio `json:"insurance_base_ratios"`
|
||||
ConfigItems []PublishRatioConfigItem `json:"config_items"`
|
||||
CoinCorrections []PublishCoinCorrection `json:"coin_corrections"`
|
||||
}
|
||||
|
||||
type PublishInsuranceBaseRatio struct {
|
||||
|
||||
@@ -105,6 +105,7 @@ func DefaultPublishOptions() PublishOptionsDTO {
|
||||
},
|
||||
},
|
||||
RatioConfig: PublishRatioConfig{
|
||||
QualityOwnerRatioDiscount: 1,
|
||||
InsuranceBaseRatios: []PublishInsuranceBaseRatio{
|
||||
{Insurance: "3*3", Ratio: 40},
|
||||
{Insurance: "2*3", Ratio: 42},
|
||||
|
||||
@@ -106,6 +106,54 @@ func TestNormalizePublishOptionsValuePreservesCurrentSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePublishOptionsValueDefaultsQualityOwnerRatioDiscount(t *testing.T) {
|
||||
legacy := `{
|
||||
"schema_version": 2,
|
||||
"ratio_config": {
|
||||
"insurance_base_ratios": [{"insurance": "3*3", "ratio": 40}]
|
||||
}
|
||||
}`
|
||||
|
||||
normalized, err := normalizePublishOptionsValue(legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("规范化失败: %v", err)
|
||||
}
|
||||
var got PublishOptionsDTO
|
||||
if err := json.Unmarshal([]byte(normalized), &got); err != nil {
|
||||
t.Fatalf("解析规范化结果失败: %v", err)
|
||||
}
|
||||
if got.RatioConfig.QualityOwnerRatioDiscount != 1 {
|
||||
t.Fatalf(
|
||||
"quality_owner_ratio_discount = %v, want 1",
|
||||
got.RatioConfig.QualityOwnerRatioDiscount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePublishOptionsValuePreservesExplicitZeroQualityOwnerDiscount(t *testing.T) {
|
||||
options := DefaultPublishOptions()
|
||||
options.RatioConfig.QualityOwnerRatioDiscount = 0
|
||||
raw, err := json.Marshal(options)
|
||||
if err != nil {
|
||||
t.Fatalf("构造配置失败: %v", err)
|
||||
}
|
||||
|
||||
normalized, err := normalizePublishOptionsValue(string(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("规范化失败: %v", err)
|
||||
}
|
||||
var got PublishOptionsDTO
|
||||
if err := json.Unmarshal([]byte(normalized), &got); err != nil {
|
||||
t.Fatalf("解析规范化结果失败: %v", err)
|
||||
}
|
||||
if got.RatioConfig.QualityOwnerRatioDiscount != 0 {
|
||||
t.Fatalf(
|
||||
"quality_owner_ratio_discount = %v, want explicit 0",
|
||||
got.RatioConfig.QualityOwnerRatioDiscount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePublishOptionsValueMigratesSchemaV1Switches(t *testing.T) {
|
||||
legacy := `{"schema_version":1,"next_season_pass_enabled":false,"server_options":["QQ"]}`
|
||||
|
||||
|
||||
@@ -333,6 +333,10 @@ func normalizePublishOptionsValue(value string) (string, error) {
|
||||
if err := json.Unmarshal([]byte(value), &fields); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasNestedJSONField(fields, "ratio_config", "quality_owner_ratio_discount") {
|
||||
options.RatioConfig.QualityOwnerRatioDiscount =
|
||||
DefaultPublishOptions().RatioConfig.QualityOwnerRatioDiscount
|
||||
}
|
||||
originalVersion := options.SchemaVersion
|
||||
if options.SchemaVersion < publishOptionsSchemaVersion {
|
||||
if hasLegacyPublishQuantityItem(options.QuantityItems) {
|
||||
@@ -360,6 +364,19 @@ func normalizePublishOptionsValue(value string) (string, error) {
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func hasNestedJSONField(fields map[string]json.RawMessage, parent, child string) bool {
|
||||
raw, ok := fields[parent]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var nested map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &nested); err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok = nested[child]
|
||||
return ok
|
||||
}
|
||||
|
||||
func hasLegacyPublishQuantityItem(items []PublishQuantityItem) bool {
|
||||
for _, item := range items {
|
||||
if item.Key == "kit5" {
|
||||
|
||||
Reference in New Issue
Block a user