14 校验、错误、日志与审计
完成登录和业务接口后,需要把分散的校验、错误响应和日志整理成稳定边界。本章不追求大型框架,而是让每个请求都能回答:谁发起、输入是否合法、业务为何失败、服务器内部发生了什么。
学习目标
- 区分前端校验、HTTP 边界校验、Sequelize 校验和数据库约束;
- 使用 express-validator 校验 params、query 和 body;
- 把预期业务错误与未知服务器错误分开;
- 在 HTTP 始终为 200 的前提下记录
businessCode; - 设计管理员关键操作审计;
- 不在响应和日志中泄漏密码、JWT、SQL 和服务器路径。
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;
}
验收:
- 未登录请求记录 requestId,不记录 Token;
- 登录成功后业务请求包含 adminId;
ACTOR_NOT_FOUND虽然 HTTP 200,日志仍按业务失败记录;- 未知异常记录 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
}
需要思考:
- 审计记录是否与业务操作使用同一事务?
- 业务回滚时是否记录失败尝试?
- 审计数据库不可用时,业务是否也失败?
- detail 中如何排除密码和 Token?
没有唯一标准,但必须明确策略。
13. 客户端中断与 headersSent
当下载、Raw 报表或慢查询过程中客户端断开:
- 不要再次调用
res.json(); - 检查
res.headersSent; - 区分 HTTP 响应结束与数据库工作结束;
- 事务写入不能因为客户端断开就处于未知状态;
- 日志应记录请求取消或响应提前关闭。
14. 本章练习
- 为登录、Actor 列表和创建租赁分别编写 ValidationChain。
- 让
pageSize=100000返回VALIDATION_ERROR,同时保持 HTTP 200。 - 将 Sequelize 唯一约束转换为稳定业务码,不返回原始 SQL。
- 让 Pino 能区分
OK与ACTOR_NOT_FOUND。 - 设计并解释
admin_audit_log的三个联合索引。 - 记录一次客户停用审计,确认日志不包含 Authorization。
- 模拟未知异常,确认客户端看不到 stack。
完成标准
[ ] Validator 不承担复杂业务流程
[ ] Service 不信任未经转换的字符串参数
[ ] 所有业务失败有稳定 code
[ ] 未知错误只向客户端返回安全消息
[ ] Pino 记录 requestId、adminId 和 businessCode
[ ] 密码、Hash 和 JWT 不出现在日志
[ ] 关键写操作可以追溯管理员