fix(sync): hydrate all origin product details

This commit is contained in:
yeuimu
2026-08-21 14:05:15 +08:00
parent 4cc99f3f23
commit a4151607c5
4 changed files with 117 additions and 17 deletions
+3 -3
View File
@@ -28,7 +28,7 @@ async function refreshLogs() {
function syncTypeLabel(type: SyncType): string { function syncTypeLabel(type: SyncType): string {
if (type === 'CATEGORIES') return '分类' if (type === 'CATEGORIES') return '分类'
if (type === 'PRODUCT_DETAILS') return '品详情' if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
return '商品列表' return '商品列表'
} }
@@ -70,7 +70,7 @@ async function doSync(type: SyncType) {
const label = syncTypeLabel(type) const label = syncTypeLabel(type)
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`, `确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
'确认', '确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' } { type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
) )
@@ -166,7 +166,7 @@ onUnmounted(() => {
:icon="Refresh" :icon="Refresh"
@click="handleSyncProductDetails" @click="handleSyncProductDetails"
> >
同步品详情 同步全部原产品详情
</el-button> </el-button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -38,7 +38,7 @@ export class SyncController {
} }
@Post('product-details') @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() { async syncProductDetails() {
return this.service.startProductDetailSync(); return this.service.startProductDetailSync();
} }
+43
View File
@@ -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: {} } },
}),
);
});
});
+70 -13
View File
@@ -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) { if (this.running.details) {
throw new Error('Product detail sync already in progress'); throw new Error('Product detail sync already in progress');
} }
@@ -411,13 +415,20 @@ export class SyncService {
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' }, data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
}); });
try { 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({ await this.prisma.syncLog.update({
where: { id: log.id }, where: { id: log.id },
data: { data: {
status: 'SUCCESS', status: 'SUCCESS',
finishedAt: new Date(), finishedAt: new Date(),
message: `synced=${result.synced} failed=${result.failed}`, message: `total=${result.total} synced=${result.synced} failed=${result.failed}`,
}, },
}); });
return result; return result;
@@ -463,25 +474,71 @@ export class SyncService {
} }
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> { async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
const configured = await this.prisma.originGood.findMany({ const result = await this.syncMatchingProductDetails({
where: { delisted: false, goods: { some: {} } }, delisted: false,
goods: { some: {} },
});
return { synced: result.synced, failed: result.failed };
}
async syncAllProductDetails(
onProgress?: (progress: {
processed: number;
total: number;
synced: number;
failed: number;
}) => Promise<void>,
): 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<void>,
attempts = 1,
): Promise<{ total: number; synced: number; failed: number }> {
const originGoods = await this.prisma.originGood.findMany({
where,
select: { id: true, sdsGoodId: true }, select: { id: true, sdsGoodId: true },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}); });
let synced = 0; let synced = 0;
let failed = 0; let failed = 0;
for (const originGood of configured) { let processed = 0;
try { for (const originGood of originGoods) {
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId); let lastError: unknown;
await this.persistProductDetail(originGood.id, upstream); let succeeded = false;
synced++; for (let attempt = 1; attempt <= attempts; attempt++) {
} catch (error) { 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++; 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}`); 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<{ async importProductDetail(upstream: SdsProductDetail): Promise<{