Files
hfb_sys/backend/internal/modules/distribution/repository_test.go
T
yml2213 329d0f75bb feat(distribution): 分站前台按独立商城呈现并支持首页文案自定义
分站前台原本对顾客暴露分销身份,且登录等页面写死了主站品牌,
与「一手独立商城」的定位不符;同时首页文案全部硬编码,站长无法按自己口径调整。

去分销身份(顾客可见处):
- 头部品牌区、首页主视觉、移动端认证卡去掉「官方分销/官方分销认证/官方分销站」标识
- 会员徽标「分销专享」→「会员专享」,「所属商城」兜底「专属分销站/商城」→「官方商城」
- 订单页说明改为「当前为【X】商城,仅展示在本站产生的订单记录」,保留订单按站隔离的必要说明
- 无公告时的兜底跑马灯、轮播角标去掉分销措辞
- 清理随之失效的 pc-brand-badge / mobile-brand-badge 样式

品牌跟随站点配置(即可自定义):
- PC/移动登录页、移动注册页、加桌面引导页的品牌改为 站点名称 + Logo(无 Logo 取站名首字),
  不再写死主站「大锤商行」;主站行为不变(新增 store getter brandMark:分站取站名首字,主站仍为「锤」)

首页文案可配置:
- 新增迁移 000076 与 distribution_sites.storefront_text_json
  (tagline 头部副标题 / hero_badge 主视觉徽标 / hero_desc 主视觉描述 / card_sub 移动端卡片副标题)
- 分销后台「品牌装修 → 首页文案」可编辑,占位提示即默认值,留空或整段清空回落内置默认
- 贯通 UpdateSiteBranding → 公开站点信息接口(含 Redis 缓存失效)→ 前台 store 逐字段合并默认值
- 装修预览同步展示站长填写的副标题与徽标

测试:
- 后端补装修字段写入/公开接口透出/清空回落断言
- 前端新增 storefrontText 单测(默认合并、覆盖、空白与非法类型回落)
2026-10-01 09:24:56 +08:00

1521 lines
58 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"}`
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)
}
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)
}
}