Files
inkreach-official-website/docs/superpowers/specs/2026-08-28-product-family-merge-design.md
T

275 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 产品族(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 migrationproduct_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` 唯一性 → 全局唯一,允许为空(历史/畸形数据)。