feat(api): add mini program catalog endpoints and product details
This commit is contained in:
@@ -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? }`.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user