+
+
+
+
+
+
+
+
数据同步
+
从上游 SDS 系统拉取最新分类和产品数据并同步到本地数据库
+
+
+
+
+ 同步分类
-
-
-
-
-
-
同步日志
-
-
- 刷新
+
+ 同步产品
+
-
-
-
-
- {{ row.status === 'SUCCESS' ? '成功' : '失败' }}
-
-
-
-
-
- {{ formatDate(row.startTime) }}
-
-
-
-
-
-
-
+
+
+
+ {{ stats.total }}
+ 总同步次数
+
+
+
+ {{ stats.success }}
+ 成功
+
+
+
+ {{ stats.failed }}
+ 失败
+
+
+
+ {{ stats.lastLog ? formatTime(stats.lastLog.startTime) : '-' }}
+ 最近同步
+
+
+
+
+
+
+
同步日志
+ 刷新
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ log.message }}
+
{{ formatTime(log.startTime) }}
+
+
+
diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts
index a695136..d813c2b 100644
--- a/apps/admin/vite.config.ts
+++ b/apps/admin/vite.config.ts
@@ -25,6 +25,7 @@ export default defineConfig({
},
},
server: {
+ host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
@@ -32,6 +33,14 @@ export default defineConfig({
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
+ '/uploads': {
+ target: 'http://localhost:3001',
+ changeOrigin: true,
+ },
+ '/assets': {
+ target: 'http://localhost:3001',
+ changeOrigin: true,
+ },
},
},
})
diff --git a/apps/api/.env.example b/apps/api/.env.example
deleted file mode 100644
index 4509b14..0000000
--- a/apps/api/.env.example
+++ /dev/null
@@ -1,6 +0,0 @@
-node_modules
-dist
-coverage
-*.log
-.DS_Store
-.env
\ No newline at end of file
diff --git a/apps/api/.gitignore b/apps/api/.gitignore
index d1aa042..e426336 100644
--- a/apps/api/.gitignore
+++ b/apps/api/.gitignore
@@ -42,4 +42,7 @@ lerna-debug.log*
# prisma/migrations (keep migrations in VCS)
# TypeScript
-*.tsbuildinfo
\ No newline at end of file
+*.tsbuildinfo
+
+# Uploads
+/uploads
\ No newline at end of file
diff --git a/apps/api/package.json b/apps/api/package.json
index 942a940..2cc6589 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -20,7 +20,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
- "prisma:studio": "prisma studio"
+ "prisma:studio": "prisma studio",
+ "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts"
},
"dependencies": {
"@nestjs/axios": "^3.0.1",
@@ -33,11 +34,13 @@
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17",
"@prisma/client": "^5.8.0",
+ "@types/multer": "^2.2.0",
"axios": "^1.6.5",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"express": "^4.21.0",
+ "multer": "^2.2.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.1",
@@ -69,4 +72,4 @@
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
}
-}
\ No newline at end of file
+}
diff --git a/apps/api/prisma/configure-product-center-icons.ts b/apps/api/prisma/configure-product-center-icons.ts
new file mode 100644
index 0000000..c9039ff
--- /dev/null
+++ b/apps/api/prisma/configure-product-center-icons.ts
@@ -0,0 +1,54 @@
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+const countryIcons: Record
= {
+ 美国: '/assets/product-center/countries/us.png',
+ 日本: '/assets/product-center/countries/jp.png',
+ 墨西哥: '/assets/product-center/countries/mx.png',
+ 巴西: '/assets/product-center/countries/br.png',
+ 中东: '/assets/product-center/countries/middle-east.png',
+ 波兰: '/assets/product-center/countries/pl.png',
+ 西班牙: '/assets/product-center/countries/es.png',
+ 德国: '/assets/product-center/countries/de.png',
+ 意大利: '/assets/product-center/countries/it.png',
+ 英国: '/assets/product-center/countries/gb.png',
+ 加拿大: '/assets/product-center/countries/ca.png',
+ 澳大利亚: '/assets/product-center/countries/au.png',
+ 韩国: '/assets/product-center/countries/kr.png',
+};
+
+const categoryIcons: Record = {
+ 男士服装: '/assets/product-center/categories/men.svg',
+ 女士服装: '/assets/product-center/categories/women.svg',
+ 儿童服装: '/assets/product-center/categories/children.svg',
+ 家居配饰: '/assets/product-center/categories/home.svg',
+};
+
+async function updateExistingIcons(
+ entries: Record,
+ update: (name: string, icon: string) => Promise<{ count: number }>,
+): Promise {
+ let updated = 0;
+ for (const [name, icon] of Object.entries(entries)) {
+ const result = await update(name, icon);
+ updated += result.count;
+ }
+ return updated;
+}
+
+async function main(): Promise {
+ const countries = await updateExistingIcons(countryIcons, (countryName, countryIcon) =>
+ prisma.country.updateMany({ where: { countryName }, data: { countryIcon } }),
+ );
+ const categories = await updateExistingIcons(categoryIcons, (categoryName, categoryIcon) =>
+ prisma.category.updateMany({
+ where: { categoryName, parentCategoryId: null },
+ data: { categoryIcon },
+ }),
+ );
+ console.log(`Configured ${countries} countries and ${categories} root categories.`);
+}
+
+main()
+ .finally(async () => prisma.$disconnect());
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 64bc945..0ce56fa 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -22,6 +22,7 @@ model OriginGood {
goodName String? @map("good_name")
goodImage String? @map("good_image")
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
+ delisted Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
diff --git a/apps/api/public/product-center/categories/children.svg b/apps/api/public/product-center/categories/children.svg
new file mode 100644
index 0000000..52c66cc
--- /dev/null
+++ b/apps/api/public/product-center/categories/children.svg
@@ -0,0 +1,7 @@
+
diff --git a/apps/api/public/product-center/categories/home.svg b/apps/api/public/product-center/categories/home.svg
new file mode 100644
index 0000000..916c3e9
--- /dev/null
+++ b/apps/api/public/product-center/categories/home.svg
@@ -0,0 +1,5 @@
+
diff --git a/apps/api/public/product-center/categories/men.svg b/apps/api/public/product-center/categories/men.svg
new file mode 100644
index 0000000..ff0570f
--- /dev/null
+++ b/apps/api/public/product-center/categories/men.svg
@@ -0,0 +1,6 @@
+
diff --git a/apps/api/public/product-center/categories/women.svg b/apps/api/public/product-center/categories/women.svg
new file mode 100644
index 0000000..0a2e3e2
--- /dev/null
+++ b/apps/api/public/product-center/categories/women.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/api/public/product-center/countries/au.png b/apps/api/public/product-center/countries/au.png
new file mode 100644
index 0000000..759dd00
Binary files /dev/null and b/apps/api/public/product-center/countries/au.png differ
diff --git a/apps/api/public/product-center/countries/br.png b/apps/api/public/product-center/countries/br.png
new file mode 100644
index 0000000..0dd6a0f
Binary files /dev/null and b/apps/api/public/product-center/countries/br.png differ
diff --git a/apps/api/public/product-center/countries/ca.png b/apps/api/public/product-center/countries/ca.png
new file mode 100644
index 0000000..44407f8
Binary files /dev/null and b/apps/api/public/product-center/countries/ca.png differ
diff --git a/apps/api/public/product-center/countries/de.png b/apps/api/public/product-center/countries/de.png
new file mode 100644
index 0000000..f46ff5b
Binary files /dev/null and b/apps/api/public/product-center/countries/de.png differ
diff --git a/apps/api/public/product-center/countries/es.png b/apps/api/public/product-center/countries/es.png
new file mode 100644
index 0000000..1f6ee33
Binary files /dev/null and b/apps/api/public/product-center/countries/es.png differ
diff --git a/apps/api/public/product-center/countries/gb.png b/apps/api/public/product-center/countries/gb.png
new file mode 100644
index 0000000..8bd3175
Binary files /dev/null and b/apps/api/public/product-center/countries/gb.png differ
diff --git a/apps/api/public/product-center/countries/it.png b/apps/api/public/product-center/countries/it.png
new file mode 100644
index 0000000..deb42ca
Binary files /dev/null and b/apps/api/public/product-center/countries/it.png differ
diff --git a/apps/api/public/product-center/countries/jp.png b/apps/api/public/product-center/countries/jp.png
new file mode 100644
index 0000000..6eac273
Binary files /dev/null and b/apps/api/public/product-center/countries/jp.png differ
diff --git a/apps/api/public/product-center/countries/kr.png b/apps/api/public/product-center/countries/kr.png
new file mode 100644
index 0000000..7cc29c8
Binary files /dev/null and b/apps/api/public/product-center/countries/kr.png differ
diff --git a/apps/api/public/product-center/countries/middle-east.png b/apps/api/public/product-center/countries/middle-east.png
new file mode 100644
index 0000000..bea8d5b
Binary files /dev/null and b/apps/api/public/product-center/countries/middle-east.png differ
diff --git a/apps/api/public/product-center/countries/mx.png b/apps/api/public/product-center/countries/mx.png
new file mode 100644
index 0000000..5e7b35c
Binary files /dev/null and b/apps/api/public/product-center/countries/mx.png differ
diff --git a/apps/api/public/product-center/countries/pl.png b/apps/api/public/product-center/countries/pl.png
new file mode 100644
index 0000000..66ced0a
Binary files /dev/null and b/apps/api/public/product-center/countries/pl.png differ
diff --git a/apps/api/public/product-center/countries/us.png b/apps/api/public/product-center/countries/us.png
new file mode 100644
index 0000000..da4a865
Binary files /dev/null and b/apps/api/public/product-center/countries/us.png differ
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 95994e5..bd82d6f 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -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 {}
\ No newline at end of file
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index 3a8a733..3eca3d0 100644
--- a/apps/api/src/main.ts
+++ b/apps/api/src/main.ts
@@ -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(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();
\ No newline at end of file
+bootstrap();
diff --git a/apps/api/src/origin-goods/origin-goods.service.ts b/apps/api/src/origin-goods/origin-goods.service.ts
index 0f972ef..ead444f 100644
--- a/apps/api/src/origin-goods/origin-goods.service.ts
+++ b/apps/api/src/origin-goods/origin-goods.service.ts
@@ -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()) ?? [],
diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts
index c742912..dd799f1 100644
--- a/apps/api/src/public/public.service.spec.ts
+++ b/apps/api/src/public/public.service.spec.ts
@@ -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(
diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts
index 611dbda..b36701f 100644
--- a/apps/api/src/public/public.service.ts
+++ b/apps/api/src/public/public.service.ts
@@ -89,7 +89,9 @@ export class PublicService {
}
async getGoods(query: PublicQueryGoodDto): Promise {
- 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;
}
-}
\ No newline at end of file
+}
diff --git a/apps/api/src/sync/sds-client.service.ts b/apps/api/src/sync/sds-client.service.ts
index 5c0c788..ab8880b 100644
--- a/apps/api/src/sync/sds-client.service.ts
+++ b/apps/api/src/sync/sds-client.service.ts
@@ -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(
+ method: 'post' | 'get',
+ url: string,
+ body?: unknown,
+ params?: Record,
+ ): Promise {
+ 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(url, body, config)
+ : this.http.get(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 {
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(url, body, { headers: POD_HEADERS }),
- );
- return Array.isArray(data) ? data : [];
+ const data = await this.request('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 {
const url = `${this.baseUrl}/products/page`;
- const { data } = await firstValueFrom(
- this.http.get(url, {
- headers: POD_HEADERS,
- params: { categoryId, page, size },
- }),
- );
- return data ?? {};
+ const data = await this.request('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;
}
}
diff --git a/apps/api/src/sync/sync.controller.ts b/apps/api/src/sync/sync.controller.ts
index 8b5e984..8e472de 100644
--- a/apps/api/src/sync/sync.controller.ts
+++ b/apps/api/src/sync/sync.controller.ts
@@ -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')
diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts
index 3112ae2..fd4a1d9 100644
--- a/apps/api/src/sync/sync.service.ts
+++ b/apps/api/src/sync/sync.service.ts
@@ -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 {
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();
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 };
diff --git a/apps/api/src/upload/upload.controller.ts b/apps/api/src/upload/upload.controller.ts
new file mode 100644
index 0000000..604dad6
--- /dev/null
+++ b/apps/api/src/upload/upload.controller.ts
@@ -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 };
+ }
+}
diff --git a/apps/api/src/upload/upload.module.ts b/apps/api/src/upload/upload.module.ts
new file mode 100644
index 0000000..b002ca9
--- /dev/null
+++ b/apps/api/src/upload/upload.module.ts
@@ -0,0 +1,7 @@
+import { Module } from '@nestjs/common';
+import { UploadController } from './upload.controller';
+
+@Module({
+ controllers: [UploadController],
+})
+export class UploadModule {}
diff --git a/apps/website/app/assets/css/tailwind.css b/apps/website/app/assets/css/tailwind.css
index 604883a..082f786 100644
--- a/apps/website/app/assets/css/tailwind.css
+++ b/apps/website/app/assets/css/tailwind.css
@@ -1,5 +1,42 @@
@import "tailwindcss";
+@font-face {
+ font-family: 'PingFang SC';
+ src: url('/fonts/pingfang-sc-thin.ttf') format('truetype');
+ font-display: swap;
+ font-style: normal;
+ font-weight: 100;
+}
+
+@font-face {
+ font-family: 'PingFang SC';
+ src: url('/fonts/pingfang-sc-regular.ttf') format('truetype');
+ font-display: swap;
+ font-style: normal;
+ font-weight: 400;
+}
+
+@font-face {
+ font-family: 'PingFang SC';
+ src: url('/fonts/pingfang-sc-medium.ttf') format('truetype');
+ font-display: swap;
+ font-style: normal;
+ font-weight: 500;
+}
+
+@font-face {
+ font-family: 'PingFang SC';
+ src: url('/fonts/pingfang-sc-semibold.ttf') format('truetype');
+ font-display: swap;
+ font-style: normal;
+ font-weight: 600 900;
+}
+
+html,
+body {
+ font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
+}
+
@theme {
--color-inkreach-homepage-1: #f3f4f5;
--color-inkreach-homepage-2: #f8f9fb;
diff --git a/apps/website/app/components/AppFooter.vue b/apps/website/app/components/AppFooter.vue
index 8497d6a..ee7f267 100644
--- a/apps/website/app/components/AppFooter.vue
+++ b/apps/website/app/components/AppFooter.vue
@@ -3,7 +3,7 @@
-
Inkreach
+
全球POD定制平台, 服务中国卖家出海
@@ -45,10 +45,3 @@
-
diff --git a/apps/website/app/components/AppHeader.vue b/apps/website/app/components/AppHeader.vue
index 2b0c9ed..b58bf46 100644
--- a/apps/website/app/components/AppHeader.vue
+++ b/apps/website/app/components/AppHeader.vue
@@ -2,7 +2,7 @@
import type { RecommendCategory, SolutionColumn, NavBanner } from '~/composables/useNavData';
const PORTAL_URL = 'https://inkpod.vip/portal/search';
-const LOGIN_URL = 'https://inkpod.vip/user/login';
+const SHOW_EXTENDED_NAV = false;
const mobileMenuOpen = ref(false);
const recommendMenuOpen = ref(false);
@@ -60,6 +60,10 @@ function closeMobileMenu(): void {
mobileSolutionOpen.value = false;
}
+function goToLogin(): void {
+ window.location.href = 'https://inkpod.vip/user/login';
+}
+
function toggleMobileRecommend(): void {
mobileRecommendOpen.value = !mobileRecommendOpen.value;
mobileSolutionOpen.value = false;
@@ -80,11 +84,11 @@ const isProductCenter = computed(() => route.path === '/product-center');
-
-

-
+
+
+
-