分站前台原本对顾客暴露分销身份,且登录等页面写死了主站品牌, 与「一手独立商城」的定位不符;同时首页文案全部硬编码,站长无法按自己口径调整。 去分销身份(顾客可见处): - 头部品牌区、首页主视觉、移动端认证卡去掉「官方分销/官方分销认证/官方分销站」标识 - 会员徽标「分销专享」→「会员专享」,「所属商城」兜底「专属分销站/商城」→「官方商城」 - 订单页说明改为「当前为【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 单测(默认合并、覆盖、空白与非法类型回落)
1521 lines
58 KiB
Go
1521 lines
58 KiB
Go
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)
|
||
}
|
||
}
|