package distribution import ( "encoding/json" "errors" "testing" "time" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" "gorm.io/datatypes" "gorm.io/gorm" ) func distributionTestDB(t *testing.T) *gorm.DB { t.Helper() db := database.NewTestDB() if err := database.AutoMigrateIfMissing(db, &model.AdminUser{}, &model.Role{}, &model.Permission{}, &model.AdminUserRole{}, &model.RolePermission{}, &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{}, &model.Dispute{}, ); err != nil { t.Fatal(err) } // 000066 迁移为分销表加了指向 admin_users 的外键, // 测试数据引用的管理员必须先落库才能通过约束校验。 if err := db.Create(&model.AdminUser{ ID: 7, Username: "distribution_test_admin", Nickname: "分销测试管理员", Status: "active", }).Error; err != nil { t.Fatal(err) } return db } // 站点归属兜底依赖「按访问域名反查站点编码」,这里覆盖大小写/空白、主站域名、停用站点等边界。 func TestRepositoryResolveSiteCodeByDomain(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) if err := db.Create(&model.DistributionSite{ SiteCode: "gz_dist", SiteName: "天域商行", Domain: "tysh.hao858.com", Status: "active", TaxRateBPS: 100, }).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionSite{ SiteCode: "down_site", SiteName: "停用站", Domain: "down.hao858.com", Status: "disabled", TaxRateBPS: 100, }).Error; err != nil { t.Fatal(err) } cases := []struct { name string domain string want string }{ {name: "命中并统一小写", domain: "TYSH.HAO858.COM", want: "gz_dist"}, {name: "去除首尾空白", domain: " tysh.hao858.com ", want: "gz_dist"}, {name: "主站域名无匹配", domain: "hao858.com", want: ""}, {name: "停用站点不解析", domain: "down.hao858.com", want: ""}, {name: "空域名", domain: " ", want: ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := repo.ResolveSiteCodeByDomain(t.Context(), tc.domain) if err != nil { t.Fatalf("ResolveSiteCodeByDomain(%q) error = %v", tc.domain, err) } if got != tc.want { t.Fatalf("ResolveSiteCodeByDomain(%q) = %q, want %q", tc.domain, got, tc.want) } }) } } 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) } // 验证分销站成员管理员可查看本站参与的订单 adminUser := model.AdminUser{Username: "dist_admin", Status: "active"} if err := db.Create(&adminUser).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionSiteMember{SiteID: site.ID, AdminUserID: adminUser.ID, MemberType: "site_operator", Status: "active"}).Error; err != nil { t.Fatal(err) } detail, err := repo.FindOrder(t.Context(), adminUser.ID, order.ID) if err != nil { t.Fatalf("FindOrder by site member failed: %v", err) } if detail.DistributorEarningCent != 1000 || detail.DistributorRole != "receive" { t.Fatalf("unexpected detail for site member: %+v", detail) } // 验证未关联站点的管理员无法查看 otherAdmin := model.AdminUser{Username: "other_admin", Status: "active"} if err := db.Create(&otherAdmin).Error; err != nil { t.Fatal(err) } if _, err := repo.FindOrder(t.Context(), otherAdmin.ID, order.ID); err != ErrSiteAccessDenied { t.Fatalf("expected ErrSiteAccessDenied, got: %v", 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 != 350 { t.Fatalf("batch total = %d, want 350 (site share only)", batch.TotalAmountCent) } var batchItems []model.DistributionSettlementItem if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 1 { t.Fatalf("batch items = %d, err=%v", len(batchItems), err) } if batchItems[0].AmountCent != 350 || batchItems[0].PartyID != parties[0].ID { t.Fatalf("batch item should be site party only: %+v", batchItems[0]) } // 空批次不允许结算。 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) } walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID) if err != nil { t.Fatal(err) } if walletDTO.AvailableBalanceCent != 350 || walletDTO.TotalSettledCent != 350 { t.Fatalf("site wallet should only receive site share, got %+v", walletDTO) } } func TestCreateSettlementBatchSkipsRentingOrders(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{SiteCode: "ds_renting", SiteName: "租赁中批次站", Domain: "ds-renting.example.com", Status: "active"} if err := db.Create(&site).Error; err != nil { t.Fatal(err) } rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)} if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } order := model.RentalOrder{OrderNo: "DS-RENTING-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "renting", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID} if err := db.Create(&order).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending", }).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400, }).Error; err != nil { t.Fatal(err) } batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{ SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour), }) if err != nil { t.Fatal(err) } if batch.TotalAmountCent != 0 { t.Fatalf("renting order must not enter DS batch, total=%d", batch.TotalAmountCent) } } func TestCancelSettlementBatchUnblocksAutoRelease(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{SiteCode: "ds_cancel", SiteName: "作废批次站", Domain: "ds-cancel.example.com", Status: "active"} if err := db.Create(&site).Error; err != nil { t.Fatal(err) } rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)} if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } order := model.RentalOrder{OrderNo: "DS-CANCEL-1", 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) } if err := db.Create(&model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending", }).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400, }).Error; err != nil { t.Fatal(err) } batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{ SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour), }) if err != nil { t.Fatal(err) } cancelled, err := repo.CancelSettlementBatch(t.Context(), 0, batch.ID) if err != nil || cancelled.Status != "cancelled" { t.Fatalf("cancel = %#v err=%v", cancelled, err) } var leftover int64 if err := db.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", batch.ID).Count(&leftover).Error; err != nil || leftover != 0 { t.Fatalf("cancelled batch must drop items, leftover=%d err=%v", leftover, err) } if _, err := repo.CancelSettlementBatch(t.Context(), 0, batch.ID); err != ErrBatchNotCancellable { t.Fatalf("second cancel err = %v", err) } again, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{ SiteID: site.ID, PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now().Add(time.Hour), }) if err != nil { t.Fatal(err) } if again.TotalAmountCent != 1400 { t.Fatalf("cancelled items should be eligible again, total=%d", again.TotalAmountCent) } } 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 _, err := repo.ScopeSiteID(t.Context(), 99, 123); err != ErrSiteAccessDenied { t.Fatalf("unbound admin must not inherit superadmin, err=%v", err) } superRole := model.Role{Code: "super_admin", Name: "超级管理员"} if err := db.Create(&superRole).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.AdminUserRole{AdminID: 88, RoleID: superRole.ID}).Error; err != nil { t.Fatal(err) } if got, err := repo.ScopeSiteID(t.Context(), 88, other.ID); err != nil || got != other.ID { t.Fatalf("superadmin scope = %d, err=%v", got, err) } allView := model.Permission{Code: "distribution:all_view", Name: "查看全部分销站点", Resource: "distribution", Action: "all_view"} if err := db.Create(&allView).Error; err != nil { t.Fatal(err) } opsRole := model.Role{Code: "platform_ops", Name: "平台运营"} if err := db.Create(&opsRole).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.RolePermission{RoleID: opsRole.ID, PermissionID: allView.ID}).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.AdminUserRole{AdminID: 77, RoleID: opsRole.ID}).Error; err != nil { t.Fatal(err) } if got, err := repo.ScopeSiteID(t.Context(), 77, other.ID); err != nil || got != other.ID { t.Fatalf("all_view 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) } svc := NewService(repo) unboundSites, err := svc.ListSitesForAdmin(t.Context(), 99) if err != nil || len(unboundSites) != 0 { t.Fatalf("unbound admin must not list all sites, got %d err=%v", len(unboundSites), err) } superSites, err := svc.ListSitesForAdmin(t.Context(), 88) if err != nil || len(superSites) != 2 { t.Fatalf("superadmin should list all sites, got %d err=%v", len(superSites), err) } qrView := model.Permission{Code: "qrcode:view", Name: "查看二维码池", Resource: "qrcode", Action: "view"} if err := db.Create(&qrView).Error; err != nil { t.Fatal(err) } csRole := model.Role{Code: "customer_service_role", Name: "客服"} if err := db.Create(&csRole).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.RolePermission{RoleID: csRole.ID, PermissionID: qrView.ID}).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.AdminUserRole{AdminID: 66, RoleID: csRole.ID}).Error; err != nil { t.Fatal(err) } csSites, err := svc.ListSitesForAdmin(t.Context(), 66) if err != nil || len(csSites) != 2 { t.Fatalf("cs admin with qrcode:view should list all sites for qr pool, got %d err=%v", len(csSites), err) } // 码池维护只需要读站点列表,必须止步于此:客服不得因此获得跨站写权限。 // distribution:all_view 是跨站写操作(改站/删站/重置站长密码)的总开关, // 而它挂在被分站站长共用的 ops 角色上,一旦授予即击穿多租户隔离。 if global, err := repo.hasGlobalDistributionAccess(t.Context(), 66); err != nil || global { t.Fatalf("qrcode admin must stay non-global, global=%v err=%v", global, err) } if _, err := repo.ScopeSiteID(t.Context(), 66, other.ID); err != ErrSiteAccessDenied { t.Fatalf("qrcode admin must not scope into other site, err=%v", err) } if err := repo.ResetSiteAdminPassword(t.Context(), 66, other.ID, "whatever123"); err != ErrSiteAccessDenied { t.Fatalf("qrcode admin must not reset other site admin password, err=%v", err) } if err := repo.DeleteSite(t.Context(), 66, other.ID); err != ErrSiteAccessDenied { t.Fatalf("qrcode admin must not delete other 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) } mainListing := model.RentalListing{ ListingNo: "L10001", AccountID: account1.ID, OwnerID: 1, PriceCent: 2500, DepositAmountCent: 10000, Status: "published", ReviewStatus: "approved", InTransaction: false, } if err := db.Create(&mainListing).Error; err != nil { t.Fatal(err) } ownAccount := model.GameAccount{ Title: "本站上传号", GameName: "delta_force", ServerRegion: "QQ区", LoginPlatform: "PC", Status: "active", } if err := db.Create(&ownAccount).Error; err != nil { t.Fatal(err) } siteID := site.ID ownListing := model.RentalListing{ ListingNo: "L10003", AccountID: ownAccount.ID, OwnerID: 2, PriceCent: 1800, DepositAmountCent: 8000, Status: "published", ReviewStatus: "approved", DistributionSiteID: &siteID, SourceChannel: site.SiteCode, } if err := db.Create(&ownListing).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) } // 默认同步全网合规商品(包含主站商品与本站上传货) resMain, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{}) if err != nil { t.Fatalf("SyncSiteAccounts err = %v", err) } if resMain.SyncedCount != 2 || resMain.SkippedCount != 0 || resMain.TotalActive != 2 { t.Fatalf("unexpected sync res: %+v", resMain) } res2, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{}) if err != nil { t.Fatalf("SyncSiteAccounts retry err = %v", err) } if res2.SyncedCount != 0 || res2.SkippedCount != 2 || res2.TotalActive != 2 { t.Fatalf("unexpected res2: %+v", res2) } if _, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{ListingIDs: []uint64{999999}}); !errors.Is(err, ErrSyncListingNotFound) { t.Fatalf("missing listing_ids err = %v", err) } resSelect, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{ListingIDs: []uint64{mainListing.ID}}) if err != nil { t.Fatalf("explicit select err = %v", err) } if resSelect.SyncedCount != 0 || resSelect.SkippedCount != 1 || resSelect.TotalActive != 1 { t.Fatalf("unexpected explicit select res: %+v", resSelect) } displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "") if err != nil { t.Fatalf("ListAccountDisplays err = %v", err) } if len(displays) != 2 { t.Fatalf("expected 2 displays, got %d", len(displays)) } byNo := map[string]AccountDisplayDTO{} for _, item := range displays { byNo[item.ListingNo] = item } mainDisplay, ok := byNo["L10001"] if !ok || mainDisplay.Title != "三角洲极品金枪号" || mainDisplay.PriceCent != 2500 || mainDisplay.DepositAmountCent != 10000 { t.Fatalf("unexpected main display: %+v", byNo) } if _, ok := byNo["L10003"]; !ok { t.Fatalf("missing own-site display: %+v", byNo) } pubList, err := repo.ListPublicSiteListings(t.Context(), site.SiteCode, 1, 20) if err != nil { t.Fatalf("ListPublicSiteListings err = %v", err) } if pubList.Total != 2 || len(pubList.Items) != 2 { t.Fatalf("expected 2 public items, got %+v", pubList) } if _, err := repo.UpdateAccountDisplay(t.Context(), 0, mainDisplay.ID, "disabled"); err != nil { t.Fatalf("UpdateAccountDisplay disabled err = %v", err) } if _, err := repo.UpdateAccountDisplay(t.Context(), 0, byNo["L10003"].ID, "disabled"); err != nil { t.Fatalf("UpdateAccountDisplay own 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 TestOnboardSiteDoesNotDumpCatalog(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) != 0 { t.Fatalf("onboard must not dump catalog, got %d displays", len(displays)) } var treasury model.WalletAccount if err := db.Where("user_id = 0 AND distribution_site_id = ?", onboardResp.Site.ID).First(&treasury).Error; err != nil { t.Fatalf("onboard must create site treasury wallet, err=%v", err) } if onboardResp.Rule.Status != "active" || onboardResp.Rule.Mode != ModeTwoPartyDistributorReceive || onboardResp.Rule.DistributorReceiveRatio != 35 || onboardResp.Rule.PlatformSellRatio != 40 || onboardResp.Rule.ServiceFeeValue != 25 { t.Fatalf("onboard receive rule should be default active 35/40/25, got %+v", onboardResp.Rule) } var extraRules []model.DistributionRuleVersion if err := db.Where("site_id = ? AND status = ?", onboardResp.Site.ID, "active").Order("mode ASC").Find(&extraRules).Error; err != nil { t.Fatal(err) } if len(extraRules) != 4 { t.Fatalf("onboard should activate all four default modes, got %d", len(extraRules)) } } 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) } logo := "https://cdn.example.com/logo.png" favicon := "https://cdn.example.com/favicon.ico" banners := `[{"image_url":"https://cdn.example.com/b1.jpg"}]` announcements := `[{"title":"开业大吉"}]` footer := `{"copyright":"2026 Brand"}` storefrontText := `{"tagline":"天域商行 · 专注回收","hero_badge":"正品保障"}` updated, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{ LogoURL: &logo, FaviconURL: &favicon, BannersJSON: &banners, AnnouncementsJSON: &announcements, FooterInfoJSON: &footer, StorefrontTextJSON: &storefrontText, }) 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) } if updated.StorefrontTextJSON != storefrontText { t.Fatalf("storefront_text_json = %q, want %q", updated.StorefrontTextJSON, storefrontText) } // 公开站点信息必须透出首页文案,前台才能读到站长自定义内容。 publicInfo, err := repo.GetPublicSiteInfo(t.Context(), site.SiteCode, "") if err != nil { t.Fatalf("GetPublicSiteInfo failed: %v", err) } if publicInfo.StorefrontTextJSON != storefrontText { t.Fatalf("public storefront_text_json = %q, want %q", publicInfo.StorefrontTextJSON, storefrontText) } // 传空字符串表示清除自定义文案,前台回落内置默认值。 emptyText := "" textCleared, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{ StorefrontTextJSON: &emptyText, }) if err != nil { t.Fatalf("clearing storefront text failed: %v", err) } if textCleared.StorefrontTextJSON != "" { t.Fatalf("storefront_text_json should be cleared, got %q", textCleared.StorefrontTextJSON) } qrURL := "/api/files/object?key=qrcode/brand-site.png" qrUpdated, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ListingQrcodeURL: &qrURL}) if err != nil { t.Fatalf("UpdateSite listing qrcode failed: %v", err) } if qrUpdated.ListingQrcodeURL != qrURL { t.Fatalf("listing_qrcode_url = %q, want %q", qrUpdated.ListingQrcodeURL, qrURL) } cleared := "" qrCleared, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ListingQrcodeURL: &cleared}) if err != nil { t.Fatalf("clear listing qrcode failed: %v", err) } if qrCleared.ListingQrcodeURL != "" { t.Fatalf("cleared listing_qrcode_url = %q, want empty", qrCleared.ListingQrcodeURL) } delta := 3.5 ratioUpdated, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ReceiveRatioDelta: &delta}) if err != nil { t.Fatalf("UpdateSite receive ratio failed: %v", err) } if ratioUpdated.ReceiveRatioDelta != 3.5 { t.Fatalf("receive_ratio_delta = %v, want 3.5", ratioUpdated.ReceiveRatioDelta) } negative := -1.0 if _, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ReceiveRatioDelta: &negative}); !errors.Is(err, ErrInvalidAmount) { t.Fatalf("negative receive ratio err = %v, want ErrInvalidAmount", err) } 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) } if pubInfo.ReceiveRatioDelta != 3.5 { t.Fatalf("public receive_ratio_delta = %v, want 3.5", pubInfo.ReceiveRatioDelta) } faviconOnly := "https://cdn.example.com/favicon2.ico" partial, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{FaviconURL: &faviconOnly}) if err != nil { t.Fatalf("partial branding update failed: %v", err) } if partial.LogoURL != "https://cdn.example.com/logo.png" || partial.FaviconURL != faviconOnly { t.Fatalf("omitted branding fields must stay, got %+v", partial) } // 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, 5) if err != nil { t.Fatalf("UpdateAccountMarkup failed: %v", err) } if display.ShelfRatioDelta != 5 { t.Fatalf("expected shelf ratio delta 5, got %v", display.ShelfRatioDelta) } // Batch update markup if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{ ListingIDs: nil, ShelfRatioDelta: 8, }); !errors.Is(err, ErrEmptyListingIDs) { t.Fatalf("empty listing_ids err = %v", err) } if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{ ListingIDs: []uint64{201}, ShelfRatioDelta: 8, }); err != nil { t.Fatalf("BatchUpdateAccountMarkup failed: %v", err) } displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "") if err != nil || len(displays) == 0 || displays[0].ShelfRatioDelta != 8 { t.Fatalf("expected shelf ratio delta 8 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) } summary, err := json.Marshal(map[string]any{ "price_breakdown": map[string]any{ "seller_ratio": 43.0, "seller_total_price": 232.6, "buyer_ratio": 38.0, }, }) if err != nil { t.Fatal(err) } ga := model.GameAccount{ OwnerID: user.ID, GameName: "delta_force", ServerRegion: "cn", LoginPlatform: "pc", Title: "满配三角洲账号", HafCoinAmount: 100_000_000, AssetSummary: datatypes.JSON(summary), } 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) } phonePage, err := repo.ListReceivedListings(t.Context(), 0, site.ID, ReceivedListingQuery{ Page: 1, PageSize: 10, Keyword: user.Phone, }) if err != nil { t.Fatalf("ListReceivedListings by phone failed: %v", err) } if phonePage.Total != 1 || phonePage.Items[0].ListingID != listing.ID { t.Fatalf("expected listing %d by phone, got total=%d items=%+v", listing.ID, phonePage.Total, phonePage.Items) } item := recPage.Items[0] if item.ListingNo != "RL-99001" || item.GameName != "delta_force" || item.OwnerNickname != "seller01" { t.Fatalf("unexpected received listing: %+v", item) } if item.SellerRatio != 43 || item.SellerTotalPriceCent != 23260 || item.BuyerRatio != 38 { t.Fatalf("expected main-site receive pricing, got ratio=%v price=%d buyer=%v", item.SellerRatio, item.SellerTotalPriceCent, item.BuyerRatio) } if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "inactive"); !errors.Is(err, ErrInvalidListingStatus) { t.Fatalf("inactive status err = %v", err) } if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "offline"); err != nil { t.Fatalf("UpdateReceivedListingStatus failed: %v", err) } var listingAfter model.RentalListing if err := db.First(&listingAfter, listing.ID).Error; err != nil { t.Fatal(err) } if listingAfter.Status != "published" { t.Fatalf("shelf offline must not change listing.status, got %s", listingAfter.Status) } var dsa model.DistributionSiteAccount if err := db.Where("site_id = ? AND listing_id = ?", site.ID, listing.ID).First(&dsa).Error; err != nil { t.Fatalf("expected display row, err=%v", err) } if dsa.DisplayStatus != "disabled" { t.Fatalf("expected display disabled, got %s", dsa.DisplayStatus) } offlinePage, err := repo.ListReceivedListings(t.Context(), 0, site.ID, ReceivedListingQuery{Page: 1, PageSize: 10}) if err != nil { t.Fatalf("ListReceivedListings after offline failed: %v", err) } if len(offlinePage.Items) != 1 || offlinePage.Items[0].Status != "offline" { t.Fatalf("expected shelf status offline, got %+v", offlinePage.Items) } // 2. Settlement batch only credits the site party, never the platform share. rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now().Add(-time.Hour)} if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } order := model.RentalOrder{OrderNo: "RECV-WALLET-1", ListingID: listing.ID, AccountID: ga.ID, OwnerID: user.ID, RenterID: 9, 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: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 3500, ProfitAmountCent: 3500}, {OrderID: order.ID, SiteID: site.ID, PartyRole: "platform_sell", PartyType: PartyTypePlatform, PartyID: 0, IncomeAmountCent: 4000, ProfitAmountCent: 4000}, } if err := db.Create(&parties).Error; err != nil { t.Fatal(err) } batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{ SiteID: site.ID, PeriodStart: time.Now().Add(-24 * time.Hour), PeriodEnd: time.Now().Add(time.Hour), }) if err != nil { t.Fatalf("CreateSettlementBatch failed: %v", err) } if batch.TotalAmountCent != 3500 { t.Fatalf("expected site share 3500, got %d", batch.TotalAmountCent) } var batchItems []model.DistributionSettlementItem if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 1 { t.Fatalf("platform party must not enter site batch, items=%d err=%v", len(batchItems), err) } 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 != 3500 || walletDTO.TotalSettledCent != 3500 { t.Fatalf("expected available balance 3500, got: %+v", walletDTO) } // 3. Apply withdrawal wd, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{ SiteID: site.ID, AmountCent: 1000, AccountType: "alipay", AccountName: "分销商李四", AccountNo: "13800005678", }) if err != nil { t.Fatalf("ApplySiteWithdrawal failed: %v", err) } if wd.AmountCent != 1000 || wd.Status != "pending" { t.Fatalf("unexpected withdrawal: %+v", wd) } walletAfterWD, err := repo.GetSiteWallet(t.Context(), 0, site.ID) if err != nil { t.Fatalf("GetSiteWallet after WD failed: %v", err) } if walletAfterWD.AvailableBalanceCent != 2500 || walletAfterWD.FrozenBalanceCent != 1000 { t.Fatalf("expected available 2500 frozen 1000, 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 != 2500 { t.Fatalf("unexpected analytics: %+v", analytics) } if len(analytics.TrendDays) == 0 || analytics.TrendDays[len(analytics.TrendDays)-1].ReceivedCount != 1 { t.Fatalf("expected trend today ReceivedCount=1, got: %+v", analytics.TrendDays) } } func TestPlatformSellSettlementCreditsOnlySiteShare(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() site := model.DistributionSite{SiteCode: "sell_site", SiteName: "卖出站", Domain: "sell.example.com", Status: "active", DisplayAccountEnabled: true} if err := db.Create(&site).Error; err != nil { t.Fatal(err) } listing := model.RentalListing{ListingNo: "L-MAIN-SELL", AccountID: 301, OwnerID: 1, Status: "published"} if err := db.Create(&listing).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionSiteAccount{SiteID: site.ID, AccountID: listing.AccountID, ListingID: listing.ID, DisplayStatus: "enabled"}).Error; err != nil { t.Fatal(err) } rule := model.DistributionRuleVersion{ SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active", PlatformReceiveRatio: 3.5, DistributorSellRatio: 4, ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 0, EffectiveFrom: now.Add(-time.Hour), } if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } foundSite, foundRule, err := ResolveOrderSourceTx(db, "sell_site", listing.ID, listing.AccountID, now) if err != nil { t.Fatal(err) } if foundRule.Mode != ModeTwoPartyPlatformReceive { t.Fatalf("mode = %s, want %s", foundRule.Mode, ModeTwoPartyPlatformReceive) } order := model.RentalOrder{ OrderNo: "ORD-PLATFORM-SELL", ListingID: listing.ID, AccountID: listing.AccountID, OwnerID: 1, RenterID: 2, Status: "completed", DistributionSiteID: &foundSite.ID, DistributionRuleVersionID: &foundRule.ID, DistributionMode: foundRule.Mode, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } if err := SnapshotPaidOrderTx(db, order.ID, 10000); err != nil { t.Fatalf("SnapshotPaidOrderTx failed: %v", err) } var parties []model.DistributionOrderParty if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 { t.Fatalf("parties = %d, err=%v", len(parties), err) } var siteIncome, platformIncome int64 for _, party := range parties { switch party.PartyRole { case "distributor_sell": if party.PartyType != PartyTypeSite || party.PartyID != site.ID { t.Fatalf("site party = %+v", party) } siteIncome = party.IncomeAmountCent case "platform_receive": if party.PartyType != PartyTypePlatform || party.PartyID != 0 { t.Fatalf("platform party_id must be 0, got %+v", party) } platformIncome = party.IncomeAmountCent default: t.Fatalf("unexpected party %+v", party) } } if siteIncome <= 0 || platformIncome <= 0 { t.Fatalf("expected both site and platform income, site=%d platform=%d", siteIncome, platformIncome) } batch, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{ SiteID: site.ID, PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour), }) if err != nil { t.Fatal(err) } if batch.TotalAmountCent != siteIncome { t.Fatalf("batch total = %d, want site income %d", batch.TotalAmountCent, siteIncome) } var batchItems []model.DistributionSettlementItem if err := db.Where("batch_id = ?", batch.ID).Find(&batchItems).Error; err != nil || len(batchItems) != 1 { t.Fatalf("platform party must not appear in site batch, items=%d err=%v", len(batchItems), err) } if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != nil { t.Fatal(err) } walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID) if err != nil { t.Fatal(err) } if walletDTO.AvailableBalanceCent != siteIncome { t.Fatalf("wallet Δ = %d, want %d", walletDTO.AvailableBalanceCent, siteIncome) } } func TestSettlementBatchSplitsThreePartyBySite(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() siteA := model.DistributionSite{SiteCode: "party_a", SiteName: "A", Domain: "a.example.com", Status: "active", DisplayAccountEnabled: true} siteB := model.DistributionSite{SiteCode: "party_b", SiteName: "B", Domain: "b.example.com", Status: "active", DisplayAccountEnabled: true} if err := db.Create(&siteA).Error; err != nil { t.Fatal(err) } if err := db.Create(&siteB).Error; err != nil { t.Fatal(err) } rule := model.DistributionRuleVersion{SiteID: &siteB.ID, Mode: ModeThreeParty, Version: 1, Status: "active", EffectiveFrom: now.Add(-time.Hour)} if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } order := model.RentalOrder{OrderNo: "ORD-3P", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", DistributionSiteID: &siteB.ID, DistributionRuleVersionID: &rule.ID} if err := db.Create(&order).Error; err != nil { t.Fatal(err) } financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: siteB.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending"} if err := db.Create(&financial).Error; err != nil { t.Fatal(err) } if err := db.Create(&[]model.DistributionOrderParty{ {OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_a", PartyType: PartyTypeSite, PartyID: siteA.ID, IncomeAmountCent: 350}, {OrderID: order.ID, SiteID: siteB.ID, PartyRole: "distributor_b", PartyType: PartyTypeSite, PartyID: siteB.ID, IncomeAmountCent: 400}, }).Error; err != nil { t.Fatal(err) } period := CreateSettlementBatchRequest{PeriodStart: now.Add(-time.Hour), PeriodEnd: now.Add(time.Hour)} batchA, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: siteA.ID, PeriodStart: period.PeriodStart, PeriodEnd: period.PeriodEnd}) if err != nil || batchA.TotalAmountCent != 350 { t.Fatalf("site A batch = %+v err=%v", batchA, err) } batchB, err := repo.CreateSettlementBatch(t.Context(), 0, CreateSettlementBatchRequest{SiteID: siteB.ID, PeriodStart: period.PeriodStart, PeriodEnd: period.PeriodEnd}) if err != nil || batchB.TotalAmountCent != 400 { t.Fatalf("site B batch = %+v err=%v", batchB, err) } if _, err := repo.MarkSettlementBatch(t.Context(), 0, batchA.ID); err != nil { t.Fatal(err) } var financialAfterA model.DistributionOrderFinancial if err := db.Where("order_id = ?", order.ID).First(&financialAfterA).Error; err != nil { t.Fatal(err) } if financialAfterA.SettlementStatus != "pending" { t.Fatalf("financial must stay pending until all site parties settle, got %s", financialAfterA.SettlementStatus) } if _, err := repo.MarkSettlementBatch(t.Context(), 0, batchB.ID); err != nil { t.Fatal(err) } var financialAfterB model.DistributionOrderFinancial if err := db.Where("order_id = ?", order.ID).First(&financialAfterB).Error; err != nil { t.Fatal(err) } if financialAfterB.SettlementStatus != "settled" { t.Fatalf("financial status = %s, want settled", financialAfterB.SettlementStatus) } walletA, err := repo.GetSiteWallet(t.Context(), 0, siteA.ID) if err != nil { t.Fatal(err) } walletB, err := repo.GetSiteWallet(t.Context(), 0, siteB.ID) if err != nil { t.Fatal(err) } if walletA.AvailableBalanceCent != 350 || walletB.AvailableBalanceCent != 400 { t.Fatalf("wallets A=%d B=%d", walletA.AvailableBalanceCent, walletB.AvailableBalanceCent) } } func TestMoneyMovementNotFrozenInProduction(t *testing.T) { t.Setenv("APP_ENV", "production") db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{SiteCode: "prod", SiteName: "生产口径站", Domain: "prod.example.com", Status: "active"} if err := db.Create(&site).Error; err != nil { t.Fatal(err) } batch := model.DistributionSettlementBatch{ SiteID: site.ID, BatchNo: "B-PROD", PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now(), TotalAmountCent: 100, Status: "draft", } if err := db.Create(&batch).Error; err != nil { t.Fatal(err) } // 结算明细的 order_id / party_id 都带外键,必须先落库对应的订单与参与方。 order := model.RentalOrder{ OrderNo: "ORD-PROD", ListingID: 1, AccountID: 1, OwnerID: 1, RenterID: 1, Status: "completed", SettlementMode: "platform_managed", } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } party := model.DistributionOrderParty{ OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 100, } if err := db.Create(&party).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionSettlementItem{ BatchID: batch.ID, OrderID: order.ID, PartyID: party.ID, AmountCent: 100, Status: "pending", }).Error; err != nil { t.Fatal(err) } // 生产与测试口径一致:结算入账与站点提现不再按 APP_ENV 冻结。 settled, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID) if err != nil { t.Fatalf("生产环境结算入账不应被冻结: %v", err) } if settled.Status != "settled" { t.Fatalf("batch status = %s, want settled", settled.Status) } wd, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{ SiteID: site.ID, AmountCent: 1, AccountType: "alipay", AccountName: "x", AccountNo: "1", }) if err != nil { t.Fatalf("生产环境站点提现不应被冻结: %v", err) } if wd.AmountCent != 1 || wd.Status != "pending" { t.Fatalf("unexpected withdrawal: %+v", wd) } } func TestUpdateSiteRewritesPendingReleaseAt(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() site := mustCreateDistSite(t, db, "hold_upd", "冷却回写站", "holdupd.dist.com") if err := db.Model(&site).Update("settlement_hold_days", 7).Error; err != nil { t.Fatal(err) } rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) settledAt := now.Add(-time.Hour).Truncate(time.Second) order := model.RentalOrder{ OrderNo: "ORD-HOLD-UPD", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, SettledAt: &settledAt, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } oldRelease := settledAt.AddDate(0, 0, 7) if err := db.Create(&model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending", ReleaseAt: &oldRelease, }).Error; err != nil { t.Fatal(err) } holdDays := 0 if _, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{SettlementHoldDays: &holdDays}); err != nil { t.Fatal(err) } var financial model.DistributionOrderFinancial if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { t.Fatal(err) } if financial.ReleaseAt == nil || !financial.ReleaseAt.Equal(settledAt) { t.Fatalf("release_at = %v, want %v after hold days set to 0", financial.ReleaseAt, settledAt) } } func TestGetSiteWalletPendingReleaseExcludesOpenDispute(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) now := time.Now() site := mustCreateDistSite(t, db, "pend_dsp", "待释放纠纷站", "penddsp.dist.com") rule := mustCreateActiveRule(t, db, site.ID, ModeTwoPartyDistributorReceive, now) past := now.Add(-time.Minute) order := model.RentalOrder{ OrderNo: "ORD-PEND-DSP", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed", RentAmountCent: 10000, OwnerRentAmountCent: 6000, RefundStatus: "none", DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, } if err := db.Create(&order).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderFinancial{ OrderID: order.ID, SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10000, SettlementStatus: "pending", ReleaseAt: &past, }).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: site.ID, IncomeAmountCent: 1400, }).Error; err != nil { t.Fatal(err) } walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID) if err != nil { t.Fatal(err) } if walletDTO.PendingReleaseCent != 1400 { t.Fatalf("pending release = %d, want 1400", walletDTO.PendingReleaseCent) } if err := db.Create(&model.Dispute{ OrderID: order.ID, InitiatorID: 4, TargetUserID: 3, Type: "cannot_login", Status: "open", }).Error; err != nil { t.Fatal(err) } walletDTO, err = repo.GetSiteWallet(t.Context(), 0, site.ID) if err != nil { t.Fatal(err) } if walletDTO.PendingReleaseCent != 0 { t.Fatalf("pending release with open dispute = %d, want 0", walletDTO.PendingReleaseCent) } } func TestSyncSiteAccountsOptimizations(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site, err := repo.CreateSite(t.Context(), CreateSiteRequest{ SiteCode: "sync_opt_site", SiteName: "同步优化分站", Domain: "sync-opt.hfb.local", }) if err != nil { t.Fatal(err) } acc1 := model.GameAccount{Title: "商品1", GameName: "cfhd", ServerRegion: "东部", HafCoinAmount: 50_000_000, Status: "active"} acc2 := model.GameAccount{Title: "商品2", GameName: "cfhd", ServerRegion: "北部", HafCoinAmount: 60_000_000, Status: "active"} db.Create(&acc1) db.Create(&acc2) l1 := model.RentalListing{ListingNo: "L-SYNC-OPT-1", AccountID: acc1.ID, OwnerID: 1, PriceCent: 3000, Status: "published", ReviewStatus: "approved"} l2 := model.RentalListing{ListingNo: "L-SYNC-OPT-2", AccountID: acc2.ID, OwnerID: 1, PriceCent: 4000, Status: "published", ReviewStatus: "approved"} db.Create(&l1) db.Create(&l2) // 1. 验证负数加价比例校验 if _, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{DefaultShelfRatioDelta: -5}); !errors.Is(err, ErrInvalidAmount) { t.Fatalf("expected ErrInvalidAmount for negative delta, got: %v", err) } // 2. 同步且设置初始加价 DefaultShelfRatioDelta = 15.5 res, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{ DefaultShelfRatioDelta: 15.5, CleanStaleListings: true, }) if err != nil { t.Fatalf("SyncSiteAccounts failed: %v", err) } if res.SyncedCount != 2 || res.TotalActive != 2 || res.DisabledStaleCount != 0 { t.Fatalf("unexpected res: %+v", res) } // 验证货架商品是否正确写入初始加价 var dsa1 model.DistributionSiteAccount if err := db.Where("site_id = ? AND listing_id = ?", site.ID, l1.ID).First(&dsa1).Error; err != nil { t.Fatal(err) } if dsa1.ShelfRatioDelta != 15.5 || dsa1.DisplayStatus != "enabled" { t.Fatalf("dsa1 mismatch: ratio=%v, status=%s", dsa1.ShelfRatioDelta, dsa1.DisplayStatus) } // 3. 站长人工下架属于选品决策,再次同步不得覆盖 var dsa1After model.DistributionSiteAccount if _, err := repo.UpdateAccountDisplay(t.Context(), 0, dsa1.ID, "disabled"); err != nil { t.Fatalf("UpdateAccountDisplay failed: %v", err) } resKeep, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{ CleanStaleListings: true, }) if err != nil { t.Fatalf("SyncSiteAccounts keep failed: %v", err) } if resKeep.ReenabledCount != 0 { t.Fatalf("人工下架不应被自动恢复: %+v", resKeep) } if err := db.First(&dsa1After, dsa1.ID).Error; err != nil { t.Fatal(err) } if dsa1After.DisplayStatus != "disabled" || dsa1After.DisplayDisabledBy != displayDisabledByOperator { t.Fatalf("人工下架标记丢失: status=%s by=%s", dsa1After.DisplayStatus, dsa1After.DisplayDisabledBy) } // 3.1 系统下架(含历史无来源标记的数据)应被铺货恢复 if err := db.Model(&model.DistributionSiteAccount{}).Where("id = ?", dsa1.ID). Updates(map[string]any{"display_status": "disabled", "display_disabled_by": ""}).Error; err != nil { t.Fatal(err) } resRepair, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{ CleanStaleListings: true, }) if err != nil { t.Fatalf("SyncSiteAccounts repair failed: %v", err) } if resRepair.ReenabledCount != 1 { t.Fatalf("expected 1 reenabled shelf row, got %+v", resRepair) } if err := db.First(&dsa1After, dsa1.ID).Error; err != nil { t.Fatal(err) } if dsa1After.DisplayStatus != "enabled" || dsa1After.DisplayDisabledBy != "" { t.Fatalf("系统下架应被恢复: status=%s by=%s", dsa1After.DisplayStatus, dsa1After.DisplayDisabledBy) } // 3.2 商品下架不清理货架行:分站前台本来就要求 published+approved 才展示, // 一旦把货架行写成 disabled,商品重新上架后就再也不会出现在分站。 db.Model(&l1).Update("status", "offline") resClean, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{ CleanStaleListings: true, }) if err != nil { t.Fatalf("SyncSiteAccounts clean failed: %v", err) } if resClean.DisabledStaleCount != 0 || resClean.ReenabledCount != 0 { t.Fatalf("下架商品不应清理货架行: %+v", resClean) } if err := db.First(&dsa1After, dsa1.ID).Error; err != nil { t.Fatal(err) } if dsa1After.DisplayStatus != "enabled" { t.Fatalf("商品下架后货架行应保持 enabled,got: %s", dsa1After.DisplayStatus) } // 3.3 只有货源已被删除的孤儿货架行才会被清理 if err := db.Delete(&model.RentalListing{}, l2.ID).Error; err != nil { t.Fatal(err) } resStale, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{ CleanStaleListings: true, }) if err != nil { t.Fatalf("SyncSiteAccounts stale failed: %v", err) } if resStale.DisabledStaleCount != 1 { t.Fatalf("expected 1 disabled stale listing, got %+v", resStale) } var dsa2 model.DistributionSiteAccount if err := db.Where("site_id = ? AND listing_id = ?", site.ID, l2.ID).First(&dsa2).Error; err != nil { t.Fatal(err) } if dsa2.DisplayStatus != "disabled" || dsa2.DisplayDisabledBy != displayDisabledBySystem { t.Fatalf("已删除货源应被系统下架: status=%s by=%s", dsa2.DisplayStatus, dsa2.DisplayDisabledBy) } // 4. 验证并发互斥锁(ErrSyncInProgress) repo.siteSyncLocks.Store(site.ID, struct{}{}) _, err = repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{}) if !errors.Is(err, ErrSyncInProgress) { t.Fatalf("expected ErrSyncInProgress, got %v", err) } repo.siteSyncLocks.Delete(site.ID) } func TestBatchOperationsAndCachingOptimizations(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site, err := repo.CreateSite(t.Context(), CreateSiteRequest{ SiteCode: "opt_batch_site", SiteName: "批量优化分站", Domain: "opt-batch.hfb.local", }) if err != nil { t.Fatal(err) } acc1 := model.GameAccount{Title: "王者生化账号-1", GameName: "cfhd", ServerRegion: "东部", HafCoinAmount: 50_000_000, Status: "active"} acc2 := model.GameAccount{Title: "王者爆破账号-2", GameName: "cfhd", ServerRegion: "北部", HafCoinAmount: 60_000_000, Status: "active"} db.Create(&acc1) db.Create(&acc2) l1 := model.RentalListing{ListingNo: "L-BATCH-1", AccountID: acc1.ID, OwnerID: 1, PriceCent: 3000, Status: "published", ReviewStatus: "approved"} l2 := model.RentalListing{ListingNo: "L-BATCH-2", AccountID: acc2.ID, OwnerID: 1, PriceCent: 4000, Status: "published", ReviewStatus: "approved"} db.Create(&l1) db.Create(&l2) // 同步到货架 _, err = repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{}) if err != nil { t.Fatal(err) } var dsaList []model.DistributionSiteAccount db.Where("site_id = ?", site.ID).Find(&dsaList) if len(dsaList) != 2 { t.Fatalf("expected 2 accounts on shelf, got %d", len(dsaList)) } // 1. 验证 BatchUpdateAccountDisplay 批量下架与恢复展示 if err := repo.BatchUpdateAccountDisplay(t.Context(), 0, site.ID, BatchUpdateAccountDisplayRequest{ DisplayIDs: []uint64{dsaList[0].ID, dsaList[1].ID}, TargetStatus: "disabled", }); err != nil { t.Fatalf("BatchUpdateAccountDisplay disabled failed: %v", err) } var countDisabled int64 db.Model(&model.DistributionSiteAccount{}).Where("site_id = ? AND display_status = 'disabled'", site.ID).Count(&countDisabled) if countDisabled != 2 { t.Fatalf("expected 2 disabled, got %d", countDisabled) } // 批量恢复 if err := repo.BatchUpdateAccountDisplay(t.Context(), 0, site.ID, BatchUpdateAccountDisplayRequest{ ListingIDs: []uint64{l1.ID, l2.ID}, TargetStatus: "enabled", }); err != nil { t.Fatalf("BatchUpdateAccountDisplay enabled failed: %v", err) } var countEnabled int64 db.Model(&model.DistributionSiteAccount{}).Where("site_id = ? AND display_status = 'enabled'", site.ID).Count(&countEnabled) if countEnabled != 2 { t.Fatalf("expected 2 enabled, got %d", countEnabled) } // 2. 验证 BatchUpdateAccountMarkup 支持原子提交 Items 差异化加价 if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{ Items: []AccountMarkupItem{ {ListingID: l1.ID, ShelfRatioDelta: 12.0}, {ListingID: l2.ID, ShelfRatioDelta: 18.5}, }, }); err != nil { t.Fatalf("BatchUpdateAccountMarkup with Items failed: %v", err) } var dsa1 model.DistributionSiteAccount var dsa2 model.DistributionSiteAccount db.Where("site_id = ? AND listing_id = ?", site.ID, l1.ID).First(&dsa1) db.Where("site_id = ? AND listing_id = ?", site.ID, l2.ID).First(&dsa2) if dsa1.ShelfRatioDelta != 12.0 || dsa2.ShelfRatioDelta != 18.5 { t.Fatalf("items markup mismatch: dsa1=%v, dsa2=%v", dsa1.ShelfRatioDelta, dsa2.ShelfRatioDelta) } // 3. 验证 ListAccountDisplaysPaged 分页与关键字筛选 pagedResult, err := repo.ListAccountDisplaysPaged(t.Context(), site.ID, AccountDisplayQuery{ Page: 1, PageSize: 10, Keyword: "生化", }) if err != nil { t.Fatalf("ListAccountDisplaysPaged failed: %v", err) } if pagedResult.Total != 1 || len(pagedResult.Items) != 1 || pagedResult.Items[0].ListingNo != "L-BATCH-1" { t.Fatalf("paged keyword mismatch: total=%d, items=%+v", pagedResult.Total, pagedResult.Items) } } func TestListDistributionUsersAndAnalytics(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site := model.DistributionSite{SiteCode: "user_test_site", SiteName: "用户测试站", Domain: "users.example.com", Status: "active"} if err := db.Create(&site).Error; err != nil { t.Fatal(err) } otherSite := model.DistributionSite{SiteCode: "other_site", SiteName: "其他站", Domain: "other.example.com", Status: "active"} if err := db.Create(&otherSite).Error; err != nil { t.Fatal(err) } now := time.Now() // 用户 1: 本站注册,未下单 u1 := model.User{Phone: "13800000001", Nickname: "用户一", DistributionSiteID: site.ID, Status: "active", CreatedAt: now} // 用户 2: 本站注册,且在本站下过单 u2 := model.User{Phone: "13800000002", Nickname: "用户二", DistributionSiteID: site.ID, Status: "active", CreatedAt: now} // 用户 3: 在主站注册 (DistributionSiteID=0),但在本站下过单 u3 := model.User{Phone: "13800000003", Nickname: "用户三", DistributionSiteID: 0, Status: "active", CreatedAt: now} // 用户 4: 在其他站注册 (DistributionSiteID=otherSite.ID),未在本站下过单 u4 := model.User{Phone: "13800000004", Nickname: "用户四", DistributionSiteID: otherSite.ID, Status: "active", CreatedAt: now} if err := db.Create(&u1).Error; err != nil { t.Fatal(err) } if err := db.Create(&u2).Error; err != nil { t.Fatal(err) } if err := db.Create(&u3).Error; err != nil { t.Fatal(err) } if err := db.Create(&u4).Error; err != nil { t.Fatal(err) } // 订单 1: 用户 2 在本站下单 o1 := model.RentalOrder{ OrderNo: "ORD-U2", ListingID: 101, AccountID: 201, OwnerID: 99, RenterID: u2.ID, DistributionSiteID: &site.ID, RentAmountCent: 5000, Status: "completed", CreatedAt: now, } // 订单 2: 用户 3 在本站下单 o2 := model.RentalOrder{ OrderNo: "ORD-U3", ListingID: 102, AccountID: 202, OwnerID: 99, RenterID: u3.ID, DistributionSiteID: &site.ID, RentAmountCent: 3000, Status: "completed", CreatedAt: now, } // 订单 3: 用户 4 在其他站下单 o3 := model.RentalOrder{ OrderNo: "ORD-U4", ListingID: 103, AccountID: 203, OwnerID: 99, RenterID: u4.ID, DistributionSiteID: &otherSite.ID, RentAmountCent: 8000, Status: "completed", CreatedAt: now, } if err := db.Create(&o1).Error; err != nil { t.Fatal(err) } if err := db.Create(&o2).Error; err != nil { t.Fatal(err) } if err := db.Create(&o3).Error; err != nil { t.Fatal(err) } // 1. 查询全部用户 (应该包含 u1, u2, u3;不包含 u4) resAll, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Page: 1, PageSize: 10}) if err != nil { t.Fatalf("ListDistributionUsers all failed: %v", err) } if resAll.Total != 3 { t.Fatalf("expected 3 total users, got %d", resAll.Total) } if resAll.TotalUsers != 2 { // u1, u2 本站注册 t.Fatalf("expected 2 total registered users, got %d", resAll.TotalUsers) } if resAll.OrderUsers != 2 { // u2, u3 本站下单 t.Fatalf("expected 2 order users, got %d", resAll.OrderUsers) } // 验证手机号完整输出 for _, it := range resAll.Items { if len(it.Phone) != 11 { t.Fatalf("expected full 11-digit phone, got %s", it.Phone) } } // 2. 筛选 source=register resReg, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Source: "register", Page: 1, PageSize: 10}) if err != nil { t.Fatalf("ListDistributionUsers register failed: %v", err) } if resReg.Total != 2 { t.Fatalf("expected 2 register users, got %d", resReg.Total) } // 3. 筛选 source=order resOrd, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Source: "order", Page: 1, PageSize: 10}) if err != nil { t.Fatalf("ListDistributionUsers order failed: %v", err) } if resOrd.Total != 2 { t.Fatalf("expected 2 order users, got %d", resOrd.Total) } // 4. 关键字搜索手机号 resKw, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Keyword: "13800000001", Page: 1, PageSize: 10}) if err != nil { t.Fatalf("ListDistributionUsers kw failed: %v", err) } if resKw.Total != 1 || resKw.Items[0].ID != u1.ID { t.Fatalf("keyword search failed: %+v", resKw) } // 5. 验证大盘用户统计 analytics, err := repo.GetDashboardAnalytics(t.Context(), 0, site.ID) if err != nil { t.Fatalf("GetDashboardAnalytics failed: %v", err) } if analytics.TodayUserCount != 2 || analytics.TotalUserCount != 2 || analytics.TotalOrderUserCount != 2 { t.Fatalf("analytics user count mismatch: today=%d, total=%d, order=%d", analytics.TodayUserCount, analytics.TotalUserCount, analytics.TotalOrderUserCount) } } func TestListOrdersPerformanceAndScoping(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) site1 := model.DistributionSite{SiteName: "站1", SiteCode: "site1", Domain: "s1.test", Status: "active"} site2 := model.DistributionSite{SiteName: "站2", SiteCode: "site2", Domain: "s2.test", Status: "active"} if err := db.Create(&site1).Error; err != nil { t.Fatal(err) } if err := db.Create(&site2).Error; err != nil { t.Fatal(err) } u := model.User{Phone: "13900000001", Nickname: "买家"} if err := db.Create(&u).Error; err != nil { t.Fatal(err) } // 1. 普通非分销订单(主站自营,不属于任何分销) normalOrder := model.RentalOrder{ OrderNo: "RO_NORMAL_001", RenterID: u.ID, Status: "completed", DistributionSiteID: nil, DistributionMode: "", } if err := db.Create(&normalOrder).Error; err != nil { t.Fatal(err) } // 2. 分销站1的订单 site1ID := site1.ID distOrder1 := model.RentalOrder{ OrderNo: "RO_DIST_001", RenterID: u.ID, Status: "completed", DistributionSiteID: &site1ID, DistributionMode: "site_self_sell", } if err := db.Create(&distOrder1).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: distOrder1.ID, SiteID: site1.ID, PartyRole: "distributor_sell", PartyType: "site", PartyID: site1.ID, }).Error; err != nil { t.Fatal(err) } // 3. 分销站2作为收号方的跨站订单(下单站点为0或主站,但分销站2为参与方) distOrder2 := model.RentalOrder{ OrderNo: "RO_DIST_002", RenterID: u.ID, Status: "renting", DistributionSiteID: nil, DistributionMode: "two_party_distributor_receive", } if err := db.Create(&distOrder2).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: distOrder2.ID, SiteID: site2.ID, PartyRole: "distributor_receive", PartyType: "site", PartyID: site2.ID, }).Error; err != nil { t.Fatal(err) } // A. 超管查询全部分销订单 (SiteID = 0) // 预期:只查出 2 笔分销订单,普通主站订单 normalOrder 绝不能混入! allPage, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{ SiteID: 0, Page: 1, Size: 10, }) if err != nil { t.Fatalf("ListOrders SiteID=0 failed: %v", err) } if allPage.Total != 2 { t.Fatalf("expected 2 distribution orders for superadmin, got %d (normal order must be excluded)", allPage.Total) } if len(allPage.Items) != 2 { t.Fatalf("expected 2 items, got %d", len(allPage.Items)) } phonePage, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{ SiteID: 0, Page: 1, Size: 10, Keyword: u.Phone, }) if err != nil { t.Fatalf("ListOrders by phone failed: %v", err) } if phonePage.Total != 2 { t.Fatalf("expected 2 distribution orders by renter phone, got %d", phonePage.Total) } // B. 分销商1查询其订单 (SiteID = site1.ID) // 预期:只查出 distOrder1 site1Page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{ SiteID: site1.ID, Page: 1, Size: 10, }) if err != nil { t.Fatalf("ListOrders SiteID=1 failed: %v", err) } if site1Page.Total != 1 || site1Page.Items[0].OrderID != distOrder1.ID { t.Fatalf("expected order %d for site1, got total=%d items=%+v", distOrder1.ID, site1Page.Total, site1Page.Items) } // C. 分销商2查询其订单 (SiteID = site2.ID) // 预期:查出 distOrder2(通过参与方命中) site2Page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{ SiteID: site2.ID, Page: 1, Size: 10, }) if err != nil { t.Fatalf("ListOrders SiteID=2 failed: %v", err) } if site2Page.Total != 1 || site2Page.Items[0].OrderID != distOrder2.ID { t.Fatalf("expected order %d for site2, got total=%d items=%+v", distOrder2.ID, site2Page.Total, site2Page.Items) } // D. 按 Kind="sold" 过滤 soldPage, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{ SiteID: site1.ID, Kind: "sold", Page: 1, Size: 10, }) if err != nil { t.Fatalf("ListOrders Kind=sold failed: %v", err) } if soldPage.Total != 1 || soldPage.Items[0].OrderID != distOrder1.ID { t.Fatalf("expected 1 sold order for site1, got %d", soldPage.Total) } // E. 按 Kind="receive" 过滤 recvPage, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{ SiteID: site2.ID, Kind: "receive", Page: 1, Size: 10, }) if err != nil { t.Fatalf("ListOrders Kind=receive failed: %v", err) } if recvPage.Total != 1 || recvPage.Items[0].OrderID != distOrder2.ID { t.Fatalf("expected 1 receive order for site2, got %d", recvPage.Total) } }