不能只用Token::check(),因其为一次验证即销毁的防抖机制,不绑定请求内容,无法保证“相同请求多次进来只执行一次”的幂等语义;刷新页面或重试时token失效导致误判提交失败,仅适用于低频人工表单,不适用API、回调等自动重试场景。

ThinkPHP 本身不提供开箱即用的防重复提交机制,必须自己搭中间件 + Redis 实现「生成-校验-删除」原子操作。靠 Token::check() 或数据库唯一索引,拦不住业务逻辑重复执行。
为什么不能只用 Token::check()?
ThinkPHP 自带的 Token::check() 是表单防抖,不是幂等控制:它验证一次就销毁 token,且不绑定请求内容。用户刷新页面后 token 失效,重试直接失败;而支付回调、移动端重试等场景需要的是「相同请求多次进来,只执行一次」——语义完全不同。
常见错误现象:Token::check() 通过后业务执行中崩溃,token 已删但逻辑没走完,用户重试时因无 token 被拒,误以为提交失败。
- 它适合低频、人工触发的表单(如后台新增文章)
- 不适合 API 接口、异步回调、自动重试类场景
- 若非要混用,必须在
Token::check()通过后立刻补一层Cache::remember()幂等锁
必须用 Lua 脚本保证「校验并删除」原子性
TP6.0 的 Cache::store('redis')->get() 和 delete() 是两个独立网络调用,高并发下必然出现「两个请求同时读到 token 存在,都放行」的问题。
立即学习“PHP免费学习笔记(深入)”;
正确做法是把判断和删除写进 Lua 脚本,由 Redis 单线程执行:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
中间件里这样调用:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
$tokenKey = 'idempotent:' . $request->header('X-Idempotent-Token');
$result = $this->redis->eval(file_get_contents(APP_PATH . 'common/redis/idempotent.lua'), [$tokenKey], 1);
注意:$result === 1 才算校验通过,别用 == true 判断。
Token 生命周期和存储结构怎么设?
key 必须加前缀隔离作用域,避免和其他业务 key 冲突;过期时间不是越长越好:
- 推荐 key 格式:
idempotent:.$request->header('X-Idempotent-Token') - 过期时间设 5–10 分钟:太短易因网络延迟导致前端重试失败;太长占 Redis 内存,且失效延迟高
- token 值必须是加密随机串,比如
bin2hex(random_bytes(16)),别用时间戳或自增 ID,防止被预测 - 生成时用
$redis->setex($key, 600, $tokenValue),不要用SET+EXPIRE两步
中间件注册位置和 header 处理细节
幂等校验必须放在全局中间件里,且执行时机要比路由调度早,否则控制器里的注解或逻辑就晚了。
前端传的 header 名建议统一为 X-Idempotency-Key(注意不是 X-Token),服务端要提前过滤空值和空白字符:
$key = trim($request->header('X-Idempotency-Key'));
if (empty($key) || strlen($key) < 16 || strlen($key) > 64) {
return json(['code' => 'TOKEN_INVALID'])->code(409);
}
命中幂等缓存时,必须返回原始成功响应体(HTTP 200 + 原始 JSON),不能简单返回 ['code'=>0],否则前端无法区分「真成功」和「幂等返回」。
真正容易被忽略的点是:token 必须和请求指纹强绑定。如果接口参数含时间戳、随机数字段,这些得在生成指纹前剔除,否则同一业务请求每次 token 都不同,幂等就失效了。


















