diff --git a/apps/admin/src/views/sync/SyncView.vue b/apps/admin/src/views/sync/SyncView.vue index 0d098d0..9c95ece 100644 --- a/apps/admin/src/views/sync/SyncView.vue +++ b/apps/admin/src/views/sync/SyncView.vue @@ -28,7 +28,7 @@ async function refreshLogs() { function syncTypeLabel(type: SyncType): string { if (type === 'CATEGORIES') return '分类' - if (type === 'PRODUCT_DETAILS') return '商品详情' + if (type === 'PRODUCT_DETAILS') return '全部原产品详情' return '商品列表' } @@ -70,7 +70,7 @@ async function doSync(type: SyncType) { const label = syncTypeLabel(type) try { await ElMessageBox.confirm( - `确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`, + `确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`, '确认', { type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' } ) @@ -166,7 +166,7 @@ onUnmounted(() => { :icon="Refresh" @click="handleSyncProductDetails" > - 同步商品详情 + 同步全部原产品详情 diff --git a/apps/api/src/sync/sync.controller.ts b/apps/api/src/sync/sync.controller.ts index 33a2abc..3388c79 100644 --- a/apps/api/src/sync/sync.controller.ts +++ b/apps/api/src/sync/sync.controller.ts @@ -38,7 +38,7 @@ export class SyncController { } @Post('product-details') - @ApiOperation({ summary: 'Manually sync details for all configured products (async)' }) + @ApiOperation({ summary: 'Manually sync details for all active origin products (async)' }) async syncProductDetails() { return this.service.startProductDetailSync(); } diff --git a/apps/api/src/sync/sync.service.spec.ts b/apps/api/src/sync/sync.service.spec.ts index 790f6cd..fd919ba 100644 --- a/apps/api/src/sync/sync.service.spec.ts +++ b/apps/api/src/sync/sync.service.spec.ts @@ -276,3 +276,46 @@ describe('SyncService', () => { }); }); }); + +describe('SyncService product detail scopes', () => { + const originGoods = [ + { id: 1n, sdsGoodId: 'all-1' }, + { id: 2n, sdsGoodId: 'all-2' }, + ]; + + function createService() { + const prisma = { + originGood: { findMany: jest.fn().mockResolvedValue(originGoods) }, + } as unknown as PrismaService; + const sds = { + fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })), + } as unknown as SdsClientService; + const scopedService = new SyncService(prisma, sds); + jest + .spyOn(scopedService as any, 'persistProductDetail') + .mockResolvedValue(undefined); + return { scopedService, prisma, sds }; + } + + it('manual detail sync selects every active origin product', async () => { + const { scopedService, prisma, sds } = createService(); + const result = await scopedService.syncAllProductDetails(); + + expect(prisma.originGood.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { delisted: false } }), + ); + expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2); + expect(result).toEqual({ total: 2, synced: 2, failed: 0 }); + }); + + it('hourly detail refresh remains limited to configured products', async () => { + const { scopedService, prisma } = createService(); + await scopedService.syncConfiguredProductDetails(); + + expect(prisma.originGood.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { delisted: false, goods: { some: {} } }, + }), + ); + }); +}); diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index 3c1ffd7..4591a72 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -402,7 +402,11 @@ export class SyncService { }); } - async syncProductDetails(): Promise<{ synced: number; failed: number }> { + async syncProductDetails(): Promise<{ + total: number; + synced: number; + failed: number; + }> { if (this.running.details) { throw new Error('Product detail sync already in progress'); } @@ -411,13 +415,20 @@ export class SyncService { data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' }, }); try { - const result = await this.syncConfiguredProductDetails(); + const result = await this.syncAllProductDetails(async (progress) => { + await this.prisma.syncLog.update({ + where: { id: log.id }, + data: { + message: `processed=${progress.processed}/${progress.total} synced=${progress.synced} failed=${progress.failed}`, + }, + }); + }); await this.prisma.syncLog.update({ where: { id: log.id }, data: { status: 'SUCCESS', finishedAt: new Date(), - message: `synced=${result.synced} failed=${result.failed}`, + message: `total=${result.total} synced=${result.synced} failed=${result.failed}`, }, }); return result; @@ -463,25 +474,71 @@ export class SyncService { } async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> { - const configured = await this.prisma.originGood.findMany({ - where: { delisted: false, goods: { some: {} } }, + const result = await this.syncMatchingProductDetails({ + delisted: false, + goods: { some: {} }, + }); + return { synced: result.synced, failed: result.failed }; + } + + async syncAllProductDetails( + onProgress?: (progress: { + processed: number; + total: number; + synced: number; + failed: number; + }) => Promise, + ): Promise<{ total: number; synced: number; failed: number }> { + return this.syncMatchingProductDetails( + { delisted: false }, + onProgress, + 2, + ); + } + + private async syncMatchingProductDetails( + where: Prisma.OriginGoodWhereInput, + onProgress?: (progress: { + processed: number; + total: number; + synced: number; + failed: number; + }) => Promise, + attempts = 1, + ): Promise<{ total: number; synced: number; failed: number }> { + const originGoods = await this.prisma.originGood.findMany({ + where, select: { id: true, sdsGoodId: true }, orderBy: { id: 'asc' }, }); let synced = 0; let failed = 0; - for (const originGood of configured) { - try { - const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId); - await this.persistProductDetail(originGood.id, upstream); - synced++; - } catch (error) { + let processed = 0; + for (const originGood of originGoods) { + let lastError: unknown; + let succeeded = false; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId); + await this.persistProductDetail(originGood.id, upstream); + synced++; + succeeded = true; + break; + } catch (error) { + lastError = error; + } + } + if (!succeeded) { failed++; - const message = error instanceof Error ? error.message : String(error); + const message = lastError instanceof Error ? lastError.message : String(lastError); this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`); } + processed++; + if (onProgress && (processed % 10 === 0 || processed === originGoods.length)) { + await onProgress({ processed, total: originGoods.length, synced, failed }); + } } - return { synced, failed }; + return { total: originGoods.length, synced, failed }; } async importProductDetail(upstream: SdsProductDetail): Promise<{