Files
hfb_sys/backend/internal/modules/distribution/repository_test.go
T
yml2213andCursor c9d9160ff4 feat(distribution): 分站可下调收号比例且不超过主站上限
让分销站能按自身能力下调收号比例扩大利润,同时把主站回收比例作为硬边界,避免分站把比例加得比主站更高。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 20:35:30 +08:00

1432 lines
54 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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 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)
}
}
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"}`
updated, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{
LogoURL: &logo,
FaviconURL: &favicon,
BannersJSON: &banners,
AnnouncementsJSON: &announcements,
FooterInfoJSON: &footer,
})
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)
}
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)
}
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. 验证主站商品下架联动(CleanStaleListings = true)
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 != 1 {
t.Fatalf("expected 1 disabled stale listing, got %d", resClean.DisabledStaleCount)
}
// 验证 dsa1 已被标记为 disabled
var dsa1After model.DistributionSiteAccount
db.First(&dsa1After, dsa1.ID)
if dsa1After.DisplayStatus != "disabled" {
t.Fatalf("expected dsa1 disabled, got: %s", dsa1After.DisplayStatus)
}
// 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)
}
}