Skip to content

后台管理面板

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 超管认证

ts
// 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

tsx
// 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 安全加固

ts
// 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 数据查询

ts
// 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 时序数据

ts
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 页面

tsx
// 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 收入指标详情

ts
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 用户列表

ts
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 用户管理页面

tsx
// 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 禁用/启用用户

ts
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)

ts
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 模拟登录提示条

tsx
// 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 租户列表查询

ts
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 手动调整租户套餐

ts
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 数据模型

ts
// 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 检查

ts
// 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

ts
// 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 中使用

tsx
// 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 健康检查端点

ts
// 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 系统状态页面

tsx
// 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 层确保已登录

基于 MIT 协议开源