Branch: 008-promotion-coupon | Date: 2026-05-29 | Spec: spec.md
Input: Feature specification from /specs/008-promotion-coupon/spec.md
为 foodie 商家端新增促销活动管理(满减/折扣商品/第二份半价/新客立减 4 种类型)和优惠券管理(满减券/商品券/免配送费券),商家可在后台创建和管理促销活动与优惠券。旧促销/优惠券代码(SalesPromotion、VipQuanyi)不动,全部新建文件、新表(promotion_ 前缀)、新接口。前端在商家端侧边栏新增独立的「营销管理」菜单。用户端(小程序)的领券、下单优惠计算不在本范围。
技术方案:后端跟随现有 CRUD 分层(Entity + MyBatis XML Mapper + Service + Controller),6 张新表 + 2 个商家端 Controller + 2 个用户端 Controller(领券/查券 + 算价计算)。前端新增 2 个 Vue 页面,促销活动创建页面用 el-tabs 切换 4 种活动类型表单,每种类型有不同的表单字段和数据提交结构。折扣类型采用逐商品设折扣方式(参考美团),每个商品独立设置折扣率,不使用折扣区分组。
Language/Version: Java 17 (Spring Boot 3.x, MyBatis-Plus) Primary Dependencies: Spring Boot, MyBatis-Plus, Vue.js 2.6, Element UI 2.15, vue-i18n Storage: MySQL (6 张新表: promotion_activity, promotion_activity_rule, promotion_coupon_batch, promotion_coupon_rule, promotion_user_coupon, pos_orderpromotion) Testing: 手动测试 (Postman + 前端页面验证) Target Platform: 商家 PC 端管理后台 (foodie-store) Project Type: Web 应用 (Java 后端 + Vue 前端) Performance Goals: 无特殊性能要求,标准 CRUD 操作 Constraints: 旧代码不动,新表用 promotion 前缀,SQL 写入 updatesql/sql.md 手动执行,前端文件 CRLF 换行 Scale/Scope: 商家端 2 个新页面 + 后端 6 个实体 + 4 个 Controller (2 商家端 + 2 用户端)
GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.
本项目 constitution 未配置具体规则(模板状态),跳过此检查。
specs/008-promotion-coupon/
├── spec.md # 功能规格(已完成)
├── plan.md # 本文件 — 实施计划
└── tasks.md # 任务列表(后续生成)
# 后端 (foodie_server)
ruoyi-system/src/main/java/com/ruoyi/system/
├── domain/
│ ├── PromotionActivity.java # 促销活动实体
│ ├── PromotionActivityRule.java # 促销规则实体
│ ├── PromotionCouponBatch.java # 券批次实体
│ ├── PromotionCouponRule.java # 券规则实体
│ └── PromotionUserCoupon.java # 用户券实体
├── mapper/
│ ├── PromotionActivityMapper.java
│ ├── PromotionActivityRuleMapper.java
│ ├── PromotionCouponBatchMapper.java
│ ├── PromotionCouponRuleMapper.java
│ └── PromotionUserCouponMapper.java
└── service/
├── IPromotionActivityService.java
├── IPromotionActivityRuleService.java
├── IPromotionCouponBatchService.java
├── IPromotionCouponRuleService.java
└── impl/
├── PromotionActivityServiceImpl.java
├── PromotionActivityRuleServiceImpl.java
├── PromotionCouponBatchServiceImpl.java
└── PromotionCouponRuleServiceImpl.java
ruoyi-system/src/main/resources/mapper/system/
├── PromotionActivityMapper.xml
├── PromotionActivityRuleMapper.xml
├── PromotionCouponBatchMapper.xml
├── PromotionCouponRuleMapper.xml
└── PromotionUserCouponMapper.xml
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/
├── ShPromotionActivityController.java # 商家促销活动 API
└── ShPromotionCouponController.java # 商家优惠券 API
ruoyi-admin/src/main/java/com/ruoyi/app/user/
├── UserPromotionCouponController.java # 用户端优惠券接口(领券、查券)
└── UserPromotionCalcController.java # 用户端算价接口(计算优惠)
updatesql/
└── sql.md # SQL 迁移脚本(追加建表语句)
# 前端 (foodie-store)
src/
├── api/
│ ├── promotionActivity.js # 促销活动 API
│ └── promotionCoupon.js # 优惠券 API
├── views/
│ ├── PromotionActivity.vue # 促销活动管理页面
│ └── CouponBatch.vue # 优惠券管理页面
├── components/
│ └── Aside.vue # 侧边栏(新增菜单项)
├── router/
│ └── index.js # 路由(新增2条路由)
└── lang/
├── zh.js # 中文 i18n(新增 promoMenu/promoActivity/couponBatch)
├── tw.js # 繁体中文
├── en.js # 英文
└── vi.js # 越南语
Structure Decision: 后端遵循现有分层架构(domain/mapper/service/controller),前端页面放在 views/ 目录,API 放在 api/ 目录。不新建子目录,与现有文件组织方式一致。
折扣商品(type=2)采用逐商品设折扣方式(参考美团/饿了么),不使用「折扣区/折扣档位」分组概念。
商家操作流程:
前端提交的 rules 即为扁平列表,与后端 promotion_activity_rule 表结构一一对应:
rules: [
{productId: 可乐ID, discountRate: 0.5},
{productId: 宫保鸡丁ID, discountRate: 0.7},
{productId: 麻婆豆腐ID, discountRate: 0.8}
]
UI 示意:
活动名称: [超值特惠] 活动时间: [开始] ~ [结束]
商品列表:
┌──────────────┬────────┬──────────────┬──────────┬────────┐
│ 商品名称 │ 原价 │ 折扣率 │ 折后价 │ 操作 │
├──────────────┼────────┼──────────────┼──────────┼────────┤
│ 可乐 │ 6 │ 50% [-][+] │ 3.0 │ 移除 │
│ 宫保鸡丁 │ 20 │ 70% [-][+] │ 14.0 │ 移除 │
│ 麻婆豆腐 │ 18 │ 80% [-][+] │ 14.4 │ 移除 │
└──────────────┴────────┴──────────────┴──────────┴────────┘
[+ 添加商品]
调研记录:docs/research-meituan-discount-product.md
活动状态使用显式字段(0=未开始, 1=进行中, 2=已结束),在创建时根据 startTime 自动设置。不使用定时任务自动更新状态(简化实现),列表查询时按 status 字段过滤即可。
现有的 Quanyi.vue 中已有商品选择弹窗模式(分类下拉 + 搜索 + 商品表格 + 分页)。PromotionActivity.vue 和 CouponBatch.vue 复用相同的交互模式和 API 调用方式,不抽取为公共组件(遵循项目现有风格)。
促销活动创建:前端发送完整的 { storeId, type, name, startTime, endTime, rules: [...] } JSON,后端在一个事务中插入 activity + rules。
优惠券创建:前端发送 { storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} } JSON,后端在一个事务中插入 batch + rule。
| Method | Path | Description | Request | Response |
|---|---|---|---|---|
| GET | /system/shPromotionActivity/list |
分页列表 | params: page, size, storeId, type?, status? | { code:200, data: { records:[], total, current, size } } |
| GET | /system/shPromotionActivity/{id} |
详情(含规则) | path: id | { code:200, data: { id, storeId, type, name, status, startTime, endTime, rules: [...] } } |
| POST | /system/shPromotionActivity |
创建活动 | body: { storeId, type, name, startTime, endTime, rules: [...] } |
{ code:200, msg:"操作成功" } |
| PUT | /system/shPromotionActivity |
修改活动(仅未开始) | body: { id, storeId, type, name, startTime, endTime, rules: [...] } |
{ code:200, msg:"操作成功" } |
| DELETE | /system/shPromotionActivity/{id} |
删除活动(仅未开始) | path: id | { code:200, msg:"操作成功" } |
| PUT | /system/shPromotionActivity/{id}/end |
结束活动 | path: id | { code:200, msg:"操作成功" } |
| Method | Path | Description | Request | Response |
|---|---|---|---|---|
| GET | /system/shPromotionCoupon/list |
分页列表 | params: page, size, storeId, couponType?, status? | { code:200, data: { records:[], total } } |
| GET | /system/shPromotionCoupon/{id} |
详情(含规则) | path: id | { code:200, data: { id, storeId, name, couponType, totalCount, ..., rule: {...} } } |
| POST | /system/shPromotionCoupon |
创建券 | body: { storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} } |
{ code:200, msg:"操作成功" } |
| PUT | /system/shPromotionCoupon |
修改券(未开始/进行中) | body: { id, storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} } |
{ code:200, msg:"操作成功" } |
| DELETE | /system/shPromotionCoupon/{id} |
删除券(仅未开始) | path: id | { code:200, msg:"操作成功" } |
| PUT | /system/shPromotionCoupon/{id}/offShelf |
下架券 | path: id | { code:200, msg:"操作成功" } |
| Method | Path | Description | Request | Response |
|---|---|---|---|---|
| GET | /app/userPromotionCoupon/storeCoupons |
查询门店可领优惠券列表 | params: storeId | { code:200, data: [{ id, name, couponType, threshold, amount, discountRate, isMutex, totalCount, remainCount, hasReceived }] } |
| POST | /app/userPromotionCoupon/receive |
领取优惠券 | body: { batchId, storeId } |
{ code:200, data: { userCouponId }, msg:"领取成功" } |
| GET | /app/userPromotionCoupon/myCoupons |
查询用户优惠券列表 | params: status?(0=未使用/1=已使用/2=已过期), storeId? | { code:200, data: [{ id, batchId, storeId, name, couponType, threshold, amount, discountRate, isMutex, status, expireTime }] } |
| Method | Path | Description | Request | Response |
|---|---|---|---|---|
| POST | /app/userPromotionCalc/calculate |
计算订单优惠 | body: { storeId, items:[{productId, quantity}], couponId?, forcePath? } |
见下方 Response 示例 |
算价接口参数说明:
items 中只传 productId 和 quantity,不传 price。后端根据 productId 从数据库查询商品实际价格,防止前端篡改couponId: 可选,传入用户券ID则计算使用该券后的价格;不传则不使用券forcePath: 可选,"A"=强制走折扣路径,"B"=强制走满减路径,不传或非法值=自动选最优算价接口 Response 示例 1(满减+优惠券+新客立减):
{
"code": 200,
"data": {
"originalAmount": 45.00,
"pathA": {
"label": "折扣",
"items": [{ "productId": 1, "originalPrice": 20.00, "finalPrice": 14.00 }],
"subtotal": 39.00,
"promotionReduce": 6.00
},
"pathB": {
"label": "满减",
"items": [{ "productId": 1, "originalPrice": 20.00, "finalPrice": 20.00 }],
"subtotal": 33.00,
"promotionReduce": 12.00,
"matchedRule": { "threshold": 40, "reduce": 12 }
},
"optimalPath": "B",
"couponReduce": 5.00,
"finalAmount": 28.00,
"details": [
{ "type": "promotion", "subType": 1, "name": "午市满减(满40减12)", "reduce": 12.00 },
{ "type": "coupon", "name": "满30减5(同享券)", "reduce": 5.00 }
],
"availableCoupons": [
{ "id": 10, "name": "满30减5(同享券)", "couponType": 1, "isMutex": 0, "threshold": 30.00, "amount": 5.00 },
{ "id": 11, "name": "满30减5(互斥券)", "couponType": 1, "isMutex": 1, "threshold": 30.00, "amount": 5.00 }
]
}
}
算价接口 Response 示例 2(互斥券场景):
{
"code": 200,
"data": {
"originalAmount": 45.00,
"pathA": { "label": "折扣", "subtotal": 39.00, "promotionReduce": 6.00 },
"pathB": { "label": "满减", "subtotal": 33.00, "promotionReduce": 12.00 },
"optimalPath": "B",
"couponId": 11,
"couponName": "满30减5(互斥券)",
"couponConflict": true,
"conflictNote": "互斥券不可与满减叠加,选择此券将取消满减优惠",
"finalAmount": 40.00,
"details": [
{ "type": "coupon", "name": "满30减5(互斥券)", "reduce": 5.00 }
]
}
}
算价接口 Response 示例 3(第二份半价场景):
{
"code": 200,
"data": {
"originalAmount": 60.00,
"pathA": {
"label": "第二份半价",
"items": [
{ "productId": 3, "originalPrice": 20.00, "finalPrice": 20.00, "discountNote": "第1件原价" },
{ "productId": 3, "originalPrice": 20.00, "finalPrice": 10.00, "discountNote": "第2件半价" }
],
"subtotal": 50.00,
"promotionReduce": 10.00
},
"pathB": { "label": "无满减活动", "subtotal": 60.00, "promotionReduce": 0 },
"optimalPath": "A",
"finalAmount": 50.00,
"details": [
{ "type": "promotion", "subType": 3, "name": "第二份半价(可乐)", "reduce": 10.00 }
]
}
}
创建满减活动:
{
"storeId": 1,
"type": 1,
"name": "午市满减",
"startTime": "2026-06-01 10:00:00",
"endTime": "2026-06-30 22:00:00",
"rules": [
{ "threshold": 20.00, "reduceAmount": 5.00 },
{ "threshold": 40.00, "reduceAmount": 12.00 },
{ "threshold": 60.00, "reduceAmount": 20.00 }
]
}
创建折扣活动:
{
"storeId": 1,
"type": 2,
"name": "夏季折扣",
"startTime": "2026-06-01 00:00:00",
"endTime": "2026-06-30 23:59:59",
"rules": [
{ "discountRate": 0.40, "productId": 101 },
{ "discountRate": 0.40, "productId": 102 },
{ "discountRate": 0.70, "productId": 201 }
]
}
创建满减券:
{
"storeId": 1,
"name": "满30减5",
"couponType": 1,
"totalCount": 100,
"validDays": 7,
"startTime": "2026-06-01 00:00:00",
"endTime": "2026-06-30 23:59:59",
"rule": {
"isMutex": 0,
"threshold": 30.00,
"amount": 5.00
}
}
参见 spec.md 的 Data Model 章节,6 张表完整 DDL 已在 spec 中定义。
表关系:
promotion_activity 1 → N promotion_activity_rule
promotion_coupon_batch 1 → 1 promotion_coupon_rule
promotion_coupon_batch 1 → N promotion_user_coupon
| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| 折扣类型前端分组概念(已废弃→改为逐商品设折扣) | 原设计按折扣率分组管理商品,参考美团后改为逐商品设折扣 | 分组概念增加商家认知负担,美团实际不使用分组 |
| 4种活动类型共用一个创建对话框 | 商家从同一个入口创建不同类型的活动 | 每种类型独立页面会导致页面冗余 |