# 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`(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 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 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 linkedSummary = productSkuService.getLinkedProductSummary(sku.getLinkedProductId()); if (linkedSummary == null) continue; // 库存为0或已下架,不显示 } ``` 在 option 构建时增加: ```java if (isLinked) { option.put("linkedProductId", sku.getLinkedProductId()); option.put("isLinked", true); Map 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 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 list = productMapper.selectForPicker(excludeProductId, categoryId, keyword, offset, pageSize); Long total = productMapper.countForPicker(excludeProductId, categoryId, keyword); Map 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 ``` - [ ] **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> linkedList(@RequestBody Map 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 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 ``` - [ ] **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 规格变体 关联商品 ``` d) 关联商品模式表单(isLinkedMode=true 时显示): ```html ``` 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 {{ selectedLinkedProduct.name }} ¥{{ formatPrice(selectedLinkedProduct.price) }} 库存 {{ selectedLinkedProduct.stock }} {{ selectedLinkedProduct.brief }} › ``` - [ ] **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 关联商品选择器全功能" ```