Zsens Admin 插件数据库抽象层:我因直接 `wpdb->query` 拼接 IN 子句导致 SQL 语法灾难,重构后才懂"占位符逃逸"的真正含义
上周给 Zsens Admin 的日志筛选模块加"批量按用户 ID 过滤",图省事直接字符串拼接,结果线上环境炸出一堆 You have an error in your SQL syntax。今天把错误写法和最终方案拉出来对比,核心问题不是"有没有用预处理",而是占位符数量与参数数组的动态对齐。
错误写法:我自以为的"动态 IN"
// 假设 $user_ids 是从 $_GET['users'] 解析出来的数组
$user_ids = array_map( 'intval', explode( ',', $_GET['users'] ) );
// 我的"聪明"写法:用 implode 造占位符字符串
$placeholders = implode( ', ', array_fill( 0, count( $user_ids ), '%d' ) );
$sql = $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}zsens_logs WHERE user_id IN ({$placeholders}) AND created_at > %s",
$user_ids, // ← 这里我直接把数组塞进去了
'2024-01-01'
);
$results = $wpdb->get_results( $sql );
问题在哪?$wpdb->prepare 的占位符解析是线性扫描的,它不会自动把数组展开成多个参数。上面的 $user_ids 数组被当成一个整体传进去,导致:
- 如果
$user_ids有 3 个元素,实际只传了 2 个参数(数组算 1 个 + 日期字符串 1 个) %d数量是 3 个,参数只有 2 个,prepare内部报错或产生未定义行为- 更隐蔽的是:某些情况下数组会被
implode成Array字符串直接嵌入 SQL
我本地测试时只传了 1 个 user_id,恰好参数数量"碰巧对上",上线后多选就崩。
正确写法:用 call_user_func_array 打散参数
function zsens_get_logs_by_users( array $user_ids, string $since ) {
global $wpdb;
if ( empty( $user_ids ) ) {
return [];
}
// 1. 先净化,确保全是正整数
$user_ids = array_filter( array_map( 'absint', $user_ids ) );
$user_ids = array_values( $user_ids ); // 重建索引,防止 array_filter 留下空洞
// 2. 动态生成占位符段,但不直接嵌入 SQL 字符串
$placeholders = implode( ', ', array_fill( 0, count( $user_ids ), '%d' ) );
// 3. SQL 模板里保留占位符位置
$sql_template = "SELECT * FROM {$wpdb->prefix}zsens_logs
WHERE user_id IN ({$placeholders})
AND created_at > %s
ORDER BY created_at DESC";
// 4. 关键:把所有参数打散成平铺数组,再传给 prepare
$args = array_merge( $user_ids, [ $since ] );
// 5. 用 call_user_func_array 让 prepare 收到正确数量的独立参数
$sql = call_user_func_array( [ $wpdb, 'prepare' ], array_merge( [ $sql_template ], $args ) );
return $wpdb->get_results( $sql );
}
这里最核心的是第 4-5 步。array_merge( $user_ids, [ $since ] ) 把 [1, 5, 9] 和 ['2024-01-01'] 变成 [1, 5, 9, '2024-01-01'],然后 call_user_func_array 相当于:
$wpdb->prepare( $sql_template, 1, 5, 9, '2024-01-01' );
每个 %d 和最后的 %s 都有独立参数对应,prepare 内部的 vsprintf 才能正确工作。
更现代的替代:如果项目允许用 WP 5.8+ 的 IN 简化
// WP 5.8 起支持单个 %s 占位符自动展开数组(仅限 IN 子句)
$sql = $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}zsens_logs WHERE user_id IN (%s) AND created_at > %s",
$user_ids, // 数组直接传,内部会展开
$since
);
但注意这个语法糖只在 IN 子句有效,而且要求数组元素类型一致。我项目要兼容 WP 5.6,所以没用这个,但新项目可以直接上。
踩坑后的自检清单
现在我写任何动态 SQL 前都会过一遍:
- 占位符数量和参数数量是否严格相等?(
substr_count( $sql, '%' )快速核对) - 数组参数有没有被打散而不是整体传递?
array_filter后有没有array_values重建索引?(空洞索引会让array_merge行为诡异)- 最终 SQL 有没有在沙箱环境用
EXPLAIN跑一遍?
这个 bug 花了 40 分钟定位,根源是我误以为 prepare 会"智能识别"数组并自动展开——实际上它就是个格式化字符串工具,参数展开是你的责任。