Files
app-261004/lib/api/response.ts
T
2026-10-08 11:09:37 +08:00

200 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}