ProductReadyProductReady

错误处理

ProductReady 应用程序的全面错误处理模式

错误处理

ProductReady 实现了多层错误处理策略,涵盖客户端、服务端和 API 错误。


概述

层级技术错误类型
客户端React Error Boundary渲染错误、组件崩溃
路由Next.js not-found.tsx404 页面
APItRPC + 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 状态使用场景
UNAUTHORIZED401未登录
FORBIDDEN403无权限
NOT_FOUND404资源不存在
BAD_REQUEST400无效输入
CONFLICT409资源重复
INTERNAL_SERVER_ERROR500服务器错误

服务端抛出错误

// 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 签名验证
  • 带上下文的错误日志
  • 用户友好的错误消息

On this page