Files
hfb_sys/backend/internal/modules/distribution/repository_test.go
T
yml2213 8521bf4753 fix(distribution): 修正分销模式判定与结算入账,冻结未确认分成
按买卖站点纯函数判定五种模式,分站卖主站货命中 two_party_platform_receive。
结算批次只收本站 site party,平台分成不再进入分销钱包;生产环境冻结结算入账与站点提现。
2026-09-08 16:52:09 +08:00

840 lines
31 KiB
Go

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