tp5防重复提交需token生成、传输、校验、销毁四环节闭环:须绑定业务上下文、严格校验非空与长度、用session('token', null)清除、配合prg模式、禁用缓存、前端用{__token__/}、数据库加唯一索引兜底。

TP5 自定义函数防重复提交,核心不是加个函数就完事,而是必须让 token 生成、传输、校验、销毁四个环节闭环,且每个环节都避开常见陷阱。
为什么自定义 creatToken() 和 checkToken() 容易失效
你贴出的代码里用 authcode() 拼接固定密钥 + md5 截取,本质是伪随机——mt_rand() 可被预测,md5($key . substr(md5($str),8,10)) 碰撞概率高,且没做空值防御。更关键的是:session('TOKEN') 是全局 key,多个表单共用就会互相覆盖;unset($_SESSION['TOKEN']) 在 ThinkPHP 里不生效,得用 session('TOKEN', null) 才真正清除。
- 生成时没绑定业务上下文,登录页和支付页共用一个
TOKEN,第二个提交必然失败 - 校验前没检查
$_POST['TOKEN']是否存在或为空,空字符串和null会通过==比对(PHP 类型松散) - 没配合 PRG 模式,校验通过后直接
$this->fetch()渲染原页面,用户刷新就是二次 POST
checkToken() 必须加这三步校验
光比对 session 值远远不够。真实生产环境要防多标签、防缓存、防异常分支漏处理:
- 先判断
input('post.TOKEN')是否非空且长度合理(比如 32 位 hex),避免空值绕过 - 再比对
session('TOKEN')是否存在且不为空,防止 session 未写入或已过期 - 比对通过后立刻执行
session('TOKEN', null),而不是session('TOKEN', '')或unset()
示例片段:
if (!input('post.TOKEN') || strlen(input('post.TOKEN')) !== 32) {
$this->error('非法请求');
}
if (session('TOKEN') !== input('post.TOKEN')) {
$this->error('重复提交或表单已失效');
}
session('TOKEN', null); // 关键:清空,不是设空字符串
前端表单必须配合 Cache-Control 和 {__token__/} 替换
ThinkPHP 的 {__token__/} 标签依赖 'validate_token' => true 配置,但默认缓存头是 Cache-Control: private,导致用户点浏览器后退时加载的是旧页面,{__token__/} 还是上一次的值,而 session 里已经清空——必报错。
- 控制器方法开头手动加
header('Cache-Control: no-cache, no-store, must-revalidate'); - 不要手写
<input name="TOKEN" value="{:session('TOKEN')}">,改用{__token__/},它会自动适配配置项和 session 驱动 - 如果用了 Redis 存 session,确认
session('TOKEN')写入和读取走的是同一实例(注意 Redis DB 切换)
数据库层唯一约束是最后防线,不能省
哪怕 token、PRG、按钮禁用全做了,仍可能因网络超时重发、MQ 重投等场景漏掉。订单号、支付流水号、用户操作日志的业务主键,必须加数据库唯一索引。
- 例如订单表加
UNIQUE KEY `uniq_order_no` (`order_no`) - 后端捕获
IntegrityException或 MySQL 错误码 1062,返回「操作已处理,请勿重复提交」 - 别指望 PHP 层 try-catch 兜底所有并发冲突,索引才是原子性保障
真正难的不是写几个函数,而是把 session 生命周期、HTTP 缓存、前端交互、数据库约束这几条线拧成一股绳。任意一环松动,重复提交就会从缝隙里钻出来。











