Zsens Admin 插件数据库抽象层:我因直接 `wpdb->query` 拼接 IN 子句导致 SQL 语法灾难,重构后才懂"占位符逃逸"的真正含义

插件开发 28 浏览 0 回复 返回上级

上周给 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 内部报错或产生未定义行为
  • 更隐蔽的是:某些情况下数组会被 implodeArray 字符串直接嵌入 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 前都会过一遍:

  1. 占位符数量和参数数量是否严格相等?(substr_count( $sql, '%' ) 快速核对)
  2. 数组参数有没有被打散而不是整体传递?
  3. array_filter 后有没有 array_values 重建索引?(空洞索引会让 array_merge 行为诡异)
  4. 最终 SQL 有没有在沙箱环境用 EXPLAIN 跑一遍?

这个 bug 花了 40 分钟定位,根源是我误以为 prepare 会"智能识别"数组并自动展开——实际上它就是个格式化字符串工具,参数展开是你的责任。

评论0
回复 · 0
还没有回复
微信客服 微信客服