From 9ae6d5eaeaff7354d0656a78726bc8c5b7109476 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 11 Sep 2026 12:47:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=90=8C=E6=AD=A5=E4=B8=BB=E7=AB=99=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=8A=9F=E8=83=BD=EF=BC=88=E5=B9=B6=E5=8F=91=E9=98=B2?= =?UTF-8?q?=E9=87=8D=E3=80=81=E5=88=86=E6=89=B9=E5=85=A5=E5=BA=93=E3=80=81?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E5=95=86=E5=93=81=E8=81=94=E5=8A=A8=E4=B8=8B?= =?UTF-8?q?=E6=9E=B6=E4=B8=8E=E5=88=9D=E5=A7=8B=E5=8A=A0=E4=BB=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端优化: - 引入 Redis + 内存 sync.Map 双重互斥锁,避免并发/连击导致唯一索引冲突 (ErrSyncInProgress) - 使用 CreateInBatches(500) 进行大数据量批量插入,杜绝超长 SQL 与锁表超时 - 增加 CleanStaleListings 支持,自动排查并联动下架主站非在售的货架商品,清理历史幽灵数据 - 增加 DefaultShelfRatioDelta 支持,新同步商品直接继承初始加价比例,免除二次手动改价 - 前端优化: - distribution-admin 货架管理新增一键同步配置弹窗(可勾选自动下架失效商品、配置初始加价) - 同步完成后展示结构化统计弹窗(新增、保持、联动下架、主站在售总量) - 前后端类型定义与 API 客户端对齐升级 - 测试覆盖: - 补充 TestSyncSiteAccountsOptimizations 单元测试与 handler 集成测试 - 全工程测试与门禁 check.sh 100% 通过 --- .../internal/e2e/distribution_flow_test.go | 2 +- backend/internal/modules/distribution/dto.go | 12 +- .../internal/modules/distribution/handler.go | 4 +- .../distribution/handler_integration_test.go | 27 ++- .../internal/modules/distribution/pricing.go | 1 + .../modules/distribution/repository.go | 77 +++++++-- .../modules/distribution/repository_test.go | 86 ++++++++- .../internal/modules/distribution/service.go | 4 +- distribution-admin/src/api.ts | 12 +- distribution-admin/src/pages/Accounts.tsx | 163 +++++++++++++++++- .../features/admin/api/adminDistribution.ts | 13 +- 11 files changed, 363 insertions(+), 38 deletions(-) diff --git a/backend/internal/e2e/distribution_flow_test.go b/backend/internal/e2e/distribution_flow_test.go index cfb9c7e..3ac9deb 100644 --- a/backend/internal/e2e/distribution_flow_test.go +++ b/backend/internal/e2e/distribution_flow_test.go @@ -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) } diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index 24ec08e..8349a61 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -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 { diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index c669a2b..f24f56e 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -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): diff --git a/backend/internal/modules/distribution/handler_integration_test.go b/backend/internal/modules/distribution/handler_integration_test.go index 28be625..d01e4b6 100644 --- a/backend/internal/modules/distribution/handler_integration_test.go +++ b/backend/internal/modules/distribution/handler_integration_test.go @@ -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} diff --git a/backend/internal/modules/distribution/pricing.go b/backend/internal/modules/distribution/pricing.go index ebb9a60..a96f829 100644 --- a/backend/internal/modules/distribution/pricing.go +++ b/backend/internal/modules/distribution/pricing.go @@ -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),按参与方角色命名。 diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 2ba4488..a41d45a 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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 } diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index db45a6d..5800a32 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -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) +} diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 7c3078e..d26960a 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -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) { diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 2130739..77f7a84 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -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 { - const resp = await api.post>(`/admin/distribution/sites/${siteID}/sync-accounts`) +export async function syncSiteAccounts(siteID: number, req?: SyncSiteAccountsRequest): Promise { + const resp = await api.post>(`/admin/distribution/sites/${siteID}/sync-accounts`, req ?? {}) return extract(resp) } diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx index 3ce7fda..9d003e7 100644 --- a/distribution-admin/src/pages/Accounts.tsx +++ b/distribution-admin/src/pages/Accounts.tsx @@ -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(0) + const [syncResultModalVisible, setSyncResultModalVisible] = useState(false) + const [syncResult, setSyncResult] = useState(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={} loading={syncing} - onClick={handleSync} + onClick={handleOpenSyncModal} style={{ background: '#1677ff' }} > 一键同步主站商品 @@ -728,7 +745,7 @@ export default function Accounts() { type="primary" icon={} loading={syncing} - onClick={handleSync} + onClick={handleOpenSyncModal} > 一键同步主站商品 @@ -1243,6 +1260,136 @@ export default function Accounts() { )} + + {/* 同步配置弹窗 */} + + + 一键同步主站商品设置 + + } + open={syncModalVisible} + onCancel={() => !syncing && setSyncModalVisible(false)} + onOk={handleExecuteSync} + confirmLoading={syncing} + okText="立即开始同步" + cancelText="取消" + width={540} + > + + + + + +
+ setSyncCleanStale(e.target.checked)} + > + 自动下架主站已失效/已出租商品 + +
+ 推荐勾选。同步时自动将主站已下架、已出租或被删除的商品标记为已下架,避免历史幽灵商品滞留货架。 +
+
+ + + +
+ 新同步商品初始加价比例(下浮比例点数) +
+ setSyncDefaultRatioDelta(val ?? 0)} + style={{ width: 160 }} + addonAfter="点比例" + /> + + {syncDefaultRatioDelta === 0 + ? '保持主站原价上架' + : `预计标价上浮 ${(syncDefaultRatioDelta * 1.1).toFixed(1)}% 左右,溢价利润 100% 归本站`} + +
+
+ 仅对本次新增入货架的商品生效,已在货架的商品保持其原有加价不变。 +
+
+
+
+
+
+ + {/* 同步结果展示弹窗 */} + + + 主站商品同步完成 + + } + open={syncResultModalVisible} + onCancel={() => setSyncResultModalVisible(false)} + footer={[ + , + ]} + width={500} + > +
+ + + + + + + + + + + + + + + + + + + + + + +
+
) } diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index 2f21463..897c20c 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -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>( - `/admin/distribution/sites/${siteId}/sync-accounts` + `/admin/distribution/sites/${siteId}/sync-accounts`, + req ?? {} ) return data.data }