后台管理面板

SaaS 产品需要两个"Dashboard"——用户看的产品 Dashboard 和运营看的 Admin Dashboard。Admin Dashboard 服务于内部团队,用于用户管理、数据概览、系统配置、问题排查。本章讲解如何用 Next.js 构建安全、高效的管理后台。

1. Admin Dashboard 架构

1.1 Admin vs 用户 Dashboard

维度用户 DashboardAdmin 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 层确保已登录