diff --git a/backend/internal/e2e/distribution_flow_test.go b/backend/internal/e2e/distribution_flow_test.go new file mode 100644 index 0000000..9e05fa3 --- /dev/null +++ b/backend/internal/e2e/distribution_flow_test.go @@ -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) +} diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 92190a9..9248ce2 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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" {