多租户与团队管理
第 65 章介绍了多租户的架构设计,第 66 章讲了权限模型。本章聚焦实现层面——从数据库 Schema 到 API,完整实现组织 CRUD、成员管理、角色变更、租户切换。
1. 数据模型实现
1.1 完整 Schema
// lib/db/schema.ts(扩展第 65 章的基础 Schema)
export const invitations = pgTable('invitations', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
role: roleEnum('role').default('member').notNull(),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
invitedBy: uuid('invited_by').references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const auditLogs = pgTable('audit_logs', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id),
userId: uuid('user_id').references(() => users.id),
action: text('action').notNull(), // 'member.invited', 'member.removed', 'role.changed'
metadata: jsonb('metadata'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})1.2 完整的 tenants 和 memberships Schema
// lib/db/schema.ts
import { pgTable, uuid, text, timestamp, pgEnum, boolean, jsonb } from 'drizzle-orm/pg-core'
export const planEnum = pgEnum('plan', ['free', 'pro', 'business', 'enterprise'])
export const roleEnum = pgEnum('role', ['owner', 'admin', 'member', 'viewer'])
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(), // URL 标识:acme-inc
plan: planEnum('plan').default('free').notNull(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
logoUrl: text('logo_url'),
settings: jsonb('settings').$type<TenantSettings>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
interface TenantSettings {
defaultRole: 'member' | 'viewer'
allowMemberInvite: boolean
ssoEnabled: boolean
}
export const memberships = pgTable('memberships', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
tenantId: uuid('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }).notNull(),
role: roleEnum('role').default('member').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
uniqueMember: unique().on(table.tenantId, table.userId),
}))1.3 索引策略
| 表 | 索引 | 用途 |
|---|---|---|
memberships | (tenant_id, user_id) UNIQUE | 防止重复加入 |
memberships | (user_id) | 查询用户所有组织 |
invitations | (token) UNIQUE | 邀请 Token 查找 |
invitations | (tenant_id, email) | 查重 |
audit_logs | (tenant_id, created_at) | 按时间查审计日志 |
tenants | (slug) UNIQUE | 按 slug 查找租户 |
1.4 Slug 生成
// lib/utils/slug.ts
export function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // 非字母数字替换为连字符
.replace(/^-|-$/g, '') // 去掉首尾连字符
.slice(0, 48) // 限制长度
}
// 确保唯一性
export async function generateUniqueSlug(name: string): Promise<string> {
const baseSlug = generateSlug(name)
let slug = baseSlug
let counter = 1
while (true) {
const [existing] = await db.select({ id: tenants.id })
.from(tenants).where(eq(tenants.slug, slug))
if (!existing) return slug
slug = `${baseSlug}-${counter++}`
}
}2. 组织 CRUD
2.1 创建组织
// lib/actions/organization.ts
'use server'
export async function createOrganization(formData: FormData) {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
const name = formData.get('name') as string
const slug = generateSlug(name) // 'Acme Inc' → 'acme-inc'
// 事务:创建组织 + 创建 owner membership
const [org] = await db.transaction(async (tx) => {
const [org] = await tx.insert(tenants).values({ name, slug }).returning()
await tx.insert(memberships).values({
userId: session.user.id,
tenantId: org.id,
role: 'owner',
})
return [org]
})
// 设置为当前组织
const cookieStore = await cookies()
cookieStore.set('current_org_id', org.id)
redirect(`/`)
}2.2 更新组织
export async function updateOrganization(formData: FormData) {
const { membership } = await requirePermission('billing:manage')
const name = formData.get('name') as string
const slug = formData.get('slug') as string
await db.update(tenants)
.set({ name, slug })
.where(eq(tenants.id, membership.tenantId))
await logAudit(membership, 'org.updated', { name, slug })
revalidatePath('/settings')
}2.3 删除组织
export async function deleteOrganization() {
const { membership } = await requirePermission('org:delete')
// 取消 Stripe 订阅
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, membership.tenantId))
if (tenant.stripeCustomerId) {
const subs = await stripe.subscriptions.list({ customer: tenant.stripeCustomerId })
for (const sub of subs.data) {
await stripe.subscriptions.cancel(sub.id)
}
}
// CASCADE 会自动删除 memberships, invitations, projects 等
await db.delete(tenants).where(eq(tenants.id, membership.tenantId))
const cookieStore = await cookies()
cookieStore.delete('current_org_id')
redirect('/')
}3. 成员管理
3.1 查询成员列表
export async function getMembers() {
const { membership } = await requirePermission('project:read')
return db
.select({
id: memberships.id,
role: memberships.role,
joinedAt: memberships.createdAt,
user: { id: users.id, name: users.name, email: users.email, avatar: users.avatarUrl },
})
.from(memberships)
.innerJoin(users, eq(memberships.userId, users.id))
.where(eq(memberships.tenantId, membership.tenantId))
.orderBy(memberships.createdAt)
}3.2 变更角色
export async function changeMemberRole(membershipId: string, newRole: string) {
const { membership: currentUser } = await requirePermission('member:remove')
// 不能修改 Owner 的角色
const [target] = await db.select().from(memberships).where(eq(memberships.id, membershipId))
if (target.role === 'owner') throw new Error('Cannot change owner role')
// 不能把自己降级
if (target.userId === currentUser.userId) throw new Error('Cannot change your own role')
await db.update(memberships)
.set({ role: newRole })
.where(eq(memberships.id, membershipId))
await logAudit(currentUser, 'role.changed', { targetId: membershipId, newRole })
revalidatePath('/settings/members')
}3.3 移除成员
export async function removeMember(membershipId: string) {
const { membership: currentUser } = await requirePermission('member:remove')
const [target] = await db.select().from(memberships).where(eq(memberships.id, membershipId))
if (target.role === 'owner') throw new Error('Cannot remove owner')
if (target.userId === currentUser.userId) throw new Error('Cannot remove yourself')
await db.delete(memberships).where(eq(memberships.id, membershipId))
await logAudit(currentUser, 'member.removed', { targetId: target.userId })
revalidatePath('/settings/members')
}4. 邀请流程
4.0 发送邀请
// lib/actions/invitation.ts
'use server'
import { nanoid } from 'nanoid'
import { sendTemplateEmail } from '@/lib/email/templates'
export async function inviteMember(formData: FormData) {
const { session, membership } = await requirePermission('member:invite')
const email = formData.get('email') as string
const role = (formData.get('role') as string) || 'member'
// 检查是否已经是成员
const [existingMember] = await db.select().from(memberships)
.innerJoin(users, eq(memberships.userId, users.id))
.where(and(
eq(memberships.tenantId, membership.tenantId),
eq(users.email, email),
))
if (existingMember) throw new Error('User is already a member')
// 检查是否已有未过期的邀请
const [existingInvite] = await db.select().from(invitations)
.where(and(
eq(invitations.tenantId, membership.tenantId),
eq(invitations.email, email),
gt(invitations.expiresAt, new Date()),
))
if (existingInvite) throw new Error('Invitation already sent')
// 检查套餐成员数限制
const [{ memberCount }] = await db.select({
memberCount: sql<number>`count(*)`,
}).from(memberships).where(eq(memberships.tenantId, membership.tenantId))
const plan = await getTenantPlan(membership.tenantId)
if (memberCount >= plan.limits.members) {
throw new Error(`Member limit reached (${plan.limits.members}). Upgrade your plan.`)
}
// 创建邀请
const token = nanoid(32)
await db.insert(invitations).values({
tenantId: membership.tenantId,
email,
role,
token,
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000), // 72 hours
invitedBy: session.user.id,
})
// 发送邀请邮件
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, membership.tenantId))
await sendTemplateEmail(email, 'invitation', {
orgName: tenant.name,
inviterName: session.user.name,
inviteUrl: `${process.env.NEXT_PUBLIC_APP_URL}/invite/${token}`,
})
await logAudit(membership, 'member.invited', { email, role })
revalidatePath('/settings/members')
}4.1 接受邀请
// app/invite/[token]/page.tsx (Server Component)
export default async function InvitePage({ params }: { params: { token: string } }) {
const [invitation] = await db.select()
.from(invitations)
.where(eq(invitations.token, params.token))
if (!invitation) return <p>Invalid invitation</p>
if (invitation.expiresAt < new Date()) return <p>Invitation expired</p>
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, invitation.tenantId))
return (
<Card>
<h2>Join {tenant.name}</h2>
<p>You've been invited as <strong>{invitation.role}</strong></p>
<form action={acceptInvitation}>
<input type="hidden" name="token" value={params.token} />
<Button type="submit">Accept Invitation</Button>
</form>
</Card>
)
}4.2 Accept Server Action
export async function acceptInvitation(formData: FormData) {
const session = await getSession()
if (!session) redirect('/login')
const token = formData.get('token') as string
const [invitation] = await db.select().from(invitations).where(eq(invitations.token, token))
if (!invitation || invitation.expiresAt < new Date()) {
throw new Error('Invalid or expired invitation')
}
await db.transaction(async (tx) => {
// 创建 membership
await tx.insert(memberships).values({
userId: session.user.id,
tenantId: invitation.tenantId,
role: invitation.role,
}).onConflictDoNothing() // 已是成员则跳过
// 删除邀请
await tx.delete(invitations).where(eq(invitations.id, invitation.id))
})
const cookieStore = await cookies()
cookieStore.set('current_org_id', invitation.tenantId)
redirect('/')
}5. 审计日志
5.1 日志记录
// lib/audit.ts
export async function logAudit(
membership: { userId: string; tenantId: string },
action: string,
metadata?: Record<string, any>
) {
await db.insert(auditLogs).values({
tenantId: membership.tenantId,
userId: membership.userId,
action,
metadata,
})
}5.2 常见审计事件
| 事件 | 触发场景 |
|---|---|
org.created | 创建组织 |
org.updated | 更新组织设置 |
org.deleted | 删除组织 |
member.invited | 发送邀请 |
member.joined | 接受邀请 |
member.removed | 移除成员 |
role.changed | 变更角色 |
subscription.started | 开始订阅 |
subscription.canceled | 取消订阅 |
6. 租户上下文与切换
6.1 获取当前租户
// lib/auth/tenant.ts
import { cookies } from 'next/headers'
import { cache } from 'react'
export const getCurrentTenantId = cache(async (): Promise<string | null> => {
const cookieStore = await cookies()
return cookieStore.get('current_org_id')?.value || null
})
export const getCurrentTenant = cache(async () => {
const tenantId = await getCurrentTenantId()
if (!tenantId) return null
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId))
return tenant || null
})
export const getCurrentMembership = cache(async () => {
const session = await getSession()
const tenantId = await getCurrentTenantId()
if (!session || !tenantId) return null
const [membership] = await db.select().from(memberships)
.where(and(
eq(memberships.userId, session.user.id),
eq(memberships.tenantId, tenantId),
))
return membership || null
})6.2 组织切换
// lib/actions/organization.ts
'use server'
export async function switchOrganization(tenantId: string) {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
// 验证用户是目标组织的成员
const [membership] = await db.select().from(memberships)
.where(and(
eq(memberships.userId, session.user.id),
eq(memberships.tenantId, tenantId),
))
if (!membership) throw new Error('Not a member of this organization')
const cookieStore = await cookies()
cookieStore.set('current_org_id', tenantId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365, // 1 year
})
redirect('/')
}6.3 组织切换器组件
// components/org-switcher.tsx
import { getMyOrganizations, switchOrganization } from '@/lib/actions/organization'
import { getCurrentTenantId } from '@/lib/auth/tenant'
export async function OrgSwitcher() {
const orgs = await getMyOrganizations()
const currentOrgId = await getCurrentTenantId()
return (
<select
defaultValue={currentOrgId || ''}
onChange={async (e) => {
'use server'
await switchOrganization(e.target.value)
}}
>
{orgs.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
)
}
// 获取用户所有组织
export async function getMyOrganizations() {
const session = await getSession()
if (!session) return []
return db.select({
id: tenants.id,
name: tenants.name,
slug: tenants.slug,
plan: tenants.plan,
role: memberships.role,
})
.from(memberships)
.innerJoin(tenants, eq(memberships.tenantId, tenants.id))
.where(eq(memberships.userId, session.user.id))
.orderBy(tenants.name)
}6.4 租户安全查询封装
// lib/db/tenant-query.ts
import { getCurrentTenantId } from '@/lib/auth/tenant'
// 自动注入 tenant_id 的查询包装
export function withTenant<T extends { tenantId: string }>(
queryFn: (tenantId: string) => Promise<T[]>
) {
return async () => {
const tenantId = await getCurrentTenantId()
if (!tenantId) throw new Error('No tenant context')
return queryFn(tenantId)
}
}
// 使用示例
export const getProjects = withTenant(async (tenantId) => {
return db.select().from(projects)
.where(eq(projects.tenantId, tenantId))
.orderBy(desc(projects.updatedAt))
})
// 带参数的查询
export async function getProjectById(projectId: string) {
const tenantId = await getCurrentTenantId()
if (!tenantId) throw new Error('No tenant context')
const [project] = await db.select().from(projects)
.where(and(
eq(projects.id, projectId),
eq(projects.tenantId, tenantId), // 租户隔离
))
if (!project) throw new Error('Project not found')
return project
}6.5 防御性查询规则
| 策略 | 实现 | 说明 |
|---|---|---|
| ORM 层 | withTenant() 自动注入 | 所有查询必须经过 |
| 数据库层 | PostgreSQL RLS 策略 | 即使 ORM 层漏掉,RLS 兜底 |
| Middleware | 验证用户是当前租户成员 | 请求入口拦截 |
| 测试层 | 跨租户数据泄漏测试 | 自动化 CI 检查 |
| Code Review | 检查所有查询是否带租户过滤 | 人工 + lint 规则 |
6.6 跨租户泄漏测试
// tests/tenant-isolation.test.ts
import { describe, it, expect } from 'vitest'
describe('Tenant Isolation', () => {
it('should not return projects from another tenant', async () => {
// 准备:在 tenant-A 创建项目
const projectA = await createProject({ tenantId: 'tenant-a', name: 'Secret' })
// 行为:以 tenant-B 的上下文查询
setCurrentTenant('tenant-b')
const projects = await getProjects()
// 断言:不应该看到 tenant-A 的项目
expect(projects.find(p => p.id === projectA.id)).toBeUndefined()
})
it('should not allow accessing another tenant project by ID', async () => {
const projectA = await createProject({ tenantId: 'tenant-a', name: 'Secret' })
setCurrentTenant('tenant-b')
await expect(getProjectById(projectA.id)).rejects.toThrow('Project not found')
})
})7. 成员管理 UI
7.1 成员列表页面
// app/(dashboard)/settings/members/page.tsx
import { getMembers } from '@/lib/actions/member'
import { getPendingInvitations } from '@/lib/actions/invitation'
export default async function MembersPage() {
const [members, invitations] = await Promise.all([
getMembers(),
getPendingInvitations(),
])
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Team Members</h2>
<InviteMemberDialog />
</div>
<MemberTable members={members} />
{invitations.length > 0 && (
<>
<h3 className="text-lg font-medium">Pending Invitations</h3>
<InvitationTable invitations={invitations} />
</>
)}
</div>
)
}7.2 待处理邀请查询
export async function getPendingInvitations() {
const { membership } = await requirePermission('member:invite')
return db.select({
id: invitations.id,
email: invitations.email,
role: invitations.role,
expiresAt: invitations.expiresAt,
invitedBy: users.name,
})
.from(invitations)
.leftJoin(users, eq(invitations.invitedBy, users.id))
.where(and(
eq(invitations.tenantId, membership.tenantId),
gt(invitations.expiresAt, new Date()), // 只返回未过期的
))
.orderBy(desc(invitations.createdAt))
}7.3 撤销邀请
export async function revokeInvitation(invitationId: string) {
const { membership } = await requirePermission('member:invite')
const [invitation] = await db.select().from(invitations)
.where(and(
eq(invitations.id, invitationId),
eq(invitations.tenantId, membership.tenantId),
))
if (!invitation) throw new Error('Invitation not found')
await db.delete(invitations).where(eq(invitations.id, invitationId))
await logAudit(membership, 'invitation.revoked', { email: invitation.email })
revalidatePath('/settings/members')
}本章小结
- 数据模型:tenants(含 settings JSON)+ memberships(唯一约束)+ invitations + audit_logs
- Slug 生成:
generateUniqueSlug()确保 URL 标识唯一性 - 组织 CRUD:事务创建(org + owner membership),删除前取消 Stripe 订阅,CASCADE 级联
- 成员管理:requirePermission 守卫,Owner 不可修改/移除,操作前检查套餐成员限制
- 邀请流程:Token 72h 过期、防重复邀请、检查已有成员、套餐限额检查、邮件通知
- 租户上下文:
getCurrentTenant()+ cache(),Cookie 持久化当前组织 - 组织切换:验证成员身份后设置 Cookie,OrgSwitcher 组件
- 安全查询:
withTenant()ORM 层 + RLS 数据库层 + 跨租户泄漏测试 - UI 实现:成员列表 + 待处理邀请 + 撤销邀请