From 1186d615e29332a972625c069577a02ecfeebc3e Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 11:45:18 +0800 Subject: [PATCH] docs(specs): add product family merge design --- .../2026-08-28-product-family-merge-design.md | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-product-family-merge-design.md diff --git a/docs/superpowers/specs/2026-08-28-product-family-merge-design.md b/docs/superpowers/specs/2026-08-28-product-family-merge-design.md new file mode 100644 index 0000000..07c80a9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-product-family-merge-design.md @@ -0,0 +1,274 @@ +# 产品族(SPU)合并 — 后端设计 + +- 日期:2026-08-28 +- 状态:已评审(讨论稿定稿,待实施计划) +- 分支:`feature/product-link-merge-yeuimu` +- 范围:仅后端(apps/api);前台展示层(apps/admin、apps/website)仅列出依赖的接口契约 + +## 1. 背景与问题 + +SDS(InkPOD)上游会为同一个实体商品下发多条近似链接,名称格式为 +`国家(物流备注)品名-SKU-工艺位置[-仓库名]`,例如 +`美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓`。 +一个"DG001 180G纯棉T恤(JSA002)"分类下存在约 18 条这样的链接。 + +现有系统的合并能力落在商品层(`goods.origin_good_id` 主源 + `good_origin_goods` 副源), +但合并语义很浅: + +- 变体列表简单拼接,无归因、不去重; +- 商品详情、尺码表、包装规则、价格**只取主源**,副源独有的大尺码(如 XXXXL)直接丢失; +- 物流、工艺只存在于链接名的自由文本中,无法结构化查询; +- 同一个族若按国家配置多个 Good,需要重复合并、重算并集。 + +## 2. 目标 / 非目标 + +**目标** + +1. 在原产品库引入"产品族(ProductFamily)"实体:多个 OriginGood 链接合并为一个族; +2. 族的共享属性:规范名称、主图、国家、分类、商品详情; +3. 尺码表与包装规则做**并集**(S~XXXL ∪ S~XXXXXL),冲突有明确裁决规则; +4. 价格完全由数据推导:`价格 = f(尺码, 颜色, 物流, 工艺, 印花数量)`,其中印花数量 + 即链接工艺段自带的属性(单面/双面印花等),**无订单量折扣、无人工定价**; +5. 官网买家可对五个维度全部做选择,价格实时联动; +6. SDS 同步流水线保持"纯镜像"定位不变。 + +**非目标** + +- 不引入价格阶梯/折扣/人工覆盖表(已被明确否决:完全按照链接价格透传); +- 不改动同步任务的核心抓取逻辑与安全护栏; +- 不在本设计内实现 admin/website 的界面(仅约定接口契约)。 + +## 3. 决策记录 + +| # | 问题 | 决策 | +| --- | --- | --- | +| D1 | 合并落在哪一层 | **新增 SPU 产品族层**(介于 OriginGood 与 Good 之间),而非强化 Good 级合并或读取时动态聚合 | +| D2 | 价格形态 | **完全按链接透传**:无阶梯折扣、无明码矩阵、无人工覆盖;价格 = 成员链接变体的 SDS 原价 | +| D3 | 买家选价粒度 | **五维全选**:详情页提供尺码/颜色/工艺(印花数量)/物流选择器,价格实时联动 | + +## 4. 架构 + +``` +SDS 同步(不动) 产品族层(新增,人工策展) 运营配置层(调整) +OriginGood #1 ─┐ ┌── ProductFamily ─────────────┐ Good +OriginGood #2 ─┼─ familyId ──────►│ 规范名/主图/国家/分类 │──────► 新增引用族(familyId) + ...(18条) │ (自动预填, │ 商品详情(canonical) │ 保留 origin_good_id +OriginGood #18 ┘ 可人工调整) │ 并集尺码表 + 并集包装规则(物化)│ 国家/坑位/标签/优先级不变 + │ 价格矩阵(推导物化,零人工) │ + └──────────────────────────────┘ +``` + +核心原则: + +- `OriginGood` 保持纯 SDS 镜像,同步流水线不改;仅在详情同步提交后追加"重算受影响族"的钩子; +- 族是详情、尺码、包装、价格的唯一事实来源; +- 族上 `autoManaged=true` 时随上游自动重算,`false` 时人工锁定、只置 stale 标记。 + +## 5. 数据模型(Prisma 草案) + +### 5.1 新增 `ProductFamily` + +```prisma +model ProductFamily { + id BigInt @id @default(autoincrement()) @map("family_id") + familyCode String? @map("family_code") // 如 DG001 / JSA002,从链接名 SKU 段提取,可人工改 + familyName String @map("family_name") // 规范名,如 180g纯棉T恤(成人款) + familyImage String? @map("family_image") + countryId BigInt? @map("country_id") // 关联现有 Country + categoryId BigInt? @map("category_id") // 关联现有 Category + detail Json? // canonical 详情(文本字段默认取主链接,可人工编辑) + sizeChart Json? @map("size_chart") // 物化并集 + packageSpecs Json? @map("package_specs") // 物化并集 + priceMatrix Json? @map("price_matrix") // 物化推导结果(重算快照,非人工数据) + autoManaged Boolean @default(true) @map("auto_managed") + stale Boolean @default(false) // 锁定族在上游变化后的待处理标记 + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + originGoods OriginGood[] + goods Good[] + country Country? @relation(fields: [countryId], references: [id]) + category Category? @relation(fields: [categoryId], references: [id]) + + @@unique([familyCode]) + @@index([countryId]) + @@index([categoryId]) + @@map("product_families") +} +``` + +### 5.2 `OriginGood` 增量 + +```prisma +familyId BigInt? @map("family_id") // 归属族,族删除时置空 +skuCode String? @map("sku_code") // 名称第2段,如 DG001 +logisticsLabel String? @map("logistics_label") // 第1段括号内物流备注,如 包邮/专线 +craftLabel String? @map("craft_label") // 第3段工艺位置,如 单面印花(含印花数量语义) +warehouseLabel String? @map("warehouse_label") // 第4段(可选)仓库,如 美西洛杉矶一仓 + +@@index([familyId]) @@index([skuCode]) @@index([logisticsLabel]) @@index([craftLabel]) +``` + +这四个解析列由同步落库时自动填充(见 §6),是价格归因、自动建族、按维度查询的共同地基。 +解析失败的链接列置空,不阻塞同步,进后台待处理列表。 + +### 5.3 `Good` 增量 + +```prisma +familyId BigInt? @map("family_id") // 新事实来源;保留 origin_good_id 作为主链接(跳转/兜底) +@@index([familyId]) +``` + +`good_origin_goods` 关联表在迁移完成后只读保留一段时间,确认无回归后废弃删除。 + +### 5.4 价格矩阵 JSON 结构(物化快照) + +```jsonc +{ + "crafts": ["单面印花", "双面印花"], // 族内去重后的工艺(印花数量)选项 + "logistics": ["包邮", "专线"], // 族内去重后的物流选项 + "rows": [ + { + "sizeId": "size_3", "sizeName": "XXXL", + "colorId": "color_2", "colorName": "黑色", + "craft": "单面印花", "logistics": "包邮", + "price": "25.00", // Decimal 字符串,来自 OriginGoodVariant.price + "sources": [ // 同格子的全部来源(后台核对下单路由用) + { "sdsGoodId": "123", "sdsVariantId": "456", "price": "25.00" } + ] + } + ] +} +``` + +## 6. 链接名结构化解析 + +输入:`OriginGood.goodName`(`国家(物流备注)品名-SKU-工艺位置[-仓库名]`)。 + +输出:`{ country?, logisticsLabel?, productName?, skuCode?, craftLabel?, warehouseLabel? }` + +- 按现有 3 段规则(`apps/admin/src/utils/origin-name.ts` 的语义)在后端以 TS 实现, + 作为共享解析器(放 `apps/api/src/origin-goods/origin-name.parser.ts` 或 common), + admin 端后续改为复用同一语义; +- 解析时机:同步 upsert `OriginGood` 时、以及存量回填脚本; +- 容错:名称不符合格式时各列置空;解析器对真实数据(约 552 条,其中 541 条符合格式)需有 + golden 样本测试,包括畸形样本。 + +## 7. 并集与冲突规则 + +- **尺码表并集**:按 `sizeName` 对齐合并各成员的 `sizeChart`; + 同一尺码测量值冲突时,取**尺码覆盖最全的成员**的该行(即提供 XXXXL 的链接优先), + 其次取主链接;无法对齐时记 warning 日志,供管理员在族编辑页人工裁决后锁定(`autoManaged=false`)。 +- **包装规则并集**:同样按尺码对齐合并 `packageSpecs`,冲突规则同上。 +- **商品详情**:canonical 版本默认取主链接(管理员可指定),并集只作用于尺码表与包装规则。 +- **同格子价格冲突**(同物流+同工艺、不同仓库的链接,同尺码颜色不同价):取**最低价**, + `sources` 保留全部来源。 + +## 8. 价格模型 + +``` +第一次:选链接 → 物流 + 工艺(印花数量) (一个组合对应一条/多条成员链接) +第二次:选变体 → 尺码 + 颜色 (链接下的 SDS 变体) + +最终价 = 该 (物流, 工艺) 组合下该 (尺码, 颜色) 变体的 SDS 价格,原样透传 +``` + +- 推导过程:每条成员链接按 `(logisticsLabel, craftLabel)` 归因 → 其变体按 + `(sizeId, colorId)` 落格 → 去重(冲突取最低价)→ 物化为 `priceMatrix`; +- 展示"起价" = 全矩阵最低格价格; +- 不存在任何人工定价入口,无阶梯折扣表。 + +## 9. 同步联动与重算 + +在 `persistProductDetail`(`apps/api/src/sync/sync.service.ts`)事务提交后: + +1. 若该 `OriginGood.familyId` 非空,投递**去重的异步重算任务**(进程内队列即可,幂等); +2. 重算内容:并集尺码表、并集包装规则、价格矩阵、族选项(crafts/logistics 去重列表); +3. `autoManaged=true`:直接重算覆盖镜像物化字段; +4. `autoManaged=false`:只置 `stale=true`,绝不静默覆盖人工数据; +5. 幂等性:重算输入只有成员链接的镜像数据,同一状态重算结果相同,可安全重试。 + +商品列表同步(`syncProducts`)在 upsert `OriginGood` 时同步刷新四个解析列; +链接被标记 `delisted` 时,其族在下一次重算中自然剔除该成员的变体与尺码贡献。 + +## 10. API 契约 + +### 10.1 后台(JWT 保护,需按现有权限体系评估,见 §13) + +| 端点 | 说明 | +| --- | --- | +| `GET /product-families?keyword=&page=&pageSize=` | 分页列表(含成员数、stale 标记) | +| `POST /product-families/auto-group` | 按 3 段规则**预览**可成族分组,`apply=true` 时落库(DG001 18 条一键成族) | +| `POST /product-families` | 手工建族 | +| `PATCH /product-families/:id` | 编辑 canonical 字段(名称/主图/国家/分类/详情/主链接/autoManaged) | +| `POST /product-families/:id/members` | 增删成员 `{ addOriginGoodIds, removeOriginGoodIds }`,变更后触发重算 | +| `POST /product-families/:id/recompute` | 手动重算 | +| `GET /origin-goods/tree` | 改为按**真实族**分组展示,替代名称截断的临时分组 | + +### 10.2 公开(无鉴权) + +`GET /public/goods/:goodId`(goodId 仍为 sdsGoodId,主源或副源命中均可,语义不变)详情新增: + +```jsonc +{ + "family": { + "familyCode": "DG001", + "sizes": [{ "id": "size_1", "name": "S", "available": true }], // 并集 + "colors": [{ "id": "color_1", "name": "黑色", "hex": "#000", "imageUrl": "..." }], + "crafts": ["单面印花", "双面印花"], + "logisticsOptions": ["包邮", "专线"], + "sizeChart": { /* 并集 */ }, + "packageSpecs": { /* 并集 */ }, + "priceMatrix": { /* §5.4 结构,嵌入详情而非单独查价接口 */ } + } +} +``` + +- 矩阵直接嵌入详情响应:无阶梯计算,矩阵是静态快照,前端本地联动即可, + 体量与现状(主副源变体拼接)相当甚至更小(去重后); +- 不可用组合由矩阵行的**缺席**表达(前端禁用对应选项); +- 读路径切换加配置开关(如 `PUBLIC_DETAIL_FROM_FAMILY`)灰度,可秒回退。 + +## 11. 迁移与灰度 + +1. **建表加列**:Prisma migration(product_families、OriginGood 四解析列 + familyId、Good.familyId); +2. **解析回填**:脚本解析全部存量 `OriginGood.goodName` 填四列,输出不可解析清单; +3. **自动建族**:按 3 段键分组,每组建族(规范名取品名段、familyCode 取 SKU 段)、挂成员; + **每条非下架链接都归属一个族**,无兄弟则单成员族(统一不变量,免特判); +4. **合并关系映射**:遍历现有 `good_origin_goods`,每个 Good 的成员集合 + (主源+副源)对应到族(恰好匹配自动族则直接关联;横跨多组则建独立族),回填 `Good.familyId`; +5. **物化回填**:对全部族跑一次重算,填充并集与价格矩阵; +6. **读路径灰度**:开关切换 `/public/goods/:goodId` 详情来源;确认无回归后, + `good_origin_goods` 转只读观察一段时间再废弃。 + +分期建议:一期(族 + 解析 + 自动建族 + 后台树改造),二期(并集物化 + 公开详情切换 + Good.familyId), +避免单次变更过大。 + +## 12. 测试要点 + +- 解析器单元测试:golden 样本覆盖真实 552 条中的代表格式 + 畸形样本; +- 并集合并:同尺码冲突裁决、缺尺码补齐、包装规则合并、warning 记录; +- 价格矩阵推导:归因正确性、同格子最低价、delisted 成员剔除、幂等重算; +- 锁定语义:`autoManaged=false` 的族在重算时只置 stale、字段不被覆盖; +- 迁移映射:现有 Good 合并关系到族的映射(恰好匹配 / 横跨多组); +- 公开接口契约:开关两态下的响应形状、任何成员 sdsGoodId 均命中同一族; +- 全量现有测试保持通过(回归红线)。 + +## 13. 权限评估(按 AGENTS.md 要求) + +新增后台操作需纳入现有权限体系(User/Role + JWT): + +- 产品族列表/详情:读权限(对齐现有 origin-goods 读); +- 建族/自动成族/成员增删/canonical 编辑/手动重算:写权限(对齐现有 goods 配置写); +- 同步触发重算:系统内部行为,走服务账户,不暴露新端点。 + +上线前需在 `docs/references/authority-matrix-ui.md` 同步权限矩阵。 + +## 14. 默认值与待确认项 + +以下规则已按默认值设计,实施前如需推翻请在此记录: + +1. 同格子多链接价格冲突 → **取最低价**; +2. 尺码测量值冲突 → **尺码覆盖最全的成员优先**; +3. 单链接是否建族 → **是**(统一不变量); +4. `familyCode` 唯一性 → 全局唯一,允许为空(历史/畸形数据)。