This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,199 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user