diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index 6011b1b..bef0ebc 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -46,6 +46,7 @@ type DistributionSiteAccount struct { ListingID uint64 `gorm:"not null;index" json:"listing_id"` DisplayStatus string `gorm:"size:16;not null;default:'enabled';index" json:"display_status"` MarkupAmountCent int64 `gorm:"not null;default:0" json:"markup_amount_cent"` + ShelfRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio_delta"` DisplayStartedAt *time.Time `json:"display_started_at,omitempty"` DisplayEndedAt *time.Time `json:"display_ended_at,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index d3674b8..53448e2 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -29,6 +29,8 @@ type RentalOrder struct { BasePriceCent int64 `gorm:"not null;default:0" json:"-"` ShelfMarkupCent int64 `gorm:"not null;default:0" json:"-"` MarkupSiteID uint64 `gorm:"not null;default:0;index" json:"markup_site_id,omitempty"` + ShelfRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio,omitempty"` + ShelfRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio_delta,omitempty"` RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"` PureCoinOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` diff --git a/backend/internal/modules/distribution/auth_onboard_test.go b/backend/internal/modules/distribution/auth_onboard_test.go index bf2df8b..6996657 100644 --- a/backend/internal/modules/distribution/auth_onboard_test.go +++ b/backend/internal/modules/distribution/auth_onboard_test.go @@ -48,8 +48,8 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) { if resp.Member.SiteID == nil || *resp.Member.SiteID != resp.Site.ID { t.Fatalf("expected SiteID=%d, got %v", resp.Site.ID, resp.Member.SiteID) } - if resp.Rule.DistributorReceiveRatio != 3.8 { - t.Fatalf("expected rule ratio 3.8, got %f", resp.Rule.DistributorReceiveRatio) + if resp.Rule.DistributorReceiveRatio != 38 { + t.Fatalf("expected rule percent 38, got %f", resp.Rule.DistributorReceiveRatio) } if resp.Rule.Status != "draft" { t.Fatalf("onboard rule must stay draft until split is verified, got %s", resp.Rule.Status) diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index cc20d25..9d8290f 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -131,23 +131,27 @@ type DistributionOrderPage struct { } type AccountDisplayDTO struct { - ID uint64 `json:"id"` - SiteID uint64 `json:"site_id"` - AccountID uint64 `json:"account_id"` - ListingID uint64 `json:"listing_id"` - DisplayStatus string `json:"display_status"` - MarkupAmountCent int64 `json:"markup_amount_cent"` - ListingNo string `json:"listing_no,omitempty"` - Title string `json:"title,omitempty"` - GameName string `json:"game_name,omitempty"` - ServerRegion string `json:"server_region,omitempty"` - LoginPlatform string `json:"login_platform,omitempty"` - RankLevel string `json:"rank_level,omitempty"` - HafCoinAmount int64 `json:"haf_coin_amount,omitempty"` - PriceCent int64 `json:"price_cent,omitempty"` - DepositAmountCent int64 `json:"deposit_amount_cent,omitempty"` - ListingStatus string `json:"listing_status,omitempty"` - InTransaction bool `json:"in_transaction,omitempty"` + ID uint64 `json:"id"` + SiteID uint64 `json:"site_id"` + AccountID uint64 `json:"account_id"` + ListingID uint64 `json:"listing_id"` + DisplayStatus string `json:"display_status"` + MarkupAmountCent int64 `json:"markup_amount_cent"` + ShelfRatioDelta float64 `json:"shelf_ratio_delta"` + BaseRatio float64 `json:"base_ratio,omitempty"` + SiteRatio float64 `json:"site_ratio,omitempty"` + ListingNo string `json:"listing_no,omitempty"` + Title string `json:"title,omitempty"` + GameName string `json:"game_name,omitempty"` + ServerRegion string `json:"server_region,omitempty"` + LoginPlatform string `json:"login_platform,omitempty"` + RankLevel string `json:"rank_level,omitempty"` + HafCoinAmount int64 `json:"haf_coin_amount,omitempty"` + PriceCent int64 `json:"price_cent,omitempty"` + SitePriceCent int64 `json:"site_price_cent,omitempty"` + DepositAmountCent int64 `json:"deposit_amount_cent,omitempty"` + ListingStatus string `json:"listing_status,omitempty"` + InTransaction bool `json:"in_transaction,omitempty"` } type SyncSiteAccountsRequest struct { @@ -161,21 +165,22 @@ type SyncSiteAccountsResult struct { } type PublicDistributionListingDTO struct { - ListingID uint64 `json:"listing_id"` - ListingNo string `json:"listing_no"` - AccountID uint64 `json:"account_id"` - 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"` - MarkupAmountCent int64 `json:"markup_amount_cent"` - InTransaction bool `json:"in_transaction"` - SiteCode string `json:"site_code"` - SiteName string `json:"site_name"` + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + 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"` + MarkupAmountCent int64 `json:"markup_amount_cent"` + ShelfRatioDelta float64 `json:"shelf_ratio_delta"` + InTransaction bool `json:"in_transaction"` + SiteCode string `json:"site_code"` + SiteName string `json:"site_name"` } type PublicDistributionListingsResult struct { @@ -199,7 +204,6 @@ type PublicSiteInfoDTO struct { DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` } - type UpdateAccountDisplayRequest struct { DisplayStatus string `json:"display_status" binding:"required,oneof=enabled disabled sold offline"` } @@ -359,14 +363,14 @@ type UpdateSiteBrandingRequest struct { FooterInfoJSON *string `json:"footer_info_json"` } -// 自主加价 DTO +// 自主加价 DTO:增加该站「1 元兑换哈夫币」比例,不是人民币。 type UpdateAccountMarkupRequest struct { - MarkupAmountCent int64 `json:"markup_amount_cent"` + ShelfRatioDelta float64 `json:"shelf_ratio_delta"` } type BatchUpdateAccountMarkupRequest struct { - ListingIDs []uint64 `json:"listing_ids" binding:"required"` - MarkupAmountCent int64 `json:"markup_amount_cent"` + ListingIDs []uint64 `json:"listing_ids" binding:"required"` + ShelfRatioDelta float64 `json:"shelf_ratio_delta"` } // 商户钱包与提现 DTO @@ -435,4 +439,3 @@ type DistributionTrendPoint struct { GMVCent int64 `json:"gmv_cent"` ProfitCent int64 `json:"profit_cent"` } - diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index 29aa3fc..b9fa2f2 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -264,6 +264,9 @@ func (h *Handler) SyncSiteAccounts(c *gin.Context) { func (h *Handler) ListPublicSiteListings(c *gin.Context) { siteCode := strings.TrimSpace(c.Param("site_code")) + if siteCode == "" { + siteCode = strings.TrimSpace(c.Param("id")) + } if siteCode == "" { siteCode = strings.TrimSpace(c.Query("site_code")) } @@ -544,7 +547,7 @@ func (h *Handler) UpdateAccountMarkup(c *gin.Context) { return } adminID, _ := currentAdminID(c) - dto, err := h.service.UpdateAccountMarkup(c.Request.Context(), adminID, id, req.MarkupAmountCent) + dto, err := h.service.UpdateAccountMarkup(c.Request.Context(), adminID, id, req.ShelfRatioDelta) if err != nil { writeError(c, err) return diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go index ecd5fc2..3eafcec 100644 --- a/backend/internal/modules/distribution/lifecycle.go +++ b/backend/internal/modules/distribution/lifecycle.go @@ -148,7 +148,7 @@ func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uin } func ruleFromVersion(rule model.DistributionRuleVersion) Rule { - return Rule{ + parsed := Rule{ Mode: rule.Mode, PlatformReceiveRatio: rule.PlatformReceiveRatio, PlatformSellRatio: rule.PlatformSellRatio, @@ -162,6 +162,8 @@ func ruleFromVersion(rule model.DistributionRuleVersion) Rule { TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, } + normalizeRulePercents(&parsed) + return parsed } // calculateForRule 按规则快照计算手续费、服务费和分成基数。 @@ -170,7 +172,7 @@ func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput) } // SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。 -// 参与方金额按规则比例对分成基数拆分,剩余(含尾差)记入 RoundingAdjustmentCent 归平台。 +// 利润 = 租金 − 号主 − 通道手续费,再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。 func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error { if !distributionTablesPresent(tx) { return nil @@ -186,16 +188,24 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error if err := tx.First(&rule, *order.DistributionRuleVersionID).Error; err != nil { return err } + grossRent := order.RentAmountCent + if grossRent <= 0 { + grossRent = grossPaidCent + } markup := order.ShelfMarkupCent if markup < 0 { markup = 0 } - result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossPaidCent, MarkupAmountCent: markup}) + ownerRent := order.OwnerRentAmountCent + if ownerRent < 0 { + ownerRent = 0 + } + result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossRent, OwnerRentAmountCent: ownerRent, MarkupAmountCent: markup}) if err != nil { return err } - splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, markup) - financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossPaidCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRemainderCent(result.SplitBaseAmountCent, splits), SettlementStatus: "pending"} + splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent) + financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossRent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, splits), SettlementStatus: "pending"} if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil { return err } @@ -242,19 +252,30 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error { } return err } - if refundAmountCent < 0 || refundAmountCent > financial.GrossPaidAmountCent { + if refundAmountCent < 0 { return ErrInvalidAmount } + if refundAmountCent > financial.GrossPaidAmountCent { + refundAmountCent = financial.GrossPaidAmountCent + } var rule model.DistributionRuleVersion if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil { return err } - result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, MarkupAmountCent: financial.MarkupAmountCent}) + var order model.RentalOrder + ownerRent := int64(0) + if err := tx.Select("owner_rent_amount_cent").First(&order, orderID).Error; err == nil { + ownerRent = order.OwnerRentAmountCent + if ownerRent < 0 { + ownerRent = 0 + } + } + result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, OwnerRentAmountCent: ownerRent, MarkupAmountCent: financial.MarkupAmountCent}) if err != nil { return err } - splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent) - if err := tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "net_paid_amount_cent": result.NetPaidAmountCent, "payment_fee_cent": result.PaymentFeeCent, "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "rounding_adjustment_cent": SplitRemainderCent(result.SplitBaseAmountCent, splits), "settlement_status": "pending"}).Error; err != nil { + splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent) + if err := tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "net_paid_amount_cent": result.NetPaidAmountCent, "payment_fee_cent": result.PaymentFeeCent, "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, splits), "settlement_status": "pending"}).Error; err != nil { return err } for _, split := range splits { diff --git a/backend/internal/modules/distribution/lifecycle_test.go b/backend/internal/modules/distribution/lifecycle_test.go index 89df585..5de39f9 100644 --- a/backend/internal/modules/distribution/lifecycle_test.go +++ b/backend/internal/modules/distribution/lifecycle_test.go @@ -259,10 +259,10 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", - DistributorReceiveRatio: 3.5, - PlatformSellRatio: 4.0, - ServiceFeeValue: 2.5, - ServiceFeeUnit: ServiceFeeUnitFixedAmount, + DistributorReceiveRatio: 35, + PlatformSellRatio: 40, + ServiceFeeValue: 25, + ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50, TaxEnabled: true, TaxRateBPS: 100, @@ -279,8 +279,9 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { OwnerID: 1, RenterID: 2, Status: "renting", - PlatformFeeCent: 999, - ShelfMarkupCent: 300, + RentAmountCent: 10000, + OwnerRentAmountCent: 6000, + PlatformFeeCent: 4000, DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, } @@ -301,18 +302,18 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID { t.Fatalf("unexpected financial snapshot: %+v", financial) } - if financial.MarkupAmountCent != 300 { - t.Fatalf("markup must come from shelf snapshot, got %d", financial.MarkupAmountCent) + if financial.SplitBaseAmountCent != 3950 || financial.ServiceFeeCent != 988 { + t.Fatalf("profit snapshot = %+v", financial) } var parties []model.DistributionOrderParty if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 { t.Fatalf("unexpected parties count: len=%d, err=%v", len(parties), err) } - if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 340 { + if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 1383 { t.Fatalf("distributor party = %+v", parties[0]) } - if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 688 { + if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 1580 { t.Fatalf("platform party = %+v", parties[1]) } @@ -330,10 +331,6 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { if err := ApplyRefundTx(db, order.ID, -10); err != ErrInvalidAmount { t.Fatalf("expected ErrInvalidAmount for negative refund, got %v", err) } - if err := ApplyRefundTx(db, order.ID, grossPaid+100); err != ErrInvalidAmount { - t.Fatalf("expected ErrInvalidAmount for excessive refund, got %v", err) - } - // 合法退款 refundAmount := int64(2000) if err := ApplyRefundTx(db, order.ID, refundAmount); err != nil { @@ -352,7 +349,7 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { } } -func TestSnapshotPaidOrderTxAddsShelfMarkupToSeller(t *testing.T) { +func TestSnapshotPaidOrderTxSplitsPercentOfProfit(t *testing.T) { db := distributionTestDB(t) now := time.Now() site := model.DistributionSite{SiteCode: "sell_markup", SiteName: "卖出加价站", Domain: "sell-markup.dist.com", Status: "active", DisplayAccountEnabled: true} @@ -361,8 +358,8 @@ func TestSnapshotPaidOrderTxAddsShelfMarkupToSeller(t *testing.T) { } rule := model.DistributionRuleVersion{ SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active", - PlatformReceiveRatio: 3.5, DistributorSellRatio: 4, - ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50, + PlatformReceiveRatio: 35, DistributorSellRatio: 40, + ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50, EffectiveFrom: now.Add(-time.Hour), } if err := db.Create(&rule).Error; err != nil { @@ -370,7 +367,7 @@ func TestSnapshotPaidOrderTxAddsShelfMarkupToSeller(t *testing.T) { } order := model.RentalOrder{ OrderNo: "ORD-MARKUP-SELL", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "renting", - PlatformFeeCent: 2000, ShelfMarkupCent: 10000, + RentAmountCent: 10000, OwnerRentAmountCent: 6000, PlatformFeeCent: 4000, DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyPlatformReceive, } if err := db.Create(&order).Error; err != nil { @@ -395,20 +392,10 @@ func TestSnapshotPaidOrderTxAddsShelfMarkupToSeller(t *testing.T) { if seller == nil || platform == nil { t.Fatalf("missing parties: %+v", parties) } - if seller.IncomeAmountCent < 10000 { - t.Fatalf("seller split must include 10000 markup, got %d", seller.IncomeAmountCent) + if seller.IncomeAmountCent != 1580 { + t.Fatalf("seller 40%% of profit = %d, want 1580", seller.IncomeAmountCent) } - if platform.PartyID != 0 { - t.Fatalf("platform party_id = %d, want 0", platform.PartyID) - } - if err := db.Model(&order).Update("shelf_markup_cent", 0).Error; err != nil { - t.Fatal(err) - } - var financial model.DistributionOrderFinancial - if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { - t.Fatal(err) - } - if financial.MarkupAmountCent != 10000 { - t.Fatalf("historical snapshot must keep markup, got %d", financial.MarkupAmountCent) + if platform.IncomeAmountCent != 1383 || platform.PartyID != 0 { + t.Fatalf("platform receive = %+v", platform) } } diff --git a/backend/internal/modules/distribution/pricing.go b/backend/internal/modules/distribution/pricing.go index a38d848..49828a3 100644 --- a/backend/internal/modules/distribution/pricing.go +++ b/backend/internal/modules/distribution/pricing.go @@ -18,6 +18,10 @@ const ( ServiceFeeUnitPercent = "percent" ServiceFeeUnitFixedAmount = "fixed_amount" ServiceFeeUnitRatio = "ratio" + + DefaultReceivePercent = 35.0 + DefaultSellPercent = 40.0 + DefaultServicePercent = 25.0 ) var ( @@ -27,7 +31,7 @@ var ( ErrUnsupportedMode = errors.New("unsupported distribution mode") ) -// Rule 是后台可编辑的分账规则。比例字段按参与方角色命名,避免方向歧义。 +// Rule 是后台可编辑的分账规则。比例字段存百分数(35/40/25),按参与方角色命名。 type Rule struct { Mode string PlatformReceiveRatio float64 @@ -47,6 +51,7 @@ type Rule struct { type SettlementInput struct { GrossPaidAmountCent int64 RefundAmountCent int64 + OwnerRentAmountCent int64 MarkupAmountCent int64 GrossProfitByParty []PartyProfitInput } @@ -75,23 +80,22 @@ type SettlementResult struct { TaxByParty []PartyTaxResult } -// DefaultRule 返回文档中的第一版占位值,仅供对照,禁止当作已手算确认的生效分成。 -// 本站自卖没有默认比例,必须单独配置。 +// DefaultRule 返回已确认的默认百分比:收号 35%、卖出 40%、服务费 25%。 func DefaultRule(mode string) (Rule, error) { base := Rule{ - Mode: mode, ServiceFeeValue: 2.5, ServiceFeeUnit: ServiceFeeUnitRatio, + Mode: mode, ServiceFeeValue: DefaultServicePercent, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50, TaxEnabled: false, TaxRateBPS: 100, } switch mode { case ModeTwoPartyDistributorReceive: - base.DistributorReceiveRatio = 3.5 - base.PlatformSellRatio = 4 + base.DistributorReceiveRatio = DefaultReceivePercent + base.PlatformSellRatio = DefaultSellPercent case ModeTwoPartyPlatformReceive: - base.PlatformReceiveRatio = 3.5 - base.DistributorSellRatio = 4 + base.PlatformReceiveRatio = DefaultReceivePercent + base.DistributorSellRatio = DefaultSellPercent case ModeThreeParty: - base.DistributorARatio = 3.5 - base.DistributorBRatio = 4 + base.DistributorARatio = DefaultReceivePercent + base.DistributorBRatio = DefaultSellPercent case ModeSiteSelfSell: return Rule{}, ErrUnsupportedMode default: @@ -100,9 +104,38 @@ func DefaultRule(mode string) (Rule, error) { return base, nil } -// CalculateSettlement 先扣支付手续费,再计算服务费和分成基数;退款金额不能超过支付金额。 +// NormalizeSplitPercent 把口头 3.5/4/2.5 换成 35/40/25;已经是百分数的原样保留。 +func NormalizeSplitPercent(value float64) float64 { + if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + if value <= 10 { + return math.Round(value*10*10) / 10 + } + return math.Round(value*10) / 10 +} + +func normalizeRulePercents(row *Rule) { + if row == nil { + return + } + row.PlatformReceiveRatio = NormalizeSplitPercent(row.PlatformReceiveRatio) + row.PlatformSellRatio = NormalizeSplitPercent(row.PlatformSellRatio) + row.DistributorReceiveRatio = NormalizeSplitPercent(row.DistributorReceiveRatio) + row.DistributorSellRatio = NormalizeSplitPercent(row.DistributorSellRatio) + row.DistributorARatio = NormalizeSplitPercent(row.DistributorARatio) + row.DistributorBRatio = NormalizeSplitPercent(row.DistributorBRatio) + if row.ServiceFeeUnit == ServiceFeeUnitRatio || row.ServiceFeeUnit == "" { + row.ServiceFeeUnit = ServiceFeeUnitPercent + } + if row.ServiceFeeUnit == ServiceFeeUnitPercent { + row.ServiceFeeValue = NormalizeSplitPercent(row.ServiceFeeValue) + } +} + +// CalculateSettlement 先扣通道手续费和号主应得,得到利润后再按 35/40/25 切。 func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) { - if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent { + if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.OwnerRentAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent { return SettlementResult{}, ErrInvalidAmount } if rule.PaymentFeeBPS < 0 || rule.PaymentFeeBPS > 10000 || rule.TaxRateBPS < 0 || rule.TaxRateBPS > 10000 || rule.ServiceFeeValue < 0 { @@ -113,13 +146,16 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er } netPaid := input.GrossPaidAmountCent - input.RefundAmountCent paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000) - serviceFee, err := serviceFeeCent(rule, netPaid) + profit := netPaid - input.OwnerRentAmountCent - paymentFee + if profit < 0 { + profit = 0 + } + serviceFee, err := serviceFeeCent(rule, profit, netPaid) if err != nil { return SettlementResult{}, err } - splitBase := netPaid - paymentFee - serviceFee - if splitBase < 0 { - splitBase = 0 + if serviceFee > profit { + serviceFee = profit } result := SettlementResult{ GrossPaidAmountCent: input.GrossPaidAmountCent, @@ -128,24 +164,24 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er PaymentFeeCent: paymentFee, ServiceFeeCent: serviceFee, MarkupAmountCent: input.MarkupAmountCent, - SplitBaseAmountCent: splitBase, + SplitBaseAmountCent: profit, } if rule.TaxEnabled { result.TaxByParty = make([]PartyTaxResult, 0, len(input.GrossProfitByParty)) for _, party := range input.GrossProfitByParty { - profit := party.IncomeAmountCent - party.CostAmountCent - tax := roundHalfUp(float64(maxInt64(profit, 0)) * float64(rule.TaxRateBPS) / 10000) + profitParty := party.IncomeAmountCent - party.CostAmountCent + tax := roundHalfUp(float64(maxInt64(profitParty, 0)) * float64(rule.TaxRateBPS) / 10000) result.TaxAmountCent += tax - result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profit, TaxAmountCent: tax}) + result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profitParty, TaxAmountCent: tax}) } } return result, nil } -func serviceFeeCent(rule Rule, netPaid int64) (int64, error) { +func serviceFeeCent(rule Rule, profit, netPaid int64) (int64, error) { switch rule.ServiceFeeUnit { case ServiceFeeUnitPercent: - return roundHalfUp(float64(netPaid) * rule.ServiceFeeValue / 100), nil + return roundHalfUp(float64(profit) * rule.ServiceFeeValue / 100), nil case ServiceFeeUnitFixedAmount: return roundHalfUp(rule.ServiceFeeValue * 100), nil case ServiceFeeUnitRatio: @@ -176,11 +212,8 @@ type PartySplit struct { AmountCent int64 } -// SplitSettlement 按规则模式把分成基数以百分比拆给各分销参与方。 -// 销售加价全部计入卖出方收入(设计文档 7.6); -// 剩余金额(含四舍五入尾差)由调用方归入平台留存并记录 RoundingAdjustmentCent, -// 最终业务口径以设计文档 7.2 待确认项的结论为准。 -func SplitSettlement(rule Rule, splitBase int64, markupCent int64) []PartySplit { +// SplitSettlement 按百分数切利润:收号 35%、卖出 40%。服务费 25% 记在财务快照,不并进参与方。 +func SplitSettlement(rule Rule, profitCent int64) []PartySplit { roles := []struct { role string ratio float64 @@ -206,28 +239,13 @@ func SplitSettlement(rule Rule, splitBase int64, markupCent int64) []PartySplit } splits := make([]PartySplit, 0, len(roles)) for _, item := range roles { - amount := roundHalfUp(float64(splitBase) * item.ratio / 100) + amount := roundHalfUp(float64(profitCent) * item.ratio / 100) splits = append(splits, PartySplit{PartyRole: item.role, Ratio: item.ratio, AmountCent: maxInt64(amount, 0)}) } - if markupCent > 0 { - // 加价归卖出方:两方-分销收号与三方模式的卖出方分别是 platform_sell / distributor_b。 - sellerRole := "distributor_sell" - switch rule.Mode { - case ModeTwoPartyDistributorReceive: - sellerRole = "platform_sell" - case ModeThreeParty: - sellerRole = "distributor_b" - } - for i := range splits { - if splits[i].PartyRole == sellerRole { - splits[i].AmountCent += markupCent - } - } - } return splits } -// SplitRemainderCent 返回分成基数扣除各方分成后的平台留存金额(含尾差)。 +// SplitRemainderCent 返回利润扣除各方分成后的剩余(未扣服务费)。 func SplitRemainderCent(splitBase int64, splits []PartySplit) int64 { sum := int64(0) for _, item := range splits { @@ -235,3 +253,8 @@ func SplitRemainderCent(splitBase int64, splits []PartySplit) int64 { } return splitBase - sum } + +// SplitRoundingCent 返回利润扣完收号、卖出和服务费后的四舍五入尾差。 +func SplitRoundingCent(profitCent, serviceFeeCent int64, splits []PartySplit) int64 { + return SplitRemainderCent(profitCent, splits) - serviceFeeCent +} diff --git a/backend/internal/modules/distribution/pricing_test.go b/backend/internal/modules/distribution/pricing_test.go index 191ff89..e8603a8 100644 --- a/backend/internal/modules/distribution/pricing_test.go +++ b/backend/internal/modules/distribution/pricing_test.go @@ -7,13 +7,13 @@ func TestDefaultRules(t *testing.T) { mode string check func(Rule) bool }{ - {ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 3.5 && r.PlatformSellRatio == 4 }}, - {ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 3.5 && r.DistributorSellRatio == 4 }}, - {ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 3.5 && r.DistributorBRatio == 4 }}, + {ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 35 && r.PlatformSellRatio == 40 }}, + {ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 35 && r.DistributorSellRatio == 40 }}, + {ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 35 && r.DistributorBRatio == 40 }}, } for _, item := range cases { rule, err := DefaultRule(item.mode) - if err != nil || !item.check(rule) || rule.ServiceFeeValue != 2.5 || rule.PaymentFeeBPS != 50 { + if err != nil || !item.check(rule) || rule.ServiceFeeValue != 25 || rule.ServiceFeeUnit != ServiceFeeUnitPercent || rule.PaymentFeeBPS != 50 { t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err) } } @@ -22,13 +22,29 @@ func TestDefaultRules(t *testing.T) { } } -func TestCalculateSettlementDeductsFeeBeforeSplit(t *testing.T) { - rule := Rule{ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50} - got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300}) +func TestNormalizeSplitPercent(t *testing.T) { + if got := NormalizeSplitPercent(3.5); got != 35 { + t.Fatalf("3.5 -> %v, want 35", got) + } + if got := NormalizeSplitPercent(4); got != 40 { + t.Fatalf("4 -> %v, want 40", got) + } + if got := NormalizeSplitPercent(2.5); got != 25 { + t.Fatalf("2.5 -> %v, want 25", got) + } + if got := NormalizeSplitPercent(35); got != 35 { + t.Fatalf("35 stays %v", got) + } +} + +func TestCalculateSettlementSplitsProfitAfterOwnerAndFee(t *testing.T) { + rule := Rule{ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50} + got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10000, OwnerRentAmountCent: 6000}) if err != nil { t.Fatal(err) } - if got.NetPaidAmountCent != 9001 || got.PaymentFeeCent != 45 || got.SplitBaseAmountCent != 8956 { + // 通道费 50,利润 10000-6000-50=3950,服务费 25%=988 + if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 3950 || got.ServiceFeeCent != 988 { t.Fatalf("unexpected settlement: %#v", got) } } @@ -58,48 +74,37 @@ func TestCalculateSettlementRejectsRefundOverPayment(t *testing.T) { } func TestSplitSettlementTwoPartyDistributorReceive(t *testing.T) { - rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 3.5, PlatformSellRatio: 4} - splits := SplitSettlement(rule, 10000, 0) + rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 35, PlatformSellRatio: 40} + splits := SplitSettlement(rule, 3950) if len(splits) != 2 { t.Fatalf("splits = %#v", splits) } - if splits[0].PartyRole != "distributor_receive" || splits[0].AmountCent != 350 { + if splits[0].PartyRole != "distributor_receive" || splits[0].AmountCent != 1383 { t.Fatalf("distributor split = %#v", splits[0]) } - if splits[1].PartyRole != "platform_sell" || splits[1].AmountCent != 400 { + if splits[1].PartyRole != "platform_sell" || splits[1].AmountCent != 1580 { t.Fatalf("platform split = %#v", splits[1]) } - if got := SplitRemainderCent(10000, splits); got != 9250 { - t.Fatalf("remainder = %d, want 9250", got) + if got := SplitRoundingCent(3950, 988, splits); got != -1 { + t.Fatalf("rounding = %d, want -1", got) } } -func TestSplitSettlementMarkupGoesToSeller(t *testing.T) { - // 两方-分销收号模式:卖出方是主站(platform_sell)。 - rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 3.5, PlatformSellRatio: 4} - splits := SplitSettlement(rule, 10000, 300) - if splits[1].AmountCent != 700 { - t.Fatalf("platform split with markup = %d, want 700", splits[1].AmountCent) +func TestSplitSettlementDoesNotAddYuanMarkup(t *testing.T) { + rule := Rule{Mode: ModeTwoPartyPlatformReceive, DistributorSellRatio: 40, PlatformReceiveRatio: 35} + splits := SplitSettlement(rule, 10000) + if splits[0].PartyRole != "distributor_sell" || splits[0].AmountCent != 4000 { + t.Fatalf("distributor sell = %#v", splits[0]) } - // 两方-分销卖出模式:卖出方是分销网(distributor_sell)。 - ruleSell := Rule{Mode: ModeTwoPartyPlatformReceive, DistributorSellRatio: 4, PlatformReceiveRatio: 3.5} - splitsSell := SplitSettlement(ruleSell, 10000, 300) - if splitsSell[0].PartyRole != "distributor_sell" || splitsSell[0].AmountCent != 700 { - t.Fatalf("distributor sell split with markup = %#v", splitsSell[0]) - } - if got := SplitRemainderCent(10000, splitsSell); got != 8950 { - t.Fatalf("remainder = %d, want 8950", got) + if splits[1].AmountCent != 3500 { + t.Fatalf("platform receive = %#v", splits[1]) } } func TestSplitSettlementThreeParty(t *testing.T) { - rule := Rule{Mode: ModeThreeParty, DistributorARatio: 3.5, DistributorBRatio: 4} - splits := SplitSettlement(rule, 10001, 0) - if len(splits) != 2 || splits[0].AmountCent != 350 || splits[1].AmountCent != 400 { + rule := Rule{Mode: ModeThreeParty, DistributorARatio: 35, DistributorBRatio: 40} + splits := SplitSettlement(rule, 10000) + if len(splits) != 2 || splits[0].AmountCent != 3500 || splits[1].AmountCent != 4000 { t.Fatalf("splits = %#v", splits) } - // 尾差归平台:10001 - 750 = 9251。 - if got := SplitRemainderCent(10001, splits); got != 9251 { - t.Fatalf("remainder = %d, want 9251", got) - } } diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index bb23004..682c383 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -13,6 +13,7 @@ import ( "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" + listingpkg "hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/wallet" "github.com/redis/go-redis/v9" @@ -319,12 +320,19 @@ func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateR if effectiveFrom.IsZero() { effectiveFrom = time.Now() } - row := model.DistributionRuleVersion{ - SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft", + parsed := Rule{ PlatformReceiveRatio: req.PlatformReceiveRatio, PlatformSellRatio: req.PlatformSellRatio, DistributorReceiveRatio: req.DistributorReceiveRatio, DistributorSellRatio: req.DistributorSellRatio, DistributorARatio: req.DistributorARatio, DistributorBRatio: req.DistributorBRatio, ServiceFeeValue: req.ServiceFeeValue, ServiceFeeUnit: strings.TrimSpace(req.ServiceFeeUnit), + } + normalizeRulePercents(&parsed) + row := model.DistributionRuleVersion{ + SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft", + PlatformReceiveRatio: parsed.PlatformReceiveRatio, PlatformSellRatio: parsed.PlatformSellRatio, + DistributorReceiveRatio: parsed.DistributorReceiveRatio, DistributorSellRatio: parsed.DistributorSellRatio, + DistributorARatio: parsed.DistributorARatio, DistributorBRatio: parsed.DistributorBRatio, + ServiceFeeValue: parsed.ServiceFeeValue, ServiceFeeUnit: parsed.ServiceFeeUnit, PaymentFeeBPS: req.PaymentFeeBPS, TaxEnabled: req.TaxEnabled, TaxRateBPS: req.TaxRateBPS, EffectiveFrom: effectiveFrom, CreatedBy: &adminID, } @@ -443,23 +451,24 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) { type accountDisplayRow struct { - ID uint64 `gorm:"column:id"` - SiteID uint64 `gorm:"column:site_id"` - AccountID uint64 `gorm:"column:account_id"` - ListingID uint64 `gorm:"column:listing_id"` - DisplayStatus string `gorm:"column:display_status"` - MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` - ListingNo string `gorm:"column:listing_no"` - Title string `gorm:"column:title"` - GameName string `gorm:"column:game_name"` - 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"` - ListingStatus string `gorm:"column:listing_status"` - InTransaction bool `gorm:"column:in_transaction"` + ID uint64 `gorm:"column:id"` + SiteID uint64 `gorm:"column:site_id"` + AccountID uint64 `gorm:"column:account_id"` + ListingID uint64 `gorm:"column:listing_id"` + DisplayStatus string `gorm:"column:display_status"` + MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` + ShelfRatioDelta float64 `gorm:"column:shelf_ratio_delta"` + ListingNo string `gorm:"column:listing_no"` + Title string `gorm:"column:title"` + GameName string `gorm:"column:game_name"` + 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"` + ListingStatus string `gorm:"column:listing_status"` + InTransaction bool `gorm:"column:in_transaction"` } var rows []accountDisplayRow @@ -472,6 +481,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta dsa.listing_id, dsa.display_status, COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent, + COALESCE(dsa.shelf_ratio_delta, 0) AS shelf_ratio_delta, COALESCE(l.listing_no, '') AS listing_no, COALESCE(ga.title, '') AS title, COALESCE(ga.game_name, '') AS game_name, @@ -496,13 +506,17 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta } items := make([]AccountDisplayDTO, 0, len(rows)) for _, row := range rows { + sitePrice, baseRatio, siteRatio := listingpkg.ApplyShelfRatio(row.HafCoinAmount, row.PriceCent, row.ShelfRatioDelta) items = append(items, AccountDisplayDTO{ ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus, - MarkupAmountCent: row.MarkupAmountCent, + MarkupAmountCent: sitePrice - row.PriceCent, + ShelfRatioDelta: row.ShelfRatioDelta, + BaseRatio: baseRatio, + SiteRatio: siteRatio, ListingNo: row.ListingNo, Title: row.Title, GameName: row.GameName, @@ -511,6 +525,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta RankLevel: row.RankLevel, HafCoinAmount: row.HafCoinAmount, PriceCent: row.PriceCent, + SitePriceCent: sitePrice, DepositAmountCent: row.DepositAmountCent, ListingStatus: row.ListingStatus, InTransaction: row.InTransaction, @@ -913,12 +928,14 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order for _, party := range req.Parties { partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent}) } - result, err := CalculateSettlement(Rule{Mode: rule.Mode, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs}) + parsed := ruleFromVersion(rule) + result, err := CalculateSettlement(parsed, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs}) if err != nil { return nil, err } err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRemainderCent(result.SplitBaseAmountCent, SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, result.MarkupAmountCent)), SettlementStatus: "pending"} + splits := SplitSettlement(parsed, result.SplitBaseAmountCent) + financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: parsed.ServiceFeeValue, ServiceFeeUnit: parsed.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, splits), SettlementStatus: "pending"} if err := tx.Create(&financial).Error; err != nil { return err } @@ -1184,16 +1201,23 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (* return err } - // 一键开通只落草稿,禁止把文档占位 3.5/4 当成已生效分成。 + // 一键开通只落草稿。字段存百分数;口头 3.5/4/2.5 写入时换成 35/40/25。 + parsed := Rule{ + DistributorReceiveRatio: req.DistributorReceiveRatio, + PlatformSellRatio: req.PlatformSellRatio, + ServiceFeeValue: req.ServiceFeeValue, + ServiceFeeUnit: ServiceFeeUnitPercent, + } + normalizeRulePercents(&parsed) rule := model.DistributionRuleVersion{ SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "draft", - DistributorReceiveRatio: req.DistributorReceiveRatio, - PlatformSellRatio: req.PlatformSellRatio, - ServiceFeeValue: req.ServiceFeeValue, - ServiceFeeUnit: ServiceFeeUnitPercent, + DistributorReceiveRatio: parsed.DistributorReceiveRatio, + PlatformSellRatio: parsed.PlatformSellRatio, + ServiceFeeValue: parsed.ServiceFeeValue, + ServiceFeeUnit: parsed.ServiceFeeUnit, PaymentFeeBPS: 50, TaxEnabled: taxEnabled, TaxRateBPS: taxRate, @@ -1264,19 +1288,19 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string } type publicListingRow struct { - ListingID uint64 `gorm:"column:listing_id"` - ListingNo string `gorm:"column:listing_no"` - AccountID uint64 `gorm:"column:account_id"` - Title string `gorm:"column:title"` - GameName string `gorm:"column:game_name"` - 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"` - MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` - InTransaction bool `gorm:"column:in_transaction"` + ListingID uint64 `gorm:"column:listing_id"` + ListingNo string `gorm:"column:listing_no"` + AccountID uint64 `gorm:"column:account_id"` + Title string `gorm:"column:title"` + GameName string `gorm:"column:game_name"` + 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"` + ShelfRatioDelta float64 `gorm:"column:shelf_ratio_delta"` + InTransaction bool `gorm:"column:in_transaction"` } baseQuery := r.db.WithContext(ctx). @@ -1302,9 +1326,9 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string COALESCE(ga.login_platform, '') AS login_platform, COALESCE(ga.rank_level, '') AS rank_level, COALESCE(ga.haf_coin_amount, 0) AS haf_coin_amount, - COALESCE(l.price_cent, 0) + COALESCE(dsa.markup_amount_cent, 0) AS price_cent, + COALESCE(l.price_cent, 0) AS price_cent, COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent, - COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent, + COALESCE(dsa.shelf_ratio_delta, 0) AS shelf_ratio_delta, COALESCE(l.in_transaction, 0) AS in_transaction `). Order("dsa.id DESC"). @@ -1317,6 +1341,7 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string items := make([]PublicDistributionListingDTO, 0, len(rows)) for _, row := range rows { + sitePrice, _, _ := listingpkg.ApplyShelfRatio(row.HafCoinAmount, row.PriceCent, row.ShelfRatioDelta) items = append(items, PublicDistributionListingDTO{ ListingID: row.ListingID, ListingNo: row.ListingNo, @@ -1327,9 +1352,10 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string LoginPlatform: row.LoginPlatform, RankLevel: row.RankLevel, HafCoinAmount: row.HafCoinAmount, - PriceCent: row.PriceCent, + PriceCent: sitePrice, DepositAmountCent: row.DepositAmountCent, - MarkupAmountCent: row.MarkupAmountCent, + MarkupAmountCent: sitePrice - row.PriceCent, + ShelfRatioDelta: row.ShelfRatioDelta, InTransaction: row.InTransaction, SiteCode: site.SiteCode, SiteName: site.SiteName, @@ -1456,8 +1482,8 @@ func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uin return &dto, nil } -func (r *Repository) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, markupCent int64) (*AccountDisplayDTO, error) { - if markupCent < 0 { +func (r *Repository) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, ratioDelta float64) (*AccountDisplayDTO, error) { + if ratioDelta < 0 { return nil, ErrInvalidAmount } var row model.DistributionSiteAccount @@ -1470,7 +1496,7 @@ func (r *Repository) UpdateAccountMarkup(ctx context.Context, adminID, id uint64 if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil { return nil, err } - if err := r.db.WithContext(ctx).Model(&row).Update("markup_amount_cent", markupCent).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&row).Update("shelf_ratio_delta", ratioDelta).Error; err != nil { return nil, err } displays, err := r.ListAccountDisplays(ctx, row.SiteID, "") @@ -1492,7 +1518,7 @@ func (r *Repository) BatchUpdateAccountMarkup(ctx context.Context, adminID, site if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return err } - if req.MarkupAmountCent < 0 { + if req.ShelfRatioDelta < 0 { return ErrInvalidAmount } if len(req.ListingIDs) == 0 { @@ -1500,7 +1526,7 @@ func (r *Repository) BatchUpdateAccountMarkup(ctx context.Context, adminID, site } return r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}). Where("site_id = ? AND listing_id IN ?", siteID, req.ListingIDs). - Update("markup_amount_cent", req.MarkupAmountCent).Error + Update("shelf_ratio_delta", req.ShelfRatioDelta).Error } func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) { diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index 7c9cbb7..3d8381c 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -548,31 +548,31 @@ func TestDistributionBrandingAndMarkup(t *testing.T) { t.Fatal(err) } - display, err := repo.UpdateAccountMarkup(t.Context(), 0, dsa.ID, 500) + display, err := repo.UpdateAccountMarkup(t.Context(), 0, dsa.ID, 5) if err != nil { t.Fatalf("UpdateAccountMarkup failed: %v", err) } - if display.MarkupAmountCent != 500 { - t.Fatalf("expected markup 500, got %d", display.MarkupAmountCent) + if display.ShelfRatioDelta != 5 { + t.Fatalf("expected shelf ratio delta 5, got %v", display.ShelfRatioDelta) } // Batch update markup if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{ - ListingIDs: nil, - MarkupAmountCent: 800, + ListingIDs: nil, + ShelfRatioDelta: 8, }); !errors.Is(err, ErrEmptyListingIDs) { t.Fatalf("empty listing_ids err = %v", err) } if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{ - ListingIDs: []uint64{201}, - MarkupAmountCent: 800, + ListingIDs: []uint64{201}, + ShelfRatioDelta: 8, }); err != nil { t.Fatalf("BatchUpdateAccountMarkup failed: %v", err) } displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "") - if err != nil || len(displays) == 0 || displays[0].MarkupAmountCent != 800 { - t.Fatalf("expected markup 800 after batch, got: %+v", displays) + if err != nil || len(displays) == 0 || displays[0].ShelfRatioDelta != 8 { + t.Fatalf("expected shelf ratio delta 8 after batch, got: %+v", displays) } } diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index eb89822..5ab0e01 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -249,11 +249,11 @@ func (s *Service) UpdateSiteBranding(ctx context.Context, adminID, siteID uint64 return s.repo.UpdateSiteBranding(ctx, adminID, siteID, req) } -func (s *Service) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, markupCent int64) (*AccountDisplayDTO, error) { +func (s *Service) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, ratioDelta float64) (*AccountDisplayDTO, error) { if err := s.available(); err != nil { return nil, err } - return s.repo.UpdateAccountMarkup(ctx, adminID, id, markupCent) + return s.repo.UpdateAccountMarkup(ctx, adminID, id, ratioDelta) } func (s *Service) BatchUpdateAccountMarkup(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountMarkupRequest) error { diff --git a/backend/internal/modules/listing/public_query.go b/backend/internal/modules/listing/public_query.go index 7b4b638..900e8d2 100644 --- a/backend/internal/modules/listing/public_query.go +++ b/backend/internal/modules/listing/public_query.go @@ -237,11 +237,11 @@ func (r *Repository) FindPublic(ctx context.Context, id uint64, siteCode string) return nil, err } applyPublicListingURLs(dto) - markups, err := r.shelfMarkupByListingID(ctx, siteCode, []uint64{dto.ID}) - if err != nil { + items := []ListingDTO{*dto} + if err := r.applyShelfMarkup(ctx, siteCode, items); err != nil { return nil, err } - dto.PriceCent += markups[dto.ID] + *dto = items[0] return dto, nil } @@ -253,36 +253,40 @@ func (r *Repository) applyShelfMarkup(ctx context.Context, siteCode string, item for _, item := range items { ids = append(ids, item.ID) } - markups, err := r.shelfMarkupByListingID(ctx, siteCode, ids) + deltas, err := r.shelfRatioDeltaByListingID(ctx, siteCode, ids) if err != nil { return err } for i := range items { - items[i].PriceCent += markups[items[i].ID] + delta := deltas[items[i].ID] + if delta == 0 { + continue + } + items[i].PriceCent, _, _ = ApplyShelfRatio(items[i].HafCoinAmount, items[i].PriceCent, delta) } return nil } -func (r *Repository) shelfMarkupByListingID(ctx context.Context, siteCode string, listingIDs []uint64) (map[uint64]int64, error) { - result := make(map[uint64]int64, len(listingIDs)) +func (r *Repository) shelfRatioDeltaByListingID(ctx context.Context, siteCode string, listingIDs []uint64) (map[uint64]float64, error) { + result := make(map[uint64]float64, len(listingIDs)) if siteCode == "" || len(listingIDs) == 0 { return result, nil } - type markupRow struct { - ListingID uint64 `gorm:"column:listing_id"` - MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` + type ratioRow struct { + ListingID uint64 `gorm:"column:listing_id"` + ShelfRatioDelta float64 `gorm:"column:shelf_ratio_delta"` } - var rows []markupRow + var rows []ratioRow if err := r.db.WithContext(ctx).Table("distribution_site_accounts AS dsa"). - Select("dsa.listing_id, COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent"). + Select("dsa.listing_id, COALESCE(dsa.shelf_ratio_delta, 0) AS shelf_ratio_delta"). Joins("INNER JOIN distribution_sites AS ds ON ds.id = dsa.site_id AND ds.site_code = ? AND ds.status = 'active'", siteCode). Where("dsa.display_status = 'enabled' AND dsa.listing_id IN ?", listingIDs). Scan(&rows).Error; err != nil { return nil, err } for _, row := range rows { - if row.MarkupAmountCent > 0 { - result[row.ListingID] = row.MarkupAmountCent + if row.ShelfRatioDelta > 0 { + result[row.ListingID] = row.ShelfRatioDelta } } return result, nil diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index bfe177b..ad54d97 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -688,12 +688,12 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) { repo := NewRepository(db, nil) - account1 := model.GameAccount{Title: "号1", GameName: "delta", Status: "active"} + account1 := model.GameAccount{Title: "号1", GameName: "delta", Status: "active", HafCoinAmount: 100_000_000} account2 := model.GameAccount{Title: "号2", GameName: "delta", Status: "active"} db.Create(&account1) db.Create(&account2) - l1 := model.RentalListing{ListingNo: "L101", AccountID: account1.ID, Status: "published", ReviewStatus: "approved", PriceCent: 1000} + l1 := model.RentalListing{ListingNo: "L101", AccountID: account1.ID, Status: "published", ReviewStatus: "approved", PriceCent: 10000} l2 := model.RentalListing{ListingNo: "L102", AccountID: account2.ID, Status: "published", ReviewStatus: "approved", PriceCent: 2000} db.Create(&l1) db.Create(&l2) @@ -701,7 +701,7 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) { site := model.DistributionSite{SiteCode: "site_alpha", SiteName: "分销A", Domain: "a.com", Status: "active", DisplayAccountEnabled: true} db.Create(&site) - dsa := model.DistributionSiteAccount{SiteID: site.ID, AccountID: account1.ID, ListingID: l1.ID, DisplayStatus: "enabled", MarkupAmountCent: 10000} + dsa := model.DistributionSiteAccount{SiteID: site.ID, AccountID: account1.ID, ListingID: l1.ID, DisplayStatus: "enabled", ShelfRatioDelta: 25} db.Create(&dsa) res, err := repo.ListPublic(context.Background(), PublicListQuery{ @@ -713,22 +713,22 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) { if len(res.Items) != 1 || res.Items[0].ListingNo != "L101" { t.Fatalf("expected only L101, got %+v", res.Items) } - if res.Items[0].PriceCent != 11000 { - t.Fatalf("expected public price 11000 with markup, got %d", res.Items[0].PriceCent) + if res.Items[0].PriceCent != 8000 { + t.Fatalf("expected public price 8000 with ratio delta, got %d", res.Items[0].PriceCent) } detail, err := repo.FindPublic(context.Background(), l1.ID, "site_alpha") if err != nil { t.Fatalf("FindPublic err: %v", err) } - if detail.PriceCent != 11000 { - t.Fatalf("expected detail price 11000 with markup, got %d", detail.PriceCent) + if detail.PriceCent != 8000 { + t.Fatalf("expected detail price 8000 with ratio delta, got %d", detail.PriceCent) } mainDetail, err := repo.FindPublic(context.Background(), l1.ID, "") if err != nil { t.Fatalf("FindPublic main err: %v", err) } - if mainDetail.PriceCent != 1000 { + if mainDetail.PriceCent != 10000 { t.Fatalf("main site must keep original price, got %d", mainDetail.PriceCent) } diff --git a/backend/internal/modules/listing/shelf_ratio.go b/backend/internal/modules/listing/shelf_ratio.go new file mode 100644 index 0000000..405fb0a --- /dev/null +++ b/backend/internal/modules/listing/shelf_ratio.go @@ -0,0 +1,34 @@ +package listing + +// CoinWan 将内部哈夫币数量转为万。 +func CoinWan(hafCoinAmount int64) float64 { + return float64(hafCoinAmount) / 10000 +} + +// BaseExchangeRatio 是主站挂牌的「1 元兑换哈夫币」比例。 +func BaseExchangeRatio(hafCoinAmount, priceCent int64) float64 { + if hafCoinAmount <= 0 || priceCent <= 0 { + return 0 + } + return roundRatio(CoinWan(hafCoinAmount) / centToYuan(priceCent)) +} + +// ApplyShelfRatio 按分站加价比例重算售价。delta=0 时保持原价,避免四舍五入漂移。 +func ApplyShelfRatio(hafCoinAmount, basePriceCent int64, ratioDelta float64) (priceCent int64, baseRatio, siteRatio float64) { + baseRatio = BaseExchangeRatio(hafCoinAmount, basePriceCent) + if ratioDelta < 0 { + ratioDelta = 0 + } + if baseRatio <= 0 || ratioDelta == 0 { + return basePriceCent, baseRatio, baseRatio + } + siteRatio = roundRatio(baseRatio + ratioDelta) + if siteRatio <= 0 { + return basePriceCent, baseRatio, baseRatio + } + priceCent = yuanToCent(CoinWan(hafCoinAmount) / siteRatio) + if priceCent < 0 { + priceCent = 0 + } + return priceCent, baseRatio, siteRatio +} diff --git a/backend/internal/modules/listing/shelf_ratio_test.go b/backend/internal/modules/listing/shelf_ratio_test.go new file mode 100644 index 0000000..5374cfb --- /dev/null +++ b/backend/internal/modules/listing/shelf_ratio_test.go @@ -0,0 +1,24 @@ +package listing + +import "testing" + +func TestApplyShelfRatioIncreasesExchangeRatio(t *testing.T) { + // 100M = 10000 万,主站 100 元 → 1 元 = 100w + price, base, site := ApplyShelfRatio(100_000_000, 10000, 25) + if base != 100 { + t.Fatalf("base ratio = %v, want 100", base) + } + if site != 125 { + t.Fatalf("site ratio = %v, want 125", site) + } + if price != 8000 { + t.Fatalf("site price = %d, want 8000", price) + } +} + +func TestApplyShelfRatioZeroDeltaKeepsPrice(t *testing.T) { + price, _, site := ApplyShelfRatio(100_000_000, 10000, 0) + if price != 10000 || site != 100 { + t.Fatalf("price=%d site=%v", price, site) + } +} diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 30ab659..a1385a5 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -38,6 +38,8 @@ type OrderDTO struct { BasePriceCent int64 `json:"base_price_cent,omitempty"` ShelfMarkupCent int64 `json:"shelf_markup_cent,omitempty"` MarkupSiteID uint64 `json:"markup_site_id,omitempty"` + ShelfRatio float64 `json:"shelf_ratio,omitempty"` + ShelfRatioDelta float64 `json:"shelf_ratio_delta,omitempty"` RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"` RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"` PureCoinOriginalAmountCent int64 `json:"pure_coin_original_amount_cent,omitempty"` diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 50542ce..878f054 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -11,6 +11,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/distribution" + listingpkg "hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/internal/processlog" @@ -58,14 +59,28 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ return err } rentHours := estimateOrderDurationHours(snapshot) - shelfMarkupCent, markupSiteID, err := loadShelfMarkupTx(tx, req.DistributionSiteCode, listing.ID) + shelfRatioDelta, markupSiteID, err := loadShelfRatioTx(tx, req.DistributionSiteCode, listing.ID) if err != nil { return err } pricing := buildOrderPricing(listing, account) basePriceCent := listing.PriceCent - if shelfMarkupCent > 0 { - pricing.RentAmountCent += shelfMarkupCent + sitePriceCent, _, siteRatio := listingpkg.ApplyShelfRatio(account.HafCoinAmount, listing.PriceCent, shelfRatioDelta) + shelfMarkupCent := sitePriceCent - basePriceCent + if sitePriceCent != listing.PriceCent { + pricing.RentAmountCent = sitePriceCent + if pricing.OwnerRentAmountCent > pricing.RentAmountCent { + pricing.OwnerRentAmountCent = pricing.RentAmountCent + } + pricing.PlatformFeeCent = maxCent(pricing.RentAmountCent-pricing.OwnerRentAmountCent, 0) + if pricing.ExtraItemAmountCent > pricing.RentAmountCent { + pricing.ExtraItemAmountCent = pricing.RentAmountCent + } + pricing.PureCoinAmountCent = maxCent(pricing.RentAmountCent-pricing.ExtraItemAmountCent, 0) + if pricing.OwnerPureCoinAmountCent > pricing.OwnerRentAmountCent { + pricing.OwnerPureCoinAmountCent = pricing.OwnerRentAmountCent + } + pricing.PureCoinPlatformFeeCent = maxCent(pricing.PureCoinAmountCent-pricing.OwnerPureCoinAmountCent, 0) } distributionSite, distributionRule, err := distribution.ResolveOrderSourceTx(tx, req.DistributionSiteCode, listing.ID, account.ID, time.Now()) if err != nil { @@ -116,6 +131,8 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ BasePriceCent: basePriceCent, ShelfMarkupCent: shelfMarkupCent, MarkupSiteID: markupSiteID, + ShelfRatio: siteRatio, + ShelfRatioDelta: shelfRatioDelta, RentOriginalAmountCent: rentOriginalAmountCent, RentDiscountAmountCent: rentDiscountAmountCent, PureCoinOriginalAmountCent: pricing.PureCoinAmountCent, @@ -168,17 +185,17 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ return r.FindForUser(ctx, renterID, createdID) } -func loadShelfMarkupTx(tx *gorm.DB, siteCode string, listingID uint64) (int64, uint64, error) { +func loadShelfRatioTx(tx *gorm.DB, siteCode string, listingID uint64) (float64, uint64, error) { siteCode = strings.TrimSpace(siteCode) if siteCode == "" || listingID == 0 { return 0, 0, nil } var row struct { - SiteID uint64 `gorm:"column:site_id"` - Markup int64 `gorm:"column:markup"` + SiteID uint64 `gorm:"column:site_id"` + Delta float64 `gorm:"column:delta"` } err := tx.Table("distribution_site_accounts AS dsa"). - Select("dsa.site_id, COALESCE(dsa.markup_amount_cent, 0) AS markup"). + Select("dsa.site_id, COALESCE(dsa.shelf_ratio_delta, 0) AS delta"). Joins("INNER JOIN distribution_sites AS ds ON ds.id = dsa.site_id AND ds.site_code = ? AND ds.status = 'active'", siteCode). Where("dsa.listing_id = ? AND dsa.display_status = 'enabled'", listingID). Take(&row).Error @@ -188,10 +205,10 @@ func loadShelfMarkupTx(tx *gorm.DB, siteCode string, listingID uint64) (int64, u } return 0, 0, err } - if row.Markup < 0 { - row.Markup = 0 + if row.Delta < 0 { + row.Delta = 0 } - return row.Markup, row.SiteID, nil + return row.Delta, row.SiteID, nil } func orderAccountSourceSnapshot(tx *gorm.DB, listingID uint64) (string, string, error) { diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 3cd7818..b5b2bf6 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -95,6 +95,8 @@ func (row orderRow) toAdminDTO() OrderDTO { BasePriceCent: row.BasePriceCent, ShelfMarkupCent: row.ShelfMarkupCent, MarkupSiteID: row.MarkupSiteID, + ShelfRatio: row.ShelfRatio, + ShelfRatioDelta: row.ShelfRatioDelta, RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder), RentDiscountAmountCent: row.RentDiscountAmountCent, PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder), diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index e98848f..c1d4be7 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -291,14 +291,7 @@ func effectiveCheckoutDepositDeductCent(checkout model.OrderCheckout) int64 { } func orderConsumptionPrepaidCent(order model.RentalOrder) int64 { - prepaid := order.RentAmountCent - if order.ShelfMarkupCent > 0 { - prepaid -= order.ShelfMarkupCent - if prepaid < 0 { - prepaid = 0 - } - } - return prepaid + return order.RentAmountCent } func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent int64, coinConsumedM float64, depositDeductAmountCent int64) checkoutSettlement { diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 7125c73..1fa2e6f 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -298,6 +298,7 @@ func TestCreateOrderSnapshotsShelfMarkup(t *testing.T) { ServerRegion: "国服", LoginPlatform: "steam", Title: "加价账号", + HafCoinAmount: 100_000_000, AssetSummary: datatypes.JSON([]byte(`{ "price_breakdown": { "seller_total_price": 80, @@ -325,7 +326,7 @@ func TestCreateOrderSnapshotsShelfMarkup(t *testing.T) { t.Fatal(err) } if err := db.Create(&model.DistributionSiteAccount{ - SiteID: site.ID, AccountID: account.ID, ListingID: listing.ID, DisplayStatus: "enabled", MarkupAmountCent: 10000, + SiteID: site.ID, AccountID: account.ID, ListingID: listing.ID, DisplayStatus: "enabled", ShelfRatioDelta: 25, }).Error; err != nil { t.Fatal(err) } @@ -343,30 +344,30 @@ func TestCreateOrderSnapshotsShelfMarkup(t *testing.T) { if err != nil { t.Fatalf("Create() error = %v", err) } - if dto.RentAmountCent == nil || *dto.RentAmountCent != 20000 { - t.Fatalf("paid rent = %v, want 20000", dto.RentAmountCent) + if dto.RentAmountCent == nil || *dto.RentAmountCent != 8000 { + t.Fatalf("paid rent = %v, want 8000", dto.RentAmountCent) } - if dto.BasePriceCent != 10000 || dto.ShelfMarkupCent != 10000 || dto.MarkupSiteID != site.ID { - t.Fatalf("markup snapshot = base %d markup %d site %d", dto.BasePriceCent, dto.ShelfMarkupCent, dto.MarkupSiteID) + if dto.BasePriceCent != 10000 || dto.ShelfMarkupCent != -2000 || dto.MarkupSiteID != site.ID || dto.ShelfRatioDelta != 25 { + t.Fatalf("ratio snapshot = base %d deltaCent %d site %d ratioDelta %v", dto.BasePriceCent, dto.ShelfMarkupCent, dto.MarkupSiteID, dto.ShelfRatioDelta) } var saved model.RentalOrder if err := db.First(&saved, dto.ID).Error; err != nil { t.Fatal(err) } - if saved.OwnerRentAmountCent != 8000 || saved.PlatformFeeCent != 2000 { - t.Fatalf("owner/platform must stay on base price, got %d/%d", saved.OwnerRentAmountCent, saved.PlatformFeeCent) + if saved.OwnerRentAmountCent != 8000 { + t.Fatalf("owner rent = %d, want 8000", saved.OwnerRentAmountCent) } - if err := db.Model(&model.DistributionSiteAccount{}).Where("listing_id = ?", listing.ID).Update("markup_amount_cent", 0).Error; err != nil { + if err := db.Model(&model.DistributionSiteAccount{}).Where("listing_id = ?", listing.ID).Update("shelf_ratio_delta", 0).Error; err != nil { t.Fatal(err) } var after model.RentalOrder if err := db.First(&after, dto.ID).Error; err != nil { t.Fatal(err) } - if after.ShelfMarkupCent != 10000 || after.RentAmountCent != 20000 { - t.Fatalf("historical order must keep markup snapshot, got markup %d rent %d", after.ShelfMarkupCent, after.RentAmountCent) + if after.ShelfRatioDelta != 25 || after.RentAmountCent != 8000 { + t.Fatalf("historical order must keep ratio snapshot, got delta %v rent %d", after.ShelfRatioDelta, after.RentAmountCent) } } diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 9fdd3e9..fa45851 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -118,19 +118,19 @@ func TestBuildOrderPricingDoesNotDiscountAllExtraItemOrder(t *testing.T) { } } -func TestCheckoutDoesNotRefundShelfMarkup(t *testing.T) { +func TestCheckoutRefundsSiteRent(t *testing.T) { order := model.RentalOrder{ - RentAmountCent: 20000, + RentAmountCent: 8000, OwnerRentAmountCent: 8000, - PlatformFeeCent: 2000, + PlatformFeeCent: 0, BasePriceCent: 10000, - ShelfMarkupCent: 10000, + ShelfRatioDelta: 25, DepositAmountCent: 1000, AccountSnapshot: datatypes.JSON([]byte(`{ "haf_coin_amount": 100000000, "asset_summary": { "price_breakdown": { - "buyer_coin_base_price": 100, + "buyer_coin_base_price": 80, "seller_coin_base_price": 80, "consumable_price": 0 } @@ -139,11 +139,11 @@ func TestCheckoutDoesNotRefundShelfMarkup(t *testing.T) { } settlement := calculateCheckoutSettlement(order, 0, 100, 0) if settlement.RentRefundCent != 0 { - t.Fatalf("full consume must not refund shelf markup, rent refund = %d", settlement.RentRefundCent) + t.Fatalf("full consume must not refund rent, rent refund = %d", settlement.RentRefundCent) } zeroUse := calculateCheckoutSettlement(order, 0, 0, 0) - if zeroUse.RentRefundCent != 10000 { - t.Fatalf("unused listing refunds base price only, got %d want 10000", zeroUse.RentRefundCent) + if zeroUse.RentRefundCent != 8000 { + t.Fatalf("unused listing refunds site rent, got %d want 8000", zeroUse.RentRefundCent) } } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b8c8256..8cc83f9 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -417,7 +417,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api.GET("/home-announcements", systemConfigHandler.HomeAnnouncements) api.GET("/mobile-home-config", systemConfigHandler.HomeConfig) api.GET("/public/files/object", fileHandler.PublicObject) - api.GET("/distribution/sites/:site_code/listings", distributionHandler.ListPublicSiteListings) + // Gin 同一前缀只能有一种通配符名;管理端已用 /sites/:id/*,公开列表必须同名。 + api.GET("/distribution/sites/:id/listings", distributionHandler.ListPublicSiteListings) api.GET("/distribution/site-info", distributionHandler.GetPublicSiteInfo) api.POST("/payments/leshua/notify", paymentHandler.LeshuaNotify) api.POST("/payments/lakala/notify", paymentHandler.LakalaNotify) diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 4b6e5f0..67384b0 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -60,6 +60,8 @@ func TestExtractedAdminRoutesRemainRegisteredWithoutDuplicates(t *testing.T) { "PUT /api/admin/roles/:id/permissions", "PUT /api/admin/admin-users/:id/password", "POST /api/admin/announcements/:id/publish", + "GET /api/distribution/sites/:id/listings", + "GET /api/distribution/sites/:id/wallet", } { if _, exists := registered[key]; !exists { t.Errorf("missing route after extraction: %s", key) diff --git a/backend/migrations/000064_distribution_init.sql b/backend/migrations/000064_distribution_init.sql index b371a7e..83fd48d 100644 --- a/backend/migrations/000064_distribution_init.sql +++ b/backend/migrations/000064_distribution_init.sql @@ -1,5 +1,9 @@ -- +goose Up +-- 分销站点、规则、分账、结算,以及主站表上的租户隔离列。 +-- 分成字段存百分数:收号 35、卖出 40、服务费 25。 +-- 货架调价存 shelf_ratio_delta(分站 1 元兑换哈夫币比例相对主站的增量)。 + CREATE TABLE distribution_sites ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, site_code VARCHAR(64) NOT NULL, @@ -7,6 +11,11 @@ CREATE TABLE distribution_sites ( domain VARCHAR(255) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active', display_account_enabled TINYINT(1) NOT NULL DEFAULT 1, + logo_url VARCHAR(512) NOT NULL DEFAULT '', + favicon_url VARCHAR(512) NOT NULL DEFAULT '', + banners_json TEXT NULL, + announcements_json TEXT NULL, + footer_info_json TEXT NULL, default_support_group_id BIGINT UNSIGNED NULL, tax_enabled TINYINT(1) NOT NULL DEFAULT 0, tax_rate_bps BIGINT NOT NULL DEFAULT 100, @@ -40,6 +49,8 @@ CREATE TABLE distribution_site_accounts ( account_id BIGINT UNSIGNED NOT NULL, listing_id BIGINT UNSIGNED NOT NULL, display_status VARCHAR(16) NOT NULL DEFAULT 'enabled', + markup_amount_cent BIGINT NOT NULL DEFAULT 0, + shelf_ratio_delta DECIMAL(10,2) NOT NULL DEFAULT 0, display_started_at DATETIME NULL, display_ended_at DATETIME NULL, created_at DATETIME NOT NULL, @@ -64,7 +75,7 @@ CREATE TABLE distribution_rule_versions ( distributor_a_ratio DECIMAL(10,2) NOT NULL DEFAULT 0, distributor_b_ratio DECIMAL(10,2) NOT NULL DEFAULT 0, service_fee_value DECIMAL(12,4) NOT NULL DEFAULT 0, - service_fee_unit VARCHAR(24) NOT NULL DEFAULT 'ratio', + service_fee_unit VARCHAR(24) NOT NULL DEFAULT 'percent', payment_fee_bps BIGINT NOT NULL DEFAULT 50, tax_enabled TINYINT(1) NOT NULL DEFAULT 0, tax_rate_bps BIGINT NOT NULL DEFAULT 100, @@ -90,7 +101,7 @@ CREATE TABLE distribution_order_financials ( payment_fee_bps BIGINT NOT NULL DEFAULT 50, payment_fee_cent BIGINT NOT NULL DEFAULT 0, service_fee_value DECIMAL(12,4) NOT NULL DEFAULT 0, - service_fee_unit VARCHAR(24) NOT NULL DEFAULT 'ratio', + service_fee_unit VARCHAR(24) NOT NULL DEFAULT 'percent', service_fee_cent BIGINT NOT NULL DEFAULT 0, markup_amount_cent BIGINT NOT NULL DEFAULT 0, tax_enabled TINYINT(1) NOT NULL DEFAULT 0, @@ -182,19 +193,54 @@ CREATE TABLE distribution_site_payment_channels ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销网站收款通道绑定'; ALTER TABLE rental_orders + ADD COLUMN base_price_cent BIGINT NOT NULL DEFAULT 0 AFTER platform_fee_cent, + ADD COLUMN shelf_markup_cent BIGINT NOT NULL DEFAULT 0 AFTER base_price_cent, + ADD COLUMN markup_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER shelf_markup_cent, + ADD COLUMN shelf_ratio DECIMAL(10,2) NOT NULL DEFAULT 0 AFTER markup_site_id, + ADD COLUMN shelf_ratio_delta DECIMAL(10,2) NOT NULL DEFAULT 0 AFTER shelf_ratio, ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER source_channel, ADD COLUMN distribution_mode VARCHAR(32) NOT NULL DEFAULT '' AFTER distribution_site_id, ADD COLUMN distribution_rule_version_id BIGINT UNSIGNED NULL AFTER distribution_mode, + ADD KEY idx_rental_orders_markup_site (markup_site_id), ADD KEY idx_rental_orders_distribution_site (distribution_site_id, distribution_mode), ADD CONSTRAINT fk_rental_orders_distribution_site FOREIGN KEY (distribution_site_id) REFERENCES distribution_sites (id), ADD CONSTRAINT fk_rental_orders_distribution_rule FOREIGN KEY (distribution_rule_version_id) REFERENCES distribution_rule_versions (id); +ALTER TABLE rental_listings + ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER managed_admin_id, + ADD COLUMN source_channel VARCHAR(64) NOT NULL DEFAULT '' AFTER distribution_site_id, + ADD KEY idx_rental_listings_distribution_site (distribution_site_id, status); + +ALTER TABLE chat_qrcode_pool + ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER group_name, + ADD KEY idx_chat_qrcode_distribution_site (distribution_site_id, status), + ADD CONSTRAINT fk_chat_qrcode_distribution_site FOREIGN KEY (distribution_site_id) REFERENCES distribution_sites (id); + +ALTER TABLE chat_conversations + ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER listing_id, + ADD KEY idx_chat_conversations_distribution_site (distribution_site_id, status); + +ALTER TABLE wallet_accounts + ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id, + DROP KEY uk_wallet_accounts_user_id, + ADD UNIQUE KEY uk_wallet_accounts_user_site (user_id, distribution_site_id); + +ALTER TABLE wallet_ledger + ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id, + ADD KEY idx_wallet_ledger_user_site (user_id, distribution_site_id); + +ALTER TABLE withdrawal_requests + ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id, + ADD KEY idx_withdrawal_user_site (user_id, distribution_site_id); + +ALTER TABLE notifications + ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER biz_id, + ADD KEY idx_notifications_user_site (user_id, distribution_site_id, id); + INSERT INTO permissions (code, name, resource, action) VALUES ('distribution:site_view', '查看分销网站', 'distribution', 'site_view'), ('distribution:site_manage', '管理分销网站', 'distribution', 'site_manage'), ('distribution:order_view', '查看分销订单', 'distribution', 'order_view'), -('distribution:process_view', '查看分销订单过程', 'distribution', 'process_view'), -('distribution:finance_view', '查看分销财务', 'distribution', 'finance_view'), ('distribution:settlement_view', '查看分销结算', 'distribution', 'settlement_view'), ('distribution:settlement_manage', '管理分销结算', 'distribution', 'settlement_manage'), ('distribution:rule_view', '查看分销规则', 'distribution', 'rule_view'), @@ -203,14 +249,19 @@ INSERT INTO permissions (code, name, resource, action) VALUES ('distribution:account_display', '管理分销账号展示', 'distribution', 'account_display'), ('distribution:payment_view', '查看分销支付通道', 'distribution', 'payment_view'), ('distribution:payment_manage', '管理分销支付通道', 'distribution', 'payment_manage'), -('distribution:chat_view', '查看分销客服消息', 'distribution', 'chat_view'), -('distribution:chat_send', '发送分销客服消息', 'distribution', 'chat_send') +('distribution:all_view', '查看全部分销站点', 'distribution', 'all_view') ON DUPLICATE KEY UPDATE name = VALUES(name), resource = VALUES(resource), action = VALUES(action); INSERT IGNORE INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.code = 'super_admin' AND p.code LIKE 'distribution:%'; +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r CROSS JOIN permissions p +WHERE r.code IN ('ops', 'finance') + AND p.code LIKE 'distribution:%' + AND p.code <> 'distribution:all_view'; + INSERT INTO distribution_rule_versions ( site_id, mode, version, status, platform_receive_ratio, platform_sell_ratio, @@ -219,29 +270,64 @@ INSERT INTO distribution_rule_versions ( service_fee_value, service_fee_unit, payment_fee_bps, tax_enabled, tax_rate_bps, effective_from, created_at ) VALUES - (NULL, 'two_party_distributor_receive', 1, 'active', 0, 4, 3.5, 0, 0, 0, 2.5, 'ratio', 50, 0, 100, NOW(), NOW()), - (NULL, 'two_party_platform_receive', 1, 'active', 3.5, 0, 0, 4, 0, 0, 2.5, 'ratio', 50, 0, 100, NOW(), NOW()), - (NULL, 'three_party', 1, 'active', 0, 0, 0, 0, 3.5, 4, 2.5, 'ratio', 50, 0, 100, NOW(), NOW()) -ON DUPLICATE KEY UPDATE - status = VALUES(status), - service_fee_value = VALUES(service_fee_value), - service_fee_unit = VALUES(service_fee_unit), - payment_fee_bps = VALUES(payment_fee_bps); + (NULL, 'two_party_distributor_receive', 1, 'active', 0, 40, 35, 0, 0, 0, 25, 'percent', 50, 0, 100, NOW(), NOW()), + (NULL, 'two_party_platform_receive', 1, 'active', 35, 0, 0, 40, 0, 0, 25, 'percent', 50, 0, 100, NOW(), NOW()), + (NULL, 'three_party', 1, 'active', 0, 0, 0, 0, 35, 40, 25, 'percent', 50, 0, 100, NOW(), NOW()); -- +goose Down -DROP TABLE IF EXISTS distribution_order_financials; -DROP TABLE IF EXISTS distribution_site_payment_channels; -DROP TABLE IF EXISTS distribution_settlement_items; -DROP TABLE IF EXISTS distribution_settlement_batches; -DROP TABLE IF EXISTS distribution_order_parties; +ALTER TABLE notifications + DROP KEY idx_notifications_user_site, + DROP COLUMN distribution_site_id; + +ALTER TABLE withdrawal_requests + DROP KEY idx_withdrawal_user_site, + DROP COLUMN distribution_site_id; + +ALTER TABLE wallet_ledger + DROP KEY idx_wallet_ledger_user_site, + DROP COLUMN distribution_site_id; + +DELETE FROM wallet_accounts WHERE distribution_site_id <> 0; + +ALTER TABLE wallet_accounts + DROP KEY uk_wallet_accounts_user_site, + DROP COLUMN distribution_site_id, + ADD UNIQUE KEY uk_wallet_accounts_user_id (user_id); + +ALTER TABLE chat_conversations + DROP KEY idx_chat_conversations_distribution_site, + DROP COLUMN distribution_site_id; + +ALTER TABLE chat_qrcode_pool + DROP FOREIGN KEY fk_chat_qrcode_distribution_site, + DROP KEY idx_chat_qrcode_distribution_site, + DROP COLUMN distribution_site_id; + +ALTER TABLE rental_listings + DROP KEY idx_rental_listings_distribution_site, + DROP COLUMN source_channel, + DROP COLUMN distribution_site_id; + ALTER TABLE rental_orders DROP FOREIGN KEY fk_rental_orders_distribution_rule, DROP FOREIGN KEY fk_rental_orders_distribution_site, DROP KEY idx_rental_orders_distribution_site, + DROP KEY idx_rental_orders_markup_site, DROP COLUMN distribution_rule_version_id, DROP COLUMN distribution_mode, - DROP COLUMN distribution_site_id; + DROP COLUMN distribution_site_id, + DROP COLUMN shelf_ratio_delta, + DROP COLUMN shelf_ratio, + DROP COLUMN markup_site_id, + DROP COLUMN shelf_markup_cent, + DROP COLUMN base_price_cent; + +DROP TABLE IF EXISTS distribution_settlement_items; +DROP TABLE IF EXISTS distribution_settlement_batches; +DROP TABLE IF EXISTS distribution_order_financials; +DROP TABLE IF EXISTS distribution_order_parties; +DROP TABLE IF EXISTS distribution_site_payment_channels; DROP TABLE IF EXISTS distribution_rule_versions; DROP TABLE IF EXISTS distribution_site_accounts; DROP TABLE IF EXISTS distribution_site_members; @@ -251,9 +337,4 @@ DELETE rp FROM role_permissions rp JOIN permissions p ON p.id = rp.permission_id WHERE p.code LIKE 'distribution:%'; -DELETE FROM permissions WHERE code IN ( - 'distribution:site_view', 'distribution:site_manage', 'distribution:order_view', 'distribution:process_view', - 'distribution:finance_view', 'distribution:settlement_view', 'distribution:settlement_manage', - 'distribution:rule_view', 'distribution:rule_manage', 'distribution:account_view', 'distribution:account_display', - 'distribution:payment_view', 'distribution:payment_manage', 'distribution:chat_view', 'distribution:chat_send' -); +DELETE FROM permissions WHERE code LIKE 'distribution:%'; diff --git a/backend/migrations/000065_distribution_qrcode_site.sql b/backend/migrations/000065_distribution_qrcode_site.sql deleted file mode 100644 index bb36f56..0000000 --- a/backend/migrations/000065_distribution_qrcode_site.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up - -ALTER TABLE chat_qrcode_pool - ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER group_name, - ADD KEY idx_chat_qrcode_distribution_site (distribution_site_id, status), - ADD CONSTRAINT fk_chat_qrcode_distribution_site FOREIGN KEY (distribution_site_id) REFERENCES distribution_sites (id); - --- +goose Down - -ALTER TABLE chat_qrcode_pool - DROP FOREIGN KEY fk_chat_qrcode_distribution_site, - DROP KEY idx_chat_qrcode_distribution_site, - DROP COLUMN distribution_site_id; diff --git a/backend/migrations/000067_order_list_least_privilege.sql b/backend/migrations/000065_order_list_least_privilege.sql similarity index 100% rename from backend/migrations/000067_order_list_least_privilege.sql rename to backend/migrations/000065_order_list_least_privilege.sql diff --git a/backend/migrations/000066_distribution_permissions.sql b/backend/migrations/000066_distribution_permissions.sql deleted file mode 100644 index e3188f3..0000000 --- a/backend/migrations/000066_distribution_permissions.sql +++ /dev/null @@ -1,34 +0,0 @@ --- +goose Up - --- 分销后台权限:站点、规则、订单、账号展示、支付通道、结算批次。 -INSERT INTO permissions (code, name, resource, action) VALUES -('distribution:site_view', '查看分销网站', 'distribution_site', 'view'), -('distribution:site_manage', '管理分销网站', 'distribution_site', 'manage'), -('distribution:rule_view', '查看分销规则', 'distribution_rule', 'view'), -('distribution:rule_manage', '管理分销规则', 'distribution_rule', 'manage'), -('distribution:order_view', '查看分销订单', 'distribution_order', 'view'), -('distribution:account_view', '查看分销账号展示', 'distribution_account', 'view'), -('distribution:account_display', '管理分销账号展示', 'distribution_account', 'display'), -('distribution:payment_view', '查看分销支付通道', 'distribution_payment', 'view'), -('distribution:payment_manage', '管理分销支付通道', 'distribution_payment', 'manage'), -('distribution:settlement_view', '查看分销结算', 'distribution_settlement', 'view'), -('distribution:settlement_manage', '管理分销结算', 'distribution_settlement', 'manage') -ON DUPLICATE KEY UPDATE - name = VALUES(name), - resource = VALUES(resource), - action = VALUES(action); - -INSERT IGNORE INTO role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM roles r -JOIN permissions p ON p.code LIKE 'distribution:%' -WHERE r.code IN ('super_admin', 'ops', 'finance'); - --- +goose Down - -DELETE rp -FROM role_permissions rp -JOIN permissions p ON p.id = rp.permission_id -WHERE p.code LIKE 'distribution:%'; - -DELETE FROM permissions WHERE code LIKE 'distribution:%'; diff --git a/backend/migrations/000068_file_upload_owners.sql b/backend/migrations/000066_file_upload_owners.sql similarity index 62% rename from backend/migrations/000068_file_upload_owners.sql rename to backend/migrations/000066_file_upload_owners.sql index f340093..5ef3482 100644 --- a/backend/migrations/000068_file_upload_owners.sql +++ b/backend/migrations/000066_file_upload_owners.sql @@ -1,14 +1,15 @@ -- +goose Up --- 私有文件上传归属:在业务记录写入前,仅允许上传者预览自己的临时文件。 -CREATE TABLE IF NOT EXISTS file_upload_owners ( +-- 私有文件上传归属:业务记录写入前,仅允许上传者预览自己的临时文件。 +CREATE TABLE file_upload_owners ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, object_key VARCHAR(512) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_file_upload_owners_object_key (object_key), - KEY idx_file_upload_owners_user_id (user_id) + KEY idx_file_upload_owners_user_id (user_id), + KEY idx_file_upload_owners_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私有文件上传归属'; -- +goose Down diff --git a/backend/migrations/000069_file_upload_owners_cleanup_index.sql b/backend/migrations/000069_file_upload_owners_cleanup_index.sql deleted file mode 100644 index 3f5b6c1..0000000 --- a/backend/migrations/000069_file_upload_owners_cleanup_index.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up - --- 为长期未关联上传归属记录的定时清理提供索引。 --- 兼容历史上已按旧编号执行过该索引的数据库,重复执行时跳过。 -SET @idx_exists := ( - SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = DATABASE() - AND table_name = 'file_upload_owners' - AND index_name = 'idx_file_upload_owners_created_at' -); -SET @ddl := IF(@idx_exists > 0, - 'SELECT 1', - 'ALTER TABLE file_upload_owners ADD KEY idx_file_upload_owners_created_at (created_at)'); -PREPARE stmt FROM @ddl; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- +goose Down - -ALTER TABLE file_upload_owners - DROP INDEX idx_file_upload_owners_created_at; diff --git a/backend/migrations/000070_distribution_chat_wallet_isolation.sql b/backend/migrations/000070_distribution_chat_wallet_isolation.sql deleted file mode 100644 index a447d6c..0000000 --- a/backend/migrations/000070_distribution_chat_wallet_isolation.sql +++ /dev/null @@ -1,178 +0,0 @@ --- +goose Up - --- 1. chat_conversations 表新增 distribution_site_id 与索引 -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'chat_conversations' AND COLUMN_NAME = 'distribution_site_id') = 0, - 'ALTER TABLE chat_conversations ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER listing_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'chat_conversations' AND INDEX_NAME = 'idx_chat_conversations_distribution_site') = 0, - 'ALTER TABLE chat_conversations ADD KEY idx_chat_conversations_distribution_site (distribution_site_id, status)', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 2. wallet_accounts 表新增 distribution_site_id 并将唯一键变更为 (user_id, distribution_site_id) -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_accounts' AND COLUMN_NAME = 'distribution_site_id') = 0, - 'ALTER TABLE wallet_accounts ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_accounts' AND INDEX_NAME = 'uk_wallet_accounts_user_id') > 0, - 'ALTER TABLE wallet_accounts DROP KEY uk_wallet_accounts_user_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_accounts' AND INDEX_NAME = 'uk_wallet_accounts_user_site') = 1, - 'ALTER TABLE wallet_accounts DROP KEY uk_wallet_accounts_user_site', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_accounts' AND INDEX_NAME = 'uk_wallet_accounts_user_site') = 0, - 'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_user_site (user_id, distribution_site_id)', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 3. wallet_ledger 表新增 distribution_site_id 列与联合索引 -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_ledger' AND COLUMN_NAME = 'distribution_site_id') = 0, - 'ALTER TABLE wallet_ledger ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_ledger' AND INDEX_NAME = 'idx_wallet_ledger_user_site') = 0, - 'ALTER TABLE wallet_ledger ADD KEY idx_wallet_ledger_user_site (user_id, distribution_site_id)', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 4. withdrawal_requests 表新增 distribution_site_id 列与联合索引 -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'withdrawal_requests' AND COLUMN_NAME = 'distribution_site_id') = 0, - 'ALTER TABLE withdrawal_requests ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'withdrawal_requests' AND INDEX_NAME = 'idx_withdrawal_user_site') = 0, - 'ALTER TABLE withdrawal_requests ADD KEY idx_withdrawal_user_site (user_id, distribution_site_id)', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- +goose Down - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'withdrawal_requests' AND INDEX_NAME = 'idx_withdrawal_user_site') > 0, - 'ALTER TABLE withdrawal_requests DROP KEY idx_withdrawal_user_site', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'withdrawal_requests' AND COLUMN_NAME = 'distribution_site_id') > 0, - 'ALTER TABLE withdrawal_requests DROP COLUMN distribution_site_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_ledger' AND INDEX_NAME = 'idx_wallet_ledger_user_site') > 0, - 'ALTER TABLE wallet_ledger DROP KEY idx_wallet_ledger_user_site', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_ledger' AND COLUMN_NAME = 'distribution_site_id') > 0, - 'ALTER TABLE wallet_ledger DROP COLUMN distribution_site_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_accounts' AND INDEX_NAME = 'uk_wallet_accounts_user_site') > 0, - 'ALTER TABLE wallet_accounts DROP KEY uk_wallet_accounts_user_site', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_accounts' AND INDEX_NAME = 'uk_wallet_accounts_user_id') = 0, - 'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_user_id (user_id)', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_accounts' AND COLUMN_NAME = 'distribution_site_id') > 0, - 'ALTER TABLE wallet_accounts DROP COLUMN distribution_site_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'chat_conversations' AND INDEX_NAME = 'idx_chat_conversations_distribution_site') > 0, - 'ALTER TABLE chat_conversations DROP KEY idx_chat_conversations_distribution_site', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'chat_conversations' AND COLUMN_NAME = 'distribution_site_id') > 0, - 'ALTER TABLE chat_conversations DROP COLUMN distribution_site_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/backend/migrations/000071_distribution_notification_isolation.sql b/backend/migrations/000071_distribution_notification_isolation.sql deleted file mode 100644 index 1ad240f..0000000 --- a/backend/migrations/000071_distribution_notification_isolation.sql +++ /dev/null @@ -1,46 +0,0 @@ --- +goose Up - --- 1. notifications 表新增 distribution_site_id 并添加联合索引 -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'notifications' AND COLUMN_NAME = 'distribution_site_id') = 0, - 'ALTER TABLE notifications ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER biz_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'notifications' AND INDEX_NAME = 'idx_notifications_user_site') = 0, - 'ALTER TABLE notifications ADD KEY idx_notifications_user_site (user_id, distribution_site_id, id)', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 2. 回填历史分销订单的站内信通知归属 -UPDATE notifications n -JOIN rental_orders o ON n.biz_id = o.id -SET n.distribution_site_id = o.distribution_site_id -WHERE n.biz_type = 'order' AND o.distribution_site_id IS NOT NULL AND o.distribution_site_id > 0; - --- +goose Down - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'notifications' AND INDEX_NAME = 'idx_notifications_user_site') > 0, - 'ALTER TABLE notifications DROP KEY idx_notifications_user_site', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'notifications' AND COLUMN_NAME = 'distribution_site_id') > 0, - 'ALTER TABLE notifications DROP COLUMN distribution_site_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/backend/migrations/000072_distribution_listing_attribution.sql b/backend/migrations/000072_distribution_listing_attribution.sql deleted file mode 100644 index 38128df..0000000 --- a/backend/migrations/000072_distribution_listing_attribution.sql +++ /dev/null @@ -1,46 +0,0 @@ --- +goose Up - --- 1. rental_listings 表新增 distribution_site_id 并添加索引 -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND COLUMN_NAME = 'distribution_site_id') = 0, - 'ALTER TABLE rental_listings ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER managed_admin_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND INDEX_NAME = 'idx_rental_listings_distribution_site') = 0, - 'ALTER TABLE rental_listings ADD KEY idx_rental_listings_distribution_site (distribution_site_id, status)', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 2. 回填历史分销账号的归属站点 -UPDATE rental_listings l -JOIN distribution_site_accounts dsa ON dsa.listing_id = l.id -SET l.distribution_site_id = dsa.site_id -WHERE l.distribution_site_id IS NULL; - --- +goose Down - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND INDEX_NAME = 'idx_rental_listings_distribution_site') > 0, - 'ALTER TABLE rental_listings DROP KEY idx_rental_listings_distribution_site', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND COLUMN_NAME = 'distribution_site_id') > 0, - 'ALTER TABLE rental_listings DROP COLUMN distribution_site_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/backend/migrations/000073_fix_distribution_listing_attribution.sql b/backend/migrations/000073_fix_distribution_listing_attribution.sql deleted file mode 100644 index 5bfc672..0000000 --- a/backend/migrations/000073_fix_distribution_listing_attribution.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up - --- 修复 000072 中误将展示货架 (distribution_site_accounts) 历史商品回填为分销站点来源的问题 --- 历史主站商品(在分销上传功能上线前创建的商品)来源应保持 distribution_site_id = NULL -UPDATE rental_listings l -JOIN distribution_site_accounts dsa ON dsa.listing_id = l.id -SET l.distribution_site_id = NULL -WHERE l.created_at < '2026-09-08 14:00:00'; - --- +goose Down -SELECT 1; diff --git a/backend/migrations/000074_distribution_enhancement.sql b/backend/migrations/000074_distribution_enhancement.sql deleted file mode 100644 index 16c39c3..0000000 --- a/backend/migrations/000074_distribution_enhancement.sql +++ /dev/null @@ -1,113 +0,0 @@ --- +goose Up - --- 1. distribution_sites 表增加品牌形象与前台装修字段 -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'logo_url') = 0, - 'ALTER TABLE distribution_sites ADD COLUMN logo_url VARCHAR(512) NOT NULL DEFAULT \'\' AFTER display_account_enabled', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'favicon_url') = 0, - 'ALTER TABLE distribution_sites ADD COLUMN favicon_url VARCHAR(512) NOT NULL DEFAULT \'\' AFTER logo_url', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'banners_json') = 0, - 'ALTER TABLE distribution_sites ADD COLUMN banners_json TEXT NULL AFTER favicon_url', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'announcements_json') = 0, - 'ALTER TABLE distribution_sites ADD COLUMN announcements_json TEXT NULL AFTER banners_json', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'footer_info_json') = 0, - 'ALTER TABLE distribution_sites ADD COLUMN footer_info_json TEXT NULL AFTER announcements_json', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 2. distribution_site_accounts 增加自主加价字段 -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_site_accounts' AND COLUMN_NAME = 'markup_amount_cent') = 0, - 'ALTER TABLE distribution_site_accounts ADD COLUMN markup_amount_cent BIGINT NOT NULL DEFAULT 0 AFTER display_status', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- +goose Down - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_site_accounts' AND COLUMN_NAME = 'markup_amount_cent') > 0, - 'ALTER TABLE distribution_site_accounts DROP COLUMN markup_amount_cent', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'footer_info_json') > 0, - 'ALTER TABLE distribution_sites DROP COLUMN footer_info_json', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'announcements_json') > 0, - 'ALTER TABLE distribution_sites DROP COLUMN announcements_json', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'banners_json') > 0, - 'ALTER TABLE distribution_sites DROP COLUMN banners_json', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'favicon_url') > 0, - 'ALTER TABLE distribution_sites DROP COLUMN favicon_url', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'logo_url') > 0, - 'ALTER TABLE distribution_sites DROP COLUMN logo_url', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/backend/migrations/000075_order_shelf_markup_snapshot.sql b/backend/migrations/000075_order_shelf_markup_snapshot.sql deleted file mode 100644 index 65f5b84..0000000 --- a/backend/migrations/000075_order_shelf_markup_snapshot.sql +++ /dev/null @@ -1,58 +0,0 @@ --- +goose Up - --- 下单时冻结货架加价,后续改价不影响历史订单。 -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'base_price_cent') = 0, - 'ALTER TABLE rental_orders ADD COLUMN base_price_cent BIGINT NOT NULL DEFAULT 0 AFTER platform_fee_cent', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'shelf_markup_cent') = 0, - 'ALTER TABLE rental_orders ADD COLUMN shelf_markup_cent BIGINT NOT NULL DEFAULT 0 AFTER base_price_cent', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'markup_site_id') = 0, - 'ALTER TABLE rental_orders ADD COLUMN markup_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER shelf_markup_cent', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- +goose Down - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'markup_site_id') > 0, - 'ALTER TABLE rental_orders DROP COLUMN markup_site_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'shelf_markup_cent') > 0, - 'ALTER TABLE rental_orders DROP COLUMN shelf_markup_cent', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'base_price_cent') > 0, - 'ALTER TABLE rental_orders DROP COLUMN base_price_cent', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/backend/migrations/000076_fix_listing_attribution_without_timestamp.sql b/backend/migrations/000076_fix_listing_attribution_without_timestamp.sql deleted file mode 100644 index a09beb2..0000000 --- a/backend/migrations/000076_fix_listing_attribution_without_timestamp.sql +++ /dev/null @@ -1,71 +0,0 @@ --- +goose Up - --- 补偿 000072 / 000073:货架展示不等于产地,禁止用绝对时间回滚。 --- 本站上传特征:无 listing_uploads(外部导入走该表,source_channel=咸鱼/淘宝等), --- 且 distribution_site_accounts 与商品在同一发布事务内写入(created_at 相差不超过 5 秒)。 - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND COLUMN_NAME = 'source_channel') = 0, - 'ALTER TABLE rental_listings ADD COLUMN source_channel VARCHAR(64) NOT NULL DEFAULT '''' AFTER distribution_site_id', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; - --- 1. 恢复被 000073 时间戳误清空的本站上传归属 -UPDATE rental_listings l -INNER JOIN ( - SELECT dsa.listing_id, MIN(dsa.site_id) AS site_id - FROM distribution_site_accounts dsa - INNER JOIN rental_listings l2 ON l2.id = dsa.listing_id - LEFT JOIN listing_uploads lu ON lu.listing_id = dsa.listing_id - WHERE lu.id IS NULL - AND TIMESTAMPDIFF(SECOND, l2.created_at, dsa.created_at) BETWEEN 0 AND 5 - GROUP BY dsa.listing_id -) origin ON origin.listing_id = l.id -SET l.distribution_site_id = origin.site_id -WHERE l.distribution_site_id IS NULL OR l.distribution_site_id = 0; - --- 2. 清掉仅因货架回填产生的归属(外部导入,或 DSA 明显晚于商品创建) -UPDATE rental_listings l -LEFT JOIN listing_uploads lu ON lu.listing_id = l.id -LEFT JOIN distribution_site_accounts origin_dsa - ON origin_dsa.listing_id = l.id - AND origin_dsa.site_id = l.distribution_site_id - AND TIMESTAMPDIFF(SECOND, l.created_at, origin_dsa.created_at) BETWEEN 0 AND 5 -SET l.distribution_site_id = NULL -WHERE l.distribution_site_id IS NOT NULL - AND l.distribution_site_id <> 0 - AND (lu.id IS NOT NULL OR origin_dsa.id IS NULL); - --- 3. 回填来源渠道快照:分销站用 site_code,外部导入用 listing_uploads.source_channel -UPDATE rental_listings l -INNER JOIN distribution_sites s ON s.id = l.distribution_site_id -SET l.source_channel = s.site_code -WHERE l.distribution_site_id IS NOT NULL - AND l.distribution_site_id <> 0 - AND l.source_channel = ''; - -UPDATE rental_listings l -INNER JOIN ( - SELECT lu.listing_id, MAX(NULLIF(lu.source_channel, '')) AS source_channel - FROM listing_uploads lu - WHERE lu.listing_id IS NOT NULL - GROUP BY lu.listing_id -) upload ON upload.listing_id = l.id -SET l.source_channel = COALESCE(upload.source_channel, '') -WHERE (l.distribution_site_id IS NULL OR l.distribution_site_id = 0) - AND l.source_channel = '' - AND COALESCE(upload.source_channel, '') <> ''; - --- +goose Down - -SET @sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND COLUMN_NAME = 'source_channel') > 0, - 'ALTER TABLE rental_listings DROP COLUMN source_channel', - 'SELECT 1' -); -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/backend/migrations/000077_distribution_all_view_permission.sql b/backend/migrations/000077_distribution_all_view_permission.sql deleted file mode 100644 index 3b87a28..0000000 --- a/backend/migrations/000077_distribution_all_view_permission.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +goose Up - --- 跨站点查看/管理分销数据的显式权限。无站点成员不再视为超管。 -INSERT INTO permissions (code, name, resource, action) VALUES -('distribution:all_view', '查看全部分销站点', 'distribution', 'all_view') -ON DUPLICATE KEY UPDATE - name = VALUES(name), - resource = VALUES(resource), - action = VALUES(action); - -INSERT IGNORE INTO role_permissions (role_id, permission_id) -SELECT r.id, p.id -FROM roles r -JOIN permissions p ON p.code = 'distribution:all_view' -WHERE r.code = 'super_admin'; - --- +goose Down - -DELETE rp -FROM role_permissions rp -JOIN permissions p ON p.id = rp.permission_id -WHERE p.code = 'distribution:all_view'; - -DELETE FROM permissions WHERE code = 'distribution:all_view'; diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 93e1f3e..2093456 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -176,6 +176,9 @@ export interface AccountDisplay { listing_id: number display_status: string markup_amount_cent?: number + shelf_ratio_delta?: number + base_ratio?: number + site_ratio?: number listing_no?: string title?: string game_name?: string @@ -184,6 +187,7 @@ export interface AccountDisplay { rank_level?: string haf_coin_amount?: number price_cent?: number + site_price_cent?: number deposit_amount_cent?: number listing_status?: string in_transaction?: boolean @@ -541,17 +545,17 @@ export async function updateSiteBranding(siteID: number, payload: SiteBrandingPa return extract(resp) } -export async function updateAccountMarkup(id: number, markupCent: number): Promise { +export async function updateAccountMarkup(id: number, shelfRatioDelta: number): Promise { const resp = await api.put>(`/admin/distribution/accounts/${id}/markup`, { - markup_amount_cent: markupCent, + shelf_ratio_delta: shelfRatioDelta, }) return extract(resp) } -export async function batchUpdateAccountMarkup(siteID: number, listingIDs: number[], markupCent: number): Promise { +export async function batchUpdateAccountMarkup(siteID: number, listingIDs: number[], shelfRatioDelta: number): Promise { await api.post(`/admin/distribution/accounts/batch-markup`, { listing_ids: listingIDs, - markup_amount_cent: markupCent, + shelf_ratio_delta: shelfRatioDelta, }, { params: { site_id: siteID }, }) diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx index 8100ee7..f7c8dab 100644 --- a/distribution-admin/src/pages/Accounts.tsx +++ b/distribution-admin/src/pages/Accounts.tsx @@ -33,7 +33,7 @@ export default function Accounts() { // Markup modal state const [markupModalVisible, setMarkupModalVisible] = useState(false) const [targetAccount, setTargetAccount] = useState(null) - const [markupYuan, setMarkupYuan] = useState(0) + const [markupRatio, setMarkupRatio] = useState(0) const [markupSaving, setMarkupSaving] = useState(false) async function load(site?: number, statusFilter?: string) { @@ -122,30 +122,29 @@ export default function Accounts() { function handleOpenMarkup(item?: AccountDisplay) { if (item) { setTargetAccount(item) - setMarkupYuan((item.markup_amount_cent ?? 0) / 100) + setMarkupRatio(item.shelf_ratio_delta ?? 0) } else { if (selectedRowKeys.length === 0) { message.warning('请先勾选需要批量加价的账号商品') return } setTargetAccount(null) - setMarkupYuan(0) + setMarkupRatio(0) } setMarkupModalVisible(true) } async function handleSaveMarkup() { if (!siteID) return - const markupCent = Math.round(markupYuan * 100) - if (markupCent < 0) { - message.error('加价金额不能小于0') + if (markupRatio < 0) { + message.error('加价比例不能小于0') return } setMarkupSaving(true) try { if (targetAccount) { - await updateAccountMarkup(targetAccount.id, markupCent) - message.success('商品加价设置成功!') + await updateAccountMarkup(targetAccount.id, markupRatio) + message.success('商品加价比例设置成功') } else { const listingIDs = items .filter((it) => selectedRowKeys.includes(it.id)) @@ -154,7 +153,7 @@ export default function Accounts() { message.warning('请先勾选要加价的商品') return } - await batchUpdateAccountMarkup(siteID, listingIDs, markupCent) + await batchUpdateAccountMarkup(siteID, listingIDs, markupRatio) message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置加价!`) setSelectedRowKeys([]) } @@ -340,8 +339,7 @@ export default function Accounts() { render: (_: unknown, row: AccountDisplay) => (
- {formatMoney(row.price_cent)} - /小时 + {formatMoney(row.site_price_cent || row.price_cent)}
押金: {formatMoney(row.deposit_amount_cent)} @@ -350,14 +348,14 @@ export default function Accounts() { ), }, { - title: '自主加价', - dataIndex: 'markup_amount_cent', + title: '加价比例', + dataIndex: 'shelf_ratio_delta', width: 120, render: (v: number | undefined) => { if (v && v > 0) { return ( - +{formatMoney(v)} + +{v} ) } @@ -422,28 +420,29 @@ export default function Accounts() { > - 自主加价金额将直接叠加在分销前台展示的每小时租金上,该部分溢价产生的收益将在成单结算时归当前分销商所有。 + 加价是增加该站账号「1 元兑换哈夫币」的比例,不是叠加人民币。比例越大,同样币数售价越低。
- 每小时加价金额: + 增加兑换比例: setMarkupYuan(val ?? 0)} - addonAfter="元/小时" - placeholder="0.00" + value={markupRatio} + onChange={(val) => setMarkupRatio(val ?? 0)} + addonAfter="比例" + placeholder="0" />
- {markupYuan > 0 && targetAccount && ( + {targetAccount && (
- 原租金 {formatMoney(targetAccount.price_cent)}/时 ➔ 分销前台标价为{' '} + 主站比例 {targetAccount.base_ratio || '-'} → 分站{' '} + {((targetAccount.base_ratio || 0) + markupRatio).toFixed(1)},标价{' '} - ¥{(((targetAccount.price_cent ?? 0) + Math.round(markupYuan * 100)) / 100).toFixed(2)} + {formatMoney(targetAccount.site_price_cent || targetAccount.price_cent)} - /时 + (保存后按新比例重算)
)}
diff --git a/distribution-admin/src/pages/Orders.tsx b/distribution-admin/src/pages/Orders.tsx index 31fb16d..80d633d 100644 --- a/distribution-admin/src/pages/Orders.tsx +++ b/distribution-admin/src/pages/Orders.tsx @@ -275,7 +275,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) { render: (_: unknown, row: ReceivedListing) => (
- {formatMoney(row.price_cent)}/时 + {formatMoney(row.price_cent)}
押金: {formatMoney(row.deposit_amount_cent)} diff --git a/distribution-admin/src/pages/Rules.tsx b/distribution-admin/src/pages/Rules.tsx index ea25b6e..920d0fb 100644 --- a/distribution-admin/src/pages/Rules.tsx +++ b/distribution-admin/src/pages/Rules.tsx @@ -80,16 +80,16 @@ export default function Rules() { title: '比例', render: (_: unknown, r: Rule) => { const parts: string[] = [] - if (r.distributor_receive_ratio > 0) parts.push(`分销收 ${r.distributor_receive_ratio}`) - if (r.platform_sell_ratio > 0) parts.push(`主站卖 ${r.platform_sell_ratio}`) - if (r.distributor_sell_ratio > 0) parts.push(`分销卖 ${r.distributor_sell_ratio}`) - if (r.platform_receive_ratio > 0) parts.push(`主站收 ${r.platform_receive_ratio}`) - if (r.distributor_a_ratio > 0) parts.push(`分销A ${r.distributor_a_ratio}`) - if (r.distributor_b_ratio > 0) parts.push(`分销B ${r.distributor_b_ratio}`) + if (r.distributor_receive_ratio > 0) parts.push(`分销收 ${r.distributor_receive_ratio}%`) + if (r.platform_sell_ratio > 0) parts.push(`主站卖 ${r.platform_sell_ratio}%`) + if (r.distributor_sell_ratio > 0) parts.push(`分销卖 ${r.distributor_sell_ratio}%`) + if (r.platform_receive_ratio > 0) parts.push(`主站收 ${r.platform_receive_ratio}%`) + if (r.distributor_a_ratio > 0) parts.push(`分销A ${r.distributor_a_ratio}%`) + if (r.distributor_b_ratio > 0) parts.push(`分销B ${r.distributor_b_ratio}%`) return parts.join(' / ') || '-' }, }, - { title: '服务费', render: (_: unknown, r: Rule) => `${r.service_fee_value} (${r.service_fee_unit})` }, + { title: '服务费', render: (_: unknown, r: Rule) => `${r.service_fee_value}%` }, { title: '手续费', dataIndex: 'payment_fee_bps', render: (v: number) => `${v / 100}%` }, { title: '税费', render: (_: unknown, r: Rule) => (r.tax_enabled ? `${r.tax_rate_bps / 100}%` : '关闭') }, { title: '生效时间', dataIndex: 'effective_from' }, diff --git a/distribution-admin/src/pages/Sites.tsx b/distribution-admin/src/pages/Sites.tsx index 4ccf298..2cf1fd8 100644 --- a/distribution-admin/src/pages/Sites.tsx +++ b/distribution-admin/src/pages/Sites.tsx @@ -43,9 +43,9 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean form.resetFields() form.setFieldsValue({ member_type: 'operator', - distributor_receive_ratio: 3.5, - platform_sell_ratio: 4.0, - service_fee_value: 2.5, + distributor_receive_ratio: 35, + platform_sell_ratio: 40, + service_fee_value: 25, display_account_enabled: true, tax_enabled: false, tax_rate_bps: 100, @@ -237,28 +237,28 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean 3. 分销比例与佣金规则初始化 - + - + - + @@ -313,7 +313,7 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean 收号分成: {successResult.rule.distributor_receive_ratio}% · 卖号分成:{' '} {successResult.rule.platform_sell_ratio}% · 服务费:{' '} - ¥{successResult.rule.service_fee_value} + {successResult.rule.service_fee_value}%
diff --git a/distribution-admin/src/pages/Storefront.tsx b/distribution-admin/src/pages/Storefront.tsx index 3719a67..aa41a8c 100644 --- a/distribution-admin/src/pages/Storefront.tsx +++ b/distribution-admin/src/pages/Storefront.tsx @@ -390,7 +390,7 @@ export default function Storefront() {
{formatMoney(item.price_cent)} - /时 +
押金:{formatMoney(item.deposit_amount_cent)} diff --git a/docs/分销网管理后台设计方案.md b/docs/分销网管理后台设计方案.md index 5ccc081..0d5dece 100644 --- a/docs/分销网管理后台设计方案.md +++ b/docs/分销网管理后台设计方案.md @@ -1,7 +1,7 @@ # 分销网管理后台设计方案 -> 状态:草案 v0.2 -> 更新时间:2026-09-04 +> 状态:草案 v0.6 +> 更新时间:2026-09-08 > 适用范围:分销网站正常租赁订单管理、订单分账、分销客服与支付配置 ## 1. 文档目的 @@ -25,7 +25,7 @@ - 客服:`chat_conversations`、`chat_messages`、客服组和二维码池。 - 管理权限:`admin_users`、`roles`、`permissions`、角色权限关联和审计日志。 -现有系统的价格快照已包含 `seller_ratio`、`buyer_ratio`、`seller_total_price`、`buyer_total_price` 和 `platform_markup_amount`。但现有默认语义是平台加价归平台,分销计划要求销售加价归卖方,因此分销必须使用独立的分账快照,不能直接改变旧字段含义。 +现有系统的价格快照已包含 `seller_ratio`、`buyer_ratio`、`seller_total_price`、`buyer_total_price` 和 `platform_markup_amount`。挂牌比例的语义是「1 元兑换多少万哈夫币」,`价格 = 哈夫币万数 / 比例`,比例越大越便宜。分销规则里的 35% / 40% / 25% **不是**这套挂牌比例,而是卖出后利润的百分比分成,见第 7.1 节。分站货架加价改的是该站账号的挂牌兑换比例,见第 7.6 节。分销必须使用独立的分账快照,不能直接改变主站旧字段含义。 ## 3. 总体架构 @@ -85,9 +85,9 @@ audit.go 1. 显示正常订单中已售出账号的详情。 2. 显示分销网站自己通过正常订单收购/接收的账号详情(卖家在分销网站上架,租客完成交易)。 -3. 配置分销网站在正常订单中的收号比例;主站卖出比例统一或按规则版本管理。 +3. 配置分销网站在正常订单中的收号分成百分比;主站卖出百分比统一或按规则版本管理(默认收号 35% / 卖出 40% / 服务费 25%)。 4. 分销网站使用指定客服组和指定二维码池的拉群码。 -5. 查看手续费、服务费、加价、税费、分成和结算金额。 +5. 查看通道手续费、服务费(利润的 25%)、货架兑换比例、税费、分成和结算金额。 6. 配置分销网站是否展示账号,并可单独关闭某个账号。 7. 配置分销网站可使用的一个或多个收款支付渠道。 8. 分销客服只读订单信息和过程记录,并可按权限处理客服消息。 @@ -181,7 +181,7 @@ distribution:chat_send - 订单号、商品编号、账号基本信息和账号快照。 - 分销网站、收号方、售出方和租客。 -- 收号比例、销售比例、销售基础价、销售加价和实际支付金额。 +- 收号 35%、卖出 40%、服务费 25%、该站 1 元兑换哈夫币比例、销售价和实际支付金额。 - 退款、手续费、服务费、税费、各方收入和结算状态。 - 交接、租用、归还、结账、退款、仲裁等过程事件。 @@ -244,44 +244,63 @@ offline ## 7. 比例、费用和分账口径 -### 7.1 初步默认规则(可后台调整) +### 7.1 卖出后利润的百分比分成(已确认) -第一版先按以下规则建立默认配置,后续由平台后台调整。默认配置不是写死在代码中的常量,必须保存为可启用/停用、可版本化的后台规则: +口头常说的 3.5 / 4 / 2.5,统一写成 **35% / 40% / 25%**。这是卖出完成后的利润分成,三方合计 100%。不是挂牌定价比例(不是 1:3.5 / 1:4),文档、字段、后台和代码都只写百分比,不再写「成」。 + +| 分成 | 角色 | +| --- | --- | +| 35% | 收号 / 上架方 | +| 40% | 卖出方 | +| 25% | 平台服务费 | +| 合计 100% | 切完这一笔利润,不另留大头给平台(四舍五入尾差除外) | ```text -两方:分销网收 3.5,主站卖出 4,服务费 2.5 -两方:分销网卖 4,主站收 3.5,服务费 2.5 -三方:主站服务费 2.5,分销 A 上架 3.5,分销 B 卖出 4 +某方金额 = round_half_up(利润基数 × 百分比 / 100) ``` -初步配置含义: +规则字段存 `35` / `40` / `25`,计算一律 `× 百分比 / 100`。 -| 模式 | 参与方 | 初始比例/费用 | +**禁止**把口头的 3.5 / 4 / 2.5 直接存进字段再 `/ 100`(那会变成 3.5%、4%、2.5%,九成以上留给平台)。 +**禁止**把 25% 服务费当成固定金额、或 `实付 / 2.5`。服务费与收号、卖出是同一笔利润上的三刀。 + +默认角色(可按站点、模式出新规则版本,历史订单用快照): + +| 模式 | 参与方 | 初始分成 | | --- | --- | --- | -| 两方-分销网收号 | 分销网收号、主站卖出 | 分销网 `3.5`,主站 `4`,服务费 `2.5` | -| 两方-主站收号 | 分销网卖出、主站收号 | 分销网 `4`,主站 `3.5`,服务费 `2.5` | -| 三方 | 主站服务费、分销 A 上架、分销 B 卖出 | 主站服务费 `2.5`,分销 A `3.5`,分销 B `4` | +| 两方-分销网收号 | 分销网收号、主站卖出 | 分销网 35%,主站 40%,服务费 25% | +| 两方-主站收号 | 分销网卖出、主站收号 | 分销网 40%,主站 35%,服务费 25% | +| 三方 | 主站服务费、分销 A 上架、分销 B 卖出 | 主站服务费 25%,分销 A 35%,分销 B 40% | -以上是当前业务的初始值。后台需要允许平台管理员按分销网站、交易模式和生效时间修改比例及费用;新订单读取当前生效规则并写入快照,历史订单不跟随后台修改。 +规则字段按参与方角色命名(`distributor_receive_ratio` 等)。后台文案写「35% / 40% / 25%」。 -现有系统的比例语义是“哈夫币数量 / 比例 = 价格”,比例越大,价格越低。3.5 和 4 在分销规则中先分别作为“收号/上架方比例”和“卖出方比例”保存;如果后续确认比例含义需要调整,只影响规则解释和新订单,已结算订单仍以金额快照为准。 +默认值必须通过迁移或初始化数据写入,不写死在结算公式里。新订单读取当前生效规则并写入快照,历史订单不跟随后台修改。 + +利润基数(与通道手续费分层,见 7.5): + +```text +利润基数 = 卖出后可供分销三方分配的利润 + = 租客实付租金 - 号主应得 - 支付通道手续费 +``` + +号主应得走订单原有 `owner_rent_amount_cent`(结账按实际消耗重算时用实际号主租金),不从 35% 里再扣。35% / 40% / 25% 只切上面这一笔利润。 ### 7.2 必须确认的口径 -以下项目在 v0.2 中分为“已有初始值”和“仍待确认”: +v0.6 已锁定 7.1 的百分比分成和 7.6 的分站加价。其余仍待确认: -| 项目 | 待确认内容 | +| 项目 | 状态 | | --- | --- | -| 两方/三方初始比例 | 已按 7.1 建立默认值,后台可调整 | -| 分销网站收号比例 | 已确定可按站点调整;是否设置上下限待确认 | -| 主站统一卖出比例 | 初步按规则版本配置;是否全站固定待确认 | -| 三方分销 | 已确定保留 A 上架、B 卖出角色;结算顺序和亏损承担待确认 | -| 3.5 和 4 的最终业务含义 | 初步按收号/上架方与卖出方比例保存,最终文字口径待确认 | -| 服务费 2.5 | 百分比、金额,还是比例值 | -| 销售加价 | 是否计入服务费和税费基数 | -| 税费税基 | 毛利、收入,还是分成后金额 | -| 退款处理 | 重算剩余分成,还是生成反向冲正流水 | -| 四舍五入尾差 | 统一归主站还是按最大分成方承担 | +| 35% / 40% / 25% 的含义 | **已确认**:卖出后利润的百分比分成;口头 3.5/4/2.5 不再使用 | +| 服务费 25% | **已确认**:利润的 25%,与收号、卖出同一基数 | +| 分站加价 | **已确认**:增加该站账号「1 元兑换哈夫币」的比例,不是叠加人民币,见 7.6 | +| 两方/三方角色 | **已确认**:收号/上架 35%,卖出 40%,服务费 25% | +| 利润是否扣号主、是否按结账消耗重算 | 公式已写在 7.1;结账后利润是否按实际消耗重切待确认 | +| 分销网站收号百分比上下限 | 待确认 | +| 三方亏损承担 | 待确认 | +| 税费税基 | 待确认:各方分成后毛利,还是利润基数 | +| 退款处理 | 待确认:重算剩余分成,还是生成反向冲正流水 | +| 四舍五入尾差 | 待确认:统一归主站还是按最大分成方承担 | ### 7.3 后台调整要求 @@ -289,7 +308,7 @@ offline 1. 只允许具备 `distribution:rule_manage` 权限的管理员操作。 2. 新增规则版本,不直接覆盖已生效版本的历史内容。 -3. 支持按分销网站、交易模式配置收号比例、卖出比例、服务费、手续费和税费开关。 +3. 支持按分销网站、交易模式配置收号百分比、卖出百分比、服务费百分比、通道手续费和税费开关;三方百分比之和必须为 100。 4. 支持设置生效时间;同一站点同一模式同一时刻只能有一个生效版本。 5. 修改前展示影响范围和新旧值,确认后写入审计日志。 6. 已创建、已支付或已结算订单继续使用其规则快照。 @@ -297,55 +316,71 @@ offline ### 7.4 推荐的金额模型 -比例只负责生成金额,最终结算全部以整数分金额为准。每笔订单保存: +两套「比例」不能混用,快照里必须分开存: ```text -ratio_snapshot +挂牌兑换比例:1 元 = X 万哈夫币(主站 publish_ratio / buyer_ratio,分站货架加价改的是这个) +分成百分比:35 / 40 / 25(只用于卖出后利润切分,字段存百分数不是 3.5) +``` + +最终结算全部以整数分为准。每笔订单保存: + +```text +split_percent_snapshot # 各方分成 35 / 40 / 25 +shelf_ratio_snapshot # 该站下单时的 1 元兑换哈夫币比例 amount_snapshot rule_version ``` -建议所有金额字段使用 `*_cent BIGINT`,所有费率使用基点 `*_bps INT`: +建议所有金额字段使用 `*_cent BIGINT`。通道手续费、税率继续用基点 `*_bps INT`: ```text 0.5% = 50 bps 1% = 100 bps +分成 35% 存 35,计算用 × 35 / 100;不要存 3.5 ``` -### 7.5 手续费 +### 7.5 通道手续费与平台服务费 -计划要求手续费在分成前扣除,按退款后的实际支付金额计算,费率为 0.5%,结果四舍五入。 +支付通道手续费和平台服务费不是同一层。 -推荐公式: +通道手续费:在算利润之前扣,按退款后的实际支付金额,费率 0.5%,四舍五入。 ```text 实际支付金额 = 支付金额 - 成功退款金额 -手续费 = round_half_up(实际支付金额 × 50 / 10000) -分成基数 = 实际支付金额 - 手续费 +通道手续费 = round_half_up(实际支付金额 × 50 / 10000) +利润基数 = 实际支付金额 - 号主应得 - 通道手续费 +平台服务费 = round_half_up(利润基数 × 25 / 100) # 25% +收号/上架 = round_half_up(利润基数 × 35 / 100) # 35% +卖出 = round_half_up(利润基数 × 40 / 100) # 40% ``` -“服务费 2.5”是否属于同一手续费层级:待确认。实现时必须将 `payment_fee`、`service_fee` 分成独立字段,不能合并为一个不可解释的费用。 +`payment_fee` 与 `service_fee` 必须分字段。服务费是利润的 25%,不是通道手续费,不是固定元,也不是 `实付/2.5`。 -### 7.6 销售加价 +### 7.6 分站加价(改兑换比例,不是加人民币) -计划要求商家加价销售时,加价全部归卖方。 - -建议保存: +分站加价是**增加该分站货架上账号的「1 元兑换哈夫币」比例**,与主站挂牌比例同一语义: ```text -markup_amount_cent -markup_beneficiary_type -markup_beneficiary_id +1 元 = 比例 w 哈夫币 +价格(元) = 哈夫币万数 / 比例 +比例越大,1 元换到的哈夫币越多,同样币数售价越低 ``` -建议第一版按以下方式实现: +不是在总租金上叠加人民币,不是「元/小时」。 ```text -销售总价 - 销售基础价 = 销售加价 -销售加价全部计入卖方收入 +分站展示比例 = 主站原兑换比例 + 该站加价比例 +分站售价 = 哈夫币万数 / 分站展示比例 ``` -加价是否计入服务费和税费基数:待确认。无明确规则前,结算引擎必须分别展示基础分成和加价,避免隐含扣费。 +约束: + +1. 加价存在 `distribution_site_accounts` 上,只影响该站货架和该站订单。 +2. 主站列表、主站订单、号主回收比例不因分站加价而变。 +3. 下单必须把「该站展示比例」和算出的售价写入订单快照;之后改货架比例不影响历史单。 +4. 货架字段是比例点数,不是 `markup_amount_cent`。现有按人民币、按小时加价的实现与本口径不一致,下一迭代改掉。 +5. 「增加比例」按现有引擎会让该站标价低于主站。卖出后的利润仍按 7.1 切 35% / 40% / 25%;不要再把「加价人民币」单独加给卖方。 ### 7.7 税费 @@ -373,13 +408,18 @@ tax_rate_bps ```text 实际支付金额 -= 手续费 - + 平台服务费 - + 各参与方收入 - + 税费 - + 固定规则下的舍入尾差 += 号主应得 + + 通道手续费 + + 利润基数 +利润基数 += 收号/上架 35% + + 卖出 40% + + 平台服务费 25% + + 四舍五入尾差 ``` +税费若开启,从各方分成中计,不破坏上面 100% 切完利润的关系;具体税基见 7.2。 + 退款、拒付、仲裁和结算撤销必须生成反向明细,不能直接覆盖原结算记录。 ## 8. 建议数据模型 @@ -419,6 +459,7 @@ distribution_site_accounts ```text id, site_id, account_id, listing_id, display_status +shelf_ratio_delta # 该站加价:增加的 1 元兑换哈夫币比例点数,不是人民币 display_started_at, display_ended_at created_at, updated_at ``` @@ -435,14 +476,14 @@ distribution_rule_versions ```text site_id mode: two_party / three_party -platform_receive_ratio -platform_sell_ratio -distributor_receive_ratio -distributor_sell_ratio -distributor_a_ratio -distributor_b_ratio -service_fee_value -service_fee_unit: percent / fixed_amount / ratio +platform_receive_ratio # 百分比,默认 35 +platform_sell_ratio # 百分比,默认 40 +distributor_receive_ratio # 百分比,默认 35 +distributor_sell_ratio # 百分比,默认 40 +distributor_a_ratio # 百分比,默认 35 +distributor_b_ratio # 百分比,默认 40 +service_fee_value # 百分比,默认 25 +service_fee_unit: percent # 只表示百分数;禁止 fixed_amount / 金额÷value payment_fee_bps tax_enabled tax_rate_bps @@ -450,15 +491,15 @@ effective_from, effective_to version, status ``` -首批默认规则按第 7.1 节初始化: +同一规则里收号 + 卖出 + 服务费必须等于 100。首批默认规则按第 7.1 节初始化: ```text -two_party_distributor_receive: distributor_receive_ratio=3.5, platform_sell_ratio=4, service_fee_value=2.5 -two_party_platform_receive: platform_receive_ratio=3.5, distributor_sell_ratio=4, service_fee_value=2.5 -three_party: distributor_a_ratio=3.5, distributor_b_ratio=4, service_fee_value=2.5 +two_party_distributor_receive: distributor_receive_ratio=35, platform_sell_ratio=40, service_fee_value=25 +two_party_platform_receive: platform_receive_ratio=35, distributor_sell_ratio=40, service_fee_value=25 +three_party: distributor_a_ratio=35, distributor_b_ratio=40, service_fee_value=25 ``` -其中 `two_party_platform_receive` 的参与方方向为分销网卖出、主站收号;比例初始值为分销网 `4`、主站 `3.5`。规则字段使用参与方角色命名,避免只写 `receive_ratio`/`sell_ratio` 造成方向歧义。上面的服务费 `2.5` 先作为规则配置值保存,具体单位由后台规则字段和后续口径确认决定。默认规则必须通过迁移或初始化数据写入,不写死在前端或结算代码中。 +字段按参与方角色命名。库里存百分数 35 / 40 / 25,计算一律 `× 百分比 / 100`。已写入的 3.5 / 4 / 2.5 不能当百分数用,迁移时换算成 35 / 40 / 25。默认规则必须通过迁移或初始化数据写入,不写死在结算代码中。 ### 8.5 订单参与方 @@ -666,9 +707,10 @@ distribution.hao858.com { ### 阶段 0:确认业务口径 -- 确认 3.5、4、2.5 的单位和方向。 -- 确认两方、三方参与方及亏损承担。 -- 确认加价是否计入服务费和税费。 +- ~~确认 3.5、4、2.5 的单位和方向。~~ **已确认(v0.6)**:卖出后利润分成 35% / 40% / 25%,文档与字段只写百分比。 +- ~~确认加价形态。~~ **已确认(v0.6)**:分站加价增加该站「1 元兑换哈夫币」比例,不是叠加人民币。 +- 确认两方、三方亏损承担。 +- 确认结账后利润是否按实际消耗重切。 - 确认税费毛利计算口径。 - 确认退款采用重算还是冲正。 - 确认分销员和分销客服的身份模型。 @@ -717,10 +759,10 @@ distribution.hao858.com { ### 账务正确性 -- 手续费按退款后的实际支付金额计算。 -- 手续费先扣除,再进行分成。 -- 0.5% 使用四舍五入并以分保存。 -- 销售加价归指定卖方。 +- 通道手续费按退款后的实际支付金额计算,0.5%,四舍五入,以分保存。 +- 通道手续费和号主应得先扣,得到利润基数后再切 35% / 40% / 25%。 +- 收号 35%、卖出 40%、服务费 25%;字段存 35/40/25,禁止存 3.5/4/2.5 再当百分数乘。 +- 分站加价改该站 1 元兑换哈夫币比例,不叠加人民币;历史订单用下单时的比例快照。 - 各主体独立计算税费。 - 重复支付、退款和结算请求不会重复入账。 - 分销结算明细与钱包流水一致。 @@ -748,9 +790,9 @@ distribution.hao858.com { 1. 分销网站是平台合作方,还是普通用户下的分销身份? 2. 分销客服是否使用现有 `admin_users`? -3. 3.5、4、2.5 的确切单位和计算方向是什么? -4. 服务费和支付手续费是否都按实际支付净额计算? -5. 加价是否计入服务费、税费和退款基数? +3. ~~3.5、4、2.5 的确切单位和计算方向是什么?~~ **已确认**:利润分成 35% / 40% / 25%,只写百分比,见 7.1。 +4. ~~服务费和支付手续费是否都按实际支付净额计算?~~ **已确认**:通道手续费按实付净额 0.5%;服务费是利润的 25%。 +5. ~~加价是否计入服务费、税费和退款基数?~~ **已确认加价形态**:改兑换比例,不是人民币;利润仍按 7.1 切 35% / 40% / 25%。 6. 税费是否只对正毛利计提?亏损是否结转? 7. 退款后佣金是重算剩余金额,还是追加负向冲正? 8. 一个账号能否同时展示在多个分销网站? @@ -762,13 +804,13 @@ distribution.hao858.com { 后续每次确认业务规则后,优先更新第 7、15 节,再同步更新第 8、9、13、14 节,确保规则、数据、接口、实施和验收标准保持一致。 -## 16. 当前实现基线(v0.4) +## 16. 当前实现基线(v0.4,口径落后于 v0.6) 本分支已落地第一批可运行功能,主站域名为 `https://hao858.com/`,独立后台工程位于 `distribution-admin/`,子域名暂定 `https://distribution.hao858.com/`: - Go 后端新增 `distribution_sites`、成员、账号展示、规则版本、订单财务快照、订单参与方、结算批次/明细、支付通道绑定表。 - `rental_orders` 增加分销站点、模式和规则版本字段;只有正常租赁订单才允许写入这些字段。 -- 分账计算引擎已实现退款后净支付、0.5% 基点手续费先扣、服务费三种单位、加价单独展示、各参与方毛利税费和四舍五入。 +- 分账计算引擎已实现退款后净支付、0.5% 基点手续费先扣、服务费三种单位、加价单独展示、各参与方毛利税费和四舍五入。**与 v0.6 口径不一致**:库和默认规则仍存 3.5 / 4 / 2.5,代码按 `分成基数 × 3.5 / 100`(当成 3.5%)切;服务费 `ratio` 单位是 `实付/2.5`;货架加价是人民币而不是兑换比例。生产结算/提现继续冻结。下一迭代把规则改存 35 / 40 / 25,公式 `× 百分比 / 100`,并改货架比例字段。 - 管理 API 已覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。 - 后台权限使用 `distribution:*` 权限码;后续接入成员站点数据范围校验时,不能使用请求参数扩大范围。 - `distribution-admin` 使用 React + TypeScript + Vite + Ant Design,已提供仪表盘、站点、售出订单、收号订单及结算/支付/账号/规则/客服入口和 Docker 镜像。 diff --git a/frontend/src/features/admin/views/AdminDistributionRulesView.vue b/frontend/src/features/admin/views/AdminDistributionRulesView.vue index d95edf6..aab861a 100644 --- a/frontend/src/features/admin/views/AdminDistributionRulesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionRulesView.vue @@ -114,8 +114,8 @@ onMounted(async () => { - - + + diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index cc05305..993fab5 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -31,9 +31,9 @@ const onboardForm = reactive({ admin_password: '', admin_nickname: '', member_type: 'operator', - distributor_receive_ratio: 3.5, - platform_sell_ratio: 4.0, - service_fee_value: 2.5, + distributor_receive_ratio: 35, + platform_sell_ratio: 40, + service_fee_value: 25, display_account_enabled: true, tax_enabled: false, tax_rate_bps: 100, @@ -72,9 +72,9 @@ function handleOpenOnboard() { onboardForm.admin_password = '' onboardForm.admin_nickname = '' onboardForm.member_type = 'operator' - onboardForm.distributor_receive_ratio = 3.5 - onboardForm.platform_sell_ratio = 4.0 - onboardForm.service_fee_value = 2.5 + onboardForm.distributor_receive_ratio = 35 + onboardForm.platform_sell_ratio = 40 + onboardForm.service_fee_value = 25 onboardForm.display_account_enabled = true onboardForm.tax_enabled = false onboardForm.tax_rate_bps = 100 @@ -306,11 +306,12 @@ onMounted(() => { - + @@ -358,9 +359,8 @@ onMounted(() => { 收号分成: {{ onboardResult.rule.distributor_receive_ratio }}% · 卖号分成: - {{ onboardResult.rule.platform_sell_ratio }}% · 服务费: ¥{{ - onboardResult.rule.service_fee_value - }} + {{ onboardResult.rule.platform_sell_ratio }}% · 服务费: + {{ onboardResult.rule.service_fee_value }}%
diff --git a/scripts/check-sql-guard.sh b/scripts/check-sql-guard.sh index 629019e..e74d169 100755 --- a/scripts/check-sql-guard.sh +++ b/scripts/check-sql-guard.sh @@ -63,12 +63,21 @@ require_pattern "backend/internal/modules/chat/support.go" "renter.phone = ? OR # 禁止核心列表重新引入跨表 6~10 路全扫描反模式 forbid_pattern "backend/internal/modules/dispute/query.go" "o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR d.id = ?" "纠纷列表禁止6表无意图全表扫描" -# 已发布迁移只允许新增,不允许修改或删除历史文件。 -if changed_migrations="$(git -C "${ROOT_DIR}" diff --name-status HEAD -- backend/migrations)"; then +# 已合入 main 的迁移只允许新增,不允许修改或删除。 +# 当前分支尚未合入 main 的迁移可以整段重写。 +published_ref="" +if git -C "${ROOT_DIR}" rev-parse --verify main >/dev/null 2>&1; then + published_ref="main" +elif git -C "${ROOT_DIR}" rev-parse --verify origin/main >/dev/null 2>&1; then + published_ref="origin/main" +fi +if [[ -z "${published_ref}" ]]; then + failures+=("无法确定 main 分支,无法检查已发布迁移") +elif changed_migrations="$(git -C "${ROOT_DIR}" diff --name-status "${published_ref}" -- backend/migrations)"; then while read -r status file _; do [[ -z "${status:-}" ]] && continue if [[ "${status}" != "A" ]]; then - failures+=("历史迁移文件不可修改或删除: ${status} ${file}") + failures+=("已发布迁移文件不可修改或删除: ${status} ${file}") fi done <<< "${changed_migrations}" else