本章目标:以"多租户博客 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 |
| 卷 V | UI 工程化 | 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
- 行级隔离是多租户 SaaS 的最佳起步方案:通过 tenantId 过滤实现数据隔离
- Middleware 是租户识别的核心:从子域名或自定义域名解析租户
- 数据访问层必须封装 tenantId:永远不要在业务代码中忘记 tenantId 过滤
- Stripe 是 SaaS 计费的标准方案:Webhook 驱动状态同步
- 自定义域名需要 DNS 验证 + SSL 证书:Let’s Encrypt 自动申请
- 安全检查清单是上线前的必须步骤:数据隔离、权限控制、计费安全
参考资料
🎉 全文完
感谢你阅读《Next.js 完全指南:从入门到 SaaS 生产实战》。希望这套教程能帮助你在 Next.js 的学习和实践中少走弯路,构建出优秀的 Web 应用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。