第 22 章:多租户 SaaS 架构实战

构建一个完整的多租户 SaaS 博客平台——从架构设计、租户隔离策略、Stripe 订阅计费、自定义域名、RBAC 权限体系,到完整的前后端实战。

本章目标:以"多租户博客 SaaS 平台"为实战项目,系统掌握 Next.js 中构建 SaaS 应用的核心技术——租户识别与隔离、数据权限控制、Stripe 订阅计费、自定义域名绑定、团队成员管理,最终交付一个生产可用的多租户系统。


22.1 多租户架构概述

什么是多租户?

多租户(Multi-Tenant) 是指一套应用实例同时服务多个客户(租户),每个租户的数据相互隔离,但共享相同的应用代码和基础设施。

单租户(Single-Tenant):
  租户 A → 独立应用实例 A → 独立数据库 A
  租户 B → 独立应用实例 B → 独立数据库 B

多租户(Multi-Tenant):
  租户 A ─┐
  租户 B ─┼→ 共享应用实例 → 数据隔离层
  租户 C ─┘

三种数据隔离策略

策略实现方式隔离强度成本适用场景
独立数据库每个租户独立 DB最强最高企业级、合规要求
共享数据库 + 独立 Schema同 DB 不同 Schema中型 SaaS
共享数据库 + 行级隔离同 DB 同表,通过 tenant_id 区分最弱最低小型 SaaS、大量租户

本教程方案:行级隔离 + 中间件注入

架构选型:

  请求 → Middleware(识别租户)→ Server Component(注入 tenantId)→ 数据库(WHERE tenant_id = ?)

优势:
  ✅ 成本低(共享数据库)
  ✅ 扩展性好(轻松支持上千租户)
  ✅ 维护简单(一套 Schema)

劣势:
  ⚠️ 需要严格的 tenant_id 过滤(防止数据泄露)
  ⚠️ 不适合强合规场景(HIPAA、金融)

22.2 数据模型设计

Prisma Schema

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// ============ 租户模块 ============

model Tenant {
  id          String       @id @default(cuid())
  name        String
  slug        String       @unique
  logo        String?
  domain      String?      @unique    // 自定义域名
  plan        Plan         @default(FREE)
  settings    Json         @default("{}")
  isActive    Boolean      @default(true)
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @updatedAt

  // 关联
  members     TenantMember[]
  articles    Article[]
  categories  Category[]

  @@map("tenants")
}

enum Plan {
  FREE
  PRO
  ENTERPRISE
}

// 租户成员
model TenantMember {
  id        String     @id @default(cuid())
  tenantId  String
  userId    String
  role      TenantRole @default(MEMBER)
  joinedAt  DateTime   @default(now())

  tenant    Tenant     @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  user      User       @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([tenantId, userId])
  @@index([userId])
  @@map("tenant_members")
}

enum TenantRole {
  OWNER
  ADMIN
  EDITOR
  MEMBER
}

// ============ 用户模块 ============

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  avatar        String?
  password      String
  emailVerified DateTime?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  // 关联
  memberships   TenantMember[]
  accounts      Account[]
  sessions      Session[]

  @@map("users")
}

// ============ 博客内容模块 ============

model Article {
  id          String    @id @default(cuid())
  tenantId    String
  title       String
  slug        String
  content     String    @db.Text
  excerpt     String?   @db.VarChar(500)
  coverImage  String?
  published   Boolean   @default(false)
  featured    Boolean   @default(false)
  views       Int       @default(0)
  authorId    String
  categoryId  String?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  publishedAt DateTime?

  tenant      Tenant    @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  category    Category? @relation(fields: [categoryId], references: [id])

  @@unique([tenantId, slug])
  @@index([tenantId, published, createdAt(sort: Desc)])
  @@index([tenantId, categoryId])
  @@map("articles")
}

model Category {
  id       String    @id @default(cuid())
  tenantId String
  name     String
  slug     String

  tenant   Tenant    @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  articles Article[]

  @@unique([tenantId, slug])
  @@map("categories")
}

// ============ 认证模块(NextAuth) ============

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@map("accounts")
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("sessions")
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
  @@map("verification_tokens")
}

// ============ 订阅与计费 ============

model Subscription {
  id                    String            @id @default(cuid())
  tenantId              String            @unique
  stripeCustomerId      String?           @unique
  stripeSubscriptionId  String?           @unique
  stripePriceId         String?
  plan                  Plan              @default(FREE)
  status                SubscriptionStatus @default(ACTIVE)
  currentPeriodStart    DateTime?
  currentPeriodEnd      DateTime?
  cancelAtPeriodEnd     Boolean           @default(false)
  createdAt             DateTime          @default(now())
  updatedAt             DateTime          @updatedAt

  @@map("subscriptions")
}

enum SubscriptionStatus {
  ACTIVE
  PAST_DUE
  CANCELED
  TRIALING
  INCOMPLETE
}

// ============ 审计日志 ============

model AuditLog {
  id        String   @id @default(cuid())
  tenantId  String
  userId    String?
  action    String
  resource  String
  detail    Json?
  ip        String?
  createdAt DateTime @default(now())

  @@index([tenantId, createdAt(sort: Desc)])
  @@map("audit_logs")
}

关键设计决策

1. Tenant.slug:用于子域名识别(tenant-slug.example.com)
2. Tenant.domain:用于自定义域名(blog.tenant-company.com)
3. @@unique([tenantId, slug]):同一租户内 slug 唯一
4. TenantMember:多对多关系,一个用户可属于多个租户
5. Subscription:与 Tenant 一对一,记录 Stripe 订阅状态
6. AuditLog:记录所有关键操作(安全审计)

22.3 租户识别与中间件

租户识别策略

请求 → 识别租户 → 注入上下文

两种识别方式:

方式 A:子域名(推荐)
  acme.example.com → tenant slug = "acme"
  beta.example.com → tenant slug = "beta"

方式 B:自定义域名
  blog.acme-corp.com → 查 Tenant.domain 找到 tenant

