200 lines
5.5 KiB
TypeScript
200 lines
5.5 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { ZodError } from "zod";
|
||
|
||
/**
|
||
* 统一的 API 响应格式与错误码。
|
||
*
|
||
* 所有接口遵循同一契约,便于前端与脚本一致地处理:
|
||
*
|
||
* 成功:{ "data": ..., "meta"?: ... }
|
||
* 失败:{ "error": { "code": "...", "message": "...", "details"?: [...] } }
|
||
*
|
||
* skill 要求"显式优于隐式":错误码是机器可读的稳定标识,
|
||
* message 面向人,details 用于字段级校验错误。
|
||
*/
|
||
|
||
/** 机器可读的错误码 */
|
||
export const ErrorCode = {
|
||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||
NOT_FOUND: "NOT_FOUND",
|
||
CONFLICT: "CONFLICT",
|
||
BAD_REQUEST: "BAD_REQUEST",
|
||
INTERNAL_ERROR: "INTERNAL_ERROR",
|
||
METHOD_NOT_ALLOWED: "METHOD_NOT_ALLOWED",
|
||
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
|
||
} as const;
|
||
|
||
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||
|
||
/** 错误码 → HTTP 状态码 */
|
||
const STATUS_BY_CODE: Record<ErrorCodeValue, number> = {
|
||
VALIDATION_ERROR: 400,
|
||
BAD_REQUEST: 400,
|
||
NOT_FOUND: 404,
|
||
CONFLICT: 409,
|
||
METHOD_NOT_ALLOWED: 405,
|
||
INTERNAL_ERROR: 500,
|
||
SERVICE_UNAVAILABLE: 503,
|
||
};
|
||
|
||
export interface ApiErrorDetail {
|
||
/** 出错的字段路径,如 "slug" 或 "tags.0" */
|
||
field?: string;
|
||
message: string;
|
||
}
|
||
|
||
/** 业务异常:路由中抛出,由 withErrorHandling 统一转成响应 */
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
readonly code: ErrorCodeValue,
|
||
message: string,
|
||
readonly details?: ApiErrorDetail[],
|
||
) {
|
||
super(message);
|
||
this.name = "ApiError";
|
||
}
|
||
|
||
static notFound(message = "资源不存在") {
|
||
return new ApiError(ErrorCode.NOT_FOUND, message);
|
||
}
|
||
|
||
static conflict(message: string, details?: ApiErrorDetail[]) {
|
||
return new ApiError(ErrorCode.CONFLICT, message, details);
|
||
}
|
||
|
||
static badRequest(message: string, details?: ApiErrorDetail[]) {
|
||
return new ApiError(ErrorCode.BAD_REQUEST, message, details);
|
||
}
|
||
|
||
static validation(message: string, details?: ApiErrorDetail[]) {
|
||
return new ApiError(ErrorCode.VALIDATION_ERROR, message, details);
|
||
}
|
||
}
|
||
|
||
/** 成功响应 */
|
||
export function ok<T>(data: T, meta?: Record<string, unknown>, status = 200) {
|
||
return NextResponse.json(meta ? { data, meta } : { data }, { status });
|
||
}
|
||
|
||
/** 创建成功(201) */
|
||
export function created<T>(data: T) {
|
||
return NextResponse.json({ data }, { status: 201 });
|
||
}
|
||
|
||
/** 无内容(删除成功) */
|
||
export function noContent() {
|
||
return new NextResponse(null, { status: 204 });
|
||
}
|
||
|
||
/** 失败响应 */
|
||
export function fail(
|
||
code: ErrorCodeValue,
|
||
message: string,
|
||
details?: ApiErrorDetail[],
|
||
) {
|
||
return NextResponse.json(
|
||
{ error: { code, message, ...(details ? { details } : {}) } },
|
||
{ status: STATUS_BY_CODE[code] },
|
||
);
|
||
}
|
||
|
||
/** 把 Zod 的校验错误转成字段级 details */
|
||
function zodToDetails(error: ZodError): ApiErrorDetail[] {
|
||
return error.issues.map((issue) => ({
|
||
field: issue.path.join(".") || undefined,
|
||
message: issue.message,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 从错误对象中提取 Postgres 错误码。
|
||
*
|
||
* 关键点:Drizzle 会把驱动抛出的原始错误包在自己的 `DrizzleQueryError`
|
||
* 里,因此 `error.code` 是 undefined,真正的 PG 错误码在 `error.cause.code`。
|
||
* 这里沿 cause 链向下查找,最多 5 层,避免无限循环。
|
||
*/
|
||
function extractPostgresErrorCode(error: unknown): string | undefined {
|
||
let current = error;
|
||
|
||
for (let depth = 0; depth < 5 && current; depth++) {
|
||
const code = (current as { code?: unknown }).code;
|
||
|
||
// PG 错误码是 5 位数字字符串(如 23505);排除其他库的同名字段
|
||
if (typeof code === "string" && /^\d{5}$/.test(code)) {
|
||
return code;
|
||
}
|
||
|
||
current = (current as { cause?: unknown }).cause;
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* 路由处理器的错误包装器。
|
||
*
|
||
* 统一处理三类异常:
|
||
* 1. ApiError → 按错误码返回
|
||
* 2. ZodError → 400 + 字段级 details
|
||
* 3. 其他(含数据库错误)→ 500,并把细节写进服务端日志
|
||
*
|
||
* 数据库约束冲突单独识别为 4xx,这对 slug 重复是常见场景。
|
||
*/
|
||
export function withErrorHandling<Args extends unknown[]>(
|
||
handler: (...args: Args) => Promise<NextResponse>,
|
||
) {
|
||
return async (...args: Args): Promise<NextResponse> => {
|
||
try {
|
||
return await handler(...args);
|
||
} catch (error) {
|
||
if (error instanceof ApiError) {
|
||
return fail(error.code, error.message, error.details);
|
||
}
|
||
|
||
if (error instanceof ZodError) {
|
||
return fail(
|
||
ErrorCode.VALIDATION_ERROR,
|
||
"请求参数校验失败",
|
||
zodToDetails(error),
|
||
);
|
||
}
|
||
|
||
const pgCode = extractPostgresErrorCode(error);
|
||
|
||
// 唯一约束冲突
|
||
if (pgCode === "23505") {
|
||
return fail(ErrorCode.CONFLICT, "该 slug 已存在,请换一个", [
|
||
{ field: "slug", message: "该 slug 已被占用" },
|
||
]);
|
||
}
|
||
// CHECK 约束冲突(如非法 status/slug 格式)
|
||
if (pgCode === "23514") {
|
||
return fail(
|
||
ErrorCode.VALIDATION_ERROR,
|
||
"数据不满足数据库约束,请检查 status 与 slug 格式",
|
||
);
|
||
}
|
||
// 非空约束冲突
|
||
if (pgCode === "23502") {
|
||
return fail(ErrorCode.VALIDATION_ERROR, "缺少必填字段");
|
||
}
|
||
// 字符串长度超限
|
||
if (pgCode === "22001") {
|
||
return fail(ErrorCode.VALIDATION_ERROR, "字段长度超出限制");
|
||
}
|
||
|
||
console.error("[api] 未处理的异常:", error);
|
||
return fail(ErrorCode.INTERNAL_ERROR, "服务器内部错误");
|
||
}
|
||
};
|
||
}
|
||
|
||
/** 解析并校验 JSON 请求体 */
|
||
export async function parseJsonBody(request: Request): Promise<unknown> {
|
||
try {
|
||
return await request.json();
|
||
} catch {
|
||
throw ApiError.badRequest("请求体不是合法的 JSON");
|
||
}
|
||
}
|