14 校验、错误、日志与审计

完成登录和业务接口后,需要把分散的校验、错误响应和日志整理成稳定边界。本章不追求大型框架,而是让每个请求都能回答:谁发起、输入是否合法、业务为何失败、服务器内部发生了什么。

学习目标

1. 四层校验的边界

前端校验
→ 提供即时输入反馈,不能作为安全边界

express-validator
→ 校验外部请求的类型、格式、长度和允许值

Service 业务校验
→ 校验状态流转、权限、关联存在性和跨字段规则

Sequelize / MySQL
→ 保证字段类型、NOT NULL、UNIQUE、CHECK 和外键底线

例如创建管理员:

username 长度和字符范围       → express-validator
当前管理员能否创建账号        → Service
username 是否最终唯一         → MySQL UNIQUE
password_hash 是否允许为空    → MySQL NOT NULL

只做“先查询用户名不存在再创建”仍有并发窗口,最终必须处理唯一约束错误。

2. ValidationChain 练习骨架

路径参数

import { param } from "express-validator";

export const adminIdValidation = [
  param("adminId")
    // TODO:不能为空
    // TODO:必须是正整数
    .bail()
    // TODO:转换为合适形式
];

思考:MySQL BIGINT UNSIGNED 的 ID 为什么不应无条件转换为 JavaScript number?

分页查询

import { query } from "express-validator";

export const pageValidation = [
  query("page")
    .optional()
    // TODO:1~合理上限的整数
  ,
  query("pageSize")
    .optional()
    // TODO:1~100
];

必须验证:

page=0
page=-1
page=abc
pageSize=1000000
pageSize=2.5

登录 Body

import { body } from "express-validator";

export const loginValidation = [
  body("username")
    // TODO:string、trim、长度和字符范围
    .bail(),
  body("password")
    // TODO:登录时不要擅自 trim 用户密码
];

为什么密码不应自动 trim:空格可能本来就是密码的一部分,服务端不能在验证时悄悄改变用户输入。

3. bail() 的使用位置

下面的顺序没有意义:

值根本不是整数
    ↓
仍然执行数据库存在性查询

应先完成便宜的格式校验,再用 bail() 阻止后续昂贵校验:

param("customerId")
  .notEmpty()
  .bail()
  .isInt({ min: 1 })
  .bail()
  .custom(async (value) => {
    // TODO:仅在格式正确后查询数据库
  });

不过,数据库存在性校验是否放在 ValidationChain 中需要权衡。复杂业务更适合放在 Service,否则 validator 会逐渐承担业务职责。

4. validateRequest 中间件契约

export function validateRequest(
  req: Request,
  res: Response<ApiResponse<never>>,
  next: NextFunction,
): void {
  // TODO:读取 validationResult(req)
  // TODO:成功时 next()
  // TODO:失败时返回统一 VALIDATION_ERROR
}

建议错误数据只包含前端需要的信息:

{
  "code": "VALIDATION_ERROR",
  "message": "请求参数不正确",
  "data": null
}

进阶题:是否要在 data 中返回字段错误列表?如果返回,应该定义专门响应类型,而不是破坏 ApiFailure 的固定结构。

5. 业务错误码清单

建议按领域命名,避免只有模糊的 ERROR

AUTH_REQUIRED
INVALID_ACCESS_TOKEN
INVALID_CREDENTIALS
ACCOUNT_DISABLED
ACCOUNT_LOCKED
ADMIN_NOT_FOUND
ADMIN_USERNAME_EXISTS

ACTOR_NOT_FOUND
ACTOR_IN_USE
CUSTOMER_NOT_FOUND
CUSTOMER_INACTIVE
FILM_NOT_FOUND
INVENTORY_NOT_FOUND
INVENTORY_NOT_AVAILABLE
RENTAL_NOT_FOUND
RENTAL_ALREADY_RETURNED

VALIDATION_ERROR
DATABASE_ERROR
INTERNAL_SERVER_ERROR

不要把 MySQL 错误文本直接作为业务错误码或客户端消息。

6. AppError 与全局错误处理器

当前项目已有 AppError 和全局错误处理中间件。练习目标是扩展错误映射,而不是在每个 Controller 重复 try/catch。

export interface ErrorResponseDefinition {
  code: string;
  message: string;
}

export function mapSequelizeError(
  error: unknown,
): ErrorResponseDefinition | null {
  // TODO:UniqueConstraintError
  // TODO:ForeignKeyConstraintError
  // TODO:ValidationError
  // 未识别错误返回 null
}

全局错误处理器必须放在 Router 之后,并保留四参数签名:

