|
|
@@ -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)"> </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 关联商品选择器全功能"
|
|
|
+```
|