2026-08-15-product-bundle.md 21 KB

商品套餐管理功能 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 完成商品套餐(组合)管理:套餐商品可配置子商品组成(商品+SKU+数量),套餐出库由订单支付自动穿透完成(已实现,不改),套餐商品在管理端可配置组成、在小程序商品详情页描述下方展示「套餐包含」明细。

架构: 延续现有半成品骨架(ProductBundleItem 实体/Mapper + AdminInventoryController 3 个 bundle 接口 + ProductOrderService 订单支付穿透出库)。本次补全 4 个缺口:① DatabaseInitializerproduct_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.javasaveBundleItems(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.jssaveBundleItems/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

    		// 迁移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 末尾追加建表快照

    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

    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 之前)新增字段与内部类

    // 套餐商品组成(仅 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

    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 注入之后)

    @Resource
    private ProductBundleItemMapper bundleItemMapper;
    
    @Resource
    private ProductSkuMapper productSkuMapper;
    

需在文件顶部追加 import(现有 import 区 13-16 行附近):

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 逻辑之前插入套餐组成填充

        // 套餐商品:填充套餐组成(子商品名称/封面/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

    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" /> 之后追加:

            <el-option label="套餐" value="bundle" />
  • [ ] 步骤 2:新增套餐组成配置区模板 — 在测评商品扩展信息 </template>(54 行「关联成长档案」divider 之前)之后、<!-- 关联成长档案 --> divider 之前插入

        <!-- 套餐商品扩展信息 -->
        <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' 修改为追加:

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: [] 之前或之后均可)

      bundleItems: [], // 套餐组成: [{childProductId, childProductId, childSkuId, quantity, productOptions:[], skuOptions:[]}]
    
  • [ ] 步骤 5:methods 新增 5 个方法 — 在 loadProduct 方法之后插入

    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 行)之后追加

          // Load bundle items config(套餐商品)
          if (p.productType === 'bundle') {
            this.loadBundleItems(p.id)
          }
    
  • [ ] 步骤 7:handleSave() 保存套餐组成 — 在「保存线上服务配置失败」catch 块(805-807 行)之后、this.$message.success(...)(809 行)之前追加

            // 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 则人工检查模板/方法闭合) 预期:无语法错误

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 行)之后插入套餐包含区块

      <!-- 套餐包含(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' 之后追加

      bundleItems: []
    
  • [ ] 步骤 3:computed 增加 isBundleProduct — computed 对象中任意位置(如 domainLabel 之后)追加

    isBundleProduct() {
      return !!(this.product && this.product.productType === 'bundle')
    }
    
  • [ ] 步骤 4:methods 增加 getBundleKey — methods 中任意位置追加(遵循禁止 :key 表达式规范)

    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 之前插入:

          that.bundleItems = (res.data && res.data.bundleItems) || []

bundleItems 为空数组时模板 v-if="bundleItems.length > 0" 不渲染区块。

  • [ ] 步骤 6:样式 — 在 <style scoped> 中追加套餐区块样式(贴近 desc-section 样式风格)

    .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 均为方法调用

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/ProductSkuMapperProductService 中注入的字段名与其类名首字母小写一致(bundleItemMapper/productSkuMapper),且 ProductService 无重名字段。

  • [ ] 步骤 4:提交全部 + 推送

    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