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
+1 -1
View File
@@ -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();
}
+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) {
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<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 },
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<{