当前业务规模(IoT 增值服务:云存储、流量包、套餐)采用 模板 + 用户券 + 使用记录 + 生命周期事件 四层模型。
整体模型关系
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 tb_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_type | VARCHAR(64) | 商品类型 | 默认 cinmooreRights=云存储,预留多商品类型扩展(流量包、套餐等) |
| 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时系统自动暂停;运营也可手动暂停 - 暂停 → 上线:运营调整预算/发行量后手动恢复上线
- 上线/暂停 → 下线:运营手动下线,不再接受新的领取请求,已领取的券也无法消费
- 下线 → 上线:运营可重新上线
- 任意 → 删除:软删除,逻辑上不再展示和生效
UserCoupon 用户券
职责
表示用户实际拥有的一张券。例如:
- 张三领取了”满100减20”
生成 tb_user_coupon 记录。
表结构
1 | CREATE TABLE tb_user_coupon |
字段说明
| 字段 | 类型 | 名称 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 雪花 |
| coupon_no | VARCHAR(64) | 券码 | 唯一标识,用户可见的券编号 |
| template_id | BIGINT | 模板ID | 关联 tb_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 已失效]
LOCKED -->|EXPIRED 定时过期| EXPIRED
USED -->|REFUNDED+RETURNED 退款退回| UNUSED
USED -->|REFUNDED 退款不返还| END1((结束))
EXPIRED --> END1
LOCKED → EXPIRED 说明: 当券被锁定后,如果券本身已过期(
expire_time < NOW()),定时过期任务会直接将 LOCKED 状态的券过期为 EXPIRED,同时清除关联的order_id和lock_time。此时订单超时取消流程会尝试unlockCoupon,但由于券已变为 EXPIRED,unlockCoupon校验状态不是 LOCKED 会跳过解锁,这是预期行为。
version 的作用
解决并发问题的乐观锁。
例如:
1 | // 1. 读取券,拿到当前 version |
避免同一张券被多次使用。
CouponUseRecord 用券记录
职责
记录优惠券在某个订单上实际产生了多少优惠。因为订单金额会变化,不能只看 UserCoupon。
表结构
1 | CREATE TABLE tb_coupon_use_record |
字段说明
| 字段 | 类型 | 名称 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 雪花 |
| user_coupon_id | BIGINT | 用户券ID | 关联 tb_user_coupon.id |
| template_id | BIGINT | 模板ID | 关联 tb_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 | 更新时间 |
示例
订单:
1 | 120元 = 12000分 |
优惠券:
1 | 满100减20 |
记录:
1 | order_amount=12000 discount_amount=2000 pay_amount=10000 |
CouponEventLog 生命周期事件
职责
统一记录优惠券的生命周期。用于:
- 审计
- 问题排查
- 运营分析
- 退款恢复
表结构
1 | CREATE TABLE tb_coupon_event_log |
字段说明
| 字段 | 类型 | 名称 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 雪花 |
| user_coupon_id | BIGINT | 用户券ID | 关联 tb_user_coupon.id |
| user_id | BIGINT | 用户ID | 冗余自 tb_user_coupon.user_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 | 更新时间 |
示例
1 | ISSUED |
1 | LOCKED |
1 | USED |
1 | REFUNDED |
1 | RETURNED |
operator_type 默认值规则
| 事件类型 | operator_type | operator_id | 说明 |
|---|---|---|---|
| ISSUED | SYSTEM | - | 系统自动发放 |
| LOCKED | USER | userId | 用户主动下单触发 |
| UNLOCKED | SYSTEM | - | 系统自动解锁(订单取消/超时) |
| USED | USER | userId | 用户支付成功触发 |
| EXPIRED | SYSTEM | - | 定时任务批量过期 |
| REFUNDED | SYSTEM | - | 退款触发 |
| RETURNED | SYSTEM | - | 退回触发 |
注:LOCKED 和 USED 事件由用户主动触发,记录
operatorType=USER和operatorId=userId;其余事件由系统自动触发,记录operatorType=SYSTEM,不记录 operatorId。
运行流程
领取优惠券
graph TD
A[用户领取优惠券] --> B[创建 UserCoupon
status=UNUSED]
B --> C[计算过期时间
expire_time=acquire_time+valid_days]
C --> D[写入 EventLog
event_type=ISSUED]
D --> E[返回领取成功]
筛选可用券(listAvailableCoupons)
此流程负责筛选符合使用条件的券,返回全部可用券列表,并内部标记最优券。
最优券选择也可由calculateOptimalCoupon独立完成(见下方)。
入参:userId、goodsId、paySystemName、copies(购买份数,可选,默认1)
copies参与抵扣金额计算:beforeCouponPrice = priceDel(商品差异化定价) × copies,份数越多,beforeCouponPrice越大,可能影响门槛校验和折扣券的实际抵扣金额。
graph TD
A[查询用户优惠券列表
status=UNUSED 且未过期] --> B{逐一校验}
B --> C{status=UNUSED?}
C -->|否| X[排除该券]
C -->|是| D{在有效期内?}
D -->|否| X
D -->|是| E{匹配商品范围
goods_ids?}
E -->|否| X
E -->|是| F{匹配支付渠道
channels?}
F -->|否| X
F -->|是| G[计算抵扣金额
基于 priceDel × copies]
G --> H[加入可用券列表]
H --> I{列表非空?}
I -->|否| J[返回空列表]
I -->|是| K[标记最优券
抵扣最大优先
金额相同选快过期的]
K --> L[返回可用券列表
isOptimal 已标记]
注:
platforms字段一期未启用,默认全平台通过,故流程图中省略。- 返回列表中每张券的
discountAmount已基于copies计算完成,最优券的isOptimal=true,前端可直接使用,无需再调calculateOptimalCoupon。
选择最优券(calculateOptimalCoupon)
graph TD
A[获取可用券列表] --> B{列表为空?}
B -->|是| C[返回无可用券]
B -->|否| D[逐张计算抵扣金额
基于 beforeCouponPrice]
D --> E[选择抵扣金额最大的券
金额相同时优先选快过期的券]
E --> F[返回最优券及抵扣金额]
一期规则:
couponIds.size() > 1时直接抛出COUPON_ONLY_ONE_ALLOWED异常,限制单券使用。
二期扩展:当stackable=1时,按stack_rule组合多券叠加,每张券独立基于原价计算抵扣。
预算管控逻辑
预算扣减和回退均通过 MQ 异步处理,保证最终一致性。
graph TD
A[优惠券核销成功
MQ消费端处理] --> 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[正常展示]
退回预算回退:优惠券退回时,MQ 消费端(
handleReturn)原子递减used_budget_amount,解析优先级:CouponUseRecord.discountAmount→OrderPay.discountRefs(couponId)→OrderPay.discountValue(一期单券 fallback)。
下单锁券
下单时优惠券处理分为预校验和锁定两步,预校验在创建订单前执行,锁定在创建订单时执行。
graph TD
A[用户提交订单
携带 couponIds] --> B{couponIds 非空?}
B -->|否| Z[无券,走普通下单流程]
B -->|是| V{一期校验
couponIds.size <= 1?}
V -->|否| W[抛出异常
COUPON_ONLY_ONE_ALLOWED]
V -->|是| P[第一步:预校验
validateCouponsForPlaceOrder]
P --> P1[批量查询用户优惠券和模板]
P1 --> P2[逐张校验
归属→状态UNUSED→未过期→模板可用→商品范围→支付渠道]
P2 --> P3{校验通过?}
P3 -->|否| W2[抛出异常
终止下单]
P3 -->|是| Q[第二步:锁定
lockCoupon]
Q --> C[UserCoupon
UNUSED → LOCKED
乐观锁 version+1
一条SQL完成状态+order_id+lock_time+版本递增]
C --> D[写入 EventLog
event_type=LOCKED
before_status=UNUSED
after_status=LOCKED]
D --> F{订单取消?}
F -->|30分钟超时自动取消| G[UserCoupon
LOCKED → UNUSED]
F -->|用户主动取消| G
G --> H[写入 EventLog
event_type=UNLOCKED
before_status=LOCKED
after_status=UNUSED]
H --> I[优惠券回到可用状态
用户可再次使用]
预校验 vs 锁定:
- 预校验(
validateCouponsForPlaceOrder):不锁定券,仅校验可用性,用于在创建订单前提前拦截无效优惠券,避免产生无效订单。校验项:存在性、归属、状态 UNUSED、未过期、模板可用(上线/暂停)、商品范围、支付渠道。- 锁定(
lockCoupon):原子更新 UNUSED → LOCKED,一条 SQL 完成状态变更 + 设置 order_id/lock_time + 版本递增,避免两步更新导致状态/版本回滚。
支付成功
graph TD
A[支付成功回调] --> B[UserCoupon
LOCKED → USED
乐观锁 version+1]
B --> C[写入 EventLog
event_type=USED
before_status=LOCKED
after_status=USED]
C --> D[事务提交后发送 MQ
tag=COUPON_REDEMPTION]
D --> E[MQ消费端异步处理]
E --> F[创建 CouponUseRecord
从 OrderPay 解析金额
幂等:已存在则跳过]
F --> G[原子递增
used_budget_amount += discount_amount]
异步化说明:
CouponUseRecord创建和预算扣减通过 MQ 异步处理,保证主流程(状态变更+事件日志)的响应速度。
- MQ 消息在事务提交后发送(
TransactionSynchronizationManager),确保只有状态变更成功才发消息。handleCouponRedemption消费端采用幂等策略:若CouponUseRecord已存在则跳过,仅执行预算扣减。CouponUseRecord的金额信息从OrderPay.discountRefs解析,优先取actualAmount(实际抵扣),fallback 取discountValue(一期单券场景)。- MQ 发送失败不影响主流程,预算变更由 MQ 重试或补偿任务保证最终一致性。
支付失败
graph TD
A[支付失败回调] --> B[UserCoupon
LOCKED → UNUSED]
B --> C[写入 EventLog
event_type=UNLOCKED
before_status=LOCKED
after_status=UNUSED]
退款
退回优惠券的通用入口为
returnCoupon(couponId),按当前券状态分支处理:
- USED → 走退款完整链路(见方案二)
- LOCKED → 校验订单已取消 + 券未过期后回退(见 LOCKED 状态退回)
- 其他状态 → 直接返回 false,不允许退回
方案一:券不返还
graph TD
A[订单退款] --> B[UserCoupon
USED 保持不变]
B --> C[写入 EventLog
event_type=REFUNDED]
方案二:券返还(USED 状态退回)
graph TD
A[订单退款] --> B[UserCoupon
USED → UNUSED
乐观锁 version+1]
B --> C[写入 EventLog
event_type=RETURNED
before_status=USED
after_status=UNUSED]
C --> D{优惠券是否过期?}
D -->|未过期| E[用户可再次使用]
D -->|已过期| F[UNUSED → EXPIRED
写入 EventLog
event_type=EXPIRED]
F --> G[事务提交后发送 MQ
预算回退]
E --> G
退回后预算回退通过 MQ 异步处理(
handleReturn),原子递减used_budget_amount,保证最终一致性。
LOCKED 状态退回
场景:券已锁定(LOCKED),但对应订单已取消,用户主动调用退回接口将券释放回可用状态。
graph TD
A[用户调用退回接口
returnCoupon] --> B{券状态?}
B -->|USED| C[走方案二
退款退回链路]
B -->|LOCKED| D{对应订单已取消?}
D -->|否| E[退回失败
订单未取消]
D -->|是| F{券是否过期?}
F -->|已过期| G[退回失败
券已过期]
F -->|未过期| H[LOCKED → UNUSED
乐观锁 version+1]
H --> I[写入 EventLog
event_type=UNLOCKED
before_status=LOCKED
after_status=UNUSED]
I --> J[券回到可用状态
用户可再次使用]
B -->|其他| K[退回失败
状态不允许退回]
前置条件:LOCKED 状态退回必须同时满足”订单已取消”和”券未过期”两个条件,否则退回失败。
与 tb_order_pay 的关联
优惠券使用时,抵扣信息会写入 tb_order_pay,新增以下折扣相关字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| original_price | INT | 优惠前价格(分),所有折扣抵扣前的订单金额 |
| discount_value | INT | 实际抵扣总额(分),恒等式:price_cent = original_price - discount_value |
| discount_refs | JSON | 折扣引用数组,每个元素含 type/id/amount/actualAmount |
| price_detail | JSON | 价格计算明细,含原价/差异化定价/份数/4G升级/优惠券等完整计算链路 |
discount_refs JSON 数组格式如下:
1 | [ |
| 字段 | 类型 | 说明 |
|---|---|---|
| type | String | 优惠类型,当前固定为 COUPON |
| id | Long | 用户券 ID(tb_user_coupon.id) |
| amount | Integer | 券面值(分):固定减免金额或折扣券封顶金额 |
| actualAmount | Integer | 实际抵扣金额(分):基于 beforeCouponPrice 计算后的真实抵扣 |
一期:数组最多 1 个元素(单券)。
二期:多券叠加时,每张券对应一个独立元素。关联字段:
tb_order_pay.coupon_price= Σ actualAmount(所有 COUPON 元素之和)。
异常码
| 异常枚举 | 错误码 | 说明 |
|---|---|---|
| COUPON_NOT_FOUND | 5000200 | 优惠券不存在 |
| COUPON_STATUS_INVALID | 5000201 | 优惠券状态无效(非期望状态) |
| COUPON_EXPIRED | 5000202 | 优惠券已过期 |
| COUPON_CONCURRENT_CONFLICT | 5000203 | 优惠券状态已变更,请重试(乐观锁冲突) |
| COUPON_NOT_APPLICABLE | 5000204 | 优惠券不适用于当前商品 |
| COUPON_CHANNEL_NOT_SUPPORTED | 5000205 | 当前支付方式不支持使用优惠券 |
| COUPON_THRESHOLD_NOT_MET | 5000206 | 未达到优惠券使用门槛 |
| COUPON_BUDGET_EXCEEDED | 5000207 | 优惠券预算已用完 |
| COUPON_NOT_OWNED_BY_USER | 5000208 | 非当前用户的优惠券 |
| COUPON_ONLY_ONE_ALLOWED | 5000209 | 仅支持使用一张优惠券 |
最终推荐模型
对于当前项目,建议只保留 4 张核心表:
| 表名 | 职责 |
|---|---|
| tb_coupon_template | 优惠券模板定义 |
| tb_user_coupon | 用户实际拥有的券 |
| tb_coupon_use_record | 核销记录 |
| tb_coupon_event_log | 生命周期事件 |
这样既不会出现十几张营销表导致过度设计,也能满足未来扩展:
- 云存储优惠券
- 流量包优惠券
- 套餐优惠券
- 兑换码
- 新人券
- 活动券
- 邀请码奖励券
- 订阅升级优惠券
后续即使接入 Stripe 订阅升级、套餐促销、自动发券等需求,也无需推翻当前模型。