This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+199
View File
@@ -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");
}
}