feat(api): add mini program catalog endpoints and product details

This commit is contained in:
yeuimu
2026-08-21 02:11:20 +08:00
parent fcbf8bb494
commit 7d09077f1d
18 changed files with 1347 additions and 179 deletions
+138 -3
View File
@@ -2,7 +2,13 @@ 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';
import {
SdsClientService,
SdsCategoryTreeNode,
SdsProduct,
SdsProductDetail,
} from './sds-client.service';
import { normalizeProductDetail } from './sds-product-detail.mapper';
export interface CategorySyncResult {
inserted: number;
@@ -17,6 +23,8 @@ export interface ProductSyncResult {
total: number;
leafCategories: number;
delisted: number;
detailsSynced: number;
detailFailures: number;
}
/**
@@ -339,15 +347,28 @@ export class SyncService {
);
}
// Only hydrate full details for products selected in the website catalog.
// This keeps the hourly sync bounded and preserves the existing Good/tag
// merchandising model. A failed detail request never erases cached data.
const detailResult = await this.syncConfiguredProductDetails();
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}`,
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length} detailsSynced=${detailResult.synced} detailFailures=${detailResult.failed}`,
},
});
return { inserted, updated, total, leafCategories: leafRows.length, delisted: delistedCount };
return {
inserted,
updated,
total,
leafCategories: leafRows.length,
delisted: delistedCount,
detailsSynced: detailResult.synced,
detailFailures: detailResult.failed,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.prisma.syncLog.update({
@@ -371,6 +392,120 @@ export class SyncService {
});
}
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
const configured = await this.prisma.originGood.findMany({
where: { delisted: false, goods: { some: {} } },
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) {
failed++;
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
}
}
return { 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;
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 } } : {}),
},
});
});
}
/**
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
*/