test(distribution): add e2e scenario 1 test and fix tx deadlock in MarkSettlementBatch
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/distribution"
|
||||
listingpkg "hfb_sys/backend/internal/modules/listing"
|
||||
orderpkg "hfb_sys/backend/internal/modules/order"
|
||||
)
|
||||
|
||||
func openDistributionE2EDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := database.NewTestDBWithName(fmt.Sprintf("dist_e2e_%d", time.Now().UnixNano()))
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if err := database.MigrateRentalTransactionTestSchema(db); err != nil {
|
||||
t.Fatalf("migrate rental transaction schema failed: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.Role{}, &model.Permission{}, &model.AdminUserRole{}, &model.RolePermission{},
|
||||
&model.PaymentMerchantConfig{},
|
||||
&model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{},
|
||||
&model.DistributionOrderParty{}, &model.DistributionSitePaymentChannel{},
|
||||
&model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{},
|
||||
&model.WithdrawalRequest{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate distribution schema failed: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement
|
||||
// 场景 1 完整端到端测试:
|
||||
// 1. 主站收号(号主底价 80 元,主站加价定价 100 元,100M 币,比例 100);
|
||||
// 2. 分站 A 同步商品,设置自主提价(调减 20 比例,售价自动升为 125 元,分站加价 25 元);
|
||||
// 3. 租客在分站前台浏览并下单,实付售价 125 元;
|
||||
// 4. 支付成功自动触发快照:
|
||||
// - 号主成本 80 元保底;
|
||||
// - 分站自主加价 25 元 100% 归卖出分站;
|
||||
// - 主站提价净利润(20 元 - 通道费 0.63 元 = 19.37 元)按 35% / 40% / 25% 严丝合缝切分;
|
||||
// - 卖出分站总获得 32.75 元;
|
||||
// 5. 完单后创建结算批次并执行结算,分站金库钱包精确入账 32.75 元,财务状态置为 settled。
|
||||
func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) {
|
||||
db := openDistributionE2EDB(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 1. 基础主体初始化
|
||||
// ----------------------------------------------------
|
||||
adminUser := model.AdminUser{Username: "super_admin", Status: "active"}
|
||||
if err := db.Create(&adminUser).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
subSiteAdmin := model.AdminUser{Username: "site_a_manager", Status: "active"}
|
||||
if err := db.Create(&subSiteAdmin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
siteA := model.DistributionSite{
|
||||
SiteCode: "branch_alpha",
|
||||
SiteName: "分销甲站",
|
||||
Domain: "alpha.example.com",
|
||||
Status: "active",
|
||||
DisplayAccountEnabled: true,
|
||||
}
|
||||
if err := db.Create(&siteA).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
siteMember := model.DistributionSiteMember{
|
||||
SiteID: siteA.ID,
|
||||
AdminUserID: subSiteAdmin.ID,
|
||||
MemberType: "operator",
|
||||
Status: "active",
|
||||
DataScope: "site",
|
||||
}
|
||||
if err := db.Create(&siteMember).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 规则版本:主站收号、分站卖出(两方-平台收号模式)
|
||||
// 收号 35%(归平台),卖出 40%(归分站),服务费 25%(归平台),通道费 50 BPS (0.5%)
|
||||
ruleVersion := model.DistributionRuleVersion{
|
||||
SiteID: &siteA.ID,
|
||||
Mode: distribution.ModeTwoPartyPlatformReceive,
|
||||
Version: 1,
|
||||
Status: "active",
|
||||
PlatformReceiveRatio: 35,
|
||||
DistributorSellRatio: 40,
|
||||
ServiceFeeValue: 25,
|
||||
ServiceFeeUnit: distribution.ServiceFeeUnitPercent,
|
||||
PaymentFeeBPS: 50,
|
||||
EffectiveFrom: now.Add(-2 * time.Hour),
|
||||
}
|
||||
if err := db.Create(&ruleVersion).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
owner := model.User{Phone: "13800000001", Status: "active"}
|
||||
renter := model.User{Phone: "13900000002", Status: "active"}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&renter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 2. 主站收号:100M 币,号主底价 80 元,主站定价 100 元
|
||||
// ----------------------------------------------------
|
||||
assetSummaryJSON, _ := json.Marshal(map[string]any{
|
||||
"publish_ratio": 100.0,
|
||||
"price_breakdown": map[string]any{
|
||||
"seller_ratio": 125.0,
|
||||
"seller_total_price": 80.0, // 号主底价 80 元
|
||||
"buyer_ratio": 100.0,
|
||||
"buyer_total_price": 100.0, // 主站标价 100 元
|
||||
"platform_markup_amount": 20.0, // 主站提价 20 元
|
||||
},
|
||||
})
|
||||
|
||||
account := model.GameAccount{
|
||||
Title: "王者账号-100M",
|
||||
HafCoinAmount: 100_000_000, // 10000 万币
|
||||
AssetSummary: datatypes.JSON(assetSummaryJSON),
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
listing := model.RentalListing{
|
||||
ListingNo: "L-E2E-101",
|
||||
AccountID: account.ID,
|
||||
OwnerID: owner.ID,
|
||||
PriceCent: 10000, // 主站挂牌基准价 100 元 (10000 分)
|
||||
DepositAmountCent: 0,
|
||||
Status: "published",
|
||||
ReviewStatus: "approved",
|
||||
DistributionSiteID: nil, // 主站自营收号
|
||||
}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 3. 分站货架选品并自主提价(调减 20 比例,售价变为 125 元)
|
||||
// ----------------------------------------------------
|
||||
shelfRelation := model.DistributionSiteAccount{
|
||||
SiteID: siteA.ID,
|
||||
AccountID: account.ID,
|
||||
ListingID: listing.ID,
|
||||
DisplayStatus: "enabled",
|
||||
ShelfRatioDelta: 20, // 提价减 20 比例点 (100 -> 80)
|
||||
}
|
||||
if err := db.Create(&shelfRelation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 验证货架公共查询计算价格
|
||||
sitePriceCent, baseRatio, siteRatio := listingpkg.ApplyShelfRatio(account.HafCoinAmount, listing.PriceCent, shelfRelation.ShelfRatioDelta)
|
||||
if baseRatio != 100 || siteRatio != 80 {
|
||||
t.Fatalf("shelf ratio calculation error: base=%v, site=%v", baseRatio, siteRatio)
|
||||
}
|
||||
if sitePriceCent != 12500 {
|
||||
t.Fatalf("shelf price calculation error: got %d, want 12500", sitePriceCent)
|
||||
}
|
||||
shelfMarkupCent := sitePriceCent - listing.PriceCent
|
||||
if shelfMarkupCent != 2500 {
|
||||
t.Fatalf("shelf markup error: got %d, want 2500", shelfMarkupCent)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 4. 租客在分站下单并生成快照
|
||||
// ----------------------------------------------------
|
||||
orderRepo := orderpkg.NewRepository(db, orderpkg.Dependencies{})
|
||||
orderDTO, err := orderRepo.Create(ctx, renter.ID, orderpkg.CreateRequest{
|
||||
ListingID: listing.ID,
|
||||
DistributionSiteCode: siteA.SiteCode,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("order create failed: %v", err)
|
||||
}
|
||||
|
||||
// 校验订单快照
|
||||
if orderDTO.RentAmountCent == nil || *orderDTO.RentAmountCent != 12500 {
|
||||
t.Fatalf("order rent amount = %v, want 12500", orderDTO.RentAmountCent)
|
||||
}
|
||||
if orderDTO.BasePriceCent != 10000 || orderDTO.ShelfMarkupCent != 2500 || orderDTO.MarkupSiteID != siteA.ID {
|
||||
t.Fatalf("order snapshot error: base=%d, markup=%d, site=%d", orderDTO.BasePriceCent, orderDTO.ShelfMarkupCent, orderDTO.MarkupSiteID)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 5. 支付成功并触发分销财务快照 SnapshotPaidOrderTx
|
||||
// ----------------------------------------------------
|
||||
grossPaidCent := int64(12500)
|
||||
if err := distribution.SnapshotPaidOrderTx(db, orderDTO.ID, grossPaidCent); err != nil {
|
||||
t.Fatalf("SnapshotPaidOrderTx failed: %v", err)
|
||||
}
|
||||
|
||||
// 验证财务快照
|
||||
var financial model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", orderDTO.ID).First(&financial).Error; err != nil {
|
||||
t.Fatalf("find financial snapshot failed: %v", err)
|
||||
}
|
||||
if financial.GrossPaidAmountCent != 12500 || financial.MarkupAmountCent != 2500 {
|
||||
t.Fatalf("financial snapshot error: gross=%d, markup=%d", financial.GrossPaidAmountCent, financial.MarkupAmountCent)
|
||||
}
|
||||
// 支付通道费 0.5%:12500 * 50 / 10000 = 63 分
|
||||
if financial.PaymentFeeCent != 63 {
|
||||
t.Fatalf("payment fee = %d, want 63", financial.PaymentFeeCent)
|
||||
}
|
||||
// 主站提价总额 10000 - 8000 = 2000 分,扣除通道费 63 分后利润基数 = 1937 分
|
||||
if financial.SplitBaseAmountCent != 1937 {
|
||||
t.Fatalf("split base amount = %d, want 1937", financial.SplitBaseAmountCent)
|
||||
}
|
||||
// 平台服务费 25%:1937 * 25% = 484 分
|
||||
if financial.ServiceFeeCent != 484 {
|
||||
t.Fatalf("service fee = %d, want 484", financial.ServiceFeeCent)
|
||||
}
|
||||
|
||||
// 验证各参与方收益快照
|
||||
var parties []model.DistributionOrderParty
|
||||
if err := db.Where("order_id = ?", orderDTO.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 {
|
||||
t.Fatalf("parties error: len=%d, err=%v", len(parties), err)
|
||||
}
|
||||
|
||||
// 参与方 0:distributor_sell(分站 A,卖方)
|
||||
// 收益 = 分站自主加价 2500 分 + 提价分成 40% (1937 * 40% = 775) = 3275 分
|
||||
var distributorParty, platformParty model.DistributionOrderParty
|
||||
for _, p := range parties {
|
||||
if p.PartyRole == "distributor_sell" {
|
||||
distributorParty = p
|
||||
} else if p.PartyRole == "platform_receive" {
|
||||
platformParty = p
|
||||
}
|
||||
}
|
||||
if distributorParty.IncomeAmountCent != 3275 || distributorParty.PartyID != siteA.ID {
|
||||
t.Fatalf("distributor_sell party error: income=%d (want 3275), partyID=%d", distributorParty.IncomeAmountCent, distributorParty.PartyID)
|
||||
}
|
||||
// 参与方 1:platform_receive(平台收号方,35% 分成:1937 * 35% = 678 分)
|
||||
if platformParty.IncomeAmountCent != 678 {
|
||||
t.Fatalf("platform_receive party error: income=%d (want 678)", platformParty.IncomeAmountCent)
|
||||
}
|
||||
|
||||
// 尾差校验:1937 - (775 + 678) - 484 = 0
|
||||
if financial.RoundingAdjustmentCent != 0 {
|
||||
t.Fatalf("rounding adjustment = %d, want 0", financial.RoundingAdjustmentCent)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 6. 批次结算与分站金库到账 (Settlement Batch & Wallet Credit)
|
||||
// ----------------------------------------------------
|
||||
distRepo := distribution.NewRepository(db)
|
||||
|
||||
// 创建结算批次
|
||||
batch, err := distRepo.CreateSettlementBatch(ctx, subSiteAdmin.ID, distribution.CreateSettlementBatchRequest{
|
||||
SiteID: siteA.ID,
|
||||
PeriodStart: now.Add(-24 * time.Hour),
|
||||
PeriodEnd: now.Add(24 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSettlementBatch failed: %v", err)
|
||||
}
|
||||
if batch.TotalAmountCent != 3275 {
|
||||
t.Fatalf("batch total amount = %d, want 3275", batch.TotalAmountCent)
|
||||
}
|
||||
|
||||
// 确认结算批次入账
|
||||
settledBatch, err := distRepo.MarkSettlementBatch(ctx, subSiteAdmin.ID, batch.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("MarkSettlementBatch failed: %v", err)
|
||||
}
|
||||
if settledBatch.Status != "settled" {
|
||||
t.Fatalf("settled batch status = %s, want settled", settledBatch.Status)
|
||||
}
|
||||
|
||||
// 验证财务快照状态更新为 settled
|
||||
var updatedFinancial model.DistributionOrderFinancial
|
||||
if err := db.Where("order_id = ?", orderDTO.ID).First(&updatedFinancial).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updatedFinancial.SettlementStatus != "settled" {
|
||||
t.Fatalf("financial settlement status = %s, want settled", updatedFinancial.SettlementStatus)
|
||||
}
|
||||
|
||||
// 验证分站金库钱包账户余额
|
||||
var siteWallet model.WalletAccount
|
||||
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&siteWallet).Error; err != nil {
|
||||
t.Fatalf("find site wallet failed: %v", err)
|
||||
}
|
||||
if siteWallet.AvailableBalanceCent != 3275 {
|
||||
t.Fatalf("site wallet balance = %d, want 3275", siteWallet.AvailableBalanceCent)
|
||||
}
|
||||
|
||||
// 验证钱包流水记录
|
||||
var ledger model.WalletLedger
|
||||
if err := db.Where("user_id = 0 AND distribution_site_id = ? AND biz_type = 'distribution_settlement'", siteA.ID).First(&ledger).Error; err != nil {
|
||||
t.Fatalf("find wallet ledger failed: %v", err)
|
||||
}
|
||||
if ledger.AmountCent != 3275 || ledger.Direction != "in" {
|
||||
t.Fatalf("wallet ledger error: amount=%d, direction=%s", ledger.AmountCent, ledger.Direction)
|
||||
}
|
||||
|
||||
t.Logf("=== 场景 1 全链路端到端验证通过!===")
|
||||
t.Logf("实付总额: %d 分 (125.00元)", grossPaidCent)
|
||||
t.Logf("号主保底成本: %d 分 (80.00元)", 8000)
|
||||
t.Logf("分站自主加价(独享): %d 分 (25.00元)", shelfMarkupCent)
|
||||
t.Logf("支付通道手续费(0.5%%): %d 分 (0.63元)", financial.PaymentFeeCent)
|
||||
t.Logf("主站提价净利润: %d 分 (19.37元)", financial.SplitBaseAmountCent)
|
||||
t.Logf(" ├─ 收号方(主站 35%%): %d 分 (6.78元)", platformParty.IncomeAmountCent)
|
||||
t.Logf(" ├─ 卖出方提价分成(分站 40%%): %d 分 (7.75元)", distributorParty.IncomeAmountCent-shelfMarkupCent)
|
||||
t.Logf(" └─ 平台服务费(25%%): %d 分 (4.84元)", financial.ServiceFeeCent)
|
||||
t.Logf("分站 A 最终结算到账: %d 分 (32.75元) [加价25元 + 分成7.75元]", siteWallet.AvailableBalanceCent)
|
||||
}
|
||||
@@ -69,14 +69,18 @@ func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
|
||||
}
|
||||
|
||||
func (r *Repository) hasGlobalDistributionAccess(ctx context.Context, adminID uint64) (bool, error) {
|
||||
return r.hasGlobalDistributionAccessWithDB(ctx, r.db, adminID)
|
||||
}
|
||||
|
||||
func (r *Repository) hasGlobalDistributionAccessWithDB(ctx context.Context, db *gorm.DB, adminID uint64) (bool, error) {
|
||||
if adminID == 0 {
|
||||
return true, nil
|
||||
}
|
||||
if r.db == nil {
|
||||
if db == nil {
|
||||
return false, nil
|
||||
}
|
||||
var n int64
|
||||
if err := r.db.WithContext(ctx).Table("roles").
|
||||
if err := db.WithContext(ctx).Table("roles").
|
||||
Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id").
|
||||
Where("aur.admin_user_id = ? AND roles.code = ?", adminID, "super_admin").
|
||||
Count(&n).Error; err != nil {
|
||||
@@ -85,7 +89,7 @@ func (r *Repository) hasGlobalDistributionAccess(ctx context.Context, adminID ui
|
||||
if n > 0 {
|
||||
return true, nil
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Table("permissions").
|
||||
if err := db.WithContext(ctx).Table("permissions").
|
||||
Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id").
|
||||
Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id").
|
||||
Where("aur.admin_user_id = ? AND permissions.code = ?", adminID, "distribution:all_view").
|
||||
@@ -142,10 +146,17 @@ func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64)
|
||||
// ensureSiteAccess 校验管理员可访问指定站点。
|
||||
// 仅超级管理员或持有 distribution:all_view 的账号可跨站;无成员记录不再视为超管。
|
||||
func (r *Repository) ensureSiteAccess(ctx context.Context, adminID, siteID uint64) error {
|
||||
return r.ensureSiteAccessWithDB(ctx, r.db, adminID, siteID)
|
||||
}
|
||||
|
||||
func (r *Repository) ensureSiteAccessWithDB(ctx context.Context, db *gorm.DB, adminID, siteID uint64) error {
|
||||
if adminID == 0 {
|
||||
return nil
|
||||
}
|
||||
global, err := r.hasGlobalDistributionAccess(ctx, adminID)
|
||||
if db == nil {
|
||||
db = r.db
|
||||
}
|
||||
global, err := r.hasGlobalDistributionAccessWithDB(ctx, db, adminID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -156,7 +167,7 @@ func (r *Repository) ensureSiteAccess(ctx context.Context, adminID, siteID uint6
|
||||
return ErrSiteAccessDenied
|
||||
}
|
||||
var allowed int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}).
|
||||
if err := db.WithContext(ctx).Model(&model.DistributionSiteMember{}).
|
||||
Where("admin_user_id = ? AND site_id = ? AND status = 'active'", adminID, siteID).Count(&allowed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -809,7 +820,7 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
|
||||
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
if row.Status == "settled" {
|
||||
|
||||
Reference in New Issue
Block a user