租户解析工具

// lib/tenant/resolver.ts

import { prisma } from '@/lib/prisma';
import { cache } from 'react';

export type TenantContext = {
  id: string;
  name: string;
  slug: string;
  logo: string | null;
  domain: string | null;
  plan: 'FREE' | 'PRO' | 'ENTERPRISE';
  settings: Record<string, unknown>;
};

// 从域名解析租户(缓存到请求级别)
export const resolveTenant = cache(async (hostname: string): Promise<TenantContext | null> => {
  // 1. 尝试自定义域名
  const byDomain = await prisma.tenant.findUnique({
    where: { domain: hostname, isActive: true },
    select: {
      id: true, name: true, slug: true,
      logo: true, domain: true, plan: true, settings: true,
    },
  });

  if (byDomain) return byDomain as TenantContext;

  // 2. 尝试子域名
  const parts = hostname.split('.');
  if (parts.length >= 3) {
    const subdomain = parts[0];

    // 排除 www、api 等保留子域名
    const reserved = ['www', 'api', 'app', 'admin', 'dashboard'];
    if (!reserved.includes(subdomain)) {
      const bySlug = await prisma.tenant.findUnique({
        where: { slug: subdomain, isActive: true },
        select: {
          id: true, name: true, slug: true,
          logo: true, domain: true, plan: true, settings: true,
        },
      });

      if (bySlug) return bySlug as TenantContext;
    }
  }

  return null;
});

租户 Middleware

// middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';

const RESERVED_SUBDOMAINS = ['www', 'api', 'app', 'admin', 'dashboard'];

export async function middleware(request: NextRequest) {
  const { hostname, pathname } = request.nextUrl;

  // 跳过静态资源
  if (
    pathname.startsWith('/_next') ||
    pathname.startsWith('/api/auth') ||
    pathname.includes('.')
  ) {
    return NextResponse.next();
  }

  // 解析租户
  let tenantSlug: string | null = null;

  // 方式 1:自定义域名
  const tenantByDomain = await fetchTenantByDomain(hostname);
  if (tenantByDomain) {
    tenantSlug = tenantByDomain.slug;
  }

  // 方式 2:子域名
  if (!tenantSlug) {
    const parts = hostname.split('.');
    if (parts.length >= 3) {
      const subdomain = parts[0];
      if (!RESERVED_SUBDOMAINS.includes(subdomain)) {
        tenantSlug = subdomain;
      }
    }
  }

  // 将租户信息注入请求头
  const requestHeaders = new Headers(request.headers);

  if (tenantSlug) {
    requestHeaders.set('X-Tenant-Slug', tenantSlug);
  }

  // 路由保护:/dashboard/* 需要登录
  if (pathname.startsWith('/dashboard')) {
    const token = await getToken({
      req: request,
      secret: process.env.AUTH_SECRET,
    });

    if (!token) {
      const loginUrl = new URL('/login', request.url);
      loginUrl.searchParams.set('callbackUrl', pathname);
      return NextResponse.redirect(loginUrl);
    }

    // 注入用户信息
    requestHeaders.set('X-User-Id', token.id as string);
    requestHeaders.set('X-User-Role', token.role as string);
  }

  const response = NextResponse.next({
    request: { headers: requestHeaders },
  });

  // 安全 Headers
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');

  return response;
}

// 简化的域名查找(Middleware 中不能用 Prisma,改用 fetch)
async function fetchTenantByDomain(hostname: string) {
  try {
    const res = await fetch(
      `${process.env.NEXTAUTH_URL}/api/internal/tenant-by-domain?domain=${hostname}`,
      { next: { revalidate: 60 } }
    );
    if (res.ok) return res.json();
  } catch {}
  return null;
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)',
  ],
};

获取当前租户(Server Component)

// lib/tenant/context.ts

import { headers } from 'next/headers';
import { resolveTenant, TenantContext } from './resolver';

// 在 Server Component 中获取当前租户
export async function getCurrentTenant(): Promise<TenantContext | null> {
  const headersList = await headers();

  // 优先从 Middleware 注入的 Header 读取
  const slug = headersList.get('X-Tenant-Slug');
  if (slug) {
    return resolveTenant(slug);
  }

  // 从 hostname 解析
  const hostname = headersList.get('host') || '';
  return resolveTenant(hostname);
}

// 要求租户存在(否则 404)
export async function requireTenant(): Promise<TenantContext> {
  const tenant = await getCurrentTenant();
  if (!tenant) {
    // 动态导入避免循环依赖
    const { notFound } = await import('next/navigation');
    notFound();
  }
  return tenant;
}

22.4 租户级权限管理

权限矩阵

权限           OWNER  ADMIN  EDITOR  MEMBER
──────────────┼──────┼──────┼──────┼──────
管理租户设置    ✅     ❌     ❌     ❌
管理成员        ✅     ✅     ❌     ❌
管理计费        ✅     ❌     ❌     ❌
发布文章        ✅     ✅     ✅     ❌
编辑他人文章    ✅     ✅     ❌     ❌
编辑自己文章    ✅     ✅     ✅     ✅
删除文章        ✅     ✅     ❌     ❌
查看文章        ✅     ✅     ✅     ✅

权限工具函数

// lib/tenant/permissions.ts

import { prisma } from '@/lib/prisma';

type Permission =
  | 'tenant:manage'
  | 'members:manage'
  | 'billing:manage'
  | 'articles:create'
  | 'articles:edit_own'
  | 'articles:edit_others'
  | 'articles:delete'
  | 'articles:publish'
  | 'articles:read';

const rolePermissions: Record<string, Permission[]> = {
  OWNER: [
    'tenant:manage', 'members:manage', 'billing:manage',
    'articles:create', 'articles:edit_own', 'articles:edit_others',
    'articles:delete', 'articles:publish', 'articles:read',
  ],
  ADMIN: [
    'members:manage',
    'articles:create', 'articles:edit_own', 'articles:edit_others',
    'articles:delete', 'articles:publish', 'articles:read',
  ],
  EDITOR: [
    'articles:create', 'articles:edit_own',
    'articles:publish', 'articles:read',
  ],
  MEMBER: [
    'articles:create', 'articles:edit_own', 'articles:read',
  ],
};

