30 答疑:SQL、输入验证、金额类型与回归测试

对应项目:Customer Controller、验证规则、Customer Service。

1. TS 检查通过,为什么仍查错?

TypeScript 不能完整验证业务与 SQL。以下都可能通过编译:

类型解决“很多错误在运行前可发现”,不等于“所有逻辑都已正确”。

2. 一个字段报错,沿着 SQL 找到所属表

错误思路:

Customer.findAll({ where: { country_id } });

即使因宽类型/构建器绕过 TS,数据库也会看到类似:

SELECT ... FROM customer AS Customer
WHERE Customer.country_id = 20;

Customer 没有这一列。正确路径是 customer.address_id → address.city_id → city.country_id,国家筛选放到 City include;若筛选客户主体,沿途 required=true。见 22 章。

检查当前数据库实际结构,不要只相信 Model:

SHOW CREATE TABLE customer;
SHOW CREATE TABLE address;
SHOW CREATE TABLE city;
SHOW INDEX FROM customer;

这些都是只读诊断。错误返回给客户端时不应泄漏整段 SQL、连接信息或内部表结构。

3. matchedData 不负责转换,也不负责补默认值

完整流程:

请求原始值
  → validator 校验
  → sanitizer 转换(例如 toInt)
  → validateRequest 拒绝失败请求
  → matchedData 提取已处理字段
  → Controller 白名单
  → Service 默认值与业务检查
query("page")
  .optional()
  .custom((value: unknown) =>
    typeof value === "string" || typeof value === "number")
  .bail()
  .isInt({ min: 1 })
  .bail()
  .toInt();

toInt() 转换;matchedData() 提取转换后的结果。matchedData<{ page: number }> 只是类型声明,不会自动执行 Number,也不会让可选 page 变成必填。

若缺 page,按规则可能未出现在提取结果;Controller 或 Service 用 ?? 1 明确补默认。.default() 是 sanitizer,不是泛型的作用;它的执行与 optional 跳过规则有关,不能想当然“写在后面就一定给缺失值默认”。

Express 5 的 req.query 是 getter,反复读取可能拿到新解析结果;不要依赖校验器对 req.query 原地修改持久存在。项目从 matchedData 读取转换结果。req.body 通常由 express.json 等解析中间件挂在请求上,与 query getter 机制不同,但提取已验证字段仍有价值。

关于链顺序详见项目 express-validator 系列。

4. 父对象参与校验,matchedData 不保证删掉所有子字段

body("customer").isObject();
body("customer.first_name").isString();

若输入包含:

{
  "customer": {
    "first_name": "ANN",
    "customer_id": 100,
    "unexpected": "extra"
  }
}

父对象本身是一个通过校验的字段,提取时可能连带它的其他 children。不能把完整父对象展开进 Customer.create,以为它已被裁成白名单。

当前 Controller 明确构造 customer/address 输入;Service 再明确构造数据库写入列。这样额外 customer_id 不会被写入。

TS interface 也不会自动删除运行时多余字段。请求类型标注不是安全过滤器。

5. undefined、null、false、0 各是什么?

输入 PATCH email active 正整数 ID
没提供 保持原值 创建时采用业务默认 必填时拒绝,可选时不筛选
null 明确清空(若允许) 当前接口拒绝 拒绝
false 类型错误 合法“停用” 拒绝
0 类型错误 不等于 JSON boolean 拒绝
空字符串 当前接口拒绝 拒绝 拒绝

.optional() 默认跳过 undefined;.optional({ values: "null" }) 还可跳过 null。把 falsy 都跳过可能把非法 0 当作“没提供”,或把合法 false 丢掉。

nullable 的 PATCH 配合 matchedData(..., { includeOptionals: true }),保留需要传给 Service 的 email:null。否则跳过的 optional 值可能不在默认提取结果中。缺失值可能出现为 undefined,Controller 仍应按 !== undefined 处理并注意 exactOptionalPropertyTypes。

更新采用:

if (input.email !== undefined) update_data.email = input.email;

而不是 if (input.email),后者不能表达清空。

