Files
hfb_sys/backend/internal/e2e/distribution_flow_test.go
yml2213 9ae6d5eaea feat(distribution): 优化一键同步主站商品功能(并发防重、分批入库、失效商品联动下架与初始加价)
- 后端优化:
  - 引入 Redis + 内存 sync.Map 双重互斥锁,避免并发/连击导致唯一索引冲突 (ErrSyncInProgress)
  - 使用 CreateInBatches(500) 进行大数据量批量插入,杜绝超长 SQL 与锁表超时
  - 增加 CleanStaleListings 支持,自动排查并联动下架主站非在售的货架商品,清理历史幽灵数据
  - 增加 DefaultShelfRatioDelta 支持,新同步商品直接继承初始加价比例,免除二次手动改价
- 前端优化:
  - distribution-admin 货架管理新增一键同步配置弹窗(可勾选自动下架失效商品、配置初始加价)
  - 同步完成后展示结构化统计弹窗(新增、保持、联动下架、主站在售总量)
  - 前后端类型定义与 API 客户端对齐升级
- 测试覆盖:
  - 补充 TestSyncSiteAccountsOptimizations 单元测试与 handler 集成测试
  - 全工程测试与门禁 check.sh 100% 通过
2026-09-11 12:47:59 +08:00

1496 lines
56 KiB
Go
Raw Permalink 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"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/internal/modules/dispute"
"hfb_sys/backend/internal/modules/distribution"
listingpkg "hfb_sys/backend/internal/modules/listing"
orderpkg "hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/payment"
"hfb_sys/backend/internal/modules/paymentconfig"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/internal/modules/withdrawal"
"hfb_sys/backend/pkg/crypto"
)
func openDistributionE2EDB(t *testing.T) *gorm.DB {
t.Helper()
return database.NewTestDB()
}
// TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement
// 场景 1 完整端到端测试:
// 1. 主站收号(号主底价 80 元,主站加价定价 100 元,100M 币,比例 100);
// 2. 分站 A 同步商品,设置自主提价(调减 20 比例,售价自动升为 125 元,分站加价 25 元);
// 3. 租客在分站前台浏览并下单,实付售价 125 元;
// 4. 支付成功自动触发快照:
// - 号主成本 80 元保底;
// - 分站自主加价 25 元 100% 归卖出分站;
// - 主站提价利润 20 元(通道费 0.63 元由主站承担)按 35% / 40% / 25% 严丝合缝切分;
// - 卖出分站总获得 33.00 元;
//
// 5. 完单后创建结算批次并执行结算,分站金库钱包精确入账 33.00 元,财务状态置为 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 分,通道费不扣减,利润基数 = 2000 分
if financial.SplitBaseAmountCent != 2000 {
t.Fatalf("split base amount = %d, want 2000", financial.SplitBaseAmountCent)
}
// 平台服务费 25%:2000 * 25% = 500 分
if financial.ServiceFeeCent != 500 {
t.Fatalf("service fee = %d, want 500", 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% (2000 * 40% = 800) = 3300 分
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 != 3300 || distributorParty.PartyID != siteA.ID {
t.Fatalf("distributor_sell party error: income=%d (want 3300), partyID=%d", distributorParty.IncomeAmountCent, distributorParty.PartyID)
}
// 参与方 1:platform_receive(平台收号方,35% 分成:2000 * 35% = 700 分)
if platformParty.IncomeAmountCent != 700 {
t.Fatalf("platform_receive party error: income=%d (want 700)", platformParty.IncomeAmountCent)
}
// 尾差校验:2000 - (800 + 700) - 500 = 0
if financial.RoundingAdjustmentCent != 0 {
t.Fatalf("rounding adjustment = %d, want 0", financial.RoundingAdjustmentCent)
}
// ----------------------------------------------------
// 6. 批次结算与分站金库到账 (Settlement Batch & Wallet Credit)
// ----------------------------------------------------
if err := db.Model(&model.RentalOrder{}).Where("id = ?", orderDTO.ID).Update("status", "completed").Error; err != nil {
t.Fatal(err)
}
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 != 3300 {
t.Fatalf("batch total amount = %d, want 3300", 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 != 3300 {
t.Fatalf("site wallet balance = %d, want 3300", 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 != 3300 || 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 分 (20.00元)", financial.SplitBaseAmountCent)
t.Logf(" ├─ 收号方(主站 35%%): %d 分 (7.00元)", platformParty.IncomeAmountCent)
t.Logf(" ├─ 卖出方提价分成(分站 40%%): %d 分 (8.00元)", distributorParty.IncomeAmountCent-shelfMarkupCent)
t.Logf(" └─ 平台服务费(25%%): %d 分 (5.00元)", financial.ServiceFeeCent)
t.Logf("分站 A 最终结算到账: %d 分 (33.00元) [加价25元 + 分成8元]", siteWallet.AvailableBalanceCent)
}
// TestE2EScenario2_SubSiteReceive_MainSiteSell_FullFlow
// 场景 2 完整端到端测试:
// 1. 分站 A 收号录入(货源归属分站 A,号主底价 80 元,挂牌基准价 100 元,主站提价 20 元);
// 2. 买家在主站前台浏览并直接下单(无分站加价,实付 100 元);
// 3. 支付成功自动触发快照 (two_party_distributor_receive):
// - 分站 A 作为收号方获得 35% 分成(7.00 元);
// - 平台作为卖出方获得 40% 分成(8.00 元)+ 25% 服务费(5.00 元);
//
// 4. 鉴权验证:分站 A 管理员调用 FindOrder 成功查看本站收益 7.00 元与 receive 角色,且无 403 权限拒绝;
// 5. 数据大盘验证:分站 A 查看大盘,今日收号数、今日订单、今日 GMV 及 7 天趋势中 ReceivedCount 均正确计算;
// 6. 订单完结后分站 A 创建结算批次并执行入账,分站金库钱包精准入账 7.00 元;
// 7. 分站 A 申请金库提现 5.00 元,总台管理端审核(显示分站名称与代号)并确认打款放款,金库可用余额扣除 5.00 元。
func TestE2EScenario2_SubSiteReceive_MainSiteSell_FullFlow(t *testing.T) {
db := openDistributionE2EDB(t)
ctx := context.Background()
now := time.Now()
// 1. 基础主体初始化
adminUser := model.AdminUser{Username: "super_admin_2", Status: "active"}
if err := db.Create(&adminUser).Error; err != nil {
t.Fatal(err)
}
siteAAdmin := model.AdminUser{Username: "site_receive_admin", Status: "active"}
if err := db.Create(&siteAAdmin).Error; err != nil {
t.Fatal(err)
}
siteA := model.DistributionSite{
SiteCode: "branch_beta",
SiteName: "分销乙站",
Domain: "beta.example.com",
Status: "active",
DisplayAccountEnabled: true,
}
if err := db.Create(&siteA).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionSiteMember{
SiteID: siteA.ID,
AdminUserID: siteAAdmin.ID,
MemberType: "site_operator",
Status: "active",
DataScope: "site",
}).Error; err != nil {
t.Fatal(err)
}
// 初始化分站 A 金库钱包
if err := db.Create(&model.WalletAccount{
UserID: 0,
DistributionSiteID: siteA.ID,
AvailableBalanceCent: 0,
FrozenBalanceCent: 0,
Status: "active",
}).Error; err != nil {
t.Fatal(err)
}
// 规则版本:分销收号、主站卖出 (two_party_distributor_receive)
// 分站收号 35%,平台卖出 40%,平台服务费 25%,通道费 50 BPS (0.5%)
ruleVersion := model.DistributionRuleVersion{
SiteID: &siteA.ID,
Mode: distribution.ModeTwoPartyDistributorReceive,
Version: 1,
Status: "active",
DistributorReceiveRatio: 35,
PlatformSellRatio: 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: "13800000003", Status: "active"}
renter := model.User{Phone: "13900000004", 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. 分站 A 收号: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,
"buyer_ratio": 100.0,
"buyer_total_price": 100.0,
"platform_markup_amount": 20.0,
},
})
account := model.GameAccount{
Title: "分销收号王者账号-100M",
HafCoinAmount: 100_000_000,
AssetSummary: datatypes.JSON(assetSummaryJSON),
Status: "active",
}
if err := db.Create(&account).Error; err != nil {
t.Fatal(err)
}
listing := model.RentalListing{
ListingNo: "L-E2E-RECEIVE-201",
AccountID: account.ID,
OwnerID: owner.ID,
PriceCent: 10000,
DepositAmountCent: 0,
Status: "published",
ReviewStatus: "approved",
DistributionSiteID: &siteA.ID, // 货源归属分站 A!
}
if err := db.Create(&listing).Error; err != nil {
t.Fatal(err)
}
// 3. 买家在主站直接下单(主站无自主加价,DistributionSiteCode 为空)
orderRepo := orderpkg.NewRepository(db, orderpkg.Dependencies{})
orderDTO, err := orderRepo.Create(ctx, renter.ID, orderpkg.CreateRequest{
ListingID: listing.ID,
})
if err != nil {
t.Fatalf("order create failed: %v", err)
}
if orderDTO.DistributionSiteID == nil || *orderDTO.DistributionSiteID != siteA.ID {
t.Fatalf("subsite receive order DistributionSiteID should be site A %d, got %v", siteA.ID, orderDTO.DistributionSiteID)
}
// 4. 支付成功并触发财务快照
grossPaidCent := int64(10000)
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.Fatal(err)
}
if financial.SplitBaseAmountCent != 2000 {
t.Fatalf("split base amount = %d, want 2000", financial.SplitBaseAmountCent)
}
var parties []model.DistributionOrderParty
if err := db.Where("order_id = ?", orderDTO.ID).Find(&parties).Error; err != nil || len(parties) != 2 {
t.Fatalf("parties error: len=%d, err=%v", len(parties), err)
}
var receiveParty, sellParty model.DistributionOrderParty
for _, p := range parties {
if p.PartyRole == "distributor_receive" {
receiveParty = p
} else if p.PartyRole == "platform_sell" {
sellParty = p
}
}
// 分站 A 获得收号分成: 2000 * 35% = 700 分
if receiveParty.IncomeAmountCent != 700 || receiveParty.PartyID != siteA.ID {
t.Fatalf("receiveParty income=%d (want 700), partyID=%d", receiveParty.IncomeAmountCent, receiveParty.PartyID)
}
// 平台获得卖出分成: 2000 * 40% = 800 分
if sellParty.IncomeAmountCent != 800 {
t.Fatalf("sellParty income=%d (want 800)", sellParty.IncomeAmountCent)
}
// 5. 权限验证:分站 A 管理员调用 FindOrder (校验无 403 权限拒绝)
distRepo := distribution.NewRepository(db)
orderDetail, err := distRepo.FindOrder(ctx, siteAAdmin.ID, orderDTO.ID)
if err != nil {
t.Fatalf("site A admin FindOrder failed: %v (must not return 403)", err)
}
if orderDetail.DistributorEarningCent != 700 || orderDetail.DistributorRole != "receive" {
t.Fatalf("unexpected order detail: earning=%d, role=%s", orderDetail.DistributorEarningCent, orderDetail.DistributorRole)
}
// 6. 数据大盘统计校验
analytics, err := distRepo.GetDashboardAnalytics(ctx, siteAAdmin.ID, siteA.ID)
if err != nil {
t.Fatalf("GetDashboardAnalytics failed: %v", err)
}
if analytics.TotalReceivedCount != 1 || analytics.TodayOrderCount != 1 || analytics.TodayGMVCent != 10000 || analytics.TodayProfitCent != 700 {
t.Fatalf("unexpected analytics: %+v", analytics)
}
if len(analytics.TrendDays) == 0 || analytics.TrendDays[len(analytics.TrendDays)-1].ReceivedCount != 1 {
t.Fatalf("unexpected trend received count: %+v", analytics.TrendDays)
}
// 7. 批次结算与钱包到账
if err := db.Model(&model.RentalOrder{}).Where("id = ?", orderDTO.ID).Update("status", "completed").Error; err != nil {
t.Fatal(err)
}
batch, err := distRepo.CreateSettlementBatch(ctx, siteAAdmin.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 != 700 {
t.Fatalf("batch total = %d, want 700", batch.TotalAmountCent)
}
if _, err := distRepo.MarkSettlementBatch(ctx, siteAAdmin.ID, batch.ID); err != nil {
t.Fatalf("MarkSettlementBatch failed: %v", err)
}
var siteWallet model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&siteWallet).Error; err != nil {
t.Fatal(err)
}
if siteWallet.AvailableBalanceCent != 700 {
t.Fatalf("wallet balance = %d, want 700", siteWallet.AvailableBalanceCent)
}
// 8. 申请金库提现与总台审核
wdRecord, err := distRepo.ApplySiteWithdrawal(ctx, siteAAdmin.ID, siteA.ID, distribution.ApplySiteWithdrawalRequest{
AmountCent: 500,
AccountType: "alipay",
AccountName: "分销乙站负责人",
AccountNo: "beta@example.com",
})
if err != nil {
t.Fatalf("ApplySiteWithdrawal failed: %v", err)
}
if wdRecord.AmountCent != 500 || wdRecord.Status != "pending" {
t.Fatalf("withdrawal record = %+v", wdRecord)
}
// 总台管理员审核并打款
withdrawalRepo := withdrawal.NewRepository(db, wallet.NewRepository(db), &crypto.MockEncryptor{})
wdDetail, err := withdrawalRepo.AdminFindByID(ctx, wdRecord.ID)
if err != nil {
t.Fatalf("AdminFindByID failed: %v", err)
}
if wdDetail.DistributionSiteName != "分销乙站" || wdDetail.DistributionSiteCode != "branch_beta" {
t.Fatalf("withdrawal detail site name/code mismatch: %+v", wdDetail)
}
approved := true
if _, err := withdrawalRepo.Review(ctx, adminUser.ID, wdRecord.ID, withdrawal.ReviewWithdrawalRequest{
Approved: &approved,
Remark: "审核通过",
}); err != nil {
t.Fatalf("Review withdrawal failed: %v", err)
}
if _, err := withdrawalRepo.ConfirmPayment(ctx, adminUser.ID, wdRecord.ID, withdrawal.ConfirmPaymentRequest{
Remark: "已打款",
}); err != nil {
t.Fatalf("ConfirmPayment failed: %v", err)
}
// 验证提现完成后的金库余额
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&siteWallet).Error; err != nil {
t.Fatal(err)
}
if siteWallet.AvailableBalanceCent != 200 || siteWallet.FrozenBalanceCent != 0 {
t.Fatalf("wallet after withdrawal: available=%d, frozen=%d", siteWallet.AvailableBalanceCent, siteWallet.FrozenBalanceCent)
}
t.Logf("=== 场景 2 全链路端到端验证通过!===")
}
// TestE2EScenario3_ThreeParty_CrossSiteSell_DedicatedPayment_Settlement
// 场景 3 完整端到端测试:
// 1. 分站 A 收号(货源归属分站 A);
// 2. 分站 B 作为销售分站,配置专属支付商户通道(微信优先级 1);
// 3. 分站 B 选品上架并自主加价(提价 20 比例点,标价 100 元变为售价 125 元,分站加价 25 元);
// 4. 买家在分站 B 下单实付 125 元;
// 5. 核心收银台支付发起 (payment.Start):断言精准命中分站 B 绑定的专属商户号,而非平台默认商户号;
// 6. 支付成功自动触发三方分销快照 (three_party):
// - 分站 B 获得专属加价 25 元 + 卖出分成 40% (8 元) = 33 元;
// - 分站 A 获得收号分成 35% (7 元);
// - 平台获得服务费 25% (5 元);
//
// 7. 隔离鉴权验证:
// - 分站 A 管理员仅查看到收号角色与 7 元收益;
// - 分站 B 管理员查看到卖出角色、25 元提价及 33 元总收益;
// - 无关分站 C 管理员调用 FindOrder 返回 ErrSiteAccessDenied;
//
// 8. 双方分别创建结算批次并结算,分站 A 金库入账 7 元,分站 B 金库入账 33 元。
func TestE2EScenario3_ThreeParty_CrossSiteSell_DedicatedPayment_Settlement(t *testing.T) {
db := openDistributionE2EDB(t)
ctx := context.Background()
now := time.Now()
// 1. 站点初始化
siteA := model.DistributionSite{SiteCode: "site_source", SiteName: "货源分站", Domain: "source.example.com", Status: "active"}
siteB := model.DistributionSite{SiteCode: "site_seller", SiteName: "零售分站", Domain: "seller.example.com", Status: "active"}
siteC := model.DistributionSite{SiteCode: "site_other", SiteName: "无关分站", Domain: "other.example.com", Status: "active"}
if err := db.Create(&siteA).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&siteB).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&siteC).Error; err != nil {
t.Fatal(err)
}
// 各站管理员
adminA := model.AdminUser{Username: "admin_a", Status: "active"}
adminB := model.AdminUser{Username: "admin_b", Status: "active"}
adminC := model.AdminUser{Username: "admin_c", Status: "active"}
db.Create(&adminA)
db.Create(&adminB)
db.Create(&adminC)
db.Create(&model.DistributionSiteMember{SiteID: siteA.ID, AdminUserID: adminA.ID, MemberType: "site_operator", Status: "active"})
db.Create(&model.DistributionSiteMember{SiteID: siteB.ID, AdminUserID: adminB.ID, MemberType: "site_operator", Status: "active"})
db.Create(&model.DistributionSiteMember{SiteID: siteC.ID, AdminUserID: adminC.ID, MemberType: "site_operator", Status: "active"})
// 初始化金库
db.Create(&model.WalletAccount{UserID: 0, DistributionSiteID: siteA.ID, Status: "active"})
db.Create(&model.WalletAccount{UserID: 0, DistributionSiteID: siteB.ID, Status: "active"})
// 2. 支付渠道配置
configRepo := paymentconfig.NewRepository(db, &paymentconfig.MockEncryptor{})
orderRepo := orderpkg.NewRepository(db, orderpkg.Dependencies{})
paymentRepo := payment.NewRepository(db, configRepo, orderRepo)
defaultConfig := model.PaymentMerchantConfig{
Name: "平台默认微信", Provider: "mock", MerchantID: "PLATFORM_MOCK", PayWay: "WX", IsDefault: true, Status: "active", Environment: "sandbox",
}
db.Create(&defaultConfig)
siteBMerchant := model.PaymentMerchantConfig{
Name: "零售分站微信", Provider: "mock", MerchantID: "SITE_B_MOCK", PayWay: "WX", IsDefault: false, Status: "active", Environment: "sandbox",
}
db.Create(&siteBMerchant)
// 分站 B 绑定专属通道
db.Create(&model.DistributionSitePaymentChannel{
SiteID: siteB.ID, ConfigID: siteBMerchant.ID, Priority: 1, Status: "active",
})
// 3. 三方规则 (three_party)
ruleVersion := model.DistributionRuleVersion{
SiteID: &siteB.ID,
Mode: distribution.ModeThreeParty,
Version: 1,
Status: "active",
DistributorARatio: 35,
DistributorBRatio: 40,
ServiceFeeValue: 25,
ServiceFeeUnit: distribution.ServiceFeeUnitPercent,
PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-2 * time.Hour),
}
db.Create(&ruleVersion)
// 4. 货源录入到分站 A
owner := model.User{Phone: "13800000005", Status: "active"}
renter := model.User{Phone: "13900000006", Status: "active"}
db.Create(&owner)
db.Create(&renter)
assetSummaryJSON, _ := json.Marshal(map[string]any{
"publish_ratio": 100.0,
"price_breakdown": map[string]any{
"seller_ratio": 125.0,
"seller_total_price": 80.0,
"buyer_ratio": 100.0,
"buyer_total_price": 100.0,
"platform_markup_amount": 20.0,
},
})
account := model.GameAccount{
Title: "三方分销账号-100M", HafCoinAmount: 100_000_000, AssetSummary: datatypes.JSON(assetSummaryJSON), Status: "active",
}
db.Create(&account)
listing := model.RentalListing{
ListingNo: "L-E2E-THREE-301",
AccountID: account.ID,
OwnerID: owner.ID,
PriceCent: 10000,
DepositAmountCent: 0,
Status: "published",
ReviewStatus: "approved",
DistributionSiteID: &siteA.ID, // 货源归属分站 A!
}
db.Create(&listing)
// 5. 分站 B 货架上架并加价 25 元 (售价 125 元)
db.Create(&model.DistributionSiteAccount{
SiteID: siteB.ID,
AccountID: account.ID,
ListingID: listing.ID,
DisplayStatus: "enabled",
ShelfRatioDelta: 20,
})
// 6. 买家在分站 B 下单
orderDTO, err := orderRepo.Create(ctx, renter.ID, orderpkg.CreateRequest{
ListingID: listing.ID,
DistributionSiteCode: siteB.SiteCode,
})
if err != nil {
t.Fatalf("order create failed: %v", err)
}
if orderDTO.DistributionSiteID == nil || *orderDTO.DistributionSiteID != siteB.ID || orderDTO.RentAmountCent == nil || *orderDTO.RentAmountCent != 12500 {
t.Fatalf("order info mismatch: site=%v, amount=%v", orderDTO.DistributionSiteID, orderDTO.RentAmountCent)
}
// 7. 发起支付:验证命中分站 B 专属商户号
payDTO, err := paymentRepo.Start(ctx, renter.ID, orderDTO.ID, payment.StartPaymentRequest{PayWay: "WX"}, "127.0.0.1")
if err != nil {
t.Fatalf("payment start failed: %v", err)
}
if payDTO.PaymentConfigID != siteBMerchant.ID {
t.Fatalf("payment expected site B config %d, got %d", siteBMerchant.ID, payDTO.PaymentConfigID)
}
// 8. 触发财务快照
if err := distribution.SnapshotPaidOrderTx(db, orderDTO.ID, 12500); err != nil {
t.Fatalf("SnapshotPaidOrderTx failed: %v", err)
}
// 验证三方快照拆分
var parties []model.DistributionOrderParty
db.Where("order_id = ?", orderDTO.ID).Find(&parties)
if len(parties) != 2 {
t.Fatalf("expected 2 parties, got %d", len(parties))
}
var partyA, partyB model.DistributionOrderParty
for _, p := range parties {
if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" {
partyA = p
} else if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" {
partyB = p
}
}
// 分站 A 获得收号分成: 2000 * 35% = 700 分
if partyA.IncomeAmountCent != 700 || partyA.PartyID != siteA.ID {
t.Fatalf("party A error: income=%d (want 700), partyID=%d", partyA.IncomeAmountCent, partyA.PartyID)
}
// 分站 B 获得加价 2500 + 卖出分成 800 = 3300 分
if partyB.IncomeAmountCent != 3300 || partyB.PartyID != siteB.ID {
t.Fatalf("party B error: income=%d (want 3300), partyID=%d", partyB.IncomeAmountCent, partyB.PartyID)
}
// 9. 隔离鉴权验证
distRepo := distribution.NewRepository(db)
detailA, err := distRepo.FindOrder(ctx, adminA.ID, orderDTO.ID)
if err != nil {
t.Fatalf("FindOrder admin A failed: %v", err)
}
if detailA.DistributorEarningCent != 700 || detailA.DistributorRole != "receive" {
t.Fatalf("detail A mismatch: earning=%d, role=%s", detailA.DistributorEarningCent, detailA.DistributorRole)
}
detailB, err := distRepo.FindOrder(ctx, adminB.ID, orderDTO.ID)
if err != nil {
t.Fatalf("FindOrder admin B failed: %v", err)
}
if detailB.DistributorEarningCent != 3300 || detailB.DistributorRole != "sell" || detailB.DistributorMarkupCent != 2500 {
t.Fatalf("detail B mismatch: earning=%d, role=%s, markup=%d", detailB.DistributorEarningCent, detailB.DistributorRole, detailB.DistributorMarkupCent)
}
// 无关分站 C 查询必须返回 ErrSiteAccessDenied
if _, err := distRepo.FindOrder(ctx, adminC.ID, orderDTO.ID); err != distribution.ErrSiteAccessDenied {
t.Fatalf("admin C expected ErrSiteAccessDenied, got %v", err)
}
// 10. 完单与分别结算
db.Model(&model.RentalOrder{}).Where("id = ?", orderDTO.ID).Update("status", "completed")
// 结算分站 A
batchA, err := distRepo.CreateSettlementBatch(ctx, adminA.ID, distribution.CreateSettlementBatchRequest{
SiteID: siteA.ID,
PeriodStart: now.Add(-24 * time.Hour),
PeriodEnd: now.Add(24 * time.Hour),
})
if err != nil {
t.Fatalf("CreateSettlementBatch A failed: %v", err)
}
if batchA.TotalAmountCent != 700 {
t.Fatalf("batch A total=%d, want 700", batchA.TotalAmountCent)
}
distRepo.MarkSettlementBatch(ctx, adminA.ID, batchA.ID)
// 结算分站 B
batchB, err := distRepo.CreateSettlementBatch(ctx, adminB.ID, distribution.CreateSettlementBatchRequest{
SiteID: siteB.ID,
PeriodStart: now.Add(-24 * time.Hour),
PeriodEnd: now.Add(24 * time.Hour),
})
if err != nil {
t.Fatalf("CreateSettlementBatch B failed: %v", err)
}
if batchB.TotalAmountCent != 3300 {
t.Fatalf("batch B total=%d, want 3300", batchB.TotalAmountCent)
}
distRepo.MarkSettlementBatch(ctx, adminB.ID, batchB.ID)
// 校验双方钱包余额
var walletA, walletB model.WalletAccount
db.Where("user_id = 0 AND distribution_site_id = ?", siteA.ID).First(&walletA)
db.Where("user_id = 0 AND distribution_site_id = ?", siteB.ID).First(&walletB)
if walletA.AvailableBalanceCent != 700 {
t.Fatalf("wallet A balance = %d, want 700", walletA.AvailableBalanceCent)
}
if walletB.AvailableBalanceCent != 3300 {
t.Fatalf("wallet B balance = %d, want 3300", walletB.AvailableBalanceCent)
}
t.Logf("=== 场景 3 全链路端到端验证通过!===")
}
// TestE2EScenario4_SiteSelfSell_CoolingOff_AutoRelease
// 场景 4 完整端到端测试:
// 1. 分站 S 自收自卖(site_self_sell 模式,设置结算冷却期 settlement_hold_days = 3);
// 2. 分站 S 货架上架并自主加价 25 元(标价 100 元,加价为 125 元,成本 80 元);
// 3. 买家在分站 S 下单实付 125 元;
// 4. 支付成功自动触发快照:
// - 号主成本 80 元;
// - 利润 20 元中,平台收取 25% 服务费(5 元),分站 S 获得 75% 利润(15 元);
// - 分站 S 自主加价 25 元 100% 归分站 S;
// - 分站 S 总收益 = 25 + 15 = 40 元;
//
// 5. 订单完单并自动写入冷却期释放时间 release_at(当前时间 + 3 天);
// 6. 到期前执行 AutoReleaseDueSettlements:未到期,释放 0 单;
// 7. 时间流逝(修改 release_at 为过去时间),执行 AutoReleaseDueSettlements:
// - 成功自动释放 1 单;
// - 订单财务快照置为 settled,released_at 非空;
// - 分站 S 金库钱包入账 40.00 元;
//
// 8. 再次执行 AutoReleaseDueSettlements:释放 0 单,分站金库钱包余额仍为 40.00 元(幂等无重复入账)。
func TestE2EScenario4_SiteSelfSell_CoolingOff_AutoRelease(t *testing.T) {
db := openDistributionE2EDB(t)
ctx := context.Background()
now := time.Now()
// 1. 创建站点 S(配置冷却期 3 天)及管理员
adminS := model.AdminUser{Username: "self_site_operator", Status: "active"}
if err := db.Create(&adminS).Error; err != nil {
t.Fatal(err)
}
siteS := model.DistributionSite{
SiteCode: "branch_self",
SiteName: "自营分销站",
Domain: "self.example.com",
Status: "active",
DisplayAccountEnabled: true,
SettlementHoldDays: 3,
}
if err := db.Create(&siteS).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionSiteMember{
SiteID: siteS.ID,
AdminUserID: adminS.ID,
MemberType: "operator",
Status: "active",
DataScope: "site",
}).Error; err != nil {
t.Fatal(err)
}
// 自收自卖规则:75% 归分站,25% 归平台服务费
rule := model.DistributionRuleVersion{
SiteID: &siteS.ID,
Mode: distribution.ModeSiteSelfSell,
Version: 1,
Status: "active",
DistributorSellRatio: 75,
ServiceFeeValue: 25,
ServiceFeeUnit: distribution.ServiceFeeUnitPercent,
EffectiveFrom: now.Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
// 2. 号主与租客
owner := model.User{Phone: "13900000007", Status: "active"}
renter := model.User{Phone: "13900000008", Status: "active"}
db.Create(&owner)
db.Create(&renter)
// 分站 S 收号
assetSummaryJSON, _ := json.Marshal(map[string]any{
"publish_ratio": 100.0,
"price_breakdown": map[string]any{
"seller_ratio": 125.0,
"seller_total_price": 80.0,
"buyer_ratio": 100.0,
"buyer_total_price": 100.0,
"platform_markup_amount": 20.0,
},
})
account := model.GameAccount{
Title: "自收自卖测试账号-100M",
HafCoinAmount: 100_000_000,
AssetSummary: datatypes.JSON(assetSummaryJSON),
Status: "active",
}
db.Create(&account)
listing := model.RentalListing{
ListingNo: "L-E2E-SELF-401",
AccountID: account.ID,
OwnerID: owner.ID,
PriceCent: 10000,
DepositAmountCent: 0,
Status: "published",
ReviewStatus: "approved",
DistributionSiteID: &siteS.ID,
}
db.Create(&listing)
// 分站 S 上架加价 25 元
db.Create(&model.DistributionSiteAccount{
SiteID: siteS.ID,
AccountID: account.ID,
ListingID: listing.ID,
DisplayStatus: "enabled",
ShelfRatioDelta: 20,
})
// 3. 买家在分站 S 下单
orderRepo := orderpkg.NewRepository(db, orderpkg.Dependencies{})
orderDTO, err := orderRepo.Create(ctx, renter.ID, orderpkg.CreateRequest{
ListingID: listing.ID,
DistributionSiteCode: siteS.SiteCode,
})
if err != nil {
t.Fatalf("order create failed: %v", err)
}
// 4. 支付成功并触发快照
if err := distribution.SnapshotPaidOrderTx(db, orderDTO.ID, 12500); err != nil {
t.Fatalf("SnapshotPaidOrderTx failed: %v", err)
}
var parties []model.DistributionOrderParty
db.Where("order_id = ?", orderDTO.ID).Find(&parties)
if len(parties) != 1 {
t.Fatalf("expected 1 party for site self sell, got %d", len(parties))
}
// 分站 S 获得加价 2500 + 卖出分成 1500 = 4000 分
if parties[0].IncomeAmountCent != 4000 || parties[0].PartyID != siteS.ID {
t.Fatalf("party S income = %d, want 4000, partyID = %d", parties[0].IncomeAmountCent, parties[0].PartyID)
}
// 5. 订单完单并触发 release_at 计算
if err := db.Model(&model.RentalOrder{}).Where("id = ?", orderDTO.ID).Update("status", "completed").Error; err != nil {
t.Fatal(err)
}
if err := distribution.EnsureCompletedReleaseAtTx(db, orderDTO.ID); err != nil {
t.Fatalf("EnsureCompletedReleaseAtTx failed: %v", err)
}
var fin model.DistributionOrderFinancial
if err := db.Where("order_id = ?", orderDTO.ID).First(&fin).Error; err != nil {
t.Fatal(err)
}
if fin.ReleaseAt == nil || fin.ReleaseAt.Before(now) {
t.Fatalf("expected release_at in future (+3 days), got %v", fin.ReleaseAt)
}
// 6. 冷却期内释放:应释放 0 单
distRepo := distribution.NewRepository(db)
count, err := distRepo.AutoReleaseDueSettlements(ctx)
if err != nil {
t.Fatalf("AutoReleaseDueSettlements failed: %v", err)
}
if count != 0 {
t.Fatalf("expected 0 released during cooling-off, got %d", count)
}
// 7. 时间流逝(将 release_at 调整至过去)
pastTime := now.Add(-10 * time.Minute)
if err := db.Model(&model.DistributionOrderFinancial{}).Where("order_id = ?", orderDTO.ID).
Update("release_at", &pastTime).Error; err != nil {
t.Fatal(err)
}
// 执行自动释放
count, err = distRepo.AutoReleaseDueSettlements(ctx)
if err != nil {
t.Fatalf("AutoReleaseDueSettlements failed: %v", err)
}
if count != 1 {
t.Fatalf("expected 1 released after cooling-off, got %d", count)
}
// 验证快照已置为已结算
if err := db.Where("order_id = ?", orderDTO.ID).First(&fin).Error; err != nil {
t.Fatal(err)
}
if fin.SettlementStatus != "settled" || fin.ReleasedAt == nil {
t.Fatalf("expected financial settled with released_at, got %+v", fin)
}
// 验证分站 S 金库钱包到账 40.00 元 (4000 分)
var siteWallet model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteS.ID).First(&siteWallet).Error; err != nil {
t.Fatal(err)
}
if siteWallet.AvailableBalanceCent != 4000 {
t.Fatalf("site S wallet balance = %d, want 4000", siteWallet.AvailableBalanceCent)
}
// 8. 幂等性校验:重复调用不重复到账
count, err = distRepo.AutoReleaseDueSettlements(ctx)
if err != nil {
t.Fatalf("second AutoReleaseDueSettlements failed: %v", err)
}
if count != 0 {
t.Fatalf("second run expected 0, got %d", count)
}
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteS.ID).First(&siteWallet).Error; err != nil {
t.Fatal(err)
}
if siteWallet.AvailableBalanceCent != 4000 {
t.Fatalf("second run wallet balance changed = %d, want 4000", siteWallet.AvailableBalanceCent)
}
t.Logf("=== 场景 4 自收自卖与冷却期自动释放测试通过!===")
}
// TestE2EScenario5_PostSettlementRefund_And_DisputeArbitration
// 场景 5 完整端到端测试:
// Part A: 仲裁与风控隔离
// 1. 租客就分销订单发起争议申诉(押金暂扣中);
// 2. 客服介入仲裁全额退款;
// 3. 断言退款扣除暂扣押金,分销财务快照同步更新净付款与退款;
// 4. 重点断言:分销订单在仲裁完单后严禁发放租客成长积分(RenterGrowthLedger 条数为 0),严防刷单套利;
// Part B: 结算后退款资金回冲(Post-Settlement Refund Reversal)
// 5. 分站订单已完成结算,收益已入账分站金库钱包;
// 6. 产生售后退款,触发 ApplyRefundTx:
// - 动态重算分站应得收益;
// - 自动从分站金库钱包回冲扣减差额;
//
// 7. 提现掏空钱包后再次发生退款回冲:
// - 系统允许扣减至负余额,确保平台兜底不受损且不阻断退款流程。
func TestE2EScenario5_PostSettlementRefund_And_DisputeArbitration(t *testing.T) {
db := openDistributionE2EDB(t)
ctx := context.Background()
now := time.Now()
// Part A: 仲裁与成长积分隔离
startedRefundCent := int64(-1)
disputeRepo := dispute.NewRepository(db, dispute.Dependencies{
RefundStarter: dispute.RefundStarterFunc(func(_ context.Context, _ uint64, refundAmountCent int64, _ string, _ string) (string, error) {
startedRefundCent = refundAmountCent
return "refunded", nil
}),
})
siteD := model.DistributionSite{
SiteCode: "arb_site", SiteName: "仲裁隔离站", Domain: "arb.dist.com",
Status: "active", DisplayAccountEnabled: true, SettlementHoldDays: 0,
}
db.Create(&siteD)
ruleD := model.DistributionRuleVersion{
SiteID: &siteD.ID, Mode: distribution.ModeTwoPartyDistributorReceive, Version: 1, Status: "active",
DistributorReceiveRatio: 35, PlatformSellRatio: 40, ServiceFeeValue: 25, ServiceFeeUnit: "percent",
EffectiveFrom: now.Add(-time.Hour),
}
db.Create(&ruleD)
renter := model.User{Phone: "13900000009", Status: "active"}
owner := model.User{Phone: "13900000010", Status: "active"}
db.Create(&renter)
db.Create(&owner)
arbAccount := model.GameAccount{
OwnerID: owner.ID,
Title: "仲裁测试账号",
ServerRegion: "国服",
LoginPlatform: "安卓",
Status: "active",
}
db.Create(&arbAccount)
arbListing := model.RentalListing{
AccountID: arbAccount.ID,
OwnerID: owner.ID,
PriceCent: 10000,
DepositAmountCent: 6000,
Status: "rented",
ReviewStatus: "approved",
DistributionSiteID: &siteD.ID,
}
db.Create(&arbListing)
arbOrder := model.RentalOrder{
OrderNo: "ORD-ARB-E2E-1",
ListingID: arbListing.ID,
AccountID: arbAccount.ID,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: "renting",
HandoffStatus: "received",
SettlementStatus: "unsettled",
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
DepositAmountCent: 6000,
DepositHoldStatus: model.DepositHoldStatusHeld,
DepositHoldAmountCent: 6000,
DistributionSiteID: &siteD.ID,
DistributionRuleVersionID: &ruleD.ID,
}
if err := db.Create(&arbOrder).Error; err != nil {
t.Fatalf("create arbOrder failed: %v", err)
}
arbFinancial := model.DistributionOrderFinancial{
OrderID: arbOrder.ID,
SiteID: siteD.ID,
RuleVersionID: ruleD.ID,
GrossPaidAmountCent: arbOrder.RentAmountCent,
NetPaidAmountCent: arbOrder.RentAmountCent,
SplitBaseAmountCent: 2000,
SettlementStatus: "pending",
}
db.Create(&arbFinancial)
// 发起争议并仲裁全额退款
disputeReq, err := disputeRepo.Create(ctx, renter.ID, arbOrder.ID, dispute.CreateRequest{
Type: "haf_coin_dispute",
Description: "仲裁测试-租客申请全额退款",
})
if err != nil {
t.Fatalf("create dispute failed: %v", err)
}
if _, err := disputeRepo.Arbitrate(ctx, 9001, disputeReq.ID, dispute.ArbitrateRequest{
Result: "full_refund",
Remark: "裁决支持全额退款",
}, dispute.AuditMeta{}); err != nil {
t.Fatalf("arbitrate failed: %v", err)
}
// 断言实际发起退款金额(裁决总额 16000 - 暂扣押金 6000 = 实际退款 10000)
if startedRefundCent != 10000 {
t.Fatalf("started refund = %d, want 10000", startedRefundCent)
}
// 核心风控断言:分销订单仲裁完单严禁产生用户成长积分!
var growthLedgerCount int64
db.Model(&model.RenterGrowthLedger{}).Where("order_id = ?", arbOrder.ID).Count(&growthLedgerCount)
if growthLedgerCount != 0 {
t.Fatalf("分销订单仲裁不应发放用户成长积分,但产生了 %d 条积分记录", growthLedgerCount)
}
// Part B: 结算后售后退款回冲(Post-Settlement Refund Reversal)
siteR := model.DistributionSite{
SiteCode: "rev_site", SiteName: "退款回冲站", Domain: "rev.dist.com",
Status: "active", DisplayAccountEnabled: true, SettlementHoldDays: 0,
}
db.Create(&siteR)
ruleR := model.DistributionRuleVersion{
SiteID: &siteR.ID, Mode: distribution.ModeTwoPartyDistributorReceive, Version: 1, Status: "active",
DistributorReceiveRatio: 35, PlatformSellRatio: 40, ServiceFeeValue: 25, ServiceFeeUnit: "percent",
EffectiveFrom: now.Add(-time.Hour),
}
db.Create(&ruleR)
revAccount := model.GameAccount{
OwnerID: owner.ID,
Title: "回冲测试账号",
ServerRegion: "国服",
LoginPlatform: "安卓",
Status: "active",
}
db.Create(&revAccount)
revListing := model.RentalListing{
AccountID: revAccount.ID,
OwnerID: owner.ID,
PriceCent: 10000,
DepositAmountCent: 0,
Status: "completed",
ReviewStatus: "approved",
DistributionSiteID: &siteR.ID,
}
db.Create(&revListing)
revOrder := model.RentalOrder{
OrderNo: "ORD-REV-E2E-1",
ListingID: revListing.ID,
AccountID: revAccount.ID,
OwnerID: owner.ID,
RenterID: renter.ID,
Status: "completed",
RentAmountCent: 10000,
OwnerRentAmountCent: 6000,
DistributionSiteID: &siteR.ID,
DistributionRuleVersionID: &ruleR.ID,
}
if err := db.Create(&revOrder).Error; err != nil {
t.Fatalf("create revOrder failed: %v", err)
}
past := now.Add(-time.Minute)
db.Create(&model.DistributionOrderFinancial{
OrderID: revOrder.ID,
SiteID: siteR.ID,
RuleVersionID: ruleR.ID,
GrossPaidAmountCent: 10000,
NetPaidAmountCent: 10000,
SplitBaseAmountCent: 4000,
SettlementStatus: "pending",
ReleaseAt: &past,
})
db.Create(&model.DistributionOrderParty{
OrderID: revOrder.ID,
SiteID: siteR.ID,
PartyRole: "distributor_receive",
PartyType: distribution.PartyTypeSite,
PartyID: siteR.ID,
IncomeAmountCent: 1400,
})
distRepo := distribution.NewRepository(db)
released, err := distRepo.AutoReleaseDueSettlements(ctx)
if err != nil || released != 1 {
t.Fatalf("AutoReleaseDueSettlements failed: released=%d, err=%v", released, err)
}
// 确认释放后钱包有 1400 分
var rWallet model.WalletAccount
db.Where("user_id = 0 AND distribution_site_id = ?", siteR.ID).First(&rWallet)
if rWallet.AvailableBalanceCent != 1400 {
t.Fatalf("wallet balance = %d, want 1400", rWallet.AvailableBalanceCent)
}
// 售后退款 3000 分:利润从 4000 降为 1000,分站应得份额由 1400 降为 350,自动回冲 1050
if err := distribution.ApplyRefundTx(db, revOrder.ID, 3000); err != nil {
t.Fatalf("ApplyRefundTx failed: %v", err)
}
db.Where("user_id = 0 AND distribution_site_id = ?", siteR.ID).First(&rWallet)
if rWallet.AvailableBalanceCent != 350 {
t.Fatalf("wallet balance after refund reversal = %d, want 350", rWallet.AvailableBalanceCent)
}
// 模拟分站已提现掏空钱包(可用余额置 0)
db.Model(&model.WalletAccount{}).Where("id = ?", rWallet.ID).Update("available_balance_cent", 0)
// 再次发生全额退款回冲(退款 10000 分):分站应得份额归 0,系统必须允许扣减为负余额 (-350)
if err := distribution.ApplyRefundTx(db, revOrder.ID, 10000); err != nil {
t.Fatalf("reverse with empty wallet failed: %v", err)
}
db.Where("user_id = 0 AND distribution_site_id = ?", siteR.ID).First(&rWallet)
if rWallet.AvailableBalanceCent != -350 {
t.Fatalf("wallet balance = %d, want -350 (allow negative balance on reversal)", rWallet.AvailableBalanceCent)
}
t.Logf("=== 场景 5 仲裁隔离与退款回冲全链路测试通过!===")
}
// TestE2EScenario6_SiteOnboarding_Branding_PublicStorefront_Sync
// 场景 6 完整端到端测试:
// 1. 总台管理员一键开站(OnboardSite):
// - 校验生成 DistributionSite、独立管理员、成员关系、独立金库钱包与初始规则;
//
// 2. 分站管理员登录与身份探针:
// - Login 签发分站管理员 JWT;
// - Me 验证当前登录主体信息与所属分站;
//
// 3. 分站品牌个性化装修(UpdateSiteBranding):
// - 更新站点标题、Logo、Favicon、页脚版权文字;
//
// 4. 前台三模多端解析验证:
// - 模式 A:通过 site_code 参数解析;
// - 模式 B:通过独立域名 domain 解析;
// - 模式 C:通过 X-Distribution-Site-Code 请求头解析;
//
// 5. 货架商品同步与上架加价透传:
// - 主站发布商品,分站调用 SyncSiteAccounts 一键同步;
// - 分站管理员上调货架比例(UpdateAccountMarkup);
// - 租客访问分站前台列表(ListPublicSiteListings):
// 断言返回商品售价与加价金额精准上浮,展示分站个性化定价。
func TestE2EScenario6_SiteOnboarding_Branding_PublicStorefront_Sync(t *testing.T) {
db := openDistributionE2EDB(t)
ctx := context.Background()
jwtMgr := auth.NewJWTManager("test_secret_key_for_distribution_e2e_32_bytes")
distRepo := distribution.NewRepository(db)
distSvc := distribution.NewService(distRepo, jwtMgr)
distHandler := distribution.NewHandler(distSvc)
// 1. 总台管理员开通分站
onboardReq := distribution.OnboardSiteRequest{
SiteCode: "sh_brand_site",
SiteName: "魔都电竞分销站",
Domain: "sh.esports-dist.com",
AdminUsername: "sh_store_admin",
AdminPassword: "Password123!",
AdminNickname: "魔都站长",
MemberType: "operator",
}
onboardResp, err := distSvc.OnboardSite(ctx, onboardReq)
if err != nil {
t.Fatalf("OnboardSite failed: %v", err)
}
if onboardResp.Site.SiteCode != "sh_brand_site" {
t.Fatalf("expected SiteCode sh_brand_site, got %s", onboardResp.Site.SiteCode)
}
siteID := onboardResp.Site.ID
// 校验独立金库钱包已同步创建
var siteWallet model.WalletAccount
if err := db.Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&siteWallet).Error; err != nil {
t.Fatalf("site treasury wallet not created: %v", err)
}
// 2. 分站管理员登录与 Me 验证
loginResp, err := distSvc.Login(ctx, distribution.DistributionLoginRequest{
Username: "sh_store_admin",
Password: "Password123!",
}, "127.0.0.1")
if err != nil {
t.Fatalf("Login failed: %v", err)
}
if loginResp.Token == "" || loginResp.Member.SiteID == nil || *loginResp.Member.SiteID != siteID {
t.Fatalf("unexpected login response: %+v", loginResp)
}
meResp, err := distSvc.Me(ctx, loginResp.Member.AdminID)
if err != nil {
t.Fatalf("Me failed: %v", err)
}
if meResp.SiteID == nil || *meResp.SiteID != siteID || meResp.SiteName != "魔都电竞分销站" {
t.Fatalf("unexpected Me response: %+v", meResp)
}
// 3. 分站品牌个性化装修
logo := "https://cdn.example.com/sh-logo.png"
favicon := "https://cdn.example.com/sh-favicon.ico"
footerJSON := `{"copyright":"Copyright © 2026 魔都电竞分销站"}`
announcementsJSON := `[{"title":"优惠公告","content":"全场极品账号特惠上新"}]`
_, err = distRepo.UpdateSiteBranding(ctx, loginResp.Member.AdminID, siteID, distribution.UpdateSiteBrandingRequest{
LogoURL: &logo,
FaviconURL: &favicon,
FooterInfoJSON: &footerJSON,
AnnouncementsJSON: &announcementsJSON,
})
if err != nil {
t.Fatalf("UpdateSiteBranding failed: %v", err)
}
// 4. 前台三模解析验证
// 模式 A: 通过 site_code 查询
infoA, err := distRepo.GetPublicSiteInfo(ctx, "sh_brand_site", "")
if err != nil {
t.Fatalf("GetPublicSiteInfo by code failed: %v", err)
}
if infoA.LogoURL != logo || infoA.FooterInfoJSON != footerJSON || infoA.AnnouncementsJSON != announcementsJSON {
t.Fatalf("info A branding mismatch: %+v", infoA)
}
// 模式 B: 通过 domain 查询
infoB, err := distRepo.GetPublicSiteInfo(ctx, "", "sh.esports-dist.com")
if err != nil {
t.Fatalf("GetPublicSiteInfo by domain failed: %v", err)
}
if infoB.SiteCode != "sh_brand_site" || infoB.FaviconURL != favicon {
t.Fatalf("info B domain mismatch: %+v", infoB)
}
// 模式 C: 通过 X-Distribution-Site-Code 请求头解析 (HTTP Handler 级别)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest("GET", "/api/v1/distribution/public/site-info", nil)
req.Header.Set("X-Distribution-Site-Code", "sh_brand_site")
c.Request = req
distHandler.GetPublicSiteInfo(c)
if w.Code != http.StatusOK {
t.Fatalf("GetPublicSiteInfo header expected 200, got %d: %s", w.Code, w.Body.String())
}
var httpResp struct {
Code string `json:"code"`
Data distribution.PublicSiteInfoDTO `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &httpResp); err != nil {
t.Fatalf("unmarshal header resp failed: %v", err)
}
if httpResp.Code != "ok" || httpResp.Data.SiteCode != "sh_brand_site" || httpResp.Data.LogoURL != logo {
t.Fatalf("header resolution data mismatch: %+v", httpResp)
}
// 5. 货架商品同步与上架加价透传
// 主站发布极品账号
masterAccount := model.GameAccount{
Title: "CFHD极品生化账号-100M",
GameName: "cfhd",
ServerRegion: "东部大区",
LoginPlatform: "PC",
RankLevel: "枪王",
HafCoinAmount: 100_000_000,
Status: "active",
}
db.Create(&masterAccount)
masterListing := model.RentalListing{
ListingNo: "L-CFHD-SYNC-1",
AccountID: masterAccount.ID,
OwnerID: 1,
PriceCent: 5000,
DepositAmountCent: 2000,
Status: "published",
ReviewStatus: "approved",
}
db.Create(&masterListing)
// 分站管理员调用同步接口
syncRes, err := distRepo.SyncSiteAccounts(ctx, loginResp.Member.AdminID, siteID, distribution.SyncSiteAccountsRequest{CleanStaleListings: true})
if err != nil {
t.Fatalf("SyncSiteAccounts failed: %v", err)
}
if syncRes.SyncedCount == 0 {
t.Fatalf("expected at least 1 account synced, got %+v", syncRes)
}
// 查询同步生成的货架记录
var dsa model.DistributionSiteAccount
if err := db.Where("site_id = ? AND listing_id = ?", siteID, masterListing.ID).First(&dsa).Error; err != nil {
t.Fatalf("DistributionSiteAccount not found: %v", err)
}
// 分站管理员调整自主加价比例 (调减比例 20,使前台标价上浮)
updatedDisplay, err := distRepo.UpdateAccountMarkup(ctx, loginResp.Member.AdminID, dsa.ID, 20)
if err != nil {
t.Fatalf("UpdateAccountMarkup failed: %v", err)
}
if updatedDisplay.ShelfRatioDelta != 20 {
t.Fatalf("shelf ratio delta = %v, want 20", updatedDisplay.ShelfRatioDelta)
}
// 租客访问前台商品列表 (ListPublicSiteListings)
publicListings, err := distRepo.ListPublicSiteListings(ctx, "sh_brand_site", 1, 10)
if err != nil {
t.Fatalf("ListPublicSiteListings failed: %v", err)
}
if len(publicListings.Items) == 0 {
t.Fatalf("expected at least 1 public listing")
}
var foundListing *distribution.PublicDistributionListingDTO
for _, item := range publicListings.Items {
if item.ListingID == masterListing.ID {
itemCopy := item
foundListing = &itemCopy
break
}
}
if foundListing == nil {
t.Fatalf("synced listing %d not found in public listings", masterListing.ID)
}
// 验证加价与前台售价生效 (标价从 5000 经 ApplyShelfRatio 调整为 5560,加价 560 分)
if foundListing.PriceCent != 5560 || foundListing.MarkupAmountCent != 560 {
t.Fatalf("listing pricing mismatch: price=%d (want 5560), markup=%d (want 560)",
foundListing.PriceCent, foundListing.MarkupAmountCent)
}
t.Logf("=== 场景 6 一键开站、品牌装修与前台货架同步加价全链路验证通过!===")
}