feat(distribution): 优化一键同步主站商品功能(并发防重、分批入库、失效商品联动下架与初始加价)

- 后端优化:
  - 引入 Redis + 内存 sync.Map 双重互斥锁,避免并发/连击导致唯一索引冲突 (ErrSyncInProgress)
  - 使用 CreateInBatches(500) 进行大数据量批量插入,杜绝超长 SQL 与锁表超时
  - 增加 CleanStaleListings 支持,自动排查并联动下架主站非在售的货架商品,清理历史幽灵数据
  - 增加 DefaultShelfRatioDelta 支持,新同步商品直接继承初始加价比例,免除二次手动改价
- 前端优化:
  - distribution-admin 货架管理新增一键同步配置弹窗(可勾选自动下架失效商品、配置初始加价)
  - 同步完成后展示结构化统计弹窗(新增、保持、联动下架、主站在售总量)
  - 前后端类型定义与 API 客户端对齐升级
- 测试覆盖:
  - 补充 TestSyncSiteAccountsOptimizations 单元测试与 handler 集成测试
  - 全工程测试与门禁 check.sh 100% 通过
This commit is contained in:
yml2213
2026-09-11 12:47:59 +08:00
parent ef91b24bf2
commit 9ae6d5eaea
11 changed files with 363 additions and 38 deletions
+8 -4
View File
@@ -190,13 +190,17 @@ type AccountDisplayDTO struct {
}
type SyncSiteAccountsRequest struct {
ListingIDs []uint64 `json:"listing_ids"`
ListingIDs []uint64 `json:"listing_ids"`
CleanStaleListings bool `json:"clean_stale_listings"`
DefaultShelfRatioDelta float64 `json:"default_shelf_ratio_delta"`
}
type SyncSiteAccountsResult struct {
SyncedCount int64 `json:"synced_count"`
SkippedCount int64 `json:"skipped_count"`
TotalActive int64 `json:"total_active"`
SyncedCount int64 `json:"synced_count"`
SkippedCount int64 `json:"skipped_count"`
UpdatedCount int64 `json:"updated_count"`
DisabledStaleCount int64 `json:"disabled_stale_count"`
TotalActive int64 `json:"total_active"`
}
type PublicDistributionListingDTO struct {
@@ -273,7 +273,7 @@ func (h *Handler) SyncSiteAccounts(c *gin.Context) {
response.BadRequest(c, "site_id 不正确")
return
}
result, err := h.service.SyncSiteAccounts(c.Request.Context(), adminID, scoped, req.ListingIDs)
result, err := h.service.SyncSiteAccounts(c.Request.Context(), adminID, scoped, req)
if err != nil {
writeError(c, err)
return
@@ -797,6 +797,8 @@ func writeError(c *gin.Context, err error) {
response.BadRequest(c, "该结算批次无法作废")
case errors.Is(err, ErrTooManySyncListings):
response.BadRequest(c, "一次勾选同步的商品过多")
case errors.Is(err, ErrSyncInProgress):
response.BadRequest(c, "当前分销站点商品同步正在进行中,请勿重复操作")
case errors.Is(err, ErrSyncListingNotFound):
response.BadRequest(c, "勾选的商品不存在")
case errors.Is(err, ErrEmptyListingIDs):
@@ -411,17 +411,40 @@ func TestHandler_SiteOperatorEndpoints(t *testing.T) {
masterListing := model.RentalListing{ListingNo: "L-SYNC-1", AccountID: masterAcc.ID, OwnerID: opOwner.ID, PriceCent: 3000, Status: "published", ReviewStatus: "approved"}
env.db.Create(&masterListing)
syncReq := SyncSiteAccountsRequest{ListingIDs: []uint64{masterListing.ID}}
syncReq := SyncSiteAccountsRequest{
ListingIDs: []uint64{masterListing.ID},
CleanStaleListings: true,
DefaultShelfRatioDelta: 12.5,
}
wSync := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/sync-accounts", siteA.ID), syncReq, adminA.ID, nil)
if wSync.Code != http.StatusOK {
t.Fatalf("sync accounts expected 200, got %d: %s", wSync.Code, wSync.Body.String())
}
var syncResp struct {
Code string `json:"code"`
Data SyncSiteAccountsResult `json:"data"`
}
json.Unmarshal(wSync.Body.Bytes(), &syncResp)
if syncResp.Data.SyncedCount != 1 {
t.Fatalf("expected 1 synced, got %+v", syncResp.Data)
}
// 获取生成的 DSA 记录
// 获取生成的 DSA 记录并验证默认加价生效
var dsa model.DistributionSiteAccount
if err := env.db.Where("site_id = ? AND listing_id = ?", siteA.ID, masterListing.ID).First(&dsa).Error; err != nil {
t.Fatal(err)
}
if dsa.ShelfRatioDelta != 12.5 {
t.Fatalf("expected shelf_ratio_delta=12.5, got %v", dsa.ShelfRatioDelta)
}
// 验证负数加价比例报错 400
wNegativeSync := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/sync-accounts", siteA.ID), SyncSiteAccountsRequest{
DefaultShelfRatioDelta: -10,
}, adminA.ID, nil)
if wNegativeSync.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for negative delta, got %d", wNegativeSync.Code)
}
// 7. 单品调价
markupReq := UpdateAccountMarkupRequest{ShelfRatioDelta: 15}
@@ -30,6 +30,7 @@ var (
ErrInvalidServiceUnit = errors.New("invalid service fee unit")
ErrUnsupportedMode = errors.New("unsupported distribution mode")
ErrInsufficientBalance = errors.New("insufficient wallet balance")
ErrSyncInProgress = errors.New("distribution site accounts sync already in progress")
)
// Rule 是后台可编辑的分账规则。比例字段存百分数(35/40/25),按参与方角色命名。
@@ -9,6 +9,7 @@ import (
"fmt"
"slices"
"strings"
"sync"
"time"
"hfb_sys/backend/internal/auditlog"
@@ -64,8 +65,9 @@ func normalizeSettlementHoldDays(days int) (int, error) {
}
type Repository struct {
db *gorm.DB
redis *redis.Client
db *gorm.DB
redis *redis.Client
siteSyncLocks sync.Map
}
func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
@@ -914,7 +916,7 @@ func uniquePositiveIDs(ids []uint64) []uint64 {
return out
}
func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64, listingIDs []uint64) (*SyncSiteAccountsResult, error) {
func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64, req SyncSiteAccountsRequest) (*SyncSiteAccountsResult, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
}
@@ -929,7 +931,30 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
return nil, err
}
selectedIDs := uniquePositiveIDs(listingIDs)
if req.DefaultShelfRatioDelta < 0 {
return nil, ErrInvalidAmount
}
// 1. 防重复并发锁(Redis 分布式锁 + 内存 sync.Map 双重互斥保障)
lockKey := fmt.Sprintf("dist:sync_accounts:%d", siteID)
if r.redis != nil {
ok, err := r.redis.SetNX(ctx, lockKey, 1, 30*time.Second).Result()
if err == nil {
if !ok {
return nil, ErrSyncInProgress
}
defer func() {
_ = r.redis.Del(context.Background(), lockKey).Err()
}()
}
}
_, loaded := r.siteSyncLocks.LoadOrStore(siteID, struct{}{})
if loaded {
return nil, ErrSyncInProgress
}
defer r.siteSyncLocks.Delete(siteID)
selectedIDs := uniquePositiveIDs(req.ListingIDs)
if len(selectedIDs) > maxSyncListingIDs {
return nil, ErrTooManySyncListings
}
@@ -969,6 +994,7 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
var toInsert []model.DistributionSiteAccount
seenAccount := make(map[uint64]bool)
var skippedCount int64
var updatedCount int64
for _, l := range candidates {
if seenAccount[l.AccountID] {
@@ -980,28 +1006,55 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
if ea.ListingID != l.ID {
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
Where("id = ?", ea.ID).Update("listing_id", l.ID).Error
updatedCount++
}
skippedCount++
} else {
toInsert = append(toInsert, model.DistributionSiteAccount{
SiteID: siteID,
AccountID: l.AccountID,
ListingID: l.ID,
DisplayStatus: "enabled",
SiteID: siteID,
AccountID: l.AccountID,
ListingID: l.ID,
DisplayStatus: "enabled",
ShelfRatioDelta: req.DefaultShelfRatioDelta,
})
}
}
// 2. 分批高效入库,防止大数据量超长 SQL 或锁表超时
if len(toInsert) > 0 {
if err := r.db.WithContext(ctx).Create(&toInsert).Error; err != nil {
if err := r.db.WithContext(ctx).CreateInBatches(&toInsert, 500).Error; err != nil {
return nil, err
}
}
// 3. 联动下架主站非在售/已失效的货架商品(若开启 CleanStaleListings)
var disabledStaleCount int64
if req.CleanStaleListings {
var staleIDs []uint64
err := r.db.WithContext(ctx).Table("distribution_site_accounts AS dsa").
Joins("LEFT JOIN rental_listings AS l ON l.id = dsa.listing_id AND l.status = 'published' AND l.review_status = 'approved'").
Where("dsa.site_id = ? AND dsa.display_status = 'enabled' AND l.id IS NULL", siteID).
Pluck("dsa.id", &staleIDs).Error
if err == nil && len(staleIDs) > 0 {
for i := 0; i < len(staleIDs); i += 500 {
end := i + 500
if end > len(staleIDs) {
end = len(staleIDs)
}
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
Where("id IN ?", staleIDs[i:end]).
Update("display_status", "disabled").Error
}
disabledStaleCount = int64(len(staleIDs))
}
}
return &SyncSiteAccountsResult{
SyncedCount: int64(len(toInsert)),
SkippedCount: skippedCount,
TotalActive: int64(len(candidates)),
SyncedCount: int64(len(toInsert)),
SkippedCount: skippedCount,
UpdatedCount: updatedCount,
DisabledStaleCount: disabledStaleCount,
TotalActive: int64(len(candidates)),
}, nil
}
@@ -441,7 +441,7 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
}
// 默认同步全网合规商品(包含主站商品与本站上传货)
resMain, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, nil)
resMain, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{})
if err != nil {
t.Fatalf("SyncSiteAccounts err = %v", err)
}
@@ -449,7 +449,7 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
t.Fatalf("unexpected sync res: %+v", resMain)
}
res2, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, nil)
res2, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{})
if err != nil {
t.Fatalf("SyncSiteAccounts retry err = %v", err)
}
@@ -457,11 +457,11 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
t.Fatalf("unexpected res2: %+v", res2)
}
if _, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, []uint64{999999}); !errors.Is(err, ErrSyncListingNotFound) {
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, []uint64{mainListing.ID})
resSelect, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{ListingIDs: []uint64{mainListing.ID}})
if err != nil {
t.Fatalf("explicit select err = %v", err)
}
@@ -1209,3 +1209,81 @@ func TestGetSiteWalletPendingReleaseExcludesOpenDispute(t *testing.T) {
t.Fatalf("pending release with open dispute = %d, want 0", walletDTO.PendingReleaseCent)
}
}
func TestSyncSiteAccountsOptimizations(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site, err := repo.CreateSite(t.Context(), CreateSiteRequest{
SiteCode: "sync_opt_site",
SiteName: "同步优化分站",
Domain: "sync-opt.hfb.local",
})
if err != nil {
t.Fatal(err)
}
acc1 := model.GameAccount{Title: "商品1", GameName: "cfhd", ServerRegion: "东部", HafCoinAmount: 50_000_000, Status: "active"}
acc2 := model.GameAccount{Title: "商品2", GameName: "cfhd", ServerRegion: "北部", HafCoinAmount: 60_000_000, Status: "active"}
db.Create(&acc1)
db.Create(&acc2)
l1 := model.RentalListing{ListingNo: "L-SYNC-OPT-1", AccountID: acc1.ID, OwnerID: 1, PriceCent: 3000, Status: "published", ReviewStatus: "approved"}
l2 := model.RentalListing{ListingNo: "L-SYNC-OPT-2", AccountID: acc2.ID, OwnerID: 1, PriceCent: 4000, Status: "published", ReviewStatus: "approved"}
db.Create(&l1)
db.Create(&l2)
// 1. 验证负数加价比例校验
if _, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{DefaultShelfRatioDelta: -5}); !errors.Is(err, ErrInvalidAmount) {
t.Fatalf("expected ErrInvalidAmount for negative delta, got: %v", err)
}
// 2. 同步且设置初始加价 DefaultShelfRatioDelta = 15.5
res, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{
DefaultShelfRatioDelta: 15.5,
CleanStaleListings: true,
})
if err != nil {
t.Fatalf("SyncSiteAccounts failed: %v", err)
}
if res.SyncedCount != 2 || res.TotalActive != 2 || res.DisabledStaleCount != 0 {
t.Fatalf("unexpected res: %+v", res)
}
// 验证货架商品是否正确写入初始加价
var dsa1 model.DistributionSiteAccount
if err := db.Where("site_id = ? AND listing_id = ?", site.ID, l1.ID).First(&dsa1).Error; err != nil {
t.Fatal(err)
}
if dsa1.ShelfRatioDelta != 15.5 || dsa1.DisplayStatus != "enabled" {
t.Fatalf("dsa1 mismatch: ratio=%v, status=%s", dsa1.ShelfRatioDelta, dsa1.DisplayStatus)
}
// 3. 验证主站商品下架联动(CleanStaleListings = true)
db.Model(&l1).Update("status", "offline")
resClean, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{
CleanStaleListings: true,
})
if err != nil {
t.Fatalf("SyncSiteAccounts clean failed: %v", err)
}
if resClean.DisabledStaleCount != 1 {
t.Fatalf("expected 1 disabled stale listing, got %d", resClean.DisabledStaleCount)
}
// 验证 dsa1 已被标记为 disabled
var dsa1After model.DistributionSiteAccount
db.First(&dsa1After, dsa1.ID)
if dsa1After.DisplayStatus != "disabled" {
t.Fatalf("expected dsa1 disabled, got: %s", dsa1After.DisplayStatus)
}
// 4. 验证并发互斥锁(ErrSyncInProgress)
repo.siteSyncLocks.Store(site.ID, struct{}{})
_, err = repo.SyncSiteAccounts(t.Context(), 0, site.ID, SyncSiteAccountsRequest{})
if !errors.Is(err, ErrSyncInProgress) {
t.Fatalf("expected ErrSyncInProgress, got %v", err)
}
repo.siteSyncLocks.Delete(site.ID)
}
@@ -233,11 +233,11 @@ func (s *Service) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*Onb
return s.repo.OnboardSite(ctx, req)
}
func (s *Service) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64, listingIDs []uint64) (*SyncSiteAccountsResult, error) {
func (s *Service) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64, req SyncSiteAccountsRequest) (*SyncSiteAccountsResult, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.SyncSiteAccounts(ctx, adminID, siteID, listingIDs)
return s.repo.SyncSiteAccounts(ctx, adminID, siteID, req)
}
func (s *Service) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) {