瀏覽代碼

docs: SKU 关联商品选择器实施计划 8 Tasks

asus 1 月之前
父節點
當前提交
61f3bb0504
共有 1 個文件被更改,包括 883 次插入0 次删除
  1. 883 0
      docs/superpowers/plans/2026-08-15-sku-linked-product-selector.md

+ 883 - 0
docs/superpowers/plans/2026-08-15-sku-linked-product-selector.md

@@ -0,0 +1,883 @@
+# SKU 关联商品选择器 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将 SKU 从"规格变体"改为"关联商品选择器"——商品 A 的 SKU 可指向另一个独立商品 B,用户在 A 页面选择规格后查看 B 的信息并购买 B。
+
+**Architecture:** `product_skus` 表新增 `linked_product_id` 和 `label` 两列。新模式 SKU(`linked_product_id IS NOT NULL`)的价格/库存/图片从 linked_product 读取,下单时直接购买 linked_product。旧模式 SKU(`linked_product_id IS NULL`)保持原规格变体行为。管理端新增商品选择器组件,SKU 编辑弹窗按模式切换表单。
+
+**Tech Stack:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / uni-app Vue 2 小程序 / Vue 2 + Element UI Web管理端
+
+## Global Constraints
+
+- 后端接口统一 `@PostMapping`,禁止 `@GetMapping`/`@PutMapping`/`@DeleteMapping`
+- 响应统一 `Result<T>`(code/message/data)
+- JWT Bearer Token,`@RequestAttribute("userId")` 获取用户 ID
+- DI 使用 `@Resource`,字段名匹配 Bean Name
+- 数据库迁移唯一入口:`DatabaseInitializer.runMigrations()`
+- 迁移必须幂等(try-catch 包裹 `ensureColumn` 或异常忽略)
+- `schema.sql` 必须同步更新(ADD COLUMN 后同步到 CREATE TABLE 语句)
+- 小程序:禁止可选链 `?.`(用 `&&`)、禁止 CSS Grid(用 flexbox)、禁止 `:key` 表达式(用方法调用)、禁止 `new Date(string)`(用 `parseDate()`)
+- Vue 2 Options API,禁止 Composition API
+- 存量 SKU 的 `linked_product_id = NULL`,保持原行为,不迁移
+
+---
+
+### Task 1: 数据库迁移 + 实体字段
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` (runMigrations() 末尾,迁移247)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java` (新增 linkedProductId、label)
+- Modify: `cfc-backend/src/main/resources/schema.sql` (同步 product_skus CREATE TABLE 新增两列)
+
+**Interfaces:**
+- Consumes: 无
+- Produces: `ProductSku.linkedProductId` (Long, nullable), `ProductSku.label` (String, nullable)
+
+- [ ] **Step 1: 修改实体 ProductSku.java**
+
+在 `private Integer enabled;` 之后、`private Date createTime;` 之前插入:
+
+```java
+    private Long linkedProductId; // 关联商品ID,null=Legacy SKU
+    private String label;         // 自定义显示标签,null=使用linked_product.name
+```
+
+- [ ] **Step 2: 在 DatabaseInitializer.runMigrations() 添加迁移247**
+
+搜索当前最大迁移编号(在文件末尾搜索 `// 迁移`),在最新迁移块之后、方法结束 `}` 之前插入:
+
+```java
+		// 迁移247: product_skus 添加 linked_product_id 和 label 列(SKU关联商品选择器)
+		ensureColumn("product_skus", "linked_product_id", "BIGINT COMMENT '关联商品ID,null=Legacy SKU'");
+		ensureColumn("product_skus", "label", "VARCHAR(100) COMMENT '自定义显示标签,null=使用linked_product.name'");
+```
+
+- [ ] **Step 3: 同步 schema.sql**
+
+搜索 `CREATE TABLE IF NOT EXISTS product_skus`,在 `enabled` 列定义之后、`create_time` 之前插入:
+
+```sql
+    linked_product_id BIGINT COMMENT '关联商品ID,null=Legacy SKU',
+    label VARCHAR(100) COMMENT '自定义显示标签,null=使用linked_product.name',
+```
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(backend): ProductSku 新增 linkedProductId/label 字段,迁移247"
+```
+
+---
+
+### Task 2: ProductSkuService 创建/更新支持 linked SKU
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java` (create/update 方法)
+
+**Interfaces:**
+- Consumes: `ProductSku.linkedProductId`, `ProductSku.label`
+- Produces: 兼容创建/更新 linked SKU(不校验 specs/price/stock 等旧字段)
+
+- [ ] **Step 1: 修改 create 方法**
+
+当前 `create()` 方法中,`if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) { sku.setSpecs("[]"); }` 改为:
+- 如果 `sku.getLinkedProductId() != null`,跳过 specs 默认值设置(linked SKU 不需要 specs)
+- 如果 `sku.getSkuCode() == null || sku.getSkuCode().isEmpty()`,仍然生成 SKU 码
+
+```java
+    @Transactional
+    public ProductSku create(ProductSku sku) {
+        log.info("[SKU-CREATE] productId={}, linkedProductId={}", sku.getProductId(), sku.getLinkedProductId());
+        if (sku.getLinkedProductId() == null) {
+            // Legacy SKU: 需要 specs
+            if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) {
+                sku.setSpecs("[]");
+            }
+        }
+        sku.setCreateTime(new Date());
+        sku.setUpdateTime(new Date());
+        if (sku.getEnabled() == null) {
+            sku.setEnabled(1);
+        }
+        if (sku.getSkuCode() == null || sku.getSkuCode().isEmpty()) {
+            sku.setSkuCode("SKU-" + sku.getProductId() + "-" + System.currentTimeMillis());
+        }
+        skuMapper.insert(sku);
+        return sku;
+    }
+```
+
+- [ ] **Step 2: 修改 update 方法**
+
+同理,`update()` 中 `if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty())` 改为只在 linkedProductId 为空时设置默认 specs:
+
+```java
+    @Transactional
+    public void update(ProductSku sku) {
+        if (sku.getLinkedProductId() == null) {
+            if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) {
+                sku.setSpecs("[]");
+            }
+        }
+        sku.setUpdateTime(new Date());
+        skuMapper.updateById(sku);
+    }
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java
+git commit -m "feat(backend): ProductSkuService 创建/更新支持 linked SKU,跳过 specs 校验"
+```
+
+---
+
+### Task 3: specMap 接口扩展返回 linked 商品摘要
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java` (specMap 方法)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java` (新增 listLinked 方法)
+
+**Interfaces:**
+- Consumes: `ProductSku.linkedProductId`
+- Produces: `specMap` 返回的 option 中增加 `linkedProductId`、`linked`(name/price/stock/image/brief)、`isLinked`
+
+- [ ] **Step 1: 在 ProductSkuService 新增查询 linked 商品摘要的方法**
+
+```java
+    /**
+     * 查询 linked 商品摘要(用于 specMap 扩展)
+     */
+    public Map<String, Object> getLinkedProductSummary(Long linkedProductId) {
+        if (linkedProductId == null) return null;
+        Product product = productMapper.selectById(linkedProductId);
+        if (product == null) return null;
+        if (product.getEnabled() == null || product.getEnabled() == 0) return null;
+        if (product.getDeleted() != null && product.getDeleted() == 1) return null;
+        if (product.getStock() != null && product.getStock() == 0) return null;
+        Map<String, Object> summary = new HashMap<>();
+        summary.put("id", product.getId());
+        summary.put("name", product.getName());
+        summary.put("price", product.getPrice());
+        summary.put("stock", product.getStock());
+        summary.put("image", product.getCoverImage());
+        summary.put("brief", product.getIntro());
+        return summary;
+    }
+```
+
+需要在 ProductSkuService 注入 `ProductMapper`:
+
+```java
+    @Resource
+    private ProductMapper productMapper;
+```
+
+- [ ] **Step 2: 修改 ProductController.specMap 方法**
+
+在循环构建 option 对象时,对 linked SKU 扩展返回字段。在 `for (ProductSku sku : skus)` 循环内,构建 option 前增加:
+
+```java
+            boolean isLinked = sku.getLinkedProductId() != null;
+
+            // Linked SKU: 跳过库存为0/已删除的关联商品
+            if (isLinked) {
+                Map<String, Object> linkedSummary = productSkuService.getLinkedProductSummary(sku.getLinkedProductId());
+                if (linkedSummary == null) continue; // 库存为0或已下架,不显示
+            }
+```
+
+在 option 构建时增加:
+
+```java
+            if (isLinked) {
+                option.put("linkedProductId", sku.getLinkedProductId());
+                option.put("isLinked", true);
+                Map<String, Object> linkedSummary = productSkuService.getLinkedProductSummary(sku.getLinkedProductId());
+                option.put("linked", linkedSummary);
+                // Linked SKU 的 option name 用 label 或 linked product name
+                String label = sku.getLabel();
+                if (label == null || label.trim().isEmpty()) {
+                    label = (String) linkedSummary.get("name");
+                }
+                option.put("name", label);
+            } else {
+                option.put("isLinked", false);
+            }
+```
+
+注意:`option.put("name", value)` 当前直接取 `specEntry` 的 value。对于 linked SKU 需要覆盖 name 为 label/linked.name。
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java
+git commit -m "feat(backend): specMap 扩展返回 linked 商品摘要,库存为0/已下架不显示"
+```
+
+---
+
+### Task 4: 管理端商品选择器 API
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java` (新增 listForPicker 方法)
+
+**Interfaces:**
+- Consumes: 无
+- Produces: `POST /api/admin/product/linked/list` — 分页 + 分类筛选 + 关键词搜索 + 排除当前商品 + 排除已关联商品
+
+- [ ] **Step 1: 在 ProductService 新增 listForPicker 方法**
+
+```java
+    /**
+     * 管理端关联商品选择器列表
+     * @param excludeProductId 排除当前商品ID
+     * @param categoryId 分类筛选,null=全部
+     * @param keyword 关键词搜索,null=不搜索
+     * @param page 页码
+     * @param pageSize 每页大小
+     * @return Map with list/total/page/pageSize
+     */
+    public Map<String, Object> listForPicker(Long excludeProductId, Long categoryId, String keyword,
+                                              Integer page, Integer pageSize) {
+        if (page == null || page < 1) page = 1;
+        if (pageSize == null || pageSize < 1) pageSize = 20;
+        int offset = (page - 1) * pageSize;
+
+        List<Product> list = productMapper.selectForPicker(excludeProductId, categoryId, keyword, offset, pageSize);
+        Long total = productMapper.countForPicker(excludeProductId, categoryId, keyword);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("list", list);
+        result.put("total", total);
+        result.put("page", page);
+        result.put("pageSize", pageSize);
+        return result;
+    }
+```
+
+- [ ] **Step 2: 在 ProductMapper 新增查询方法**
+
+```xml
+    <!-- ProductMapper.xml -->
+    <select id="selectForPicker" resultType="com.etotem.cfc.entity.Product">
+        SELECT id, name, price, stock, cover_image AS coverImage, category_id AS categoryId
+        FROM products
+        WHERE deleted = 0 AND enabled = 1
+        <if test="excludeProductId != null">
+            AND id != #{excludeProductId}
+        </if>
+        <if test="categoryId != null">
+            AND category_id = #{categoryId}
+        </if>
+        <if test="keyword != null and keyword != ''">
+            AND name LIKE CONCAT('%', #{keyword}, '%')
+        </if>
+        ORDER BY sort_order, id DESC
+        LIMIT #{pageSize} OFFSET #{offset}
+    </select>
+
+    <select id="countForPicker" resultType="long">
+        SELECT COUNT(*) FROM products
+        WHERE deleted = 0 AND enabled = 1
+        <if test="excludeProductId != null">
+            AND id != #{excludeProductId}
+        </if>
+        <if test="categoryId != null">
+            AND category_id = #{categoryId}
+        </if>
+        <if test="keyword != null and keyword != ''">
+            AND name LIKE CONCAT('%', #{keyword}, '%')
+        </if>
+    </select>
+```
+
+- [ ] **Step 3: 创建 AdminProductPickerController**
+
+```java
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ProductService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/product")
+public class AdminProductPickerController {
+
+    @Resource
+    private ProductService productService;
+
+    @PostMapping("/linked/list")
+    public Result<Map<String, Object>> linkedList(@RequestBody Map<String, Object> params) {
+        Long productId = params.get("productId") != null ? Long.valueOf(params.get("productId").toString()) : null;
+        Long categoryId = params.get("categoryId") != null ? Long.valueOf(params.get("categoryId").toString()) : null;
+        String keyword = (String) params.get("keyword");
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 20;
+
+        // 同分类限制:只返回同一分类的商品
+        if (categoryId == null && productId != null) {
+            // 如果未传 categoryId,从当前商品获取
+            com.etotem.cfc.entity.Product product = productService.getById(productId);
+            if (product != null) {
+                categoryId = product.getCategoryId();
+            }
+        }
+
+        Map<String, Object> result = productService.listForPicker(productId, categoryId, keyword, page, pageSize);
+        return Result.success(result);
+    }
+}
+```
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
+git commit -m "feat(backend): 管理端商品选择器 API /api/admin/product/linked/list"
+```
+
+---
+
+### Task 5: 购物车/下单 linked 商品转发
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java` (buildCartItemDTO linked 处理)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java` (createMultiItem linked 处理)
+
+**Interfaces:**
+- Consumes: `ProductSku.linkedProductId`
+- Produces: 用户选 linked SKU 时,加入购物车/下单的 `productId` = linked_product_id
+
+- [ ] **Step 1: 修改 CartService.buildCartItemDTO**
+
+`buildCartItemDTO` 方法在已有 sku 查询块内,追加 linked 转发逻辑:
+
+```java
+        if (cart.getSkuId() != null) {
+            ProductSku sku = productSkuService.getById(cart.getSkuId());
+            if (sku != null) {
+                // Linked SKU: 转发到 linked 商品
+                if (sku.getLinkedProductId() != null) {
+                    Product linkedProduct = productService.getById(sku.getLinkedProductId());
+                    if (linkedProduct != null) {
+                        dto.setProductId(linkedProduct.getId());
+                        dto.setProductName(linkedProduct.getName());
+                        dto.setUnitPrice(linkedProduct.getPrice() != null ? linkedProduct.getPrice() : 0);
+                        // 不覆盖 specs 解析(linked SKU 无 specs)
+                    }
+                } else {
+                    // Legacy SKU 逻辑保持原样
+                    if (sku.getPrice() != null) {
+                        dto.setUnitPrice(sku.getPrice());
+                    }
+                    if (sku.getSpecs() != null) {
+                        // ... 现有 specs 解析逻辑 ...
+                    }
+                }
+            }
+        }
+```
+
+注意:当前 `CartService.java` 的 `buildCartItemDTO` 方法在之前的 Task 3 中已修改为 `if (sku.getPrice() != null) { dto.setUnitPrice(sku.getPrice()); }`。需要将这段改为 linked-aware 的版本。
+
+- [ ] **Step 2: 修改 ProductOrderService.createMultiItem**
+
+在 `createMultiItem` 方法中,解析 `productData` 时,如果传了 `skuId`,查询 SKU 的 linkedProductId:
+
+```java
+        // 如果传了 skuId,检查是否为 linked SKU
+        if (skuId != null) {
+            ProductSku sku = productSkuService.getById(skuId);
+            if (sku != null && sku.getLinkedProductId() != null) {
+                Product linkedProduct = productService.getById(sku.getLinkedProductId());
+                if (linkedProduct != null) {
+                    productId = linkedProduct.getId();
+                    productName = linkedProduct.getName();
+                    unitPrice = linkedProduct.getPrice() != null ? linkedProduct.getPrice() : 0;
+                    coverImage = linkedProduct.getCoverImage();
+                }
+            }
+        }
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
+git commit -m "feat(backend): 购物车/下单 linked SKU 转发到关联商品"
+```
+
+---
+
+### Task 6: 管理端 ProductSkuEditor 双模式 + ProductPicker 组件
+
+**Files:**
+- Create: `cfc-web/src/views/admin/components/ProductPicker.vue`
+- Modify: `cfc-web/src/views/admin/components/ProductSkuEditor.vue`
+- Modify: `cfc-web/src/api/admin.js` (新增 linkedProductList API)
+
+**Interfaces:**
+- Consumes: `POST /api/admin/product/linked/list`
+- Produces: 管理端可创建/编辑 linked SKU(选择商品 + 自定义标签)
+
+- [ ] **Step 1: 在 admin.js 新增 linkedProductList API**
+
+```js
+export function linkedProductList(params) {
+  return request({
+    url: '/api/admin/product/linked/list',
+    method: 'post',
+    data: params
+  })
+}
+```
+
+- [ ] **Step 2: 创建 ProductPicker.vue 商品选择器组件**
+
+```vue
+<template>
+  <el-dialog :visible.sync="visible" title="选择关联商品" width="600px" @close="handleClose">
+    <el-form :inline="true" size="small">
+      <el-form-item label="分类">
+        <el-select v-model="query.categoryId" placeholder="全部" clearable @change="search">
+          <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="搜索">
+        <el-input v-model="query.keyword" placeholder="商品名称" clearable @keyup.enter.native="search" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="search">搜索</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-table :data="list" highlight-current-row @current-change="onSelect">
+      <el-table-column width="50">
+        <template slot-scope="{ row }">
+          <el-radio :value="selectedId" :label="row.id" @change="onSelect(row)">&nbsp;</el-radio>
+        </template>
+      </el-table-column>
+      <el-table-column label="商品" width="380">
+        <template slot-scope="{ row }">
+          <div style="display:flex;align-items:center;gap:10px;">
+            <img v-if="row.coverImage" :src="row.coverImage" style="width:50px;height:50px;object-fit:cover;border-radius:4px;" />
+            <span v-else style="width:50px;height:50px;background:#f0f0f0;display:inline-block;border-radius:4px;"></span>
+            <div>
+              <div>{{ row.name }}</div>
+              <div style="font-size:12px;color:#999;">¥{{ row.price ? (row.price/100).toFixed(2) : '0.00' }}</div>
+            </div>
+          </div>
+        </template>
+      </el-table-column>
+      <el-table-column prop="stock" label="库存" width="80" />
+    </el-table>
+
+    <el-pagination
+      v-if="total > pageSize"
+      :current-page="query.page"
+      :page-size="pageSize"
+      :total="total"
+      layout="prev, pager, next"
+      @current-change="onPageChange"
+    />
+
+    <span slot="footer">
+      <el-button @click="visible = false">取消</el-button>
+      <el-button type="primary" :disabled="!selectedProduct" @click="confirm">确认选择</el-button>
+    </span>
+  </el-dialog>
+</template>
+
+<script>
+import { linkedProductList } from '@/api/admin'
+
+export default {
+  name: 'ProductPicker',
+  props: {
+    productId: { type: Number, default: null },
+    categoryId: { type: Number, default: null }
+  },
+  data() {
+    return {
+      visible: false,
+      list: [],
+      total: 0,
+      pageSize: 20,
+      query: { keyword: '', categoryId: null, page: 1 },
+      selectedProduct: null,
+      categories: []
+    }
+  },
+  computed: {
+    selectedId() { return this.selectedProduct ? this.selectedProduct.id : null }
+  },
+  methods: {
+    open() {
+      this.visible = true
+      this.query.categoryId = this.categoryId
+      this.search()
+    },
+    async search() {
+      this.query.page = 1
+      await this.load()
+    },
+    async load() {
+      try {
+        const res = await linkedProductList({
+          productId: this.productId,
+          categoryId: this.query.categoryId,
+          keyword: this.query.keyword,
+          page: this.query.page,
+          pageSize: this.pageSize
+        })
+        if (res.code === 200) {
+          this.list = res.data.list || []
+          this.total = res.data.total || 0
+        }
+      } catch (e) {
+        this.$message.error('加载商品列表失败')
+      }
+    },
+    onSelect(row) {
+      this.selectedProduct = row
+    },
+    confirm() {
+      this.$emit('selected', this.selectedProduct)
+      this.visible = false
+    },
+    onPageChange(page) {
+      this.query.page = page
+      this.load()
+    },
+    handleClose() {
+      this.selectedProduct = null
+    }
+  }
+}
+</script>
+```
+
+- [ ] **Step 3: 改造 ProductSkuEditor.vue**
+
+在 `ProductSkuEditor.vue` 中:
+
+a) 引入 ProductPicker 组件:
+```js
+import ProductPicker from './ProductPicker.vue'
+
+export default {
+  components: { ProductPicker },
+  // ...
+}
+```
+
+b) data 中新增 linked SKU 相关字段:
+```js
+data() {
+  return {
+    // ... 现有字段
+    skuForm: {
+      // ... 现有字段
+      label: '',
+      linkedProductId: null,
+      linkedProductName: '',
+      linkedProductPrice: 0,
+      linkedProductImage: '',
+      linkedProductStock: 0
+    },
+    isLinkedMode: false
+  }
+}
+```
+
+c) 弹窗中新增模式切换(在顶部添加):
+```html
+<el-radio-group v-model="isLinkedMode" size="small" style="margin-bottom:12px;">
+  <el-radio-button :label="false">规格变体</el-radio-button>
+  <el-radio-button :label="true">关联商品</el-radio-button>
+</el-radio-group>
+```
+
+d) 关联商品模式表单(isLinkedMode=true 时显示):
+```html
+<template v-if="isLinkedMode">
+  <el-form-item label="显示标签">
+    <el-input v-model="skuForm.label" placeholder="默认使用关联商品名称" />
+  </el-form-item>
+  <el-form-item label="关联商品">
+    <el-button @click="openProductPicker">选择商品</el-button>
+    <div v-if="skuForm.linkedProductName" style="margin-top:8px;display:flex;align-items:center;gap:10px;padding:8px;background:#f5f7fa;border-radius:4px;">
+      <img v-if="skuForm.linkedProductImage" :src="skuForm.linkedProductImage" style="width:50px;height:50px;object-fit:cover;border-radius:4px;" />
+      <div>
+        <div>{{ skuForm.linkedProductName }}</div>
+        <div style="font-size:12px;color:#999;">¥{{ skuForm.linkedProductPrice ? (skuForm.linkedProductPrice/100).toFixed(2) : '0.00' }} | 库存 {{ skuForm.linkedProductStock }}</div>
+      </div>
+      <el-button size="mini" type="text" @click="clearLinkedProduct">更换</el-button>
+    </div>
+  </el-form-item>
+</template>
+```
+
+e) 规格变体模式保持原表单(isLinkedMode=false 时显示,隐藏 price/stock/ specs 等字段)
+f) `handleSave` 方法中,根据 isLinkedMode 构建 payload:
+```js
+if (this.isLinkedMode) {
+  payload.linkedProductId = this.skuForm.linkedProductId
+  payload.label = this.skuForm.label || null
+} else {
+  payload.specs = this.normalizeSpecsToJson(this.skuForm.specs)
+  payload.price = Math.round(parseFloat(this.skuForm.price) * 100)
+  // ... 其他旧字段
+}
+```
+
+g) 新增 openProductPicker 方法:
+```js
+openProductPicker() {
+  this.$refs.productPicker.open()
+},
+onProductSelected(product) {
+  this.skuForm.linkedProductId = product.id
+  this.skuForm.linkedProductName = product.name
+  this.skuForm.linkedProductPrice = product.price
+  this.skuForm.linkedProductImage = product.image || product.coverImage
+  this.skuForm.linkedProductStock = product.stock
+}
+```
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-web/src/views/admin/components/ProductPicker.vue cfc-web/src/views/admin/components/ProductSkuEditor.vue cfc-web/src/api/admin.js
+git commit -m "feat(admin): ProductSkuEditor 双模式(规格变体/关联商品)+ ProductPicker 组件"
+```
+
+---
+
+### Task 7: 小程序详情页 linked SKU 展示与购买
+
+**Files:**
+- Modify: `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue`
+
+**Interfaces:**
+- Consumes: `specMap` 返回的 `linked` 字段(name/price/stock/image/brief)
+- Produces: 用户选中 linked SKU 时展示简介卡片,下单时 `productId = linked.id`
+
+- [ ] **Step 1: data 中新增 linked 相关数据**
+
+```js
+data() {
+  return {
+    // ... 现有字段
+    linkedProductMap: {},  // { skuId: { name, price, stock, image, brief } }
+    selectedLinkedProduct: null  // 当前选中的 linked 商品
+  }
+}
+```
+
+- [ ] **Step 2: 在收到 specMap 数据后构建 linkedProductMap**
+
+在 `loadProduct` 或 `loadSpec` 方法中,处理 specMap 返回后:
+
+```js
+// 构建 linked 商品映射
+var linkedMap = {}
+if (specGroups && specGroups.length > 0) {
+  for (var i = 0; i < specGroups.length; i++) {
+    var options = specGroups[i].options || []
+    for (var j = 0; j < options.length; j++) {
+      var opt = options[j]
+      if (opt.isLinked && opt.linked) {
+        linkedMap[opt.skuId] = opt.linked
+      }
+    }
+  }
+}
+this.linkedProductMap = linkedMap
+```
+
+- [ ] **Step 3: 修改 selectSpec 方法,选中 linked SKU 时更新简介卡片**
+
+在 `selectSpec` 方法中,匹配到 SKU 后:
+
+```js
+// 检查是否为 linked SKU
+var sku = this.matchSku() // 或从 selectedSpecs 推导
+var linked = this.linkedProductMap[matchedSkuId]
+if (linked) {
+  this.selectedLinkedProduct = linked
+  this.actualPrice = linked.price
+  // 显示 linked 商品简介
+} else {
+  this.selectedLinkedProduct = null
+  // 恢复为父商品价格
+}
+```
+
+- [ ] **Step 4: 模板中新增 linked 商品简介卡片**
+
+在规格选择区下方、购买按钮上方插入:
+
+```html
+<!-- Linked 商品简介卡片 -->
+<view class="linked-product-card" v-if="selectedLinkedProduct">
+  <view class="linked-card-inner" @click="goLinkedProduct(selectedLinkedProduct.id)">
+    <image class="linked-image" :src="selectedLinkedProduct.image" mode="aspectFill" />
+    <view class="linked-info">
+      <text class="linked-name">{{ selectedLinkedProduct.name }}</text>
+      <text class="linked-price">¥{{ formatPrice(selectedLinkedProduct.price) }}</text>
+      <text class="linked-stock">库存 {{ selectedLinkedProduct.stock }}</text>
+      <text class="linked-brief" v-if="selectedLinkedProduct.brief">{{ selectedLinkedProduct.brief }}</text>
+    </view>
+    <view class="linked-arrow">›</view>
+  </view>
+</view>
+```
+
+- [ ] **Step 5: 修改 onBuy 和 onAddToCart——选择 linked SKU 时买 linked 商品**
+
+```js
+onBuy: function() {
+  var userId = uni.getStorageSync('userId')
+  if (!userId) {
+    uni.navigateTo({ url: '/pages/login/login' })
+    return
+  }
+  var productId = this.product.id
+  var productName = this.product.name
+  var price = this.actualPrice
+  var coverImage = this.product.coverImage
+  var skuId = this.selectedSku ? this.selectedSku.id : null
+
+  // Linked SKU: 转发到 linked 商品
+  if (this.selectedLinkedProduct) {
+    productId = this.selectedLinkedProduct.id
+    productName = this.selectedLinkedProduct.name
+    price = this.selectedLinkedProduct.price
+    coverImage = this.selectedLinkedProduct.image
+  }
+
+  var productData = {
+    productId: productId,
+    productName: productName,
+    coverImage: coverImage,
+    // ...
+  }
+  // 如果有关联 skuId,传递
+  if (skuId) {
+    productData.skuId = skuId
+  }
+  // ...
+}
+```
+
+- [ ] **Step 6: 添加 goLinkedProduct 方法**
+
+```js
+goLinkedProduct: function(productId) {
+  uni.navigateTo({
+    url: '/pages/discover-detail/product-detail/product-detail?id=' + productId
+  })
+}
+```
+
+- [ ] **Step 7: 验证小程序约束**
+
+确保:
+- 无 `?.` 可选链
+- 无 CSS Grid(用 flexbox)
+- 无 `:key` 表达式(用方法调用)
+- 无 `new Date(string)`(用 `parseDate()`)
+
+- [ ] **Step 8: 提交**
+
+```bash
+git add cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
+git commit -m "feat(frontend): 详情页 linked SKU 简介卡片展示 + 购买转发到关联商品"
+```
+
+---
+
+### Task 8: 端到端验证
+
+**Files:**
+- 所有以上修改的文件
+
+**验证项:**
+
+- [ ] **后端编译**
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+Expected: BUILD SUCCESS
+
+- [ ] **迁移幂等验证**
+检查 DatabaseInitializer.java 中迁移247 使用 `ensureColumn` 方法,自动处理重复列异常。
+
+- [ ] **schema.sql 同步检查**
+确认 `product_skus` 的 CREATE TABLE 中包含 `linked_product_id` 和 `label` 列。
+
+- [ ] **管理端路由检查**
+检查新路由 `/api/admin/product/linked/list` 是否与其他 Controller 重复:
+```bash
+grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep linked
+```
+Expected: 只有一条匹配
+
+- [ ] **Bean 名冲突检查**
+确认 `AdminProductPickerController` 类名不与其他 Controller 重名。
+
+- [ ] **提交**
+```bash
+git add -A
+git commit -m "chore: 端到端验证 — SKU 关联商品选择器全功能"
+```