优惠券系统采用 模板 + 用户券 + 使用记录 + 生命周期事件 四层模型。
整体模型关系
classDiagram
CouponTemplate "1" --> "N" UserCoupon: 生成
UserCoupon "1" --> "N" CouponEventLog: 记录
UserCoupon "1" --> "0..1" CouponUseRecord: 核销
职责划分:
| 实体 | 职责 |
|---|---|
| CouponTemplate | 定义优惠券规则(满减、折扣、适用范围等) |
| UserCoupon | 用户实际拥有的券,记录状态流转 |
| CouponUseRecord | 记录核销结果(实际优惠金额、实付金额) |
| CouponEventLog | 记录整个生命周期事件,用于审计、排查、退款恢复 |
CouponTemplate 优惠券模板
职责
定义优惠券属性与规则。模板本身不能使用,用户领取后才生成用户券。
类似:
- 满100减20
- 8折券
- 云存储专用50元券
表结构
1 | CREATE TABLE coupon_template |
字段说明
| 字段 | 类型 | 名称 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 雪花 |
| template_code | VARCHAR(64) | 模板编码 | 唯一标识,如 CLOUD_50_OFF |
| template_name | VARCHAR(128) | 模板名称 | 如”云存储50元优惠券” |
| status | TINYINT | 状态 | 枚举:0=草稿、1=上线、2=暂停、3=下线、4=删除,详见下方状态说明 |
| coupon_type | VARCHAR(32) | 优惠类型 | 枚举:FIXED=固定减免、DISCOUNT=折扣券 |
| discount_amount | INT | 固定减免金额 | coupon_type=FIXED 时使用,单位:分。如 2000 表示减20元。DISCOUNT 时为 NULL |
| discount_rate | INT | 折扣率 | coupon_type=DISCOUNT 时使用,单位:千分之。如 800=8折、850=85折。FIXED 时为 NULL |
| threshold_amount | INT | 最低消费金额 | 满减门槛,单位:分。如满100减20则填 10000 |
| max_discount_amount | INT | 最大优惠金额 | 折扣券封顶金额,单位:分。如8折最多减50元则填 5000 |
| total_quantity | INT | 发行总量 | NULL 表示不限量 |
| issued_quantity | INT | 已发放数量 | 领取时累加 |
| budget_amount | INT | 优惠预算上限 | 单券种最大优惠金额预算,单位:分。NULL表示不限预算 |
| used_budget_amount | INT | 已使用预算 | 核销时累加优惠金额,单位:分。达到 budget_amount 时自动暂停发放 |
| valid_type | VARCHAR(32) | 有效期类型 | 枚举:FIXED_DATE=固定时间范围、AFTER_RECEIVE=领取后N天 |
| valid_days | INT | 有效天数 | valid_type=AFTER_RECEIVE 时必填,如30表示领取后30天 |
| valid_start_time | DATETIME | 有效期开始 | valid_type=FIXED_DATE 时必填 |
| valid_end_time | DATETIME | 有效期结束 | valid_type=FIXED_DATE 时必填 |
| claim_start_time | DATETIME | 领取开始时间 | 用户可领取的起始时间 |
| claim_end_time | DATETIME | 领取结束时间 | 用户可领取的截止时间,过期后不可领取 |
| goods_ids | JSON | 适用商品 | 商品ID数组,NULL表示不限商品,如 [1001, 1002] |
| platforms | JSON | 适用平台 | 一期不做,默认全平台。枚举数组:IOS、ANDROID、WEB、MINI_PROGRAM,NULL表示全平台 |
| channels | JSON | 适用支付渠道 | 枚举数组:WECHAT、ALIPAY,NULL表示全支付渠道 |
| stackable | TINYINT | 是否可叠加 | 一期不做,默认不可堆叠。0=不可叠加(默认)、1=可叠加 |
| stack_rule | JSON | 叠加规则 | 一期不做,默认不可堆叠。可叠加时的规则详情,如 {"maxStackCount": 2, "stackableTypes": ["FIXED"]} |
| rule_json | JSON | 扩展规则 | 自定义规则,如 {"newUserOnly": true, "maxReceiveCount": 1} |
| remark | VARCHAR(500) | 备注 | 运营备注 |
| gmt_create | DATETIME | 创建时间 | |
| gmt_modified | DATETIME | 更新时间 |
模板状态说明
| 枚举值 | 状态名称 | 说明 | 可领取 | 可下发 | 已领取可消费 |
|---|---|---|---|---|---|
0 |
草稿 | 编辑状态,模板尚未发布,仍在配置中。不能领取,已领取的不能消费 | ❌ | ❌ | ❌ |
1 |
上线 | 正式状态,模板已发布。用户可正常领取、系统可下发,已领取的券可正常消费 | ✅ | ✅ | ✅ |
2 |
暂停 | 因预算或发放量达到限制而自动/手动暂停。不能领取、不能下发,但已领取的券仍可正常消费 | ❌ | ❌ | ✅ |
3 |
下线 | 相对于上线状态,模板已下线。不能领取,已领取的券也不能消费 | ❌ | ❌ | ❌ |
4 |
删除 | 模板已作废删除。不能领取,已领取的券也不能消费 | ❌ | ❌ | ❌ |
模板状态流转
graph LR
DRAFT[0 草稿] -->|上线| ONLINE[1 上线]
ONLINE -->|预算/发放量达限| PAUSED[2 暂停]
PAUSED -->|运营手动恢复| ONLINE
ONLINE -->|下线| OFFLINE[3 下线]
DRAFT -->|删除| DELETED[4 删除]
ONLINE -->|删除| DELETED
PAUSED -->|删除| DELETED
OFFLINE -->|删除| DELETED
OFFLINE -->|重新上线| ONLINE
状态行为差异
- 草稿 → 上线:运营确认模板配置无误后手动上线,上线后用户才可见、可领取
- 上线 → 暂停:当
used_budget_amount >= budget_amount或issued_quantity >= total_quantity时系统自动暂停;运营也可手动暂停 - 暂停 → 上线:运营调整预算/发行量后手动恢复上线
- 上线/暂停 → 下线:运营手动下线,不再接受新的领取请求,已领取的券也无法消费
- 下线 → 上线:运营可重新上线
- 任意 → 删除:软删除,逻辑上不再展示和生效
其他核心枚举
coupon_type 优惠类型
| 值 | 含义 | 使用的字段 |
|---|---|---|
| FIXED | 固定减免 | discount_amount |
| DISCOUNT | 折扣券 | discount_rate + max_discount_amount |
valid_type 有效期类型
| 值 | 含义 | 使用的字段 |
|---|---|---|
| FIXED_DATE | 固定时间范围 | valid_start_time + valid_end_time |
| AFTER_RECEIVE | 领取后N天 | valid_days |
UserCoupon 用户券
职责
表示用户实际拥有的一张券。例如张三领取了”满100减20”,则生成一条 user_coupon 记录。
表结构
1 | CREATE TABLE user_coupon |
字段说明
| 字段 | 类型 | 名称 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 雪花 |
| coupon_no | VARCHAR(64) | 券码 | 唯一标识,用户可见的券编号 |
| template_id | BIGINT | 模板ID | 关联 coupon_template.id |
| user_id | BIGINT | 用户ID | 领券用户 |
| status | VARCHAR(32) | 状态 | 枚举:UNUSED=未使用、LOCKED=已锁定、USED=已使用、EXPIRED=已过期 |
| acquire_time | DATETIME | 领取时间 | |
| expire_time | DATETIME | 过期时间 | 到期后状态变为 EXPIRED |
| lock_time | DATETIME | 锁定时间 | 提交订单时锁定,支付成功后变为 USED,支付失败回退 UNUSED |
| used_time | DATETIME | 使用时间 | 核销时间 |
| order_id | BIGINT | 订单ID | 使用时关联的订单 |
| version | INT | 版本号 | 乐观锁,默认0 |
| gmt_create | DATETIME | 创建时间 | |
| gmt_modified | DATETIME | 更新时间 |
状态流转
graph LR
START((开始)) -->|ISSUED 发放| UNUSED[UNUSED 未使用]
UNUSED -->|LOCKED 下单锁定| LOCKED[LOCKED 已锁定]
LOCKED -->|UNLOCKED 支付失败| UNUSED
LOCKED -->|USED 支付成功| USED[USED 已使用]
UNUSED -->|EXPIRED 过期| EXPIRED[EXPIRED 已失效]
USED -->|REFUNDED+RETURNED 退款退回| UNUSED
USED -->|REFUNDED 退款不返还| END1((结束))
EXPIRED --> END1
状态说明:
| 状态 | 含义 | 触发场景 |
|---|---|---|
| UNUSED | 未使用 | 用户领取后初始状态 |
| LOCKED | 已锁定 | 提交订单时锁定,防止并发使用 |
| USED | 已使用 | 支付成功后核销 |
| EXPIRED | 已失效 | 超过 expire_time 自动过期 |
乐观锁(version)
解决并发问题。同一张券不能被多次使用:
1 | // 1. 读取券,拿到当前 version |
CouponUseRecord 用券记录
职责
记录优惠券在某个订单上实际产生了多少优惠。因为订单金额会变化,不能只看 UserCoupon。
表结构
1 | CREATE TABLE coupon_use_record |
字段说明
| 字段 | 类型 | 名称 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 雪花 |
| user_coupon_id | BIGINT | 用户券ID | 关联 user_coupon.id |
| template_id | BIGINT | 模板ID | 关联 coupon_template.id |
| user_id | BIGINT | 用户ID | |
| order_id | BIGINT | 订单ID | |
| order_amount | INT | 订单金额 | 原始订单金额(分),与 tb_order_pay.original_price 对齐 |
| discount_amount | INT | 优惠金额 | 券实际减免金额(分) |
| pay_amount | INT | 实付金额 | order_amount - discount_amount(分) |
| use_time | DATETIME | 使用时间 | |
| gmt_create | DATETIME | 创建时间 | |
| gmt_modified | DATETIME | 更新时间 |
示例
订单 120 元 = 12000 分,优惠券满100减20:
1 | order_amount=12000 discount_amount=2000 pay_amount=10000 |
CouponEventLog 生命周期事件
职责
统一记录优惠券的生命周期事件,用于审计、问题排查、运营分析、退款恢复。
表结构
1 | CREATE TABLE coupon_event_log |
字段说明
| 字段 | 类型 | 名称 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 雪花 |
| user_coupon_id | BIGINT | 用户券ID | 关联 user_coupon.id |
| event_type | VARCHAR(32) | 事件类型 | 枚举:ISSUED=发放、LOCKED=锁定、UNLOCKED=解锁、USED=使用、EXPIRED=过期、REFUNDED=退款、RETURNED=退回 |
| before_status | VARCHAR(32) | 变更前状态 | |
| after_status | VARCHAR(32) | 变更后状态 | |
| operator_type | VARCHAR(32) | 操作者类型 | 枚举:USER=用户、SYSTEM=系统、ADMIN=管理员 |
| operator_id | BIGINT | 操作者ID | |
| biz_id | VARCHAR(64) | 业务ID | 如订单号、退款单号等 |
| ext_info | JSON | 扩展信息 | 附加业务数据 |
| event_time | DATETIME | 事件时间 | |
| gmt_create | DATETIME | 创建时间 | |
| gmt_modified | DATETIME | 更新时间 |
事件类型
| 事件 | 含义 | 典型 before→after |
|---|---|---|
| ISSUED | 发放 | → UNUSED |
| LOCKED | 锁定 | UNUSED → LOCKED |
| UNLOCKED | 解锁 | LOCKED → UNUSED |
| USED | 使用 | LOCKED → USED |
| EXPIRED | 过期 | UNUSED → EXPIRED |
| REFUNDED | 退款 | USED → * |
| RETURNED | 退回 | * → UNUSED |
示例
一张券的完整生命周期:
| 时间 | 事件 | 说明 |
|---|---|---|
| 2026-01-01 | ISSUED | 用户领取 |
| 2026-01-10 | LOCKED | 提交订单 |
| 2026-01-10 | USED | 支付成功 |
| 2026-01-15 | REFUNDED | 订单退款 |
| 2026-01-15 | RETURNED | 优惠券退回 |
运行流程
领取优惠券
graph TD
A[用户领取优惠券] --> B[创建 UserCoupon
status=UNUSED]
B --> C[计算过期时间
expire_time=acquire_time+valid_days]
C --> D[写入 EventLog
event_type=ISSUED]
D --> E[返回领取成功]
查询可用券
graph TD
A[查询用户优惠券列表] --> B{status=UNUSED?}
B -->|否| X[排除该券]
B -->|是| C{在有效期内?}
C -->|否| X
C -->|是| D{匹配商品范围
goods_ids?}
D -->|否| X
D -->|是| E{匹配平台范围
platforms?}
E -->|否| X
E -->|是| F{匹配支付渠道
channels?}
F -->|否| X
F -->|是| G{stackable=?}
G -->|stackable = 0| H[同一订单仅可用1张券
自动匹配抵扣金额最高的券]
G -->|stackable = 1| I[按 stack_rule 匹配
可叠加券组合]
H --> J[返回可用券列表]
I --> J
预算管控逻辑
graph TD
A[优惠券核销成功] --> B[used_budget_amount += discount_amount]
B --> C{budget_amount 不为NULL?}
C -->|否| D[继续正常发放]
C -->|是| E{used_budget_amount
>= budget_amount?}
E -->|否| D
E -->|是| F[自动暂停该券种发放
拦截后续领取请求]
D --> G[运营后台展示预算使用率]
F --> G
G --> H{使用率 > 80%?}
H -->|是| I[预警提示]
H -->|否| J[正常展示]
下单锁券
graph TD
A[用户提交订单] --> B[校验优惠券可用性]
B --> C[UserCoupon
UNUSED → LOCKED]
C --> D[写入 EventLog
event_type=LOCKED
before_status=UNUSED
after_status=LOCKED]
D --> E{订单取消?}
E -->|30分钟超时自动取消| F[UserCoupon
LOCKED → UNUSED]
E -->|用户主动取消| F
F --> G[写入 EventLog
event_type=UNLOCKED
before_status=LOCKED
after_status=UNUSED]
G --> H[优惠券回到可领取状态
用户可再次使用]
支付成功
graph TD
A[支付成功回调] --> B[UserCoupon
LOCKED → USED]
B --> C[创建 CouponUseRecord
记录 order_amount / discount_amount / pay_amount]
C --> D[累加 coupon_template.used_budget_amount]
D --> E[写入 EventLog
event_type=USED
before_status=LOCKED
after_status=USED]
支付失败
graph TD
A[支付失败回调] --> B[UserCoupon
LOCKED → UNUSED]
B --> C[写入 EventLog
event_type=UNLOCKED
before_status=LOCKED
after_status=UNUSED]
退款
退款有两种方案,根据模板配置决定:
方案一:券不返还
graph TD
A[订单退款] --> B[UserCoupon
USED 保持不变]
B --> C[写入 EventLog
event_type=REFUNDED]
方案二:券返还
graph TD
A[订单退款] --> B[UserCoupon
USED → UNUSED]
B --> C[写入 EventLog
event_type=REFUNDED
before_status=USED
after_status=UNUSED]
C --> D[写入 EventLog
event_type=RETURNED]
D --> E{优惠券是否过期?}
E -->|未过期| F[用户可再次使用]
E -->|已过期| G[状态变为 EXPIRED
不可再次使用]
模型总结
4 张核心表:
| 表名 | 职责 |
|---|---|
| coupon_template | 优惠券模板定义 |
| user_coupon | 用户实际拥有的券 |
| coupon_use_record | 核销记录 |
| coupon_event_log | 生命周期事件 |
这样既不会出现十几张营销表导致过度设计,也能满足未来扩展:
- 云存储优惠券
- 流量包优惠券
- 套餐优惠券
- 兑换码
- 新人券
- 活动券
- 邀请码奖励券
- 订阅升级优惠券
后续即使接入 Stripe 订阅升级、套餐促销、自动发券等需求,也无需推翻当前模型。