Express 5 + TypeScript + express-validator 练习答案(二)
对应 题目,本册覆盖 9~15 题。第 1 册:基础字段、数组与条件验证。
练习 9:异步邮箱唯一性
const existingEmails = new Set(["alice@example.com", "bob@example.com"]);
async function findEmail(email: string): Promise<boolean> {
await new Promise<void>((resolve) => setTimeout(resolve, 30));
return existingEmails.has(email);
}
const emailUniqueValidation = [
body("email").isEmail().withMessage("email 格式不正确").normalizeEmail().bail()
.custom(async (email: string) => {
const exists = await findEmail(email);
if (exists) throw new Error("该邮箱已注册");
return true;
}),
validationHandler,
];
bail() 位于格式验证之后:格式错误时不执行无意义的异步查询。数据库不可用不是用户字段写错,通常交给错误中间件返回 503(依赖暂时不可用)或 500(未知内部故障),不应伪装成“邮箱不合法”。无论提前查询是否通过,MySQL 都必须有 UNIQUE(email):两个并发请求可同时查到“不存在”,只有数据库约束能裁决最终写入;捕获 duplicate-key 后映射为 409 Conflict。
练习 10:严格字段白名单
import { body, checkExact, matchedData } from "express-validator";
interface CreateProductInput {
name: string;
price: number;
stock: number;
description?: string;
}
const createProductValidation = [
body("name").isString().trim().isLength({ min: 1, max: 100 }),
body("price").isFloat({ min: 0 }).withMessage("price 必须是不小于 0 的数字").toFloat(),
body("stock").isInt({ min: 0, max: 1_000_000 }).toInt(),
body("description").optional().isString().trim().isLength({ max: 2_000 }),
checkExact(undefined, { locations: ["body"] }),
validationHandler,
];
app.post("/api/admin/products", createProductValidation, async (req, res) => {
const input = matchedData(req, { locations: ["body"] }) as CreateProductInput;
const product = await productRepository.create(input);
return res.status(201).json(product);
});
checkExact 必须在字段链之后,否则它还不知道哪些字段已被验证。它会生成 unknown_fields 联合错误,统一处理器必须把字段路径取出,而不能假设所有错误都有 path。
练习 11:用 checkSchema 重写注册
import { checkSchema, matchedData, type Schema } from "express-validator";
const registerSchema = {
name: {
in: ["body"], isString: { errorMessage: "name 必须是字符串" },
trim: true, isLength: { options: { min: 2, max: 50 }, errorMessage: "name 长度必须是 2 到 50" },
},
email: {
in: ["body"], isEmail: { errorMessage: "email 格式不正确" }, normalizeEmail: true,
},
password: {
in: ["body"], isString: { errorMessage: "password 必须是字符串" },
isLength: { options: { min: 8, max: 128 }, errorMessage: "password 长度必须是 8 到 128" },
},
passwordConfirmation: {
in: ["body"], custom: {
options: (value: unknown, { req }) => {
if (value !== req.body.password) throw new Error("两次密码不一致");
return true;
},
},
},
age: {
in: ["body"], optional: true, isInt: { options: { min: 0, max: 150 } }, toInt: true,
},
} satisfies Schema;
app.post("/api/users", [...checkSchema(registerSchema), validationHandler], handler);
checkSchema 适合字段多、规则需要集中扫描的 DTO;链式 API 更适合强调执行顺序、bail()、if() 与复杂跨字段关系。Schema 的跨字段 custom 可用,但会让业务复杂度迅速上升。结论:简单/常规字段用 checkSchema 提升可读性;涉及条件、异步和可读顺序时,链式验证通常更清楚。无论形式如何,Service 和 MySQL 的规则不应消失。
练习 12:统一错误中间件
import type { RequestHandler } from "express";
import { validationResult, type ValidationError } from "express-validator";
type ClientValidationError = { field: string; location: string; message: string };
const resultFactory = validationResult.withDefaults<ValidationError>({ formatter: (error) => error });
function asClientErrors(errors: readonly ValidationError[]): ClientValidationError[] {
const output: ClientValidationError[] = [];
const seen = new Set<string>();
const add = (field: string, location: string, message: unknown) => {
const key = `${location}.${field}`;
if (!seen.has(key)) { seen.add(key); output.push({ field, location, message: String(message) }); }
};
for (const error of errors) {
if (error.type === "field") add(error.path, error.location, error.msg);
else if (error.type === "unknown_fields") {
for (const field of error.fields) add(field.path, field.location, "不允许的字段");
} else if (error.type === "alternative") {
add("_form", "body", error.msg);
} else if (error.type === "alternative_grouped") {
add("_form", "body", error.msg);
}
}
return output;
}
export const validationHandler: RequestHandler = (req, res, next) => {
const result = resultFactory(req);
if (result.isEmpty()) return next();
return res.status(422).json({
code: "VALIDATION_FAILED", message: "请求参数验证失败", errors: asClientErrors(result.array()),
});
};
不要返回 value,其中可能有密码、Token 或整段上传元数据。至少测试:缺 page、page=0、未知 role、email/phone 都缺、两者格式都错、地址第 2 项邮编错误。实际项目也可给 alternative 输出更友好的统一文案,而不展开内部组合细节。
练习 13:multipart 附加字段
multer.single("avatar") 先消费 multipart 流并填充 req.file/req.body;随后验证普通字段。customSanitizer 必须自己捕获 JSON.parse,否则异常会变成未处理错误。
import multer from "multer";
import { body } from "express-validator";
type Metadata = { crop?: { x: number; y: number; width: number; height: number } };
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024, files: 1 } });
const multipartValidation = [
body("displayName").isString().trim().isLength({ min: 1, max: 50 }),
body("metadata").customSanitizer((value: unknown): Metadata | undefined => {
if (typeof value !== "string") return undefined;
try {
const parsed: unknown = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Metadata : undefined;
} catch { return undefined; }
}).custom((value: unknown) => {
if (value === undefined) throw new Error("metadata 必须是 JSON 对象");
return true;
}),
validationHandler,
];
app.post("/api/avatar", upload.single("avatar"), multipartValidation, async (req, res, next) => {
if (!req.file) return res.status(422).json({ code: "VALIDATION_FAILED", errors: [{ field: "avatar", location: "body", message: "必须上传头像" }] });
try {
await inspectImageMagicBytes(req.file.buffer); // 解码/真实内容检查
return res.sendStatus(204);
} catch (error) { return next(error); }
});
express-validator 看不到上传流的大小,也不能凭声明的 MIME 判断真实文件类型;Multer 限制流量,业务层用可信解析器/魔数检查内容,并处理病毒扫描、像素数限制和临时文件清理。
练习 14:数据库约束映射
- 验证层:email 格式、name 非空/长度、age 数字范围、status 枚举。它让用户更早得到清晰的 422。
- MySQL 必须保留:
NOT NULL、UNIQUE、列长度/类型、ENUM(或 CHECK)、主键。它是并发写入与其他服务绕过 API 时的最后防线。 - 邮箱唯一键冲突是资源冲突,通常 409;若故意隐藏账户是否存在的登录/找回密码场景可采用不同业务响应。
- API 放行 100 字符而
VARCHAR(50)拒绝,会产生数据库错误(严格 SQL mode 下),用户得到不稳定的 500;应让两边规则对齐。 - 用 migration 作为 Schema 真源;把字段长度、枚举等共享常量或契约测试纳入 CI;部署时评审 API 验证与 migration 的同一变更。
练习 15:综合订单创建
interface CreateOrderInput {
customerId: number;
items: Array<{ productId: number; quantity: number }>;
couponCode?: string;
shippingAddress: { recipient: string; phone: string; city: string; detail: string };
}
const createOrderValidation = [
body("customerId").isInt({ min: 1 }).toInt(),
body("items").isArray({ min: 1, max: 50 }),
body("items.*.productId").isInt({ min: 1 }).toInt(),
body("items.*.quantity").isInt({ min: 1, max: 999 }).toInt(),
body("items").custom((items: unknown) => {
if (!Array.isArray(items)) return true;
const ids = items.map((item) => (item && typeof item === "object" ? (item as { productId?: unknown }).productId : undefined));
if (new Set(ids).size !== ids.length) throw new Error("商品不能重复");
return true;
}),
body("couponCode").optional().isString().trim().isLength({ min: 1, max: 32 }).toUpperCase(),
body("shippingAddress.recipient").isString().trim().isLength({ min: 1, max: 50 }),
body("shippingAddress.phone").matches(/^1[3-9]\d{9}$/),
body("shippingAddress.city").isString().trim().isLength({ min: 1, max: 50 }),
body("shippingAddress.detail").isString().trim().isLength({ min: 1, max: 200 }),
checkExact(undefined, { locations: ["body"] }),
validationHandler,
];
app.post("/api/orders", createOrderValidation, async (req, res, next) => {
try {
const input = matchedData(req, { locations: ["body"] }) as CreateOrderInput;
const order = await orderService.create(input); // transaction 内锁定库存、校验并写入
return res.status(201).json(order);
} catch (error) { return next(error); }
});
Service 应在同一事务中:确认 customer/product 存在与可售、验证优惠券、用条件更新或行锁防止超卖、创建订单与订单项、扣库存。库存检查不能只做异步 custom:验证与写入之间存在并发时间窗,两个请求都可能看到库存充足;事务、锁/条件更新和受数据库保护的库存不变量才是正确边界。
最终复盘
Request泛型只描述开发时类型,不验证网络输入;Validator 做运行时检查,Sanitizer 转换/规范化值。- 验证链只收集错误;
validationResult/中间件才负责响应。 optional()是“字段不存在时跳过后续链”,不是“存在任意值都合法”。bail()可避免后续昂贵校验(数据库、网络)在前置规则失败后运行。matchedData构造白名单 DTO;它的泛型不是自动推导业务接口的证明。checkExact拒绝未知字段,matchedData默认忽略它们;二者可配合使用。- 异步唯一性查询改善提示,唯一索引保证并发正确性。
- 语法/形状在验证层,跨资源与状态规则在 Service,持久化不变量在 MySQL。