Files
hfb_sys/backend/internal/e2e/distribution_flow_test.go
T
yml2213 cbd5412315 test: 后端测试从 SQLite 全面切换到 MySQL,与生产环境对齐
测试库不再使用 SQLite 内存库,改为每个测试包独立的 MySQL 库,
表结构由 backend/migrations 的真实迁移建立。

基础设施
- 新增 internal/database/testdb.go:NewTestDB() 保持原签名(28 处调用零改动),
  内部连接 MySQL 测试库,首次调用重建库并执行全部真实迁移,之后每次调用清空全表
- 库名 = 测试库基础名 + 包目录名,同包共享、跨包隔离,使 go test ./... 可并发执行
  (共用一库时各包会互相 DROP/CREATE 导致 database exists)
- 新增 NewDryRunDB():MySQL 方言 + DryRun,供断言 SQL 形态的 5 处测试使用
- 新增 AutoMigrateIfMissing():只对不存在的表 AutoMigrate,
  避免改动迁移定义的索引与约束
- 新增 scripts/prepare-test-db.sh:建库与通配授权,可重复执行
- 33 处 sqlite.Open 全部替换,26 个测试文件改用 MySQL
- 删除 46 处手写 SQLite 方言 DDL(AUTOINCREMENT / INTEGER PRIMARY KEY / TEXT),
  表统一由真实迁移建立
- go.mod 移除 gorm.io/driver/sqlite 与 mattn/go-sqlite3
- 删除生产代码里 distribution/repository.go 的 sqlite 方言分支(strftime)

对齐环境后暴露并修复的真实问题
- DATE(col) 在 MySQL 返回 DATE 类型,使 Date string 字段变成 RFC3339 时间串,
  大盘日期会带出时间部分;28 处改为 DATE_FORMAT(col, '%Y-%m-%d')
- 系统自动创建分销结算批次时 created_by 写入 0,违反指向 admin_users 的外键,
  生产会直接插入失败;改为写 NULL(新增 adminRef helper)
- MySQL DATETIME 仅到秒,静默截断 Go 侧写入的时间,
  导致租用时长计算与结算时点回读比对偏差;
  rental_orders 的 9 个业务时间列提到 DATETIME(6)
- 测试数据补齐严格模式要求的 NOT NULL 列、修正超出列长的取值、补齐外键父记录

迁移
- 000065 的 ALTER TABLE rental_orders 一并完成业务时间列精度调整,
  feature 侧不再单开迁移文件

脚本与文档
- check.sh / check-backend-coverage.sh 去掉 -p 1 串行与 SQLite 相关注释
- 更新 docs/Repository层测试补充总结.md 的测试运行说明
2026-09-10 14:34:01 +08:00

331 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package e2e
import (
"context"
"encoding/json"
"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.NewTestDB()
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 := database.AutoMigrateIfMissing(db,
&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)
}