跳转至

第 7 章:全栈开发实战

学习时间:6 小时 | 难度:⭐⭐⭐⭐ 高级 | 前置知识:第 6 章,React 基础


本章概览

本章使用 Next.js 15 App Router + tRPC v11 + Drizzle ORM 构建端到端类型安全的全栈应用。整个项目从数据库到前端 UI,全程零 any,类型错误在编码阶段 100% 可见。

学习目标:

  • 掌握 Next.js 15 App Router 的 Server Components 与 Server Actions
  • 使用 tRPC v11 实现前后端共享类型(零 API 文档维护)
  • 掌握 Drizzle ORM 的类型推断与迁移
  • 理解 React Server Components 的数据流
  • 构建带认证的完整 CRUD 应用

7.1 Next.js 15 App Router 核心

Bash
pnpm create next-app@latest fullstack-app \
  --typescript --tailwind --app --src-dir \
  --import-alias "@/*"

cd fullstack-app
pnpm add drizzle-orm @neondatabase/serverless
pnpm add drizzle-kit -D
pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next
pnpm add @tanstack/react-query zod
pnpm add next-auth @auth/drizzle-adapter

App Router 目录结构

Text Only
src/
├── app/                       # Next.js App Router
│   ├── layout.tsx             # 根布局(Server Component)
│   ├── page.tsx               # 首页
│   ├── (auth)/                # 路由组(不影响 URL)
│   │   ├── login/page.tsx
│   │   └── register/page.tsx
│   ├── dashboard/
│   │   ├── layout.tsx         # 仪表盘布局(需要登录)
│   │   ├── page.tsx
│   │   └── posts/
│   │       ├── page.tsx       # 帖子列表
│   │       └── [id]/page.tsx  # 帖子详情
│   └── api/
│       ├── trpc/[trpc]/route.ts  # tRPC HTTP 处理器
│       └── auth/[...nextauth]/route.ts
├── server/
│   ├── db/
│   │   ├── schema.ts          # Drizzle Schema
│   │   └── index.ts           # DB 连接
│   ├── trpc/
│   │   ├── init.ts            # tRPC 初始化
│   │   ├── routers/           # 路由定义
│   │   │   ├── post.ts
│   │   │   └── user.ts
│   │   └── root.ts            # 根路由(合并所有子路由)
│   └── auth.ts                # NextAuth 配置
├── trpc/
│   ├── server.ts              # Server-side helper
│   └── client.ts              # Client-side helper
└── components/
    ├── ui/                    # shadcn/ui 组件
    └── posts/                 # 业务组件

7.2 Drizzle ORM Schema

TypeScript
// src/server/db/schema.ts
import { pgTable, text, timestamp, boolean, uuid, pgEnum } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";

// 枚举类型
export const roleEnum = pgEnum("role", ["user", "admin"]);

// 用户表
export const users = pgTable("users", {
  id:            uuid("id").primaryKey().defaultRandom(),
  name:          text("name").notNull(),
  email:         text("email").notNull().unique(),
  emailVerified: timestamp("email_verified"),
  image:         text("image"),
  role:          roleEnum("role").notNull().default("user"),
  createdAt:     timestamp("created_at").notNull().defaultNow(),
  updatedAt:     timestamp("updated_at").notNull().$onUpdate(() => new Date()),
});

// 帖子表
export const posts = pgTable("posts", {
  id:          uuid("id").primaryKey().defaultRandom(),
  title:       text("title").notNull(),
  content:     text("content").notNull(),
  published:   boolean("published").notNull().default(false),
  authorId:    uuid("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
  publishedAt: timestamp("published_at"),
  createdAt:   timestamp("created_at").notNull().defaultNow(),
  updatedAt:   timestamp("updated_at").notNull().$onUpdate(() => new Date()),
});

// 定义关联关系(用于 Drizzle 的 query API)
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));

// 从 schema 推断 TypeScript 类型
export type User = typeof users.$inferSelect;           // SELECT 时的类型
export type NewUser = typeof users.$inferInsert;        // INSERT 时的类型
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

// src/server/db/index.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

7.3 tRPC v11 — 端到端类型安全 API

tRPC 的核心思想:API 不是 HTTP 接口,而是 TypeScript 函数调用

TypeScript
// src/server/trpc/init.ts — tRPC 初始化
import { initTRPC, TRPCError } from "@trpc/server";
import { auth } from "../auth";
import { db } from "../db";
import { ZodError } from "zod";
import superjson from "superjson";

// Context:每次请求都会创建,包含认证信息和数据库连接
type Context = {
  db: typeof db;
  session: Awaited<ReturnType<typeof auth>> | null;
};

export async function createContext(): Promise<Context> {
  const session = await auth();
  return { db, session };
}

const t = initTRPC.context<Context>().create({
  transformer: superjson, // 支持 Date、Map、Set 等类型序列化
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

// 导出构建块
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;

// 需要认证的 procedure
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({ ctx: { ...ctx, session: ctx.session } }); // session 不再是 null
});

// 需要管理员的 procedure
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
  if (ctx.session.user.role !== "admin") {
    throw new TRPCError({ code: "FORBIDDEN", message: "Admin only" });
  }
  return next({ ctx });
});
TypeScript
// src/server/trpc/routers/post.ts — 帖子路由
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../init";
import { z } from "zod";
import { posts } from "../../db/schema";
import { eq, desc, and, like } from "drizzle-orm";

