plan.md 16 KB

Implementation Plan: 促销 + 优惠券系统

Branch: 008-promotion-coupon | Date: 2026-05-29 | Spec: 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_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 用户端)

Constitution Check

GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.

本项目 constitution 未配置具体规则(模板状态),跳过此检查。

Project Structure

Documentation (this feature)

specs/008-promotion-coupon/
├── spec.md              # 功能规格(已完成)
├── plan.md              # 本文件 — 实施计划
└── tasks.md             # 任务列表(后续生成)

Source Code (repository root)

# 后端 (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 中只传 productIdquantity不传 price。后端根据 productId 从数据库查询商品实际价格,防止前端篡改
  • couponId: 可选,传入用户券ID则计算使用该券后的价格;不传则不使用券
  • forcePath: 可选,"A"=强制走折扣路径,"B"=强制走满减路径,不传或非法值=自动选最优
  • 接口需通过登录态获取 userId,用于判断新客立减资格和查询用户可用券

算价接口 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 }
    ]
  }
}

Request Body Examples

创建满减活动:

{
  "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
  }
}

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种活动类型共用一个创建对话框 商家从同一个入口创建不同类型的活动 每种类型独立页面会导致页面冗余