ThinkPHP 6 实现积分系统关键在于守住一致性边界:必须用 Db::transaction() 原子包裹查余额、查库存、扣积分、减库存、写日志全流程,配合 lock(true) 行级锁、库存 where('stock > 0') 校验、affectedRows 检查及明细日志同事务落库,杜绝负分、超发与日志缺失。

ThinkPHP 6 实现积分系统,关键不在“怎么写增减逻辑”,而在于“如何守住一致性边界”——用户余额、商品库存、操作日志三者必须原子性联动,否则高并发下极易出现积分扣成负数、库存超发、日志缺失等生产事故。
必须用事务包裹全部关键操作
所有涉及积分变动的接口(如兑换、退款、签到发放),都要用 Db::transaction() 包裹。不能只对某一步加事务,而应把「查用户余额」「查商品库存」「扣积分」「减库存」「写日志」全部纳入同一事务中。任一环节失败,整个操作回滚,数据状态始终可预期。
- TP6 默认不开启自动事务,
dec()或inc()是独立 SQL,不参与事务上下文 - 避免手写
$user->score -= $cost; $user->save();—— 这类赋值绕过校验,且无法追溯操作类型和前后值 - 推荐在模型中定义业务方法,如
exchangeGoods($uid, $gid),内部统一调用事务逻辑
关键查询必须加行级锁
读取用户积分或商品库存时,不能只用 where()->find(),必须显式加锁:lock(true)。否则两个请求同时读到 score=100,都判断够扣80,最终可能扣出 -60。
- 正确写法:
Db::name('user')->where('id', $uid)->lock(true)->find() - 库存扣减也需前置校验:
where('stock > 0'),再执行dec('stock'),并检查affectedRows() === 1 - 锁只对 InnoDB 表生效,MyISAM 不支持行锁,务必确认数据库引擎
积分变动必须留痕,不可直接赋值
积分字段(如 score)是结果型字段,只允许由日志表重算得出。每次变动必须写入明细日志,包含:type(1=签到/2=兑换/3=退款)、amount、before、after、remark、log_id。
- 日志表必须与主业务表在同一事务中插入,推荐用
insertGetId()并记录当前事务 ID(如uniqid('log_')) - 可在模型
afterWrite事件中触发日志写入,但注意:该事件在事务提交前触发,所以日志也要走同一事务连接 - 禁止前端传参直接改
score,后端也不应做$user->score = 200类赋值
防重与幂等要前置拦截
用户重复点击兑换按钮,不能依赖前端限制。TP6 需配合 Redis 实现服务端幂等控制:
- 客户端每次请求携带
X-Idempotent-Token请求头 - 中间件中用 Lua 脚本原子执行:
eval "return redis.call('get', KEYS[1]) and redis.call('del', KEYS[1])" 1 idempotent:xxx - Token 键建议设为
"idempotent:" . $token,过期时间 5–10 分钟 - 仅靠数据库唯一索引无法防止业务逻辑重复执行(比如已扣分但未减库存)

















