feat(product-center): implement Figma-designed UI, upload module, and website tests
- Redesign website homepage & product-center per Figma (fonts, logos, hero/footer/customer cases) - Add API upload module (multer) with static serving for uploads/public assets - Add OriginGood.delisted flag and SDS request retry logic - Add admin ImageUpload component and goods import/upload flows - Add vitest suite for website components and composables (32 tests) - Add skills, docs, plans and PRODUCT.md
This commit is contained in:
@@ -65,10 +65,39 @@ export class SdsClientService {
|
||||
'https://mapi.sdspod.com';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the SDS category tree of type 3 (products category).
|
||||
* Body matches the legacy inkpod client.
|
||||
*/
|
||||
private async request<T>(
|
||||
method: 'post' | 'get',
|
||||
url: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const MAX_RETRIES = 3;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const config = {
|
||||
headers: POD_HEADERS,
|
||||
timeout: 30000,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
const obs =
|
||||
method === 'post'
|
||||
? this.http.post<T>(url, body, config)
|
||||
: this.http.get<T>(url, config);
|
||||
const { data } = await firstValueFrom(obs);
|
||||
return data as T;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
this.logger.warn(`SDS request attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`);
|
||||
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
|
||||
const url = `${this.baseUrl}/category/tree/3`;
|
||||
const body = {
|
||||
@@ -76,27 +105,23 @@ export class SdsClientService {
|
||||
withPrivate: true,
|
||||
onlyHaveProduct: true,
|
||||
};
|
||||
const { data } = await firstValueFrom(
|
||||
this.http.post<SdsCategoryTreeNode[]>(url, body, { headers: POD_HEADERS }),
|
||||
);
|
||||
return Array.isArray(data) ? data : [];
|
||||
const data = await this.request<unknown>('post', url, body);
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`SDS category tree returned ${typeof data}, expected array`);
|
||||
}
|
||||
return data as SdsCategoryTreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one page of products for a given SDS category.
|
||||
*/
|
||||
async fetchProductsPage(
|
||||
categoryId: string | number,
|
||||
page = 1,
|
||||
size = 50,
|
||||
): Promise<SdsProductsPage> {
|
||||
const url = `${this.baseUrl}/products/page`;
|
||||
const { data } = await firstValueFrom(
|
||||
this.http.get<SdsProductsPage>(url, {
|
||||
headers: POD_HEADERS,
|
||||
params: { categoryId, page, size },
|
||||
}),
|
||||
);
|
||||
return data ?? {};
|
||||
const data = await this.request<unknown>('get', url, undefined, { categoryId, page, size });
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error(`SDS products page returned ${typeof data}, expected object`);
|
||||
}
|
||||
return data as SdsProductsPage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,15 +25,15 @@ export class SyncController {
|
||||
constructor(private readonly service: SyncService) {}
|
||||
|
||||
@Post('categories')
|
||||
@ApiOperation({ summary: 'Manually trigger category sync' })
|
||||
syncCategories() {
|
||||
return this.service.syncCategories();
|
||||
@ApiOperation({ summary: 'Manually trigger category sync (async)' })
|
||||
async syncCategories() {
|
||||
return this.service.startCategorySync();
|
||||
}
|
||||
|
||||
@Post('products')
|
||||
@ApiOperation({ summary: 'Manually trigger product sync' })
|
||||
syncProducts() {
|
||||
return this.service.syncProducts();
|
||||
@ApiOperation({ summary: 'Manually trigger product sync (async)' })
|
||||
async syncProducts() {
|
||||
return this.service.startProductSync();
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
|
||||
@@ -42,6 +42,32 @@ export class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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' };
|
||||
}
|
||||
|
||||
/** Check if a sync type is currently running. */
|
||||
isRunning(type: 'categories' | 'products'): boolean {
|
||||
return this.running[type];
|
||||
}
|
||||
|
||||
async syncCategories(): Promise<CategorySyncResult> {
|
||||
if (this.running.categories) {
|
||||
throw new Error('Category sync already in progress');
|
||||
@@ -54,57 +80,111 @@ export class SyncService {
|
||||
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));
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
// Single transaction: upsert + wire parents + delete stale.
|
||||
// SDS tree is the source of truth — anything not in the response gets deleted.
|
||||
const { inserted, updated, deletedStale } = await this.prisma.$transaction(async (tx) => {
|
||||
let ins = 0;
|
||||
let upd = 0;
|
||||
|
||||
// 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 },
|
||||
// 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: node.name,
|
||||
categoryIcon: node.icon ?? null,
|
||||
},
|
||||
});
|
||||
ins++;
|
||||
} else {
|
||||
await tx.category.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
categoryName: 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.
|
||||
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));
|
||||
|
||||
// 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: deletableIds.length };
|
||||
});
|
||||
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: 'SUCCESS',
|
||||
finishedAt: new Date(),
|
||||
message: `inserted=${inserted} updated=${updated} total=${flat.length}`,
|
||||
message: `inserted=${inserted} updated=${updated} total=${flat.length} staleDeleted=${deletedStale}`,
|
||||
},
|
||||
});
|
||||
return { inserted, updated, total: flat.length };
|
||||
@@ -147,6 +227,7 @@ export class SyncService {
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let total = 0;
|
||||
const seenSdsGoodIds = new Set<string>();
|
||||
|
||||
for (const leaf of leafRows) {
|
||||
const sdsCategoryId = leaf.sdsCategoryId!;
|
||||
@@ -157,6 +238,7 @@ export class SyncService {
|
||||
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++;
|
||||
@@ -172,12 +254,31 @@ export class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect delisted products: mark origin goods not seen in upstream as delisted,
|
||||
// and re-activate any previously delisted goods that reappeared.
|
||||
let delistedCount = 0;
|
||||
let reactivatedCount = 0;
|
||||
if (seenSdsGoodIds.size > 0) {
|
||||
const delistedResult = await this.prisma.originGood.updateMany({
|
||||
where: { sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false },
|
||||
data: { delisted: true },
|
||||
});
|
||||
const reactivatedResult = await this.prisma.originGood.updateMany({
|
||||
where: { sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true },
|
||||
data: { delisted: false },
|
||||
});
|
||||
delistedCount = delistedResult.count;
|
||||
reactivatedCount = reactivatedResult.count;
|
||||
} else {
|
||||
this.logger.warn('No products seen from SDS — skipping delist detection');
|
||||
}
|
||||
|
||||
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}`,
|
||||
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
|
||||
},
|
||||
});
|
||||
return { inserted, updated, total, leafCategories: leafRows.length };
|
||||
|
||||
Reference in New Issue
Block a user