feat(admin): manage and sync product details

This commit is contained in:
yeuimu
2026-08-21 10:18:57 +08:00
parent 7d09077f1d
commit d375df810d
18 changed files with 507 additions and 106 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ export class SyncLogDto {
id!: string;
@ApiProperty()
type!: 'CATEGORIES' | 'PRODUCTS';
type!: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS';
@ApiProperty()
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
+13
View File
@@ -2,6 +2,7 @@ import {
Controller,
DefaultValuePipe,
Get,
Param,
ParseIntPipe,
Post,
Query,
@@ -36,6 +37,18 @@ export class SyncController {
return this.service.startProductSync();
}
@Post('product-details')
@ApiOperation({ summary: 'Manually sync details for all configured products (async)' })
async syncProductDetails() {
return this.service.startProductDetailSync();
}
@Post('products/:goodId/detail')
@ApiOperation({ summary: 'Immediately sync one SDS product detail' })
async syncOneProductDetail(@Param('goodId') goodId: string) {
return this.service.syncOneProductDetail(goodId);
}
@Get('status')
@ApiOperation({ summary: 'Recent sync log entries' })
@ApiQuery({ name: 'limit', required: false, type: Number })
+73 -3
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@@ -67,7 +67,7 @@ export function shouldRunDelistDetection(leafCategories: number, seenGoods: numb
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
private running = { categories: false, products: false };
private running = { categories: false, products: false, details: false };
constructor(
private readonly prisma: PrismaService,
@@ -110,8 +110,18 @@ export class SyncService {
return { message: 'Product sync started' };
}
async startProductDetailSync(): Promise<{ message: string }> {
if (this.running.details) {
return { message: 'Product detail sync already in progress' };
}
void this.syncProductDetails().catch((err) =>
this.logger.error('Product detail sync failed', err as Error),
);
return { message: 'Product detail sync started' };
}
/** Check if a sync type is currently running. */
isRunning(type: 'categories' | 'products'): boolean {
isRunning(type: 'categories' | 'products' | 'details'): boolean {
return this.running[type];
}
@@ -392,6 +402,66 @@ export class SyncService {
});
}
async syncProductDetails(): Promise<{ synced: number; failed: number }> {
if (this.running.details) {
throw new Error('Product detail sync already in progress');
}
this.running.details = true;
const log = await this.prisma.syncLog.create({
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
});
try {
const result = await this.syncConfiguredProductDetails();
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `synced=${result.synced} failed=${result.failed}`,
},
});
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.prisma.syncLog.update({
where: { id: log.id },
data: { status: 'FAILED', finishedAt: new Date(), message },
});
throw error;
} finally {
this.running.details = false;
}
}
async syncOneProductDetail(goodId: string): Promise<{
goodId: string;
variants: number;
detailSyncedAt: string;
}> {
const originGood = await this.prisma.originGood.findUnique({
where: { sdsGoodId: goodId },
select: { id: true },
});
if (!originGood) {
throw new NotFoundException(`SDS product ${goodId} not found locally`);
}
const upstream = await this.sds.fetchProductDetail(goodId);
const normalized = normalizeProductDetail(upstream);
await this.persistProductDetail(originGood.id, upstream);
return {
goodId,
variants: normalized.variants.length,
detailSyncedAt: new Date().toISOString(),
};
}
queueProductDetailSync(goodId: string): void {
void this.syncOneProductDetail(goodId).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Queued detail sync failed for ${goodId}: ${message}`);
});
}
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
const configured = await this.prisma.originGood.findMany({
where: { delisted: false, goods: { some: {} } },