ThinkPHP /d 过滤器把 UUID 截成前导数字,查库永远 not found
后端接口返回"记录不存在",前端开发说 id 绝对是对的,我在 MySQL 里手查一条条都能找到。把日志翻了三遍,SQL 没报错,ORM 也没抛异常——接口就是静默地返回"不存在"。
这种"明明存在却找不到"的 bug,最消耗精力,因为所有中间环节看起来都正常。
现象
- 前端 POST
id=01f619b087e846cab9cb5001cfd5d22c(一个 varchar(32) UUID) - 接口返回
{"code": 0, "msg": "记录不存在"} - 直接在 MySQL 执行
SELECT * FROM tbl WHERE id='01f619b087e846cab9cb5001cfd5d22c'→ 一行数据完好 - 其他接口用同一个 id 查同一张表,完全正常
- 日志里没有任何报错,ORM 的
find()返回null,平静得像什么都没发生
根因
ThinkPHP 的 input("post.id/d", 0) 中的 /d 修饰符,会把入参强制转换为整型,底层就是 PHP 的 (int) 强转。
PHP 对字符串做 (int) 时的行为:从头开始解析,遇到第一个非数字字符就停下,返回已解析到的部分。
(int)"01f619b087e846cab9cb5001cfd5d22c" // => 1 ("01" 解析完遇到 'f' 停止)
(int)"7c8a2f9b" // => 7 ("7" 解析完遇到 'c' 停止)
(int)"abc123" // => 0 (首字符就是字母,直接返回 0)
(int)"206444" // => 206444 (全数字,正常)于是 $id = input("post.id/d", 0) 把 UUID 01f619b0... 截成了整数 1。
接下来 ->where("id", $id) 生成 WHERE id = 1,打到 varchar(32) 列上。MySQL 隐式类型转换:把整数 1 转成字符串 "1" 去比较——表里没有 id 恰好是 "1" 的行,于是 find() 返回 null,接口报"不存在"。
全程零报错、零警告,ORM 认为查询本身是合法的,只是没查到数据。
input("post.id/d") 对 varchar UUID 是隐性破坏。前几位是 "01" 就变成整数 1,前几位是字母就变成 0。接口永远返回"不存在",没有任何错误日志。
快速诊断:
SHOW COLUMNS FROM <table> WHERE Field='id';
# Type 是 varchar(...) → 你中招了
# Type 是 bigint/int → /d 是正确的,去别处找原因正解
把 /d(整型)改成 /s(字符串),并相应调整空值判断:
// BEFORE — 对 varchar UUID 主键是错的
$id = input("post.id/d", 0);
if ($id <= 0) {
$this->error("缺少 id");
}
// AFTER — 正确写法
$id = trim((string)input("post.id/s", ""));
if ($id === "") {
$this->error("缺少 id");
}几个细节:
/s对字符串是无操作,但写出来表明了意图——这个字段是 varchar,不要做数值解释trim()防御剪贴板带来的\r\n或 URL 参数里的空格- 用
=== ""而不是empty()——empty("0")是true,但"0"在某些 schema 里可以是合法 id
改完之后 $id 持有完整的 UUID 字符串,ORM 生成 WHERE id = '01f619b0...',能正确命中行。
第二个更隐蔽的坑:出口 DTO 里的 (int) 强转
改完入口的 /d 之后,如果"第一个请求成功、第二个请求(confirm / edit / delete)又 404 了"——这时候要看出口。
控制器返回 DTO 时,常见的写法:
// BROKEN — 出口强转
return [
"id" => isset($row["id"]) ? (int)$row["id"] : 0, // ← UUID 又被截成 1
"name" => $row["name"],
// ...
];这条路更隐蔽:第一个请求(列表 / 预览)在内存里已经有 $row,其他字段全部渲染正确,看上去成功了。但响应体里 "id": 1 被前端缓存,下一次"确认"操作带着 id=1 去请求,后端找不到——即使入口已经修了 /d,最终还是静默 404。
Chrome DevTools 的 Network 面板一眼就能发现:Response body 里 "id": 1,而 MySQL 实际值是 '01f619b0...'。
修法同理:
return [
"id" => isset($row["id"]) ? (string)$row["id"] : "",
// ...
];修完入口的 /d 之后,立即 grep 同一文件里的出口强转:
grep -nE '\(int\)\$[a-zA-Z_]+\["id"\]|intval\(\$.*\["id"\]\)' controller.php如果 id 列确实是 bigint AUTO_INCREMENT,保留 /d 反而能防止 SQL 注入(强制整型)。改之前先确认列类型,不要一键替换。
一句话外卖
ThinkPHP 的
/d过滤器和(int)强转对 varchar UUID 是无声的数据破坏——入口修一处还不够,出口 DTO 里的(int)$row["id"]是等着你的第二个坑。