多租户与团队管理

第 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 实现:成员列表 + 待处理邀请 + 撤销邀请