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:
yeuimu
2026-08-20 14:32:03 +08:00
parent b5fc88f3fa
commit 79fabd85f7
107 changed files with 5364 additions and 2601 deletions
+2
View File
@@ -11,6 +11,7 @@ import { OriginGoodsModule } from './origin-goods/origin-goods.module';
import { GoodsModule } from './goods/goods.module';
import { SyncModule } from './sync/sync.module';
import { PublicModule } from './public/public.module';
import { UploadModule } from './upload/upload.module';
@Module({
imports: [
@@ -28,6 +29,7 @@ import { PublicModule } from './public/public.module';
GoodsModule,
SyncModule,
PublicModule,
UploadModule,
],
})
export class AppModule {}
+16 -6
View File
@@ -1,13 +1,15 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { json } from 'express';
import { join } from 'path';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
// Replace Express's JSON parser with one that stringifies BigInt.
// Express's default `json()` throws "Do not know how to serialize a BigInt".
@@ -28,10 +30,18 @@ async function bootstrap() {
// CORS
app.enableCors({
origin: ['http://localhost:5173', 'http://localhost:3000'],
origin: true,
credentials: true,
});
// Serve uploaded files
app.useStaticAssets(join(process.cwd(), 'uploads'), {
prefix: '/uploads/',
});
app.useStaticAssets(join(process.cwd(), 'public'), {
prefix: '/assets/',
});
// Global pipes
app.useGlobalPipes(
new ValidationPipe({
@@ -57,9 +67,9 @@ async function bootstrap() {
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT ?? 3001;
await app.listen(port);
console.log(`🚀 Application is running on: http://localhost:${port}`);
console.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
await app.listen(port, '0.0.0.0');
console.log(`🚀 Application is running on: http://0.0.0.0:${port}`);
console.log(`📚 Swagger documentation: http://0.0.0.0:${port}/api/docs`);
}
// Make JSON.stringify aware of BigInt so outgoing responses containing
@@ -69,4 +79,4 @@ async function bootstrap() {
return this.toString();
};
bootstrap();
bootstrap();
@@ -29,6 +29,7 @@ export interface OriginGoodsTreeNode {
goodImage: string | null;
goodPrice: string | null;
sdsGoodId: string;
delisted: boolean;
configuredCount: number;
configuredCountries: string[];
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
@@ -110,7 +111,7 @@ export class OriginGoodsService {
parentCategoryId: true,
},
}),
this.prisma.originGood.findMany({ orderBy: { goodName: 'asc' } }),
this.prisma.originGood.findMany({ where: { delisted: false }, orderBy: { goodName: 'asc' } }),
this.prisma.good.groupBy({
by: ['originGoodId'],
_count: { _all: true },
@@ -194,7 +195,9 @@ export class OriginGoodsService {
const childrenCats = allCategories.filter(
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
);
const childNodes = childrenCats.map(buildNode);
const childNodes = childrenCats
.map(buildNode)
.filter((n) => n.totalCount > 0);
const ogsForThisCat = allOriginGoods.filter(
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
@@ -205,6 +208,7 @@ export class OriginGoodsService {
goodImage: og.goodImage,
goodPrice: og.goodPrice?.toString() ?? null,
sdsGoodId: og.sdsGoodId,
delisted: og.delisted,
configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [],
@@ -229,7 +233,7 @@ export class OriginGoodsService {
};
const roots = allCategories.filter((c) => c.parentCategoryId === null);
const tree = roots.map(buildNode);
const tree = roots.map(buildNode).filter((n) => n.totalCount > 0);
const unmapped = allOriginGoods.filter(
(og) => !ogToCategory.has(og.id.toString()),
@@ -250,6 +254,7 @@ export class OriginGoodsService {
goodImage: og.goodImage,
goodPrice: og.goodPrice?.toString() ?? null,
sdsGoodId: og.sdsGoodId,
delisted: og.delisted,
configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [],
+14 -1
View File
@@ -193,6 +193,19 @@ describe('PublicService', () => {
expect(priorities).toEqual(sorted);
});
it('returns the SDS product id as the public product id', async () => {
const result = await service.getGoods({
page: 1,
pageSize: 1,
countryId: Number(countryId),
keyword: `Pub High ${stamp}`,
});
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe(`pub-sds-${stamp}`);
expect(result.items[0].id).not.toBe(goodIds[0].toString());
});
it('getGood returns detail and 404 for unknown id', async () => {
const first = await service.getGoods({
page: 1,
@@ -201,7 +214,7 @@ describe('PublicService', () => {
keyword: `Pub `,
});
expect(first.items.length).toBe(1);
const detail = await service.getGood(BigInt(first.items[0].id));
const detail = await service.getGood(goodIds[0]);
expect(detail.id).toBe(first.items[0].id);
await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf(
+7 -4
View File
@@ -89,7 +89,9 @@ export class PublicService {
}
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
const where: Prisma.GoodWhereInput = {};
const where: Prisma.GoodWhereInput = {
originGood: { delisted: false },
};
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
if (query.tagIds) {
const ids = query.tagIds
@@ -154,9 +156,10 @@ export class PublicService {
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null;
position: { id: bigint; indexVal: number } | null;
originGood: {
sdsGoodId: string;
goodImage: string | null;
goodPrice: { toString(): string } | null;
} | null;
};
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[];
createdAt: Date;
}): PublicGoodDto {
@@ -169,7 +172,7 @@ export class PublicService {
}
: null;
return {
id: good.id.toString(),
id: good.originGood.sdsGoodId,
goodName: good.goodName,
goodPriority: good.goodPriority,
country: {
@@ -250,4 +253,4 @@ export class PublicService {
}
return roots;
}
}
}
+43 -18
View File
@@ -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;
}
}
+6 -6
View File
@@ -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')
+143 -42
View File
@@ -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 };
+42
View File
@@ -0,0 +1,42 @@
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import { randomUUID } from 'crypto';
const UPLOAD_DIR = join(process.cwd(), 'uploads');
@Controller('upload')
export class UploadController {
@Post('image')
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: UPLOAD_DIR,
filename: (_req, file, cb) => {
const ext = extname(file.originalname) || '.png';
cb(null, `${randomUUID()}${ext}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new BadRequestException('仅支持图片文件'), false);
}
cb(null, true);
},
}),
)
uploadImage(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('请选择要上传的文件');
}
return { url: `/uploads/${file.filename}`, filename: file.filename };
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
@Module({
controllers: [UploadController],
})
export class UploadModule {}