错误处理
ProductReady 应用程序的全面错误处理模式
错误处理
ProductReady 实现了多层错误处理策略,涵盖客户端、服务端和 API 错误。
概述
| 层级 | 技术 | 错误类型 |
|---|---|---|
| 客户端 | React Error Boundary | 渲染错误、组件崩溃 |
| 路由 | Next.js not-found.tsx | 404 页面 |
| API | tRPC + TRPCError | 验证、认证、业务逻辑 |
| 数据库 | Drizzle ORM | 查询错误、约束 |
| 外部 | try/catch | 第三方 API 失败 |
404 页面
Next.js 全局 404
创建 src/app/not-found.tsx 处理未匹配的路由:
// src/app/not-found.tsx
import { NotFoundView } from "~/components/shared/not-found-view";
export default function NotFound() {
return (
<html lang="zh-CN">
<body className="bg-background">
<NotFoundView fullScreen />
</body>
</html>
);
}共享 NotFoundView 组件
可复用的 404 组件,适用于 Next.js 页面和 SPA 路由:
// src/components/shared/not-found-view.tsx
"use client";
import { Button } from "kui/button";
import { FileQuestion, Home } from "lucide-react";
import Link from "next/link";
interface NotFoundViewProps {
title?: string;
description?: string;
homeUrl?: string;
homeText?: string;
fullScreen?: boolean;
}
export function NotFoundView({
title = "页面未找到",
description = "抱歉,我们找不到您要访问的页面。",
homeUrl = "/",
homeText = "返回首页",
fullScreen = false,
}: NotFoundViewProps) {
return (
<div className={`flex flex-col items-center justify-center px-4 text-center ${
fullScreen ? "min-h-screen" : "min-h-[60vh]"
}`}>
<div className="mb-6 rounded-full bg-muted p-6">
<FileQuestion className="h-12 w-12 text-muted-foreground" />
</div>
<h1 className="mb-2 text-4xl font-bold tracking-tight">404</h1>
<h2 className="mb-4 text-xl font-semibold">{title}</h2>
<p className="mb-8 max-w-md text-muted-foreground">{description}</p>
<Button asChild>
<Link href={homeUrl}>
<Home className="mr-2 h-4 w-4" />
{homeText}
</Link>
</Button>
</div>
);
}
// 特定区域的变体
export function DashboardNotFound() {
return <NotFoundView homeUrl="/dashboard" homeText="返回仪表盘" />;
}
export function SiteAdminNotFound() {
return <NotFoundView homeUrl="/systemadmin" homeText="返回管理后台" />;
}SPA 路由 404 (wouter)
在 wouter Switch 中作为回退使用:
// 在 spa-client.tsx 中
import { DashboardNotFound } from "~/components/shared/not-found-view";
<Switch>
{routes.map((route) => (
<Route key={route.path} path={route.path}>
{route.component}
</Route>
))}
{/* 404 回退 */}
<Route>
<DashboardNotFound />
</Route>
</Switch>tRPC 错误处理
TRPCError 错误码
| 错误码 | HTTP 状态 | 使用场景 |
|---|---|---|
UNAUTHORIZED | 401 | 未登录 |
FORBIDDEN | 403 | 无权限 |
NOT_FOUND | 404 | 资源不存在 |
BAD_REQUEST | 400 | 无效输入 |
CONFLICT | 409 | 资源重复 |
INTERNAL_SERVER_ERROR | 500 | 服务器错误 |
服务端抛出错误
// src/server/routers/posts.ts
import { TRPCError } from "@trpc/server";
export const postsRouter = createTRPCRouter({
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const post = await ctx.db.query.posts.findFirst({
where: eq(posts.id, input.id),
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "文章未找到",
});
}
// 检查所有权
if (post.authorId !== ctx.userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "您没有访问此文章的权限",
});
}
return post;
}),
create: protectedProcedure
.input(createPostSchema)
.mutation(async ({ ctx, input }) => {
// 验证业务规则
if (input.title.length < 3) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "标题至少需要 3 个字符",
});
}
// 检查重复
const existing = await ctx.db.query.posts.findFirst({
where: eq(posts.slug, input.slug),
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "已存在相同 slug 的文章",
});
}
return ctx.db.insert(posts).values(input).returning();
}),
});客户端错误处理
"use client";
import { trpc } from "~/lib/trpc/client";
import { toast } from "sonner";
export function CreatePostForm() {
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
toast.success("文章创建成功");
},
onError: (error) => {
// 处理特定错误码
if (error.data?.code === "CONFLICT") {
toast.error("此 slug 已被使用");
} else if (error.data?.code === "FORBIDDEN") {
toast.error("您没有执行此操作的权限");
} else {
toast.error(error.message || "出错了");
}
},
});
// ...
}认证错误
受保护的 Procedure
// src/server/trpc.ts
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "需要登录",
});
}
return next({ ctx: { ...ctx, userId: ctx.userId } });
});Space 访问错误
export const spaceProcedure = protectedProcedure.use(async ({ ctx, next }) => {
// ... space 解析逻辑 ...
if (!currentSpaceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "无 Space 访问权限。请先创建或加入一个 Space。",
});
}
return next({ ctx: { ...ctx, currentSpaceId } });
});系统管理员错误
export const siteAdminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const admin = await ctx.db
.select()
.from(systemAdmins)
.where(eq(systemAdmins.userId, ctx.userId))
.limit(1);
if (admin.length === 0) {
throw new TRPCError({
code: "FORBIDDEN",
message: "需要系统管理员权限",
});
}
return next({ ctx: { ...ctx, isSiteAdmin: true } });
});表单验证错误
Zod Schema 验证
tRPC 自动使用 Zod 验证输入并返回结构化错误:
// 服务端
export const userRouter = createTRPCRouter({
update: protectedProcedure
.input(z.object({
name: z.string().min(2, "名称至少需要 2 个字符"),
email: z.string().email("无效的邮箱地址"),
}))
.mutation(async ({ input }) => {
// Zod 验证自动进行
// 无效输入会抛出 BAD_REQUEST 并包含字段错误
}),
});// 客户端 - 显示字段错误
const updateUser = trpc.users.update.useMutation({
onError: (error) => {
// Zod 错误包含字段级别的详情
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
Object.entries(fieldErrors).forEach(([field, errors]) => {
toast.error(`${field}: ${errors?.join(", ")}`);
});
}
},
});数据库错误处理
约束违反
import { PostgresError } from "postgres";
export const usersRouter = createTRPCRouter({
create: publicProcedure
.input(createUserSchema)
.mutation(async ({ ctx, input }) => {
try {
return await ctx.db.insert(users).values(input).returning();
} catch (error) {
// 处理唯一约束违反
if (error instanceof PostgresError && error.code === "23505") {
throw new TRPCError({
code: "CONFLICT",
message: "邮箱已被注册",
});
}
throw error;
}
}),
});事务错误
export const ordersRouter = createTRPCRouter({
create: protectedProcedure
.input(createOrderSchema)
.mutation(async ({ ctx, input }) => {
return await ctx.db.transaction(async (tx) => {
// 创建订单
const [order] = await tx.insert(orders).values(input).returning();
// 扣减库存 - 可能失败
const updated = await tx
.update(inventory)
.set({ quantity: sql`quantity - ${input.quantity}` })
.where(and(
eq(inventory.productId, input.productId),
gte(inventory.quantity, input.quantity)
))
.returning();
if (updated.length === 0) {
// 回滚事务
throw new TRPCError({
code: "BAD_REQUEST",
message: "库存不足",
});
}
return order;
});
}),
});外部 API 错误处理
支付提供商错误
export const billingRouter = createTRPCRouter({
createCheckout: spaceProcedure
.input(z.object({ plan: z.enum(["pro"]) }))
.mutation(async ({ ctx, input }) => {
if (!isBillingConfigured()) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "支付功能未配置",
});
}
try {
const checkout = await billingProvider.createCheckout({
userId: ctx.userId,
// ...
});
return { url: checkout.url };
} catch (error) {
console.error("[Billing] 创建结账失败:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "创建结账会话失败",
});
}
}),
});Webhook 错误处理
// src/app/api/billing/webhook/route.ts
export async function POST(req: Request) {
try {
const signature = req.headers.get("x-signature") || "";
const payload = await req.text();
// 验证签名
const isValid = await provider.verifyWebhook({ signature, payload });
if (!isValid) {
console.error("[Webhook] 无效签名");
return NextResponse.json({ error: "无效签名" }, { status: 401 });
}
// 处理事件
const event = await provider.parseWebhookEvent(payload, signature);
// ... 处理事件 ...
return NextResponse.json({ received: true });
} catch (error) {
console.error("[Webhook] 错误:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Webhook 处理失败" },
{ status: 400 }
);
}
}错误日志
服务端日志
// 始终记录带上下文的错误
try {
await someOperation();
} catch (error) {
console.error("[ModuleName] 操作失败:", {
error,
userId: ctx.userId,
input,
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "操作失败",
});
}结构化错误响应
// 不要向客户端暴露内部细节
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "出错了", // 用户友好的消息
cause: error, // 原始错误用于日志(不发送给客户端)
});最佳实践
✅ 应该做
- 使用适当的 TRPCError 错误码
- 提供用户友好的错误消息
- 记录带上下文的错误以便调试
- 在适当的层级处理错误
- 对多步骤操作使用事务
- 使用 Zod schema 验证输入
❌ 不应该做
- 不要向用户暴露堆栈跟踪
- 不要静默吞掉错误
- 不要到处使用通用错误消息
- 不要忘记处理异步错误
- 不要记录敏感数据(密码、令牌)
错误处理检查清单
- 全局 404 页面 (
src/app/not-found.tsx) - SPA 路由 404 回退
- 所有失败情况的 tRPC 错误码
- Zod 表单验证
- 数据库约束处理
- 外部 API 错误处理
- Webhook 签名验证
- 带上下文的错误日志
- 用户友好的错误消息