优惠券核销不能直接用 time.Now() 判断过期,因时区不一致、系统时间漂移及并发请求下时间判断不同步,易导致误核销;必须统一用 UTC 时间比对,且从数据库读取 expire_at 后转 time.Time 再调用 Before(time.Now().UTC())。

优惠券核销为什么不能直接用 time.Now() 判断过期?
因为时区、系统时间漂移、并发请求下时间判断不一致,会导致刚过期的券被误核销或已过期的券被误放行。Gin 本身不处理业务时间逻辑,必须在核销前统一用 UTC 时间做比对,且建议从数据库读取 expire_at 后转为 time.Time 再比较,而不是拼 SQL 字符串做时间过滤。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 所有时间字段在数据库存 UTC(如 PostgreSQL 的
TIMESTAMP WITH TIME ZONE),Go 中用time.UTC加载 - 核销前调用
coupon.ExpireAt.Before(time.Now().UTC()),而非time.Now().After(coupon.ExpireAt)(语义更直白,避免双重否定) - 注意 MySQL 驱动默认不启用时区支持,需在 DSN 加
&parseTime=true&loc=UTC
Gin 路由里怎么安全地校验用户身份和券归属?
不能只靠前端传来的 user_id 或 coupon_code,必须在核销接口内重新查库确认:该用户是否真拥有这张未使用的券。常见错误是先查券再查用户,结果券属于别人——顺序反了。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- SQL 查询要带双条件:
WHERE code = ? AND user_id = ? AND status = 'unused',避免SELECT * FROM coupons WHERE code = ?后再做 Go 层校验 - 用
db.Get(&coupon, "SELECT ...")或db.Select().Where("code = ? AND user_id = ?", code, uid).First(&coupon),确保原子性 - 如果用 Redis 缓存券状态,务必设置相同过期时间,并在核销成功后
DEL对应 key,防止缓存与 DB 不一致
并发核销同一张券怎么办?数据库锁还是应用层锁?
用 UPDATE ... WHERE code = ? AND status = 'unused' + 影响行数判断是最轻量、最可靠的方式。别用 Redis 分布式锁或 Go 的 sync.Mutex,前者引入额外依赖,后者在多实例部署下完全无效。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 执行更新后检查
sql.Result.RowsAffected()是否为 1;为 0 表示已被他人抢兑或状态异常 - PostgreSQL 可加
FOR UPDATE,但 Gin 接口响应要求快,优先走乐观锁(即 WHERE 条件+影响行数) - MySQL 注意隔离级别:默认
REPEATABLE READ下不会幻读,但更新时仍需 WHERE 精确匹配,不能只靠主键
核销成功后,哪些状态变更必须写入事务?
至少三件事必须在一个事务里完成:更新优惠券状态、扣减库存(如有)、记录核销日志。漏掉任意一项都会导致数据不一致,比如券标成已使用但日志没写,后续排查无依据。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用
tx := db.Begin()包裹全部操作,defer tx.Rollback(),最后tx.Commit() - 日志表字段至少含:
coupon_code、user_id、used_at(UTC)、ip(从 Ginc.ClientIP()取) - 不要在事务里调外部 HTTP 接口(如发短信),失败会导致整个事务回滚;可改用消息队列异步通知
Gin 本身只是路由和中间件容器,真正的核销逻辑脆弱点都在时间处理、归属校验、并发控制和事务边界这四个地方。最容易被忽略的是:本地开发时用 time.Now() 测试没问题,上线后因服务器时区或 NTP 同步延迟出问题;还有就是把“查到券”当成“能核销”,跳过了用户归属验证。

