export const errorHandler: ErrorRequestHandler = (
  error,
  req,
  res,
  next,
) => {
  if (res.headersSent) {
    next(error);
    return;
  }

  // TODO:AppError
  // TODO:Sequelize 已知错误
  // TODO:记录未知错误
  // TODO:统一 HTTP 200 JSON
};

7. 为什么未知错误不能返回 error.message

未知错误可能包含:

SQL 文本
数据库表和字段
服务器绝对路径
临时文件名
JWT 解析细节
调用栈

客户端只收到:

{
  "code": "INTERNAL_SERVER_ERROR",
  "message": "服务器内部错误",
  "data": null
}

完整错误写入受控服务器日志。

8. 让日志看到业务失败

因为所有响应都是 HTTP 200,Pino 不能只依赖 res.statusCode。可以在统一响应辅助函数中同步设置:

export function sendFailure(
  res: Response<ApiFailure>,
  code: string,
  message: string,
): void {
  res.locals.businessCode = code;
  res.status(200).json({ code, message, data: null });
}

成功时同样设置:

res.locals.businessCode = "OK";

然后让请求完成日志读取:

requestId
adminId
method
path
businessCode
durationMs

不要记录:

password
passwordHash
Authorization 完整内容
JWT
数据库密码
完整 Cookie

9. Pino 请求日志练习

要求完成一个日志上下文接口:

export interface RequestLogContext {
  requestId: string;
  adminId?: string;
  method: string;
  path: string;
  businessCode?: string;
}

验收:

  1. 未登录请求记录 requestId,不记录 Token;
  2. 登录成功后业务请求包含 adminId;
  3. ACTOR_NOT_FOUND 虽然 HTTP 200,日志仍按业务失败记录;
  4. 未知异常记录 stack,但客户端不看到 stack。

10. 审计日志与普通日志的区别

普通日志主要用于排查系统:

连接失败
请求耗时
异常堆栈
SQL 执行错误

审计日志回答业务问题:

谁在什么时候停用了哪个客户?
谁创建了一次租赁?
谁重置了管理员密码?
操作成功还是失败?

日志文件可能轮转或集中采集,关键审计记录可以保存到数据库。

11. 可选表 admin_audit_log

建议在完成主流程后再增加,不阻塞第一阶段登录。

字段 建议类型 说明
id BIGINT UNSIGNED 主键
admin_user_id BIGINT UNSIGNED 操作者,与 admin_user.id 类型一致
action_code VARCHAR(64) CUSTOMER_DISABLE
resource_type VARCHAR(64) customer
resource_id VARCHAR(64) 兼容不同资源 ID 表达
request_id VARCHAR(64) 请求链路 ID
result_code VARCHAR(64) OK 或业务错误码
ip_address VARCHAR(45) IPv4/IPv6 文本
detail JSON 非核心、可变的补充信息
created_at DATETIME(3) 操作时间

稳定查询字段必须独立成列,不能全部塞入 detail

建议索引练习:

(admin_user_id, created_at)
(resource_type, resource_id, created_at)
(action_code, created_at)
request_id 唯一还是普通索引?请按业务语义判断

12. 审计 Service 契约

export interface WriteAuditLogInput {
  adminUserId: string;
  actionCode: string;
  resourceType: string;
  resourceId: string;
  requestId: string;
  resultCode: string;
  ipAddress: string;
  detail?: Record<string, unknown>;
}

export async function writeAuditLog(
  input: WriteAuditLogInput,
  options?: { transaction?: Transaction },
): Promise<void> {
  // TODO
}

需要思考:

没有唯一标准,但必须明确策略。

13. 客户端中断与 headersSent

当下载、Raw 报表或慢查询过程中客户端断开:

14. 本章练习

  1. 为登录、Actor 列表和创建租赁分别编写 ValidationChain。
  2. pageSize=100000 返回 VALIDATION_ERROR,同时保持 HTTP 200。
  3. 将 Sequelize 唯一约束转换为稳定业务码,不返回原始 SQL。
  4. 让 Pino 能区分 OKACTOR_NOT_FOUND
  5. 设计并解释 admin_audit_log 的三个联合索引。
  6. 记录一次客户停用审计,确认日志不包含 Authorization。
  7. 模拟未知异常,确认客户端看不到 stack。

完成标准

[ ] Validator 不承担复杂业务流程
[ ] Service 不信任未经转换的字符串参数
[ ] 所有业务失败有稳定 code
[ ] 未知错误只向客户端返回安全消息
[ ] Pino 记录 requestId、adminId 和 businessCode
[ ] 密码、Hash 和 JWT 不出现在日志
[ ] 关键写操作可以追溯管理员