# Implementation Plan: 促销 + 优惠券系统 **Branch**: `008-promotion-coupon` | **Date**: 2026-05-29 | **Spec**: [spec.md](./spec.md) **Input**: Feature specification from `/specs/008-promotion-coupon/spec.md` ## Summary 为 foodie 商家端新增促销活动管理(满减/折扣商品/第二份半价/新客立减 4 种类型)和优惠券管理(满减券/商品券/免配送费券),商家可在后台创建和管理促销活动与优惠券。旧促销/优惠券代码(SalesPromotion、VipQuanyi)不动,全部新建文件、新表(promotion_ 前缀)、新接口。前端在商家端侧边栏新增独立的「营销管理」菜单。用户端(小程序)的领券、下单优惠计算不在本范围。 **技术方案**:后端跟随现有 CRUD 分层(Entity + MyBatis XML Mapper + Service + Controller),6 张新表 + 2 个商家端 Controller + 2 个用户端 Controller(领券/查券 + 算价计算)。前端新增 2 个 Vue 页面,促销活动创建页面用 el-tabs 切换 4 种活动类型表单,每种类型有不同的表单字段和数据提交结构。折扣类型采用**逐商品设折扣**方式(参考美团),每个商品独立设置折扣率,不使用折扣区分组。 ## Technical Context **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_order_promotion) **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 用户端) ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* 本项目 constitution 未配置具体规则(模板状态),跳过此检查。 ## Project Structure ### Documentation (this feature) ```text specs/008-promotion-coupon/ ├── spec.md # 功能规格(已完成) ├── plan.md # 本文件 — 实施计划 └── tasks.md # 任务列表(后续生成) ``` ### Source Code (repository root) ```text # 后端 (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/ 目录。不新建子目录,与现有文件组织方式一致。 ## Key Design Decisions ### 1. 折扣类型采用逐商品设折扣(参考美团) 折扣商品(type=2)采用**逐商品设折扣**方式(参考美团/饿了么),不使用「折扣区/折扣档位」分组概念。 商家操作流程: 1. 点击「添加商品」打开商品选择弹窗,勾选参与折扣的商品 2. 选中后每个商品在列表中独立一行,可分别设置折扣率(10%~99%,步长0.1,-/+按钮或直接输入) 3. 折后价实时计算显示 4. 同一商品只能添加一次 前端提交的 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` ### 2. 活动状态管理 活动状态使用显式字段(0=未开始, 1=进行中, 2=已结束),在创建时根据 startTime 自动设置。不使用定时任务自动更新状态(简化实现),列表查询时按 status 字段过滤即可。 ### 3. 商品选择弹窗复用 现有的 `Quanyi.vue` 中已有商品选择弹窗模式(分类下拉 + 搜索 + 商品表格 + 分页)。PromotionActivity.vue 和 CouponBatch.vue 复用相同的交互模式和 API 调用方式,不抽取为公共组件(遵循项目现有风格)。 ### 4. 前端提交数据结构 促销活动创建:前端发送完整的 `{ storeId, type, name, startTime, endTime, rules: [...] }` JSON,后端在一个事务中插入 activity + rules。 优惠券创建:前端发送 `{ storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} }` JSON,后端在一个事务中插入 batch + rule。 ## API Contracts ### ShPromotionActivityController | 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:"操作成功" }` | ### ShPromotionCouponController | 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:"操作成功" }` | ### UserPromotionCouponController(用户端 — 领券/查券) | 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 }] }` | ### UserPromotionCalcController(用户端 — 算价计算) | 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"=强制走满减路径,不传或非法值=自动选最优 - 接口需通过登录态获取 userId,用于判断新客立减资格和查询用户可用券 **算价接口 Response 示例 1(满减+优惠券+新客立减)**: ```json { "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(互斥券场景)**: ```json { "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(第二份半价场景)**: ```json { "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 } ] } } ``` ### Request Body Examples **创建满减活动**: ```json { "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 } ] } ``` **创建折扣活动**: ```json { "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 } ] } ``` **创建满减券**: ```json { "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 } } ``` ## Data Model 参见 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 ``` ## Complexity Tracking | Violation | Why Needed | Simpler Alternative Rejected Because | |-----------|------------|-------------------------------------| | 折扣类型前端分组概念(已废弃→改为逐商品设折扣) | 原设计按折扣率分组管理商品,参考美团后改为逐商品设折扣 | 分组概念增加商家认知负担,美团实际不使用分组 | | 4种活动类型共用一个创建对话框 | 商家从同一个入口创建不同类型的活动 | 每种类型独立页面会导致页面冗余 |