基于Next.js、Fastify、Prisma和NGINX的实用单体仓库架构

探索一种实用的单体仓库架构,结合Next.js、Fastify、Prisma与NGINX,重点展示实际集成与工作流程。
已发布:
Aleksandar Stajić
已更新: 2026年2月20日 21:41
基于Next.js、Fastify、Prisma和NGINX的实用单体仓库架构

配图

平台单体仓库:Next.js(公共 + 管理端)+ Fastify API + Prisma DB + NGINX

本文档描述了当前目标架构,该架构采用单体仓库,包含一个公共Next.js站点、一个带有Cookie会话的管理端Next.js应用、一个Fastify API以及一个基于Prisma的数据库包。本文档面向需要接触多个包并需要明确边界的团队成员。

我们暂不妥协的约束条件

  • 公共站点必须采用SSR(Next.js App Router)。不允许仅使用SPA的快捷方式。
  • 公共站点只能通过位于 /api 下的公共API路由与后端通信(例如,/api/content, /api/media)。
  • 管理端使用基于Cookie的会话认证。HTTP-only Cookie。不使用localStorage令牌。
  • API使用Fastify。会话通过@fastify/session实现。开发环境使用“fakeRedis”存储;生产环境后续使用Redis。
  • 数据库访问仅通过 @platform/db 包。不允许在应用中分散使用直接的Prisma客户端。
  • NGINX终止TLS并路由 /、/admin 和 /api。初期使用一个域名即可;后续可选择使用子域名。
  • 从第一天起就建立监控(Prometheus抓取 + Grafana仪表板占位符)。

仓库布局

repo/
  apps/
    platform/        # public site (SSR)
    admin/           # admin UI (cookie session)
  packages/
    api/             # Fastify backend (auth/users/content/media)
    db/              # Prisma client + migrations
    shared/          # shared types, UI bits, utilities
  INFRASTRUCTURE/
    nginx/
      nginx.conf
      sites/
        app.conf
    monitoring/
      prometheus.yml
      grafana/
  docker-compose.yml
  turbo.json
  package.json

权衡取舍(真正的取舍)

  • Cookie会话很普通。但这正是其优点。它们在代理后工作,并将认证逻辑排除在前端之外。缺点是:你必须正确设置SameSite/Secure/path,否则你会追查“随机”的登录错误。
  • 单一的 /api 接口很清晰,但这同时也意味着你必须严格区分公共端点和仅限管理员的端点。不要因为方便而泄露管理员端点。
  • Prisma在“多数据库就绪”的意义上可以切换提供商。它并不是一个能在单一模式下连接SQL和Mongo的魔法桥梁。如果有人这么说,那他们肯定没有实际部署过。
  • Next.js SSR对SEO和首次加载有好处。但如果你没有有意设置缓存和重新验证,它也可能对API造成冲击。

路由契约(NGINX作为前门)

我们目前将所有流量通过一个域名路由。这样调试更简单。如果我们后续将管理端移至子域名,我们将重新审视Cookie范围和CSRF假设。

# INFRASTRUCTURE/nginx/sites/app.conf
server {
  listen 80;
  server_name yourdomain.com;

  location /api/ {
    proxy_pass http://api:4000/;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
  }

  location /admin/ {
    proxy_pass http://admin:3001/;
    proxy_set_header Host $host;
  }

  location / {
    proxy_pass http://platform:3000/;
    proxy_set_header Host $host;
  }
}

API包(Fastify)—— 会话与认证

API负责认证和会话状态。应用从不生成令牌。它们只是回传Cookie。保持会话负载小巧。用户ID + 角色。仅此而已。

// packages/api/src/server.ts
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import session from "@fastify/session";
import { buildSessionStore } from "./sessionStore";
import { authRoutes } from "./routes/auth";
import { meRoutes } from "./routes/me";

export async function buildServer() {
  const app = Fastify({ logger: true });

  await app.register(cookie);
  await app.register(session, {
    secret: process.env.SESSION_SECRET || "dev-secret-change-me",
    cookieName: "sid",
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      path: "/"
    },
    store: buildSessionStore(),
    saveUninitialized: false
  });

  app.register(authRoutes, { prefix: "/api" });
  app.register(meRoutes, { prefix: "/api" });

  return app;
}

// packages/api/src/index.ts
import { buildServer } from "./server";

(async () => {
  const app = await buildServer();
  const port = Number(process.env.PORT || 4000);
  await app.listen({ port, host: "0.0.0.0" });
})();
// packages/api/src/sessionStore.ts
import type { SessionStore } from "@fastify/session";

