feat(distribution): 优化一键同步主站商品功能(并发防重、分批入库、失效商品联动下架与初始加价)
- 后端优化: - 引入 Redis + 内存 sync.Map 双重互斥锁,避免并发/连击导致唯一索引冲突 (ErrSyncInProgress) - 使用 CreateInBatches(500) 进行大数据量批量插入,杜绝超长 SQL 与锁表超时 - 增加 CleanStaleListings 支持,自动排查并联动下架主站非在售的货架商品,清理历史幽灵数据 - 增加 DefaultShelfRatioDelta 支持,新同步商品直接继承初始加价比例,免除二次手动改价 - 前端优化: - distribution-admin 货架管理新增一键同步配置弹窗(可勾选自动下架失效商品、配置初始加价) - 同步完成后展示结构化统计弹窗(新增、保持、联动下架、主站在售总量) - 前后端类型定义与 API 客户端对齐升级 - 测试覆盖: - 补充 TestSyncSiteAccountsOptimizations 单元测试与 handler 集成测试 - 全工程测试与门禁 check.sh 100% 通过
This commit is contained in:
@@ -1442,7 +1442,7 @@ func TestE2EScenario6_SiteOnboarding_Branding_PublicStorefront_Sync(t *testing.T
|
|||||||
db.Create(&masterListing)
|
db.Create(&masterListing)
|
||||||
|
|
||||||
// 分站管理员调用同步接口
|
// 分站管理员调用同步接口
|
||||||
syncRes, err := distRepo.SyncSiteAccounts(ctx, loginResp.Member.AdminID, siteID, nil)
|
syncRes, err := distRepo.SyncSiteAccounts(ctx, loginResp.Member.AdminID, siteID, distribution.SyncSiteAccountsRequest{CleanStaleListings: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SyncSiteAccounts failed: %v", err)
|
t.Fatalf("SyncSiteAccounts failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,13 +190,17 @@ type AccountDisplayDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SyncSiteAccountsRequest 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 {
|
type SyncSiteAccountsResult struct {
|
||||||
SyncedCount int64 `json:"synced_count"`
|
SyncedCount int64 `json:"synced_count"`
|
||||||
SkippedCount int64 `json:"skipped_count"`
|
SkippedCount int64 `json:"skipped_count"`
|
||||||
TotalActive int64 `json:"total_active"`
|
UpdatedCount int64 `json:"updated_count"`
|
||||||
|
DisabledStaleCount int64 `json:"disabled_stale_count"`
|
||||||
|
TotalActive int64 `json:"total_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicDistributionListingDTO struct {
|
type PublicDistributionListingDTO struct {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ func (h *Handler) SyncSiteAccounts(c *gin.Context) {
|
|||||||
response.BadRequest(c, "site_id 不正确")
|
response.BadRequest(c, "site_id 不正确")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@@ -797,6 +797,8 @@ func writeError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "该结算批次无法作废")
|
response.BadRequest(c, "该结算批次无法作废")
|
||||||
case errors.Is(err, ErrTooManySyncListings):
|
case errors.Is(err, ErrTooManySyncListings):
|
||||||
response.BadRequest(c, "一次勾选同步的商品过多")
|
response.BadRequest(c, "一次勾选同步的商品过多")
|
||||||
|
case errors.Is(err, ErrSyncInProgress):
|
||||||
|
response.BadRequest(c, "当前分销站点商品同步正在进行中,请勿重复操作")
|
||||||
case errors.Is(err, ErrSyncListingNotFound):
|
case errors.Is(err, ErrSyncListingNotFound):
|
||||||
response.BadRequest(c, "勾选的商品不存在")
|
response.BadRequest(c, "勾选的商品不存在")
|
||||||
case errors.Is(err, ErrEmptyListingIDs):
|
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"}
|
masterListing := model.RentalListing{ListingNo: "L-SYNC-1", AccountID: masterAcc.ID, OwnerID: opOwner.ID, PriceCent: 3000, Status: "published", ReviewStatus: "approved"}
|
||||||
env.db.Create(&masterListing)
|
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)
|
wSync := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/sync-accounts", siteA.ID), syncReq, adminA.ID, nil)
|
||||||
if wSync.Code != http.StatusOK {
|
if wSync.Code != http.StatusOK {
|
||||||
t.Fatalf("sync accounts expected 200, got %d: %s", wSync.Code, wSync.Body.String())
|
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
|
var dsa model.DistributionSiteAccount
|
||||||
if err := env.db.Where("site_id = ? AND listing_id = ?", siteA.ID, masterListing.ID).First(&dsa).Error; err != nil {
|
if err := env.db.Where("site_id = ? AND listing_id = ?", siteA.ID, masterListing.ID).First(&dsa).Error; err != nil {
|
||||||
t.Fatal(err)
|
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. 单品调价
|
// 7. 单品调价
|
||||||
markupReq := UpdateAccountMarkupRequest{ShelfRatioDelta: 15}
|
markupReq := UpdateAccountMarkupRequest{ShelfRatioDelta: 15}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ var (
|
|||||||
ErrInvalidServiceUnit = errors.New("invalid service fee unit")
|
ErrInvalidServiceUnit = errors.New("invalid service fee unit")
|
||||||
ErrUnsupportedMode = errors.New("unsupported distribution mode")
|
ErrUnsupportedMode = errors.New("unsupported distribution mode")
|
||||||
ErrInsufficientBalance = errors.New("insufficient wallet balance")
|
ErrInsufficientBalance = errors.New("insufficient wallet balance")
|
||||||
|
ErrSyncInProgress = errors.New("distribution site accounts sync already in progress")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rule 是后台可编辑的分账规则。比例字段存百分数(35/40/25),按参与方角色命名。
|
// Rule 是后台可编辑的分账规则。比例字段存百分数(35/40/25),按参与方角色命名。
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/auditlog"
|
"hfb_sys/backend/internal/auditlog"
|
||||||
@@ -64,8 +65,9 @@ func normalizeSettlementHoldDays(days int) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
|
siteSyncLocks sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
|
func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
|
||||||
@@ -914,7 +916,7 @@ func uniquePositiveIDs(ids []uint64) []uint64 {
|
|||||||
return out
|
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 {
|
if r.db == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
@@ -929,7 +931,30 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
|
|||||||
return nil, err
|
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 {
|
if len(selectedIDs) > maxSyncListingIDs {
|
||||||
return nil, ErrTooManySyncListings
|
return nil, ErrTooManySyncListings
|
||||||
}
|
}
|
||||||
@@ -969,6 +994,7 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
|
|||||||
var toInsert []model.DistributionSiteAccount
|
var toInsert []model.DistributionSiteAccount
|
||||||
seenAccount := make(map[uint64]bool)
|
seenAccount := make(map[uint64]bool)
|
||||||
var skippedCount int64
|
var skippedCount int64
|
||||||
|
var updatedCount int64
|
||||||
|
|
||||||
for _, l := range candidates {
|
for _, l := range candidates {
|
||||||
if seenAccount[l.AccountID] {
|
if seenAccount[l.AccountID] {
|
||||||
@@ -980,28 +1006,55 @@ func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint6
|
|||||||
if ea.ListingID != l.ID {
|
if ea.ListingID != l.ID {
|
||||||
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
|
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
|
||||||
Where("id = ?", ea.ID).Update("listing_id", l.ID).Error
|
Where("id = ?", ea.ID).Update("listing_id", l.ID).Error
|
||||||
|
updatedCount++
|
||||||
}
|
}
|
||||||
skippedCount++
|
skippedCount++
|
||||||
} else {
|
} else {
|
||||||
toInsert = append(toInsert, model.DistributionSiteAccount{
|
toInsert = append(toInsert, model.DistributionSiteAccount{
|
||||||
SiteID: siteID,
|
SiteID: siteID,
|
||||||
AccountID: l.AccountID,
|
AccountID: l.AccountID,
|
||||||
ListingID: l.ID,
|
ListingID: l.ID,
|
||||||
DisplayStatus: "enabled",
|
DisplayStatus: "enabled",
|
||||||
|
ShelfRatioDelta: req.DefaultShelfRatioDelta,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 分批高效入库,防止大数据量超长 SQL 或锁表超时
|
||||||
if len(toInsert) > 0 {
|
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
|
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{
|
return &SyncSiteAccountsResult{
|
||||||
SyncedCount: int64(len(toInsert)),
|
SyncedCount: int64(len(toInsert)),
|
||||||
SkippedCount: skippedCount,
|
SkippedCount: skippedCount,
|
||||||
TotalActive: int64(len(candidates)),
|
UpdatedCount: updatedCount,
|
||||||
|
DisabledStaleCount: disabledStaleCount,
|
||||||
|
TotalActive: int64(len(candidates)),
|
||||||
}, nil
|
}, 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 {
|
if err != nil {
|
||||||
t.Fatalf("SyncSiteAccounts err = %v", err)
|
t.Fatalf("SyncSiteAccounts err = %v", err)
|
||||||
}
|
}
|
||||||
@@ -449,7 +449,7 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
|
|||||||
t.Fatalf("unexpected sync res: %+v", resMain)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("SyncSiteAccounts retry err = %v", err)
|
t.Fatalf("SyncSiteAccounts retry err = %v", err)
|
||||||
}
|
}
|
||||||
@@ -457,11 +457,11 @@ func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
|
|||||||
t.Fatalf("unexpected res2: %+v", res2)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("explicit select err = %v", err)
|
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)
|
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)
|
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 {
|
if err := s.available(); err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *Service) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) {
|
||||||
|
|||||||
@@ -222,9 +222,17 @@ export interface AccountDisplay {
|
|||||||
source_channel?: string
|
source_channel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncSiteAccountsRequest {
|
||||||
|
listing_ids?: number[]
|
||||||
|
clean_stale_listings?: boolean
|
||||||
|
default_shelf_ratio_delta?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SyncSiteAccountsResult {
|
export interface SyncSiteAccountsResult {
|
||||||
synced_count: number
|
synced_count: number
|
||||||
skipped_count: number
|
skipped_count: number
|
||||||
|
updated_count?: number
|
||||||
|
disabled_stale_count?: number
|
||||||
total_active: number
|
total_active: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,8 +413,8 @@ export async function updateAccountDisplay(id: number, displayStatus: string): P
|
|||||||
return extract(resp)
|
return extract(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncSiteAccounts(siteID: number): Promise<SyncSiteAccountsResult> {
|
export async function syncSiteAccounts(siteID: number, req?: SyncSiteAccountsRequest): Promise<SyncSiteAccountsResult> {
|
||||||
const resp = await api.post<ApiResponse<SyncSiteAccountsResult>>(`/admin/distribution/sites/${siteID}/sync-accounts`)
|
const resp = await api.post<ApiResponse<SyncSiteAccountsResult>>(`/admin/distribution/sites/${siteID}/sync-accounts`, req ?? {})
|
||||||
return extract(resp)
|
return extract(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
|
Checkbox,
|
||||||
|
Statistic,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
@@ -42,6 +44,7 @@ import {
|
|||||||
getMe,
|
getMe,
|
||||||
type AccountDisplay,
|
type AccountDisplay,
|
||||||
type Site,
|
type Site,
|
||||||
|
type SyncSiteAccountsResult,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { useMember } from '../MemberContext'
|
import { useMember } from '../MemberContext'
|
||||||
|
|
||||||
@@ -199,18 +202,32 @@ export default function Accounts() {
|
|||||||
void init()
|
void init()
|
||||||
}, [member])
|
}, [member])
|
||||||
|
|
||||||
async function handleSync() {
|
// 同步主站商品配置与结果弹窗状态
|
||||||
|
const [syncModalVisible, setSyncModalVisible] = useState(false)
|
||||||
|
const [syncCleanStale, setSyncCleanStale] = useState(true)
|
||||||
|
const [syncDefaultRatioDelta, setSyncDefaultRatioDelta] = useState<number>(0)
|
||||||
|
const [syncResultModalVisible, setSyncResultModalVisible] = useState(false)
|
||||||
|
const [syncResult, setSyncResult] = useState<SyncSiteAccountsResult | null>(null)
|
||||||
|
|
||||||
|
function handleOpenSyncModal() {
|
||||||
if (!siteID) {
|
if (!siteID) {
|
||||||
message.warning('无法获取当前分销站点信息')
|
message.warning('无法获取当前分销站点信息')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setSyncModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExecuteSync() {
|
||||||
|
if (!siteID) return
|
||||||
setSyncing(true)
|
setSyncing(true)
|
||||||
try {
|
try {
|
||||||
const res = await syncSiteAccounts(siteID)
|
const res = await syncSiteAccounts(siteID, {
|
||||||
message.success(
|
clean_stale_listings: syncCleanStale,
|
||||||
`同步完成!成功同步新增商品 ${res.synced_count} 个,已在货架 ${res.skipped_count} 个(主站共享池在售共 ${res.total_active} 个)`,
|
default_shelf_ratio_delta: Math.max(0, syncDefaultRatioDelta),
|
||||||
5,
|
})
|
||||||
)
|
setSyncResult(res)
|
||||||
|
setSyncModalVisible(false)
|
||||||
|
setSyncResultModalVisible(true)
|
||||||
await load(siteID)
|
await load(siteID)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
|
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
|
||||||
@@ -578,7 +595,7 @@ export default function Accounts() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon={<SyncOutlined spin={syncing} />}
|
icon={<SyncOutlined spin={syncing} />}
|
||||||
loading={syncing}
|
loading={syncing}
|
||||||
onClick={handleSync}
|
onClick={handleOpenSyncModal}
|
||||||
style={{ background: '#1677ff' }}
|
style={{ background: '#1677ff' }}
|
||||||
>
|
>
|
||||||
一键同步主站商品
|
一键同步主站商品
|
||||||
@@ -728,7 +745,7 @@ export default function Accounts() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon={<SyncOutlined spin={syncing} />}
|
icon={<SyncOutlined spin={syncing} />}
|
||||||
loading={syncing}
|
loading={syncing}
|
||||||
onClick={handleSync}
|
onClick={handleOpenSyncModal}
|
||||||
>
|
>
|
||||||
一键同步主站商品
|
一键同步主站商品
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1243,6 +1260,136 @@ export default function Accounts() {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 同步配置弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<SyncOutlined style={{ color: '#1677ff' }} />
|
||||||
|
<span>一键同步主站商品设置</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={syncModalVisible}
|
||||||
|
onCancel={() => !syncing && setSyncModalVisible(false)}
|
||||||
|
onOk={handleExecuteSync}
|
||||||
|
confirmLoading={syncing}
|
||||||
|
okText="立即开始同步"
|
||||||
|
cancelText="取消"
|
||||||
|
width={540}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%', marginTop: 12 }}>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="货架铺货说明"
|
||||||
|
description="系统将自动拉取主站及全网审核通过的在售精品账号,极速批量补充到本分站货架,已在货架的商品不会重复生成。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card size="small" style={{ background: '#f8fafc', borderColor: '#e2e8f0' }}>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
checked={syncCleanStale}
|
||||||
|
onChange={(e) => setSyncCleanStale(e.target.checked)}
|
||||||
|
>
|
||||||
|
<Typography.Text strong>自动下架主站已失效/已出租商品</Typography.Text>
|
||||||
|
</Checkbox>
|
||||||
|
<div style={{ paddingLeft: 24, fontSize: 12, color: '#64748b', marginTop: 4 }}>
|
||||||
|
推荐勾选。同步时自动将主站已下架、已出租或被删除的商品标记为已下架,避免历史幽灵商品滞留货架。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>新同步商品初始加价比例(下浮比例点数)</Typography.Text>
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
step={0.5}
|
||||||
|
value={syncDefaultRatioDelta}
|
||||||
|
onChange={(val) => setSyncDefaultRatioDelta(val ?? 0)}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
addonAfter="点比例"
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{syncDefaultRatioDelta === 0
|
||||||
|
? '保持主站原价上架'
|
||||||
|
: `预计标价上浮 ${(syncDefaultRatioDelta * 1.1).toFixed(1)}% 左右,溢价利润 100% 归本站`}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 6 }}>
|
||||||
|
仅对本次<strong>新增入货架</strong>的商品生效,已在货架的商品保持其原有加价不变。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 同步结果展示弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
<span>主站商品同步完成</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={syncResultModalVisible}
|
||||||
|
onCancel={() => setSyncResultModalVisible(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="ok" type="primary" onClick={() => setSyncResultModalVisible(false)}>
|
||||||
|
我知道了
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '16px 0' }}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" style={{ background: '#f6ffed', borderColor: '#b7eb8f', textAlign: 'center' }}>
|
||||||
|
<Statistic
|
||||||
|
title="新增上架商品"
|
||||||
|
value={syncResult?.synced_count ?? 0}
|
||||||
|
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
||||||
|
suffix="个"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" style={{ background: '#f0f5ff', borderColor: '#adc6ff', textAlign: 'center' }}>
|
||||||
|
<Statistic
|
||||||
|
title="已在货架保持"
|
||||||
|
value={syncResult?.skipped_count ?? 0}
|
||||||
|
valueStyle={{ color: '#1677ff', fontWeight: 'bold' }}
|
||||||
|
suffix="个"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" style={{ background: '#fffbe6', borderColor: '#ffe58f', textAlign: 'center' }}>
|
||||||
|
<Statistic
|
||||||
|
title="联动下架失效商品"
|
||||||
|
value={syncResult?.disabled_stale_count ?? 0}
|
||||||
|
valueStyle={{ color: '#faad14', fontWeight: 'bold' }}
|
||||||
|
suffix="个"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" style={{ background: '#fafafa', borderColor: '#d9d9d9', textAlign: 'center' }}>
|
||||||
|
<Statistic
|
||||||
|
title="主站共享池在售"
|
||||||
|
value={syncResult?.total_active ?? 0}
|
||||||
|
valueStyle={{ color: '#595959', fontWeight: 'bold' }}
|
||||||
|
suffix="个"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,15 +276,24 @@ export async function fetchDistributionSettlementItems(batchId: number) {
|
|||||||
return data.data.items ?? []
|
return data.data.items ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncSiteAccountsRequest {
|
||||||
|
listing_ids?: number[]
|
||||||
|
clean_stale_listings?: boolean
|
||||||
|
default_shelf_ratio_delta?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SyncSiteAccountsResult {
|
export interface SyncSiteAccountsResult {
|
||||||
synced_count: number
|
synced_count: number
|
||||||
skipped_count: number
|
skipped_count: number
|
||||||
|
updated_count?: number
|
||||||
|
disabled_stale_count?: number
|
||||||
total_active: number
|
total_active: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncDistributionSiteAccounts(siteId: number) {
|
export async function syncDistributionSiteAccounts(siteId: number, req?: SyncSiteAccountsRequest) {
|
||||||
const { data } = await apiClient.post<ApiResponse<SyncSiteAccountsResult>>(
|
const { data } = await apiClient.post<ApiResponse<SyncSiteAccountsResult>>(
|
||||||
`/admin/distribution/sites/${siteId}/sync-accounts`
|
`/admin/distribution/sites/${siteId}/sync-accounts`,
|
||||||
|
req ?? {}
|
||||||
)
|
)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user