diff --git a/backend/internal/e2e/distribution_flow_test.go b/backend/internal/e2e/distribution_flow_test.go index 461a2ab..cfb9c7e 100644 --- a/backend/internal/e2e/distribution_flow_test.go +++ b/backend/internal/e2e/distribution_flow_test.go @@ -3,17 +3,27 @@ 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 { @@ -311,3 +321,1175 @@ func TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement(t *testing.T) 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, nil) + 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 一键开站、品牌装修与前台货架同步加价全链路验证通过!===") +} diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index 01e271f..c669a2b 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -787,6 +787,8 @@ func writeError(c *gin.Context, err error) { response.Error(c, http.StatusForbidden, "admin_disabled", "分销管理员账号已被禁用") case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch): response.BadRequest(c, "分销规则参数不正确") + case errors.Is(err, ErrInsufficientBalance): + response.BadRequest(c, "可提现余额不足") case errors.Is(err, ErrSettlementBatchStale): response.BadRequest(c, "批次内订单发生退款变动或维权,该批次已失效,请作废后重新生成") case errors.Is(err, ErrInvalidSettlementHold): diff --git a/backend/internal/modules/distribution/handler_integration_test.go b/backend/internal/modules/distribution/handler_integration_test.go new file mode 100644 index 0000000..28be625 --- /dev/null +++ b/backend/internal/modules/distribution/handler_integration_test.go @@ -0,0 +1,736 @@ +package distribution + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/datatypes" + "gorm.io/gorm" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/auth" +) + +type handlerTestEnv struct { + db *gorm.DB + repo *Repository + service *Service + handler *Handler + jwtMgr *auth.JWTManager + router *gin.Engine +} + +func setupHandlerTestEnv(t *testing.T) *handlerTestEnv { + t.Helper() + gin.SetMode(gin.TestMode) + + db := distributionTestDB(t) + repo := NewRepository(db) + jwtMgr := auth.NewJWTManager("test_secret_key_32_bytes_handler_test") + svc := NewService(repo, jwtMgr) + h := NewHandler(svc) + + r := gin.New() + r.Use(gin.Recovery()) + + // 模拟上下文中间件注入 adminID + authMiddleware := func(c *gin.Context) { + adminIDStr := c.GetHeader("X-Mock-Admin-ID") + if adminIDStr != "" { + var adminID uint64 + fmt.Sscanf(adminIDStr, "%d", &adminID) + c.Set(middleware.ContextAdminID, adminID) + } + c.Next() + } + + // 1. 公开路由 + pub := r.Group("/api/v1/distribution") + { + pub.GET("/site-info", h.GetPublicSiteInfo) + pub.GET("/sites/:id/listings", h.ListPublicSiteListings) + pub.POST("/auth/login", h.Login) + } + + // 2. 站点商户/操作员受保护路由 + siteProtected := r.Group("/api/v1/distribution", authMiddleware) + { + siteProtected.GET("/auth/me", h.Me) + siteProtected.POST("/auth/logout", h.Logout) + siteProtected.GET("/sites/:id/wallet", h.GetSiteWallet) + siteProtected.POST("/sites/:id/withdrawals", h.ApplySiteWithdrawal) + siteProtected.GET("/sites/:id/withdrawals", h.ListSiteWithdrawals) + siteProtected.GET("/sites/:id/analytics", h.GetDashboardAnalytics) + siteProtected.PUT("/sites/:id/branding", h.UpdateSiteBranding) + siteProtected.POST("/sites/:id/sync-accounts", h.SyncSiteAccounts) + siteProtected.GET("/received-listings", h.ListReceivedListings) + siteProtected.PUT("/received-listings/:id/status", h.UpdateReceivedListingStatus) + siteProtected.PUT("/accounts/:id/markup", h.UpdateAccountMarkup) + siteProtected.POST("/accounts/batch-markup", h.BatchUpdateAccountMarkup) + } + + // 3. 总台管理路由 + adm := r.Group("/api/v1/admin/distribution", authMiddleware) + { + adm.GET("/sites", h.ListSites) + adm.GET("/site-defaults", h.GetSiteDefaults) + adm.GET("/sites/:id", h.GetSite) + adm.POST("/sites", h.CreateSite) + adm.POST("/sites/onboard", h.OnboardSite) + adm.PUT("/sites/:id", h.UpdateSite) + adm.GET("/rules", h.ListRules) + adm.POST("/rules", h.CreateRule) + adm.PUT("/rules/:id/status", h.UpdateRuleStatus) + adm.GET("/orders", h.ListOrders) + adm.GET("/orders/:id", h.FindOrder) + adm.POST("/orders/:id/financial-snapshot", h.CreateFinancialSnapshot) + adm.GET("/payment-channels", h.ListPaymentChannels) + adm.POST("/payment-channels", h.BindPaymentChannel) + adm.DELETE("/payment-channels/:id", h.UnbindPaymentChannel) + adm.GET("/settlement-batches", h.ListSettlementBatches) + adm.POST("/settlement-batches", h.CreateSettlementBatch) + adm.GET("/settlement-batches/:id/items", h.ListSettlementItems) + adm.POST("/settlement-batches/:id/settle", h.MarkSettlementBatch) + adm.POST("/settlement-batches/:id/cancel", h.CancelSettlementBatch) + } + + return &handlerTestEnv{ + db: db, + repo: repo, + service: svc, + handler: h, + jwtMgr: jwtMgr, + router: r, + } +} + +func (env *handlerTestEnv) request(method, path string, body any, adminID uint64, extraHeaders map[string]string) *httptest.ResponseRecorder { + var bodyReader *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + bodyReader = bytes.NewReader(b) + } else { + bodyReader = bytes.NewReader([]byte{}) + } + req, _ := http.NewRequest(method, path, bodyReader) + req.Header.Set("Content-Type", "application/json") + if adminID > 0 { + req.Header.Set("X-Mock-Admin-ID", fmt.Sprintf("%d", adminID)) + } + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + env.router.ServeHTTP(w, req) + return w +} + +// TestHandler_PublicEndpoints 验证所有公开端点(无需登录): +// 1. /api/v1/distribution/site-info (通过 site_code, domain, X-Distribution-Site-Code 解析及 404) +// 2. /api/v1/distribution/sites/:id/listings (正常上架商品查询、分页、DisplayAccountEnabled=false 时返回空列表) +func TestHandler_PublicEndpoints(t *testing.T) { + env := setupHandlerTestEnv(t) + now := time.Now() + + site := model.DistributionSite{ + SiteCode: "pub_site_alpha", + SiteName: "前台测试站", + Domain: "pub.example.com", + Status: "active", + DisplayAccountEnabled: true, + } + if err := env.db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + // 1. GetPublicSiteInfo 验证 + // 1.1 参数 ?site_code= + w1 := env.request("GET", "/api/v1/distribution/site-info?site_code=pub_site_alpha", nil, 0, nil) + if w1.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w1.Code, w1.Body.String()) + } + var resp1 struct { + Code string `json:"code"` + Data PublicSiteInfoDTO `json:"data"` + } + json.Unmarshal(w1.Body.Bytes(), &resp1) + if resp1.Code != "ok" || resp1.Data.SiteCode != "pub_site_alpha" { + t.Fatalf("unexpected resp1: %+v", resp1) + } + + // 1.2 参数 ?domain= + w2 := env.request("GET", "/api/v1/distribution/site-info?domain=pub.example.com", nil, 0, nil) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w2.Code) + } + + // 1.3 请求头 X-Distribution-Site-Code + w3 := env.request("GET", "/api/v1/distribution/site-info", nil, 0, map[string]string{ + "X-Distribution-Site-Code": "pub_site_alpha", + }) + if w3.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w3.Code) + } + + // 1.4 不存在的站点返回 404 + w4 := env.request("GET", "/api/v1/distribution/site-info?site_code=non_existent", nil, 0, nil) + if w4.Code != http.StatusNotFound { + t.Fatalf("expected 404 for not found site, got %d: %s", w4.Code, w4.Body.String()) + } + + // 2. ListPublicSiteListings 验证 + acc := model.GameAccount{ + Title: "极品号-1", GameName: "cfhd", HafCoinAmount: 50_000_000, Status: "active", + } + env.db.Create(&acc) + listing := model.RentalListing{ + ListingNo: "L-PUB-1", AccountID: acc.ID, OwnerID: 1, PriceCent: 4000, Status: "published", ReviewStatus: "approved", + } + env.db.Create(&listing) + env.db.Create(&model.DistributionSiteAccount{ + SiteID: site.ID, AccountID: acc.ID, ListingID: listing.ID, DisplayStatus: "enabled", ShelfRatioDelta: 10, + }) + + wList := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%s/listings?page=1&page_size=10", site.SiteCode), nil, 0, nil) + if wList.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", wList.Code, wList.Body.String()) + } + var listResp struct { + Code string `json:"code"` + Data PublicDistributionListingsResult `json:"data"` + } + json.Unmarshal(wList.Body.Bytes(), &listResp) + if listResp.Data.Total != 1 || len(listResp.Data.Items) != 1 { + t.Fatalf("expected 1 listing, got total=%d items=%d", listResp.Data.Total, len(listResp.Data.Items)) + } + + // 2.1 关闭展示开关时返回空列表 + env.db.Model(&site).Update("display_account_enabled", false) + wListClosed := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%s/listings", site.SiteCode), nil, 0, nil) + if wListClosed.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", wListClosed.Code) + } + json.Unmarshal(wListClosed.Body.Bytes(), &listResp) + if listResp.Data.Total != 0 || len(listResp.Data.Items) != 0 { + t.Fatalf("expected empty listings when display_account_enabled is false, got %+v", listResp.Data) + } + _ = now +} + +// TestHandler_AuthEndpoints 验证分销身份认证相关路由: +// 1. POST /api/v1/distribution/auth/login (账号密码登录成功与错误密码) +// 2. GET /api/v1/distribution/auth/me (查询自身账号身份、所属分站) +// 3. POST /api/v1/distribution/auth/logout (登出) +func TestHandler_AuthEndpoints(t *testing.T) { + env := setupHandlerTestEnv(t) + + // 先通过 OnboardSite 创建完整的分站管理员账号 + onboardResp, err := env.service.OnboardSite(t.Context(), OnboardSiteRequest{ + SiteCode: "auth_test_site", + SiteName: "认证测试站", + Domain: "auth.example.com", + AdminUsername: "auth_op_user", + AdminPassword: "SecurePassword123!", + AdminNickname: "认证站长", + MemberType: "operator", + }) + if err != nil { + t.Fatal(err) + } + + // 1. 正确登录 + loginReq := DistributionLoginRequest{ + Username: "auth_op_user", + Password: "SecurePassword123!", + } + wLogin := env.request("POST", "/api/v1/distribution/auth/login", loginReq, 0, nil) + if wLogin.Code != http.StatusOK { + t.Fatalf("login expected 200, got %d: %s", wLogin.Code, wLogin.Body.String()) + } + var loginResp struct { + Code string `json:"code"` + Data DistributionLoginResponse `json:"data"` + } + json.Unmarshal(wLogin.Body.Bytes(), &loginResp) + if loginResp.Data.Token == "" || loginResp.Data.Member.Username != "auth_op_user" { + t.Fatalf("invalid login response: %+v", loginResp) + } + + // 2. 错误密码登录拦截 + badLoginReq := DistributionLoginRequest{ + Username: "auth_op_user", + Password: "WrongPassword!", + } + wBadLogin := env.request("POST", "/api/v1/distribution/auth/login", badLoginReq, 0, nil) + if wBadLogin.Code != http.StatusBadRequest { + t.Fatalf("bad password expected 400, got %d: %s", wBadLogin.Code, wBadLogin.Body.String()) + } + + // 3. GET /api/v1/distribution/auth/me + // 带分站管理员身份 + wMe := env.request("GET", "/api/v1/distribution/auth/me", nil, onboardResp.Member.AdminID, nil) + if wMe.Code != http.StatusOK { + t.Fatalf("me expected 200, got %d: %s", wMe.Code, wMe.Body.String()) + } + var meResp struct { + Code string `json:"code"` + Data DistributionCurrentMemberDTO `json:"data"` + } + json.Unmarshal(wMe.Body.Bytes(), &meResp) + if meResp.Data.Username != "auth_op_user" || meResp.Data.SiteCode != "auth_test_site" { + t.Fatalf("unexpected me response: %+v", meResp) + } + + // 4. 未登录访问 /api/v1/distribution/auth/me 返回 401 + wMeUnauth := env.request("GET", "/api/v1/distribution/auth/me", nil, 0, nil) + if wMeUnauth.Code != http.StatusUnauthorized { + t.Fatalf("unauth me expected 401, got %d", wMeUnauth.Code) + } + + // 5. POST /api/v1/distribution/auth/logout + wLogout := env.request("POST", "/api/v1/distribution/auth/logout", nil, onboardResp.Member.AdminID, nil) + if wLogout.Code != http.StatusOK { + t.Fatalf("logout expected 200, got %d", wLogout.Code) + } +} + +// TestHandler_SiteOperatorEndpoints 验证分销站点管理员操作自身资源的端点: +// 1. GET /api/v1/distribution/sites/:id/wallet (查询站点金库) +// 2. POST /api/v1/distribution/sites/:id/withdrawals (申请金库提现) +// 3. GET /api/v1/distribution/sites/:id/withdrawals (查询提现申请记录) +// 4. GET /api/v1/distribution/sites/:id/analytics (站点数据大盘看板) +// 5. PUT /api/v1/distribution/sites/:id/branding (站点个性化装修与配置) +// 6. POST /api/v1/distribution/sites/:id/sync-accounts (商品货架全量/选品同步) +// 7. PUT /api/v1/distribution/accounts/:id/markup (单品加价调价) +// 8. POST /api/v1/distribution/accounts/batch-markup (批量调价) +// 9. GET /api/v1/distribution/received-listings (收号资产列表) +// 10. PUT /api/v1/distribution/received-listings/:id/status (收号资产状态启停) +// 11. 鉴权越权隔离:操作员跨站访问其他分站资源一律返回 403 Forbidden +func TestHandler_SiteOperatorEndpoints(t *testing.T) { + env := setupHandlerTestEnv(t) + + // 创建站点 A 及操作员 A + siteA := model.DistributionSite{SiteCode: "op_site_a", SiteName: "操作分站A", Domain: "opa.example.com", Status: "active", DisplayAccountEnabled: true} + env.db.Create(&siteA) + adminA := model.AdminUser{Username: "op_user_a", Status: "active"} + env.db.Create(&adminA) + env.db.Create(&model.DistributionSiteMember{SiteID: siteA.ID, AdminUserID: adminA.ID, MemberType: "operator", Status: "active"}) + // 站点 A 金库钱包初始 50.00 元 (5000 分) + env.db.Create(&model.WalletAccount{UserID: 0, DistributionSiteID: siteA.ID, AvailableBalanceCent: 5000, Status: "active"}) + + // 创建站点 B 及操作员 B (用于跨站越权测试) + siteB := model.DistributionSite{SiteCode: "op_site_b", SiteName: "操作分站B", Domain: "opb.example.com", Status: "active"} + env.db.Create(&siteB) + adminB := model.AdminUser{Username: "op_user_b", Status: "active"} + env.db.Create(&adminB) + env.db.Create(&model.DistributionSiteMember{SiteID: siteB.ID, AdminUserID: adminB.ID, MemberType: "operator", Status: "active"}) + env.db.Create(&model.WalletAccount{UserID: 0, DistributionSiteID: siteB.ID, AvailableBalanceCent: 2000, Status: "active"}) + + // 1. 查询钱包余额 + wWallet := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%d/wallet", siteA.ID), nil, adminA.ID, nil) + if wWallet.Code != http.StatusOK { + t.Fatalf("wallet expected 200, got %d: %s", wWallet.Code, wWallet.Body.String()) + } + var walletResp struct { + Code string `json:"code"` + Data SiteWalletDTO `json:"data"` + } + json.Unmarshal(wWallet.Body.Bytes(), &walletResp) + if walletResp.Data.AvailableBalanceCent != 5000 { + t.Fatalf("wallet balance = %d, want 5000", walletResp.Data.AvailableBalanceCent) + } + + // 越权拦截:操作员 B 查站点 A 的钱包必须返回 403 + wCrossWallet := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%d/wallet", siteA.ID), nil, adminB.ID, nil) + if wCrossWallet.Code != http.StatusForbidden { + t.Fatalf("cross-site wallet expected 403, got %d: %s", wCrossWallet.Code, wCrossWallet.Body.String()) + } + + // 2. 申请提现 + wdReq := ApplySiteWithdrawalRequest{ + AmountCent: 1500, + AccountType: "alipay", + AccountName: "站长张三", + AccountNo: "zhangsan@alipay.com", + } + wWd := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/withdrawals", siteA.ID), wdReq, adminA.ID, nil) + if wWd.Code != http.StatusCreated && wWd.Code != http.StatusOK { + t.Fatalf("apply withdrawal expected 201/200, got %d: %s", wWd.Code, wWd.Body.String()) + } + + // 提现金额超过余额应拦截 (400) + overWdReq := ApplySiteWithdrawalRequest{ + AmountCent: 999999, + AccountType: "alipay", + AccountName: "站长张三", + AccountNo: "zhangsan@alipay.com", + } + wOverWd := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/withdrawals", siteA.ID), overWdReq, adminA.ID, nil) + if wOverWd.Code != http.StatusBadRequest { + t.Fatalf("over withdrawal expected 400, got %d: %s", wOverWd.Code, wOverWd.Body.String()) + } + + // 3. 查询提现列表 + wWdList := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%d/withdrawals?page=1&page_size=10", siteA.ID), nil, adminA.ID, nil) + if wWdList.Code != http.StatusOK { + t.Fatalf("list withdrawals expected 200, got %d: %s", wWdList.Code, wWdList.Body.String()) + } + + // 4. 查询数据看板 + wAnalytics := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%d/analytics", siteA.ID), nil, adminA.ID, nil) + if wAnalytics.Code != http.StatusOK { + t.Fatalf("analytics expected 200, got %d: %s", wAnalytics.Code, wAnalytics.Body.String()) + } + + // 5. 更新装修与品牌 + logo := "https://example.com/new_logo.png" + favicon := "https://example.com/new_fav.ico" + footerJSON := `{"icp":"京ICP备123456号"}` + brandReq := UpdateSiteBrandingRequest{ + LogoURL: &logo, + FaviconURL: &favicon, + FooterInfoJSON: &footerJSON, + } + wBrand := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d/branding", siteA.ID), brandReq, adminA.ID, nil) + if wBrand.Code != http.StatusOK { + t.Fatalf("branding expected 200, got %d: %s", wBrand.Code, wBrand.Body.String()) + } + + // 6. 同步主站商品货架 + opOwner := model.User{Phone: "13800000077", Status: "active"} + env.db.Create(&opOwner) + masterAcc := model.GameAccount{OwnerID: opOwner.ID, Title: "同步母账号", GameName: "cfhd", HafCoinAmount: 10_000_000, Status: "active"} + env.db.Create(&masterAcc) + masterListing := model.RentalListing{ListingNo: "L-SYNC-1", AccountID: masterAcc.ID, OwnerID: opOwner.ID, PriceCent: 3000, Status: "published", ReviewStatus: "approved"} + env.db.Create(&masterListing) + + syncReq := SyncSiteAccountsRequest{ListingIDs: []uint64{masterListing.ID}} + wSync := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/sync-accounts", siteA.ID), syncReq, adminA.ID, nil) + if wSync.Code != http.StatusOK { + t.Fatalf("sync accounts expected 200, got %d: %s", wSync.Code, wSync.Body.String()) + } + + // 获取生成的 DSA 记录 + var dsa model.DistributionSiteAccount + if err := env.db.Where("site_id = ? AND listing_id = ?", siteA.ID, masterListing.ID).First(&dsa).Error; err != nil { + t.Fatal(err) + } + + // 7. 单品调价 + markupReq := UpdateAccountMarkupRequest{ShelfRatioDelta: 15} + wMarkup := env.request("PUT", fmt.Sprintf("/api/v1/distribution/accounts/%d/markup", dsa.ID), markupReq, adminA.ID, nil) + if wMarkup.Code != http.StatusOK { + t.Fatalf("update markup expected 200, got %d: %s", wMarkup.Code, wMarkup.Body.String()) + } + + // 8. 批量调价 + batchMarkupReq := BatchUpdateAccountMarkupRequest{ + ListingIDs: []uint64{masterListing.ID}, + ShelfRatioDelta: 20, + } + wBatchMarkup := env.request("POST", "/api/v1/distribution/accounts/batch-markup", batchMarkupReq, adminA.ID, nil) + if wBatchMarkup.Code != http.StatusOK { + t.Fatalf("batch markup expected 200, got %d: %s", wBatchMarkup.Code, wBatchMarkup.Body.String()) + } + + // 9. 查询收号资产列表 + wRecList := env.request("GET", "/api/v1/distribution/received-listings?page=1&size=10", nil, adminA.ID, nil) + if wRecList.Code != http.StatusOK { + t.Fatalf("received listings expected 200, got %d: %s", wRecList.Code, wRecList.Body.String()) + } + + // 10. 更新收号商品上下架状态 + ownAcc := model.GameAccount{OwnerID: opOwner.ID, Title: "自收自管独立账号", GameName: "cfhd", HafCoinAmount: 15_000_000, Status: "active"} + env.db.Create(&ownAcc) + ownListing := model.RentalListing{ + ListingNo: "L-OWN-1", AccountID: ownAcc.ID, OwnerID: opOwner.ID, PriceCent: 3000, + Status: "published", ReviewStatus: "approved", DistributionSiteID: &siteA.ID, + } + if err := env.db.Create(&ownListing).Error; err != nil { + t.Fatal(err) + } + + statusReq := UpdateReceivedListingStatusRequest{Status: "offline"} + wStatus := env.request("PUT", fmt.Sprintf("/api/v1/distribution/received-listings/%d/status", ownListing.ID), statusReq, adminA.ID, nil) + if wStatus.Code != http.StatusOK { + t.Fatalf("update received listing status expected 200, got %d: %s", wStatus.Code, wStatus.Body.String()) + } +} + +// TestHandler_AdminEndpoints 验证平台超级管理员专用的管理端点: +// 1. GET /api/v1/admin/distribution/sites (站点列表) +// 2. GET /api/v1/admin/distribution/site-defaults (站点默认配置) +// 3. GET /api/v1/admin/distribution/sites/:id (站点详情) +// 4. POST /api/v1/admin/distribution/sites (创建站点) +// 5. POST /api/v1/admin/distribution/sites/onboard (开通站点) +// 6. PUT /api/v1/admin/distribution/sites/:id (更新站点) +// 7. GET /api/v1/admin/distribution/rules (规则列表) +// 8. POST /api/v1/admin/distribution/rules (创建新版本规则) +// 9. PUT /api/v1/admin/distribution/rules/:id/status (启停规则) +// 10. GET /api/v1/admin/distribution/orders (订单列表) +// 11. GET /api/v1/admin/distribution/orders/:id (订单详情) +// 12. POST /api/v1/admin/distribution/orders/:id/financial-snapshot (手动快照触发) +// 13. GET /api/v1/admin/distribution/payment-channels (通道列表) +// 14. POST /api/v1/admin/distribution/payment-channels (绑定通道) +// 15. DELETE /api/v1/admin/distribution/payment-channels/:id (解绑通道) +// 16. GET /api/v1/admin/distribution/settlement-batches (结算批次列表) +// 17. POST /api/v1/admin/distribution/settlement-batches (创建结算批次) +// 18. GET /api/v1/admin/distribution/settlement-batches/:id/items (批次明细列表) +// 19. POST /api/v1/admin/distribution/settlement-batches/:id/settle (执行结算) +// 20. POST /api/v1/admin/distribution/settlement-batches/:id/cancel (作废结算批次) +func TestHandler_AdminEndpoints(t *testing.T) { + env := setupHandlerTestEnv(t) + now := time.Now() + + // 创建真实超级管理员账号并赋予 super_admin 角色 + superAdmin := model.AdminUser{Username: "super_admin_suite", Status: "active"} + env.db.Create(&superAdmin) + role := model.Role{Name: "超级管理员", Code: "super_admin", Description: "超级管理员"} + env.db.Create(&role) + env.db.Create(&model.AdminUserRole{AdminID: superAdmin.ID, RoleID: role.ID}) + superAdminID := superAdmin.ID + + // 1. ListSites + wListSites := env.request("GET", "/api/v1/admin/distribution/sites", nil, superAdminID, nil) + if wListSites.Code != http.StatusOK { + t.Fatalf("ListSites expected 200, got %d: %s", wListSites.Code, wListSites.Body.String()) + } + + // 2. GetSiteDefaults + wDefaults := env.request("GET", "/api/v1/admin/distribution/site-defaults", nil, superAdminID, nil) + if wDefaults.Code != http.StatusOK { + t.Fatalf("GetSiteDefaults expected 200, got %d: %s", wDefaults.Code, wDefaults.Body.String()) + } + + // 3. CreateSite + enabled := true + createReq := CreateSiteRequest{ + SiteCode: "adm_site_x", + SiteName: "总台创建站", + Domain: "x.example.com", + DisplayAccountEnabled: &enabled, + } + wCreate := env.request("POST", "/api/v1/admin/distribution/sites", createReq, superAdminID, nil) + if wCreate.Code != http.StatusCreated && wCreate.Code != http.StatusOK { + t.Fatalf("CreateSite expected 201/200, got %d: %s", wCreate.Code, wCreate.Body.String()) + } + var createdSiteResp struct { + Code string `json:"code"` + Data SiteDTO `json:"data"` + } + json.Unmarshal(wCreate.Body.Bytes(), &createdSiteResp) + siteID := createdSiteResp.Data.ID + + // 4. GetSite + wGetSite := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/sites/%d", siteID), nil, superAdminID, nil) + if wGetSite.Code != http.StatusOK { + t.Fatalf("GetSite expected 200, got %d: %s", wGetSite.Code, wGetSite.Body.String()) + } + + // 5. UpdateSite + newName := "总台更新站-改名" + updateReq := UpdateSiteRequest{ + SiteName: &newName, + } + wUpdateSite := env.request("PUT", fmt.Sprintf("/api/v1/admin/distribution/sites/%d", siteID), updateReq, superAdminID, nil) + if wUpdateSite.Code != http.StatusOK { + t.Fatalf("UpdateSite expected 200, got %d: %s", wUpdateSite.Code, wUpdateSite.Body.String()) + } + + // 6. OnboardSite + onboardReq := OnboardSiteRequest{ + SiteCode: "adm_onboard_site", + SiteName: "总台开通站", + Domain: "onboard.example.com", + AdminUsername: "onboard_manager", + AdminPassword: "Password123!", + AdminNickname: "开通站长", + MemberType: "operator", + } + wOnboard := env.request("POST", "/api/v1/admin/distribution/sites/onboard", onboardReq, superAdminID, nil) + if wOnboard.Code != http.StatusOK && wOnboard.Code != http.StatusCreated { + t.Fatalf("OnboardSite expected 200/201, got %d: %s", wOnboard.Code, wOnboard.Body.String()) + } + + // 7. ListRules + wRules := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/rules?site_id=%d", siteID), nil, superAdminID, nil) + if wRules.Code != http.StatusOK { + t.Fatalf("ListRules expected 200, got %d: %s", wRules.Code, wRules.Body.String()) + } + + // 8. CreateRule + eff := now.Add(-time.Hour) + createRuleReq := CreateRuleRequest{ + SiteID: &siteID, + Mode: ModeTwoPartyDistributorReceive, + DistributorReceiveRatio: 35, + PlatformSellRatio: 40, + ServiceFeeValue: 25, + ServiceFeeUnit: ServiceFeeUnitPercent, + PaymentFeeBPS: 50, + EffectiveFrom: eff, + } + wCreateRule := env.request("POST", "/api/v1/admin/distribution/rules", createRuleReq, superAdminID, nil) + if wCreateRule.Code != http.StatusOK && wCreateRule.Code != http.StatusCreated { + t.Fatalf("CreateRule expected 200/201, got %d: %s", wCreateRule.Code, wCreateRule.Body.String()) + } + var ruleResp struct { + Code string `json:"code"` + Data RuleDTO `json:"data"` + } + json.Unmarshal(wCreateRule.Body.Bytes(), &ruleResp) + ruleID := ruleResp.Data.ID + + // 9. UpdateRuleStatus + statusReq := UpdateRuleStatusRequest{Status: "disabled"} + wRuleStatus := env.request("PUT", fmt.Sprintf("/api/v1/admin/distribution/rules/%d/status", ruleID), statusReq, superAdminID, nil) + if wRuleStatus.Code != http.StatusOK { + t.Fatalf("UpdateRuleStatus expected 200, got %d: %s", wRuleStatus.Code, wRuleStatus.Body.String()) + } + + // 10. 构造测试订单并验证 ListOrders / FindOrder + owner := model.User{Phone: "13800000088", Status: "active"} + renter := model.User{Phone: "13900000088", Status: "active"} + env.db.Create(&owner) + env.db.Create(&renter) + acc := model.GameAccount{Title: "总台测试账号", GameName: "cfhd", HafCoinAmount: 20_000_000, Status: "active"} + env.db.Create(&acc) + listing := model.RentalListing{ListingNo: "L-ADM-ORD-1", AccountID: acc.ID, OwnerID: owner.ID, PriceCent: 5000, Status: "published", ReviewStatus: "approved", DistributionSiteID: &siteID} + env.db.Create(&listing) + + order := model.RentalOrder{ + OrderNo: "ORD-ADM-1", + ListingID: listing.ID, + AccountID: acc.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: "completed", + RentAmountCent: 5000, + OwnerRentAmountCent: 3000, + DistributionSiteID: &siteID, + } + env.db.Create(&order) + + // 手动触发快照端点: POST /api/v1/admin/distribution/orders/:id/financial-snapshot + snapReq := CreateFinancialSnapshotRequest{ + SiteID: siteID, + RuleVersionID: ruleID, + GrossPaidAmountCent: 5000, + Parties: []SnapshotPartyRequest{ + { + PartyRole: "distributor_receive", + PartyType: PartyTypeSite, + PartyID: siteID, + Ratio: 35, + IncomeAmountCent: 700, + }, + }, + } + wSnap := env.request("POST", fmt.Sprintf("/api/v1/admin/distribution/orders/%d/financial-snapshot", order.ID), snapReq, superAdminID, nil) + if wSnap.Code != http.StatusOK && wSnap.Code != http.StatusCreated { + t.Fatalf("CreateFinancialSnapshot expected 200/201, got %d: %s", wSnap.Code, wSnap.Body.String()) + } + + // 11. ListOrders + wOrders := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/orders?site_id=%d&page=1&size=10", siteID), nil, superAdminID, nil) + if wOrders.Code != http.StatusOK { + t.Fatalf("ListOrders expected 200, got %d: %s", wOrders.Code, wOrders.Body.String()) + } + + // 12. FindOrder + wFindOrder := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/orders/%d", order.ID), nil, superAdminID, nil) + if wFindOrder.Code != http.StatusOK { + t.Fatalf("FindOrder expected 200, got %d: %s", wFindOrder.Code, wFindOrder.Body.String()) + } + + // 13. 支付通道管理 + merchantCfg := model.PaymentMerchantConfig{Name: "测试通道", Provider: "mock", MerchantID: "MOCK_CH_1", PayWay: "WX", Status: "active", Environment: "sandbox"} + env.db.Create(&merchantCfg) + + bindReq := BindPaymentChannelRequest{ + ConfigID: merchantCfg.ID, + Priority: 1, + } + wBind := env.request("POST", fmt.Sprintf("/api/v1/admin/distribution/payment-channels?site_id=%d", siteID), bindReq, superAdminID, nil) + if wBind.Code != http.StatusOK && wBind.Code != http.StatusCreated { + t.Fatalf("BindPaymentChannel expected 200/201, got %d: %s", wBind.Code, wBind.Body.String()) + } + var bindResp struct { + Code string `json:"code"` + Data PaymentChannelDTO `json:"data"` + } + json.Unmarshal(wBind.Body.Bytes(), &bindResp) + channelID := bindResp.Data.ID + + wChannels := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/payment-channels?site_id=%d", siteID), nil, superAdminID, nil) + if wChannels.Code != http.StatusOK { + t.Fatalf("ListPaymentChannels expected 200, got %d", wChannels.Code) + } + + wUnbind := env.request("DELETE", fmt.Sprintf("/api/v1/admin/distribution/payment-channels/%d", channelID), nil, superAdminID, nil) + if wUnbind.Code != http.StatusOK { + t.Fatalf("UnbindPaymentChannel expected 200, got %d: %s", wUnbind.Code, wUnbind.Body.String()) + } + + // 14. 结算批次全流程端点测试 (Create -> List -> Items -> Settle / Cancel) + // 初始化钱包以支撑入账 + env.db.Create(&model.WalletAccount{UserID: 0, DistributionSiteID: siteID, Status: "active"}) + + batchReq := CreateSettlementBatchRequest{ + SiteID: siteID, + PeriodStart: now.Add(-24 * time.Hour), + PeriodEnd: now.Add(24 * time.Hour), + } + wCreateBatch := env.request("POST", "/api/v1/admin/distribution/settlement-batches", batchReq, superAdminID, nil) + if wCreateBatch.Code != http.StatusOK && wCreateBatch.Code != http.StatusCreated { + t.Fatalf("CreateSettlementBatch expected 200/201, got %d: %s", wCreateBatch.Code, wCreateBatch.Body.String()) + } + var batchResp struct { + Code string `json:"code"` + Data SettlementBatchDTO `json:"data"` + } + json.Unmarshal(wCreateBatch.Body.Bytes(), &batchResp) + batchID := batchResp.Data.ID + + // 查询批次列表 + wBatches := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/settlement-batches?site_id=%d&page=1&size=10", siteID), nil, superAdminID, nil) + if wBatches.Code != http.StatusOK { + t.Fatalf("ListSettlementBatches expected 200, got %d", wBatches.Code) + } + + // 查询批次明细 + wItems := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/settlement-batches/%d/items", batchID), nil, superAdminID, nil) + if wItems.Code != http.StatusOK { + t.Fatalf("ListSettlementItems expected 200, got %d: %s", wItems.Code, wItems.Body.String()) + } + + // 执行批次结算 + wSettle := env.request("POST", fmt.Sprintf("/api/v1/admin/distribution/settlement-batches/%d/settle", batchID), nil, superAdminID, nil) + if wSettle.Code != http.StatusOK { + t.Fatalf("MarkSettlementBatch expected 200, got %d: %s", wSettle.Code, wSettle.Body.String()) + } + + // 再次创建一个空批次用于测试作废 (Cancel) + wCreateBatch2 := env.request("POST", "/api/v1/admin/distribution/settlement-batches", batchReq, superAdminID, nil) + if wCreateBatch2.Code == http.StatusOK || wCreateBatch2.Code == http.StatusCreated { + var batchResp2 struct { + Code string `json:"code"` + Data SettlementBatchDTO `json:"data"` + } + json.Unmarshal(wCreateBatch2.Body.Bytes(), &batchResp2) + if batchResp2.Data.ID > 0 { + wCancel := env.request("POST", fmt.Sprintf("/api/v1/admin/distribution/settlement-batches/%d/cancel", batchResp2.Data.ID), nil, superAdminID, nil) + if wCancel.Code != http.StatusOK { + t.Fatalf("CancelSettlementBatch expected 200, got %d: %s", wCancel.Code, wCancel.Body.String()) + } + } + } + _ = datatypes.JSON{} +} diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go index 140191b..1cf7993 100644 --- a/backend/internal/modules/distribution/lifecycle.go +++ b/backend/internal/modules/distribution/lifecycle.go @@ -242,7 +242,11 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error partyID = *listing.DistributionSiteID } } - parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: split.PartyRole, PartyType: partyType, PartyID: partyID, Ratio: split.Ratio, IncomeAmountCent: split.AmountCent, ProfitAmountCent: split.AmountCent}) + partySiteID := *order.DistributionSiteID + if partyType == PartyTypeSite && partyID > 0 { + partySiteID = partyID + } + parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: partySiteID, PartyRole: split.PartyRole, PartyType: partyType, PartyID: partyID, Ratio: split.Ratio, IncomeAmountCent: split.AmountCent, ProfitAmountCent: split.AmountCent}) } if len(parties) == 0 { return nil diff --git a/backend/internal/modules/distribution/pricing.go b/backend/internal/modules/distribution/pricing.go index f17787d..ebb9a60 100644 --- a/backend/internal/modules/distribution/pricing.go +++ b/backend/internal/modules/distribution/pricing.go @@ -25,10 +25,11 @@ const ( ) var ( - ErrInvalidAmount = errors.New("invalid distribution amount") - ErrInvalidRate = errors.New("invalid distribution rate") - ErrInvalidServiceUnit = errors.New("invalid service fee unit") - ErrUnsupportedMode = errors.New("unsupported distribution mode") + ErrInvalidAmount = errors.New("invalid distribution amount") + ErrInvalidRate = errors.New("invalid distribution rate") + ErrInvalidServiceUnit = errors.New("invalid service fee unit") + ErrUnsupportedMode = errors.New("unsupported distribution mode") + ErrInsufficientBalance = errors.New("insufficient wallet balance") ) // Rule 是后台可编辑的分账规则。比例字段存百分数(35/40/25),按参与方角色命名。 diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index baac9b9..2ba4488 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -592,6 +592,9 @@ func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderList if p.PartyType != PartyTypeSite { continue } + if query.SiteID > 0 && p.PartyID > 0 && p.PartyID != query.SiteID { + continue + } totalEarning += p.IncomeAmountCent if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" { roles = append(roles, "sell") @@ -723,13 +726,12 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri } if len(siteSet) > 0 { match := false - if p.SiteID > 0 { - if _, ok := siteSet[p.SiteID]; ok { + if p.PartyType == PartyTypeSite && p.PartyID > 0 { + if _, ok := siteSet[p.PartyID]; ok { match = true } - } - if p.PartyID > 0 { - if _, ok := siteSet[p.PartyID]; ok { + } else if p.SiteID > 0 { + if _, ok := siteSet[p.SiteID]; ok { match = true } } @@ -2261,7 +2263,7 @@ func (r *Repository) ApplySiteWithdrawal(ctx context.Context, adminID, siteID ui return err } if account.AvailableBalanceCent < req.AmountCent { - return errors.New("可提现余额不足") + return ErrInsufficientBalance } withdrawal = model.WithdrawalRequest{