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

378 lines
22 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(同日修订:价格允许人工覆盖、支持人工添加自定义商品入族;公开接口零新增全复用;admin 前端纳入本期)
- 状态:已评审(讨论稿定稿,待实施计划)
- 分支:`feature/product-link-merge-yeuimu`
- 范围:后端(apps/api+ 后台管理前端(apps/admin);官网(apps/website)不在本期,
但公开接口保持 100% 兼容复用(见 §10.2)
## 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 同步流水线保持"纯镜像"定位不变。
**非目标**
- 不引入价格阶梯/折扣表(订单量定价已被明确否决);价格默认按链接推导透传,
但**允许人工按格改价(覆盖表)**,并支持**人工添加自定义商品入族**(见 §5.5、§5.6、§8);
- 不改动同步任务的核心抓取逻辑与安全护栏;
- 不改动 apps/website(官网)——公开接口零新增、原样复用,官网可无感灰度。
## 3. 决策记录
| # | 问题 | 决策 |
| --- | --- | --- |
| D1 | 合并落在哪一层 | **新增 SPU 产品族层**(介于 OriginGood 与 Good 之间),而非强化 Good 级合并或读取时动态聚合 |
| D2 | 价格形态 | **默认按链接透传 + 人工覆盖**2026-08-28 修订):无阶梯折扣、无全手动明码矩阵;价格默认 = 成员链接变体的 SDS 原价,允许按格子人工改价(覆盖表),并允许人工添加自定义商品入族 |
| D3 | 买家选价粒度 | **五维全选**:详情页提供尺码/颜色/工艺(印花数量)/物流选择器,价格实时联动 |
| D4 | 公开接口 | **零新增、全复用**:不新建任何公开端点,族数据以增量字段嵌入既有 `/public/*` 响应;`goodId=sdsGoodId` 与"任意成员命中同一族"语义不变 |
| D5 | 前端范围 | **admin 后台前端纳入本期**(原产品树按族分组、族管理、人工改价、自定义商品四块界面);官网不做 |
## 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 字符串):推导价或人工覆盖价
"manual": false, // true = 该格价格来自 FamilyPriceOverride
"sources": [ // 同格子的全部来源(后台核对下单路由用)
{ "sdsGoodId": "123", "sdsVariantId": "456", "price": "25.00" }
]
}
]
}
```
### 5.5 价格覆盖表(人工改价)
```prisma
model FamilyPriceOverride {
id BigInt @id @default(autoincrement()) @map("family_price_override_id")
familyId BigInt @map("family_id")
sizeId String @map("size_id") // 精确格,不做通配
colorId String @map("color_id")
craft String @map("craft")
logistics String @map("logistics")
price Decimal @db.Decimal(12, 2) // 人工价,仅校验为正数,不限制方向
note String? // 改价原因备注(审计)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
family ProductFamily @relation(fields: [familyId], references: [id], onDelete: Cascade)
@@unique([familyId, sizeId, colorId, craft, logistics])
@@map("family_price_overrides")
}
```
- 覆盖表**独立于推导矩阵**:重算只重建推导矩阵,覆盖表永不被动过;
- 有效价 = 覆盖命中 ? 覆盖价 : 推导价,物化时合并进 `priceMatrix`(命中行标记 `manual: true`);
- 覆盖可命中推导矩阵中**不存在**的格子(人工补组合/补价),但各维度取值必须来自该族
的选项集合(sizes/colors/crafts/logistics),校验失败返回 400
- 删除覆盖即恢复推导价,无任何副作用。
### 5.6 自定义成员(人工添加商品)
复用现有 `OriginGood.source = CUSTOM` 机制(`POST /goods/custom` 已存在),扩展为可入族:
- 自定义 OriginGood 与 SDS 链接一样通过 `familyId` 挂族;其变体(尺码/颜色/价格)、
详情、尺码表、包装规则全部为人工数据;
- 四个解析列(skuCode/logistics/craft/warehouse)由管理员创建时**手工填写**,
其中物流、工艺为必填(价格矩阵归因依赖);
- 重算语义:自定义成员的变体与详情是**人工数据、只读贡献**——并集与矩阵推导时读取,
永不覆盖或删除;仅当管理员显式移出族或删除该商品时失效;
- 自动建族(3 段规则)不会把 CUSTOM 成员吸进族,归属完全由人工决定。
## 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` 保留全部来源;在此基础上若存在人工覆盖,以覆盖价为最终有效价(§5.5)。
## 8. 价格模型
```
第一次:选链接 → 物流 + 工艺(印花数量) (一个组合对应一条/多条成员链接)
第二次:选变体 → 尺码 + 颜色 (链接下的 SDS 变体)
推导价 = 该 (物流, 工艺) 组合下该 (尺码, 颜色) 变体的 SDS 价格,原样透传
有效价 = 覆盖表命中 ? 覆盖价 : 推导价 // 人工改价入口,见 §5.5
```
- 推导过程:每条成员链接按 `(logisticsLabel, craftLabel)` 归因 → 其变体按
`(sizeId, colorId)` 落格 → 去重(冲突取最低价)→ 物化为 `priceMatrix`
- 展示"起价" = 有效矩阵(推导 ∪ 覆盖)最低格价格;
- 阶梯折扣表不存在;人工定价仅有覆盖表按格修改一个入口(§5.5)。
## 9. 同步联动与重算
`persistProductDetail``apps/api/src/sync/sync.service.ts`)事务提交后:
1. 若该 `OriginGood.familyId` 非空,投递**去重的异步重算任务**(进程内队列即可,幂等);
2. 重算内容:并集尺码表、并集包装规则、价格矩阵、族选项(crafts/logistics 去重列表);
3. `autoManaged=true`:直接重算覆盖镜像物化字段;
4. `autoManaged=false`:只置 `stale=true`,绝不静默覆盖人工数据;
5. 幂等性:重算输入均为确定性数据(成员镜像、覆盖表、自定义成员),同一状态重算结果相同,可安全重试;
6. 覆盖表(§5.5)与自定义成员数据(§5.6)在重算中**只读**,永不覆盖;
物化 `priceMatrix` 时把覆盖合并为最终有效矩阵(命中行 `manual: true`)。
商品列表同步(`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 /product-families/:id/price-overrides` | 覆盖列表(含推导价对照、差异标记) |
| `PUT /product-families/:id/price-overrides` | 批量 upsert 人工价 `{ items: [{ sizeId, colorId, craft, logistics, price, note }] }`,写后触发重物化 |
| `DELETE /product-families/:id/price-overrides` | 按格删除覆盖 `{ cells: [...] }`,恢复推导价 |
| `POST /product-families/:id/members/custom` | 在族内创建自定义成员(人工商品:名称/主图/物流/工艺归因/变体价格/详情) |
| `GET /origin-goods/tree` | 改为按**真实族**分组展示,替代名称截断的临时分组 |
`POST /goods/custom` 保留并扩展:请求体增加解析列(物流/工艺必填)与结构化变体
(尺码/颜色/价格),创建后可直接指定 `familyId` 挂族;未指定则形成单成员族。
### 10.2 公开(无鉴权)
**复用原则(D4):不新增任何公开端点、不改变路径与参数语义**。族数据全部以增量字段
嵌入既有响应,官网现有代码继续工作;详情页消费 `family` 块属于纯增量升级。
`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 结构(含 manual 标记),嵌入详情而非单独查价接口 */ }
}
}
```
- 矩阵直接嵌入详情响应:无阶梯计算,矩阵是静态快照,前端本地联动即可,
体量与现状(主副源变体拼接)相当甚至更小(去重后);
- 不可用组合由矩阵行的**缺席**表达(前端禁用对应选项);
- 读路径切换加配置开关(如 `PUBLIC_DETAIL_FROM_FAMILY`)灰度,可秒回退。
## 11. 后台管理前端(apps/adminD5
### 11.1 现状与入口
- 外层壳 `views/product-management/ProductManagementView.vue` 以标签页组织:
商品配置(`views/goods/GoodsView.vue`,约 2655 行)、数据同步(`views/sync/SyncView.vue`);
- 本期新增第三个标签页"**产品族**",并改造 GoodsView 的原产品树。
### 11.2 新增"产品族"标签页
新建 `views/product-family/` 目录,按职责拆组件(避免复刻 GoodsView 的巨型单文件):
- `FamilyList.vue`:族列表——关键词/分页/成员数、`stale` 红点、`autoManaged` 状态;
- `FamilyDetail.vue`:族详情——canonical 字段编辑(名称/主图/国家/分类/主链接/autoManaged)、
成员管理(SDS 链接增删、创建自定义成员)、手动重算;
- `AutoGroupDialog.vue`:自动建族——按 3 段规则展示候选分组预览,确认后应用;
- `PriceOverridePanel.vue`:人工改价矩阵——按 (工艺 × 物流) 切换页签,表格为尺码 × 颜色,
单元格显示有效价,人工格高亮并展示与推导价的差额,行内编辑即调
`PUT / DELETE price-overrides`
- 图片上传复用现有 `components/ImageUpload.vue`
### 11.3 GoodsView 改造
- 右侧原产品树分组依据从**前端名称截断**(`utils/origin-name.ts`,本期后退役)切换为
`GET /origin-goods/tree` 返回的真实族分组;
- 原"合并到 Good"交互改为"挂到族":勾选兄弟链接 → 加入既有族或新建族;
- Good 配置表单增加 `familyId`(选族替代选主源+副源),保留"主链接"概念用于跳转兜底。
### 11.4 API client
- 新增 `src/api/product-families.ts`(族 CRUD / auto-group / members / overrides / recompute);
- `src/api/origin-goods.ts``src/api/goods.ts` 随契约更新(familyId 字段)。
### 11.5 前端测试
关键交互(auto-group 预览、覆盖编辑、自定义成员创建、树分组切换)随仓库现有测试设施
补充组件/交互测试;全量现有测试保持通过。
## 12. 迁移与灰度
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` 转只读观察一段时间再废弃;
7. **自定义商品与覆盖**:存量 `source=CUSTOM` 的 OriginGood 解析列保持为空,由管理员
按需补填并挂族;覆盖表初始为空,随运营逐步产生,无需数据迁移。
分期建议:一期后端(族 + 解析 + 自动建族 + 树接口改造),二期 admin 前端(产品族页 + GoodsView 改造),
三期(并集物化 + 公开详情灰度切换 + Good.familyId),避免单次变更过大。
## 13. 测试要点
- 解析器单元测试:golden 样本覆盖真实 552 条中的代表格式 + 畸形样本;
- 并集合并:同尺码冲突裁决、缺尺码补齐、包装规则合并、warning 记录;
- 价格矩阵推导:归因正确性、同格子最低价、delisted 成员剔除、幂等重算;
- 锁定语义:`autoManaged=false` 的族在重算时只置 stale、字段不被覆盖;
- 覆盖语义:重算后覆盖价保留且生效;覆盖可新增推导矩阵中不存在的格子(校验维度取值);
删除覆盖恢复推导价;覆盖行在公开矩阵中带 `manual` 标记;
- 自定义成员:变体/详情不被重算覆盖;物流/工艺归因正确进入矩阵;自动建族不吸收 CUSTOM 成员;
- 迁移映射:现有 Good 合并关系到族的映射(恰好匹配 / 横跨多组);
- 公开接口契约:开关两态下的响应形状、任何成员 sdsGoodId 均命中同一族;
- 全量现有测试保持通过(回归红线)。
## 14. 权限评估(按 AGENTS.md 要求)
新增后台操作需纳入现有权限体系(User/Role + JWT):
- 产品族列表/详情:读权限(对齐现有 origin-goods 读);
- 建族/自动成族/成员增删/canonical 编辑/手动重算:写权限(对齐现有 goods 配置写);
- 同步触发重算:系统内部行为,走服务账户,不暴露新端点。
上线前需在 `docs/references/authority-matrix-ui.md` 同步权限矩阵。
## 15. 默认值与待确认项
以下规则已按默认值设计,实施前如需推翻请在此记录:
1. 同格子多链接价格冲突 → **取最低价**
2. 尺码测量值冲突 → **尺码覆盖最全的成员优先**
3. 单链接是否建族 → **是**(统一不变量);
4. `familyCode` 唯一性 → 全局唯一,允许为空(历史/畸形数据);
5. 覆盖粒度 → 精确格 `(sizeId, colorId, craft, logistics)`,不做通配符;
6. 覆盖价方向 → 不限制(可高于或低于推导价),仅校验为正数。