export function buildSessionStore(): SessionStore {
  const mem = new Map<string, { value: any; expiresAt: number }>();

  return {
    get: (sid, cb) => {
      const hit = mem.get(sid);
      if (!hit) return cb(null, null);
      if (Date.now() > hit.expiresAt) {
        mem.delete(sid);
        return cb(null, null);
      }
      cb(null, hit.value);
    },
    set: (sid, session, cb) => {
      const ttlMs = 1000 * 60 * 60 * 8;
      mem.set(sid, { value: session, expiresAt: Date.now() + ttlMs });
      cb(null);
    },
    destroy: (sid, cb) => {
      mem.delete(sid);
      cb(null);
    }
  };
}

数据库包(Prisma)—— 单一客户端,共享类型

我们从 @platform/db 暴露一个 PrismaClient。API 只导入这个客户端。如果你因为“原型开发更快”而在应用中创建第二个 Prisma 客户端,你只是在制造未来的事故。

// packages/db/src/index.ts
import { PrismaClient } from "@prisma/client";

export const db = new PrismaClient();

管理端应用 —— Cookie 会话与 /me 守卫

管理端是一个普通的 Next.js App Router 应用。唯一特殊之处在于它必须将 API 视为唯一真实来源,并且在调用 /api/me 时始终包含 Cookie。

// apps/admin/app/(admin)/layout.tsx
import { cookies } from "next/headers";

async function getMe() {
  const cookieHeader = cookies().toString();
  const res = await fetch(`${process.env.ADMIN_BASE_URL}/api/me`, {
    headers: { cookie: cookieHeader },
    cache: "no-store"
  });
  return res.json();
}

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
  const me = await getMe();
  if (!me?.ok) {
    return (
      <html>
        <body>
          <p>未经授权。请前往 /admin/login。</p>
        </body>
      </html>
    );
  }

  return (
    <html>
      <body>{children}</body>
    </html>
  );
}

逐步说明:管理端登录流程

  1. 浏览器提交凭据:POST /api/login
  2. NGINX 将 /api/* 转发给 Fastify
  3. Fastify 根据数据库(Prisma)验证用户
  4. Fastify 将会话写入 fakeRedis 存储(开发环境)并返回 Set-Cookie: sid=…
  5. 浏览器存储 Cookie(HTTP-only)
  6. 管理端服务器组件调用 GET /api/me 并附上 Cookie
  7. 仅当 /me 返回 ok=true 时,管理端才渲染仪表板

一个出过的问题(以免重蹈覆辙)

我们曾遇到登录循环,即使 /api/login 返回 200。Cookie 从未在 /api/me 请求中发送回来。原因很普通:Cookie 路径与代理路由不匹配。我们错误地将 Cookie 路径设置为 /admin,但 /api/me 位于 /api 下。浏览器完全按照应有的方式工作。它没有发送 Cookie。修复方法是将 Cookie 路径设置为 "/",并确认 NGINX 没有以破坏它的方式重写路径。

项目计划(先交付,后优化)

  1. 阶段 0:单体仓库配置(Turbo、TS 配置、共享包导入)。保持精简。
  2. 阶段 1:API 认证/会话端点:/login、/logout、/me。在 /login 上添加基本速率限制。
  3. 阶段 2:管理端 MVP:登录界面 + 服务器端守卫 + 最小化仪表板外壳。
  4. 阶段 3:公共平台的内容读取端点:/api/content/*(先实现只读)。
  5. 阶段 4:管理端的内容 CRUD + 媒体上传(从简单开始,后续再更换存储方案)。
  6. 阶段 5:监控与加固:Prometheus 指标端点、Grafana 仪表板、HTTP 头、备份。

完成定义(以免后续争论)

  • 新浏览器可以登录 /admin,并在刷新后保持登录状态。
  • 公共平台通过 /api/content 渲染 SSR 内容,且不暴露仅限管理员的端点。
  • NGINX 在 docker-compose 中正确路由 /、/admin、/api。
  • API 可以重启而不会破坏会话行为(开发存储重置是可以的,但必须优雅地失败)。
  • 至少存在一个 Prometheus 抓取目标(即使 Grafana 面板是占位符)。

如果你正在实现一个新端点,请先决定:它是公共的、仅限管理员的,还是内部的。不要猜测。然后将其放在正确的前缀后面并加以保护。这就是全部要义。