// 获取用户在租户内的角色
export async function getTenantRole(
  userId: string,
  tenantId: string
): Promise<string | null> {
  const member = await prisma.tenantMember.findUnique({
    where: {
      tenantId_userId: { tenantId, userId },
    },
    select: { role: true },
  });

  return member?.role ?? null;
}

// 检查用户是否有权限
export async function hasTenantPermission(
  userId: string,
  tenantId: string,
  permission: Permission
): Promise<boolean> {
  const role = await getTenantRole(userId, tenantId);
  if (!role) return false;

  return rolePermissions[role]?.includes(permission) ?? false;
}

// 要求权限(无权限则抛出错误)
export async function requireTenantPermission(
  userId: string,
  tenantId: string,
  permission: Permission
): Promise<void> {
  const hasPermission = await hasTenantPermission(userId, tenantId, permission);
  if (!hasPermission) {
    throw new Error(`Permission denied: ${permission}`);
  }
}

// 要求租户成员身份
export async function requireTenantMember(
  userId: string,
  tenantId: string
): Promise<{ role: string }> {
  const member = await prisma.tenantMember.findUnique({
    where: {
      tenantId_userId: { tenantId, userId },
    },
    select: { role: true },
  });

  if (!member) {
    throw new Error('Not a member of this tenant');
  }

  return member;
}

数据访问层封装(租户隔离)

// lib/services/tenant-article.ts

import { prisma } from '@/lib/prisma';
import { cache } from 'react';
import { revalidatePath } from 'next/cache';

// ✅ 所有查询必须包含 tenantId 过滤

export const getTenantArticles = cache(async (
  tenantId: string,
  options: {
    page?: number;
    limit?: number;
    published?: boolean;
    categoryId?: string;
  } = {}
) => {
  const { page = 1, limit = 10, published, categoryId } = options;

  const where = {
    tenantId,  // ← 关键:租户隔离
    ...(published !== undefined && { published }),
    ...(categoryId && { categoryId }),
  };

  const [articles, total] = await Promise.all([
    prisma.article.findMany({
      where,
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: 'desc' },
      select: {
        id: true,
        title: true,
        slug: true,
        excerpt: true,
        coverImage: true,
        published: true,
        views: true,
        createdAt: true,
        publishedAt: true,
        category: { select: { name: true, slug: true } },
      },
    }),
    prisma.article.count({ where }),
  ]);

  return {
    articles,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  };
});

export const getTenantArticle = cache(async (
  tenantId: string,
  slug: string
) => {
  return prisma.article.findFirst({
    where: {
      tenantId,  // ← 租户隔离
      slug,
      published: true,
    },
    include: {
      category: true,
    },
  });
});

export async function createTenantArticle(
  tenantId: string,
  authorId: string,
  data: {
    title: string;
    slug: string;
    content: string;
    excerpt?: string;
    coverImage?: string;
    categoryId?: string;
    published?: boolean;
  }
) {
  // 检查 slug 在当前租户内是否唯一
  const existing = await prisma.article.findFirst({
    where: { tenantId, slug: data.slug },
  });

  if (existing) {
    throw new Error('Slug already exists in this tenant');
  }

  const article = await prisma.article.create({
    data: {
      ...data,
      tenantId,    // ← 绑定租户
      authorId,
      publishedAt: data.published ? new Date() : null,
    },
  });

  revalidatePath('/');
  return article;
}

export async function deleteTenantArticle(
  tenantId: string,
  articleId: string
) {
  // 确保文章属于当前租户
  const article = await prisma.article.findFirst({
    where: { id: articleId, tenantId },
  });

  if (!article) {
    throw new Error('Article not found or access denied');
  }

  await prisma.article.delete({ where: { id: articleId } });
  revalidatePath('/');
}

22.5 Stripe 订阅计费

安装

npm install stripe @stripe/stripe-js

价格方案设计

// lib/billing/plans.ts

export const PLANS = {
  FREE: {
    name: '免费版',
    price: 0,
    stripePriceId: null,
    features: {
      maxArticles: 10,
      maxMembers: 2,
      customDomain: false,
      analytics: false,
      apiAccess: false,
      storage: '1GB',
    },
  },
  PRO: {
    name: '专业版',
    price: 29,
    stripePriceId: process.env.STRIPE_PRICE_PRO,
    features: {
      maxArticles: 100,
      maxMembers: 10,
      customDomain: true,
      analytics: true,
      apiAccess: true,
      storage: '10GB',
    },
  },
  ENTERPRISE: {
    name: '企业版',
    price: 99,
    stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE,
    features: {
      maxArticles: -1,  // 无限
      maxMembers: -1,
      customDomain: true,
      analytics: true,
      apiAccess: true,
      storage: '100GB',
      prioritySupport: true,
      sla: true,
    },
  },
} as const;

export type PlanKey = keyof typeof PLANS;

// 检查租户是否达到限制
export async function checkTenantLimit(
  tenantId: string,
  resource: 'articles' | 'members'
): Promise<{ allowed: boolean; current: number; max: number }> {
  const tenant = await prisma.tenant.findUnique({
    where: { id: tenantId },
    select: { plan: true },
  });

  if (!tenant) throw new Error('Tenant not found');

  const plan = PLANS[tenant.plan as PlanKey];

  if (resource === 'articles') {
    const count = await prisma.article.count({ where: { tenantId } });
    return {
      allowed: plan.features.maxArticles === -1 || count < plan.features.maxArticles,
      current: count,
      max: plan.features.maxArticles,
    };
  }

  if (resource === 'members') {
    const count = await prisma.tenantMember.count({ where: { tenantId } });
    return {
      allowed: plan.features.maxMembers === -1 || count < plan.features.maxMembers,
      current: count,
      max: plan.features.maxMembers,
    };
  }

  return { allowed: true, current: 0, max: -1 };
}

