面向 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 |
描述下方展示「套餐包含」区块 |
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表(套餐商品组成明细)"
文件:
修改: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套餐组成字段"
文件:
修改: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返回套餐商品组成"
文件:
cfc-web/src/views/admin/ProductEdit.vue前提说明: saveBundleItems/getBundleItems 已在 @/api/admin 封装(1870-1890 行);getProductSkuList(productId) 已封装(607 行);getProductList({keyword,page,size}) 已封装。子商品搜索复用 getProductList,SKU 拉取复用 getProductSkuList。
在 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 商品不受影响。
运行: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): 商品编辑页支持套餐类型及套餐组成配置"
文件:
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): 商品详情页展示套餐包含明细"
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
运行:grep -rn '@PostMapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@PostMapping\("\K[^"]*' | sort | uniq -d
预期:无重复路由输出
确认 ProductBundleItemMapper/ProductSkuMapper 在 ProductService 中注入的字段名与其类名首字母小写一致(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 |