import { BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { SdsClientService, SdsCategoryTreeNode, SdsProduct, SdsProductDetail, } from './sds-client.service'; import { normalizeProductDetail } from './sds-product-detail.mapper'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; import { PublicCacheService } from '../public/public-cache.service'; export interface CategorySyncResult { inserted: number; updated: number; total: number; deletedStale: number; } /** 全角/半角括号段(非嵌套),分类名清洗用 */ const PAREN_GROUP = /([^()()]*)|\([^()()]*\)/g; /** * 分类展示名清洗:删除所有括号段(型号限定词如 `(JSA002)`、内容性括号如 * `(黑+卡其)`/`(短裤 & 长裤)`,整段删除),折叠多余空白。剥空(名称仅剩 * 括号段)时原样返回。分类名每小时被 SDS 同步覆盖,清洗必须在此入库路径生效 * 才能持久;款号 token(如 `DG001`)不受影响。 */ export function cleanCategoryDisplayName(name: string | null | undefined): string | null | undefined { if (!name) return name; let cleaned = name.replace(PAREN_GROUP, ' '); // 嵌套/残缺括号剥除后可能留下空括号对 cleaned = cleaned.replace(/(\s*)|\(\s*\)/g, ' '); cleaned = cleaned.replace(/\s+/g, ' ').trim(); return cleaned || name; } export interface ProductSyncResult { inserted: number; updated: number; total: number; leafCategories: number; delisted: number; } /** * Safety guards so a degenerate/partial SDS response never triggers a * destructive operation (stale category deletion / mass delist marking). * Upstream normally returns ~226 categories and ~150 leaf categories with * hundreds of products — the floors below only trigger on abnormal responses. */ export const SYNC_GUARDS = { MIN_CATEGORY_COUNT: 10, MIN_CATEGORY_RATIO: 0.5, MIN_LEAF_CATEGORIES: 10, MIN_SEEN_GOODS: 50, } as const; /** * True when the fetched category count is suspiciously small compared to the * categories already synced from SDS, i.e. the upstream response is likely * partial/degenerate. In that case stale deletion must be skipped. */ export function shouldSkipStaleDeletion(fetched: number, existingSds: number): boolean { if (existingSds <= 0) return false; return ( fetched < SYNC_GUARDS.MIN_CATEGORY_COUNT || fetched < SYNC_GUARDS.MIN_CATEGORY_RATIO * existingSds ); } /** * True only when both the leaf-category count and the number of seen products * are healthy enough to trust the "not seen upstream => delisted" conclusion. */ export function shouldRunDelistDetection(leafCategories: number, seenGoods: number): boolean { return ( leafCategories >= SYNC_GUARDS.MIN_LEAF_CATEGORIES && seenGoods >= SYNC_GUARDS.MIN_SEEN_GOODS ); } @Injectable() export class SyncService { private readonly logger = new Logger(SyncService.name); private running = { categories: false, products: false, details: false }; constructor( private readonly prisma: PrismaService, private readonly sds: SdsClientService, private readonly familyRecompute: FamilyRecomputeService, private readonly publicCache: PublicCacheService, ) {} /** * 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 { try { await this.syncCategories(); await this.syncProducts(); } catch (err) { this.logger.error('Hourly cron sync failed', err as Error); } } /** Fire-and-forget wrappers for manual triggers via HTTP. */ async startCategorySync(): Promise<{ message: string }> { if (this.running.categories) { return { message: 'Category sync already in progress' }; } void this.syncCategories().catch((err) => this.logger.error('Category sync failed', err as Error), ); return { message: 'Category sync started' }; } async startProductSync(): Promise<{ message: string }> { if (this.running.products) { return { message: 'Product sync already in progress' }; } void this.syncProducts().catch((err) => this.logger.error('Product sync failed', err as Error), ); return { message: 'Product sync started' }; } /** Refresh all active SDS product details once per day at 03:30. */ @Cron('0 30 3 * * *', { timeZone: 'Asia/Shanghai' }) async dailyProductDetailCron(): Promise { try { await this.syncProductDetails(); } catch (err) { this.logger.error('Daily product detail sync failed', err as Error); } } 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' | 'details'): boolean { return this.running[type]; } async syncCategories(): Promise { 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`); const seenSdsIds = new Set(flat.map((n) => n.sdsId)); // Guard: if the upstream tree is suspiciously small vs what we already // have from SDS, skip stale deletion entirely — a partial response must // never wipe the category library. const existingSdsCount = await this.prisma.category.count({ where: { sdsCategoryId: { not: null } }, }); const skipStaleDeletion = shouldSkipStaleDeletion(flat.length, existingSdsCount); if (skipStaleDeletion) { this.logger.warn( `Skipping stale category deletion: fetched=${flat.length} existingSds=${existingSdsCount} ` + `(below guard thresholds)`, ); } // Single transaction: upsert + wire parents + delete stale. // SDS tree is the source of truth — anything not in the response gets deleted // (unless the response looks degenerate, see guard above). const { inserted, updated, deletedStale } = await this.prisma.$transaction(async (tx) => { let ins = 0; let upd = 0; // 1. Upsert all SDS categories for (const node of flat) { const existing = await tx.category.findUnique({ where: { sdsCategoryId: node.sdsId }, }); if (!existing) { await tx.category.create({ data: { sdsCategoryId: node.sdsId, categoryName: cleanCategoryDisplayName(node.name) ?? node.name, categoryIcon: node.icon ?? null, }, }); ins++; } else { await tx.category.update({ where: { id: existing.id }, data: { categoryName: cleanCategoryDisplayName(node.name) ?? node.name, categoryIcon: node.icon ?? null, }, }); upd++; } } // 2. Wire parent-child relationships for (const node of flat) { if (!node.parentSdsId) continue; const child = await tx.category.findUnique({ where: { sdsCategoryId: node.sdsId }, }); const parent = await tx.category.findUnique({ where: { sdsCategoryId: node.parentSdsId }, }); if (child && parent && child.parentCategoryId !== parent.id) { await tx.category.update({ where: { id: child.id }, data: { parentCategoryId: parent.id }, }); } } // 3. Delete stale categories (in DB but not in SDS response) // Detach parent links first, then delete leaf-first to respect FK constraints. // Skipped entirely when the response looks degenerate (see guard above). let deletedStale = 0; if (!skipStaleDeletion) { const staleCats = await tx.category.findMany({ where: { sdsCategoryId: { notIn: [...seenSdsIds] } }, select: { id: true }, }); const staleIds = staleCats.map((c) => c.id); // Protect categories that have configured goods — onDelete: Restrict const goodsInStale = await tx.good.groupBy({ by: ['categoryId'], where: { categoryId: { in: staleIds } }, }); const protectedIds = new Set(goodsInStale.map((g) => g.categoryId)); const deletableIds = staleIds.filter((id) => !protectedIds.has(id)); deletedStale = deletableIds.length; // Detach all deletable categories from their parents if (deletableIds.length > 0) { await tx.category.updateMany({ where: { id: { in: deletableIds } }, data: { parentCategoryId: null }, }); // Also detach any non-deletable children pointing to deletable parents await tx.category.updateMany({ where: { parentCategoryId: { in: deletableIds } }, data: { parentCategoryId: null }, }); // Delete leaf-first (repeatedly remove nodes with no children) let remaining = [...deletableIds]; while (remaining.length > 0) { const withChildren = await tx.category.findMany({ where: { parentCategoryId: { in: remaining } }, select: { parentCategoryId: true }, distinct: ['parentCategoryId'], }); const hasChildSet = new Set( withChildren.filter((c) => c.parentCategoryId).map((c) => c.parentCategoryId!.toString()), ); const leaves = remaining.filter((id) => !hasChildSet.has(id.toString())); if (leaves.length === 0) break; // safety: circular dependency await tx.category.deleteMany({ where: { id: { in: leaves } } }); remaining = remaining.filter((id) => !leaves.some((l) => l === id)); } } } return { inserted: ins, updated: upd, deletedStale }; }); await this.prisma.syncLog.update({ where: { id: log.id }, data: { status: 'SUCCESS', finishedAt: new Date(), message: `inserted=${inserted} updated=${updated} total=${flat.length} staleDeleted=${deletedStale}`, }, }); // 分类树/树序元数据变化 → public 缓存失效(全局约束 §2:不等 TTL) this.publicCache.bump('meta', 'goods'); return { inserted, updated, total: flat.length, deletedStale }; } 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 { 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; const seenSdsGoodIds = new Set(); 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) { seenSdsGoodIds.add(String(product.id)); 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; } } } // Detect delisted products: mark origin goods not seen in upstream as delisted, // and re-activate any previously delisted goods that reappeared. // Guard: only trust this conclusion when the sync covered a healthy number of // leaf categories and saw a healthy number of products — otherwise a partial // sync must never mass-delist the product library. let delistedCount = 0; let reactivatedCount = 0; const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size); if (runDelist) { const delistedResult = await this.prisma.originGood.updateMany({ where: { source: 'SDS', sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false, }, data: { delisted: true }, }); const reactivatedResult = await this.prisma.originGood.updateMany({ where: { source: 'SDS', sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true, }, data: { delisted: false }, }); delistedCount = delistedResult.count; reactivatedCount = reactivatedResult.count; } else { this.logger.warn( `Skipping delist detection: leafCategories=${leafRows.length} seenGoods=${seenSdsGoodIds.size} ` + `(below guard thresholds)`, ); } await this.prisma.syncLog.update({ where: { id: log.id }, data: { status: 'SUCCESS', finishedAt: new Date(), message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`, }, }); // 链接镜像(名称/图/价/上下架)变化 → public 商品列表/详情失效 this.publicCache.bump('goods'); return { inserted, updated, total, leafCategories: leafRows.length, delisted: delistedCount, }; } 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, }); } async syncProductDetails(): Promise<{ total: number; 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.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: `total=${result.total} 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, source: true }, }); if (!originGood) { throw new NotFoundException(`SDS product ${goodId} not found locally`); } if (originGood.source !== 'SDS') { throw new BadRequestException('自定义商品不支持从 SDS 同步详情'); } 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 result = await this.syncMatchingProductDetails({ delisted: false, source: 'SDS', 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, source: 'SDS' }, 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; 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 = 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 { total: originGoods.length, synced, failed }; } async importProductDetail(upstream: SdsProductDetail): Promise<{ goodId: string; variants: number; sizeRows: number; packageRows: number; configuredGoods: number; }> { const goodId = String(upstream.id); const normalized = normalizeProductDetail(upstream); const originGood = await this.prisma.originGood.upsert({ where: { sdsGoodId: goodId }, create: { sdsGoodId: goodId, goodName: String(upstream.name ?? goodId), goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || null, goodPrice: upstream.min_price === undefined || upstream.min_price === null ? null : new Prisma.Decimal(Number(upstream.min_price)), }, update: { goodName: upstream.name ? String(upstream.name) : undefined, goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || undefined, goodPrice: upstream.min_price === undefined || upstream.min_price === null ? undefined : new Prisma.Decimal(Number(upstream.min_price)), }, }); await this.persistProductDetail(originGood.id, upstream); const configuredGoods = await this.prisma.good.count({ where: { originGoodId: originGood.id }, }); const sizeChart = normalized.sizeChart as { rows?: unknown[] } | null; const packageSpecs = normalized.packageSpecs as { rows?: unknown[] } | null; return { goodId, variants: normalized.variants.length, sizeRows: sizeChart?.rows?.length ?? 0, packageRows: packageSpecs?.rows?.length ?? 0, configuredGoods, }; } private async persistProductDetail(originGoodId: bigint, upstream: SdsProductDetail): Promise { const normalized = normalizeProductDetail(upstream); const { variants, ...detail } = normalized; await this.prisma.$transaction(async (tx) => { const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull; // Backfill the origin good's price from upstream min_price when present if (upstream.min_price !== undefined && upstream.min_price !== null) { await tx.originGood.update({ where: { id: originGoodId }, data: { goodPrice: new Prisma.Decimal(Number(upstream.min_price)) }, }); } await tx.originGoodDetail.upsert({ where: { originGoodId }, create: { originGoodId, ...detail, sizeChart: json(detail.sizeChart), packageSpecs: json(detail.packageSpecs), options: json(detail.options), media: json(detail.media), }, update: { ...detail, sizeChart: json(detail.sizeChart), packageSpecs: json(detail.packageSpecs), options: json(detail.options), media: json(detail.media), syncedAt: new Date(), }, }); const seenVariantIds: string[] = []; for (const variant of variants) { seenVariantIds.push(variant.sdsVariantId); const { designData, ...data } = variant; await tx.originGoodVariant.upsert({ where: { originGoodId_sdsVariantId: { originGoodId, sdsVariantId: variant.sdsVariantId, }, }, create: { originGoodId, ...data, designData: json(designData) }, update: { ...data, designData: json(designData) }, }); } await tx.originGoodVariant.deleteMany({ where: { originGoodId, ...(seenVariantIds.length ? { sdsVariantId: { notIn: seenVariantIds } } : {}), }, }); }); // 详情/变体/价格落库 → public 详情缓存失效;族矩阵变化由重算钩子 bump matrix this.publicCache.bump('goods'); // 族成员的详情/变体变化 → 异步重算该族(进程内去重) await this.maybeEnqueueFamilyRecompute(originGoodId); } /** 详情同步后的族重算钩子:链接有族归属才入队 */ private async maybeEnqueueFamilyRecompute(originGoodId: bigint): Promise { const og = await this.prisma.originGood.findUnique({ where: { id: originGoodId }, select: { familyId: true }, }); if (og?.familyId) this.familyRecompute.enqueue(og.familyId); } /** * 新链接自动挂族已移除(解析去运行时化):归族由整理(OrganizeService)显式完成。 * 详见 plans/refactor/organize-script-refactor.md。 */ /** * 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); } } // 纯镜像:只存 SDS 原文(名称/图/价/分类 ID)。 // 结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)与自动归族 // 由整理(OrganizeService,显式人工触发)负责,同步不解析、不推断。 const data: Prisma.OriginGoodUncheckedUpdateInput = { sdsCategoryId, goodName, goodImage, goodPrice, }; if (!existing) { await this.prisma.originGood.create({ data: { sdsGoodId, sdsCategoryId, goodName, goodImage, goodPrice, }, select: { id: true }, }); return 'inserted'; } await this.prisma.originGood.update({ where: { id: existing.id }, data, }); return 'updated'; } }