开发指南组织管理
组织管理
🚀 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]
// 验证组织是否存在
// 验证用户权限
}
}
📎 延伸阅读
技术文档:
- Multi-tenancy Patterns - 多租户架构模式
- RBAC 设计指南 - 基于角色的访问控制
最佳实践:
- dashboard Security - dashboard 应用安全指南
- Data Isolation - 数据隔离最佳实践
下一步: 组织管理配置完成后,查看 [支付系统]./payments) 了解如何实现组织级订阅计费。