Stripe 初始化

// lib/stripe.ts

import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
  typescript: true,
});

创建结账会话

// app/actions/billing.ts
'use server';

import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import { requireTenantPermission } from '@/lib/tenant/permissions';
import { PLANS, PlanKey } from '@/lib/billing/plans';

export async function createCheckoutSession(
  tenantId: string,
  userId: string,
  planKey: PlanKey
) {
  // 权限检查:只有 OWNER 可以管理计费
  await requireTenantPermission(userId, tenantId, 'billing:manage');

  const plan = PLANS[planKey];
  if (!plan.stripePriceId) {
    throw new Error('This plan does not require payment');
  }

  // 获取或创建 Stripe Customer
  let subscription = await prisma.subscription.findUnique({
    where: { tenantId },
  });

  let customerId = subscription?.stripeCustomerId;

  if (!customerId) {
    const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
    if (!tenant) throw new Error('Tenant not found');

    const customer = await stripe.customers.create({
      metadata: { tenantId, tenantSlug: tenant.slug },
      name: tenant.name,
    });
    customerId = customer.id;
  }

  // 创建 Checkout Session
  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [
      {
        price: plan.stripePriceId,
        quantity: 1,
      },
    ],
    success_url: `${process.env.NEXTAUTH_URL}/dashboard/billing?success=true`,
    cancel_url: `${process.env.NEXTAUTH_URL}/dashboard/billing?canceled=true`,
    metadata: { tenantId },
    subscription_data: {
      metadata: { tenantId },
    },
  });

  return { url: session.url };
}

export async function createBillingPortalSession(
  tenantId: string,
  userId: string
) {
  await requireTenantPermission(userId, tenantId, 'billing:manage');

  const subscription = await prisma.subscription.findUnique({
    where: { tenantId },
  });

  if (!subscription?.stripeCustomerId) {
    throw new Error('No billing account found');
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: subscription.stripeCustomerId,
    return_url: `${process.env.NEXTAUTH_URL}/dashboard/billing`,
  });

  return { url: session.url };
}

Stripe Webhook

// app/api/webhooks/stripe/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature')!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err: any) {
    console.error('Webhook signature verification failed:', err.message);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  try {
    switch (event.type) {
      // 订阅创建/更新
      case 'customer.subscription.created':
      case 'customer.subscription.updated': {
        const subscription = event.data.object as Stripe.Subscription;
        const tenantId = subscription.metadata.tenantId;

        if (!tenantId) break;

        await prisma.subscription.upsert({
          where: { tenantId },
          create: {
            tenantId,
            stripeCustomerId: subscription.customer as string,
            stripeSubscriptionId: subscription.id,
            stripePriceId: subscription.items.data[0]?.price.id,
            plan: getPlanFromPrice(subscription.items.data[0]?.price.id),
            status: mapStripeStatus(subscription.status),
            currentPeriodStart: new Date(subscription.current_period_start * 1000),
            currentPeriodEnd: new Date(subscription.current_period_end * 1000),
            cancelAtPeriodEnd: subscription.cancel_at_period_end,
          },
          update: {
            stripeSubscriptionId: subscription.id,
            stripePriceId: subscription.items.data[0]?.price.id,
            plan: getPlanFromPrice(subscription.items.data[0]?.price.id),
            status: mapStripeStatus(subscription.status),
            currentPeriodStart: new Date(subscription.current_period_start * 1000),
            currentPeriodEnd: new Date(subscription.current_period_end * 1000),
            cancelAtPeriodEnd: subscription.cancel_at_period_end,
          },
        });

        // 同步租户 Plan
        const plan = getPlanFromPrice(subscription.items.data[0]?.price.id);
        await prisma.tenant.update({
          where: { id: tenantId },
          data: { plan },
        });

        break;
      }

      // 订阅取消
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        const tenantId = subscription.metadata.tenantId;

        if (!tenantId) break;

        await prisma.subscription.update({
          where: { tenantId },
          data: {
            status: 'CANCELED',
            plan: 'FREE',
          },
        });

        // 降级到免费版
        await prisma.tenant.update({
          where: { id: tenantId },
          data: { plan: 'FREE' },
        });

        break;
      }

      // 付款失败
      case 'invoice.payment_failed': {
        const invoice = event.data.object as Stripe.Invoice;
        const customerId = invoice.customer as string;

        await prisma.subscription.updateMany({
          where: { stripeCustomerId: customerId },
          data: { status: 'PAST_DUE' },
        });

        break;
      }
    }
  } catch (error) {
    console.error('Webhook handler error:', error);
    return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 });
  }

  return NextResponse.json({ received: true });
}

// 辅助函数
function getPlanFromPrice(priceId: string | undefined): 'FREE' | 'PRO' | 'ENTERPRISE' {
  if (priceId === process.env.STRIPE_PRICE_PRO) return 'PRO';
  if (priceId === process.env.STRIPE_PRICE_ENTERPRISE) return 'ENTERPRISE';
  return 'FREE';
}

function mapStripeStatus(status: Stripe.Subscription.Status): string {
  const map: Record<string, string> = {
    active: 'ACTIVE',
    past_due: 'PAST_DUE',
    canceled: 'CANCELED',
    trialing: 'TRIALING',
    incomplete: 'INCOMPLETE',
  };
  return map[status] || 'ACTIVE';
}

计费页面

// app/dashboard/billing/page.tsx

import { requireTenant } from '@/lib/tenant/context';
import { requireAuth } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';
import { PLANS } from '@/lib/billing/plans';
import { BillingPlans } from '@/app/components/billing/billing-plans';
import { BillingStatus } from '@/app/components/billing/billing-status';

