# 产品族（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/admin，D5）

### 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 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` 转只读观察一段时间再废弃；
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. 覆盖价方向 → 不限制（可高于或低于推导价），仅校验为正数。
