Kaynağa Gözat

docs(superpowers): 商品套餐管理功能实现计划

Xiaogang Liao 1 ay önce
ebeveyn
işleme
e3a57e276c
1 değiştirilmiş dosya ile 456 ekleme ve 0 silme
  1. 456 0
      docs/superpowers/plans/2026-08-15-product-bundle.md

+ 456 - 0
docs/superpowers/plans/2026-08-15-product-bundle.md

@@ -0,0 +1,456 @@
+# 商品套餐管理功能 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 完成商品套餐(组合)管理:套餐商品可配置子商品组成(商品+SKU+数量),套餐出库由订单支付自动穿透完成(已实现,不改),套餐商品在管理端可配置组成、在小程序商品详情页描述下方展示「套餐包含」明细。
+
+**架构:** 延续现有半成品骨架(`ProductBundleItem` 实体/Mapper + `AdminInventoryController` 3 个 bundle 接口 + `ProductOrderService` 订单支付穿透出库)。本次补全 4 个缺口:① `DatabaseInitializer` 建 `product_bundle_items` 表 + 同步 schema.sql;② `ProductService.detail()` 对 `productType=bundle` 商品填充套餐组成到 `ProductDTO.bundleItems`;③ 管理端 `ProductEdit.vue` 增加「套餐」类型选项 + 套餐组成配置区;④ 小程序 `product-detail.vue` 描述下方展示「套餐包含」区块。
+
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus(Java 8)/ Vue 2 Options API + Element UI(cfc-web)/ uni-app Vue 2(cfc-frontend)
+
+**规格来源:** `docs/superpowers/specs/2026-08-15-product-bundle-design.md`
+
+**关键既有代码位置(半成品,勿重复实现):**
+- `cfc-backend/.../entity/ProductBundleItem.java`(已存在,勿修改)
+- `cfc-backend/.../mapper/ProductBundleItemMapper.java`(已存在,BaseMapper,勿修改)
+- `cfc-backend/.../service/InventoryService.java` 中 `saveBundleItems`(452行)/ `listBundleItems`(470行)/ `checkBundleStock`(479行)(已存在,勿修改)
+- `cfc-backend/.../controller/admin/AdminInventoryController.java` 中 `/bundle/items`、`/bundle/items/list`、`/bundle/check-stock` 接口(已存在,勿修改)
+- `cfc-backend/.../service/ProductOrderService.java` 582 行套餐穿透出库(已存在,勿修改)
+- `cfc-web/src/api/admin.js` 中 `saveBundleItems`/`getBundleItems`/`checkBundleStock` 封装(1870-1890 行,已存在,勿修改)
+
+---
+
+## 文件清单
+
+| 操作 | 文件 | 职责 |
+|------|------|------|
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 迁移246:创建 `product_bundle_items` 表 |
+| 修改 | `cfc-backend/src/main/resources/schema.sql` | 追加 `CREATE TABLE IF NOT EXISTS product_bundle_items` 快照 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java` | 新增 `bundleItems` 字段 + `BundleItemVO` 内部类 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java` | 注入 bundleItemMapper/productSkuMapper;`detail()` 填充套餐组成 |
+| 修改 | `cfc-web/src/views/admin/ProductEdit.vue` | 商品类型加「套餐」+ 套餐组成配置区 |
+| 修改 | `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue` | 描述下方展示「套餐包含」区块 |
+
+---
+
+### 任务 1:后端迁移 — 创建 `product_bundle_items` 表
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(runMigrations() 内 245b 之后、方法闭合括号 `\t}`(9159行)之前插入)
+- 修改:`cfc-backend/src/main/resources/schema.sql`(文件末尾追加)
+
+- [ ] **步骤 1:在 DatabaseInitializer.runMigrations() 末尾(迁移245b 块之后、方法闭合 `\t}` 之前)添加迁移246**
+
+```java
+		// 迁移246: 创建 product_bundle_items 表(套餐商品组成明细 BUNDLE-001)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_bundle_items (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"bundle_product_id BIGINT NOT NULL COMMENT '套餐商品ID', " +
+					"child_product_id BIGINT NOT NULL COMMENT '子商品ID', " +
+					"child_sku_id BIGINT DEFAULT NULL COMMENT '子SKU ID(为空时直接扣 product.stock)', " +
+					"quantity INT NOT NULL DEFAULT 1 COMMENT '子商品数量', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"INDEX idx_bundle (bundle_product_id), " +
+					"INDEX idx_child (child_product_id)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐商品组成明细'");
+			log.info("已创建product_bundle_items表");
+		} catch (Exception e) {
+			// 表已存在,忽略错误
+		}
+```
+
+- [ ] **步骤 2:在 schema.sql 末尾追加建表快照**
+
+```sql
+CREATE TABLE IF NOT EXISTS product_bundle_items (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    bundle_product_id BIGINT NOT NULL COMMENT '套餐商品ID',
+    child_product_id BIGINT NOT NULL COMMENT '子商品ID',
+    child_sku_id BIGINT DEFAULT NULL COMMENT '子SKU ID(为空时直接扣 product.stock)',
+    quantity INT NOT NULL DEFAULT 1 COMMENT '子商品数量',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_bundle (bundle_product_id),
+    INDEX idx_child (child_product_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐商品组成明细';
+```
+
+- [ ] **步骤 3:Commit**
+
+```bash
+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): 迁移246创建product_bundle_items表(套餐商品组成明细)"
+```
+
+---
+
+### 任务 2:后端 — ProductDTO 增加套餐组成字段
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java`
+
+- [ ] **步骤 1:在 ProductDTO 类中(`deliveryConfig` 字段之后、`createdAt` 之前)新增字段与内部类**
+
+```java
+    // 套餐商品组成(仅 productType=bundle 时非空)
+    private List<BundleItemVO> bundleItems;
+
+    @Data
+    public static class BundleItemVO {
+        private Long childProductId;
+        private String childProductName;
+        private String coverImage;
+        private Long childSkuId;
+        private String specs;        // SKU 规格描述(无 SKU 时为空)
+        private Integer quantity;
+    }
+```
+
+- [ ] **步骤 2:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
+git commit -m "feat(backend): ProductDTO新增bundleItems套餐组成字段"
+```
+
+---
+
+### 任务 3:后端 — ProductService.detail() 填充套餐组成
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java`(依赖注入区 32-51 行 + `detail()` 方法 121-142 行)
+
+- [ ] **步骤 1:新增依赖注入(在 `productDeliveryConfigService` 注入之后)**
+
+```java
+    @Resource
+    private ProductBundleItemMapper bundleItemMapper;
+
+    @Resource
+    private ProductSkuMapper productSkuMapper;
+```
+
+需在文件顶部追加 import(现有 import 区 13-16 行附近):
+```java
+import com.etotem.cfc.entity.ProductBundleItem;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.ProductBundleItemMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+```
+
+- [ ] **步骤 2:修改 `detail()` 方法 — 在 `ProductDTO dto = ProductDTO.from(product);` 之后、`ProductGiftRule` 逻辑之前插入套餐组成填充**
+
+```java
+        // 套餐商品:填充套餐组成(子商品名称/封面/SKU规格/数量)
+        if ("bundle".equals(product.getProductType())) {
+            List<ProductBundleItem> items = bundleItemMapper.selectList(
+                    new LambdaQueryWrapper<ProductBundleItem>()
+                            .eq(ProductBundleItem::getBundleProductId, id));
+            List<ProductDTO.BundleItemVO> bundleItems = new ArrayList<>();
+            for (ProductBundleItem bi : items) {
+                Product child = productMapper.selectById(bi.getChildProductId());
+                if (child == null) continue;   // 子商品已删除则跳过
+                ProductDTO.BundleItemVO vo = new ProductDTO.BundleItemVO();
+                vo.setChildProductId(child.getId());
+                vo.setChildProductName(child.getName());
+                vo.setCoverImage(child.getCoverImage());
+                vo.setChildSkuId(bi.getChildSkuId());
+                vo.setQuantity(bi.getQuantity());
+                if (bi.getChildSkuId() != null) {
+                    ProductSku sku = productSkuMapper.selectById(bi.getChildSkuId());
+                    if (sku != null) vo.setSpecs(sku.getSpecs());
+                }
+                bundleItems.add(vo);
+            }
+            dto.setBundleItems(bundleItems);
+        }
+```
+
+- [ ] **步骤 3:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS,无编译错误(若报 Bean 注入找不到 ProductSkuMapper,确认 `mapper/ProductSkuMapper.java` 存在——已存在)
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
+git commit -m "feat(backend): ProductService.detail返回套餐商品组成"
+```
+
+---
+
+### 任务 4:管理端 — ProductEdit.vue 套餐类型 + 套餐组成配置区
+
+**文件:**
+- 修改:`cfc-web/src/views/admin/ProductEdit.vue`
+
+**前提说明:** `saveBundleItems`/`getBundleItems` 已在 `@/api/admin` 封装(1870-1890 行);`getProductSkuList(productId)` 已封装(607 行);`getProductList({keyword,page,size})` 已封装。子商品搜索复用 `getProductList`,SKU 拉取复用 `getProductSkuList`。
+
+- [ ] **步骤 1:商品类型下拉增加「套餐」选项**
+
+在 `ProductEdit.vue` 模板 22 行 `<el-option label="测评商品" value="assessment" />` 之后追加:
+
+```html
+            <el-option label="套餐" value="bundle" />
+```
+
+- [ ] **步骤 2:新增套餐组成配置区模板 — 在测评商品扩展信息 `</template>`(54 行「关联成长档案」divider 之前)之后、`<!-- 关联成长档案 -->` divider 之前插入**
+
+```html
+        <!-- 套餐商品扩展信息 -->
+        <template v-if="form.productType === 'bundle'">
+          <el-divider content-position="left">套餐组成</el-divider>
+          <el-form-item label="组成商品">
+            <div v-for="(item, idx) in bundleItems" :key="'bi' + idx" style="display:flex; align-items:center; margin-bottom:8px; gap:8px; flex-wrap:wrap;">
+              <el-select v-model="item.childProductId" filterable remote placeholder="搜索子商品" :remote-method="(q) => searchBundleProduct(q, idx)" style="width:220px;" @change="(pid) => onBundleProductChange(pid, idx)">
+                <el-option v-for="p in item.productOptions" :key="p.id" :label="(p.name || 'ID:' + p.id) + ' (库存:' + (p.stock || 0) + ')'" :value="p.id" />
+              </el-select>
+              <el-select v-if="item.skuOptions.length" v-model="item.childSkuId" placeholder="选择规格" clearable style="width:160px;">
+                <el-option v-for="s in item.skuOptions" :key="s.id" :label="(s.specs || '默认') + ' (库存:' + (s.stock || 0) + ')'" :value="s.id" />
+              </el-select>
+              <el-input-number v-model="item.quantity" :min="1" :precision="0" size="small" style="width:110px;" />
+              <el-button type="danger" size="mini" @click="removeBundleItem(idx)">删除</el-button>
+            </div>
+            <el-button type="primary" size="mini" plain @click="addBundleItem">+ 添加子商品</el-button>
+            <div style="margin-top:8px; font-size:12px; color:#999;">套餐出库时自动完成所有组成商品出库(数量×套餐数量)</div>
+          </el-form-item>
+        </template>
+```
+
+- [ ] **步骤 3:import 区(296 行)追加 4 个 API**
+
+现有 import 行结尾 `...saveDeliveryOnlineSlots } from '@/api/admin'` 修改为追加:
+
+```js
+import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct, listDeliveryServicePersons, saveDeliveryServicePersons, listDeliveryOnlineSlots, saveDeliveryOnlineSlots, getProductList, getProductSkuList, saveBundleItems, getBundleItems } from '@/api/admin'
+```
+
+- [ ] **步骤 4:data() 新增状态 — 在 `bundleItems: []` 定义到 data 返回对象(`sortOrder: 0` 之后,338 行 `imageList: []` 之前或之后均可)**
+
+```js
+      bundleItems: [], // 套餐组成: [{childProductId, childProductId, childSkuId, quantity, productOptions:[], skuOptions:[]}]
+```
+
+- [ ] **步骤 5:methods 新增 5 个方法 — 在 `loadProduct` 方法之后插入**
+
+```js
+    addBundleItem() {
+      this.bundleItems.push({
+        childProductId: null,
+        childSkuId: null,
+        quantity: 1,
+        productOptions: [],
+        skuOptions: []
+      })
+    },
+    removeBundleItem(idx) {
+      this.bundleItems.splice(idx, 1)
+    },
+    async searchBundleProduct(query, idx) {
+      if (!query || query.length < 1) return
+      try {
+        const res = await getProductList({ keyword: query, page: 1, size: 20 })
+        if (res.code === 200 && res.data && Array.isArray(res.data.records)) {
+          this.bundleItems[idx].productOptions = res.data.records
+        }
+      } catch (e) { /* ignore */ }
+    },
+    async onBundleProductChange(productId, idx) {
+      const item = this.bundleItems[idx]
+      item.childSkuId = null
+      item.skuOptions = []
+      if (!productId) return
+      try {
+        const res = await getProductSkuList(productId)
+        if (res.code === 200 && Array.isArray(res.data)) {
+          item.skuOptions = res.data
+        }
+      } catch (e) { /* ignore */ }
+    },
+    buildBundleItemPayload() {
+      return this.bundleItems
+        .filter(b => b.childProductId)
+        .map(b => ({
+          childProductId: b.childProductId,
+          childSkuId: b.childSkuId || null,
+          quantity: b.quantity || 1
+        }))
+    },
+    async loadBundleItems(productId) {
+      try {
+        const res = await getBundleItems(productId)
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.bundleItems = res.data.map(b => ({
+            childProductId: b.childProductId,
+            childSkuId: b.childSkuId || null,
+            quantity: b.quantity || 1,
+            productOptions: [],
+            skuOptions: []
+          }))
+          // 回填 SKU 选项(为有 skuId 的行拉取规格列表供标题展示)
+          this.bundleItems.forEach((item, idx) => {
+            if (item.childProductId) this.onBundleProductChange(item.childProductId, idx)
+          })
+        }
+      } catch (e) { /* ignore */ }
+    }
+```
+
+- [ ] **步骤 6:loadProduct() 中回填套餐组成 — 在 `this.loadDeliveryConfig(p.id)`(495 行)之后追加**
+
+```js
+          // Load bundle items config(套餐商品)
+          if (p.productType === 'bundle') {
+            this.loadBundleItems(p.id)
+          }
+```
+
+- [ ] **步骤 7:handleSave() 保存套餐组成 — 在「保存线上服务配置失败」catch 块(805-807 行)之后、`this.$message.success(...)`(809 行)之前追加**
+
+```js
+            // Save bundle items config(套餐商品)
+            if (productId) {
+              try {
+                const bundlePayload = this.buildBundleItemPayload()
+                await saveBundleItems(productId, bundlePayload)
+              } catch (e) {
+                this.$message.error('保存套餐组成失败')
+                console.warn('保存套餐组成失败', e)
+              }
+            }
+```
+
+> 注:`saveBundleItems` 为覆盖式保存(先 delete 后 insert),非套餐商品传空数组即可清除旧配置;此处仅对 bundle 商品调用,非 bundle 商品不受影响。
+
+- [ ] **步骤 8:语法校验 + Commit**
+
+运行:`node --check` 语法校验思路(复制 `<script>` 块内容做 ES 语法检查,或运行 `cd cfc-web && npx eslint src/views/admin/ProductEdit.vue --no-inline-config` 若项目配置了 eslint;若无 eslint 则人工检查模板/方法闭合)
+预期:无语法错误
+
+```bash
+git add cfc-web/src/views/admin/ProductEdit.vue
+git commit -m "feat(web): 商品编辑页支持套餐类型及套餐组成配置"
+```
+
+---
+
+### 任务 5:小程序 — product-detail.vue 展示「套餐包含」
+
+**文件:**
+- 修改:`cfc-frontend/pages/discover-detail/product-detail/product-detail.vue`
+
+**前提说明:** 后端 `/api/product/detail` 返回的 `ProductDTO` 已含 `bundleItems`(任务3);小程序通过 `productDetail` API 拿到 `product` 对象。展示区块放在「商品详情」desc-section(99-102 行)之后。
+
+- [ ] **步骤 1:模板 — 在 desc-section 闭合 `</view>`(102 行)之后插入套餐包含区块**
+
+```html
+      <!-- 套餐包含(bundle商品展示组成) -->
+      <view v-if="isBundleProduct && bundleItems.length > 0" class="bundle-section">
+        <text class="section-title">套餐包含</text>
+        <view class="bundle-item" v-for="(bi, idx) in bundleItems" :key="getBundleKey(bi, idx)">
+          <image v-if="bi.coverImage" class="bundle-item-img" :src="getImageUrl(bi.coverImage)" mode="aspectFill" />
+          <view class="bundle-item-info">
+            <text class="bundle-item-name">{{ bi.childProductName }}</text>
+            <text v-if="bi.specs" class="bundle-item-spec">{{ bi.specs }}</text>
+          </view>
+          <text class="bundle-item-qty">×{{ bi.quantity }}</text>
+        </view>
+      </view>
+```
+
+- [ ] **步骤 2:data() 增加 bundleItems 数组 — 154 行 `memberLevel: 'FREE'` 之后追加**
+
+```js
+      bundleItems: []
+```
+
+- [ ] **步骤 3:computed 增加 isBundleProduct — computed 对象中任意位置(如 `domainLabel` 之后)追加**
+
+```js
+    isBundleProduct() {
+      return !!(this.product && this.product.productType === 'bundle')
+    }
+```
+
+- [ ] **步骤 4:methods 增加 getBundleKey — methods 中任意位置追加(遵循禁止 `:key` 表达式规范)**
+
+```js
+    getBundleKey(item, idx) {
+      return item ? (item.childProductId || idx) : idx
+    }
+```
+
+- [ ] **步骤 5:详情数据加载处填充 bundleItems**
+
+`productDetail` 调用成功回调在 315-329 行,其中 318 行为 `that.product = res.data`(回调内使用 `that` 变量,`that = this` 已在函数顶部定义)。在 318 行之后、319 行 `that.setShareInfo` 之前插入:
+
+```js
+          that.bundleItems = (res.data && res.data.bundleItems) || []
+```
+
+> `bundleItems` 为空数组时模板 `v-if="bundleItems.length > 0"` 不渲染区块。
+
+- [ ] **步骤 6:样式 — 在 `<style scoped>` 中追加套餐区块样式(贴近 desc-section 样式风格)**
+
+```css
+.bundle-section { background: #fff; margin: 16rpx; border-radius: 16rpx; padding: 24rpx; }
+.bundle-section .section-title { display: block; font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 16rpx; }
+.bundle-item { display: flex; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.bundle-item:last-child { border-bottom: none; }
+.bundle-item-img { width: 80rpx; height: 80rpx; border-radius: 12rpx; margin-right: 16rpx; flex-shrink: 0; background: #f7f7f7; }
+.bundle-item-info { flex: 1; display: flex; flex-direction: column; min-width: 0; }
+.bundle-item-name { font-size: 28rpx; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.bundle-item-spec { font-size: 24rpx; color: #999; margin-top: 4rpx; }
+.bundle-item-qty { font-size: 30rpx; font-weight: bold; color: #F97316; margin-left: 16rpx; flex-shrink: 0; }
+```
+
+- [ ] **步骤 7:语法校验 + Commit**
+
+校验:仅做语法/结构校验(打包必须用 HBuilderX,Agent 禁止执行 build 命令——见 cfc-frontend/AGENTS.md)
+预期:模板标签闭合、无可选链 `?.`、`:key` 均为方法调用
+
+```bash
+git add cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
+git commit -m "feat(frontend): 商品详情页展示套餐包含明细"
+```
+
+---
+
+### 任务 6:整体验证 + 推送
+
+- [ ] **步骤 1:后端编译**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 2:路由重复检查(新增接口无,但确认无冲突)**
+
+运行:`grep -rn '@PostMapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@PostMapping\("\K[^"]*' | sort | uniq -d`
+预期:无重复路由输出
+
+- [ ] **步骤 3:BEAN 名冲突检查**
+
+确认 `ProductBundleItemMapper`/`ProductSkuMapper` 在 `ProductService` 中注入的字段名与其类名首字母小写一致(`bundleItemMapper`/`productSkuMapper`),且 `ProductService` 无重名字段。
+
+- [ ] **步骤 4:提交全部 + 推送**
+
+```bash
+git log --oneline -6   # 确认 6 个 commit
+git push origin cfclub
+```
+
+预期:推送到远端成功(若远端有更新先 `git pull --rebase --autostash origin cfclub`)
+
+---
+
+## 自检对照(规格 → 任务)
+
+| 规格章节 | 对应任务 |
+|----------|----------|
+| 3.1 建表 product_bundle_items + 迁移 + schema.sql | 任务1 |
+| 3.2 ProductDTO.bundleItems + BundleItemVO | 任务2 |
+| 3.3 ProductService.detail() 填充 | 任务3 |
+| 4.1 ProductEdit.vue 套餐类型 + 组成配置区 | 任务4 |
+| 5.1 product-detail.vue 套餐包含展示 | 任务5 |
+| 6/7 错误处理边界 + 测试策略 | 任务3 步骤3 / 任务6 |