export default async function BillingPage() {
  const tenant = await requireTenant();
  const user = await requireAuth();

  const subscription = await prisma.subscription.findUnique({
    where: { tenantId: tenant.id },
  });

  return (
    <div className="max-w-4xl mx-auto">
      <h1 className="text-2xl font-bold mb-6">订阅与计费</h1>

      {/* 当前订阅状态 */}
      <BillingStatus
        plan={tenant.plan}
        subscription={subscription}
        isOwner={true}
      />

      {/* 套餐选择 */}
      <div className="mt-12">
        <h2 className="text-xl font-semibold mb-6">选择套餐</h2>
        <BillingPlans
          currentPlan={tenant.plan}
          tenantId={tenant.id}
          userId={user.id}
        />
      </div>
    </div>
  );
}
// app/components/billing/billing-plans.tsx
'use client';

import { useState } from 'react';
import { PLANS, PlanKey } from '@/lib/billing/plans';
import { createCheckoutSession, createBillingPortalSession } from '@/app/actions/billing';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';

export function BillingPlans({
  currentPlan,
  tenantId,
  userId,
}: {
  currentPlan: string;
  tenantId: string;
  userId: string;
}) {
  const [loading, setLoading] = useState<string | null>(null);

  async function handleUpgrade(planKey: PlanKey) {
    setLoading(planKey);
    try {
      const result = await createCheckoutSession(tenantId, userId, planKey);
      if (result.url) {
        window.location.href = result.url;
      }
    } catch (error) {
      console.error('Checkout failed:', error);
    } finally {
      setLoading(null);
    }
  }

  async function handleManage() {
    setLoading('portal');
    try {
      const result = await createBillingPortalSession(tenantId, userId);
      if (result.url) {
        window.location.href = result.url;
      }
    } catch (error) {
      console.error('Portal failed:', error);
    } finally {
      setLoading(null);
    }
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {Object.entries(PLANS).map(([key, plan]) => {
        const isCurrent = currentPlan === key;
        const isFree = key === 'FREE';

        return (
          <Card
            key={key}
            className={isCurrent ? 'ring-2 ring-primary' : ''}
          >
            <CardHeader>
              <div className="flex items-center justify-between">
                <CardTitle>{plan.name}</CardTitle>
                {isCurrent && (
                  <Badge variant="default">当前</Badge>
                )}
              </div>
              <div className="mt-2">
                <span className="text-3xl font-bold">¥{plan.price}</span>
                {!isFree && <span className="text-muted-foreground">/</span>}
              </div>
            </CardHeader>

            <CardContent>
              <ul className="space-y-2 text-sm">
                <li>📝 {plan.features.maxArticles === -1 ? '无限' : plan.features.maxArticles} 篇文章</li>
                <li>👥 {plan.features.maxMembers === -1 ? '无限' : plan.features.maxMembers} 个成员</li>
                <li>{plan.features.customDomain ? '✅' : '❌'} 自定义域名</li>
                <li>{plan.features.analytics ? '✅' : '❌'} 数据分析</li>
                <li>{plan.features.apiAccess ? '✅' : '❌'} API 访问</li>
                <li>💾 {plan.features.storage} 存储</li>
              </ul>
            </CardContent>

            <CardFooter>
              {isCurrent ? (
                <Button
                  variant="outline"
                  className="w-full"
                  onClick={handleManage}
                  disabled={loading === 'portal' || isFree}
                >
                  {loading === 'portal' ? '跳转中...' : '管理订阅'}
                </Button>
              ) : (
                <Button
                  className="w-full"
                  onClick={() => handleUpgrade(key as PlanKey)}
                  disabled={loading === key || isFree}
                >
                  {loading === key ? '跳转中...' : isFree ? '当前方案' : `升级到 ${plan.name}`}
                </Button>
              )}
            </CardFooter>
          </Card>
        );
      })}
    </div>
  );
}

22.6 自定义域名

域名绑定流程

用户操作流程:

