- PublicCacheService:分域版本号失效(bump 即作废,不等 TTL)、loader 期间 bump 的竞态防护(返回但不回写)、并发 miss 单飞、PUBLIC_CACHE_DISABLED /TTL_MS/MAX_ENTRIES 应急开关;@Global 模块 - PublicService 六端点接缓存:列表缓存全量物化(分页切片在缓存外按请求执行, 修复'所有页返回第一页'的切片缓存错误)、详情/首页/树/标签组/树序元数据/ 族最低价聚合各按依赖域缓存 - 全写路径挂钩 bump:admin CRUD(goods/categories/countries/tags/tag-groups/ positions/origin-goods 标签)、sync 三同步、族重算、整理全家桶—— 事务提交成功后失效对应域,product-families 经 recompute 天然覆盖 - 测试:PublicCacheService 单测 13 例 + 失效链路集成 8 例(读命中/写后立即可见 端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
793 lines
28 KiB
TypeScript
793 lines
28 KiB
TypeScript
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<void> {
|
||
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<void> {
|
||
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<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`);
|
||
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<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;
|
||
const seenSdsGoodIds = new Set<string>();
|
||
|
||
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<void>,
|
||
): 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<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;
|
||
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<void> {
|
||
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<void> {
|
||
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';
|
||
}
|
||
}
|