返回博客

CSV 字符串被当等值匹配,仪表盘四个接口同时静默返空

某天上线了一个数据仪表盘,产品打开后发现:趋势图、分布图、明细表、导出——全部空白。但页面顶部的"汇总概览"卡片显示正常。后台没有任何报错,所有接口都返回 HTTP 200,只是 data: []total: 0

第一反应是数据问题,翻了半天数据库,发现数据完好无损。

现象

  • 同一时间范围内,汇总接口 /overview 返回正确的总金额和单量
  • 其余四个接口(/list/trend/distribution/export)全部返回空
  • 单值请求 docType=NS 能正常出数据,多值请求 docType=NS,SR,PR,PX 必空
  • 没有 5xx,没有异常日志,纯静默失败

根因

开启慢日志后,看到了这条 SQL:

SELECT * FROM purchase_records
WHERE del_flag = 0
  AND doc_type = 'NS,SR,PR,PX'
  AND doc_date BETWEEN '2026-01-01' AND '2026-03-31'
-- Rows: 0

根因是共用查询构造器里的一行代码:

// ThinkPHP — 共用的 buildPurchaseQuery()
$query->where('doc_type', $params['docType']);

where('col', $value) 在 ThinkPHP / Laravel Eloquent 里生成的是等值匹配 col = ?。前端传来的是逗号分隔的字符串 "NS,SR,PR,PX",整个字符串被绑定为单个参数——而表里任何一行的 doc_type 都不会等于 "NS,SR,PR,PX" 这个字面量,于是永远返回空集。

更糟的是,这个构造器被四个接口共享,一行 bug 同时把四个数据通道全部拦截。

坑:where(col, csvString) 不是 whereIn

$query->where('doc_type', 'NS,SR,PR,PX') 生成的 SQL 是:

WHERE doc_type = 'NS,SR,PR,PX'

不是:

WHERE doc_type IN ('NS', 'SR', 'PR', 'PX')

HTTP 返回 200、无异常、单值 smoke test 通过——三重掩护让这个 bug 能在生产藏几周不被发现。

正解

场景 A:列表 / 明细 / 导出——用 whereIn 过滤子集

// ThinkPHP
$docTypes = array_values(array_filter(
    explode(',', (string)($params['docType'] ?? ''))
));
if (!empty($docTypes)) {
    $query->whereIn('doc_type', $docTypes);
}
// Knex / Node
const docTypes = (params.docType || '').split(',').map(s => s.trim()).filter(Boolean);
if (docTypes.length) query.whereIn('doc_type', docTypes);
<!-- MyBatis -->
<if test="docTypeList != null and docTypeList.size() > 0">
  AND doc_type IN
  <foreach collection="docTypeList" item="t" open="(" separator="," close=")">
    #{t}
  </foreach>
</if>

场景 B:分布图——不要用用户传入的 CSV 去过滤

分布接口的语义是"按类型汇总",如果用用户传来的过滤条件去 WHERE,你正在过滤的恰好是要分组的维度,结果必然全空。应该用枚举硬白名单,或完全不过滤:

// 分布接口:用枚举硬限,不用用户 CSV
$query->whereIn('doc_type', ['NS', 'SR', 'PR', 'PX'])
      ->field('doc_type, COUNT(*) AS cnt, SUM(amount) AS amt')
      ->group('doc_type');

共用构造器防御性重构

让构造器从一开始就接受数组,字符串输入自动拆解,从根源堵住这类 bug:

private function normalizeMultiValue($v): array {
    if (is_array($v)) return array_values(array_filter($v, fn($x) => $x !== ''));
    if (is_string($v) && $v !== '') {
        return array_values(array_filter(explode(',', $v), fn($x) => trim($x) !== ''));
    }
    return [];
}
正解:区分接口语义,分别用 whereIn 或跳过过滤
  • 列表 / 明细 / 导出:explode CSV → whereIn
  • 分布 / 聚合:不用用户传来的分类字段过滤,用枚举硬白名单 GROUP BY
  • 构造器本身:接受 array 并封装 normalizeMultiValue,让调用方没机会传 CSV 字符串

排查捷径

  1. 开启查询日志(ThinkPHP: Db::getLastSql()),直接看生成的 SQL
  2. 看到 WHERE col = 'a,b,c' 就确认是这个坑
  3. 对比:SELECT doc_type, COUNT(*) FROM t GROUP BY doc_type 的总行数 vs 接口 total

别只修一个接口——共用构造器意味着 bug 同域扩散,修完所有消费者之后,还要把构造器本身防御化,否则下一个新接口接进来就会重蹈覆辙。

一句话外卖

多值过滤参数进后端时,先看生成的 SQL 是 = ? 还是 IN (?),前者只匹配字面量,不会自动拆 CSV。