Просмотр исходного кода

docs: SKU商品组合设计文档与实施计划

Xiaogang Liao 1 неделя назад
Родитель
Сommit
dee4c0f0ed

+ 1312 - 0
docs/superpowers/plans/2026-10-31-sku-combo.md

@@ -0,0 +1,1312 @@
+# 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 的「关联商品」升级为「商品组合」——一个 SKU 关联多个商品(各自数量、有 SKU 的商品必须选具体 SKU),手填组合售价,整体售卖,支付时按组成商品扣库存、退款时对称回补。
+
+**Architecture:** 新增子表 `product_sku_combo_items` 存组合明细;`ProductSku.comboItems` 为非表字段,由 Service 统一回填;判定优先级固定为 comboItems → linkedProductId → legacy specs。`product_orders` 增 `sku_id`,支付/退款据此识别组合订单,扣减/回补严格对称(child_sku_id 优先,否则 child_product_id)。
+
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + MySQL 8.0.34(JUnit 5 + Mockito);Vue 2 + Element UI + Jest。
+
+**Spec:** `docs/superpowers/specs/2026-10-31-sku-combo-design.md`
+
+## Global Constraints
+
+- 所有代码改动在远程 `/app/cfc`(SSH `root@111.228.6.214`)执行;本地 `/root/cfc` 只是壳。
+- 分支 `cfclub`;每任务完成即提交(`git fetch origin && git pull --rebase origin cfclub` 后提交)。
+- 迁移编号固定 **432**,并同步 `cfc-backend/src/main/resources/schema.sql`。
+- 不改动 `product_bundle_items`(套餐)逻辑。
+- 不改动现有非组合、非 linked、非 bundle 订单的任何路径。
+- 组合订单:**不扣父商品库存**;取消/退款/后台关闭 3 处必须回补组成商品。
+- 组成商品有启用 SKU 时,`child_sku_id` 必填;否则拒绝保存。
+- `ProductSku.update` 清除 `linked_product_id` 必须用 `LambdaUpdateWrapper.set(..., null)`(MyBatis-Plus `updateById` 忽略 null)。
+- 后端编译:`cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`。
+- 后端单测:`mvn -q test -Dtest=<Class>`。
+- 前端测试:`cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage`。
+- 部署严格按 `AGENTS.md`:备份 → 部署 → 健康检查;禁止未确认改动 `/app/pro/cfc`。
+
+## Review Focus
+
+1. **不双扣**:组合订单支付成功时跳过父商品扣减,只扣组合明细。
+2. **对称回补**:`cancel` / `refund` / `adminClose` 3 处都走组合回补,且不补父商品。
+3. **旧路径零回归**:`linked_product_id` SKU 与 legacy SKU 的加购、下单、详情、退款行为不变。
+4. **有 SKU 必选 SKU**:组成商品存在启用 SKU 时 `childSkuId` 必须非空。
+5. **无可用 SKU 的商品不可选**:某商品的启用 SKU 全部是组合 SKU 时,不得被当作「无 SKU 商品」按 `product.stock` 扣减。
+6. **父商品库存不拦截组合下单**:`createMultiItem` 的父商品 `stock` 校验必须对组合行跳过。
+
+---
+
+### Task 1: 迁移 432 + schema.sql
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(`runMigrations()` 末尾 + 新增 `migrateSkuCombo()`)
+- Modify: `cfc-backend/src/main/resources/schema.sql`(`product_orders` 加 `sku_id`;`product_bundle_items` 后新增组合表)
+
+**Interfaces:**
+- Produces: 表 `product_sku_combo_items(id, sku_id, child_product_id, child_sku_id, quantity, created_at)`;列 `product_orders.sku_id`。
+
+- [ ] **Step 1: 在 `runMigrations()` 末尾(`migrateFamilyNameBackfill();` 之后)追加调用**
+
+```java
+        // 迁移432: SKU商品组合明细表 + 订单记录下单SKU(组合SKU识别)
+        migrateSkuCombo();
+```
+
+- [ ] **Step 2: 在 `createCouponGiftTransferTable()` 之后新增迁移方法(幂等;参照迁移 428 写法)**
+
+```java
+    // ==================== 迁移432:SKU 商品组合 ====================
+
+    void migrateSkuCombo() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `product_sku_combo_items` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`sku_id` BIGINT NOT NULL COMMENT '组合SKU ID(product_skus.id)', "
+                    + "`child_product_id` BIGINT NOT NULL COMMENT '组成商品ID', "
+                    + "`child_sku_id` BIGINT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)', "
+                    + "`quantity` INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "INDEX `idx_sku` (`sku_id`), "
+                    + "INDEX `idx_child_product` (`child_product_id`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细'");
+            log.info("迁移432: 已创建 product_sku_combo_items 表");
+        } catch (Exception e) {
+            log.warn("迁移432: 创建 product_sku_combo_items 表失败(可能已存在): {}", e.getMessage());
+        }
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'product_orders' AND COLUMN_NAME = 'sku_id'",
+                    Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN sku_id BIGINT NULL COMMENT '下单SKU ID(组合SKU识别用)' AFTER product_type");
+                log.info("迁移432: 已为 product_orders 增加 sku_id 列");
+            } else {
+                log.info("迁移432: product_orders.sku_id 列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("迁移432: 检查/添加 product_orders.sku_id 列失败: {}", e.getMessage());
+        }
+    }
+```
+
+- [ ] **Step 3: `schema.sql` 中 `product_orders` 的 `product_type` 后加列**
+
+```sql
+    sku_id BIGINT COMMENT '下单SKU ID(组合SKU识别用)',
+```
+
+- [ ] **Step 4: `schema.sql` 中 `product_bundle_items` 之后新增表**
+
+```sql
+-- 商品SKU组合明细表(组合SKU = 多个商品/子SKU + 数量,整体手填售价售卖)
+CREATE TABLE IF NOT EXISTS product_sku_combo_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    sku_id BIGINT NOT NULL COMMENT '组合SKU ID(product_skus.id)',
+    child_product_id BIGINT NOT NULL COMMENT '组成商品ID',
+    child_sku_id BIGINT DEFAULT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)',
+    quantity INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_sku (sku_id),
+    INDEX idx_child_product (child_product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细';
+```
+
+- [ ] **Step 5: 编译验证**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
+Expected: BUILD SUCCESS(无输出错误)
+
+- [ ] **Step 6: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(backend): 迁移432 SKU商品组合明细表与订单sku_id"
+```
+
+---
+
+### Task 2: 实体 / Mapper / DTO
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductSkuComboItemMapper.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java`
+
+**Interfaces:**
+- Consumes: 表结构(Task 1)。
+- Produces: `ProductSkuComboItem`(字段 `id, skuId, childProductId, childSkuId, quantity, createdAt` + 非表字段 `childProductName, childProductImage, childProductPrice, childSkuSpecs`);`ProductSku.getComboItems()/setComboItems(List)`;`ProductOrder.getSkuId()/setSkuId(Long)`;`ProductSkuDTO.getComboItems()` 与 `getLinkedProductId()`。
+
+- [ ] **Step 1: 创建实体 `ProductSkuComboItem.java`**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("product_sku_combo_items")
+public class ProductSkuComboItem implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long skuId;
+    private Long childProductId;
+    private Long childSkuId;
+    private Integer quantity;
+    private Date createdAt;
+
+    @TableField(exist = false)
+    private String childProductName;
+    @TableField(exist = false)
+    private String childProductImage;
+    @TableField(exist = false)
+    private Integer childProductPrice;
+    @TableField(exist = false)
+    private String childSkuSpecs;
+}
+```
+
+- [ ] **Step 2: 创建 Mapper**
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductSkuComboItem;
+
+public interface ProductSkuComboItemMapper extends BaseMapper<ProductSkuComboItem> {
+}
+```
+
+- [ ] **Step 3: `ProductSku.java` 末尾(`updateTime` 后)增加**
+
+```java
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private java.util.List<ProductSkuComboItem> comboItems;
+
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private String linkedProductName;
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer linkedProductPrice;
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private String linkedProductImage;
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer linkedProductStock;
+```
+
+- [ ] **Step 4: `ProductOrder.java` 的 `productType` 后增加**
+
+```java
+    private Long skuId;
+```
+
+- [ ] **Step 5: `ProductSkuDTO.java` 增加字段,并在 `from()` 的 `return dto;` 前拷贝**
+
+```java
+    private Long linkedProductId;
+    private String linkedProductName;
+    private Integer linkedProductPrice;
+    private String linkedProductImage;
+    private Integer linkedProductStock;
+    private java.util.List<com.etotem.cfc.entity.ProductSkuComboItem> comboItems;
+```
+
+```java
+        dto.setLinkedProductId(sku.getLinkedProductId());
+        dto.setLinkedProductName(sku.getLinkedProductName());
+        dto.setLinkedProductPrice(sku.getLinkedProductPrice());
+        dto.setLinkedProductImage(sku.getLinkedProductImage());
+        dto.setLinkedProductStock(sku.getLinkedProductStock());
+        dto.setComboItems(sku.getComboItems());
+```
+
+- [ ] **Step 6: 编译 + 提交**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
+Expected: BUILD SUCCESS
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductSkuComboItemMapper.java cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java
+git commit -m "feat(backend): 组合SKU实体/DTO与Mapper"
+```
+
+---
+
+### Task 3: ProductSkuService 组合读写 + 单测(TDD)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java`
+- Create: `cfc-backend/src/test/java/com/etotem/cfc/unit/ProductSkuComboServiceTest.java`
+
+**Interfaces:**
+- Consumes: `ProductSkuComboItemMapper`、`ProductSku.getComboItems()`、`ProductMapper.selectBatchIds`、`ProductSkuMapper.selectBatchIds`。
+- Produces:
+  - `boolean hasComboItems(ProductSku sku)`
+  - `List<ProductSkuComboItem> listComboItems(Long skuId)`
+  - `void fillComboItems(List<ProductSku> skus)`
+  - `String buildComboSummary(List<ProductSkuComboItem> items)`(格式 `商品A×2;商品B×1`)
+  - `create/update` 支持 `comboItems`(null=不改,空数组=清除,非空=整表替换)
+
+- [ ] **Step 1: 先写失败测试 `ProductSkuComboServiceTest`**
+
+```java
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.ProductSkuComboItem;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductSkuComboItemMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+import com.etotem.cfc.service.ProductSkuService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+class ProductSkuComboServiceTest {
+
+    private ProductSkuService service;
+    private ProductSkuMapper skuMapper;
+    private ProductMapper productMapper;
+    private ProductSkuComboItemMapper comboItemMapper;
+
+    @BeforeEach
+    void setUp() {
+        service = new ProductSkuService();
+        skuMapper = mock(ProductSkuMapper.class);
+        productMapper = mock(ProductMapper.class);
+        comboItemMapper = mock(ProductSkuComboItemMapper.class);
+        ReflectionTestUtils.setField(service, "skuMapper", skuMapper);
+        ReflectionTestUtils.setField(service, "productMapper", productMapper);
+        ReflectionTestUtils.setField(service, "comboItemMapper", comboItemMapper);
+    }
+
+    private ProductSkuComboItem item(Long productId, Long skuId, int qty) {
+        ProductSkuComboItem it = new ProductSkuComboItem();
+        it.setChildProductId(productId);
+        it.setChildSkuId(skuId);
+        it.setQuantity(qty);
+        return it;
+    }
+
+    @Test
+    void create_withComboItems_persistsRowsWithSkuId() {
+        ProductSku sku = new ProductSku();
+        sku.setProductId(1L);
+        sku.setComboItems(Arrays.asList(item(10L, 100L, 2), item(11L, null, 1)));
+        when(skuMapper.findByProductId(10L)).thenReturn(Collections.emptyList());
+        when(skuMapper.findByProductId(11L)).thenReturn(Collections.emptyList());
+        doAnswer(inv -> { sku.setId(55L); return 1; }).when(skuMapper).insert(any(ProductSku.class));
+
+        service.create(sku);
+
+        verify(comboItemMapper, times(2)).insert(any(ProductSkuComboItem.class));
+        assertEquals(55L, sku.getComboItems().get(0).getSkuId());
+    }
+
+    @Test
+    void update_emptyComboItems_deletesRows() {
+        ProductSku sku = new ProductSku();
+        sku.setId(55L);
+        sku.setComboItems(Collections.emptyList());
+
+        service.update(sku);
+
+        verify(comboItemMapper).delete(any());
+        verify(comboItemMapper, never()).insert(any());
+    }
+
+    @Test
+    void update_comboItems_clearsLinkedProductId() {
+        ProductSku sku = new ProductSku();
+        sku.setId(55L);
+        sku.setComboItems(Collections.singletonList(item(10L, 100L, 1)));
+        when(skuMapper.findByProductId(10L)).thenReturn(Collections.emptyList());
+
+        service.update(sku);
+
+        verify(skuMapper).update(eq(null), any());
+    }
+
+    @Test
+    void create_childProductHasEnabledSku_withoutChildSkuId_throws() {
+        ProductSku childSku = new ProductSku();
+        childSku.setId(100L);
+        childSku.setEnabled(1);
+        when(skuMapper.findByProductId(10L)).thenReturn(Collections.singletonList(childSku));
+
+        ProductSku sku = new ProductSku();
+        sku.setProductId(1L);
+        sku.setComboItems(Collections.singletonList(item(10L, null, 1)));
+
+        assertThrows(IllegalArgumentException.class, () -> service.create(sku));
+        verify(skuMapper, never()).insert(any(ProductSku.class));
+    }
+
+    @Test
+    void buildComboSummary_formatsNameAndQuantity() {
+        ProductSkuComboItem a = item(10L, null, 2);
+        a.setChildProductName("商品A");
+        ProductSkuComboItem b = item(11L, null, 1);
+        b.setChildProductName("商品B");
+
+        assertEquals("商品A×2;商品B×1", service.buildComboSummary(Arrays.asList(a, b)));
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductSkuComboServiceTest`
+Expected: 编译失败 / 方法不存在(`hasComboItems` 等未定义)
+
+- [ ] **Step 3: 实现 Service**
+
+注入:
+
+```java
+    @Resource
+    private ProductSkuComboItemMapper comboItemMapper;
+```
+
+`create`:在 `skuMapper.insert(sku);` 前调用 `validateComboItems(sku.getComboItems());`,insert 之后调用 `saveComboItems(sku.getId(), sku.getComboItems());`。
+
+`update`:入口 `validateComboItems(sku.getComboItems());`;随后:
+
+```java
+        if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+            sku.setLinkedProductId(null);
+        }
+        sku.setUpdateTime(new Date());
+        skuMapper.updateById(sku);
+        saveComboItems(sku.getId(), sku.getComboItems());
+        if (sku.getLinkedProductId() == null) {
+            skuMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ProductSku>()
+                    .eq(ProductSku::getId, sku.getId())
+                    .set(ProductSku::getLinkedProductId, null));
+        }
+```
+
+`delete`:先
+
+```java
+        comboItemMapper.delete(new LambdaQueryWrapper<ProductSkuComboItem>()
+                .eq(ProductSkuComboItem::getSkuId, id));
+```
+
+`getById`:查询后 `fillComboItems(java.util.Collections.singletonList(sku));`;`listByProductId`/`listByProductIdAll`:查询后 `fillComboItems(list);`。
+
+私有/公开方法按 Task 3 Interfaces 的签名实现:
+
+```java
+    private void saveComboItems(Long skuId, List<ProductSkuComboItem> items) {
+        if (items == null) return;
+        comboItemMapper.delete(new LambdaQueryWrapper<ProductSkuComboItem>()
+                .eq(ProductSkuComboItem::getSkuId, skuId));
+        if (items.isEmpty()) return;
+        for (ProductSkuComboItem item : items) {
+            item.setId(null);
+            item.setSkuId(skuId);
+            if (item.getQuantity() == null || item.getQuantity() < 1) item.setQuantity(1);
+            comboItemMapper.insert(item);
+        }
+    }
+
+    private void validateComboItems(List<ProductSkuComboItem> items) {
+        if (items == null || items.isEmpty()) return;
+        for (ProductSkuComboItem item : items) {
+            if (item.getChildProductId() == null) throw new IllegalArgumentException("组合商品不能为空");
+            List<ProductSku> childSkus = skuMapper.findByProductId(item.getChildProductId());
+            boolean hasEnabledSku = childSkus != null
+                    && childSkus.stream().anyMatch(s -> s.getEnabled() != null && s.getEnabled() == 1);
+            if (hasEnabledSku && item.getChildSkuId() == null) {
+                throw new IllegalArgumentException("组合商品存在 SKU,必须选择具体 SKU");
+            }
+        }
+    }
+```
+
+`fillComboItems`(批量回填子商品名/图/价、子 SKU 规格):
+
+```java
+    public void fillComboItems(List<ProductSku> skus) {
+        if (skus == null || skus.isEmpty()) return;
+        List<Long> skuIds = skus.stream().map(ProductSku::getId)
+                .filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toList());
+        if (skuIds.isEmpty()) return;
+        List<ProductSkuComboItem> all = comboItemMapper.selectList(
+                new LambdaQueryWrapper<ProductSkuComboItem>().in(ProductSkuComboItem::getSkuId, skuIds));
+        if (all.isEmpty()) {
+            skus.forEach(s -> s.setComboItems(java.util.Collections.emptyList()));
+            return;
+        }
+        java.util.Set<Long> productIds = all.stream().map(ProductSkuComboItem::getChildProductId)
+                .filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toSet());
+        java.util.Map<Long, Product> productMap = productIds.isEmpty() ? java.util.Collections.emptyMap()
+                : productMapper.selectBatchIds(productIds).stream()
+                    .collect(java.util.stream.Collectors.toMap(Product::getId, p -> p, (a, b) -> a));
+        java.util.Set<Long> childSkuIds = all.stream().map(ProductSkuComboItem::getChildSkuId)
+                .filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toSet());
+        java.util.Map<Long, ProductSku> childSkuMap = childSkuIds.isEmpty() ? java.util.Collections.emptyMap()
+                : skuMapper.selectBatchIds(childSkuIds).stream()
+                    .collect(java.util.stream.Collectors.toMap(ProductSku::getId, s -> s, (a, b) -> a));
+        for (ProductSkuComboItem item : all) {
+            Product p = productMap.get(item.getChildProductId());
+            if (p != null) {
+                item.setChildProductName(p.getName());
+                item.setChildProductImage(p.getCoverImage());
+                item.setChildProductPrice(p.getPrice());
+            }
+            ProductSku cs = item.getChildSkuId() == null ? null : childSkuMap.get(item.getChildSkuId());
+            if (cs != null) item.setChildSkuSpecs(cs.getSpecs());
+        }
+        java.util.Map<Long, List<ProductSkuComboItem>> grouped = all.stream()
+                .collect(java.util.stream.Collectors.groupingBy(ProductSkuComboItem::getSkuId));
+        for (ProductSku sku : skus) {
+            sku.setComboItems(grouped.getOrDefault(sku.getId(), java.util.Collections.emptyList()));
+        }
+    }
+
+    public boolean hasComboItems(ProductSku sku) {
+        return sku != null && sku.getComboItems() != null && !sku.getComboItems().isEmpty();
+    }
+
+    public List<ProductSkuComboItem> listComboItems(Long skuId) {
+        ProductSku tmp = new ProductSku();
+        tmp.setId(skuId);
+        fillComboItems(java.util.Collections.singletonList(tmp));
+        return tmp.getComboItems() == null ? java.util.Collections.emptyList() : tmp.getComboItems();
+    }
+
+    public String buildComboSummary(List<ProductSkuComboItem> items) {
+        if (items == null || items.isEmpty()) return "";
+        StringBuilder sb = new StringBuilder();
+        for (ProductSkuComboItem item : items) {
+            if (sb.length() > 0) sb.append(";");
+            String name = item.getChildProductName() != null ? item.getChildProductName()
+                    : ("商品#" + item.getChildProductId());
+            sb.append(name).append("×").append(item.getQuantity() == null ? 1 : item.getQuantity());
+        }
+        return sb.toString();
+    }
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductSkuComboServiceTest`
+Expected: Tests run: 5, Failures: 0, Errors: 0
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java cfc-backend/src/test/java/com/etotem/cfc/unit/ProductSkuComboServiceTest.java
+git commit -m "feat(backend): 组合SKU服务层读写与校验"
+```
+
+---
+
+### Task 4: 管理端选择器接口(跨分类 + 子 SKU + hasSkus)
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java`
+
+**Interfaces:**
+- Consumes: `ProductSkuMapper.findByProductIdAll`、`ProductSkuComboItemMapper`。
+- Produces: `POST /api/admin/product/linked/list` 新增参数 `crossCategory`;`data.list[]` 在 `crossCategory=true` 时增加 `skus`(已启用且非组合 SKU)与 `hasSkus`(该商品是否存在任何启用 SKU)。
+
+- [ ] **Step 1: 注入依赖**
+
+```java
+    @Resource
+    private com.etotem.cfc.mapper.ProductSkuMapper productSkuMapper;
+    @Resource
+    private com.etotem.cfc.mapper.ProductSkuComboItemMapper comboItemMapper;
+```
+
+- [ ] **Step 2: 参数解析与查询条件**
+
+在方法开头追加:
+
+```java
+        boolean crossCategory = Boolean.parseBoolean(String.valueOf(params.getOrDefault("crossCategory", "false")));
+```
+
+把现有 wrapper 条件替换为:
+
+```java
+        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(Product::getStatus, "on_shelf");
+        if (!crossCategory) {
+            if (categoryId == null && productId != null) {
+                Product product = productMapper.selectById(productId);
+                if (product != null) categoryId = product.getCategoryId();
+            }
+            if (categoryId != null) wrapper.eq(Product::getCategoryId, categoryId);
+        } else {
+            if (productId != null) wrapper.ne(Product::getId, productId);
+            wrapper.ne(Product::getProductType, "bundle");
+        }
+        if (keyword != null && !keyword.trim().isEmpty()) wrapper.like(Product::getName, keyword.trim());
+```
+
+- [ ] **Step 3: 记录转 Map,并在跨分类时附加 `skus` / `hasSkus`**
+
+```java
+        java.util.List<java.util.Map<String, Object>> list = new java.util.ArrayList<>();
+        for (Product p : pageResult.getRecords()) {
+            java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
+            m.put("id", p.getId());
+            m.put("name", p.getName());
+            m.put("coverImage", p.getCoverImage());
+            m.put("price", p.getPrice());
+            m.put("stock", p.getStock());
+            m.put("categoryId", p.getCategoryId());
+            m.put("productType", p.getProductType());
+            if (crossCategory) {
+                java.util.List<ProductSku> allSkus = productSkuMapper.findByProductIdAll(p.getId()).stream()
+                        .filter(s -> s.getEnabled() != null && s.getEnabled() == 1)
+                        .collect(java.util.stream.Collectors.toList());
+                m.put("hasSkus", !allSkus.isEmpty());
+                java.util.Set<Long> comboSkuIds = java.util.Collections.emptySet();
+                if (!allSkus.isEmpty()) {
+                    java.util.List<Long> ids = allSkus.stream().map(ProductSku::getId)
+                            .collect(java.util.stream.Collectors.toList());
+                    comboSkuIds = comboItemMapper.selectList(
+                                    new LambdaQueryWrapper<ProductSkuComboItem>()
+                                            .in(ProductSkuComboItem::getSkuId, ids)).stream()
+                            .map(ProductSkuComboItem::getSkuId)
+                            .collect(java.util.stream.Collectors.toSet());
+                }
+                java.util.List<java.util.Map<String, Object>> skuList = new java.util.ArrayList<>();
+                for (ProductSku s : allSkus) {
+                    if (comboSkuIds.contains(s.getId())) continue;
+                    java.util.Map<String, Object> sm = new java.util.LinkedHashMap<>();
+                    sm.put("id", s.getId());
+                    sm.put("specs", s.getSpecs());
+                    sm.put("price", s.getPrice());
+                    sm.put("stock", s.getStock());
+                    skuList.add(sm);
+                }
+                m.put("skus", skuList);
+            }
+            list.add(m);
+        }
+        result.put("list", list);
+```
+
+补 import:`com.etotem.cfc.entity.ProductSku`、`com.etotem.cfc.entity.ProductSkuComboItem`。
+
+- [ ] **Step 4: 编译 + 提交**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
+Expected: BUILD SUCCESS
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java
+git commit -m "feat(backend): 商品选择器支持跨分类与子SKU列表"
+```
+
+---
+
+### Task 5: 消费点(购物车 / 下单 / 支付扣减 / 退款回补 / 商品详情)+ 单测
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java`(约 350 行「Linked SKU」分支)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java`(`createMultiItem` 约 740-870;`doHandlePaymentSuccess` 约 1030;回补 3 处约 1266/1714/1932)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java`(约 120「Linked SKU」分支前)
+- Create: `cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderComboStockTest.java`(与 `ProductOrderService` 同包,才能访问包级方法)
+
+**Interfaces:**
+- Consumes: `ProductSkuService.hasComboItems/getById/buildComboSummary`、`ProductSkuComboItem`、`ProductOrder.getSkuId()`。
+- Produces: `ProductOrderService.deductComboStock(ProductSku, ProductOrder, String)`、`ProductOrderService.restockComboOrder(ProductOrder) -> boolean`(包级,供单测直接调用)。
+
+- [ ] **Step 1: 先写失败测试 `ProductOrderComboStockTest`**
+
+```java
+package com.etotem.cfc.service;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.ProductSkuComboItem;
+import com.etotem.cfc.service.InventoryService;
+import com.etotem.cfc.service.ProductOrderService;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProductSkuService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class ProductOrderComboStockTest {
+
+    private ProductOrderService orderService;
+    private ProductSkuService skuService;
+    private ProductService productService;
+    private InventoryService inventoryService;
+
+    @BeforeEach
+    void setUp() {
+        orderService = new ProductOrderService();
+        skuService = mock(ProductSkuService.class);
+        productService = mock(ProductService.class);
+        inventoryService = mock(InventoryService.class);
+        ReflectionTestUtils.setField(orderService, "productSkuService", skuService);
+        ReflectionTestUtils.setField(orderService, "productService", productService);
+        ReflectionTestUtils.setField(orderService, "inventoryService", inventoryService);
+    }
+
+    private ProductSku combo() {
+        ProductSkuComboItem a = new ProductSkuComboItem();
+        a.setChildProductId(10L); a.setChildSkuId(100L); a.setQuantity(2);
+        ProductSkuComboItem b = new ProductSkuComboItem();
+        b.setChildProductId(11L); b.setChildSkuId(null); b.setQuantity(1);
+        ProductSku sku = new ProductSku();
+        sku.setId(9L);
+        sku.setComboItems(Arrays.asList(a, b));
+        return sku;
+    }
+
+    private ProductOrder order() {
+        ProductOrder o = new ProductOrder();
+        o.setId(77L);
+        o.setOrderNo("PO1");
+        o.setSkuId(9L);
+        o.setQuantity(3);
+        return o;
+    }
+
+    @Test
+    void deductComboStock_childSku_decreasesSkuAndRecordsOutbound() {
+        when(skuService.decreaseStock(100L, 6)).thenReturn(true);
+        when(productService.decreaseStock(11L, 3)).thenReturn(true);
+
+        orderService.deductComboStock(combo(), order(), "PO1");
+
+        verify(skuService).decreaseStock(100L, 6);
+        verify(inventoryService).recordOutbound(10L, 100L, 6, "order", 77L, null, "组合出库:PO1");
+        verify(productService).decreaseStock(11L, 3);
+        verify(inventoryService).recordOutbound(11L, null, 3, "order", 77L, null, "组合出库:PO1");
+    }
+
+    @Test
+    void deductComboStock_insufficient_throws() {
+        when(skuService.decreaseStock(100L, 6)).thenReturn(false);
+
+        assertThrows(RuntimeException.class, () -> orderService.deductComboStock(combo(), order(), "PO1"));
+    }
+
+    @Test
+    void restockComboOrder_nonCombo_returnsFalse() {
+        when(skuService.getById(9L)).thenReturn(new ProductSku());
+        when(skuService.hasComboItems(any())).thenReturn(false);
+
+        assertFalse(orderService.restockComboOrder(order()));
+        verify(productService, never()).increaseStock(anyLong(), anyInt());
+    }
+
+    @Test
+    void restockComboOrder_combo_restocksChildrenOnly() {
+        ProductSku sku = combo();
+        when(skuService.getById(9L)).thenReturn(sku);
+        when(skuService.hasComboItems(sku)).thenReturn(true);
+
+        assertTrue(orderService.restockComboOrder(order()));
+
+        verify(skuService).increaseStock(100L, 6);
+        verify(inventoryService).recordRefundInbound(10L, 100L, 6, "order", 77L, null, "组合退款入库:PO1");
+        verify(productService).increaseStock(11L, 3);
+        // 父商品(order.productId)不得回补:本用例未设置 productId,仍断言未调用
+        verify(productService, never()).increaseStock(null, 3);
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductOrderComboStockTest`
+Expected: 编译失败(`deductComboStock` / `restockComboOrder` 不存在)
+
+- [ ] **Step 3: CartService 组合分支**
+
+在 `if (sku.getLinkedProductId() != null)` 之前插入:
+
+```java
+                if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+                    if (sku.getPrice() != null) dto.setUnitPrice(sku.getPrice());
+                    dto.setSpecDesc(productSkuService.buildComboSummary(sku.getComboItems()));
+                } else if (sku.getLinkedProductId() != null) {
+```
+
+- [ ] **Step 4: `createMultiItem` 组合支持**
+
+循环开头:
+
+```java
+            ProductSku itemSku = item.getSkuId() != null ? productSkuService.getById(item.getSkuId()) : null;
+            boolean comboItem = productSkuService.hasComboItems(itemSku);
+```
+
+父商品库存校验加 `!comboItem`:
+
+```java
+            if (!comboItem && "physical".equals(product.getProductType())
+                    && product.getStock() != null
+                    && product.getStock() < item.getQuantity()) {
+                return Result.error("库存不足: " + product.getName());
+            }
+```
+
+bundle 校验后追加组合库存校验:
+
+```java
+            if (comboItem) {
+                for (ProductSkuComboItem ci : itemSku.getComboItems()) {
+                    int need = (ci.getQuantity() == null ? 1 : ci.getQuantity()) * item.getQuantity();
+                    if (ci.getChildSkuId() != null) {
+                        if (!productSkuService.hasStock(ci.getChildSkuId(), need)) {
+                            return Result.error("组合商品库存不足: " + ci.getChildProductName());
+                        }
+                    } else {
+                        Product cp = productMapper.selectById(ci.getChildProductId());
+                        if (cp == null || cp.getStock() == null || cp.getStock() < need) {
+                            return Result.error("组合商品库存不足: "
+                                    + (cp == null ? ci.getChildProductId() : cp.getName()));
+                        }
+                    }
+                }
+            }
+```
+
+SKU 价格分支改为:
+
+```java
+            if (itemSku != null) {
+                if (comboItem) {
+                    if (itemSku.getPrice() != null) unitPrice = itemSku.getPrice();
+                    if (itemSku.getMinQuantity() != null && item.getQuantity() < itemSku.getMinQuantity()) {
+                        return Result.error(product.getName() + " 该组合最低需购买" + itemSku.getMinQuantity() + "件");
+                    }
+                } else if (itemSku.getLinkedProductId() != null) {
+                    // ... 原有 linked 逻辑保持不变 ...
+                } else {
+                    // ... 原有 legacy 逻辑保持不变 ...
+                }
+            }
+```
+
+明细行:
+
+```java
+            itemRecord.setSkuSpecs(comboItem
+                    ? productSkuService.buildComboSummary(itemSku.getComboItems())
+                    : item.getSpecOptionIds());
+```
+
+订单头(`order.setProductType(...)` 后):
+
+```java
+        order.setSkuId(firstItem.getSkuId());
+```
+
+- [ ] **Step 5: 支付成功扣减**
+
+```java
+        Product paidProduct = productMapper.selectById(order.getProductId());
+        ProductSku paidSku = order.getSkuId() != null ? productSkuService.getById(order.getSkuId()) : null;
+        if (productSkuService.hasComboItems(paidSku)) {
+            deductComboStock(paidSku, order, orderNo);
+        } else if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
+            // ... 原有 physical 逻辑保持不变 ...
+        } else if (paidProduct != null && "bundle".equals(paidProduct.getProductType())) {
+            // ... 原有 bundle 逻辑保持不变 ...
+        }
+```
+
+新增方法:
+
+```java
+    void deductComboStock(ProductSku combo, ProductOrder order, String orderNo) {
+        for (ProductSkuComboItem ci : combo.getComboItems()) {
+            int unit = ci.getQuantity() == null ? 1 : ci.getQuantity();
+            int need = unit * order.getQuantity();
+            if (ci.getChildSkuId() != null) {
+                if (!productSkuService.decreaseStock(ci.getChildSkuId(), need)) {
+                    throw new RuntimeException("组合商品库存不足: skuId=" + ci.getChildSkuId());
+                }
+                inventoryService.recordOutbound(ci.getChildProductId(), ci.getChildSkuId(), need,
+                        "order", order.getId(), null, "组合出库:" + orderNo);
+            } else {
+                if (!productService.decreaseStock(ci.getChildProductId(), need)) {
+                    throw new RuntimeException("组合商品库存不足: productId=" + ci.getChildProductId());
+                }
+                inventoryService.recordOutbound(ci.getChildProductId(), null, need,
+                        "order", order.getId(), null, "组合出库:" + orderNo);
+            }
+        }
+    }
+```
+
+- [ ] **Step 6: 3 处回补改为组合优先**
+
+```java
+    boolean restockComboOrder(ProductOrder order) {
+        ProductSku sku = order.getSkuId() != null ? productSkuService.getById(order.getSkuId()) : null;
+        if (!productSkuService.hasComboItems(sku)) return false;
+        for (ProductSkuComboItem ci : sku.getComboItems()) {
+            int unit = ci.getQuantity() == null ? 1 : ci.getQuantity();
+            int need = unit * order.getQuantity();
+            if (ci.getChildSkuId() != null) {
+                productSkuService.increaseStock(ci.getChildSkuId(), need);
+                inventoryService.recordRefundInbound(ci.getChildProductId(), ci.getChildSkuId(), need,
+                        "order", order.getId(), null, "组合退款入库:" + order.getOrderNo());
+            } else {
+                productService.increaseStock(ci.getChildProductId(), need);
+                inventoryService.recordRefundInbound(ci.getChildProductId(), null, need,
+                        "order", order.getId(), null, "组合退款入库:" + order.getOrderNo());
+            }
+        }
+        return true;
+    }
+```
+
+三处原逻辑包一层:
+
+```java
+        if (!restockComboOrder(order)) {
+            productService.increaseStock(order.getProductId(), order.getQuantity());
+            inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
+                    "order", order.getId(), null, "<原 remark>");
+        }
+```
+
+(`<原 remark>` 分别沿用 `"取消订单:" + orderNo`、退款处、关闭处原文案。)
+
+- [ ] **Step 7: ProductController 商品详情增加组合选项**
+
+在 `boolean isLinked = sku.getLinkedProductId() != null;` 之前插入组合分支(`skus` 已由 Service 回填):
+
+```java
+                if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+                    String optionName = (sku.getLabel() != null && !sku.getLabel().trim().isEmpty())
+                            ? sku.getLabel() : "商品组合";
+                    String groupName = "商品组合";
+                    if (!nameToGroupId.containsKey(groupName)) {
+                        nameToGroupId.put(groupName, groupId++);
+                        Map<String, Object> grp = new HashMap<>();
+                        grp.put("groupId", nameToGroupId.get(groupName));
+                        grp.put("groupName", groupName);
+                        grp.put("options", new ArrayList<Map<String, Object>>());
+                        groups.add(grp);
+                    }
+                    List<Map<String, Object>> comboList = new ArrayList<>();
+                    for (ProductSkuComboItem ci : sku.getComboItems()) {
+                        Map<String, Object> cm = new HashMap<>();
+                        cm.put("productId", ci.getChildProductId());
+                        cm.put("skuId", ci.getChildSkuId());
+                        cm.put("name", ci.getChildProductName());
+                        cm.put("image", ci.getChildProductImage());
+                        cm.put("price", ci.getChildProductPrice());
+                        cm.put("quantity", ci.getQuantity());
+                        comboList.add(cm);
+                    }
+                    Map<String, Object> option = new HashMap<>();
+                    option.put("id", sku.getId());
+                    option.put("groupId", nameToGroupId.get(groupName));
+                    option.put("name", optionName);
+                    option.put("skuId", sku.getId());
+                    option.put("isCombo", true);
+                    option.put("price", sku.getPrice());
+                    option.put("comboItems", comboList);
+                    for (Map<String, Object> g : groups) {
+                        if (g.get("groupId").equals(nameToGroupId.get(groupName))) {
+                            @SuppressWarnings("unchecked")
+                            List<Map<String, Object>> opts = (List<Map<String, Object>>) g.get("options");
+                            opts.add(option);
+                        }
+                    }
+                    continue;
+                }
+```
+
+- [ ] **Step 8: 运行测试确认通过**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductOrderComboStockTest`
+Expected: Tests run: 4, Failures: 0, Errors: 0
+
+- [ ] **Step 9: 提交**
+
+```bash
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java cfc-backend/src/test/java/com/etotem/cfc/unit/ProductOrderComboStockTest.java
+git commit -m "feat(backend): 组合SKU购物车/下单/扣库/回补与详情展示"
+```
+
+---
+
+### Task 6: 前端 ProductPicker 多选 + 数量 + SKU + 跨分类(TDD 工具函数)
+
+**Files:**
+- Create: `cfc-web/src/utils/skuCombo.js`
+- Create: `cfc-web/src/__tests__/utils/skuCombo.test.js`
+- Rewrite: `cfc-web/src/views/admin/components/ProductPicker.vue`
+
+**Interfaces:**
+- Consumes: `linkedProductList(params)`(`@/api/admin`),参数新增 `crossCategory`;响应 `list[].skus` / `hasSkus`(Task 4)。
+- Produces:
+  - `formatComboSummary(items, max=3) -> string`
+  - `normalizeComboPayload(items) -> [{childProductId, childSkuId, quantity}]`
+  - 组件 props `{ productId, crossCategory, multiple, selected }`;events `selected`(单选对象)、`confirm`(多选数组)。
+
+- [ ] **Step 1: 先写工具函数测试**
+
+```js
+import { formatComboSummary, normalizeComboPayload } from '@/utils/skuCombo'
+
+describe('skuCombo utils', () => {
+  test('formatComboSummary empty', () => {
+    expect(formatComboSummary([])).toBe('')
+    expect(formatComboSummary(null)).toBe('')
+  })
+
+  test('formatComboSummary names and quantities', () => {
+    expect(formatComboSummary([
+      { childProductName: '商品A', quantity: 2 },
+      { childProductName: '商品B', quantity: 1 }
+    ])).toBe('商品A×2;商品B×1')
+  })
+
+  test('formatComboSummary over max', () => {
+    const items = [1, 2, 3, 4].map(i => ({ childProductName: '商品' + i, quantity: 1 }))
+    expect(formatComboSummary(items, 3)).toBe('商品1×1;商品2×1;商品3×1 等4项')
+  })
+
+  test('normalizeComboPayload defaults', () => {
+    expect(normalizeComboPayload([{ id: 7, childSkuId: 8 }]))
+      .toEqual([{ childProductId: 7, childSkuId: 8, quantity: 1 }])
+  })
+})
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__/utils/skuCombo.test.js --no-coverage`
+Expected: FAIL(Cannot find module '@/utils/skuCombo')
+
+- [ ] **Step 3: 实现 `src/utils/skuCombo.js`**
+
+```js
+export function formatComboSummary(items, max = 3) {
+  if (!items || !items.length) return ''
+  const shown = items.slice(0, max).map(it => {
+    const name = it.childProductName || it.name || ('商品#' + (it.childProductId || it.productId))
+    const qty = it.quantity == null ? 1 : it.quantity
+    return name + '×' + qty
+  })
+  return shown.join(';') + (items.length > max ? ' 等' + items.length + '项' : '')
+}
+
+export function normalizeComboPayload(items) {
+  return (items || []).map(it => ({
+    childProductId: it.childProductId || it.id,
+    childSkuId: it.childSkuId || null,
+    quantity: it.quantity == null ? 1 : it.quantity
+  }))
+}
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__/utils/skuCombo.test.js --no-coverage`
+Expected: PASS(4 个用例)
+
+- [ ] **Step 5: 重写 `ProductPicker.vue`**
+
+要求:
+- 模板结构修正:`el-pagination` 与 footer 放在 `el-table` 之外。
+- 单选模式(`multiple=false`)保留 radio + `@selected` 旧行为。
+- 多选模式(`multiple=true`):`type="selection"`,每行 SKU 下拉与数量;`row.hasSkus && !row.skus.length` 时禁用该行(`:selectable` 返回 false)并显示「无可用SKU」。
+- `open()` 用 `selected` 预填 `picked`,`load()` 后对已选行 `toggleRowSelection(row, true)`。
+- `confirm()` 校验:多选时每个已选且有 `skus` 的行必须有 `childSkuId`;输出 `[{id, childProductId, childSkuId, name, price, coverImage, stock, quantity}]`,`$emit('confirm', result)`。
+- 组件根加 `ref="table"` 到 `el-table`。
+
+- [ ] **Step 6: 运行前端相关测试 + 构建**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage`
+Expected: 全部 PASS
+
+Run: `npm run build`
+Expected: 构建成功
+
+- [ ] **Step 7: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/utils/skuCombo.js cfc-web/src/__tests__/utils/skuCombo.test.js cfc-web/src/views/admin/components/ProductPicker.vue
+git commit -m "feat(web): 商品选择器支持多选/数量/SKU/跨分类"
+```
+
+---
+
+### Task 7: 前端 ProductSkuEditor 组合模式
+
+**Files:**
+- Modify: `cfc-web/src/views/admin/components/ProductSkuEditor.vue`
+
+**Interfaces:**
+- Consumes: `formatComboSummary`、`normalizeComboPayload`(Task 6);`ProductPicker` 的 `multiple/crossCategory/selected` 与 `@confirm`;未选 SKU 商品的 `hasSkus`。
+- Produces: SKU create/update payload 增加 `comboItems`(`[]` 表示非组合);组合模式 payload 为 `{productId, comboItems, price, stock, label, enabled, linkedProductId: null}`。
+
+- [ ] **Step 1: 三模式改造**
+
+`data`:`isLinkedMode` → `skuMode: 'spec'`,新增 `comboItems: []`。
+
+模板 radio:
+
+```html
+        <el-radio-group v-model="skuMode" size="small" class="mb-16">
+          <el-radio-button label="spec">规格变体</el-radio-button>
+          <el-radio-button label="linked">关联商品</el-radio-button>
+          <el-radio-button label="combo">商品组合</el-radio-button>
+        </el-radio-group>
+```
+
+把 `v-if="isLinkedMode"` 改为 `v-if="skuMode === 'linked'"`,原有规格区 `v-else` 改为 `v-else-if="skuMode === 'spec'"`。
+
+- [ ] **Step 2: 新增组合区模板**
+
+```html
+        <template v-else-if="skuMode === 'combo'">
+          <el-form-item label="显示标签">
+            <el-input v-model="skuForm.label" placeholder="默认显示“商品组合”" />
+          </el-form-item>
+          <el-form-item label="组合商品">
+            <el-button size="small" type="primary" plain icon="el-icon-plus" @click="openProductPicker">选择商品</el-button>
+            <div v-if="comboItems.length" class="combo-list">
+              <div v-for="(it, idx) in comboItems" :key="(it.childProductId || it.id) + '-' + (it.childSkuId || '')" class="combo-row">
+                <img v-if="it.coverImage" :src="it.coverImage" class="combo-thumb" />
+                <span v-else class="combo-thumb combo-thumb-placeholder"></span>
+                <div class="combo-main">
+                  <div>{{ it.name }}</div>
+                  <div class="combo-meta">
+                    <span v-if="it.childSkuSpecs">规格:{{ parseSpecs(it.childSkuSpecs) }}</span>
+                    <span>单价 ¥{{ ((it.price || 0)/100).toFixed(2) }}</span>
+                  </div>
+                </div>
+                <el-input-number v-model="it.quantity" :min="1" :precision="0" size="mini" class="combo-qty" />
+                <el-button type="text" class="combo-remove" @click="comboItems.splice(idx, 1)">删除</el-button>
+              </div>
+            </div>
+            <div v-else class="combo-empty">尚未添加组合商品</div>
+          </el-form-item>
+          <el-form-item label="组合售价(元)" prop="price">
+            <el-input-number v-model="skuForm.price" :min="0" :precision="2" class="full-width" />
+          </el-form-item>
+          <el-form-item label="展示库存">
+            <el-input-number v-model="skuForm.stock" :min="0" :precision="0" class="full-width" />
+          </el-form-item>
+        </template>
+```
+
+- [ ] **Step 3: ProductPicker 挂载更新**
+
+```html
+    <ProductPicker
+      ref="productPicker"
+      :product-id="productId"
+      :cross-category="skuMode === 'combo'"
+      :multiple="skuMode === 'combo'"
+      :selected="comboItems.map(i => ({ childProductId: i.childProductId || i.id, childSkuId: i.childSkuId, quantity: i.quantity }))"
+      @selected="onProductSelected"
+      @confirm="onComboConfirm"
+    />
+```
+
+- [ ] **Step 4: 回显与确认**
+
+`showDialog(sku)`:
+
+```js
+      const comboRaw = sku.comboItems || []
+      this.comboItems = comboRaw.map(it => ({
+        childProductId: it.childProductId,
+        childSkuId: it.childSkuId,
+        quantity: it.quantity || 1,
+        name: it.childProductName || ('商品#' + it.childProductId),
+        coverImage: it.childProductImage,
+        price: it.childProductPrice || 0,
+        childSkuSpecs: it.childSkuSpecs
+      }))
+      this.skuMode = comboRaw.length ? 'combo' : (sku.linkedProductId != null ? 'linked' : 'spec')
+```
+
+`onComboConfirm(items)`:
+
+```js
+    onComboConfirm(items) {
+      this.comboItems = (items || []).map(it => ({
+        childProductId: it.childProductId || it.id,
+        childSkuId: it.childSkuId || null,
+        quantity: it.quantity || 1,
+        name: it.name,
+        coverImage: it.coverImage,
+        price: it.price || 0,
+        childSkuSpecs: it.childSkuSpecs || null
+      }))
+    }
+```
+
+- [ ] **Step 5: `handleSave` 增加组合分支,并给另外两分支补 `comboItems`**
+
+```js
+      if (this.skuMode === 'combo') {
+        if (!this.comboItems.length) { this.$message.warning('请至少添加一个组合商品'); return }
+        if (!(this.skuForm.price > 0)) { this.$message.warning('请填写组合售价'); return }
+        const payload = {
+          productId: this.productId,
+          comboItems: normalizeComboPayload(this.comboItems),
+          price: Math.round(parseFloat(this.skuForm.price) * 100),
+          stock: this.skuForm.stock || 0,
+          label: this.skuForm.label || null,
+          enabled: this.skuForm.enabled,
+          linkedProductId: null
+        }
+        // 与 linked 分支相同的「新建暂存 / update / create + 刷新列表」流程
+        return
+      }
+```
+
+- linked 分支 payload 追加 `comboItems: []`;
+- spec 分支 payload 追加 `comboItems: []`、`linkedProductId: null`;
+- `resetForm()` 重置 `skuMode='spec'`、`comboItems=[]`。
+
+- [ ] **Step 6: 列表规格列显示组合摘要**
+
+```html
+      <el-table-column label="规格/组合" min-width="160">
+        <template slot-scope="{ row }">
+          <span v-if="row.comboItems && row.comboItems.length">组合:{{ formatComboSummary(row.comboItems) }}</span>
+          <span v-else>{{ parseSpecs(row.specs) }}</span>
+        </template>
+      </el-table-column>
+```
+
+并 `import { formatComboSummary, normalizeComboPayload } from '@/utils/skuCombo'`;补充组合相关样式。
+
+- [ ] **Step 7: 测试 + 构建**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage && npm run build`
+Expected: 测试全 PASS,构建成功
+
+- [ ] **Step 8: 提交**
+
+```bash
+cd /app/cfc && git add cfc-web/src/views/admin/components/ProductSkuEditor.vue
+git commit -m "feat(web): SKU编辑器支持商品组合(多商品+数量+手填售价)"
+```
+
+---
+
+### Task 8: 全量回归
+
+**Files:** 无新增;运行全部测试与手工回归清单。
+
+- [ ] **Step 1: 后端全量单测**
+
+Run: `cd /app/cfc/cfc-backend && mvn -q test`
+Expected: BUILD SUCCESS,0 failures
+
+- [ ] **Step 2: 前端全量测试 + 构建**
+
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage && npm run build`
+Expected: 全 PASS,构建成功
+
+- [ ] **Step 3: 手工回归(本地/预发,逐条记录结果)**
+
+- 旧 linked SKU:加购显示关联商品名/价;下单金额 = 关联商品价。
+- legacy 规格 SKU:加购/下单金额 = SKU 价。
+- 组合 SKU:加购显示父商品 + 组合摘要 + 手填价;下单总额 = 手填价 × 数量。
+- 组合 SKU:支付成功(可用 testMode)后各组成 SKU/商品按 `quantity × 订单数量` 扣减,**父商品库存不变**。
+- 组合 SKU:取消/退款后各组成库存回补,父商品库存不变。
+- 商品详情:组合 SKU 返回 `isCombo=true` 与 `comboItems`。
+- 管理端:有 SKU 的商品作为组成项时强制选 SKU;无可用 SKU 的商品不可勾选。
+
+- [ ] **Step 4: 提交回归记录(如无代码变更可跳过)**
+
+```bash
+cd /app/cfc && git status
+```
+
+---
+
+### Task 9: 部署(需用户 go/no-go)
+
+**Files:** 无代码变更;运行部署脚本。
+
+- [ ] **Step 1: 同步与备份**
+
+```bash
+ssh root@111.228.6.214
+cd /app/cfc && git status && git fetch origin && git pull --rebase origin cfclub
+tar -czf /app/pro/cfc_backup_$(date +%Y%m%d_%H%M%S).tar.gz -C /app pro/cfc
+```
+
+- [ ] **Step 2: 后端部署**
+
+```bash
+cd /app/cfc/cfc-backend
+mvn clean package -Dmaven.test.skip=true -Dmaven.compile.fork=true
+cp target/cfc-backend-1.0.0.jar /app/pro/cfc/backend/
+systemctl restart cfc-backend
+```
+
+- [ ] **Step 3: 前端部署**
+
+```bash
+cd /app/cfc/cfc-web && npm run build
+rsync -a --delete dist/ /app/pro/cfc/frontend/
+```
+
+- [ ] **Step 4: 验证**
+
+```bash
+systemctl status cfc-backend --no-pager | head -5
+ss -tlnp | grep 9082
+curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:9082/api/cart/list
+curl -s -o /dev/null -w "%{http_code}\n" -k https://cfc.etotem.com.cn/admin/
+grep "迁移432" /app/pro/cfc/backend/cfc-backend.log | tail -5
+curl -s -X POST http://localhost:9082/api/admin/product/linked/list -H 'Content-Type: application/json' -d '{"crossCategory":true,"page":1,"pageSize":3}'
+```
+
+Expected: 服务 active;端口 9082 监听;两个 URL 200;日志含「迁移432」;选择器返回 `code=200` 且含 `skus`/`hasSkus`。
+
+- [ ] **Step 5: 失败回滚**
+
+```bash
+tar -xzf /app/pro/cfc_backup_<ts>.tar.gz -C /app && systemctl restart cfc-backend
+```
+
+- [ ] **Step 6: git 收尾**
+
+```bash
+cd /app/cfc && git status
+# npm run build 可能改动 package.json / .last_build_commit / CHANGELOG*,按 AGENTS.md 提交推送(冲突时取远程版本号)
+```

+ 163 - 0
docs/superpowers/specs/2026-10-31-sku-combo-design.md

@@ -0,0 +1,163 @@
+# SKU 商品组合功能设计
+
+日期:2026-10-31
+状态:设计已确认,待写实施计划
+
+## 1. 背景
+
+管理端商品编辑页(`cfc-web/src/views/admin/ProductEdit.vue`)的 SKU 编辑(`ProductSkuEditor.vue`)中,"关联商品"模式目前只支持**单个**关联商品(`product_skus.linked_product_id BIGINT`)。购物车、下单、商品详情均把整个 SKU 替换为该关联商品(名称、价格、库存取自关联商品)。
+
+业务需要:一个 SKU 可以关联**多个商品组成组合**,每个组成商品带数量;组合 SKU 由运营**手填售价**;下单整体售卖,后台按各组成商品扣库存。选择商品时支持多选并确认。
+
+## 2. 已确认决策
+
+### 用户决定
+1. 组合形态:多商品 + 各自数量(如 商品A×2 + 商品B×1)
+2. 交互:点击"选择商品"弹出多选窗口,勾选多个商品并设置数量后点"确认"回填
+3. 定价:**手填售价**(写入 `product_skus.price`)
+4. 组合 SKU 保留自身 `stock` 字段,但仅用于展示,不参与实际扣减
+5. 购物车/下单:组合 SKU **整体售卖**(一行,名称=父商品/标签,价格=手填售价),后台记明细用于扣库存
+6. **库存口径**:组成商品有 SKU 的必须选到具体 SKU,按 SKU 库存扣;无 SKU 的按商品库存扣
+7. **父商品与退款**:组合订单**不扣父商品库存**,只扣各组成商品;取消/退款时**反向回补各组成商品**(改 3 处回补逻辑)
+8. **跨分类**:组合选择器允许跨分类;旧的单选"关联商品"保持同分类不变
+
+### 实施者决定(builder calls,可复核)
+- 保留旧"关联商品"单选模式,新增"商品组合"作为第三种模式(radio 三选一:规格变体 / 关联商品 / 商品组合)。生产当前 0 个 linked SKU,风险低。
+- 组合组成商品**禁止**:选中商品自身、`productType=bundle` 的商品、以及其他组合 SKU 的商品(避免递归扣减)。
+- `comboItems` 语义:**非空数组**=整表替换;**空数组**=清除组合;**字段省略(null)**=保持不变(避免部分更新误清空)。
+- 切换模式时显式清理旧数据(切到组合→清 `linkedProductId`;切到关联/规格→删组合明细)。
+- 支付/退款识别组合订单:**给 `product_orders` 增加 `sku_id` 列**(迁移 432 一并做),下单时记录;比改造 `order_items` 写入路径更直接。
+- 支付扣减只对**组合行**做逐项扣减,不改变现有非组合订单(含多行订单)的扣减行为,避免扩大回归面。
+
+## 3. 目标
+
+1. 管理端 SKU 编辑支持组合:多商品 + 数量(有 SKU 的商品需选 SKU)、手填售价
+2. `ProductPicker` 支持多选 + 每项数量(有 SKU 商品带 SKU 选择)+ 确认 + 跨分类
+3. 组合 SKU 加购/下单:整体一行,价格 = `sku.price`,展示父商品名 + 组合摘要
+4. 支付成功按组合明细扣各组成 SKU/商品库存,并写出库流水;取消/退款反向回补
+5. 旧单选 `linked_product_id` SKU 行为**完全不变**
+
+## 4. 非目标
+
+- 不修改套餐(`productType=bundle`,`product_bundle_items`)逻辑
+- 不自动计算组合 SKU 的可用库存(`stock` 仅展示,运营手填)
+- 不做小程序端/其他消费端 UI 改造(实现前 grep 确认消费方,保证接口字段兼容)
+- 不新增组合订单明细快照表
+
+## 5. 数据模型
+
+### 5.1 新表 `product_sku_combo_items`
+
+```sql
+CREATE TABLE IF NOT EXISTS product_sku_combo_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    sku_id BIGINT NOT NULL COMMENT '组合SKU ID',
+    child_product_id BIGINT NOT NULL COMMENT '组成商品ID',
+    child_sku_id BIGINT DEFAULT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)',
+    quantity INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_sku (sku_id),
+    INDEX idx_child_product (child_product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细';
+```
+
+### 5.2 `product_orders` 增加 `sku_id`
+
+```sql
+ALTER TABLE product_orders ADD COLUMN sku_id BIGINT NULL COMMENT '下单SKU ID(组合SKU识别用)';
+```
+
+- 迁移编号 **432**,放在 `DatabaseInitializer.runMigrations()` 末尾(当前最大 431)
+- 同步 `schema.sql`
+
+### 5.3 `product_skus`
+
+结构不变。`linked_product_id` 保留供旧单选关联。
+
+**判定优先级**(后端所有消费点统一):
+1. `comboItems` 非空 → 组合 SKU(新逻辑)
+2. `linked_product_id` 非空 → 旧单选关联(原逻辑)
+3. 否则 → legacy specs SKU(原逻辑)
+
+## 6. 后端改动
+
+### 6.1 实体与 Mapper
+
+- 新增 `entity/ProductSkuComboItem.java`(`@TableName("product_sku_combo_items")`)
+- 新增 `mapper/ProductSkuComboItemMapper.java`(`BaseMapper` + 按 skuId 查询/删除)
+- `entity/ProductSku.java` 增加 `@TableField(exist = false) private List<ProductSkuComboItem> comboItems;`
+- `entity/ProductOrder.java` 增加 `private Long skuId;`
+
+### 6.2 Service
+
+- `ProductSkuService`:
+  - `create`/`update`:`comboItems != null` 时先删旧明细再批量插入(空数组=删除);组合模式下 `specs` 走 `"[]"` 兜底
+  - `splitComboItems`/`fillComboItems`:查询返回前填充
+  - `getById` / `listByProductIdAll` 返回前填充 `comboItems`
+- `ProductSkuDTO`:增加 `comboItems`,`from()` 回填(mapper 注入或 controller 组装)
+
+### 6.3 Controller
+
+- `ProductSkuController.create/update`:`ProductSku` 直接接收 `comboItems`(`@TableField(exist=false)` 不影响 Jackson);异常则改显式 DTO
+- `ProductSkuController.list/get`:返回 `comboItems`
+- `AdminProductPickerController.linkedList`:增加参数 `crossCategory`(Boolean,默认 false)。true 时不按分类过滤、并排除自身/bundle/组合商品;同时返回每个商品的 SKU 列表概要(供有 SKU 商品选择 SKU)
+
+### 6.4 消费点
+
+| 位置 | 现状 | 组合分支 |
+|---|---|---|
+| `CartService`(约 350 行) | `linkedProductId` 非空 → 替换为关联商品 | `comboItems` 非空 → **不替换商品**;显示父商品名/标签 + 组合摘要;`unitPrice = sku.price` |
+| `ProductOrderService` 下单(约 765 行) | `linkedProductId` 非空 → 替换商品 + 价格 | `comboItems` 非空 → 不替换;`unitPrice = sku.price`;记录 `order.skuId`;校验各组成 SKU/商品库存(可选) |
+| `ProductOrderService.doHandlePaymentSuccess`(约 1035 行) | physical → 扣父商品;bundle → 穿透子商品 | 若 `order.skuId` 是组合 SKU:**跳过父商品扣减**,按 combo items 扣减 `childSkuId`(有值)或 `childProductId`,数量=`combo.quantity × order.quantity`,`recordOutbound(... "组合出库:" + orderNo)` |
+| `ProductOrderService` 回补 3 处(约 1266/1714/1932 行) | 无条件 `increaseStock(order.productId, order.quantity)` | 若 `order.skuId` 是组合 SKU:按 combo items 反向回补各组成 SKU/商品(`increaseStock`),不补父商品 |
+| `ProductController`(约 151 行) | 返回 `linkedProductId` + `linked` 摘要 | 组合 SKU 返回 `comboItems` 摘要 + `isCombo = true` |
+
+### 6.5 库存与事务
+
+- 扣减复用 `ProductSkuService.decreaseStock` / `ProductService.decreaseStock`
+- 支付成功已在 `@Transactional` + Redis 分布式锁内,任一组成库存不足抛异常整体回滚
+- 退款/取消回补与扣减严格对称(同一份 comboItems 解析)
+
+## 7. 前端改动
+
+### 7.1 `ProductPicker.vue`
+
+- 表格首列 radio → **checkbox 多选**
+- 每个已选行提供**数量输入**(`el-input-number`,min=1)
+- **有 SKU 的商品**:行内出现 SKU 下拉(必选),提交 `childSkuId`
+- 新增 prop `crossCategory`(组合模式传 true → 调 linked/list 带 `crossCategory: true`)
+- 底部"确认选择"emit 数组:`[{ childProductId, childSkuId, name, price, coverImage, stock, quantity }]`
+- 支持 prop 传入已选列表用于编辑回显
+
+### 7.2 `ProductSkuEditor.vue`
+
+- radio 三选一:规格变体 / 关联商品 / 商品组合
+- 组合模式:
+  - 已选组合列表:图片/名称/单价/**数量(可编辑)**/删除
+  - "选择商品"打开 `ProductPicker`(多选 + 跨分类 + SKU 子选)
+  - 新增**组合售价(元)** 输入(手填)→ `skuForm.price`
+  - 保留"显示标签"与"启用"
+- `handleSave` payload 增加 `comboItems: [{ childProductId, childSkuId, quantity }]`;校验:≥1 个组成商品、手填售价 > 0、有 SKU 的商品必须选 SKU
+- 编辑回显:根据返回的 `comboItems` 渲染
+- 列表"规格"列对组合 SKU 展示组合摘要
+
+### 7.3 API
+
+`createProductSku/updateProductSku` 已是 POST,body 增加 `comboItems` 即可。
+
+## 8. 验证与风险
+
+- **金额**:组合 SKU 必须用 `sku.price`,不能被 linked 替换逻辑覆盖
+- **库存**:仅支付成功扣一次(已有幂等锁);组合扣减失败整体回滚;取消/退款对称回补
+- **兼容**:旧 `linked_product_id` SKU 回归(加购、下单、详情)
+- **测试**:后端单测(combo 保存/查询/扣减/回补);前端组件测试(ProductPicker 多选+SKU、SKU 编辑器组合保存);`npx jest src/__tests__/api` + 后端 `mvn test`
+- **部署**:备份 → 后端 jar 重启 → 前端构建 → 健康检查(遵循 AGENTS.md)
+
+## 9. 实施顺序建议
+
+1. 迁移 432(新表 + product_orders.sku_id)+ schema.sql
+2. 实体/Mapper/DTO/Service/Controller(组合 CRUD)
+3. 消费点改造(Cart → Order 下单 → 支付扣减 → 退款回补 → 商品详情)
+4. 前端 ProductPicker 多选/SKU/跨分类
+5. 前端 ProductSkuEditor 组合模式
+6. 测试 + 构建 + 部署