1. 用户在 SaaS 后台输入自定义域名(blog.acme-corp.com)
2. 系统验证域名所有权(DNS TXT 记录 或 CNAME)
3. 用户配置 CNAME 指向 cname.example.com
4. 系统自动申请 SSL 证书(Let's Encrypt)
5. 域名生效,用户可通过自定义域名访问

域名绑定 Server Action

// app/actions/domain.ts
'use server';

import { prisma } from '@/lib/prisma';
import { requireTenantPermission } from '@/lib/tenant/permissions';
import { PLANS, PlanKey } from '@/lib/billing/plans';

export async function addCustomDomain(
  tenantId: string,
  userId: string,
  domain: string
) {
  // 权限检查
  await requireTenantPermission(userId, tenantId, 'tenant:manage');

  // 检查套餐是否支持自定义域名
  const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
  if (!tenant) throw new Error('Tenant not found');

  const plan = PLANS[tenant.plan as PlanKey];
  if (!plan.features.customDomain) {
    return {
      error: '自定义域名功能需要专业版或更高套餐',
    };
  }

  // 验证域名格式
  const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
  if (!domainRegex.test(domain)) {
    return { error: '域名格式不正确' };
  }

  // 检查域名是否已被使用
  const existing = await prisma.tenant.findUnique({ where: { domain } });
  if (existing) {
    return { error: '该域名已被其他租户使用' };
  }

  // 生成验证 Token
  const verificationToken = `verify-${tenantId}-${Date.now()}`;

  // 更新租户域名(待验证状态)
  await prisma.tenant.update({
    where: { id: tenantId },
    data: {
      domain,
      settings: {
        ...((tenant.settings as Record<string, unknown>) || {}),
        domainVerificationToken: verificationToken,
        domainVerified: false,
      },
    },
  });

  return {
    success: true,
    verificationToken,
    instructions: {
      cname: {
        host: domain,
        value: `cname.${process.env.NEXT_PUBLIC_APP_DOMAIN}`,
      },
      txt: {
        host: `_verify.${domain}`,
        value: verificationToken,
      },
    },
  };
}

// 验证域名 DNS
export async function verifyDomain(
  tenantId: string,
  userId: string
) {
  await requireTenantPermission(userId, tenantId, 'tenant:manage');

  const tenant = await prisma.tenant.findUnique({
    where: { id: tenantId },
    select: { domain: true, settings: true },
  });

  if (!tenant?.domain) {
    return { error: '未设置自定义域名' };
  }

  const settings = tenant.settings as Record<string, unknown>;
  const expectedToken = settings.domainVerificationToken as string;

  // DNS 查询(使用 Node.js dns 模块或外部 API)
  try {
    const response = await fetch(
      `https://dns.google/resolve?name=_verify.${tenant.domain}&type=TXT`
    );
    const data = await response.json();

    const txtRecords = data.Answer?.map((a: any) => a.data.replace(/"/g, '')) || [];
    const isVerified = txtRecords.includes(expectedToken);

    if (isVerified) {
      await prisma.tenant.update({
        where: { id: tenantId },
        data: {
          settings: {
            ...settings,
            domainVerified: true,
          },
        },
      });

      // 触发 SSL 证书申请(Vercel / Cloudflare 自动处理)
      // 自托管需要 certbot 或 acme.sh

      return { success: true, verified: true };
    }

    return {
      success: false,
      verified: false,
      message: 'DNS 记录尚未生效,请等待几分钟后重试',
    };
  } catch (error) {
    return { error: 'DNS 验证失败' };
  }
}

域名配置 UI

// app/dashboard/settings/domain/page.tsx

import { requireTenant } from '@/lib/tenant/context';
import { requireAuth } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';
import { DomainSettings } from '@/app/components/settings/domain-settings';

export default async function DomainSettingsPage() {
  const tenant = await requireTenant();
  const user = await requireAuth();

  const settings = tenant.settings as Record<string, unknown>;

  return (
    <div className="max-w-2xl">
      <h1 className="text-2xl font-bold mb-6">自定义域名</h1>

      <DomainSettings
        tenantId={tenant.id}
        userId={user.id}
        currentDomain={tenant.domain}
        isVerified={settings.domainVerified as boolean || false}
        plan={tenant.plan}
      />
    </div>
  );
}
// app/components/settings/domain-settings.tsx
'use client';

import { useState } from 'react';
import { addCustomDomain, verifyDomain } from '@/app/actions/domain';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';

type Props = {
  tenantId: string;
  userId: string;
  currentDomain: string | null;
  isVerified: boolean;
  plan: string;
};

export function DomainSettings({
  tenantId,
  userId,
  currentDomain,
  isVerified,
  plan,
}: Props) {
  const [domain, setDomain] = useState(currentDomain || '');
  const [instructions, setInstructions] = useState<any>(null);
  const [verifying, setVerifying] = useState(false);

  async function handleAddDomain() {
    const result = await addCustomDomain(tenantId, userId, domain);

    if (result.error) {
      toast.error(result.error);
      return;
    }

    if (result.success) {
      setInstructions(result.instructions);
      toast.success('域名已添加,请配置 DNS 记录');
    }
  }

  async function handleVerify() {
    setVerifying(true);
    const result = await verifyDomain(tenantId, userId);

    if (result.verified) {
      toast.success('域名验证成功!');
    } else if (result.error) {
      toast.error(result.error);
    } else {
      toast.info(result.message || '尚未验证通过');
    }
    setVerifying(false);
  }

  if (plan === 'FREE') {
    return (
      <div className="p-6 bg-muted rounded-lg text-center">
        <p className="text-muted-foreground">
          自定义域名功能需要专业版或更高套餐。
        </p>
        <Button className="mt-4" asChild>
          <a href="/dashboard/billing">升级套餐</a>
        </Button>
      </div>
    );
  }

  return (
    <div className="space-y-6">
      {/* 域名输入 */}
      <div className="flex items-center gap-3">
        <Input
          value={domain}
          onChange={(e) => setDomain(e.target.value)}
          placeholder="blog.yourcompany.com"
          className="flex-1"
        />
        <Button onClick={handleAddDomain}>
          {currentDomain ? '更新域名' : '添加域名'}
        </Button>
      </div>

      {/* 当前状态 */}
      {currentDomain && (
        <div className="flex items-center gap-2">
          <span className="text-sm">当前域名:{currentDomain}</span>
          <Badge variant={isVerified ? 'default' : 'secondary'}>
            {isVerified ? '✅ 已验证' : '⏳ 待验证'}
          </Badge>
          {!isVerified && (
            <Button
              variant="outline"
              size="sm"
              onClick={handleVerify}
              disabled={verifying}
            >
              {verifying ? '验证中...' : '立即验证'}
            </Button>
          )}
        </div>
      )}

      {/* DNS 配置说明 */}
      {instructions && !isVerified && (
        <div className="p-4 border rounded-lg space-y-4">
          <h3 className="font-semibold">请按以下步骤配置 DNS</h3>

          <div>
            <p className="text-sm font-medium mb-1">1. 添加 CNAME 记录</p>
            <div className="bg-muted p-3 rounded text-sm font-mono">
              <div>类型: CNAME</div>
              <div>主机记录: {instructions.cname.host}</div>
              <div>记录值: {instructions.cname.value}</div>
            </div>
          </div>

          <div>
            <p className="text-sm font-medium mb-1">2. 添加 TXT 验证记录</p>
            <div className="bg-muted p-3 rounded text-sm font-mono">
              <div>类型: TXT</div>
              <div>主机记录: {instructions.txt.host}</div>
              <div>记录值: {instructions.txt.value}</div>
            </div>
          </div>

          <p className="text-xs text-muted-foreground">
            DNS 记录可能需要几分钟到 48 小时生效。配置完成后点击"立即验证"
          </p>
        </div>
      )}
    </div>
  );
}

22.7 团队成员管理

邀请成员

// app/actions/members.ts
'use server';

import { prisma } from '@/lib/prisma';
import { requireTenantPermission } from '@/lib/tenant/permissions';
import { checkTenantLimit } from '@/lib/billing/plans';
import { revalidatePath } from 'next/cache';

export async function inviteMember(
  tenantId: string,
  userId: string,
  email: string,
  role: 'ADMIN' | 'EDITOR' | 'MEMBER'
) {
  await requireTenantPermission(userId, tenantId, 'members:manage');

  // 检查成员数量限制
  const limit = await checkTenantLimit(tenantId, 'members');
  if (!limit.allowed) {
    return {
      error: `成员数量已达上限(${limit.max} 人),请升级套餐`,
    };
  }

  // 查找用户
  const invitee = await prisma.user.findUnique({ where: { email } });
  if (!invitee) {
    // 用户不存在,发送邀请邮件
    // await sendInviteEmail(email, tenantId, role);
    return { success: true, message: '邀请邮件已发送' };
  }

  // 检查是否已是成员
  const existing = await prisma.tenantMember.findUnique({
    where: {
      tenantId_userId: { tenantId, userId: invitee.id },
    },
  });

  if (existing) {
    return { error: '该用户已是团队成员' };
  }

  // 添加成员
  await prisma.tenantMember.create({
    data: {
      tenantId,
      userId: invitee.id,
      role,
    },
  });

  revalidatePath('/dashboard/members');
  return { success: true, message: '成员已添加' };
}

export async function updateMemberRole(
  tenantId: string,
  userId: string,
  targetUserId: string,
  newRole: 'ADMIN' | 'EDITOR' | 'MEMBER'
) {
  await requireTenantPermission(userId, tenantId, 'members:manage');

  // 不能修改 OWNER 角色
  const target = await prisma.tenantMember.findUnique({
    where: { tenantId_userId: { tenantId, userId: targetUserId } },
  });

  if (target?.role === 'OWNER') {
    return { error: '不能修改所有者角色' };
  }

  await prisma.tenantMember.update({
    where: { tenantId_userId: { tenantId, userId: targetUserId } },
    data: { role: newRole },
  });

  revalidatePath('/dashboard/members');
  return { success: true };
}

export async function removeMember(
  tenantId: string,
  userId: string,
  targetUserId: string
) {
  await requireTenantPermission(userId, tenantId, 'members:manage');

  // 不能移除 OWNER
  const target = await prisma.tenantMember.findUnique({
    where: { tenantId_userId: { tenantId, userId: targetUserId } },
  });

  if (target?.role === 'OWNER') {
    return { error: '不能移除所有者' };
  }

  await prisma.tenantMember.delete({
    where: { tenantId_userId: { tenantId, userId: targetUserId } },
  });

  revalidatePath('/dashboard/members');
  return { success: true };
}

成员管理页面

// app/dashboard/members/page.tsx

import { requireTenant } from '@/lib/tenant/context';
import { requireAuth } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';
import { MemberList } from '@/app/components/members/member-list';
import { InviteMemberForm } from '@/app/components/members/invite-member-form';

export default async function MembersPage() {
  const tenant = await requireTenant();
  const user = await requireAuth();

  const members = await prisma.tenantMember.findMany({
    where: { tenantId: tenant.id },
    include: {
      user: {
        select: { id: true, name: true, email: true, avatar: true },
      },
    },
    orderBy: { joinedAt: 'asc' },
  });

  // 当前用户角色
  const currentMember = members.find((m) => m.userId === user.id);

  return (
    <div className="max-w-4xl mx-auto">
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold">团队成员</h1>
        {currentMember && ['OWNER', 'ADMIN'].includes(currentMember.role) && (
          <InviteMemberForm tenantId={tenant.id} userId={user.id} />
        )}
      </div>

      <MemberList
        members={members}
        tenantId={tenant.id}
        currentUserId={user.id}
        currentUserRole={currentMember?.role || 'MEMBER'}
      />
    </div>
  );
}

22.8 租户仪表盘布局

// app/dashboard/layout.tsx

import { requireTenant } from '@/lib/tenant/context';
import { requireAuth } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';
import { DashboardNav } from '@/app/components/dashboard/dashboard-nav';
import { TenantSwitcher } from '@/app/components/tenant/tenant-switcher';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const tenant = await requireTenant();
  const user = await requireAuth();

  // 获取用户加入的所有租户
  const memberships = await prisma.tenantMember.findMany({
    where: { userId: user.id },
    include: {
      tenant: { select: { id: true, name: true, slug: true, logo: true } },
    },
  });

  const currentMember = memberships.find((m) => m.tenantId === tenant.id);

  return (
    <div className="flex min-h-screen">
      {/* 侧边栏 */}
      <aside className="w-64 border-r border-border bg-card">
        <div className="p-4">
          <TenantSwitcher
            currentTenant={tenant}
            tenants={memberships.map((m) => m.tenant)}
          />
        </div>

        <DashboardNav
          tenantSlug={tenant.slug}
          userRole={currentMember?.role || 'MEMBER'}
        />
      </aside>

      {/* 主内容 */}
      <main className="flex-1 p-8">
        {children}
      </main>
    </div>
  );
}

租户切换组件

// app/components/tenant/tenant-switcher.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

type Tenant = {
  id: string;
  name: string;
  slug: string;
  logo: string | null;
};

export function TenantSwitcher({
  currentTenant,
  tenants,
}: {
  currentTenant: Tenant;
  tenants: Tenant[];
}) {
  const router = useRouter();
  const [open, setOpen] = useState(false);

  return (
    <DropdownMenu open={open} onOpenChange={setOpen}>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" className="w-full justify-between">
          <div className="flex items-center gap-2">
            <div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
              {currentTenant.name[0]}
            </div>
            <span className="truncate">{currentTenant.name}</span>
          </div>
          <span className="text-xs"></span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56">
        {tenants.map((tenant) => (
          <DropdownMenuItem
            key={tenant.id}
            onClick={() => {
              router.push(`https://${tenant.slug}.${process.env.NEXT_PUBLIC_APP_DOMAIN}/dashboard`);
            }}
          >
            <div className="flex items-center gap-2">
              <div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
                {tenant.name[0]}
              </div>
              <span>{tenant.name}</span>
            </div>
          </DropdownMenuItem>
        ))}
        <DropdownMenuSeparator />
        <DropdownMenuItem onClick={() => router.push('/tenants/new')}>
          + 创建新租户
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

22.9 公开博客页面

// app/[...slug]/page.tsx(租户公开博客首页)

import { requireTenant } from '@/lib/tenant/context';
import { getTenantArticles } from '@/lib/services/tenant-article';
import { ArticleCard } from '@/app/components/article-card';
import Link from 'next/link';

export default async function TenantBlogPage() {
  const tenant = await requireTenant();
  const { articles } = await getTenantArticles(tenant.id, { published: true });

  return (
    <div className="min-h-screen bg-background">
      {/* 租户品牌导航 */}
      <nav className="border-b border-border">
        <div className="max-w-4xl mx-auto flex items-center justify-between px-4 h-16">
          <Link href="/" className="flex items-center gap-2">
            {tenant.logo ? (
              <img src={tenant.logo} alt={tenant.name} className="h-8 w-8 rounded" />
            ) : (
              <div className="h-8 w-8 rounded bg-primary flex items-center justify-center text-white font-bold text-sm">
                {tenant.name[0]}
              </div>
            )}
            <span className="font-bold text-lg">{tenant.name}</span>
          </Link>

          <div className="flex items-center gap-4 text-sm">
            <Link href="/" className="hover:text-primary">首页</Link>
            <Link href="/about" className="hover:text-primary">关于</Link>
          </div>
        </div>
      </nav>

      {/* 文章列表 */}
      <main className="max-w-4xl mx-auto px-4 py-12">
        <h1 className="text-3xl font-bold mb-8">{tenant.name} 博客</h1>

        <div className="space-y-8">
          {articles.map((article) => (
            <ArticleCard key={article.id} article={article} tenantSlug={tenant.slug} />
          ))}
        </div>
      </main>

      {/* 底部 */}
      <footer className="border-t border-border mt-16">
        <div className="max-w-4xl mx-auto px-4 py-8 text-center text-sm text-muted-foreground">
          <p>© {new Date().getFullYear()} {tenant.name}. All rights reserved.</p>
          <p className="mt-1">
            Powered by{' '}
            <a href={process.env.NEXT_PUBLIC_APP_URL} className="text-primary hover:underline">
              MyBlog Platform
            </a>
          </p>
        </div>
      </footer>
    </div>
  );
}

22.10 SaaS 安全检查清单

## 多租户安全检查清单

### 数据隔离
- [ ] 所有数据库查询都包含 tenantId 过滤
- [ ] 数据访问层封装了 tenantId 注入
- [ ] API 端点验证 tenantId 归属
- [ ] Server Action 验证用户属于目标租户
- [ ] 不存在跨租户数据泄露的可能

### 权限控制
- [ ] 每个操作都有权限检查
- [ ] 角色权限矩阵已文档化
- [ ] OWNER 不可被降级或删除
- [ ] 敏感操作记录审计日志

### 认证安全
- [ ] Session 包含 tenantId 上下文
- [ ] 租户切换时刷新 Session
- [ ] OAuth 回调正确关联租户
- [ ] 密码强度验证

### 计费安全
- [ ] Webhook 签名验证
- [ ] 套餐限制严格执行
- [ ] 降级时正确处理数据超限
- [ ] 付款失败有告警

### 域名安全
- [ ] 域名所有权验证
- [ ] SSL 证书自动续期
- [ ] 防止域名劫持
- [ ] DNS 验证 Token 不可预测

22.11 系列总结

🎉 恭喜你完成了 Next.js 完全指南!

从第 1 章到第 22 章,你已经系统学习了:

主题核心能力
卷 I基础入门Next.js 核心理念、App Router 目录结构
卷 II渲染模型CSR / SSR / SSG / ISR / RSC 全场景渲染
卷 III数据获取fetch 缓存、Route Handlers、Server Actions
卷 IV数据库认证中间件Prisma + NextAuth + Middleware
卷 VUI 工程化Tailwind + shadcn/ui + Zustand + React Hook Form
卷 VI高级架构性能优化、SEO、测试、监控、部署、多租户 SaaS

技术栈总结

前端:Next.js 15 + React 19 + TypeScript + Tailwind CSS + shadcn/ui
后端:Prisma + PostgreSQL + NextAuth.js v5 + Stripe
测试:Vitest + RTL + Playwright
部署:Docker + GitHub Actions + Vercel / Kubernetes
监控:Sentry + web-vitals + 结构化日志

持续学习

推荐资源:

1. Next.js 官方博客 — https://nextjs.org/blog
2. React 官方博客 — https://react.dev/blog
3. Vercel 模板市场 — https://vercel.com/templates
4. shadcn/ui 组件库 — https://ui.shadcn.com
5. Prisma 数据平台 — https://www.prisma.io/data-platform

进阶方向:

- AI 集成(Vercel AI SDK、OpenAI)
- 实时通信(WebSocket、Socket.io)
- 微前端架构(Module Federation)
- 边缘计算(Cloudflare Workers、Deno Deploy)

本章小结

Key Takeaways

  1. 行级隔离是多租户 SaaS 的最佳起步方案:通过 tenantId 过滤实现数据隔离
  2. Middleware 是租户识别的核心:从子域名或自定义域名解析租户
  3. 数据访问层必须封装 tenantId:永远不要在业务代码中忘记 tenantId 过滤
  4. Stripe 是 SaaS 计费的标准方案:Webhook 驱动状态同步
  5. 自定义域名需要 DNS 验证 + SSL 证书:Let’s Encrypt 自动申请
  6. 安全检查清单是上线前的必须步骤:数据隔离、权限控制、计费安全

参考资料


🎉 全文完

感谢你阅读《Next.js 完全指南:从入门到 SaaS 生产实战》。希望这套教程能帮助你在 Next.js 的学习和实践中少走弯路,构建出优秀的 Web 应用。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页