- NestJS backend: Prisma + PostgreSQL, JWT auth, CRUD for goods/categories/countries/tags/positions, SDS sync with cron, public API, Swagger docs - Vue3 Admin: Element Plus, goods/categories/countries/tags/positions/sync management pages, batch operations, login with JWT - Nuxt4 Website: product-center page with sidebar navigation, country filter, search, product grid, pagination, skeleton loading - Nitro proxy routes for backend API - 54 backend tests passing - Updated docs and README
290 lines
8.8 KiB
TypeScript
290 lines
8.8 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { SdsClientService, SdsCategoryTreeNode, SdsProduct } from './sds-client.service';
|
|
|
|
export interface CategorySyncResult {
|
|
inserted: number;
|
|
updated: number;
|
|
total: number;
|
|
}
|
|
|
|
export interface ProductSyncResult {
|
|
inserted: number;
|
|
updated: number;
|
|
total: number;
|
|
leafCategories: number;
|
|
}
|
|
|
|
@Injectable()
|
|
export class SyncService {
|
|
private readonly logger = new Logger(SyncService.name);
|
|
private running = { categories: false, products: false };
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly sds: SdsClientService,
|
|
) {}
|
|
|
|
/**
|
|
* Hourly full sync — runs `syncCategories` first (since product
|
|
* sync depends on knowing which leaf categories exist) and then
|
|
* `syncProducts`.
|
|
*/
|
|
@Cron(CronExpression.EVERY_HOUR)
|
|
async hourlyCron(): Promise<void> {
|
|
try {
|
|
await this.syncCategories();
|
|
await this.syncProducts();
|
|
} catch (err) {
|
|
this.logger.error('Hourly cron sync failed', err as Error);
|
|
}
|
|
}
|
|
|
|
async syncCategories(): Promise<CategorySyncResult> {
|
|
if (this.running.categories) {
|
|
throw new Error('Category sync already in progress');
|
|
}
|
|
this.running.categories = true;
|
|
const log = await this.prisma.syncLog.create({
|
|
data: { type: 'CATEGORIES', status: 'RUNNING' },
|
|
});
|
|
try {
|
|
const tree = await this.sds.fetchCategoryTree();
|
|
const flat = this.flattenCategoryTree(tree);
|
|
this.logger.log(`Fetched ${flat.length} SDS categories`);
|
|
|
|
let inserted = 0;
|
|
let updated = 0;
|
|
for (const node of flat) {
|
|
const existing = await this.prisma.category.findUnique({
|
|
where: { sdsCategoryId: node.sdsId },
|
|
});
|
|
if (!existing) {
|
|
await this.prisma.category.create({
|
|
data: {
|
|
sdsCategoryId: node.sdsId,
|
|
categoryName: node.name,
|
|
categoryIcon: node.icon ?? null,
|
|
},
|
|
});
|
|
inserted++;
|
|
} else {
|
|
await this.prisma.category.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
categoryName: node.name,
|
|
categoryIcon: node.icon ?? null,
|
|
},
|
|
});
|
|
updated++;
|
|
}
|
|
}
|
|
|
|
// Second pass: wire up parents by sdsCategoryId.
|
|
for (const node of flat) {
|
|
if (!node.parentSdsId) continue;
|
|
const child = await this.prisma.category.findUnique({
|
|
where: { sdsCategoryId: node.sdsId },
|
|
});
|
|
const parent = await this.prisma.category.findUnique({
|
|
where: { sdsCategoryId: node.parentSdsId },
|
|
});
|
|
if (child && parent && child.parentCategoryId !== parent.id) {
|
|
await this.prisma.category.update({
|
|
where: { id: child.id },
|
|
data: { parentCategoryId: parent.id },
|
|
});
|
|
}
|
|
}
|
|
|
|
await this.prisma.syncLog.update({
|
|
where: { id: log.id },
|
|
data: {
|
|
status: 'SUCCESS',
|
|
finishedAt: new Date(),
|
|
message: `inserted=${inserted} updated=${updated} total=${flat.length}`,
|
|
},
|
|
});
|
|
return { inserted, updated, total: flat.length };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await this.prisma.syncLog.update({
|
|
where: { id: log.id },
|
|
data: {
|
|
status: 'FAILED',
|
|
finishedAt: new Date(),
|
|
message,
|
|
},
|
|
});
|
|
throw err;
|
|
} finally {
|
|
this.running.categories = false;
|
|
}
|
|
}
|
|
|
|
async syncProducts(): Promise<ProductSyncResult> {
|
|
if (this.running.products) {
|
|
throw new Error('Product sync already in progress');
|
|
}
|
|
this.running.products = true;
|
|
const log = await this.prisma.syncLog.create({
|
|
data: { type: 'PRODUCTS', status: 'RUNNING' },
|
|
});
|
|
try {
|
|
// Identify leaf categories — those with no children.
|
|
const all = await this.prisma.category.findMany({
|
|
select: { id: true, sdsCategoryId: true },
|
|
});
|
|
const parents = await this.prisma.category.findMany({
|
|
where: { parent: { isNot: null } },
|
|
select: { parentCategoryId: true },
|
|
});
|
|
const parentIds = new Set(parents.map((p) => p.parentCategoryId!));
|
|
const leafRows = all.filter((c) => !parentIds.has(c.id) && c.sdsCategoryId);
|
|
|
|
let inserted = 0;
|
|
let updated = 0;
|
|
let total = 0;
|
|
|
|
for (const leaf of leafRows) {
|
|
const sdsCategoryId = leaf.sdsCategoryId!;
|
|
let page = 1;
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
const resp = await this.sds.fetchProductsPage(sdsCategoryId, page, 50);
|
|
const products = resp.items ?? resp.content ?? [];
|
|
if (products.length === 0) break;
|
|
for (const product of products) {
|
|
const upserted = await this.upsertOriginGood(product, sdsCategoryId);
|
|
if (upserted === 'inserted') inserted++;
|
|
else updated++;
|
|
total++;
|
|
}
|
|
if (products.length < 50) break;
|
|
page++;
|
|
if (page > 200) {
|
|
// Safety net — at most 10k products per category.
|
|
this.logger.warn(`Reached 200-page safety cap for ${sdsCategoryId}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
await this.prisma.syncLog.update({
|
|
where: { id: log.id },
|
|
data: {
|
|
status: 'SUCCESS',
|
|
finishedAt: new Date(),
|
|
message: `inserted=${inserted} updated=${updated} total=${total} leafCategories=${leafRows.length}`,
|
|
},
|
|
});
|
|
return { inserted, updated, total, leafCategories: leafRows.length };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await this.prisma.syncLog.update({
|
|
where: { id: log.id },
|
|
data: {
|
|
status: 'FAILED',
|
|
finishedAt: new Date(),
|
|
message,
|
|
},
|
|
});
|
|
throw err;
|
|
} finally {
|
|
this.running.products = false;
|
|
}
|
|
}
|
|
|
|
async getStatus(limit = 20) {
|
|
return this.prisma.syncLog.findMany({
|
|
orderBy: { startedAt: 'desc' },
|
|
take: limit,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
|
|
*/
|
|
flattenCategoryTree(
|
|
nodes: SdsCategoryTreeNode[],
|
|
parentSdsId?: string,
|
|
): Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> {
|
|
const out: Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> = [];
|
|
const walk = (node: SdsCategoryTreeNode, parent?: string) => {
|
|
const sdsId = String(node.id);
|
|
if (sdsId === '' || sdsId === 'undefined' || sdsId === 'null') return;
|
|
out.push({
|
|
sdsId,
|
|
parentSdsId: parent,
|
|
name: String(node.name ?? node.title ?? sdsId),
|
|
icon: node.icon ? String(node.icon) : undefined,
|
|
});
|
|
if (Array.isArray(node.children)) {
|
|
for (const child of node.children) walk(child, sdsId);
|
|
}
|
|
};
|
|
for (const root of nodes) walk(root, parentSdsId);
|
|
return out;
|
|
}
|
|
|
|
private async upsertOriginGood(
|
|
product: SdsProduct,
|
|
sdsCategoryId: string,
|
|
): Promise<'inserted' | 'updated'> {
|
|
const sdsGoodId = String(product.id);
|
|
const existing = await this.prisma.originGood.findUnique({
|
|
where: { sdsGoodId },
|
|
});
|
|
const goodName = String(product.name ?? product.title ?? sdsGoodId);
|
|
const goodImage = product.psd_img_url
|
|
? String(product.psd_img_url)
|
|
: product.blankDesignUrl
|
|
? String(product.blankDesignUrl)
|
|
: product.thumbImgUrl
|
|
? String(product.thumbImgUrl)
|
|
: product.show_img
|
|
? String(product.show_img)
|
|
: product.img_url
|
|
? String(product.img_url)
|
|
: product.pic
|
|
? String(product.pic)
|
|
: product.image
|
|
? String(product.image)
|
|
: null;
|
|
const priceValue = product.currentPrice ?? product.price;
|
|
let goodPrice: Prisma.Decimal | null = null;
|
|
if (priceValue !== undefined && priceValue !== null) {
|
|
const n = typeof priceValue === 'string' ? Number(priceValue) : priceValue;
|
|
if (Number.isFinite(n)) {
|
|
goodPrice = new Prisma.Decimal(n);
|
|
}
|
|
}
|
|
const data: Prisma.OriginGoodUncheckedUpdateInput = {
|
|
sdsCategoryId,
|
|
goodName,
|
|
goodImage,
|
|
goodPrice,
|
|
};
|
|
|
|
if (!existing) {
|
|
await this.prisma.originGood.create({
|
|
data: {
|
|
sdsGoodId,
|
|
sdsCategoryId,
|
|
goodName,
|
|
goodImage,
|
|
goodPrice,
|
|
},
|
|
});
|
|
return 'inserted';
|
|
}
|
|
await this.prisma.originGood.update({
|
|
where: { id: existing.id },
|
|
data,
|
|
});
|
|
return 'updated';
|
|
}
|
|
}
|