Files
ClassHelper/packages/server/src/middleware/error.ts
T

98 lines
3.7 KiB
TypeScript
Raw Normal View History

import type { ErrorRequestHandler, RequestHandler } from 'express';
import { ZodError } from 'zod';
import type { ApiErrorResponse } from '@classhelper/shared';
import { ApiError } from '../lib/http.js';
import { logger } from '../lib/logger.js';
import { isDeveloperMode } from '../lib/settings.js';
import { env } from '../config/env.js';
function sendError(
res: Parameters<ErrorRequestHandler>[2],
status: number,
message: string,
code: string,
details?: unknown,
): void {
const body: ApiErrorResponse = { success: false, data: null, message, code };
if (details !== undefined) body.details = details;
res.status(status).json(body);
}
/** 404:未匹配到任何路由 */
export const notFoundHandler: RequestHandler = (req, res) => {
sendError(res, 404, `接口不存在:${req.method} ${req.originalUrl}`, 'NOT_FOUND');
};
/** 统一错误处理:把 Zod / ApiError / Prisma 错误转换成统一响应体 */
export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
if (error instanceof ZodError) {
const details = error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
}));
logger.warn(`参数校验失败:${req.method} ${req.originalUrl}`, details);
sendError(res, 422, '请求参数不合法', 'VALIDATION_ERROR', details);
return;
}
if (error instanceof ApiError) {
if (error.status >= 500) logger.error(error.message, error.details);
/**
* 开发者模式:把 `error.stack` 一并回传,否则"服务端 500"在浏览器里只有一句中文,
* 排查时还得去服务器上翻日志。**500 才带**、且只在管理员显式打开开发者模式时生效
* (开关见 `modules/system`),普通用户看到的东西一个字段都不多。
*/
const withStack =
isDeveloperMode() && error.status >= 500 && error.stack
? {
...(error.details && typeof error.details === 'object'
? (error.details as Record<string, unknown>)
: { details: error.details }),
stack: error.stack,
}
: error.details;
sendError(res, error.status, error.message, error.code, withStack);
return;
}
/**
* 请求体超限(body-parser 抛的 `entity.too.large`)。
*
* 必须单独处理:它带 `status: 413` 但不是 ApiError,落进下面的兜底分支会变成
* 「500 服务器内部错误:request entity too large」——既是错的状态码,也把英文原文抛给用户,
* 而用户真正需要知道的是"文件太大了、请拆分"(表格导入的 8MB 上限见 table-import.service)。
*/
if ((error as { type?: string }).type === 'entity.too.large') {
logger.warn(`请求体超过上限:${req.method} ${req.originalUrl}`);
sendError(
res,
413,
'上传内容过大:请把文件拆分后分批导入(表格文件上限 8MB)',
'IMPORT_TOO_LARGE',
);
return;
}
// Prisma 已知错误码(P2002 唯一约束 / P2025 记录不存在 / P2003 外键约束)
const prismaCode = (error as { code?: string }).code;
if (prismaCode === 'P2002') {
sendError(res, 409, '数据已存在,请检查唯一字段(用户名 / 课程名等)', 'CONFLICT');
return;
}
if (prismaCode === 'P2025') {
sendError(res, 404, '目标记录不存在或已被删除', 'NOT_FOUND');
return;
}
if (prismaCode === 'P2003') {
sendError(res, 400, '关联数据不存在或仍被引用', 'BAD_REQUEST');
return;
}
logger.error(`未处理异常:${req.method} ${req.originalUrl}`, error);
sendError(
res,
500,
env.isProduction ? '服务器内部错误' : `服务器内部错误:${(error as Error).message}`,
'INTERNAL_ERROR',
);
};