diff --git a/apps/api/package.json b/apps/api/package.json index 2cc6589..3859875 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,7 +21,8 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", - "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts" + "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", + "import:product-detail": "ts-node prisma/import-product-detail.ts" }, "dependencies": { "@nestjs/axios": "^3.0.1", diff --git a/apps/api/prisma/import-product-detail.ts b/apps/api/prisma/import-product-detail.ts new file mode 100644 index 0000000..119d43e --- /dev/null +++ b/apps/api/prisma/import-product-detail.ts @@ -0,0 +1,36 @@ +import { readFile } from 'fs/promises'; +import { resolve } from 'path'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../src/app.module'; +import { SyncService } from '../src/sync/sync.service'; +import { SdsProductDetail } from '../src/sync/sds-client.service'; + +async function main(): Promise { + const inputPath = process.argv[2]; + if (!inputPath) { + throw new Error('Usage: pnpm --filter @inkreach/api import:product-detail -- '); + } + const absolutePath = resolve(inputPath); + const raw = await readFile(absolutePath, 'utf8'); + const jsonStart = raw.indexOf('{'); + if (jsonStart < 0) throw new Error('No JSON object found in product detail file'); + const detail = JSON.parse(raw.slice(jsonStart)) as SdsProductDetail; + + const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] }); + try { + const result = await app.get(SyncService).importProductDetail(detail); + process.stdout.write( + `Imported SDS product ${result.goodId}: ${result.variants} variants, ` + + `${result.sizeRows} size rows, ${result.packageRows} package rows, ` + + `${result.configuredGoods} configured goods\n`, + ); + } finally { + await app.close(); + } +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/apps/api/prisma/migrations/20260821000000_add_origin_good_details/migration.sql b/apps/api/prisma/migrations/20260821000000_add_origin_good_details/migration.sql new file mode 100644 index 0000000..a50644c --- /dev/null +++ b/apps/api/prisma/migrations/20260821000000_add_origin_good_details/migration.sql @@ -0,0 +1,70 @@ +-- Cache SDS product details separately from website merchandising configuration. +CREATE TABLE "origin_good_details" ( + "origin_good_id" BIGINT NOT NULL, + "product_code" TEXT, + "english_name" TEXT, + "blank_design_url" TEXT, + "details_page_video_url" TEXT, + "texture_name" TEXT, + "production_cycle_hours" INTEGER, + "min_weight_g" DECIMAL(12,3), + "reminder" TEXT, + "production_process" TEXT, + "material_description" TEXT, + "product_performance" TEXT, + "applicable_scenarios" TEXT, + "washing_instructions" TEXT, + "special_description" TEXT, + "design_explanation" TEXT, + "design_area" TEXT, + "picture_request" TEXT, + "size_chart" JSONB, + "package_specs" JSONB, + "options" JSONB, + "media" JSONB, + "upstream_updated_at" TIMESTAMPTZ(6), + "synced_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "origin_good_details_pkey" PRIMARY KEY ("origin_good_id") +); + +CREATE TABLE "origin_good_variants" ( + "origin_good_variant_id" BIGSERIAL NOT NULL, + "origin_good_id" BIGINT NOT NULL, + "sds_variant_id" TEXT NOT NULL, + "sku" TEXT NOT NULL, + "size_id" TEXT, + "size_name" TEXT, + "color_id" TEXT, + "color_name" TEXT, + "color_hex" TEXT, + "image_url" TEXT, + "price" DECIMAL(12,2), + "original_price" DECIMAL(12,2), + "weight_g" DECIMAL(12,3), + "box_length_cm" DECIMAL(12,3), + "box_width_cm" DECIMAL(12,3), + "box_height_cm" DECIMAL(12,3), + "enabled" BOOLEAN NOT NULL DEFAULT true, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "design_data" JSONB, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "origin_good_variants_pkey" PRIMARY KEY ("origin_good_variant_id") +); + +CREATE UNIQUE INDEX "origin_good_variants_origin_good_id_sds_variant_id_key" +ON "origin_good_variants"("origin_good_id", "sds_variant_id"); +CREATE INDEX "origin_good_variants_origin_good_id_sort_order_idx" +ON "origin_good_variants"("origin_good_id", "sort_order"); +CREATE INDEX "origin_good_variants_sku_idx" ON "origin_good_variants"("sku"); + +ALTER TABLE "origin_good_details" +ADD CONSTRAINT "origin_good_details_origin_good_id_fkey" +FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") +ON DELETE CASCADE ON UPDATE NO ACTION; + +ALTER TABLE "origin_good_variants" +ADD CONSTRAINT "origin_good_variants_origin_good_id_fkey" +FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") +ON DELETE CASCADE ON UPDATE NO ACTION; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0ce56fa..6d46ada 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -15,23 +15,90 @@ datasource db { // ---------- Origin Goods ---------- model OriginGood { - id BigInt @id @default(autoincrement()) @map("origin_good_id") - sdsGoodId String @unique @map("sds_good_id") + id BigInt @id @default(autoincrement()) @map("origin_good_id") + sdsGoodId String @unique @map("sds_good_id") // Cached SDS product metadata (filled during sync) - sdsCategoryId String? @map("sds_category_id") - 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) + sdsCategoryId String? @map("sds_category_id") + 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) - goods Good[] + goods Good[] + detail OriginGoodDetail? + variants OriginGoodVariant[] @@index([sdsCategoryId]) @@map("origin_goods") } +// ---------- Origin Good Details (cached from SDS /products/{id}) ---------- +model OriginGoodDetail { + originGoodId BigInt @id @map("origin_good_id") + productCode String? @map("product_code") + englishName String? @map("english_name") + blankDesignUrl String? @map("blank_design_url") + detailsPageVideoUrl String? @map("details_page_video_url") + textureName String? @map("texture_name") + productionCycleHours Int? @map("production_cycle_hours") + minWeightG Decimal? @map("min_weight_g") @db.Decimal(12, 3) + reminder String? + productionProcess String? @map("production_process") + materialDescription String? @map("material_description") + productPerformance String? @map("product_performance") + applicableScenarios String? @map("applicable_scenarios") + washingInstructions String? @map("washing_instructions") + specialDescription String? @map("special_description") + designExplanation String? @map("design_explanation") + designArea String? @map("design_area") + pictureRequest String? @map("picture_request") + sizeChart Json? @map("size_chart") + packageSpecs Json? @map("package_specs") + options Json? + media Json? + upstreamUpdatedAt DateTime? @map("upstream_updated_at") @db.Timestamptz(6) + syncedAt DateTime @default(now()) @map("synced_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + + originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@map("origin_good_details") +} + +// ---------- Origin Good Variants (cached SDS child products / SKUs) ---------- +model OriginGoodVariant { + id BigInt @id @default(autoincrement()) @map("origin_good_variant_id") + originGoodId BigInt @map("origin_good_id") + sdsVariantId String @map("sds_variant_id") + sku String + sizeId String? @map("size_id") + sizeName String? @map("size_name") + colorId String? @map("color_id") + colorName String? @map("color_name") + colorHex String? @map("color_hex") + imageUrl String? @map("image_url") + price Decimal? @db.Decimal(12, 2) + originalPrice Decimal? @map("original_price") @db.Decimal(12, 2) + weightG Decimal? @map("weight_g") @db.Decimal(12, 3) + boxLengthCm Decimal? @map("box_length_cm") @db.Decimal(12, 3) + boxWidthCm Decimal? @map("box_width_cm") @db.Decimal(12, 3) + boxHeightCm Decimal? @map("box_height_cm") @db.Decimal(12, 3) + enabled Boolean @default(true) + sortOrder Int @default(0) @map("sort_order") + designData Json? @map("design_data") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + + originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([originGoodId, sdsVariantId]) + @@index([originGoodId, sortOrder]) + @@index([sku]) + @@map("origin_good_variants") +} + // ---------- Countries ---------- model Country { id BigInt @id @default(autoincrement()) @map("country_id") @@ -48,18 +115,18 @@ model Country { // ---------- Categories (self-referential tree) ---------- model Category { - id BigInt @id @default(autoincrement()) @map("category_id") - parentCategoryId BigInt? @map("parent_category_id") - categoryName String @map("category_name") - categoryIcon String? @map("category_icon") - sdsCategoryId String? @unique @map("sds_category_id") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + id BigInt @id @default(autoincrement()) @map("category_id") + parentCategoryId BigInt? @map("parent_category_id") + categoryName String @map("category_name") + categoryIcon String? @map("category_icon") + sdsCategoryId String? @unique @map("sds_category_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) - parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction) - children Category[] @relation("CategoryToCategory") - goods Good[] - positions Position[] + parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction) + children Category[] @relation("CategoryToCategory") + goods Good[] + positions Position[] @@index([parentCategoryId]) @@map("categories") @@ -94,7 +161,7 @@ model Tag { goods Good[] goodTags GoodTag[] - tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction) + tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction) @@index([tagGroupId]) @@index([tagGroupId, sortOrder]) @@ -122,17 +189,17 @@ model Position { // ---------- Goods ---------- model Good { - id BigInt @id @default(autoincrement()) @map("good_id") - originGoodId BigInt @map("origin_good_id") - countryId BigInt @map("country_id") - categoryId BigInt @map("category_id") - tagId BigInt? @map("tag_id") - positionId BigInt? @map("position_id") - goodName String @map("good_name") - goodImage String? @map("good_image") - goodPriority Int @default(0) @map("good_priority") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + id BigInt @id @default(autoincrement()) @map("good_id") + originGoodId BigInt @map("origin_good_id") + countryId BigInt @map("country_id") + categoryId BigInt @map("category_id") + tagId BigInt? @map("tag_id") + positionId BigInt? @map("position_id") + goodName String @map("good_name") + goodImage String? @map("good_image") + goodPriority Int @default(0) @map("good_priority") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction) country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction) diff --git a/apps/api/src/public/dto/public-category.dto.ts b/apps/api/src/public/dto/public-category.dto.ts index 67783b4..73350e5 100644 --- a/apps/api/src/public/dto/public-category.dto.ts +++ b/apps/api/src/public/dto/public-category.dto.ts @@ -17,6 +17,9 @@ export class PublicCategoryNodeDto { @ApiProperty({ type: [PublicCategoryNodeDto] }) children!: PublicCategoryNodeDto[]; + @ApiProperty({ description: '当前节点及其后代分类的商品数量' }) + productCount!: number; + static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto { return { id: category.id.toString(), @@ -26,6 +29,7 @@ export class PublicCategoryNodeDto { ? category.parentCategoryId.toString() : null, children, + productCount: 0, }; } } diff --git a/apps/api/src/public/dto/public-good-detail.dto.ts b/apps/api/src/public/dto/public-good-detail.dto.ts new file mode 100644 index 0000000..43a0217 --- /dev/null +++ b/apps/api/src/public/dto/public-good-detail.dto.ts @@ -0,0 +1,81 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PublicGoodDto } from './public-good.dto'; + +export class PublicGoodDetailDto extends PublicGoodDto { + @ApiProperty({ nullable: true }) + productCode!: string | null; + + @ApiProperty({ nullable: true }) + englishName!: string | null; + + @ApiProperty({ nullable: true }) + productionCycleHours!: number | null; + + @ApiProperty({ nullable: true }) + minWeightG!: string | null; + + @ApiProperty({ type: Object }) + details!: Record; + + @ApiProperty({ nullable: true, type: Object }) + media!: Record | null; + + @ApiProperty({ nullable: true, type: Object }) + options!: Record | null; + + @ApiProperty({ nullable: true, type: Object }) + sizeChart!: Record | null; + + @ApiProperty({ nullable: true, type: Object }) + packageSpecs!: Record | null; + + @ApiProperty({ type: Array }) + variants!: Array<{ + id: string; + sku: string; + sizeId: string | null; + sizeName: string | null; + colorId: string | null; + colorName: string | null; + colorHex: string | null; + imageUrl: string | null; + price: string | null; + originalPrice: string | null; + weightG: string | null; + boxLengthCm: string | null; + boxWidthCm: string | null; + boxHeightCm: string | null; + enabled: boolean; + sortOrder: number; + }>; + + @ApiProperty({ nullable: true }) + detailSyncedAt!: string | null; +} + +export class PublicTagGroupFilterDto { + @ApiProperty() + id!: string; + + @ApiProperty() + groupName!: string; + + @ApiProperty({ nullable: true }) + groupIcon!: string | null; + + @ApiProperty({ nullable: true }) + groupColor!: string | null; + + @ApiProperty() + sortOrder!: number; + + @ApiProperty({ type: Array }) + tags!: Array<{ + id: string; + tagName: string; + tagColor: string | null; + tagFontColor: string | null; + sortOrder: number; + productCount: number; + }>; +} diff --git a/apps/api/src/public/dto/public-good.dto.ts b/apps/api/src/public/dto/public-good.dto.ts index ec90ed9..a82a156 100644 --- a/apps/api/src/public/dto/public-good.dto.ts +++ b/apps/api/src/public/dto/public-good.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; export class PublicGoodDto { @ApiProperty() - id!: string; + goodId!: string; @ApiProperty() goodName!: string; @@ -33,4 +33,4 @@ export class PublicGoodDto { @ApiProperty() createdAt!: string; -} \ No newline at end of file +} diff --git a/apps/api/src/public/dto/public-query-good.dto.ts b/apps/api/src/public/dto/public-query-good.dto.ts index 1a80046..620f313 100644 --- a/apps/api/src/public/dto/public-query-good.dto.ts +++ b/apps/api/src/public/dto/public-query-good.dto.ts @@ -1,13 +1,25 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { + IsArray, + IsIn, IsInt, + IsNumberString, IsOptional, IsString, Max, Min, } from 'class-validator'; +const stringList = ({ value }: { value: unknown }): string[] | undefined => { + if (value === undefined || value === null || value === '') return undefined; + const values = Array.isArray(value) ? value : [value]; + return values + .flatMap((item) => String(item).split(',')) + .map((item) => item.trim()) + .filter(Boolean); +}; + export class PublicQueryGoodDto { @ApiProperty({ required: false, default: 1 }) @IsOptional() @@ -16,7 +28,7 @@ export class PublicQueryGoodDto { @Min(1) page: number = 1; - @ApiProperty({ required: false, default: 20 }) + @ApiProperty({ required: false, default: 20, maximum: 200 }) @IsOptional() @Type(() => Number) @IsInt() @@ -24,25 +36,78 @@ export class PublicQueryGoodDto { @Max(200) pageSize: number = 20; - @ApiProperty({ required: false }) + @ApiProperty({ required: false, type: String, description: '不传表示全部国家' }) @IsOptional() - @Type(() => Number) - @IsInt() - countryId?: number; + @IsNumberString() + countryId?: string; + + @ApiProperty({ required: false, type: String }) + @IsOptional() + @IsNumberString() + categoryId?: string; + + @ApiProperty({ required: false, type: [String], description: '标签 ID;支持重复参数或逗号分隔' }) + @IsOptional() + @Transform(stringList) + @IsArray() + @IsNumberString({}, { each: true }) + tagIds?: string[]; + + @ApiProperty({ required: false, type: [String], description: '印刷工艺标签 ID' }) + @IsOptional() + @Transform(stringList) + @IsArray() + @IsNumberString({}, { each: true }) + craftIds?: string[]; + + @ApiProperty({ required: false, type: [String], description: '材质标签 ID' }) + @IsOptional() + @Transform(stringList) + @IsArray() + @IsNumberString({}, { each: true }) + materialIds?: string[]; + + @ApiProperty({ required: false, enum: ['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], isArray: true }) + @IsOptional() + @Transform(stringList) + @IsArray() + @IsIn(['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], { each: true }) + freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>; @ApiProperty({ required: false }) @IsOptional() - @Type(() => Number) - @IsInt() - categoryId?: number; - - @ApiProperty({ required: false, description: 'Comma-separated tag IDs, e.g. "30,34"' }) - @IsOptional() @IsString() - tagIds?: string; + minPrice?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + maxPrice?: string; + + @ApiProperty({ required: false, enum: ['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'] }) + @IsOptional() + @IsIn(['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST']) + sort?: 'DEFAULT' | 'PRICE_ASC' | 'PRICE_DESC' | 'NEWEST'; @ApiProperty({ required: false }) @IsOptional() @IsString() keyword?: string; } + +export class PublicCountryQueryDto { + @ApiProperty({ required: false, type: String, description: '不传表示全部国家' }) + @IsOptional() + @IsNumberString() + countryId?: string; +} + +export class PublicHomeGoodsQueryDto extends PublicCountryQueryDto { + @ApiProperty({ required: false, default: 10, maximum: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit: number = 10; +} diff --git a/apps/api/src/public/public.controller.ts b/apps/api/src/public/public.controller.ts index a1bce0a..9b96e50 100644 --- a/apps/api/src/public/public.controller.ts +++ b/apps/api/src/public/public.controller.ts @@ -2,14 +2,18 @@ import { Controller, Get, Param, - ParseIntPipe, Query, } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { PublicService } from './public.service'; -import { PublicQueryGoodDto } from './dto/public-query-good.dto'; +import { + PublicCountryQueryDto, + PublicHomeGoodsQueryDto, + PublicQueryGoodDto, +} from './dto/public-query-good.dto'; import { PublicTagDto } from './dto/public-tag.dto'; -import { PublicTagGroupDto } from './dto/public-tag-group.dto'; +import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto'; +import { PublicGoodDto } from './dto/public-good.dto'; @ApiTags('public') @Controller('public') @@ -17,9 +21,9 @@ export class PublicController { constructor(private readonly service: PublicService) {} @Get('categories') - @ApiOperation({ summary: 'Public list of categories that have goods' }) - getCategories() { - return this.service.getCategoriesTree(); + @ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' }) + getCategories(@Query() query: PublicCountryQueryDto) { + return this.service.getCategoriesTree(query.countryId); } @Get('countries') @@ -35,20 +39,30 @@ export class PublicController { } @Get('tag-groups') - @ApiOperation({ summary: 'Public list of tag groups that have goods' }) - getTagGroups(): Promise { - return this.service.getTagGroups(); + @ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' }) + @ApiOkResponse({ type: [PublicTagGroupFilterDto] }) + getTagGroups(@Query() query: PublicCountryQueryDto): Promise { + return this.service.getTagGroups(query.countryId); } @Get('goods') - @ApiOperation({ summary: 'Public paginated goods with filters' }) + @ApiOperation({ summary: '分页获取商品' }) getGoods(@Query() query: PublicQueryGoodDto) { return this.service.getGoods(query); } - @Get('goods/:id') - @ApiOperation({ summary: 'Public good detail' }) - getGood(@Param('id', ParseIntPipe) id: string) { - return this.service.getGood(BigInt(id)); + @Get('goods/:goodId') + @ApiOperation({ summary: '获取商品完整详情' }) + @ApiParam({ name: 'goodId', type: String, example: '168746' }) + @ApiOkResponse({ type: PublicGoodDetailDto }) + getGood(@Param('goodId') goodId: string): Promise { + return this.service.getGood(goodId); + } + + @Get('home-goods') + @ApiOperation({ summary: '获取首页商品;可按国家返回' }) + @ApiOkResponse({ type: [PublicGoodDto] }) + getHomeGoods(@Query() query: PublicHomeGoodsQueryDto): Promise { + return this.service.getHomeGoods(query); } } diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index dd799f1..68d4bfe 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -12,6 +12,8 @@ describe('PublicService', () => { let childCategoryId: bigint; let otherCategoryId: bigint; let tagId: bigint; + let filterGroupIds: bigint[] = []; + let filterTagIds: bigint[] = []; let originGoodId: bigint; let goodIds: bigint[] = []; @@ -98,6 +100,50 @@ describe('PublicService', () => { }); goodIds = [g1.id, g2.id, g3.id]; + const craftGroup = await prisma.tagGroup.create({ + data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 }, + }); + const materialGroup = await prisma.tagGroup.create({ + data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 }, + }); + filterGroupIds = [craftGroup.id, materialGroup.id]; + const craftA = await prisma.tag.create({ + data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id }, + }); + const craftB = await prisma.tag.create({ + data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id }, + }); + const cotton = await prisma.tag.create({ + data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id }, + }); + filterTagIds = [craftA.id, craftB.id, cotton.id]; + await prisma.goodTag.createMany({ + data: [ + { goodId: g1.id, tagId: craftA.id }, + { goodId: g1.id, tagId: cotton.id }, + { goodId: g2.id, tagId: craftB.id }, + ], + }); + + await prisma.originGoodDetail.create({ + data: { + originGoodId, + productCode: 'OZ10827003', + productionProcess: '白墨烫画', + sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] }, + packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] }, + }, + }); + await prisma.originGoodVariant.create({ + data: { + originGoodId, + sdsVariantId: `pub-variant-${stamp}`, + sku: `OZ${stamp}`, + sizeName: 'S', + price: 38, + }, + }); + // Seed a good in `otherCategory` so the "onlyHaveGoods" filter // returns more than one category. await prisma.good.create({ @@ -134,6 +180,8 @@ describe('PublicService', () => { where: { countryId }, }); await prisma.tag.delete({ where: { id: tagId } }); + await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } }); + await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } }); await prisma.originGood.delete({ where: { id: originGoodId } }); // Delete children before parent (FK self-relation is RESTRICT). await prisma.category.delete({ where: { id: childCategoryId } }); @@ -172,8 +220,8 @@ describe('PublicService', () => { const filtered = await service.getGoods({ page: 1, pageSize: 50, - countryId: Number(countryId), - categoryId: Number(categoryId), // includes child + countryId: countryId.toString(), + categoryId: categoryId.toString(), // includes child keyword: `Pub `, }); expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood @@ -184,7 +232,7 @@ describe('PublicService', () => { const result = await service.getGoods({ page: 1, pageSize: 50, - countryId: Number(countryId), + countryId: countryId.toString(), keyword: `Pub `, }); const priorities = result.items.map((g) => g.goodPriority); @@ -193,31 +241,59 @@ describe('PublicService', () => { expect(priorities).toEqual(sorted); }); + it('uses OR within one tag group and AND across tag groups', async () => { + const sameGroup = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + tagIds: filterTagIds.slice(0, 2).map(String), + }); + expect(sameGroup.items.map((item) => item.goodName)).toEqual( + expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]), + ); + + const acrossGroups = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + tagIds: filterTagIds.map(String), + }); + expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`); + expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); + }); + it('returns the SDS product id as the public product id', async () => { const result = await service.getGoods({ page: 1, pageSize: 1, - countryId: Number(countryId), + countryId: countryId.toString(), 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()); + expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`); + expect(result.items[0].goodId).not.toBe(goodIds[0].toString()); }); it('getGood returns detail and 404 for unknown id', async () => { const first = await service.getGoods({ page: 1, pageSize: 1, - countryId: Number(countryId), + countryId: countryId.toString(), keyword: `Pub `, }); expect(first.items.length).toBe(1); - const detail = await service.getGood(goodIds[0]); - expect(detail.id).toBe(first.items[0].id); + const detail = await service.getGood(`pub-sds-${stamp}`); + expect(detail.goodId).toBe(first.items[0].goodId); + expect(detail.productCode).toBe('OZ10827003'); + expect(detail.details.productionProcess).toBe('白墨烫画'); + expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1); + expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1); + expect(detail.variants).toHaveLength(1); - await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf( + await expect(service.getGood('99999999')).rejects.toBeInstanceOf( NotFoundException, ); }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index b36701f..dbda7a1 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -1,12 +1,19 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Category as PrismaCategory, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; -import { PublicQueryGoodDto } from './dto/public-query-good.dto'; +import { + PublicHomeGoodsQueryDto, + PublicQueryGoodDto, +} from './dto/public-query-good.dto'; import { PublicCategoryNodeDto } from './dto/public-category.dto'; import { PublicCountryDto } from './dto/public-country.dto'; import { PublicTagDto } from './dto/public-tag.dto'; import { PublicTagGroupDto } from './dto/public-tag-group.dto'; import { PublicGoodDto } from './dto/public-good.dto'; +import { + PublicGoodDetailDto, + PublicTagGroupFilterDto, +} from './dto/public-good-detail.dto'; export interface PublicPaginatedGoods { items: PublicGoodDto[]; @@ -20,17 +27,28 @@ const PUBLIC_GOOD_INCLUDE = { category: true, tag: { include: { tagGroup: true } }, position: true, - originGood: true, + originGood: { + include: { + detail: true, + variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + }, + }, goodTags: { include: { tag: { include: { tagGroup: true } } } }, } satisfies Prisma.GoodInclude; +type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>; + @Injectable() export class PublicService { constructor(private readonly prisma: PrismaService) {} - async getCategoriesTree(): Promise { + async getCategoriesTree(countryId?: string): Promise { + const goodsWhere: Prisma.GoodWhereInput = { + originGood: { delisted: false }, + ...(countryId ? { countryId: BigInt(countryId) } : {}), + }; const leafCategories = await this.prisma.category.findMany({ - where: { goods: { some: {} } }, + where: { goods: { some: goodsWhere } }, orderBy: { id: 'asc' }, }); const ancestorIds = new Set(); @@ -46,22 +64,30 @@ export class PublicService { cursor = parent.parentCategoryId; } } - const ancestorRows = ancestorIds.size > 0 + const ancestorRows = ancestorIds.size ? await this.prisma.category.findMany({ where: { id: { in: [...ancestorIds] } }, orderBy: { id: 'asc' }, }) : []; const allRows = [...leafCategories, ...ancestorRows].filter( - (row, idx, arr) => arr.findIndex((r) => r.id === row.id) === idx, + (row, index, rows) => rows.findIndex((item) => item.id === row.id) === index, ); allRows.sort((a, b) => Number(a.id - b.id)); - return this.buildTree(allRows); + const directCounts = await this.prisma.good.groupBy({ + by: ['categoryId'], + where: goodsWhere, + _count: { _all: true }, + }); + return this.buildTree( + allRows, + new Map(directCounts.map((row) => [row.categoryId, row._count._all])), + ); } async getCountries(): Promise { const rows = await this.prisma.country.findMany({ - where: { goods: { some: {} } }, + where: { goods: { some: { originGood: { delisted: false } } } }, orderBy: { id: 'asc' }, }); return rows.map(PublicCountryDto.from); @@ -69,7 +95,7 @@ export class PublicService { async getTags(): Promise { const rows = await this.prisma.tag.findMany({ - where: { goodTags: { some: {} } }, + where: { goodTags: { some: { good: { originGood: { delisted: false } } } } }, orderBy: [ { tagGroup: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, @@ -80,99 +106,139 @@ export class PublicService { return rows.map(PublicTagDto.from); } - async getTagGroups(): Promise { + async getTagGroups(countryId?: string): Promise { + const goodWhere: Prisma.GoodWhereInput = { + originGood: { delisted: false }, + ...(countryId ? { countryId: BigInt(countryId) } : {}), + }; const rows = await this.prisma.tagGroup.findMany({ - where: { tags: { some: { goodTags: { some: {} } } } }, + where: { tags: { some: { goodTags: { some: { good: goodWhere } } } } }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + include: { + tags: { + where: { goodTags: { some: { good: goodWhere } } }, + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + include: { + _count: { select: { goodTags: { where: { good: goodWhere } } } }, + }, + }, + }, }); - return rows.map(PublicTagGroupDto.from); + return rows.map((group) => ({ + ...PublicTagGroupDto.from(group), + tags: group.tags.map((tag) => ({ + id: tag.id.toString(), + tagName: tag.tagName, + tagColor: tag.tagColor, + tagFontColor: tag.tagFontColor, + sortOrder: tag.sortOrder, + productCount: tag._count.goodTags, + })), + })); } async getGoods(query: PublicQueryGoodDto): Promise { - const where: Prisma.GoodWhereInput = { - originGood: { delisted: false }, - }; - if (query.countryId !== undefined) where.countryId = BigInt(query.countryId); - if (query.tagIds) { - const ids = query.tagIds - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - .map((s) => BigInt(s)); - if (ids.length > 0) { - // AND logic: 商品必须同时具备所有选中的 tag - where.AND = ids.map((id) => ({ goodTags: { some: { tagId: id } } })); - } + const where: Prisma.GoodWhereInput = { originGood: { delisted: false } }; + if (query.countryId) where.countryId = BigInt(query.countryId); + if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' }; + if (query.categoryId) { + where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) }; } - if (query.keyword) { - where.goodName = { contains: query.keyword, mode: 'insensitive' }; + + const tagIds = [ + ...new Set([ + ...(query.tagIds ?? []), + ...(query.craftIds ?? []), + ...(query.materialIds ?? []), + ]), + ]; + const tagFilters = await this.buildTagGroupFilters(tagIds, query.freeShipping); + if (tagFilters.length) where.AND = tagFilters; + + const minPrice = this.parsePrice(query.minPrice, 'minPrice'); + const maxPrice = this.parsePrice(query.maxPrice, 'maxPrice'); + if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) { + throw new BadRequestException('minPrice 不能大于 maxPrice'); } - if (query.categoryId !== undefined) { - const ids = await this.collectCategoryDescendants(BigInt(query.categoryId)); - where.categoryId = { in: ids }; + if (minPrice !== null || maxPrice !== null) { + where.originGood = { + delisted: false, + goodPrice: { + ...(minPrice !== null ? { gte: minPrice } : {}), + ...(maxPrice !== null ? { lte: maxPrice } : {}), + }, + }; } + const orderBy: Prisma.GoodOrderByWithRelationInput[] = + query.sort === 'PRICE_ASC' + ? [{ originGood: { goodPrice: 'asc' } }, { id: 'asc' }] + : query.sort === 'PRICE_DESC' + ? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }] + : query.sort === 'NEWEST' + ? [{ createdAt: 'desc' }, { id: 'asc' }] + : [ + { goodPriority: 'desc' }, + { position: { indexVal: 'asc' } }, + { createdAt: 'desc' }, + { id: 'asc' }, + ]; + const [total, rows] = await this.prisma.$transaction([ this.prisma.good.count({ where }), this.prisma.good.findMany({ where, include: PUBLIC_GOOD_INCLUDE, - // Server-side primary sort; PublicGoodDto retains original indexes - // for stable pagination but the final ORDER BY is mirrored below. - orderBy: [ - { goodPriority: 'desc' }, - { position: { indexVal: 'asc' } }, - { createdAt: 'desc' }, - ], + orderBy, skip: (query.page - 1) * query.pageSize, take: query.pageSize, }), ]); - return { - items: rows.map((g) => this.toPublicGood(g)), + items: rows.map((good) => this.toPublicGood(good)), total, page: query.page, pageSize: query.pageSize, }; } - async getGood(id: bigint): Promise { - const good = await this.prisma.good.findUnique({ - where: { id }, + async getGood(goodId: string): Promise { + const good = await this.prisma.good.findFirst({ + where: { originGood: { sdsGoodId: goodId, delisted: false } }, include: PUBLIC_GOOD_INCLUDE, + orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }], }); - if (!good) throw new NotFoundException(`Good ${id} not found`); - return this.toPublicGood(good); + if (!good) { + throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' }); + } + return this.toPublicGoodDetail(good); } - private toPublicGood(good: { - id: bigint; - goodName: string; - goodImage: string | null; - goodPriority: number; - country: { id: bigint; countryName: string; countryIcon: string | null }; - category: { id: bigint; categoryName: string; categoryIcon: string | null }; - 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; - }; - goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[]; - createdAt: Date; - }): PublicGoodDto { - const formatGroup = (g: { id: bigint; groupName: string; sortOrder: number } | null) => - g - ? { - id: g.id.toString(), - groupName: g.groupName, - sortOrder: g.sortOrder, - } + async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise { + const rows = await this.prisma.good.findMany({ + where: { + positionId: { not: null }, + originGood: { delisted: false }, + ...(query.countryId ? { countryId: BigInt(query.countryId) } : {}), + }, + include: PUBLIC_GOOD_INCLUDE, + orderBy: [ + { position: { indexVal: 'asc' } }, + { goodPriority: 'desc' }, + { id: 'asc' }, + ], + take: query.limit, + }); + return rows.map((good) => this.toPublicGood(good)); + } + + private toPublicGood(good: PublicGoodRow): PublicGoodDto { + const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) => + group + ? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder } : null; return { - id: good.originGood.sdsGoodId, + goodId: good.originGood.sdsGoodId, goodName: good.goodName, goodPriority: good.goodPriority, country: { @@ -194,63 +260,153 @@ export class PublicService { group: formatGroup(good.tag.tagGroup), } : null, - tags: good.goodTags.map((gt) => ({ - id: gt.tag.id.toString(), - tagName: gt.tag.tagName, - tagColor: gt.tag.tagColor, - tagFontColor: gt.tag.tagFontColor, - group: formatGroup(gt.tag.tagGroup), + tags: good.goodTags.map(({ tag }) => ({ + id: tag.id.toString(), + tagName: tag.tagName, + tagColor: tag.tagColor, + tagFontColor: tag.tagFontColor, + group: formatGroup(tag.tagGroup), })), position: good.position - ? { - id: good.position.id.toString(), - indexVal: good.position.indexVal, - } + ? { id: good.position.id.toString(), indexVal: good.position.indexVal } : null, - image: good.goodImage ?? good.originGood?.goodImage ?? null, - price: - good.originGood?.goodPrice === null || - good.originGood?.goodPrice === undefined - ? null - : good.originGood.goodPrice.toString(), + image: good.goodImage ?? good.originGood.goodImage, + price: good.originGood.goodPrice?.toString() ?? null, createdAt: good.createdAt.toISOString(), }; } + private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto { + const base = this.toPublicGood(good); + const detail = good.originGood.detail; + return { + ...base, + productCode: detail?.productCode ?? null, + englishName: detail?.englishName ?? null, + productionCycleHours: detail?.productionCycleHours ?? null, + minWeightG: detail?.minWeightG?.toString() ?? null, + details: { + reminder: detail?.reminder ?? null, + productionProcess: detail?.productionProcess ?? null, + materialDescription: detail?.materialDescription ?? null, + productPerformance: detail?.productPerformance ?? null, + applicableScenarios: detail?.applicableScenarios ?? null, + washingInstructions: detail?.washingInstructions ?? null, + specialDescription: detail?.specialDescription ?? null, + designExplanation: detail?.designExplanation ?? null, + designArea: detail?.designArea ?? null, + pictureRequest: detail?.pictureRequest ?? null, + }, + media: (detail?.media as Record | null) ?? null, + options: (detail?.options as Record | null) ?? null, + sizeChart: (detail?.sizeChart as Record | null) ?? null, + packageSpecs: (detail?.packageSpecs as Record | null) ?? null, + variants: good.originGood.variants.map((variant) => ({ + id: variant.sdsVariantId, + sku: variant.sku, + sizeId: variant.sizeId, + sizeName: variant.sizeName, + colorId: variant.colorId, + colorName: variant.colorName, + colorHex: variant.colorHex, + imageUrl: variant.imageUrl, + price: variant.price?.toString() ?? null, + originalPrice: variant.originalPrice?.toString() ?? null, + weightG: variant.weightG?.toString() ?? null, + boxLengthCm: variant.boxLengthCm?.toString() ?? null, + boxWidthCm: variant.boxWidthCm?.toString() ?? null, + boxHeightCm: variant.boxHeightCm?.toString() ?? null, + enabled: variant.enabled, + sortOrder: variant.sortOrder, + })), + detailSyncedAt: detail?.syncedAt.toISOString() ?? null, + }; + } + + private async buildTagGroupFilters( + selectedTagIds: string[], + freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>, + ): Promise { + const selected = selectedTagIds.length + ? await this.prisma.tag.findMany({ + where: { id: { in: selectedTagIds.map((id) => BigInt(id)) } }, + select: { id: true, tagGroupId: true }, + }) + : []; + if (selected.length !== selectedTagIds.length) { + throw new BadRequestException('包含不存在的标签 ID'); + } + if (freeShipping?.length) { + const names = freeShipping.map((value) => + value === 'FREE_SHIPPING' ? '包邮' : '不包邮', + ); + const shippingTags = await this.prisma.tag.findMany({ + where: { tagName: { in: names }, tagGroup: { groupName: '物流渠道' } }, + select: { id: true, tagGroupId: true }, + }); + if (shippingTags.length !== new Set(names).size) { + throw new BadRequestException('物流渠道标签配置不完整'); + } + selected.push(...shippingTags); + } + const byGroup = new Map(); + for (const tag of selected) { + const key = tag.tagGroupId?.toString() ?? `tag:${tag.id.toString()}`; + const ids = byGroup.get(key) ?? []; + if (!ids.some((id) => id === tag.id)) ids.push(tag.id); + byGroup.set(key, ids); + } + return [...byGroup.values()].map((ids) => ({ + goodTags: { some: { tagId: { in: ids } } }, + })); + } + + private parsePrice(value: string | undefined, field: string): number | null { + if (value === undefined || value === '') return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new BadRequestException(`${field} 必须是大于等于 0 的金额`); + } + return parsed; + } + private async collectCategoryDescendants(rootId: bigint): Promise { const ids: bigint[] = [rootId]; let frontier: bigint[] = [rootId]; - while (frontier.length > 0) { + while (frontier.length) { const children = await this.prisma.category.findMany({ where: { parentCategoryId: { in: frontier } }, select: { id: true }, }); - if (children.length === 0) break; - const childIds = children.map((c) => c.id); - ids.push(...childIds); - frontier = childIds; + if (!children.length) break; + frontier = children.map((child) => child.id); + ids.push(...frontier); } return ids; } private buildTree( rows: PrismaCategory[], + directCounts: Map, ): PublicCategoryNodeDto[] { const byId = new Map(); for (const row of rows) { - byId.set(row.id, PublicCategoryNodeDto.from(row, [])); + const node = PublicCategoryNodeDto.from(row, []); + node.productCount = directCounts.get(row.id) ?? 0; + byId.set(row.id, node); } const roots: PublicCategoryNodeDto[] = []; for (const row of rows) { const node = byId.get(row.id)!; - if (row.parentCategoryId === null) { - roots.push(node); - } else { - const parent = byId.get(row.parentCategoryId); - if (parent) parent.children.push(node); - else roots.push(node); - } + const parent = row.parentCategoryId === null ? null : byId.get(row.parentCategoryId); + if (parent) parent.children.push(node); + else roots.push(node); } + const total = (node: PublicCategoryNodeDto): number => { + node.productCount += node.children.reduce((sum, child) => sum + total(child), 0); + return node.productCount; + }; + roots.forEach(total); return roots; } } diff --git a/apps/api/src/sync/sds-client.service.spec.ts b/apps/api/src/sync/sds-client.service.spec.ts index a2f4fbb..3dc37b8 100644 --- a/apps/api/src/sync/sds-client.service.spec.ts +++ b/apps/api/src/sync/sds-client.service.spec.ts @@ -33,4 +33,23 @@ describe('SdsClientService', () => { expect(result).toHaveLength(20); }); }); + + describe('fetchProductDetail', () => { + it('requests /products/{goodId} and validates the returned id', async () => { + http.get.mockReturnValue(of({ data: { id: 168746, sku: 'OZ10827003' } })); + + const result = await service.fetchProductDetail('168746'); + + expect(result.sku).toBe('OZ10827003'); + expect(http.get).toHaveBeenCalledWith( + 'https://mapi.sdspod.com/products/168746', + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it('rejects a mismatched product response', async () => { + http.get.mockReturnValue(of({ data: { id: 1 } })); + await expect(service.fetchProductDetail('168746')).rejects.toThrow(/id mismatch/i); + }); + }); }); diff --git a/apps/api/src/sync/sds-client.service.ts b/apps/api/src/sync/sds-client.service.ts index 90f1d54..9c17bee 100644 --- a/apps/api/src/sync/sds-client.service.ts +++ b/apps/api/src/sync/sds-client.service.ts @@ -52,6 +52,86 @@ export interface SdsProductsPage { [key: string]: unknown; } +export interface SdsProductVariant extends Record { + id?: number | string; + sku?: string; + size?: string; + sizeId?: number | string; + sizeDto?: { id?: number | string; sizeName?: string }; + colorId?: number | string; + color_name?: string; + color?: { + colorId?: number | string; + color?: string; + color_name?: string; + chineseName?: string; + }; + currentPrice?: number | string; + originalPrice?: number | string; + unit_price?: number | string; + min_price?: number | string; + weight?: number | string; + box_length?: number | string; + box_width?: number | string; + box_height?: number | string; + status?: number | string; + delFlag?: number | string; + size_sort?: number | string; + attribute_sort?: string; + psd_img_url?: string; + img_url?: string; + blankDesignUrl?: string; + designPrototype?: { + detailImgUrls?: Array<{ imageUrl?: string }>; + prototypeResultGroups?: Array<{ resultImage?: string }>; + [key: string]: unknown; + }; +} + +export interface SdsProductDetail extends Record { + id: number | string; + name?: string; + sku?: string; + english_name?: string; + blankDesignUrl?: string; + detailsPageVideoUrl?: string; + productionCycle?: number | string; + minWeight?: number | string; + min_price?: number | string; + updateTime?: number | string; + psd_img_url?: string; + img_url?: string; + texture?: { name?: string }; + product_details?: { + reminder?: string; + production_process?: string; + material_description?: string; + product_performance?: string; + applicable_scenarios?: string; + washing_instructions?: string; + special_description?: string; + design_explanation?: string; + design_area?: string; + picture_request?: string; + product_size?: string; + packaging_specification?: string; + }; + subproducts?: { + attributers?: Array<{ + size?: string; + sizeId?: number | string; + colors?: Array<{ + colorId?: number | string; + color?: string; + color_name?: string; + chineseName?: string; + colorSort?: number | string; + }>; + }>; + items?: SdsProductVariant[]; + }; +} + /** * Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the * `SyncService` consumes. @@ -137,4 +217,17 @@ export class SdsClientService { } return data as SdsProductsPage; } + + async fetchProductDetail(goodId: string | number): Promise { + const url = `${this.baseUrl}/products/${encodeURIComponent(String(goodId))}`; + const data = await this.request('get', url); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error(`SDS product detail returned ${typeof data}, expected object`); + } + const detail = data as SdsProductDetail; + if (String(detail.id) !== String(goodId)) { + throw new Error(`SDS product detail id mismatch: expected ${goodId}, got ${String(detail.id)}`); + } + return detail; + } } diff --git a/apps/api/src/sync/sds-product-detail.mapper.spec.ts b/apps/api/src/sync/sds-product-detail.mapper.spec.ts new file mode 100644 index 0000000..d6f0245 --- /dev/null +++ b/apps/api/src/sync/sds-product-detail.mapper.spec.ts @@ -0,0 +1,86 @@ +import { + normalizeProductDetail, + parsePackageSpecs, + parseSizeChart, +} from './sds-product-detail.mapper'; + +const sizeTable = JSON.stringify([ + ['尺码', '衣长(cm/in)', '胸围(cm/in)', '肩宽(cm/in)', '袖长(cm/in)'].map((content) => ({ content, remark: '' })), + ['S', '71', '92', '43', '22'].map((content) => ({ content, remark: '' })), + ['M', '74', '102', '45', '22'].map((content) => ({ content, remark: '' })), + ['L', '76', '112', '48', '23'].map((content) => ({ content, remark: '' })), + ['XL', '79', '122', '51', '23'].map((content) => ({ content, remark: '' })), + ['2XL', '82', '132', '53', '25'].map((content) => ({ content, remark: '' })), + ['3XL', '84', '142', '56', '25'].map((content) => ({ content, remark: '' })), +]); + +const packageTable = JSON.stringify([ + ['尺码', '包装尺寸(cm)', '包装尺寸(in)', '包装体积(cm³)', '包装体积(in³)', '含包装重量(g)', '含包装重量(lb)'].map((content) => ({ content })), + ['S', '36.0*26.0*1.0\t', '14.17*10.24*0.39\t', '936.00', '57.12', '208.00', '0.46'].map((content) => ({ content })), + ['M', '36.0*26.0*1.0', '14.17*10.24*0.39', '936.00', '57.12', '218.00', '0.48'].map((content) => ({ content })), +]); + +describe('SDS product detail mapper', () => { + it('parses the product_detail.txt size table into structured rows', () => { + const chart = parseSizeChart(sizeTable) as any; + expect(chart.columns.map((column: any) => column.key)).toEqual([ + 'bodyLength', + 'chest', + 'shoulder', + 'sleeveLength', + ]); + expect(chart.rows).toHaveLength(6); + expect(chart.rows[0].measurements[0]).toEqual({ key: 'bodyLength', cm: '71', in: '27.95' }); + }); + + it('parses packaging dimensions and weights', () => { + const specs = parsePackageSpecs(packageTable) as any; + expect(specs.rows).toHaveLength(2); + expect(specs.rows[0].dimensionsCm).toEqual({ length: '36.0', width: '26.0', height: '1.0' }); + expect(specs.rows[0].grossWeightG).toBe('208.00'); + }); + + it('normalizes detail text, options and variants', () => { + const normalized = normalizeProductDetail({ + id: 168746, + sku: 'OZ10827003', + english_name: 't-shirt', + productionCycle: 24, + minWeight: 250, + product_details: { + production_process: '白墨烫画', + material_description: '100%纯棉', + product_size: sizeTable, + packaging_specification: packageTable, + }, + subproducts: { + attributers: [{ + size: 'S', + sizeId: 1922304, + colors: [{ colorId: 1139383, color: '#000300', color_name: 'black', colorSort: 1 }], + }], + items: [{ + id: 168747, + sku: 'OZ10827003001', + size: 'S', + sizeId: 1922304, + colorId: 1139383, + color: { colorId: 1139383, color: '#000300', color_name: 'black' }, + currentPrice: 38, + originalPrice: 38, + weight: 250, + box_length: 30, + box_width: 20, + box_height: 5, + status: 1, + delFlag: '0', + }], + }, + }); + + expect(normalized.productCode).toBe('OZ10827003'); + expect(normalized.productionProcess).toBe('白墨烫画'); + expect(normalized.variants[0].sku).toBe('OZ10827003001'); + expect(normalized.variants[0].price?.toString()).toBe('38'); + }); +}); diff --git a/apps/api/src/sync/sds-product-detail.mapper.ts b/apps/api/src/sync/sds-product-detail.mapper.ts new file mode 100644 index 0000000..e5f0827 --- /dev/null +++ b/apps/api/src/sync/sds-product-detail.mapper.ts @@ -0,0 +1,261 @@ +import { Prisma } from '@prisma/client'; +import { SdsProductDetail, SdsProductVariant } from './sds-client.service'; + +type TableCell = { content?: unknown; remark?: unknown }; +type Table = TableCell[][]; + +export interface NormalizedVariant { + sdsVariantId: string; + sku: string; + sizeId: string | null; + sizeName: string | null; + colorId: string | null; + colorName: string | null; + colorHex: string | null; + imageUrl: string | null; + price: Prisma.Decimal | null; + originalPrice: Prisma.Decimal | null; + weightG: Prisma.Decimal | null; + boxLengthCm: Prisma.Decimal | null; + boxWidthCm: Prisma.Decimal | null; + boxHeightCm: Prisma.Decimal | null; + enabled: boolean; + sortOrder: number; + designData: Prisma.InputJsonValue | null; +} + +export interface NormalizedProductDetail { + productCode: string | null; + englishName: string | null; + blankDesignUrl: string | null; + detailsPageVideoUrl: string | null; + textureName: string | null; + productionCycleHours: number | null; + minWeightG: Prisma.Decimal | null; + reminder: string | null; + productionProcess: string | null; + materialDescription: string | null; + productPerformance: string | null; + applicableScenarios: string | null; + washingInstructions: string | null; + specialDescription: string | null; + designExplanation: string | null; + designArea: string | null; + pictureRequest: string | null; + sizeChart: Prisma.InputJsonValue | null; + packageSpecs: Prisma.InputJsonValue | null; + options: Prisma.InputJsonValue | null; + media: Prisma.InputJsonValue | null; + upstreamUpdatedAt: Date | null; + variants: NormalizedVariant[]; +} + +const text = (value: unknown): string | null => { + if (value === undefined || value === null) return null; + const normalized = String(value).trim(); + return normalized.length > 0 ? normalized : null; +}; + +const decimal = (value: unknown): Prisma.Decimal | null => { + if (value === undefined || value === null || value === '') return null; + const n = Number(value); + return Number.isFinite(n) ? new Prisma.Decimal(n) : null; +}; + +const integer = (value: unknown): number | null => { + const n = Number(value); + return Number.isInteger(n) ? n : null; +}; + +function parseTable(raw: unknown): Table | null { + if (typeof raw !== 'string' || raw.trim() === '') return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed) || parsed.length < 2) return null; + const rows = parsed.filter(Array.isArray) as Table; + return rows.length >= 2 ? rows : null; + } catch { + return null; + } +} + +const cell = (row: TableCell[], index: number): string => + text(row[index]?.content)?.replace(/\t/g, '').trim() ?? ''; + +const measurementKey = (header: string, index: number): string => { + if (header.includes('衣长')) return 'bodyLength'; + if (header.includes('胸围')) return 'chest'; + if (header.includes('肩宽')) return 'shoulder'; + if (header.includes('袖长')) return 'sleeveLength'; + return `measurement${index}`; +}; + +export function parseSizeChart(raw: unknown): Prisma.InputJsonValue | null { + const table = parseTable(raw); + if (!table) return null; + const [header, ...body] = table; + const columns = header.slice(1).map((item, index) => { + const name = text(item.content)?.replace(/\s*\(cm\/in\)\s*/i, '') ?? `规格${index + 1}`; + return { key: measurementKey(name, index + 1), name }; + }); + const rows = body + .map((row, rowIndex) => { + const sizeName = cell(row, 0); + if (!sizeName) return null; + return { + sizeId: `size_${rowIndex}`, + sizeName, + measurements: columns.map((column, index) => { + const cm = cell(row, index + 1); + const cmNumber = Number(cm); + return { + key: column.key, + cm: cm || null, + in: Number.isFinite(cmNumber) ? (cmNumber / 2.54).toFixed(2) : null, + }; + }), + }; + }) + .filter((row): row is NonNullable => row !== null); + return { columns, rows } as Prisma.InputJsonValue; +} + +function dimensions(value: string): { length: string; width: string; height: string } | null { + const parts = value + .replace(/[×x]/gi, '*') + .split('*') + .map((part) => part.trim()); + if (parts.length !== 3 || parts.some((part) => !Number.isFinite(Number(part)))) return null; + return { length: parts[0], width: parts[1], height: parts[2] }; +} + +export function parsePackageSpecs(raw: unknown): Prisma.InputJsonValue | null { + const table = parseTable(raw); + if (!table) return null; + const rows = table.slice(1) + .map((row, rowIndex) => { + const sizeName = cell(row, 0); + if (!sizeName) return null; + return { + sizeId: `size_${rowIndex}`, + sizeName, + dimensionsCm: dimensions(cell(row, 1)), + dimensionsIn: dimensions(cell(row, 2)), + volumeCm3: cell(row, 3) || null, + volumeIn3: cell(row, 4) || null, + grossWeightG: cell(row, 5) || null, + grossWeightLb: cell(row, 6) || null, + }; + }) + .filter((row): row is NonNullable => row !== null); + return { rows } as Prisma.InputJsonValue; +} + +function normalizeOptions(detail: SdsProductDetail): Prisma.InputJsonValue | null { + const attributers = detail.subproducts?.attributers; + if (!Array.isArray(attributers)) return null; + const sizeMap = new Map(); + const colorMap = new Map(); + attributers.forEach((attribute, sizeIndex) => { + const sizeName = text(attribute.size); + const sizeId = text(attribute.sizeId) ?? `size_${sizeIndex}`; + if (sizeName) sizeMap.set(sizeId, { id: sizeId, name: sizeName, sortOrder: sizeIndex, enabled: true }); + if (Array.isArray(attribute.colors)) { + attribute.colors.forEach((color, colorIndex) => { + const colorId = text(color.colorId) ?? `color_${colorIndex}`; + if (!colorMap.has(colorId)) { + colorMap.set(colorId, { + id: colorId, + name: text(color.chineseName) ?? text(color.color_name) ?? colorId, + hex: text(color.color), + sortOrder: integer(color.colorSort) ?? colorIndex, + enabled: true, + }); + } + }); + } + }); + return { sizes: [...sizeMap.values()], colors: [...colorMap.values()] } as Prisma.InputJsonValue; +} + +function normalizeMedia(detail: SdsProductDetail, variants: SdsProductVariant[]): Prisma.InputJsonValue | null { + const urls: string[] = []; + const add = (value: unknown) => { + const url = text(value); + if (url && !urls.includes(url)) urls.push(url); + }; + add(detail.blankDesignUrl); + add(detail.psd_img_url); + add(detail.img_url); + for (const variant of variants) { + add(variant.psd_img_url); + add(variant.img_url); + add(variant.blankDesignUrl); + for (const image of variant.designPrototype?.detailImgUrls ?? []) add(image.imageUrl); + for (const image of variant.designPrototype?.prototypeResultGroups ?? []) add(image.resultImage); + } + if (urls.length === 0) return null; + return { + primaryImageUrl: urls[0], + images: urls.map((url, index) => ({ id: `image_${index}`, url, sortOrder: index })), + } as Prisma.InputJsonValue; +} + +function normalizeVariant(variant: SdsProductVariant, index: number): NormalizedVariant | null { + const sdsVariantId = text(variant.id); + const sku = text(variant.sku); + if (!sdsVariantId || !sku) return null; + return { + sdsVariantId, + sku, + sizeId: text(variant.sizeId) ?? text(variant.sizeDto?.id), + sizeName: text(variant.size) ?? text(variant.sizeDto?.sizeName), + colorId: text(variant.colorId) ?? text(variant.color?.colorId), + colorName: text(variant.color?.chineseName) ?? text(variant.color_name) ?? text(variant.color?.color_name), + colorHex: text(variant.color?.color), + imageUrl: text(variant.psd_img_url) ?? text(variant.img_url) ?? text(variant.blankDesignUrl), + price: decimal(variant.currentPrice ?? variant.unit_price ?? variant.min_price), + originalPrice: decimal(variant.originalPrice), + weightG: decimal(variant.weight), + boxLengthCm: decimal(variant.box_length), + boxWidthCm: decimal(variant.box_width), + boxHeightCm: decimal(variant.box_height), + enabled: Number(variant.status ?? 1) === 1 && String(variant.delFlag ?? '0') === '0', + sortOrder: integer(variant.attribute_sort?.split('-')[0]) ?? integer(variant.size_sort) ?? index, + designData: variant.designPrototype ? (variant.designPrototype as Prisma.InputJsonValue) : null, + }; +} + +export function normalizeProductDetail(detail: SdsProductDetail): NormalizedProductDetail { + const productDetails = detail.product_details ?? {}; + const sourceVariants = Array.isArray(detail.subproducts?.items) ? detail.subproducts!.items! : []; + const variants = sourceVariants + .map(normalizeVariant) + .filter((variant): variant is NormalizedVariant => variant !== null); + const updatedAt = Number(detail.updateTime); + return { + productCode: text(detail.sku), + englishName: text(detail.english_name), + blankDesignUrl: text(detail.blankDesignUrl), + detailsPageVideoUrl: text(detail.detailsPageVideoUrl), + textureName: text(detail.texture?.name), + productionCycleHours: integer(detail.productionCycle), + minWeightG: decimal(detail.minWeight), + reminder: text(productDetails.reminder), + productionProcess: text(productDetails.production_process), + materialDescription: text(productDetails.material_description), + productPerformance: text(productDetails.product_performance), + applicableScenarios: text(productDetails.applicable_scenarios), + washingInstructions: text(productDetails.washing_instructions), + specialDescription: text(productDetails.special_description), + designExplanation: text(productDetails.design_explanation), + designArea: text(productDetails.design_area), + pictureRequest: text(productDetails.picture_request), + sizeChart: parseSizeChart(productDetails.product_size), + packageSpecs: parsePackageSpecs(productDetails.packaging_specification), + options: normalizeOptions(detail), + media: normalizeMedia(detail, sourceVariants), + upstreamUpdatedAt: Number.isFinite(updatedAt) ? new Date(updatedAt) : null, + variants, + }; +} diff --git a/apps/api/src/sync/sync.service.spec.ts b/apps/api/src/sync/sync.service.spec.ts index 7a79b3f..790f6cd 100644 --- a/apps/api/src/sync/sync.service.spec.ts +++ b/apps/api/src/sync/sync.service.spec.ts @@ -19,6 +19,7 @@ describe('SyncService', () => { const sdsMock: Partial = { fetchCategoryTree: jest.fn(), fetchProductsPage: jest.fn(), + fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })), }; const moduleRef = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ isGlobal: true })], @@ -29,6 +30,9 @@ describe('SyncService', () => { ], }).compile(); service = moduleRef.get(SyncService); + jest + .spyOn(service, 'syncConfiguredProductDetails') + .mockResolvedValue({ synced: 0, failed: 0 }); sds = moduleRef.get(SdsClientService) as jest.Mocked; prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index cbbec84..c9ce037 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -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 { + 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? }`. */ diff --git a/apps/website/app/composables/useProductCenter.ts b/apps/website/app/composables/useProductCenter.ts index 3b36f66..075e907 100644 --- a/apps/website/app/composables/useProductCenter.ts +++ b/apps/website/app/composables/useProductCenter.ts @@ -145,7 +145,7 @@ interface BackendTag { } interface BackendProduct { - id: string; + goodId: string; goodName: string; goodPriority: number; country: { id: string; countryName: string; countryIcon: string | null }; @@ -243,7 +243,7 @@ export function resolveDefaultProductFilters( function mapProduct(p: BackendProduct, backendUrl: string): Product { return { - id: p.id, + id: p.goodId, name: p.goodName, priority: p.goodPriority, image: resolveBackendAssetUrl(p.image, backendUrl),