返回博客

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 认为查询本身是合法的,只是没查到数据。

坑:/d 过滤器把 UUID 截成前导数字

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
正解:/d 改 /s,空值判断改 === 空字符串

改完之后 $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
反模式提醒:不要盲目把所有 /d 改成 /s

如果 id 列确实是 bigint AUTO_INCREMENT,保留 /d 反而能防止 SQL 注入(强制整型)。改之前先确认列类型,不要一键替换。

一句话外卖

ThinkPHP 的 /d 过滤器和 (int) 强转对 varchar UUID 是无声的数据破坏——入口修一处还不够,出口 DTO 里的 (int)$row["id"] 是等着你的第二个坑。