From b4b9fbbe6078512392db6b91f4281afbf164bb76 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 13:34:42 +0800 Subject: [PATCH 1/2] feat(api): good family_id column with backfill --- .../migration.sql | 14 ++++++ apps/api/prisma/schema.prisma | 4 ++ .../feature/product-family-public-feature.md | 49 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 apps/api/prisma/migrations/20260828053415_add_good_family_id/migration.sql create mode 100644 plans/feature/product-family-public-feature.md diff --git a/apps/api/prisma/migrations/20260828053415_add_good_family_id/migration.sql b/apps/api/prisma/migrations/20260828053415_add_good_family_id/migration.sql new file mode 100644 index 0000000..9dc2ee2 --- /dev/null +++ b/apps/api/prisma/migrations/20260828053415_add_good_family_id/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "goods" ADD COLUMN "family_id" BIGINT; + +-- CreateIndex +CREATE INDEX "goods_family_id_idx" ON "goods"("family_id"); + +-- AddForeignKey +ALTER TABLE "goods" ADD CONSTRAINT "goods_family_id_fkey" FOREIGN KEY ("family_id") REFERENCES "product_families"("family_id") ON DELETE SET NULL ON UPDATE NO ACTION; + +-- Backfill: Good 的族 = 其主链接所属族(派生数据,后续由服务层联动维护) +UPDATE "goods" SET "family_id" = ( + SELECT "o"."family_id" FROM "origin_goods" "o" + WHERE "o"."origin_good_id" = "goods"."origin_good_id" +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5929a2c..270aa76 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -212,6 +212,7 @@ model Position { model Good { id BigInt @id @default(autoincrement()) @map("good_id") originGoodId BigInt @map("origin_good_id") + familyId BigInt? @map("family_id") countryId BigInt @map("country_id") categoryId BigInt @map("category_id") tagId BigInt? @map("tag_id") @@ -223,6 +224,7 @@ model Good { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction) + family ProductFamily? @relation(fields: [familyId], references: [id], onDelete: SetNull, onUpdate: NoAction) country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction) category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction) tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction) @@ -231,6 +233,7 @@ model Good { mergedOriginGoods GoodOriginGood[] @@index([originGoodId]) + @@index([familyId]) @@index([countryId]) @@index([categoryId]) @@index([tagId]) @@ -292,6 +295,7 @@ model ProductFamily { originGoods OriginGood[] priceOverrides FamilyPriceOverride[] + goods Good[] country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction) category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull, onUpdate: NoAction) diff --git a/plans/feature/product-family-public-feature.md b/plans/feature/product-family-public-feature.md new file mode 100644 index 0000000..9d8f67e --- /dev/null +++ b/plans/feature/product-family-public-feature.md @@ -0,0 +1,49 @@ +# 产品族三期(公开读路径灰度 + Good.familyId)实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `Good.familyId` 落库并在公开详情中灰度暴露族数据(并集尺码表/包装/五维价格矩阵),零新增公开端点、开关可秒回退(设计 D4)。 + +**Architecture:** `Good.familyId` 是**派生数据**:创建时自动取主链接的族;族成员变更时联动刷新。公开详情的族块直接来自 `ProductFamily` 物化 JSON(零额外查询),由环境变量 `PUBLIC_DETAIL_FROM_FAMILY`(默认 false)控制是否输出。 + +**明确的范围边界:** 列表/筛选/排序仍基于主源 `goodPrice`(SQL 层无法廉价解析族矩阵 JSON),仅详情页价格以矩阵为准;`good_origin_goods` 转只读保留,观察期后再删(另行任务)。 + +--- + +### Task 1: Schema + 迁移 + 回填 + +- [ ] `Good` model 增加 `familyId BigInt? @map("family_id")` + `family ProductFamily? @relation(... SetNull)` + `@@index([familyId])`;`ProductFamily` relations 加 `goods Good[]`。 +- [ ] `npx prisma migrate dev --name add_good_family_id`,迁移 SQL 末尾追加回填: + `UPDATE goods SET family_id = (SELECT family_id FROM origin_goods WHERE origin_goods.origin_good_id = goods.origin_good_id);` +- [ ] Commit `feat(api): good family_id column with backfill` + +### Task 2: Good.familyId 派生逻辑 + +- [ ] `goods.service.ts`:`create` / `batchCreate` / `createCustom` 创建 Good 时 `familyId = originGood.familyId`(含 custom 直挂的 familyId); +- [ ] `product-families.service.ts`:`updateMembers` / `tryAutoAttachToFamily`(sync.service)成员移动后联动 + `UPDATE goods SET family_id`(`good.updateMany({ where: { originGoodId: { in: movedIds } }, data: { familyId } })`,摘除时置 null)。 +- [ ] 测试:创建 Good 带 familyId、成员移动联动(集成)。 +- [ ] Commit `feat(api): derive good family membership` + +### Task 3: 公开详情族块(灰度开关) + +- [ ] `PUBLIC_GOOD_INCLUDE` 的 `originGood.include` 加 `family: { select: { id, familyCode, familyName, sizeChart, packageSpecs, priceMatrix } }`; +- [ ] `PublicGoodDetailDto` 增加可选 `family` 字段;`toPublicGoodDetail` 在 + `process.env.PUBLIC_DETAIL_FROM_FAMILY === 'true'` 且 `originGood.family` 存在时输出 + `{ familyId, familyCode, familyName, sizes, colors, crafts, logistics, sizeChart, packageSpecs, priceMatrix, minPrice }` + (minPrice = 矩阵 rows 最低价);开关关闭时**完全不含**该字段(响应形状与现状逐字节一致)。 +- [ ] 测试:开关两态(spec 内直接改 env 再实例化 service)、族块字段正确、无族 Good 字段缺失。 +- [ ] Commit `feat(api): public good detail family block behind flag` + +### Task 4: 验证 + 文档 + 合并 + +- [ ] api 全量测试两轮全绿;`pnpm -r build` 通过。 +- [ ] 冒烟:开关 off 响应无 family 键;on 时含 DG001 族块(470 格)。 +- [ ] 更新 `docs/references/structs.md`(Good.familyId、公开详情族块、环境变量)、`docs/references/product-center.md`(三期行为与边界声明)、`README.md` 环境变量说明。 +- [ ] 遵循 verification-before-completion;合并回 develop。 + +## Self-Review + +- 设计 §10.2 复用原则 ✓(零新增端点);§5.3 Good.familyId ✓;§12.6 读路径灰度 ✓(开关粒度为详情族块); +- 列表价/筛选不动已作为边界显式声明(SQL 不可行 + 避免展示价与筛选价不一致); +- `good_origin_goods` 保留只读,删除另立任务(观察期要求)。 From 6418aa7302d2143412a8d19b6afb0a7b6c013ae7 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 13:40:19 +0800 Subject: [PATCH 2/2] feat(api): public good detail family block behind gray-release flag --- apps/api/.env.example | 4 + apps/api/src/goods/goods.service.ts | 8 + .../product-families.service.ts | 14 +- .../src/public/dto/public-good-detail.dto.ts | 21 ++ .../src/public/public-family-block.spec.ts | 205 ++++++++++++++++++ apps/api/src/public/public.service.ts | 46 ++++ apps/api/src/sync/sync.service.ts | 5 + docs/references/product-center.md | 23 ++ docs/references/structs.md | 4 +- 9 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/public/public-family-block.spec.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 493372c..c9de2c5 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -18,3 +18,7 @@ CORS_ORIGINS=http://localhost:5173 THROTTLE_LIMIT=120 PORT=3001 + +# Gray release: expose product-family block (union size chart + 5-dim price matrix) +# in GET /public/goods/:goodId responses. Off = response shape identical to before. +PUBLIC_DETAIL_FROM_FAMILY=false diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index 0c9e850..c86c245 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -122,11 +122,17 @@ export class GoodsService { ); await this.ensureMergedOriginGoods(mergedIds); const result = await this.prisma.$transaction(async (tx) => { + // Good 的族是派生数据:主链接所属族 + const primary = await tx.originGood.findUnique({ + where: { id: BigInt(dto.originGoodId) }, + select: { familyId: true }, + }); const created = await tx.good.create({ data: { goodName: dto.goodName, goodImage: dto.goodImage, originGoodId: BigInt(dto.originGoodId), + familyId: primary?.familyId ?? null, countryId: BigInt(dto.countryId), categoryId: BigInt(dto.categoryId), positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), @@ -217,6 +223,7 @@ export class GoodsService { const good = await tx.good.create({ data: { originGoodId: originGood.id, + familyId: family?.id ?? null, countryId: BigInt(dto.countryId), categoryId: BigInt(dto.categoryId), positionId: @@ -445,6 +452,7 @@ export class GoodsService { goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`, goodImage: og.goodImage, originGoodId: og.id, + familyId: og.familyId, countryId: BigInt(dto.countryId), categoryId: BigInt(dto.categoryId), positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), diff --git a/apps/api/src/product-families/product-families.service.ts b/apps/api/src/product-families/product-families.service.ts index 7408f05..f624e06 100644 --- a/apps/api/src/product-families/product-families.service.ts +++ b/apps/api/src/product-families/product-families.service.ts @@ -221,6 +221,10 @@ export class ProductFamiliesService { where: { id: { in: removeIds }, familyId: id }, data: { familyId: null }, }); + await this.prisma.good.updateMany({ + where: { originGoodId: { in: removeIds }, familyId: id }, + data: { familyId: null }, + }); // 移除的是主链接(或主链接已不在族内)→ 落到剩余第一个成员 if (remaining > 0) { const stillPrimary = await this.prisma.originGood.count({ @@ -248,10 +252,12 @@ export class ProductFamiliesService { } if (dto.addOriginGoodIds?.length) { - await this.attachMembers( - id, - dto.addOriginGoodIds.map((v) => BigInt(v)), - ); + const addIds = dto.addOriginGoodIds.map((v) => BigInt(v)); + await this.attachMembers(id, addIds); + await this.prisma.good.updateMany({ + where: { originGoodId: { in: addIds } }, + data: { familyId: id }, + }); } await this.recompute.recomputeFamily(id); diff --git a/apps/api/src/public/dto/public-good-detail.dto.ts b/apps/api/src/public/dto/public-good-detail.dto.ts index 56df449..b9c764e 100644 --- a/apps/api/src/public/dto/public-good-detail.dto.ts +++ b/apps/api/src/public/dto/public-good-detail.dto.ts @@ -59,6 +59,27 @@ export class PublicGoodDetailDto extends PublicGoodDto { @ApiProperty({ nullable: true }) detailSyncedAt!: string | null; + + /** 灰度字段:PUBLIC_DETAIL_FROM_FAMILY=true 且主链接有所属族时输出,否则完全不含该键 */ + @ApiProperty({ + required: false, + nullable: true, + type: Object, + description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×工艺×物流)', + }) + family?: { + familyId: string; + familyCode: string | null; + familyName: string; + sizes: Array<{ key: string; name: string | null }>; + colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>; + crafts: string[]; + logistics: string[]; + sizeChart: Record | null; + packageSpecs: Record | null; + priceMatrix: Record; + minPrice: string | null; + } | null; } export class PublicTagGroupFilterDto { diff --git a/apps/api/src/public/public-family-block.spec.ts b/apps/api/src/public/public-family-block.spec.ts new file mode 100644 index 0000000..78db062 --- /dev/null +++ b/apps/api/src/public/public-family-block.spec.ts @@ -0,0 +1,205 @@ +import { Test } from '@nestjs/testing'; +import { Prisma } from '@prisma/client'; +import { PublicService } from './public.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; + +/** + * 三期灰度族块集成测试:PUBLIC_DETAIL_FROM_FAMILY 开关两态行为、 + * 族块字段内容、Good.familyId 派生与成员移动联动。 + */ +describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => { + let service: PublicService; + let prisma: PrismaService; + const stamp = Date.now(); + const createdOriginGoodIds: bigint[] = []; + const createdFamilyIds: bigint[] = []; + const createdGoodIds: bigint[] = []; + const createdCountryIds: bigint[] = []; + const createdCategoryIds: bigint[] = []; + const prevFlag = process.env.PUBLIC_DETAIL_FROM_FAMILY; + let sdsGoodId = ''; + let familyId = 0n; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PublicService, PrismaService], + }).compile(); + service = moduleRef.get(PublicService); + prisma = moduleRef.get(PrismaService); + await prisma.onModuleInit(); + + const country = await prisma.country.create({ + data: { countryName: `公开族国家-${stamp}` }, + }); + createdCountryIds.push(country.id); + const category = await prisma.category.create({ + data: { categoryName: `公开族分类-${stamp}` }, + }); + createdCategoryIds.push(category.id); + + // 族 + 主链接(带变体)+ Good + const og = await prisma.originGood.create({ + data: { + sdsGoodId: `pubfam-${stamp}`, + goodName: `美国(包邮)测试T恤-PF${stamp}-单面印花`, + goodPrice: new Prisma.Decimal(25), + craftLabel: '单面印花', + logisticsLabel: '包邮', + }, + }); + sdsGoodId = og.sdsGoodId; + createdOriginGoodIds.push(og.id); + await prisma.originGoodVariant.create({ + data: { + originGoodId: og.id, + sdsVariantId: 'pf-v1', + sku: `PF-${stamp}-S`, + sizeId: 'size_S', + sizeName: 'S', + colorId: 'color_blk', + colorName: '黑色', + price: new Prisma.Decimal(25), + }, + }); + const family = await prisma.productFamily.create({ + data: { familyName: `公开族-${stamp}`, familyCode: `PF${stamp}`, primaryOriginGoodId: og.id }, + }); + familyId = family.id; + createdFamilyIds.push(family.id); + await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } }); + await new FamilyRecomputeService(prisma).recomputeFamily(family.id); + + const good = await prisma.good.create({ + data: { + originGoodId: og.id, + familyId: family.id, + countryId: country.id, + categoryId: category.id, + goodName: `公开族商品-${stamp}`, + }, + }); + createdGoodIds.push(good.id); + }); + + afterAll(async () => { + process.env.PUBLIC_DETAIL_FROM_FAMILY = prevFlag; + await prisma.good.deleteMany({ where: { id: { in: createdGoodIds } } }); + await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } }); + await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } }); + await prisma.country.deleteMany({ where: { id: { in: createdCountryIds } } }); + await prisma.category.deleteMany({ where: { id: { in: createdCategoryIds } } }); + await prisma.$disconnect(); + }); + + it('开关关闭:响应完全不含 family 键(与现状形状一致)', async () => { + process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false'; + const detail = await service.getGood(sdsGoodId); + expect(detail.goodId).toBe(sdsGoodId); + expect('family' in detail).toBe(false); + }); + + it('开关开启:输出族块(物化矩阵 + 并集 + 起价)', async () => { + process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true'; + const detail = await service.getGood(sdsGoodId); + expect(detail.family).not.toBeNull(); + expect(detail.family!.familyCode).toBe(`PF${stamp}`); + expect(detail.family!.familyId).toBe(familyId.toString()); + expect(detail.family!.crafts).toEqual(['单面印花']); + expect(detail.family!.logistics).toEqual(['包邮']); + expect(detail.family!.priceMatrix).toBeTruthy(); + expect(detail.family!.minPrice).toBe('25'); + // 旧字段保留(向后兼容) + expect(detail.variants.length).toBe(1); + expect(detail.sizeChart).toBeDefined(); + }); + + it('无族商品:开关开启也不含 family 键', async () => { + process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true'; + const og2 = await prisma.originGood.create({ + data: { + sdsGoodId: `pubfam-2-${stamp}`, + goodName: `无族链接-${stamp}`, + goodPrice: new Prisma.Decimal(10), + }, + }); + createdOriginGoodIds.push(og2.id); + const good2 = await prisma.good.create({ + data: { + originGoodId: og2.id, + countryId: createdCountryIds[0], + categoryId: createdCategoryIds[0], + goodName: `无族商品-${stamp}`, + }, + }); + createdGoodIds.push(good2.id); + const detail = await service.getGood(`pubfam-2-${stamp}`); + expect('family' in detail).toBe(false); + }); +}); + +describe('Good familyId derivation', () => { + let prisma: PrismaService; + let familiesService: import('../product-families/product-families.service').ProductFamiliesService; + const stamp = Date.now(); + const ids = { originGood: [] as bigint[], family: [] as bigint[], good: [] as bigint[], country: [] as bigint[], category: [] as bigint[] }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaService], + }).compile(); + prisma = moduleRef.get(PrismaService); + await prisma.onModuleInit(); + familiesService = new (require('../product-families/product-families.service').ProductFamiliesService)( + prisma, + new FamilyRecomputeService(prisma), + ); + }); + + afterAll(async () => { + await prisma.good.deleteMany({ where: { id: { in: ids.good } } }); + await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } }); + await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } }); + await prisma.country.deleteMany({ where: { id: { in: ids.country } } }); + await prisma.category.deleteMany({ where: { id: { in: ids.category } } }); + await prisma.$disconnect(); + }); + + it('updateMembers 移除/添加成员时,Good.familyId 联动', async () => { + const country = await prisma.country.create({ data: { countryName: `联动国家-${stamp}` } }); + ids.country.push(country.id); + const category = await prisma.category.create({ data: { categoryName: `联动分类-${stamp}` } }); + ids.category.push(category.id); + const og = await prisma.originGood.create({ + data: { sdsGoodId: `derive-${stamp}`, goodName: `联动链接-${stamp}` }, + }); + ids.originGood.push(og.id); + const family = await prisma.productFamily.create({ + data: { familyName: `联动族-${stamp}`, primaryOriginGoodId: og.id }, + }); + ids.family.push(family.id); + await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } }); + const good = await prisma.good.create({ + data: { + originGoodId: og.id, + familyId: family.id, + countryId: country.id, + categoryId: category.id, + goodName: `联动商品-${stamp}`, + }, + }); + ids.good.push(good.id); + + // 移出族 → Good.familyId 置空 + await familiesService.updateMembers(family.id, { removeOriginGoodIds: [og.id.toString()] }); + expect( + (await prisma.good.findUniqueOrThrow({ where: { id: good.id } })).familyId, + ).toBeNull(); + + // 重新挂回 → Good.familyId 回填 + await familiesService.updateMembers(family.id, { addOriginGoodIds: [og.id.toString()] }); + expect( + (await prisma.good.findUniqueOrThrow({ where: { id: good.id } })).familyId, + ).toBe(family.id); + }); +}); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 37e74ba..70397b5 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -32,6 +32,16 @@ const PUBLIC_GOOD_INCLUDE = { include: { detail: true, variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, + family: { + select: { + id: true, + familyCode: true, + familyName: true, + sizeChart: true, + packageSpecs: true, + priceMatrix: true, + }, + }, }, }, mergedOriginGoods: { @@ -402,6 +412,42 @@ export class PublicService { sortOrder: variant.sortOrder, })), detailSyncedAt: detail?.syncedAt.toISOString() ?? null, + ...this.familyBlock(good), + }; + } + + /** + * 灰度族块(设计 D4:零新增公开端点):仅当 PUBLIC_DETAIL_FROM_FAMILY=true + * 且主链接有所属族时输出;数据全部来自 ProductFamily 的物化 JSON,无额外查询。 + */ + private familyBlock( + good: PublicGoodRow, + ): Pick | Record { + if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'true') return {}; + const family = good.originGood.family; + if (!family || !family.priceMatrix) return {}; + const matrix = family.priceMatrix as { + sizes: Array<{ key: string; name: string | null }>; + colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>; + crafts: string[]; + logistics: string[]; + rows: Array<{ price: string }>; + }; + const prices = matrix.rows.map((r) => Number(r.price)).filter((n) => Number.isFinite(n)); + return { + family: { + familyId: family.id.toString(), + familyCode: family.familyCode, + familyName: family.familyName, + sizes: matrix.sizes, + colors: matrix.colors, + crafts: matrix.crafts, + logistics: matrix.logistics, + sizeChart: (family.sizeChart as Record | null) ?? null, + packageSpecs: (family.packageSpecs as Record | null) ?? null, + priceMatrix: matrix as unknown as Record, + minPrice: prices.length ? String(Math.min(...prices)) : null, + }, }; } diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index c923f80..1bd4ee6 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -715,6 +715,11 @@ export class SyncService { where: { id: originGoodId }, data: { familyId }, }); + // Good 的族随主链接联动 + await this.prisma.good.updateMany({ + where: { originGoodId }, + data: { familyId }, + }); this.familyRecompute.enqueue(familyId); } else { await this.prisma.productFamily.update({ diff --git a/docs/references/product-center.md b/docs/references/product-center.md index c4d9b1d..458c56d 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -101,8 +101,31 @@ pnpm --filter @inkreach/api backfill:product-families # → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑) ``` +**公开读路径灰度(三期已上线,默认关闭)**:环境变量 `PUBLIC_DETAIL_FROM_FAMILY=true` 时, +`GET /public/goods/:goodId` 在既有响应上**增量**输出 `family` 块: + +```jsonc +{ + "family": { + "familyId": "12", "familyCode": "DG015", "familyName": "DG015 180G纯棉T恤", + "sizes": [...], "colors": [...], "crafts": ["单面印花", ...], "logistics": ["包邮", ...], + "sizeChart": { /* 并集 */ }, "packageSpecs": { /* 并集 */ }, + "priceMatrix": { /* 五维矩阵:rows 为 {sizeId, colorId, craft, logistics, price, manual, sources} */ }, + "minPrice": "29.5" + } +} +``` + +实测(DG015 族):4 工艺 × 2 物流 × 8 尺码 × 12 颜色 = 653 格矩阵,族起价 ¥29.5 +(主源单链接价为 ¥68.74——族视角展示了光板/单面的更低档价格)。前端本地按五维联动 +`priceMatrix` 即可实时算价,无需新增查价端点(设计 D4)。 + +**边界声明**:商品列表/筛选/排序仍基于主源 `goodPrice`(SQL 层无法廉价解析族矩阵 JSON, +且避免展示价与筛选价不一致);`good_origin_goods` 转只读保留,观察期后另行删除。 + **后台操作入口(二期已上线)**:商品中心新增「产品族」标签页(`/goods?tab=families`): + - 列表:关键词/分页、成员数、人工改价数、自动托管/锁定状态、`stale` 待处理标记; - 「自动成族」:先预览候选分组(SDS 分类聚合),确认后应用并逐族重算; - 详情抽屉:canonical 字段编辑(名称/编码/主图/主链接)、`autoManaged` 开关、手动重算、 diff --git a/docs/references/structs.md b/docs/references/structs.md index 4d476c5..946b796 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -89,7 +89,7 @@ apps/api/ | `Tag` | 标签,含 `tagColor`、`tagFontColor`、`tagGroupId`、`sortOrder`、`timing` | | `TagGroup` | 标签分组(含 `groupName`、`groupColor`、`groupIcon`、`sortOrder`),删除分组时组内 tag 的 `tagGroupId` 通过 `onDelete: SetNull` 自动置空 | | `Position` | 坑位:`(country, category)` 维度,关联多个 goods | -| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority` | +| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority`;`familyId` 为派生数据(主链接所属族,创建时自动填充,族成员变更时联动) | | `GoodOriginGood` | 副源关联中间表(多对一):一个 Good 可关联多个副源 OriginGood;主源走 `goods.origin_good_id` 不入表。用于把名称相同但工厂/仓库不同的多个原产品合并为一个商品展示 | | `User` | 后台用户(bcrypt 哈希) | | `SyncLog` | 同步任务日志,含 `SyncType`(CATEGORIES / PRODUCTS)和 `SyncStatus` | @@ -114,7 +114,7 @@ apps/api/ | `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 | | `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 | | `/public/goods` `GET` | 分页商品(支持 `countryId/categoryId/tagIds(逗号分隔)/keyword/page/pageSize`,`tagIds` 为 AND 关系) | 公开 | -| `/public/goods/:id` `GET` | 商品详情 | 公开 | +| `/public/goods/:id` `GET` | 商品详情;`PUBLIC_DETAIL_FROM_FAMILY=true` 且主链接有族时额外输出 `family` 块(并集尺码表/包装 + 五维价格矩阵 + 族起价),开关关闭时响应形状与现状一致 | 公开 | | `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT | | `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT | | `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |