30 答疑:SQL、输入验证、金额类型与回归测试
对应项目:Customer Controller、验证规则、Customer Service。
1. TS 检查通过,为什么仍查错?
TypeScript 不能完整验证业务与 SQL。以下都可能通过编译:
- 按 customer.country_id 筛选,但 country_id 实际属于 city。
- 把 district 赋给 address2,二者都是 string。
- 加 LIMIT 10,却没有按租赁时间排序。
- 给 nullable 邮箱定义 string,Raw SELECT 泛型不会核实真实返回。
- 挂载路由时忘了 JWT 中间件。
类型解决“很多错误在运行前可发现”,不等于“所有逻辑都已正确”。
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 章:
- DECIMAL 金额保留 string,主要为了准确表达十进制和精度。
- Raw COUNT 的 BIGINT 类结果受 bigNumberStrings 配置影响,保留 string 避免大整数精度丢失。
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 章。
建议优先覆盖这次实际问题:
- 国家筛选只返回该国客户。
- 姓氏匹配的停用客户不会绕过 active 条件。
- email:null 真正清空;active:false 不被默认值覆盖。
- 标量 ID 拒绝数组、null、0、
1junk。 - 新地址创建后客户写入失败,两表都无残留。
- 两次替换同一演员关系不会混出半套结果。
- 两笔同额支付都计入总额;没有租赁的计数为 0。
- 最近租赁按日期及主键稳定排序。
数据库写入测试在独立测试库运行,用自己创建的数据,不修改重要 Sakila 样本。测试并发时至少两个连接;单个事务包住整个测试不能证明跨事务竞争。
10. 复盘方式:一次只改一个层次
先写预期 SQL 和预期结果,再检查 options;先确认值是什么,再决定类型;先明确业务不变量,再选择事务/锁。
推荐复习顺序:20 WHERE → 22 include → 24 表达式 → 26 事务场景 → 27 并发锁。