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
@@ -1442,7 +1442,7 @@ func TestE2EScenario6_SiteOnboarding_Branding_PublicStorefront_Sync(t *testing.T
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 {
t.Fatalf("SyncSiteAccounts failed: %v", err)
}
+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) {
+10 -2
View File
@@ -222,9 +222,17 @@ export interface AccountDisplay {
source_channel?: string
}
export interface SyncSiteAccountsRequest {
listing_ids?: number[]
clean_stale_listings?: boolean
default_shelf_ratio_delta?: number
}
export interface SyncSiteAccountsResult {
synced_count: number
skipped_count: number
updated_count?: number
disabled_stale_count?: number
total_active: number
}
@@ -405,8 +413,8 @@ export async function updateAccountDisplay(id: number, displayStatus: string): P
return extract(resp)
}
export async function syncSiteAccounts(siteID: number): Promise<SyncSiteAccountsResult> {
const resp = await api.post<ApiResponse<SyncSiteAccountsResult>>(`/admin/distribution/sites/${siteID}/sync-accounts`)
export async function syncSiteAccounts(siteID: number, req?: SyncSiteAccountsRequest): Promise<SyncSiteAccountsResult> {
const resp = await api.post<ApiResponse<SyncSiteAccountsResult>>(`/admin/distribution/sites/${siteID}/sync-accounts`, req ?? {})
return extract(resp)
}
+155 -8
View File
@@ -18,6 +18,8 @@ import {
Radio,
Row,
Col,
Checkbox,
Statistic,
} from 'antd'
import {
SyncOutlined,
@@ -42,6 +44,7 @@ import {
getMe,
type AccountDisplay,
type Site,
type SyncSiteAccountsResult,
} from '../api'
import { useMember } from '../MemberContext'
@@ -199,18 +202,32 @@ export default function Accounts() {
void init()
}, [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) {
message.warning('无法获取当前分销站点信息')
return
}
setSyncModalVisible(true)
}
async function handleExecuteSync() {
if (!siteID) return
setSyncing(true)
try {
const res = await syncSiteAccounts(siteID)
message.success(
`同步完成!成功同步新增商品 ${res.synced_count} 个,已在货架 ${res.skipped_count} 个(主站共享池在售共 ${res.total_active} 个)`,
5,
)
const res = await syncSiteAccounts(siteID, {
clean_stale_listings: syncCleanStale,
default_shelf_ratio_delta: Math.max(0, syncDefaultRatioDelta),
})
setSyncResult(res)
setSyncModalVisible(false)
setSyncResultModalVisible(true)
await load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
@@ -578,7 +595,7 @@ export default function Accounts() {
type="primary"
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSync}
onClick={handleOpenSyncModal}
style={{ background: '#1677ff' }}
>
一键同步主站商品
@@ -728,7 +745,7 @@ export default function Accounts() {
type="primary"
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSync}
onClick={handleOpenSyncModal}
>
一键同步主站商品
</Button>
@@ -1243,6 +1260,136 @@ export default function Accounts() {
)}
</Space>
</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>
)
}
@@ -276,15 +276,24 @@ export async function fetchDistributionSettlementItems(batchId: number) {
return data.data.items ?? []
}
export interface SyncSiteAccountsRequest {
listing_ids?: number[]
clean_stale_listings?: boolean
default_shelf_ratio_delta?: number
}
export interface SyncSiteAccountsResult {
synced_count: number
skipped_count: number
updated_count?: number
disabled_stale_count?: 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>>(
`/admin/distribution/sites/${siteId}/sync-accounts`
`/admin/distribution/sites/${siteId}/sync-accounts`,
req ?? {}
)
return data.data
}