Feature Branch: 008-promotion-coupon
Created: 2026-05-29
Status: Draft
Input: 参考美团外卖商家端营销活动机制,为 foodie 系统实现促销和优惠券功能
foodie 系统目前缺少商家端的营销工具。商家无法设置满减、折扣等促销活动,也无法发放优惠券。需要参考美团外卖的机制,实现一套适合 foodie 的促销 + 优惠券系统。
系统中已有旧的促销/优惠券代码,全部废弃不使用,不修改:
| 旧代码 | 位置 | 说明 |
|---|---|---|
SalesPromotion.java |
ruoyi-system/domain | 旧促销活动实体,表 sales_promotion |
VipQuanyi.java |
ruoyi-system/domain | 旧优惠券模板,表 vip_quanyi |
VipUserQuanyi.java |
ruoyi-system/domain | 旧用户券,表 vip_user_quanyi |
SalesPromotionController |
ruoyi-admin/app/promotion | 平台管理端促销接口 |
ShSalesPromotionController |
ruoyi-admin/app/mendian | 商家端促销接口 |
VipUserQuanyiController |
ruoyi-admin/app/user | 用户端优惠券接口 |
原则:所有新功能新建文件、新表、新接口,不动旧代码。
所有新建数据库表使用 promotion_ 前缀,与旧表区分:
| 新表名 | 说明 |
|---|---|
promotion_activity |
促销活动表(替代旧 sales_promotion) |
promotion_activity_rule |
促销规则明细表 |
promotion_coupon_batch |
券批次表(替代旧 vip_quanyi) |
promotion_coupon_rule |
券规则表 |
promotion_user_coupon |
用户券表(替代旧 vip_user_quanyi) |
只有两部分:
用户端(小程序)的前端界面不在本 spec 范围内,后续单独做。但后端提供的用户端接口(FR-011~022)在本期实施。
美团外卖商家端有16种营销活动,分为三类:
本需求只实现以下功能:
现有促销(4种):满减、折扣商品(逐商品设折扣)、第二份半价、新客立减
2026-10-19 追加(需求已确认,功能未开始):满额免运,在促销活动中配置,用户满足门槛自动享受,详见 FR-028~FR-036。本轮交付后端和商家 PC;App 代码不动,代码实现交由 Claude Code,直接使用现有分支。
不做:爆品、买赠、无门槛减配送费、阶梯减配送费、好友助力等;满额免运按 2026-10-19 追加规则纳入。
优惠券:只做"店内领券"一种发放方式
不做:店外发券、下单返券、集点返券、收藏有礼、售卖代金券
促销不是都作用于同一个价格层级:
| 层级 | 作用对象 | 包含的促销类型 |
|---|---|---|
| 订单级 | 订单总价 | 满减、新客立减 |
| 商品级 | 指定商品单价 | 折扣商品、第二份半价 |
| 配送费级(2026-10-19 追加,待实现) | 客户应付配送费 | 满额免运;商家承担配送费,骑手按原规则结算 |
参考美团做法,用户下单时系统自动计算两条路径,选最优惠的:
商品原价 → 促销(满减/折扣/第二份半价,三选一)→ 商家满减券 = 实付金额
商品券单独作用在指定商品上,不与满减券叠加
一个订单最多:1个促销 + 1张满减券
2026-10-19 追加:命中满额免运时,先全额预留免运费用,再按上述既有商品优惠顺序分配剩余预算;满额免运不占商品促销三选一名额。FR-030 的预算优先规则覆盖本节既有顺序中涉及预算分配的部分,功能尚未实现。
同享券门槛基准:满减券的 threshold(使用门槛)基于促销后的金额判断(如促销后¥33,券要求满30,¥33≥¥30 可用)。
商品券与促销互斥规则:商品券对应的商品如果正在参加折扣或第二份半价活动,该商品券不可用于该商品(放入不可用券列表),避免折上折。
商家在后台创建促销活动(满减/折扣/第二份半价/新客立减),活动生效后用户下单时自动享受优惠。未开始的活动可以修改和删除,参考美团商家端交互。
Why this priority: 促销是商家最核心的营销需求,没有促销就没有优惠,后续优惠券也无法叠加使用。
Independent Test: 商家创建一个满减活动(满20减5),用户下单满20元后自动减5元,订单金额正确。
操作权限矩阵(参考美团):
| 活动状态 | 编辑 | 删除 | 结束 |
|---|---|---|---|
| 未开始(0) | ✅ | ✅ | ❌ |
| 进行中(1) | ❌ | ❌ | ✅ |
| 已结束(2) | ❌ | ❌ | ❌ |
Acceptance Scenarios:
商家创建优惠券(满减券/商品券/免配送费券),设置库存和有效期。用户进店时看到领券区,领取后下单使用。未开始和进行中的优惠券可以修改,未开始的可以删除。
Why this priority: 优惠券在促销基础上进一步促进复购和转化,但依赖促销系统先完成。
Independent Test: 商家创建"满30减5"优惠券100张,用户进店领取,下单满30元时选择使用,实付减5元。
操作权限矩阵(参考美团):
| 券状态 | 编辑 | 删除 | 下架 |
|---|---|---|---|
| 未开始(0) | ✅ | ✅ | ❌ |
| 进行中(1) | ✅ | ❌ | ✅ |
| 已结束(2) | ❌ | ❌ | ❌ |
| 已下架(3) | ❌ | ❌ | ❌ |
Acceptance Scenarios:
用户在结算页看到优惠明细,系统自动选择最优方案,用户可手动切换。
Why this priority: 结算页的优惠展示和计算是用户最终看到的结果,需要在促销和优惠券都完成后实现。
Independent Test: 用户选好商品进入结算页,页面展示优惠明细(满减/折扣自动选最优 + 可用券列表),选择券后实付金额实时更新。
Acceptance Scenarios:
WHERE remain_count > 0,只有一人成功,其余返回"已领完"BigDecimal,中间过程不截断,最终结果 ROUND_HALF_UP(四舍五入)保留2位小数expire_time = 领取日期 + valid_days 天后的 23:59:59(如6月1日领券,valid_days=7,则6月8日 23:59:59 过期)expire_time < NOW() 则标记为已过期(status→2),不依赖定时任务商家在现有“营销管理 → 促销活动”中创建满额免运,设置门槛、开始日期和结束日期,并按现有活动状态查看、编辑、删除或结束。这里增加的是自动生效的促销类型,不另外创建券批次、领券入口或新菜单。App 页面及 App 代码均不在本轮范围。
Independent Test: 商家 PC 创建原价满100免运的活动;后端完整报价和创建订单使用同一金额规则。商品原价100、配送费30、预算20时,客户付100,商家承担30,骑手配送毛收入基数仍为30。实际支付、退款、发票及订单优惠快照按实付核对。
Acceptance Scenarios:
docs/research-meituan-discount-product.mdUPDATE remain_count = remain_count - 1, received_count = received_count + 1 WHERE id = #{id} AND remain_count > 0,affected rows=0 则返回"已领完"max(0, 总预算 - 免运金额);免运已用完或超过预算时,这些优惠不再追加,不能把全免改成部分减运费。验收示例(预算开关开启、比例20%,仅列受预算限制的优惠):
| 商品原价 | 配送费 | 总预算 | 免运金额 | 其余优惠最多可用预算 |
|---|---|---|---|---|
| 100 | 30 | 20 | 30(全免) | 0 |
| 100 | 20 | 20 | 20(全免) | 0 |
| 100 | 10 | 20 | 10(全免) | 10 |
商品原价未达到门槛时,不触发满额免运,沿用既有优惠规则。上述规则为本项目用户确认的业务选择,不作为美团现行规则的描述。
deliveryFee,前端传入当前配送费,后端计算 min(coupon.amount, deliveryFee) 作为实际减免额B 为按既有比例和取整规则计算的总优惠预算,F 为本单免运金额:客户配送费全免 F,即使 F > B 也不得截断;其余受限优惠按既有顺序使用 max(0, B - F),F >= B 时不再追加受限优惠。切换商品促销路径或互斥券路径时不得释放已预留的免运费用;预算开关关闭时不封顶后续优惠,现有第二份半价预算豁免继续保留。满额免运独立于原有商品促销三选一名额。FR-031: 满额免运成本 MUST 由商家承担,客户应付配送费为0,骑手配送费按原规则结算。算价、下单金额及优惠快照 MUST 区分配送费原金额、商家承担的免运金额与客户实付,不得通过清零骑手配送费或从商品金额重复扣减来实现免运。
FR-032: 本轮 MUST 包含商家 PC 的满额免运创建、编辑回填、详情、列表类型展示和结束入口,使用现有“促销活动”页面及四种语言;MUST NOT 新增必须领券的免运流程、修改 App 源码、改动平台 PC 或继续实施已暂停的 036 多店下单。
FR-033: 新金额流程 MUST 由服务端核实商品/规格、门店归属、数量、收货地址和配送费,预览与创建复用同一能力;支付只用已固化的客户应付额。报价变化必须返回可识别错误要求重新报价,不得仅记日志后接受客户端伪造或过期的优惠金额。
FR-034: 旧算价接口 finalAmount MUST 保持商品应付金额含义。完整配送报价及订单创建的新契约见 contracts/free-delivery.md;未接入该契约的旧 App 请求保留旧行为,本轮仅完成商家配置和后端可调用能力,App 的完整免运结算展示另行交付。
FR-035: 订单创建后 MUST 固化免运金额和规则快照;活动结束及支付方式切换不得追改已有金额。到付客户待收=客户应付总额;取餐给商家金额=客户应付总额−原配送费,若为负则方向改为商家给骑手其绝对值。在线已付时商家给骑手原配送费;现金交付和账单/钱包不得重复支付同笔配送费。佣金、税仍依既有规则计算。
FR-036: 命中满额免运时免配送费券 MUST 标为不可用,不重复减免、不核销;因预算不足实减为0的普通券同样不核销。新流程的商品优惠汇总不包含免运,发票原有“不含运费”口径应扣除客户实际支付的配送费,退款上限按实际付款,不将商家承担的免运补贴额外退给用户。
POST /app/userPromotionCalc/deliveryQuote MUST 支持全部用餐类型,成为唯一报价入口:平台骑手外送(orderType=0 且非自配送)评估满额免运;商家自配送订单 MUST 按同一跳表口径返回运费 R 且免运不评估(F=0、D=R、A=G+R);自取/堂食(orderType=1/2)MUST NOT 要求收货地址并固定返回 R/F/D=0、payableAmount=G;促销/优惠券/预算规则全部场景一致。未知 orderType 或门店不存在仍返回 unsupportedOrder。版本2下单仍限外送(type=0,含自配送门店,其免运快照 F=0)。旧 /calculate 行为不变,待 App 版本换代后废弃。food_spec_relation 为唯一读取源:App 菜单/详情、满额免运报价与版本2订单 food JSON 的规格选项与加价 MUST 同源;MUST NOT 回退读取 pos_food.food_sku。存量 food_sku 数据 MUST 先经回填脚本(updatesql/backfill_food_spec_relation.py,幂等)迁入关联表,再部署去回退版本。规格/商品校验错误文案 MUST 携带商品ID 以便定位。所有新建表使用 promotion_ 前缀,与旧表(sales_promotion、vip_quanyi、vip_user_quanyi)完全独立。
CREATE TABLE promotion_activity (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
store_id BIGINT NOT NULL COMMENT '门店ID',
type TINYINT NOT NULL COMMENT '类型: 1=满减 2=折扣 3=第二份半价 4=新客立减',
name VARCHAR(100) NOT NULL COMMENT '活动名称',
status TINYINT DEFAULT 0 COMMENT '0=未开始 1=进行中 2=已结束',
start_time DATETIME NOT NULL COMMENT '开始时间',
end_time DATETIME NOT NULL COMMENT '结束时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_store_type (store_id, type)
) COMMENT '促销活动表';
CREATE TABLE promotion_activity_rule (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
activity_id BIGINT NOT NULL COMMENT '关联促销活动',
product_id BIGINT DEFAULT NULL COMMENT '商品ID(折扣/第二份半价用)',
threshold DECIMAL(10,2) DEFAULT NULL COMMENT '满减门槛(满X元)',
reduce_amount DECIMAL(10,2) DEFAULT NULL COMMENT '减免金额(满减/新客立减)',
discount_rate DECIMAL(3,2) DEFAULT NULL COMMENT '折扣率(0.70=7折 / 0.50=半价)',
min_quantity INT DEFAULT NULL COMMENT '最低数量(第二份半价=2)',
INDEX idx_activity (activity_id)
) COMMENT '促销规则明细';
CREATE TABLE promotion_coupon_batch (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
store_id BIGINT NOT NULL COMMENT '门店ID',
name VARCHAR(100) NOT NULL COMMENT '券名称',
coupon_type TINYINT NOT NULL COMMENT '1=满减券 2=商品券 3=免配送费券',
total_count INT NOT NULL COMMENT '发放总量',
remain_count INT NOT NULL COMMENT '剩余数量',
received_count INT DEFAULT 0 COMMENT '已领取数量',
status TINYINT DEFAULT 0 COMMENT '0=未开始 1=进行中 2=已结束 3=已下架',
start_time DATETIME NOT NULL COMMENT '领取开始时间',
end_time DATETIME NOT NULL COMMENT '领取结束时间',
valid_days INT NOT NULL COMMENT '领取后有效天数',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_store_status (store_id, status)
) COMMENT '券批次表(模板)';
CREATE TABLE promotion_coupon_rule (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
batch_id BIGINT NOT NULL COMMENT '关联券批次',
product_id BIGINT DEFAULT NULL COMMENT '商品ID(商品券用,满减券=NULL)',
is_mutex TINYINT DEFAULT 0 COMMENT '0=同享券 1=互斥券(仅满减券使用;商品券此字段为NULL,互斥逻辑由促销活动判断)',
threshold DECIMAL(10,2) DEFAULT NULL COMMENT '使用门槛:满减券=促销后订单金额门槛,商品券=该商品小计金额门槛(单价×数量)',
amount DECIMAL(10,2) DEFAULT NULL COMMENT '减免金额(满减券/抵用券)',
discount_rate DECIMAL(3,2) DEFAULT NULL COMMENT '折扣率(商品折扣券用)',
INDEX idx_batch (batch_id)
) COMMENT '券规则';
CREATE TABLE promotion_user_coupon (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL COMMENT '用户ID',
batch_id BIGINT NOT NULL COMMENT '券批次ID',
store_id BIGINT NOT NULL COMMENT '门店ID',
status TINYINT DEFAULT 0 COMMENT '0=未使用 1=已使用 2=已过期 3=冻结',
order_id BIGINT DEFAULT NULL COMMENT '使用的订单ID',
receive_time DATETIME NOT NULL COMMENT '领取时间',
use_time DATETIME DEFAULT NULL COMMENT '使用时间',
expire_time DATETIME NOT NULL COMMENT '过期时间',
INDEX idx_user_status (user_id, status),
INDEX idx_store (store_id)
) COMMENT '用户领券记录';
下单时快照记录每个订单命中的优惠信息,优惠类型级别(每条记录 = 一种优惠),不做 SKU 级分摊。由算价接口在下单时写入。
CREATE TABLE pos_order_promotion (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT NOT NULL COMMENT '关联订单ID',
promo_type TINYINT NOT NULL COMMENT '优惠类型: 1=促销活动 2=商家优惠券',
promo_sub_type TINYINT DEFAULT NULL COMMENT '促销子类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 (promo_type=1时有效)',
promo_id BIGINT DEFAULT NULL COMMENT '促销活动ID(promo_type=1) 或 券批次ID(promo_type=2)',
user_coupon_id BIGINT DEFAULT NULL COMMENT '用户券ID (仅优惠券 promo_type=2 时有值, 关联 promotion_user_coupon.id)',
promo_name VARCHAR(200) NOT NULL COMMENT '快照名称: 如"午市满减(满40减12)"',
promo_detail VARCHAR(500) DEFAULT NULL COMMENT '快照详情JSON: 如{"threshold":40,"reduce":12}',
reduce_amount DECIMAL(10,2) NOT NULL COMMENT '减免金额',
path_summary VARCHAR(500) DEFAULT NULL COMMENT '路径对比摘要, 仅第一条记录有值, 如"满减路径¥33 vs 折扣路径¥39, 选择满减"',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_order (order_id)
) COMMENT '订单优惠明细';
| 字段 | 作用 | 示例 |
|---|---|---|
promo_type |
区分是促销还是优惠券 | 1=促销, 2=优惠券 |
promo_sub_type |
促销的具体子类型 | 1=满减, 2=折扣, 3=第二份半价, 4=新客立减;优惠券时为 null |
promo_id |
关联原始活动或券批次 | promo_type=1 时为 promotion_activity.id;promo_type=2 时为 promotion_coupon_batch.id |
user_coupon_id |
关联用户领取的券实例 | promo_type=2 时为 promotion_user_coupon.id;促销时为 null |
promo_name |
下单时的快照名称 | "午市满减(满40减12)" |
promo_detail |
规则快照 JSON | {"type":"manjian","threshold":40,"reduce":12} |
reduce_amount |
实际减免金额 | 12.00 |
path_summary |
路径对比摘要 | "满减路径¥33 vs 折扣路径¥39, 选择满减",仅第一条记录有值 |
订单:宫保鸡丁¥20 + 鸡腿饭¥25 = ¥45,商家有满减(满40减12)和折扣(宫保鸡丁7折),用户有满30减5同享券,新用户
| promo_type | promo_sub_type | promo_name | reduce_amount | path_summary |
|---|---|---|---|---|
| 1(促销) | 1(满减) | 午市满减(满40减12) | 12.00 | 满减路径¥33 vs 折扣路径¥39, 选择满减 |
| 2(优惠券) | null | 满30减5(同享券) | 5.00 | null |
| 1(促销) | 4(新客立减) | 新客立减 | 3.00 | null |
下单时双写:
pos_order 的 mdSalesReduction、mdDiscountAmount 等汇总字段照常写入pos_order_promotion 写入每条优惠明细(快照)查询场景:
pos_order 汇总字段pos_order_promotion 获取明细path_summary 字段记录系统计算的路径对比结果(如满减 vs 折扣),仅存在该订单的第一条明细记录上,其余记录该字段为 nullmdSalesReduction、mdDiscountAmount 等字段,新表明细作为补充信息促销模块:
promotion_activity 1 → N promotion_activity_rule
优惠券模块:
promotion_coupon_batch 1 → 1 promotion_coupon_rule
promotion_coupon_batch 1 → N promotion_user_coupon
订单优惠:
pos_order 1 → N pos_order_promotion
pos_order_promotion N → 1 promotion_activity (promo_type=1 时)
pos_order_promotion N → 1 promotion_coupon_batch (promo_type=2 时)
pos_order_promotion N → 1 promotion_user_coupon (promo_type=2 时)
SalesPromotion、VipQuanyi、VipUserQuanyi 等旧实体和旧接口全部废弃,新功能完全新建文件promotion_:与旧表(sales_promotion、vip_quanyi)完全独立pos_order_promotion(订单优惠明细表):下单时快照记录优惠明细,单表扁平设计,优惠类型级别,不做 SKU 分摊。本期建表并实现写入逻辑pos_order 表的优惠汇总字段(mdSalesReduction、mdDiscountAmount 等)保留不动,pos_order_promotion 明细表作为补充updatesql/sql.md,由开发者手动执行meituan-promotion-types-demo.html、merchant-promotion-ui-demo.html、meituan-checkout-demo.html用户已确认:促销自动生效、原价门槛、免运预算最高优先且允许超预算、商家承担与骑手取餐收取、保留既有预算豁免;本轮包含商家 PC,App 不动,Claude Code 实施,不建分支。
本次规划选择(供执行者遵守,不冒称用户原话):活动类型采用5、单个门槛;复用活动规则表,不增加券批次;以独立内部配送上下文和新增完整配送报价接口保护旧算价契约;新金额流程用显式版本2接入,旧请求不静默改价;首期只覆盖平台骑手配送的单店外送,自取、堂食、商家自配送、闪送和036合并配送不接入;所有金额与 API 契约、订单快照字段和兼容规则以 plan.md 本次变更及 contracts/free-delivery.md 为准。活动按报价/创建时的本地日期生效,预约单在创建时锁定权益,不等到预约时段再判断活动。
实现状态(2026-10-19 更新,Claude Code 实施):后端与商家 PC 已实现并通过定向测试与模块回归(T074~T077 全部勾选)。type=5 活动 CRUD/归属/行锁/按日期有效状态、三参加价重载与预算优先、POST /app/userPromotionCalc/deliveryQuote、版本2订单固化与真实用券、商家分成 G=A−D、发票 A−D、三方派生交款字段、支付切换方向均落地;SQL 已登记 updatesql/sql.md 末尾待手动执行。未完成:商家 PC 浏览器手工验收(四语言/重叠拒绝等,T078 人工项)、App 端未接入(本轮明确不改)、生产数据库迁移未执行。历史章节中“四种促销”“只看 status=1”“互斥券使用完整预算”“总优惠不超过预算”等说明,在本次新增类型/新金额契约命中时按 FR-028~FR-036 覆盖;未接入新契约的历史行为保留。