跳到主要内容

coupon — 优惠券

定位:券模板(直减/满减,admin 发布)、领券(可重建 Redis 计数 + MySQL 事务硬约束)、我的券、下单核销与取消回退;与秒杀互斥。

实现:internal/coupon/

数据模型

coupon_templates(券模板,admin 发布)

字段类型说明
idBIGINT UNSIGNED PK模板 ID
nameVARCHAR(64)模板名
typeVARCHAR(16)direct(直减:满 0 减面额)/ threshold(满减:满 min_amount 减面额)
valueBIGINT UNSIGNED面额(分)
min_amountBIGINT UNSIGNED满减门槛(分;直减为 0,校验门槛 ≥ 面额)
totalINT UNSIGNED发放总量
per_user_limitINT UNSIGNED每人限领(默认 1)
valid_from / valid_untilDATETIME(3)有效期

user_coupons(用户持有的券)

字段类型说明
idBIGINT UNSIGNED PK领取记录 ID
user_id / template_idBIGINT UNSIGNED FK领取人与模板(CASCADE)
statusVARCHAR(16)unused / usedexpired 由读取时按有效期派生(未用且已过 valid_until),不落库

Redis 约定与原子能力

key说明
coupon:claimed:{template_id}总量计数
coupon:peruser:{template_id}:{user_id}每人限领计数
coupon:version:{template_id}总计数已同步的 MySQL 版本
coupon:peruser-version:{template_id}:{user_id}当前用户计数已同步的 MySQL 版本

业务 service 调用 ClaimCoupon 触发类型化计数;缓存适配器内部以 Lua 先把缺失或落后的计数抬升到 MySQL 已领数,再校验有效期窗口 → 检查总量 → 检查每人限领 → 双计数 INCR,并把原始返回码封装为 CouponClaimed / CouponSoldOut / CouponNotInWindow / CouponLimitReached。最终领取结果由 MySQL 事务裁决,且有效期时间在获取模板行锁后采样。每次数据库裁决后调用 SyncCouponCounts,总计数与当前用户计数分别以自身的 MySQL 计数为单调版本按数据库事实覆盖,延迟到达的旧快照不会阻塞另一用户的计数修复。

接口

HTTP(handler/coupon_handler.go)

用户(Bearer):

方法路径说明
GET/api/coupons可领券列表(含当前用户视角状态:claimable / not_started / ended / sold_out / limit_reached;计数取 DB 事实)
POST/api/coupons/:id/claim领取(成功 201 返回用户券)
GET/api/coupons/mine我的券(status 筛选:unused/used/expired + 分页)

admin(Bearer + admin):

方法路径说明
POST / PUT/api/admin/coupons(/:id)发布 / 编辑券模板
GET/api/admin/coupons模板列表(含已领数 claimed_count,供后台展示 已领/总量)

跨模块端口(供 order 结算事务)

端口说明
GetUsable结算前校验:归属当前用户 / 未用 / 在有效期(门槛校验由 order 按订单总额完成——全场券,无商品维度限制)
UseCoupon事务内条件核销(unused→used + 有效期窗口原子校验,并发仅一次成功;失败重查区分已用/过期/不存在)
RollbackCoupon事务内条件回退(used→unused),取消订单时调用

关键流程

领券

POST /api/coupons/:id/claim
→ DB 读模板与已领数(不存在 → 404),作为 Redis 重建基线
→ ClaimCoupon 类型化原子计数;缓存丢失时先从 DB 事实重建
→ MySQL 锁定模板行,在同一事务内重查有效期/总量/限领并落库 user_coupons(unused)
→ Redis 拒绝/故障不单独决定领取结果;与事务结果不一致时按 DB 事实修复双计数

下单核销与回退(order 事务内)

下单:GetUsable 校验 → 事务内 UseCoupon(条件更新,并发仅一次成功)
→ 订单记 discount_amount,应付 = 总额 − 券额(满减门槛在结算时校验,门槛不足 409)
取消:事务内 RollbackCoupon(used→unused 回退)——与取消状态迁移同事务

互斥:秒杀订单不使用优惠券(order_type=seckill 建单不带 coupon_id)。

权威源:docs/DESIGN.md 优惠券、迁移 000004_coupons