package distribution import ( "errors" "testing" "time" "hfb_sys/backend/internal/model" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func distributionTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate( &model.AdminUser{}, &model.Role{}, &model.ChatConversation{}, &model.RentalOrder{}, &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}, &model.RentalListing{}, &model.GameAccount{}, &model.User{}, &model.WalletAccount{}, &model.WalletLedger{}, &model.WithdrawalRequest{}, ); err != nil { t.Fatal(err) } return db } func TestDistributionOrderSnapshotAndQueries(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", DisplayAccountEnabled: true, TaxRateBPS: 100} if err := db.Create(&site).Error; err != nil { t.Fatal(err) } now := time.Now() rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50, TaxRateBPS: 100, EffectiveFrom: now} if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } order := model.RentalOrder{OrderNo: "D-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed"} if err := db.Create(&order).Error; err != nil { t.Fatal(err) } _, err := repo.CreateFinancialSnapshot(t.Context(), 0, order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300, Parties: []SnapshotPartyRequest{{PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 1000, CostAmountCent: 400}}}) if err != nil { t.Fatal(err) } // 重复补数必须被拒绝,避免覆盖真实财务快照。 if _, err := repo.CreateFinancialSnapshot(t.Context(), 0, order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001}); err != ErrOrderSnapshotExists { t.Fatalf("duplicate snapshot err = %v", err) } page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "receive", Page: 1, Size: 20}) if err != nil { t.Fatal(err) } if page.Total != 1 || len(page.Items) != 1 || page.Items[0].PaymentFeeCent != 45 || page.Items[0].NetPaidAmountCent != 9001 { t.Fatalf("unexpected page: %#v", page) } if _, err := repo.FindOrder(t.Context(), 0, order.ID); err != nil { t.Fatal(err) } } func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", TaxRateBPS: 100} if err := db.Create(&site).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.PaymentMerchantConfig{Name: "拉卡拉", Provider: "lkl", MerchantID: "M-1", PayWay: "ZFBZF"}).Error; err != nil { t.Fatal(err) } if _, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 404, Priority: 1}); err != ErrPaymentConfigNotFound { t.Fatalf("unknown config err = %v", err) } channel, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 1, Priority: 1}) if err != nil { t.Fatal(err) } items, err := repo.ListPaymentChannels(t.Context(), site.ID) if err != nil || len(items) != 1 || items[0].ConfigID != 1 { t.Fatalf("channels = %#v, err=%v", items, err) } if err := repo.UnbindPaymentChannel(t.Context(), 0, channel.ID); err != nil { t.Fatal(err) } // 准备一笔待结算订单:财务快照 + 参与方。 rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", PaymentFeeBPS: 50, EffectiveFrom: time.Now().Add(-2 * time.Hour)} if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } order := model.RentalOrder{OrderNo: "D-2", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID} if err := db.Create(&order).Error; err != nil { t.Fatal(err) } financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, NetPaidAmountCent: 9950, SplitBaseAmountCent: 9900, SettlementStatus: "pending"} if err := db.Create(&financial).Error; err != nil { t.Fatal(err) } parties := []model.DistributionOrderParty{ {OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 350}, {OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: "platform", IncomeAmountCent: 400}, } if err := db.Create(&parties).Error; err != nil { t.Fatal(err) } start := time.Now().Add(-time.Hour) batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start, PeriodEnd: time.Now().Add(time.Hour)}) if err != nil { t.Fatal(err) } if batch.TotalAmountCent != 750 { t.Fatalf("batch total = %d, want 750", batch.TotalAmountCent) } var batchItems []model.DistributionSettlementItem if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 2 { t.Fatalf("batch items = %d, err=%v", len(batchItems), err) } // 空批次不允许结算。 emptyBatch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start.Add(-48 * time.Hour), PeriodEnd: start.Add(-24 * time.Hour)}) if err != nil { t.Fatal(err) } if _, err := repo.MarkSettlementBatch(t.Context(), 0, emptyBatch.ID); err != ErrSettlementBatchEmpty { t.Fatalf("empty batch settle err = %v", err) } settled, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID) if err != nil || settled.Status != "settled" { t.Fatalf("settled = %#v, err=%v", settled, err) } var settledFinancial model.DistributionOrderFinancial if err := db.Where("order_id = ?", order.ID).First(&settledFinancial).Error; err != nil || settledFinancial.SettlementStatus != "settled" || settledFinancial.SettledAt == nil { t.Fatalf("financial after settle = %#v, err=%v", settledFinancial, err) } if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchAlreadySettled { t.Fatalf("second settle err = %v", err) } } func TestScopeSiteIDRestrictsBoundMember(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{SiteCode: "scope", SiteName: "范围站", Domain: "scope.example.com"} if err := db.Create(&site).Error; err != nil { t.Fatal(err) } other := model.DistributionSite{SiteCode: "other", SiteName: "其他站", Domain: "other.example.com"} if err := db.Create(&other).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionSiteMember{SiteID: site.ID, AdminUserID: 7, MemberType: "customer_service", Status: "active"}).Error; err != nil { t.Fatal(err) } if got, err := repo.ScopeSiteID(t.Context(), 7, 0); err != nil || got != site.ID { t.Fatalf("scope = %d, err=%v", got, err) } if _, err := repo.ScopeSiteID(t.Context(), 7, site.ID+1); err != ErrSiteAccessDenied { t.Fatalf("access error = %v", err) } if got, err := repo.ScopeSiteID(t.Context(), 99, 123); err != nil || got != 123 { t.Fatalf("global scope = %d, err=%v", got, err) } // 被限制到站点 A 的成员不能通过资源 ID 跨站点访问。 if _, err := repo.FindSite(t.Context(), 7, other.ID); err != ErrSiteAccessDenied { t.Fatalf("FindSite cross-site err = %v", err) } if _, err := repo.UpdateAccountDisplay(t.Context(), 7, 999, "enabled"); err != ErrAccountDisplayNotFound { t.Fatalf("UpdateAccountDisplay missing err = %v", err) } display := model.DistributionSiteAccount{SiteID: other.ID, AccountID: 5, ListingID: 6, DisplayStatus: "enabled"} if err := db.Create(&display).Error; err != nil { t.Fatal(err) } if _, err := repo.UpdateAccountDisplay(t.Context(), 7, display.ID, "disabled"); err != ErrSiteAccessDenied { t.Fatalf("UpdateAccountDisplay cross-site err = %v", err) } } func TestSyncSiteAccountsAndPublicListings(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{ SiteCode: "sync_demo", SiteName: "同步演示站", Domain: "sync.example.com", DisplayAccountEnabled: true, Status: "active", } if err := db.Create(&site).Error; err != nil { t.Fatal(err) } account1 := model.GameAccount{ Title: "三角洲极品金枪号", GameName: "delta_force", ServerRegion: "微信区", LoginPlatform: "PC", RankLevel: "王者", HafCoinAmount: 500000, Status: "active", } if err := db.Create(&account1).Error; err != nil { t.Fatal(err) } listing1 := model.RentalListing{ ListingNo: "L10001", AccountID: account1.ID, OwnerID: 1, PriceCent: 2500, DepositAmountCent: 10000, Status: "published", ReviewStatus: "approved", InTransaction: false, } if err := db.Create(&listing1).Error; err != nil { t.Fatal(err) } // 草稿状态的商品不应该被同步 listingDraft := model.RentalListing{ ListingNo: "L10002", AccountID: 999, OwnerID: 1, PriceCent: 3000, DepositAmountCent: 5000, Status: "draft", ReviewStatus: "pending", } if err := db.Create(&listingDraft).Error; err != nil { t.Fatal(err) } // 首次同步:应当成功新增 1 条,跳过 0 条 res1, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID) if err != nil { t.Fatalf("SyncSiteAccounts err = %v", err) } if res1.SyncedCount != 1 || res1.SkippedCount != 0 || res1.TotalActive != 1 { t.Fatalf("unexpected res1: %+v", res1) } // 再次同步:幂等保证,跳过 1 条,新增 0 条 res2, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID) if err != nil { t.Fatalf("SyncSiteAccounts retry err = %v", err) } if res2.SyncedCount != 0 || res2.SkippedCount != 1 || res2.TotalActive != 1 { t.Fatalf("unexpected res2: %+v", res2) } // 验证管理后台列表查询携带丰富字段 displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "") if err != nil { t.Fatalf("ListAccountDisplays err = %v", err) } if len(displays) != 1 { t.Fatalf("expected 1 display, got %d", len(displays)) } d := displays[0] if d.Title != "三角洲极品金枪号" || d.GameName != "delta_force" || d.PriceCent != 2500 || d.DepositAmountCent != 10000 || d.ListingNo != "L10001" { t.Fatalf("unexpected enriched display: %+v", d) } // 验证公共端点浏览查询 pubList, err := repo.ListPublicSiteListings(t.Context(), site.SiteCode, 1, 20) if err != nil { t.Fatalf("ListPublicSiteListings err = %v", err) } if pubList.Total != 1 || len(pubList.Items) != 1 { t.Fatalf("expected 1 public item, got %+v", pubList) } if pubList.Items[0].Title != "三角洲极品金枪号" || pubList.Items[0].SiteCode != site.SiteCode { t.Fatalf("unexpected public item: %+v", pubList.Items[0]) } // 下架该展示账号后,公共端点不再返回 if _, err := repo.UpdateAccountDisplay(t.Context(), 0, d.ID, "disabled"); err != nil { t.Fatalf("UpdateAccountDisplay disabled err = %v", err) } pubList2, err := repo.ListPublicSiteListings(t.Context(), site.SiteCode, 1, 20) if err != nil { t.Fatalf("ListPublicSiteListings after disabled err = %v", err) } if pubList2.Total != 0 || len(pubList2.Items) != 0 { t.Fatalf("expected 0 public items after disabled, got %+v", pubList2) } } func TestOnboardSiteAutoSyncsActiveListings(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) account := model.GameAccount{ Title: "王者荣耀国服账号", GameName: "honor_of_kings", ServerRegion: "手Q一区", LoginPlatform: "iOS", Status: "active", } if err := db.Create(&account).Error; err != nil { t.Fatal(err) } listing := model.RentalListing{ ListingNo: "L20001", AccountID: account.ID, OwnerID: 2, PriceCent: 5000, Status: "published", ReviewStatus: "approved", } if err := db.Create(&listing).Error; err != nil { t.Fatal(err) } dispEnabled := true onboardResp, err := repo.OnboardSite(t.Context(), OnboardSiteRequest{ SiteCode: "auto_sync_site", SiteName: "自动同步站点", Domain: "auto.example.com", AdminUsername: "auto_admin", AdminPassword: "securepass123", DisplayAccountEnabled: &dispEnabled, }) if err != nil { t.Fatalf("OnboardSite err = %v", err) } displays, err := repo.ListAccountDisplays(t.Context(), onboardResp.Site.ID, "") if err != nil { t.Fatalf("ListAccountDisplays err = %v", err) } if len(displays) != 1 { t.Fatalf("expected 1 auto-synced account, got %d", len(displays)) } if displays[0].Title != "王者荣耀国服账号" || displays[0].ListingNo != "L20001" { t.Fatalf("unexpected auto-synced display: %+v", displays[0]) } } func TestGetPublicSiteInfo(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{ SiteCode: "info_site", SiteName: "信息测试站", Domain: "info.example.com", Status: "active", DisplayAccountEnabled: true, } if err := db.Create(&site).Error; err != nil { t.Fatal(err) } // Query by code info, err := repo.GetPublicSiteInfo(t.Context(), "info_site", "") if err != nil { t.Fatalf("GetPublicSiteInfo by code failed: %v", err) } if info.SiteName != "信息测试站" || info.SiteCode != "info_site" { t.Fatalf("unexpected info: %+v", info) } // Query by domain infoByDomain, err := repo.GetPublicSiteInfo(t.Context(), "", "info.example.com") if err != nil { t.Fatalf("GetPublicSiteInfo by domain failed: %v", err) } if infoByDomain.SiteName != "信息测试站" { t.Fatalf("unexpected info by domain: %+v", infoByDomain) } // Query not found _, err = repo.GetPublicSiteInfo(t.Context(), "not_found", "") if !errors.Is(err, ErrSiteNotFound) { t.Fatalf("expected ErrSiteNotFound, got %v", err) } } func TestDistributionBrandingAndMarkup(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{ SiteCode: "brand_site", SiteName: "品牌测试站", Domain: "brand.example.com", Status: "active", DisplayAccountEnabled: true, } if err := db.Create(&site).Error; err != nil { t.Fatal(err) } // 1. Update branding updated, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{ LogoURL: "https://cdn.example.com/logo.png", FaviconURL: "https://cdn.example.com/favicon.ico", BannersJSON: `[{"image_url":"https://cdn.example.com/b1.jpg"}]`, AnnouncementsJSON: `[{"title":"开业大吉"}]`, FooterInfoJSON: `{"copyright":"2026 Brand"}`, }) if err != nil { t.Fatalf("UpdateSiteBranding failed: %v", err) } if updated.LogoURL != "https://cdn.example.com/logo.png" || updated.FaviconURL != "https://cdn.example.com/favicon.ico" { t.Fatalf("unexpected branding: %+v", updated) } // Verify PublicSiteInfo contains branding pubInfo, err := repo.GetPublicSiteInfo(t.Context(), "brand_site", "") if err != nil { t.Fatalf("GetPublicSiteInfo failed: %v", err) } if pubInfo.LogoURL != "https://cdn.example.com/logo.png" { t.Fatalf("expected LogoURL, got: %+v", pubInfo) } // 2. Account Markup dsa := model.DistributionSiteAccount{ SiteID: site.ID, AccountID: 101, ListingID: 201, DisplayStatus: "enabled", } if err := db.Create(&dsa).Error; err != nil { t.Fatal(err) } display, err := repo.UpdateAccountMarkup(t.Context(), 0, dsa.ID, 500) if err != nil { t.Fatalf("UpdateAccountMarkup failed: %v", err) } if display.MarkupAmountCent != 500 { t.Fatalf("expected markup 500, got %d", display.MarkupAmountCent) } // Batch update markup if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{ ListingIDs: []uint64{201}, MarkupAmountCent: 800, }); err != nil { t.Fatalf("BatchUpdateAccountMarkup failed: %v", err) } displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "") if err != nil || len(displays) == 0 || displays[0].MarkupAmountCent != 800 { t.Fatalf("expected markup 800 after batch, got: %+v", displays) } } func TestDistributionReceivedListingsAndWallet(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{ SiteCode: "recv_site", SiteName: "收号测试站", Domain: "recv.example.com", Status: "active", DisplayAccountEnabled: true, } if err := db.Create(&site).Error; err != nil { t.Fatal(err) } user := model.User{ Phone: "13800001234", Nickname: "seller01", } if err := db.Create(&user).Error; err != nil { t.Fatal(err) } ga := model.GameAccount{ OwnerID: user.ID, GameName: "delta_force", ServerRegion: "cn", LoginPlatform: "pc", Title: "满配三角洲账号", } if err := db.Create(&ga).Error; err != nil { t.Fatal(err) } listing := model.RentalListing{ ListingNo: "RL-99001", AccountID: ga.ID, OwnerID: user.ID, PriceCent: 2000, DepositAmountCent: 5000, Status: "published", ReviewStatus: "approved", DistributionSiteID: &site.ID, } if err := db.Create(&listing).Error; err != nil { t.Fatal(err) } // 1. List received listings recPage, err := repo.ListReceivedListings(t.Context(), 0, site.ID, ReceivedListingQuery{ Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("ListReceivedListings failed: %v", err) } if recPage.Total != 1 || len(recPage.Items) != 1 { t.Fatalf("expected 1 received listing, got %d", recPage.Total) } item := recPage.Items[0] if item.ListingNo != "RL-99001" || item.GameName != "delta_force" || item.OwnerNickname != "seller01" { t.Fatalf("unexpected received listing: %+v", item) } // Update received listing status if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "inactive"); err != nil { t.Fatalf("UpdateReceivedListingStatus failed: %v", err) } // 2. Settlement batch settles and credits wallet rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now()} if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } batch := model.DistributionSettlementBatch{ SiteID: site.ID, BatchNo: "B-WALLET-01", PeriodStart: time.Now().Add(-24 * time.Hour), PeriodEnd: time.Now(), TotalAmountCent: 10000, Status: "pending", } if err := db.Create(&batch).Error; err != nil { t.Fatal(err) } batchItem := model.DistributionSettlementItem{ BatchID: batch.ID, OrderID: 888, PartyID: site.ID, AmountCent: 10000, Status: "pending", } if err := db.Create(&batchItem).Error; err != nil { t.Fatal(err) } // Mark batch settled -> auto-credits merchant wallet if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != nil { t.Fatalf("MarkSettlementBatch failed: %v", err) } walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID) if err != nil { t.Fatalf("GetSiteWallet failed: %v", err) } if walletDTO.AvailableBalanceCent != 10000 || walletDTO.TotalSettledCent != 10000 { t.Fatalf("expected available balance 10000, got: %+v", walletDTO) } // 3. Apply withdrawal wd, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{ SiteID: site.ID, AmountCent: 4000, AccountType: "alipay", AccountName: "分销商李四", AccountNo: "13800005678", }) if err != nil { t.Fatalf("ApplySiteWithdrawal failed: %v", err) } if wd.AmountCent != 4000 || wd.Status != "pending" { t.Fatalf("unexpected withdrawal: %+v", wd) } // Check balance after freeze walletAfterWD, err := repo.GetSiteWallet(t.Context(), 0, site.ID) if err != nil { t.Fatalf("GetSiteWallet after WD failed: %v", err) } if walletAfterWD.AvailableBalanceCent != 6000 || walletAfterWD.FrozenBalanceCent != 4000 { t.Fatalf("expected available 6000 frozen 4000, got: %+v", walletAfterWD) } // 4. List withdrawals wdPage, err := repo.ListSiteWithdrawals(t.Context(), 0, site.ID, 1, 10) if err != nil { t.Fatalf("ListSiteWithdrawals failed: %v", err) } if wdPage.Total != 1 || len(wdPage.Items) != 1 { t.Fatalf("expected 1 withdrawal record, got %d", wdPage.Total) } // 5. Dashboard analytics analytics, err := repo.GetDashboardAnalytics(t.Context(), 0, site.ID) if err != nil { t.Fatalf("GetDashboardAnalytics failed: %v", err) } if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 6000 { t.Fatalf("unexpected analytics: %+v", analytics) } }