6. isInt 为什么还可能放过数组?

express-validator 的一些标准验证器会将值转为字符串、对数组元素逐项验证。所有元素是整数,不代表整个输入就是“单个整数”。

因此标量字段先检查形状,再检查内容,再转换:

body("address_id")
  .custom((value: unknown) =>
    typeof value === "number" || typeof value === "string")
  .withMessage("address_id 必须是单个整数")
  .bail()
  .isInt({ min: 1, max: 65_535 })
  .bail()
  .toInt();

数组字段则先 .isArray(),再检查每个元素是否为合法标量整数。不要靠 map(Number) 把嵌套数组、null、空字符串悄悄变成数字。

对 JSON boolean,当前 active 用 typeof value === "boolean",因为“可以解释成布尔值的字符串”与真正 boolean 是两个契约。

7. payment_total 和 open_rental_count 都是 string,原因相同吗?

不完全相同,见 25 章:

0.1 + 0.2 是 JS 二进制浮点的一个典型问题,但不是全部原因。9007199254740993 一旦先变成不安全 number,再调用 String 也恢复不了原值。

计数在安全范围内可以转 number;超出时保留 string 或使用明确的大整数类型处理,JSON 不能直接序列化 bigint。金额运算则需十进制或整数最小货币单位方案,并定义币种与舍入规则。

SQL 返回 string、DTO 返回 string 是合理的 API 设计,不是类型没有修完。前端展示时也不要直接反复 Number 累加总金额。

8. 从日志到 DTO:按这个顺序排查

输入及验证后的值
  → 实际 SQL 与 transaction 标识
  → 数据库源记录/表结构
  → mysql2 返回值及 typeof
  → Model 关联与 get() 值
  → DTO 映射
  → HTTP 响应

开发时可以局部记录非敏感字段的值与类型;不要 console.log 整个 req、JWT、密码、Model 或完整 SQL 参数。

SQL 语义对了再用 EXPLAIN 检查性能:

EXPLAIN
SELECT customer_id, last_name
FROM customer
WHERE store_id = 1 AND active = 1
ORDER BY last_name, customer_id
LIMIT 20;

观察扫描方式、估计行数和额外排序,再结合数据量与真实耗时判断。出现 filesort 不等于必定灾难,也不要为了每种排序增加一个索引。

9. Mock 测试与真实 MySQL 测试分别证明什么?

测试层 适合验证 不能单独证明
验证链/Controller false、null、数组、默认、白名单、鉴权挂载 真实 SQL 与数据库行为
Service Mock 调用顺序、transaction 传递、错误映射、DTO FK、事务隔离、mysql2 类型、锁
真实 MySQL 集成 回滚、约束名、DECIMAL/BIGINT、实际 JOIN 所有并发交错与所有业务输入
双连接并发测试 锁等待、竞争写入、统一协议 所有未来写入口自动安全

项目已有 tests/sakila-regressions.ts,可阅读其内存回归检查思路。它不等于完整的数据库集成测试。更完整的策略见 16 章。

建议优先覆盖这次实际问题:

  1. 国家筛选只返回该国客户。
  2. 姓氏匹配的停用客户不会绕过 active 条件。
  3. email:null 真正清空;active:false 不被默认值覆盖。
  4. 标量 ID 拒绝数组、null、0、1junk。
  5. 新地址创建后客户写入失败,两表都无残留。
  6. 两次替换同一演员关系不会混出半套结果。
  7. 两笔同额支付都计入总额;没有租赁的计数为 0。
  8. 最近租赁按日期及主键稳定排序。

数据库写入测试在独立测试库运行,用自己创建的数据,不修改重要 Sakila 样本。测试并发时至少两个连接;单个事务包住整个测试不能证明跨事务竞争。

10. 复盘方式:一次只改一个层次

先写预期 SQL 和预期结果,再检查 options;先确认值是什么,再决定类型;先明确业务不变量,再选择事务/锁。

推荐复习顺序:20 WHERE → 22 include → 24 表达式 → 26 事务场景 → 27 并发锁。