Sfoglia il codice sorgente

docs: SKU 关联商品选择器设计文档 — linked_product_id + label 模型,不迁移存量

asus 1 mese fa
parent
commit
65d4e5ebfb

+ 314 - 0
docs/superpowers/specs/2026-08-15-sku-linked-product-selector-design.md

@@ -0,0 +1,314 @@
+# SKU 关联商品选择器设计
+
+**优先级:** P1
+**状态:** 设计稿
+**日期:** 2026-08-15
+
+## 1. 概述
+
+### 1.1 背景
+
+当前 SKU 模型是"规格变体":一个商品下的多个 SKU 表示同款商品的不同属性(颜色、尺码等),每个 SKU 有自己的价格、库存、图片。
+
+新需求将 SKU 改为"关联商品选择器":商品 A 的 SKU 可以指向另一个独立商品 B,用户在商品 A 页面通过"规格"方式选择商品 B,查看商品 B 的信息并购买商品 B。
+
+### 1.2 核心概念
+
+- **父商品(Parent Product)**:展示 SKU 选择器的商品页面
+- **关联商品(Linked Product)**:SKU 指向的目标商品,选中后购买此商品
+- **Linked SKU**:`linked_product_id IS NOT NULL` 的新模式 SKU
+- **Legacy SKU**:`linked_product_id IS NULL` 的旧模式 SKU(规格变体,保持兼容)
+
+### 1.3 决策记录
+
+| 决策 | 结论 |
+|------|------|
+| 父商品本身可购买吗? | 可以,不选 SKU 直接买父商品 |
+| 价格/库存/图片来源? | 从 linked_product 读取,库存为 0 不显示选项 |
+| 选项标签来源? | 默认取 linked_product.name,可自定义覆盖 |
+| 下单时买什么? | 买 linked_product(`productId = linked.id`) |
+| 管理端选商品限制? | 只能选同一分类的商品 |
+| 可重复指向同一商品? | 不可以 |
+| 存量 SKU 处理? | 不迁移,原样保留 |
+| `specs` 字段? | 废弃,新 SKU 不填 |
+
+---
+
+## 2. 数据模型
+
+### 2.1 product_skus 表变更
+
+`product_skus` 表新增两列,旧列(`specs`/`price`/`stock`/`image` 等)保留但新模式 SKU 不填:
+
+| 新增列 | 类型 | 说明 |
+|--------|------|------|
+| `linked_product_id` | `BIGINT NULL` | 关联的商品 ID,`null`=Legacy SKU |
+| `label` | `VARCHAR(100) NULL` | 自定义显示标签,`null` 时取 linked_product.name |
+
+### 2.2 行为规则
+
+| 条件 | 模式 | 行为 |
+|------|------|------|
+| `linked_product_id IS NOT NULL` | Linked SKU | 价格/库存/图片/简介从 linked_product 读取;`specs`/`price`/`stock`/`image`/`purchasePrice`/`originalPrice`/`minQuantity` 等旧列忽略 |
+| `linked_product_id IS NULL` | Legacy SKU | 保持原有规格变体行为不变 |
+| `linked.stock = 0` | 隐藏 | 该 option 不返回给前端(不显示) |
+| `linked_product deleted/disabled` | 隐藏 | 该 option 不返回 |
+
+### 2.3 迁移
+
+无需迁移。新增列默认 `NULL`,所有存量 SKU 的 `linked_product_id = null`,保持旧行为。
+
+---
+
+## 3. 后端 API
+
+### 3.1 新增接口
+
+#### `POST /api/admin/product/linked/list`
+
+**用途:** 管理端关联商品选择器(分页、分类筛选、关键字搜索、排除当前商品)
+
+**请求:**
+```json
+{
+  "productId": 123,
+  "categoryId": 5,
+  "keyword": "净水",
+  "page": 1,
+  "pageSize": 20
+}
+```
+
+**返回:**
+```json
+{
+  "code": 200,
+  "data": {
+    "list": [{
+      "id": 456,
+      "name": "豪华版净水机",
+      "price": 5999,
+      "stock": 20,
+      "image": "https://...",
+      "categoryId": 5,
+      "categoryName": "净水设备"
+    }],
+    "total": 1,
+    "page": 1,
+    "pageSize": 20
+  }
+}
+```
+
+### 3.2 修改接口
+
+#### `POST /api/admin/product/sku/create`
+
+**接受:** 新增 `{ ..., label, linkedProductId }`。当 `linkedProductId` 不为空时,`specs`/`price`/`stock`/`image` 等旧字段不需要传。
+
+```json
+{
+  "productId": 123,
+  "label": "豪华版",
+  "linkedProductId": 456
+}
+```
+
+#### `POST /api/admin/product/sku/update`
+
+同 create,支持更新 `label` 和 `linkedProductId`。
+
+#### `POST /api/product/spec/map`
+
+**返回扩展:** 每个 option 增加 linked 商品信息。
+
+```json
+{
+  "options": [{
+    "id": 123,
+    "groupId": 1,
+    "name": "豪华版",
+    "skuId": 123,
+    "linkedProductId": 456,
+    "linked": {
+      "id": 456,
+      "name": "豪华版净水机",
+      "price": 5999,
+      "stock": 20,
+      "image": "https://...",
+      "brief": "内置五级滤芯..."
+    },
+    "isLinked": true
+  }]
+}
+```
+
+**过滤规则:** `linked_product_id IS NOT NULL` 且 `linked.stock = 0` 或 `linked.enabled = 0` 或 `linked.deleted = 1` 时,该 option 不返回。
+
+### 3.3 下单/购物车流程
+
+| 阶段 | 有 linked SKU | 无 linked SKU | 无 SKU |
+|------|---------------|---------------|--------|
+| 加入购物车 | `productId = linked.id`,`skuId = sku.id` | `productId = 父商品.id`,`skuId = sku.id` | `productId = 父商品.id` |
+| 下单 | `productId = linked.id`,`skuId = sku.id` | `productId = 父商品.id`,`skuId = sku.id` | `productId = 父商品.id` |
+| 价格 | linked.price | sku.price | product.price |
+| 库存 | linked.stock | sku.stock | product.stock |
+
+---
+
+## 4. 管理端(ProductSkuEditor.vue)
+
+### 4.1 UI 变更
+
+**当前:** 弹窗表单(specs 输入框 + 价格 + 库存 + 图片 + 启用)
+
+**新的 SKU 编辑弹窗(Linked SKU 模式):**
+
+```
+┌─ 添加SKU ───────────────────────────┐
+│                                      │
+│  显示标签: [豪华版         ]         │
+│                                      │
+│  关联商品: [选择商品...]            │
+│  ┌─ 已选: 豪华版净水机 ──┐          │
+│  │ [图片] 名称 | ¥5999   │          │
+│  │ 库存: 20              │          │
+│  └────────────────────────┘          │
+│                                      │
+│  [启用]                              │
+│                                      │
+│       [取消]          [保存]         │
+└──────────────────────────────────────┘
+```
+
+**点击"选择商品"弹出商品选择器:**
+
+```
+┌─ 选择关联商品 ─────────────────────────┐
+│                                        │
+│  分类: [全部 ▼]   搜索: [净水    ] 🔍  │
+│                                        │
+│  ┌─ 商品列表 ──────────────────────┐   │
+│  │ ○ 豪华版净水机  ¥5999  库存20   │   │
+│  │ ○ 标准版净水机  ¥3999  库存50   │   │
+│  │ ○ 滤芯套装      ¥299   库存200  │   │
+│  └──────────────────────────────────┘   │
+│                                        │
+│  共 3 条                       1/1 页  │
+│                                        │
+│          [取消]    [确认选择]           │
+└──────────────────────────────────────────┘
+```
+
+### 4.2 规则
+
+- 只能选择**同一分类**的商品(`categoryId = 父商品.categoryId`)
+- 排除当前商品自身(不能关联自己)
+- 已关联过的商品不可重复选择(同一父商品下不可重复)
+- 支持关键字搜索(商品名模糊匹配)
+- 支持分页
+- 商品列表中显示已选中的商品
+
+### 4.3 兼容性
+
+- 已有 Legacy SKU 的编辑弹窗**保持原样**(specs/price/stock 表单)
+- 新 SKU 创建时,`linkedProductId` 不为空时隐藏 specs/price/stock 等旧字段
+- 编辑时根据 `linkedProductId` 是否为空决定显示哪种表单
+
+---
+
+## 5. 小程序详情页(product-detail.vue)
+
+### 5.1 规格选择流程
+
+**加载阶段:**
+1. 页面加载调用 `specMap` 获取 SKU 列表
+2. 对每个 `isLinked = true` 的 option,缓存 linked 商品信息到 `linkedProductMap[skuId]`
+3. 旧模式 SKU(`isLinked = false`)保持原逻辑
+
+**选中 linked SKU 时:**
+1. 在规格选择区下方展示 linked 商品简介卡片
+
+```
+┌─────────────────────────────────┐
+│  ┌─────┐                        │
+│  │     │  豪华版净水机          │
+│  │图片 │  ¥5,999                │
+│  │     │  库存 20               │
+│  └─────┘  ─────────────────    │
+│          内置五级滤芯,有效去除   │
+│          余氯、重金属、细菌...   │
+│          [查看详情 →]            │
+└─────────────────────────────────┘
+```
+
+2. 价格显示切换为 linked 商品价格
+3. 库存显示切换为 linked 商品库存
+4. "查看详情"可跳转到 linked 商品详情页
+
+**未选规格时:**
+- 显示父商品 A 的信息
+- 按钮始终可点(买 A)
+
+### 5.2 购物车/下单
+
+- 有 linked SKU 时:`productId = linked.id`,`skuId = sku.id`
+- 无 linked SKU 时:`productId = 父商品.id`,`skuId = 无`
+- 无 SKU 时:`productId = 父商品.id`
+
+---
+
+## 6. 错误处理与边界情况
+
+| 情况 | 处理 |
+|------|------|
+| linked 商品库存为 0 | 该 option 不显示 |
+| linked 商品已下架/删除 | 该 option 不显示 |
+| 同一商品被多个父商品关联 | 正常,无限制 |
+| 商品关联自己 | 管理端排除当前商品,API 层面加校验 |
+| 循环关联(A→B→A→B...) | 不处理深层引用;linked 商品详情页不展示其自身的 SKU(或仅展示 1 层) |
+| 自定义 label 为空 | 回退用 linked.name |
+| Legacy SKU(无 linked_product_id) | 保持原样,不触发新逻辑 |
+
+---
+
+## 7. 实施范围
+
+### 7.1 后端
+
+| 任务 | 文件 | 改动 |
+|------|------|------|
+| 实体新增字段 | `ProductSku.java` | 新增 `linkedProductId`、`label` |
+| 新增商品选择器接口 | `ProductController.java` / `AdminProductController.java` | `POST /api/admin/product/linked/list` |
+| 修改 specMap | `ProductController.java` | option 扩展 linked 摘要 |
+| 修改 SKU 创建/更新 | `ProductSkuService.java` | 支持 linkedProductId |
+| 购物车 linked 转发 | `CartService.java` | `cart.getSkuId()` 时检查 linked |
+| 下单 linked 转发 | `ProductOrderService.java` | 下单时识别 linked 目标 |
+| 迁移 | `DatabaseInitializer.java` | 新增 `linked_product_id` 和 `label` 列 |
+
+### 7.2 管理端(cfc-web)
+
+| 任务 | 文件 | 改动 |
+|------|------|------|
+| 商品选择器组件 | 新增 `ProductPicker.vue` | 分类筛选 + 搜索 + 分页 + 单选 |
+| SKU 编辑弹窗改造 | `ProductSkuEditor.vue` | 按 linkedProductId 切换两种表单模式 |
+| API 新增 | `admin.js` | 新增 `linkedProductList` |
+
+### 7.3 小程序(cfc-frontend)
+
+| 任务 | 文件 | 改动 |
+|------|------|------|
+| 规格选择扩展 | `product-detail.vue` | linked SKU 选中时展示简介卡片 |
+| 价格/库存切换 | `product-detail.vue` | 切换为 linked 商品的价格/库存 |
+| 下单转发 | `product-detail.vue` | 有 linked SKU 时 productId 传 linked.id |
+| API 调用 | `api.js` | 已有 `productSpecGroups` 不变,解析新字段 |
+
+---
+
+## 8. 不涉及
+
+- 不改 schema.sql(新列通过迁移添加)
+- 不改 ProductOrderService 的库存校验逻辑(linked 商品用自己的库存)
+- 不改 ProductSkuDTO(仅创建/更新时用)
+- 不改旧 SKU 的任何行为
+- 不做数据迁移