后台管理面板
SaaS 产品需要两个"Dashboard"——用户看的产品 Dashboard 和运营看的 Admin Dashboard。Admin Dashboard 服务于内部团队,用于用户管理、数据概览、系统配置、问题排查。本章讲解如何用 Next.js 构建安全、高效的管理后台。
1. Admin Dashboard 架构
1.1 Admin vs 用户 Dashboard
| 维度 | 用户 Dashboard | Admin Dashboard |
|---|---|---|
| 访问者 | 客户 | 内部团队 |
| 权限 | 基于角色 | 超级管理员 |
| 数据范围 | 单租户 | 跨租户 |
| 安全级别 | 高 | 最高 |
| 路由 | /dashboard | /admin |
1.2 路由设计
app/
├── (admin)/ ← Admin 专用路由组
│ ├── layout.tsx ← Admin Layout + 超管权限检查
│ ├── admin/
│ │ ├── page.tsx ← 数据概览
│ │ ├── users/page.tsx ← 用户管理
│ │ ├── tenants/page.tsx ← 租户管理
│ │ ├── billing/page.tsx ← 收入数据
│ │ └── system/page.tsx ← 系统配置
1.3 超管认证
// app/(admin)/layout.tsx
import { getSession } from '@/lib/auth/session'
import { redirect } from 'next/navigation'
const SUPER_ADMIN_EMAILS = process.env.SUPER_ADMIN_EMAILS?.split(',') || []
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
if (!session || !SUPER_ADMIN_EMAILS.includes(session.user.email)) {
redirect('/') // 非超管静默重定向,不暴露 Admin 的存在
}
return (
<div className="flex h-screen">
<AdminSidebar />
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
)
}1.4 Admin Sidebar
// components/admin/sidebar.tsx
import Link from 'next/link'
import { LayoutDashboard, Users, Building, CreditCard, Settings, Flag, Activity } from 'lucide-react'
const NAV_ITEMS = [
{ label: 'Overview', href: '/admin', icon: LayoutDashboard },
{ label: 'Users', href: '/admin/users', icon: Users },
{ label: 'Tenants', href: '/admin/tenants', icon: Building },
{ label: 'Billing', href: '/admin/billing', icon: CreditCard },
{ label: 'Feature Flags', href: '/admin/flags', icon: Flag },
{ label: 'System', href: '/admin/system', icon: Activity },
{ label: 'Settings', href: '/admin/settings', icon: Settings },
]
export function AdminSidebar() {
return (
<aside className="w-64 border-r bg-muted/30 p-4">
<div className="mb-6 px-2">
<h1 className="text-lg font-bold">Admin Panel</h1>
<p className="text-xs text-muted-foreground">Internal only</p>
</div>
<nav className="space-y-1">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-muted"
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
</aside>
)
}1.5 Middleware 安全加固
// middleware.ts(Admin 路径额外保护)
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Admin 路径:确保已登录(Middleware 层不做细粒度权限,由 Layout 负责)
if (pathname.startsWith('/admin')) {
const session = request.cookies.get('session')
if (!session) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
return NextResponse.next()
}2. 数据概览
2.1 核心指标
| 指标 | 说明 | 计算方式 |
|---|---|---|
| MRR | 月度经常性收入 | 所有活跃订阅月费之和 |
| 总用户数 | 注册用户总数 | count(users) |
| 活跃租户 | 本月有登录的租户 | 近 30 天有活动 |
| 转化率 | 免费 → 付费 | 付费租户 / 总租户 |
| 流失率 | 取消订阅 / 总付费 | 本月取消 / 上月付费 |
2.2 数据查询
// app/(admin)/admin/actions.ts
export async function getDashboardMetrics() {
const [
[{ totalUsers }],
[{ totalTenants }],
[{ paidTenants }],
[{ mrr }],
] = await Promise.all([
db.select({ totalUsers: sql<number>`count(*)` }).from(users),
db.select({ totalTenants: sql<number>`count(*)` }).from(tenants),
db.select({ paidTenants: sql<number>`count(*)` }).from(tenants)
.where(ne(tenants.plan, 'free')),
db.select({
mrr: sql<number>`coalesce(sum(
case when ${subscriptions.status} = 'active'
then ${subscriptions.monthlyAmount} else 0 end
), 0)`,
}).from(subscriptions),
])
return {
totalUsers, totalTenants, paidTenants, mrr,
conversionRate: totalTenants > 0 ? (paidTenants / totalTenants * 100).toFixed(1) : '0',
}
}2.3 时序数据
export async function getSignupTrend(days: number = 30) {
return db.select({
date: sql<string>`date_trunc('day', ${users.createdAt})::date`,
count: sql<number>`count(*)`,
})
.from(users)
.where(gte(users.createdAt, sql`now() - interval '${sql.raw(String(days))} days'`))
.groupBy(sql`date_trunc('day', ${users.createdAt})`)
.orderBy(sql`date_trunc('day', ${users.createdAt})`)
}2.4 Overview 页面
// app/(admin)/admin/page.tsx
import { getDashboardMetrics, getSignupTrend } from './actions'
export default async function AdminOverview() {
const [metrics, signupTrend] = await Promise.all([
getDashboardMetrics(),
getSignupTrend(30),
])
return (
<div className="space-y-8">
<h2 className="text-2xl font-bold">Dashboard</h2>
<div className="grid grid-cols-4 gap-4">
<MetricCard title="Total Users" value={metrics.totalUsers} />
<MetricCard title="Tenants" value={metrics.totalTenants} />
<MetricCard title="MRR" value={`$${metrics.mrr}`} />
<MetricCard title="Conversion" value={`${metrics.conversionRate}%`} />
</div>
<div className="rounded-lg border p-6">
<h3 className="mb-4 font-semibold">Signups (30 days)</h3>
<SignupChart data={signupTrend} />
</div>
</div>
)
}
function MetricCard({ title, value }: { title: string; value: string | number }) {
return (
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">{title}</p>
<p className="text-2xl font-bold">{value}</p>
</div>
)
}2.5 收入指标详情
export async function getRevenueMetrics() {
const now = new Date()
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const [currentMRR, lastMRR, newSubscriptions, churned] = await Promise.all([
// 当前 MRR
db.select({
mrr: sql<number>`coalesce(sum(${subscriptions.monthlyAmount}), 0)`,
}).from(subscriptions).where(eq(subscriptions.status, 'active')),
// 上月 MRR(快照或近似)
db.select({
mrr: sql<number>`coalesce(sum(${subscriptions.monthlyAmount}), 0)`,
}).from(subscriptions).where(and(
eq(subscriptions.status, 'active'),
lt(subscriptions.createdAt, thisMonth),
)),
// 本月新增订阅
db.select({ count: sql<number>`count(*)` }).from(subscriptions)
.where(and(
gte(subscriptions.createdAt, thisMonth),
eq(subscriptions.status, 'active'),
)),
// 本月流失
db.select({ count: sql<number>`count(*)` }).from(subscriptions)
.where(and(
gte(subscriptions.canceledAt, thisMonth),
eq(subscriptions.status, 'canceled'),
)),
])
return {
currentMRR: currentMRR[0].mrr,
lastMRR: lastMRR[0].mrr,
mrrGrowth: lastMRR[0].mrr > 0
? ((currentMRR[0].mrr - lastMRR[0].mrr) / lastMRR[0].mrr * 100).toFixed(1)
: '0',
newSubscriptions: newSubscriptions[0].count,
churned: churned[0].count,
}
}3. 用户管理
3.1 用户列表
export async function getAdminUsers(filters: {
search?: string
page?: number
}) {
const { search, page = 1 } = filters
const limit = 50
const conditions = []
if (search) {
conditions.push(or(
ilike(users.email, `%${search}%`),
ilike(users.name, `%${search}%`),
))
}
const [userList, [{ count }]] = await Promise.all([
db.select({
id: users.id, email: users.email, name: users.name,
createdAt: users.createdAt,
orgCount: sql<number>`count(${memberships.id})`,
})
.from(users)
.leftJoin(memberships, eq(users.id, memberships.userId))
.where(conditions.length ? and(...conditions) : undefined)
.groupBy(users.id)
.orderBy(desc(users.createdAt))
.limit(limit).offset((page - 1) * limit),
db.select({ count: sql<number>`count(*)` }).from(users)
.where(conditions.length ? and(...conditions) : undefined),
])
return { users: userList, total: count }
}3.2 管理操作
| 操作 | 权限 | 风险 |
|---|---|---|
| 查看用户详情 | Admin | 低 |
| 禁用账户 | Admin | 中 |
| 删除用户 | Super Admin | 高 |
| 模拟登录 | Super Admin + 日志 | 高 |
| 修改订阅 | Admin | 中 |
3.3 用户管理页面
// app/(admin)/admin/users/page.tsx
import { getAdminUsers } from '../actions'
export default async function AdminUsersPage({
searchParams,
}: {
searchParams: { search?: string; page?: string }
}) {
const { users: userList, total } = await getAdminUsers({
search: searchParams.search,
page: Number(searchParams.page) || 1,
})
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Users ({total})</h2>
<SearchInput placeholder="Search by email or name..." />
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">User</th>
<th className="pb-2">Email</th>
<th className="pb-2">Orgs</th>
<th className="pb-2">Joined</th>
<th className="pb-2">Actions</th>
</tr>
</thead>
<tbody>
{userList.map((user) => (
<tr key={user.id} className="border-b">
<td className="py-3">{user.name || '—'}</td>
<td className="py-3">{user.email}</td>
<td className="py-3">{user.orgCount}</td>
<td className="py-3">{user.createdAt.toLocaleDateString()}</td>
<td className="py-3">
<UserActions userId={user.id} email={user.email} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}3.4 禁用/启用用户
export async function toggleUserStatus(userId: string, disabled: boolean) {
const session = await getSession()
if (!SUPER_ADMIN_EMAILS.includes(session!.user.email)) {
throw new Error('Forbidden')
}
await db.update(users)
.set({ disabled, updatedAt: new Date() })
.where(eq(users.id, userId))
await logAudit({
tenantId: 'system',
actorId: session!.user.id,
actorEmail: session!.user.email,
action: disabled ? 'admin.user_disabled' : 'admin.user_enabled',
targetType: 'user',
targetId: userId,
})
revalidatePath('/admin/users')
}3.5 模拟登录(Impersonation)
export async function impersonateUser(userId: string) {
// 只有超管可以模拟登录
const session = await getSession()
if (!SUPER_ADMIN_EMAILS.includes(session!.user.email)) {
throw new Error('Forbidden')
}
// 记录审计日志——模拟登录必须留痕
await logAudit({
tenantId: 'system',
actorId: session!.user.id,
actorEmail: session!.user.email,
action: 'admin.impersonate',
targetType: 'user',
targetId: userId,
})
// 创建临时 session(带标记,UI 显示"正在模拟")
const cookieStore = await cookies()
cookieStore.set('impersonating', userId, { maxAge: 3600 })
cookieStore.set('original_admin', session!.user.id, { maxAge: 3600 })
redirect('/')
}
// 结束模拟
export async function stopImpersonation() {
const cookieStore = await cookies()
cookieStore.delete('impersonating')
cookieStore.delete('original_admin')
redirect('/admin/users')
}3.6 模拟登录提示条
// components/impersonation-banner.tsx
import { cookies } from 'next/headers'
export async function ImpersonationBanner() {
const cookieStore = await cookies()
const impersonating = cookieStore.get('impersonating')?.value
if (!impersonating) return null
const [user] = await db.select().from(users).where(eq(users.id, impersonating))
return (
<div className="sticky top-0 z-50 bg-yellow-500 px-4 py-2 text-center text-sm font-medium text-black">
You are impersonating <strong>{user?.email}</strong>
<form action={stopImpersonation} className="inline">
<button type="submit" className="ml-4 underline">Stop Impersonating</button>
</form>
</div>
)
}4. 租户管理
4.1 租户列表查询
export async function getAdminTenants(filters: {
search?: string
plan?: string
page?: number
}) {
const { search, plan, page = 1 } = filters
const limit = 50
const conditions = []
if (search) conditions.push(ilike(tenants.name, `%${search}%`))
if (plan) conditions.push(eq(tenants.plan, plan))
const [tenantList, [{ count }]] = await Promise.all([
db.select({
id: tenants.id,
name: tenants.name,
slug: tenants.slug,
plan: tenants.plan,
createdAt: tenants.createdAt,
memberCount: sql<number>`count(distinct ${memberships.userId})`,
})
.from(tenants)
.leftJoin(memberships, eq(tenants.id, memberships.tenantId))
.where(conditions.length ? and(...conditions) : undefined)
.groupBy(tenants.id)
.orderBy(desc(tenants.createdAt))
.limit(limit).offset((page - 1) * limit),
db.select({ count: sql<number>`count(*)` }).from(tenants)
.where(conditions.length ? and(...conditions) : undefined),
])
return { tenants: tenantList, total: count }
}4.2 手动调整租户套餐
export async function adminChangeTenantPlan(tenantId: string, newPlan: string) {
const session = await getSession()
if (!SUPER_ADMIN_EMAILS.includes(session!.user.email)) {
throw new Error('Forbidden')
}
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId))
await db.update(tenants)
.set({ plan: newPlan, updatedAt: new Date() })
.where(eq(tenants.id, tenantId))
await logAudit({
tenantId: 'system',
actorId: session!.user.id,
actorEmail: session!.user.email,
action: 'admin.plan_changed',
targetType: 'tenant',
targetId: tenantId,
metadata: { from: tenant.plan, to: newPlan },
})
revalidatePath('/admin/tenants')
}5. Feature Flags
5.1 Feature Flags 用途
| 场景 | 说明 |
|---|---|
| 渐进发布 | 先给 10% 用户开放新功能 |
| A/B 测试 | 对比不同方案的转化率 |
| Kill Switch | 快速关闭出问题的功能 |
| Beta 测试 | 特定租户优先体验 |
5.2 数据模型
// lib/db/schema.ts
export const featureFlags = pgTable('feature_flags', {
id: uuid('id').primaryKey().defaultRandom(),
key: text('key').notNull().unique(), // 'new-dashboard', 'ai-assistant'
enabled: boolean('enabled').default(false).notNull(),
description: text('description'),
rolloutPercentage: integer('rollout_percentage').default(0), // 0-100
enabledTenants: text('enabled_tenants').array(), // 指定租户
enabledPlans: text('enabled_plans').array(), // 指定套餐
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})5.3 Feature Flag 检查
// lib/feature-flags.ts
import { cache } from 'react'
export const isFeatureEnabled = cache(async (key: string, context?: {
tenantId?: string
plan?: string
userId?: string
}) => {
const [flag] = await db.select().from(featureFlags).where(eq(featureFlags.key, key))
if (!flag) return false
// 全局启用
if (flag.enabled) return true
// 按租户启用
if (context?.tenantId && flag.enabledTenants?.includes(context.tenantId)) return true
// 按套餐启用
if (context?.plan && flag.enabledPlans?.includes(context.plan)) return true
// 按百分比灰度
if (flag.rolloutPercentage > 0 && context?.userId) {
const hash = simpleHash(context.userId + key)
return (hash % 100) < flag.rolloutPercentage
}
return false
})
function simpleHash(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}5.4 Feature Flag 管理 API
// app/(admin)/admin/flags/actions.ts
export async function updateFeatureFlag(flagId: string, data: {
enabled?: boolean
rolloutPercentage?: number
enabledTenants?: string[]
}) {
const session = await getSession()
if (!SUPER_ADMIN_EMAILS.includes(session!.user.email)) {
throw new Error('Forbidden')
}
const [current] = await db.select().from(featureFlags).where(eq(featureFlags.id, flagId))
await db.update(featureFlags)
.set({ ...data, updatedAt: new Date() })
.where(eq(featureFlags.id, flagId))
await logAudit({
tenantId: 'system',
actorId: session!.user.id,
actorEmail: session!.user.email,
action: 'admin.flag_updated',
targetType: 'feature_flag',
targetId: flagId,
metadata: { key: current.key, changes: data },
})
revalidatePath('/admin/flags')
}5.5 在 Server Component 中使用
// app/(dashboard)/projects/page.tsx
export default async function ProjectsPage() {
const tenant = await getCurrentTenant()
const session = await getSession()
const showAI = await isFeatureEnabled('ai-assistant', {
tenantId: tenant?.id,
plan: tenant?.plan,
userId: session?.user.id,
})
return (
<div>
<ProjectList />
{showAI && <AIAssistantPanel />}
</div>
)
}6. 系统健康检查
6.1 健康检查端点
// app/api/admin/health/route.ts
export async function GET() {
const checks = await Promise.allSettled([
checkDatabase(),
checkStripe(),
checkEmail(),
])
const results = {
database: checks[0].status === 'fulfilled' ? 'healthy' : 'unhealthy',
stripe: checks[1].status === 'fulfilled' ? 'healthy' : 'unhealthy',
email: checks[2].status === 'fulfilled' ? 'healthy' : 'unhealthy',
}
const allHealthy = Object.values(results).every(v => v === 'healthy')
return Response.json({
status: allHealthy ? 'healthy' : 'degraded',
checks: results,
timestamp: new Date().toISOString(),
version: process.env.NEXT_PUBLIC_APP_VERSION,
}, { status: allHealthy ? 200 : 503 })
}
async function checkDatabase() {
await db.execute(sql`SELECT 1`)
}
async function checkStripe() {
await stripe.balance.retrieve()
}
async function checkEmail() {
// Resend API key validation
const res = await fetch('https://api.resend.com/domains', {
headers: { 'Authorization': `Bearer ${process.env.RESEND_API_KEY}` },
})
if (!res.ok) throw new Error('Email service unavailable')
}6.2 系统状态页面
// app/(admin)/admin/system/page.tsx
export default async function SystemPage() {
const health = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/admin/health`)
.then(r => r.json())
const dbStats = await db.select({
tableCount: sql<number>`count(*)`,
}).from(sql`information_schema.tables`)
.where(eq(sql`table_schema`, 'public'))
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">System Status</h2>
<div className="grid grid-cols-3 gap-4">
{Object.entries(health.checks).map(([name, status]) => (
<div key={name} className="rounded-lg border p-4">
<p className="text-sm capitalize text-muted-foreground">{name}</p>
<p className={`text-lg font-bold ${
status === 'healthy' ? 'text-green-600' : 'text-red-600'
}`}>
{status as string}
</p>
</div>
))}
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Version</p>
<p className="font-mono">{health.version || 'unknown'}</p>
</div>
</div>
)
}本章小结
- 架构:Admin 路由组
(admin)+ 超管邮箱白名单,非超管静默重定向不暴露存在 - Sidebar:Lucide 图标导航,Overview / Users / Tenants / Billing / Flags / System
- 数据概览:MRR、用户数、转化率、MRR 增长率、注册趋势,全部并行查询
- 收入分析:当前 MRR vs 上月 MRR、新增订阅数、流失数
- 用户管理:搜索 + 分页列表,禁用/启用、模拟登录(Cookie 标记 + 黄色提示条)
- 模拟登录:必须超管权限 + 审计日志留痕 + 1h 自动过期 + 可手动结束
- 租户管理:按名称/套餐筛选,手动调整套餐(审计留痕)
- Feature Flags:数据库存储,支持全局/按租户/按套餐/按百分比灰度
- 健康检查:数据库 + Stripe + Email 三项检查,降级返回 503
- 安全:所有管理操作都记录审计日志,Middleware 层确保已登录