export const postRouter = createTRPCRouter({
  // 查询所有已发布帖子(公开)
  list: publicProcedure
    .input(z.object({
      page:    z.number().int().positive().default(1),
      limit:   z.number().int().positive().max(50).default(10),
      search:  z.string().optional(),
    }))
    .query(async ({ ctx, input }) => {
      const { page, limit, search } = input;
      const offset = (page - 1) * limit;

      const where = and(
        eq(posts.published, true),
        search ? like(posts.title, `%${search}%`) : undefined,
      );

      const [data, countResult] = await Promise.all([
        ctx.db.query.posts.findMany({
          where,
          with: { author: { columns: { name: true, image: true } } },
          limit,
          offset,
          orderBy: [desc(posts.publishedAt)],
        }),
        ctx.db.select({ count: sql<number>`count(*)::int` })
          .from(posts).where(where),
      ]);

      return {
        posts: data,
        total: countResult[0]?.count ?? 0,
        page,
      };
    }),

  // 获取单篇帖子(公开)
  byId: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ ctx, input }) => {
      const post = await ctx.db.query.posts.findFirst({
        where: eq(posts.id, input.id),
        with: { author: { columns: { name: true, image: true, id: true } } },
      });
      if (!post) throw new TRPCError({ code: "NOT_FOUND" });
      return post;
    }),

  // 创建帖子(需要登录)
  create: protectedProcedure
    .input(z.object({
      title:   z.string().min(5).max(200),
      content: z.string().min(20),
    }))
    .mutation(async ({ ctx, input }) => {
      const [post] = await ctx.db.insert(posts).values({
        ...input,
        authorId: ctx.session.user.id,
      }).returning();
      return post;
    }),

  // 发布/取消发布(只有作者可以操作)
  togglePublish: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.db.query.posts.findFirst({
        where: and(eq(posts.id, input.id), eq(posts.authorId, ctx.session.user.id)),
      });
      if (!post) throw new TRPCError({ code: "NOT_FOUND" });

      const [updated] = await ctx.db.update(posts)
        .set({
          published:   !post.published,
          publishedAt: !post.published ? new Date() : null,
        })
        .where(eq(posts.id, input.id))
        .returning();

      return updated;
    }),
});

7.4 Server Components 与 Server Actions

TypeScript
// src/app/dashboard/posts/page.tsx — Server Component(默认)
import { HydrateClient, trpc } from "@/trpc/server";
import { PostList } from "@/components/posts/PostList";
import { Suspense } from "react";

export default async function PostsPage({
  searchParams,
}: {
  searchParams: Promise<{ page?: string; search?: string }>;
}) {
  const { page = "1", search } = await searchParams;

  // 在服务器上预取数据(避免客户端瀑布请求)
  void trpc.post.list.prefetch({ page: Number(page), search });

  return (
    <HydrateClient>
      <div className="container py-8">
        <h1 className="text-3xl font-bold mb-6">我的帖子</h1>
        <Suspense fallback={<PostsListSkeleton />}>
          {/* PostList 是 Client Component,使用 tRPC hooks */}
          <PostList page={Number(page)} search={search} />
        </Suspense>
      </div>
    </HydrateClient>
  );
}

// src/components/posts/PostList.tsx — Client Component
"use client";
import { trpc } from "@/trpc/client";

interface PostListProps {
  page: number;
  search?: string;
}

export function PostList({ page, search }: PostListProps) {
  // 完整的类型推断!data 的类型来自 postRouter.list 的返回值
  const { data, isLoading } = trpc.post.list.useQuery({ page, search });
  const togglePublish = trpc.post.togglePublish.useMutation({
    onSuccess: () => {
      // 刷新列表
      utils.post.list.invalidate();
    },
  });
  const utils = trpc.useUtils();

  if (isLoading) return <div>Loading...</div>;

  return (
    <ul className="space-y-4">
      {data?.posts.map((post) => (
        <li key={post.id} className="p-4 border rounded-lg">
          <h2 className="font-semibold">{post.title}</h2>
          <p className="text-sm text-gray-500">by {post.author.name}</p>
          <button
            onClick={() => togglePublish.mutate({ id: post.id })}
            className="mt-2 text-sm text-blue-600 hover:text-blue-800"
          >
            {post.published ? "取消发布" : "发布"}
          </button>
        </li>
      ))}
    </ul>
  );
}

// Server Actions:表单提交不需要 API 路由
// src/app/dashboard/posts/new/page.tsx
"use server";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
import { posts } from "@/server/db/schema";
import { redirect } from "next/navigation";
import { z } from "zod";

const CreatePostAction = z.object({
  title:   z.string().min(5).max(200),
  content: z.string().min(20),
});

export async function createPost(formData: FormData) {
  "use server";                                    // 标记为 Server Action
  const session = await auth();
  if (!session?.user) redirect("/login");

  const result = CreatePostAction.safeParse({
    title:   formData.get("title"),
    content: formData.get("content"),
  });

  if (!result.success) {
    return { error: result.error.flatten() };
  }

  await db.insert(posts).values({
    ...result.data,
    authorId: session.user.id,
  });

  redirect("/dashboard/posts");
}

📌 本章小结

技术 作用
Next.js 15 App Router Server Components + Server Actions,减少客户端 JS
tRPC v11 前后端共享 TypeScript 类型,零 REST 文档
Drizzle ORM 类型安全数据库操作,自动推断 Schema 类型
superjson Date/Map/Set 等类型跨网络序列化

Next.js 15 · tRPC v11 · Drizzle ORM · 2025