文档
开发指南组织管理

组织管理

🚀 Cheatsheet (快速执行)

目标: 20分钟内实现完整的组织管理功能,支持团队协作和权限控制

最快上手方式:

# 1. 数据库已经包含组织相关表
# 2. 创建组织管理页面
mkdir -p app/\[organizationSlug\]/settings
touch app/\[organizationSlug\]/settings/members/page.tsx

# 3. 测试功能
bun dev
# 访问 http://localhost:3000/your-org/settings/members

立即生效: 用户可以创建组织、邀请成员、分配角色权限

❓ 为什么需要组织管理

对 MVP 的核心价值:

  • 团队协作: 多人协作提高工作效率
  • B2B销售: 企业客户需要团队功能才会付费
  • 数据隔离: 不同组织的数据完全分离
  • 权限控制: 不同角色不同权限,满足企业安全需求

真实场景举例: 你的 AI 写作工具需要团队版,公司的内容团队可以一起协作,主管可以管理成员权限,普通成员只能编辑自己的内容。

🤔 为什么选择多租户架构

我们的实战经验:

  • 商业价值: B2B 客户付费能力强,团队功能是必需品
  • 技术简单: 通过 organizationId 过滤数据即可
  • 易于扩展: 单一代码库服务多个客户
  • 运维高效: 统一部署和升级

对比其他方案:

单用户架构:     简单但无法 B2B 销售
多实例部署:     维护成本高,难以扩展
复杂权限系统:   过度设计,开发周期长
多租户架构: 平衡复杂度和商业价值

🧠 简要原理 & 最简案例

组织数据隔离原理: 每个数据表都有 organizationId 字段 → 查询时自动添加组织过滤条件 → 实现数据隔离

最简组织架构:

// 数据库查询示例
const posts = await db.post.findMany({
  where: {
    organizationId: user.currentOrganizationId, // 自动过滤组织数据
    authorId: user.id,
  }
})

// 权限检查示例
function checkPermission(user: User, action: string) {
  const membership = user.organizationMemberships.find(
    m => m.organizationId === user.currentOrganizationId
  )
  
  return membership?.role === 'admin' || membership?.role === 'owner'
}

🛠️ 实际操作步骤

1. 创建组织管理页面 (15分钟)

组织设置布局:

// app/[organizationSlug]/settings/layout.tsx
import { auth } from '@/lib/auth'
import { getOrganization } from '@/lib/organizations'
import { SettingsNav } from '@/modules/settings/SettingsNav'

export default async function SettingsLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: { organizationSlug: string }
}) {
  const session = await auth()
  const organization = await getOrganization(params.organizationSlug)

  if (!organization) {
    return <div>组织不存在</div>
  }

  return (
    <div className="container max-w-6xl mx-auto py-8">
      <div className="flex gap-8">
        <aside className="w-64">
          <SettingsNav organizationSlug={params.organizationSlug} />
        </aside>
        <main className="flex-1">
          {children}
        </main>
      </div>
    </div>
  )
}

成员管理页面:

// app/[organizationSlug]/settings/members/page.tsx
import { auth } from '@/lib/auth'
import { getOrganizationMembers } from '@/lib/organizations'
import { MembersList } from '@/modules/organizations/MembersList'
import { InviteMemberForm } from '@/modules/organizations/InviteMemberForm'

export default async function MembersPage({
  params,
}: {
  params: { organizationSlug: string }
}) {
  const session = await auth()
  const members = await getOrganizationMembers(params.organizationSlug)

  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold">团队成员</h1>
        <p className="text-gray-600">管理组织成员和权限</p>
      </div>

      <InviteMemberForm organizationSlug={params.organizationSlug} />
      
      <MembersList
        members={members}
        organizationSlug={params.organizationSlug}
      />
    </div>
  )
}

2. 实现成员邀请功能 (10分钟)

邀请表单组件:

// components/organizations/InviteMemberForm.tsx
"use client"

import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'

export function InviteMemberForm({ organizationSlug }: { organizationSlug: string }) {
  const [email, setEmail] = useState('')
  const [role, setRole] = useState<'member' | 'admin'>('member')
  const [loading, setLoading] = useState(false)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)

    try {
      const response = await fetch(`/api/organizations/${organizationSlug}/invitations`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, role }),
      })

      if (response.ok) {
        setEmail('')
        alert('邀请已发送!')
        window.location.reload()
      } else {
        const error = await response.json()
        alert(error.message || '邀请失败')
      }
    } catch (error) {
      alert('邀请失败,请重试')
    } finally {
      setLoading(false)
    }
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle>邀请新成员</CardTitle>
      </CardHeader>
      <CardContent>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <Input
              type="email"
              placeholder="邮箱地址"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
            />
          </div>
          
          <div>
            <Select value={role} onValueChange={(value: 'member' | 'admin') => setRole(value)}>
              <SelectTrigger>
                <SelectValue placeholder="选择角色" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="member">普通成员</SelectItem>
                <SelectItem value="admin">管理员</SelectItem>
              </SelectContent>
            </Select>
          </div>

          <Button type="submit" disabled={loading}>
            {loading ? '发送中...' : '发送邀请'}
          </Button>
        </form>
      </CardContent>
    </Card>
  )
}

3. 成员列表和权限管理 (10分钟)

成员列表组件:

// components/organizations/MembersList.tsx
"use client"

import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table'

interface Member {
  id: string
  user: {
    name: string
    email: string
    image?: string
  }
  role: 'owner' | 'admin' | 'member'
  joinedAt: string
}

export function MembersList({ 
  members, 
  organizationSlug 
}: { 
  members: Member[]
  organizationSlug: string 
}) {
  const [loading, setLoading] = useState<string | null>(null)

  const updateMemberRole = async (memberId: string, newRole: string) => {
    setLoading(memberId)
    try {
      const response = await fetch(`/api/organizations/${organizationSlug}/members/${memberId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ role: newRole }),
      })

      if (response.ok) {
        window.location.reload()
      } else {
        alert('更新失败')
      }
    } catch (error) {
      alert('更新失败')
    } finally {
      setLoading(null)
    }
  }

  const removeMember = async (memberId: string) => {
    if (!confirm('确定要移除此成员吗?')) return

    setLoading(memberId)
    try {
      const response = await fetch(`/api/organizations/${organizationSlug}/members/${memberId}`, {
        method: 'DELETE',
      })

      if (response.ok) {
        window.location.reload()
      } else {
        alert('移除失败')
      }
    } catch (error) {
      alert('移除失败')
    } finally {
      setLoading(null)
    }
  }

  const getRoleBadge = (role: string) => {
    const variants = {
      owner: 'default',
      admin: 'secondary',
      member: 'outline',
    } as const

    const labels = {
      owner: '所有者',
      admin: '管理员',
      member: '普通成员',
    }

    return (
      <Badge variant={variants[role as keyof typeof variants]}>
        {labels[role as keyof typeof labels]}
      </Badge>
    )
  }

  return (
    <div className="border rounded-lg">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>成员</TableHead>
            <TableHead>角色</TableHead>
            <TableHead>加入时间</TableHead>
            <TableHead>操作</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {members.map((member) => (
            <TableRow key={member.id}>
              <TableCell>
                <div className="flex items-center gap-3">
                  <Avatar className="h-8 w-8">
                    <AvatarImage src={member.user.image} alt={member.user.name} />
                    <AvatarFallback>
                      {member.user.name.slice(0, 2).toUpperCase()}
                    </AvatarFallback>
                  </Avatar>
                  <div>
                    <div className="font-medium">{member.user.name}</div>
                    <div className="text-sm text-gray-500">{member.user.email}</div>
                  </div>
                </div>
              </TableCell>
              <TableCell>
                {getRoleBadge(member.role)}
              </TableCell>
              <TableCell>
                {new Date(member.joinedAt).toLocaleDateString()}
              </TableCell>
              <TableCell>
                {member.role !== 'owner' && (
                  <div className="flex gap-2">
                    {member.role === 'member' && (
                      <Button
                        size="sm"
                        variant="outline"
                        onClick={() => updateMemberRole(member.id, 'admin')}
                        disabled={loading === member.id}
                      >
                        提升为管理员
                      </Button>
                    )}
                    {member.role === 'admin' && (
                      <Button
                        size="sm"
                        variant="outline"
                        onClick={() => updateMemberRole(member.id, 'member')}
                        disabled={loading === member.id}
                      >
                        降为普通成员
                      </Button>
                    )}
                    <Button
                      size="sm"
                      variant="destructive"
                      onClick={() => removeMember(member.id)}
                      disabled={loading === member.id}
                    >
                      移除
                    </Button>
                  </div>
                )}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}

4. API 路由实现 (15分钟)

邀请成员API:

// app/api/organizations/[slug]/invitations/route.ts
import { auth } from '@/lib/auth'
import { db } from '@/lib/database'
import { sendInvitationEmail } from '@/lib/mail/templates'
import { z } from 'zod'

const InviteSchema = z.object({
  email: z.string().email(),
  role: z.enum(['member', 'admin']),
})

export async function POST(
  request: Request,
  { params }: { params: { slug: string } }
) {
  try {
    const session = await auth()
    if (!session?.user) {
      return Response.json({ error: '请先登录' }, { status: 401 })
    }

    // 检查用户是否有邀请权限
    const membership = await db.organizationMember.findFirst({
      where: {
        organizationId: params.slug,
        userId: session.user.id,
        role: { in: ['owner', 'admin'] },
      },
    })

    if (!membership) {
      return Response.json({ error: '权限不足' }, { status: 403 })
    }

    const body = await request.json()
    const { email, role } = InviteSchema.parse(body)

    // 检查用户是否已经是成员
    const existingMember = await db.organizationMember.findFirst({
      where: {
        organizationId: params.slug,
        user: { email },
      },
    })

    if (existingMember) {
      return Response.json({ error: '用户已经是组织成员' }, { status: 400 })
    }

    // 创建邀请
    const invitation = await db.organizationInvitation.create({
      data: {
        organizationId: params.slug,
        email,
        role,
        invitedBy: session.user.id,
        token: crypto.randomUUID(),
        expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
      },
    })

    // 发送邀请邮件
    const inviteUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/invite/${invitation.token}`
    await sendInvitationEmail(email, inviteUrl)

    return Response.json({ success: true })

  } catch (error) {
    console.error('邀请成员失败:', error)
    return Response.json({ error: '邀请失败' }, { status: 500 })
  }
}

5. 权限检查中间件 (5分钟)

组织权限验证:

// lib/organizations/permissions.ts
import { auth } from '@/lib/auth'
import { db } from '@/lib/database'

export async function checkOrganizationPermission(
  organizationSlug: string,
  requiredRole: 'owner' | 'admin' | 'member' = 'member'
) {
  const session = await auth()
  if (!session?.user) {
    throw new Error('请先登录')
  }

  const membership = await db.organizationMember.findFirst({
    where: {
      organizationId: organizationSlug,
      userId: session.user.id,
    },
    include: {
      organization: true,
    },
  })

  if (!membership) {
    throw new Error('不是组织成员')
  }

  const roleHierarchy = { owner: 3, admin: 2, member: 1 }
  const userLevel = roleHierarchy[membership.role as keyof typeof roleHierarchy]
  const requiredLevel = roleHierarchy[requiredRole]

  if (userLevel < requiredLevel) {
    throw new Error('权限不足')
  }

  return {
    user: session.user,
    membership,
    organization: membership.organization,
  }
}

// 使用示例
export async function requireAdmin(organizationSlug: string) {
  return checkOrganizationPermission(organizationSlug, 'admin')
}

💡 组织切换功能

组织选择器:

// components/organizations/OrganizationSwitcher.tsx
"use client"

import { useRouter } from 'next/navigation'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover'

interface Organization {
  id: string
  name: string
  slug: string
}

export function OrganizationSwitcher({ 
  organizations, 
  currentOrg 
}: { 
  organizations: Organization[]
  currentOrg: Organization 
}) {
  const router = useRouter()

  const switchOrganization = (slug: string) => {
    router.push(`/${slug}`)
  }

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline" className="w-[200px] justify-between">
          {currentOrg.name}
          <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-[200px] p-0">
        {organizations.map((org) => (
          <Button
            key={org.id}
            variant="ghost"
            className="w-full justify-start"
            onClick={() => switchOrganization(org.slug)}
          >
            <Check
              className={`mr-2 h-4 w-4 ${
                org.id === currentOrg.id ? 'opacity-100' : 'opacity-0'
              }`}
            />
            {org.name}
          </Button>
        ))}
      </PopoverContent>
    </Popover>
  )
}

🆘 常见问题解决

问题1: 数据隔离不完整

// 确保所有查询都包含组织过滤
const posts = await db.post.findMany({
  where: {
    organizationId: currentOrgId, // 关键:必须包含
    // ... 其他条件
  }
})

问题2: 权限检查遗漏

// 在敏感操作前检查权限
export async function updatePost(postId: string, organizationSlug: string) {
  await requireAdmin(organizationSlug) // 先检查权限
  
  // 再执行操作
  return db.post.update({
    where: { id: postId },
    data: { ... }
  })
}

问题3: 组织切换路由问题

// middleware.ts 中处理组织路由
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  
  // 匹配组织路由 /[org]/...
  const orgMatch = pathname.match(/^\/([^\/]+)/)
  if (orgMatch) {
    const orgSlug = orgMatch[1]
    // 验证组织是否存在
    // 验证用户权限
  }
}

📎 延伸阅读

技术文档:

最佳实践:


下一步: 组织管理配置完成后,查看 [支付系统]./payments) 了解如何实现组织级订阅计费。