Sistine Starter

管理后台

Sistine Starter 管理后台完整使用指南

管理后台

管理后台为管理员提供了强大的用户管理、订阅管理和积分管理能力。本文档详细介绍管理员权限设置、功能模块使用和最佳实践。

管理员权限设置

权限标识

管理员权限通过数据库中的 user.role 字段标识:

该字段位于 user 表中,建表语句请参考 数据库 文档。

角色类型:

角色标识权限
普通用户user访问用户功能 (仪表板、聊天、积分购买等)
管理员admin访问所有用户功能 + 管理后台

创建管理员账户

方法 1: 使用 pnpm 命令 (推荐)

前置条件:

⚠️ 在运行命令之前,请确保:

  1. 你已经在网站上完成了账户注册(使用邮箱密码或 Google OAuth)
  2. 记住注册时使用的邮箱地址

项目提供了便捷的管理员权限提升脚本:

ADMIN_EMAIL=your-email@example.com pnpm admin:setup

重要说明:

  • 将命令中的 your-email@example.com 替换为你在网站上注册的真实用户邮箱
  • 此命令会将现有用户提升为管理员,而不是创建新账户
  • 如果该邮箱对应的用户不存在,命令会失败

使用示例:

# 将 alice@company.com 用户提升为管理员
ADMIN_EMAIL=alice@company.com pnpm admin:setup

命令执行后的输出:

✅ 管理员权限设置成功!
📧 邮箱: alice@company.com
🔑 角色: admin
💰 当前积分: 300

方法 2: 数据库手动设置

如果已有账户需要提升为管理员:

使用 SQL 命令:

UPDATE "user"
SET "role" = 'admin'
WHERE "email" = 'your-email@example.com';

使用 Drizzle Studio:

# 启动 Drizzle Studio
pnpm db:studio

# 在浏览器中打开 https://local.drizzle.studio
# 找到对应用户,修改 role 字段为 'admin'

方法 3: 通过代码创建

import { db } from "@/lib/db";
import { user } from "@/lib/db/schema";
import { eq } from "drizzle-orm";

// 提升现有用户为管理员
await db
  .update(user)
  .set({ role: "admin" })
  .where(eq(user.email, "admin@example.com"));

权限验证机制

管理后台使用 lib/auth/admin.ts 中的函数进行权限验证:

isAdmin()

检查当前用户是否为管理员:

import { isAdmin } from "@/lib/auth/admin";

const adminStatus = await isAdmin();
if (adminStatus) {
  // 用户是管理员
}

实现逻辑:

export async function isAdmin(): Promise<boolean> {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session || !session.user) {
    return false;
  }

  const dbUser = await db
    .select({ role: user.role })
    .from(user)
    .where(eq(user.id, session.user.id))
    .limit(1);

  return dbUser[0]?.role === "admin";
}

requireAdmin()

保护管理员路由,非管理员自动重定向:

import { requireAdmin } from "@/lib/auth/admin";

export default async function AdminPage() {
  await requireAdmin(); // 非管理员会被重定向到 /dashboard

  // 管理员专属逻辑
}

实现逻辑:

export async function requireAdmin() {
  const adminStatus = await isAdmin();

  if (!adminStatus) {
    redirect("/dashboard"); // 重定向到仪表板
  }
}

getCurrentUserWithRole()

获取当前用户的完整信息 (包括角色):

import { getCurrentUserWithRole } from "@/lib/auth/admin";

const currentUser = await getCurrentUserWithRole();
if (currentUser?.role === "admin") {
  // 执行管理员操作
}

功能模块

管理后台位于 app/[locale]/(admin)/admin/ 目录,提供以下核心功能:

1. 管理面板概览

路由: /admin (或 /zh/admin, /en/admin)

文件: app/[locale]/(admin)/admin/page.tsx

功能:

  • 📊 系统统计数据展示
  • 👥 最近注册用户
  • 💰 最近支付记录
  • 💬 对话使用统计

统计指标:

指标说明数据来源
总用户数所有注册用户user
活跃用户数30天内有活动的用户user.updatedAt
总支付次数所有支付记录payment
总收入成功支付的总金额payment.amountCents (status='succeeded')
总对话数所有聊天会话chatSession
总积分消耗所有负值账本记录的总和creditLedger.delta < 0

示例代码 (简化版):

export default async function AdminPage() {
  // 获取统计数据
  const stats = {
    totalUsers: await db.select({ count: sql`count(*)` }).from(user),
    activeUsers: await db
      .select({ count: sql`count(*)` })
      .from(user)
      .where(sql`${user.updatedAt} > NOW() - INTERVAL '30 days'`),
    totalRevenue: await db
      .select({ total: sql`sum(${payment.amountCents})` })
      .from(payment)
      .where(sql`${payment.status} = 'succeeded'`),
    // ...
  };

  return <AdminDashboard stats={stats} />;
}

2. 用户管理

路由: /admin/users

文件: app/[locale]/(admin)/admin/users/page.tsx

功能:

查看所有用户

  • 用户列表展示 (ID、邮箱、积分、角色、状态)
  • 排序和筛选 (按创建时间、积分余额、订阅状态等)
  • 查看用户详细信息

显示字段:

{
  id: string;
  name: string | null;
  email: string;
  emailVerified: boolean;
  credits: number;              // 当前积分
  role: "user" | "admin";       // 角色
  banned: boolean;              // 是否封禁
  banReason: string | null;     // 封禁原因
  banExpires: Date | null;      // 封禁到期时间
  planKey: string;              // 订阅计划
  createdAt: Date;              // 注册时间
  updatedAt: Date;              // 最后活动时间
}

手动调整用户积分

API 端点: POST /api/admin/users/[userId]/credits

文件: app/api/admin/users/[userId]/credits/route.ts

请求格式:

POST /api/admin/users/user_123/credits
Content-Type: application/json

{
  "amount": 1000,           // 积分变动量 (正数=增加, 负数=扣除)
  "reason": "admin_adjustment" // 变动原因
}

示例:

// 增加 1000 积分
fetch('/api/admin/users/user_123/credits', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount: 1000,
    reason: 'compensation'
  })
});

// 扣除 500 积分
fetch('/api/admin/users/user_456/credits', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount: -500,
    reason: 'violation_penalty'
  })
});

核心实现:

export async function POST(
  request: NextRequest,
  { params }: { params: { userId: string } }
) {
  // 1. 验证管理员权限
  await requireAdmin();

  const { amount, reason } = await request.json();
  const delta = Number(amount);

  // 2. 事务性更新
  await db.transaction(async (tx) => {
    // 更新用户积分
    await tx
      .update(user)
      .set({ credits: sql`${user.credits} + ${delta}` })
      .where(eq(user.id, params.userId));

    // 记录到账本
    await tx.insert(creditLedger).values({
      id: crypto.randomUUID(),
      userId: params.userId,
      delta,
      reason: reason || "adjustment",
    });
  });

  return NextResponse.json({ success: true });
}

常见原因标识:

Reason说明使用场景
admin_adjustment管理员手动调整默认原因
compensation补偿积分服务故障补偿
bonus奖励积分活动奖励
violation_penalty违规扣除处罚用户违规
refund退款订单退款
test测试用途测试账号

修改用户订阅

API 端点: POST /api/admin/users/[userId]/subscription

文件: app/api/admin/users/[userId]/subscription/route.ts

请求格式:

POST /api/admin/users/user_123/subscription
Content-Type: application/json

{
  "planKey": "pro_yearly",           // 订阅计划 key
  "action": "upgrade"                 // 操作类型: 'upgrade', 'downgrade', 'cancel'
}

可用计划:

  • free - 免费计划
  • starter_monthly - Starter 月付
  • starter_yearly - Starter 年付
  • pro_monthly - Pro 月付
  • pro_yearly - Pro 年付

封禁/解封用户

封禁用户:

// 临时封禁 (7天)
await db
  .update(user)
  .set({
    banned: true,
    banReason: "Spam behavior",
    banExpires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后
  })
  .where(eq(user.id, userId));

// 永久封禁
await db
  .update(user)
  .set({
    banned: true,
    banReason: "Terms of service violation",
    banExpires: null, // null 表示永久
  })
  .where(eq(user.id, userId));

解封用户:

await db
  .update(user)
  .set({
    banned: false,
    banReason: null,
    banExpires: null,
  })
  .where(eq(user.id, userId));

封禁效果:

  • 用户无法登录
  • 所有 API 请求返回 403
  • 前端显示封禁提示

3. 订阅管理

路由: /admin/subscriptions

文件: app/[locale]/(admin)/admin/subscriptions/page.tsx

功能:

  • 查看所有活跃订阅
  • 查看订阅详情 (计划、价格、到期时间)
  • 查看订阅关联的用户信息
  • 查看订阅的支付历史

显示信息:

{
  subscriptionId: string;
  userId: string;
  userEmail: string;
  planKey: string;              // 计划标识
  status: string;               // 订阅状态
  currentPeriodEnd: Date;       // 当前周期结束时间
  createdAt: Date;              // 订阅创建时间
  providerSubId: string;        // Creem 订阅 ID
}

常见订阅状态:

Status说明
active活跃订阅
canceled已取消 (但可能仍在有效期内)
past_due逾期未支付
expired已过期

4. 积分管理

路由: /admin/credits

文件: app/[locale]/(admin)/admin/credits/page.tsx

功能:

  • 查看所有积分变动记录 (creditLedger)
  • 按用户、原因、时间筛选
  • 导出积分账本数据
  • 审计积分流向

显示字段:

{
  id: string;
  userId: string;
  userEmail: string;            // 关联的用户邮箱
  delta: number;                // 积分变化量
  reason: string;               // 变动原因
  paymentId: string | null;     // 关联的支付 ID
  createdAt: Date;              // 变动时间
}

筛选功能:

// 按用户筛选
const userCredits = await db
  .select()
  .from(creditLedger)
  .where(eq(creditLedger.userId, userId));

// 按原因筛选
const chatUsage = await db
  .select()
  .from(creditLedger)
  .where(eq(creditLedger.reason, "chat_usage"));

// 按时间范围筛选
const recentCredits = await db
  .select()
  .from(creditLedger)
  .where(sql`${creditLedger.createdAt} > NOW() - INTERVAL '7 days'`);

审计功能:

检查用户积分余额与账本记录是否一致:

import { sql } from "drizzle-orm";

// 计算账本总和
const ledgerSum = await db
  .select({ total: sql`SUM(delta)` })
  .from(creditLedger)
  .where(eq(creditLedger.userId, userId));

// 查询用户当前积分
const currentCredits = await db
  .select({ credits: user.credits })
  .from(user)
  .where(eq(user.id, userId));

// 验证一致性
if (ledgerSum[0].total !== currentCredits[0].credits) {
  console.error("Credits mismatch detected!");
}

安全考虑

1. 权限验证

每个管理员 API 端点都必须验证权限:

// ✅ 正确: 使用 requireAdmin()
export async function POST(request: NextRequest) {
  await requireAdmin(); // 非管理员自动返回 403 或重定向

  // 管理员操作
}

// ❌ 错误: 信任前端传来的角色
export async function POST(request: NextRequest) {
  const { isAdmin } = await request.json(); // 不安全!
  if (isAdmin) {
    // 任何人都可以伪造请求
  }
}

2. 操作审计

所有管理员操作都应记录日志:

// 记录管理员操作
await db.insert(adminAuditLog).values({
  id: randomUUID(),
  adminId: currentUser.id,
  action: "adjust_credits",
  targetUserId: userId,
  details: JSON.stringify({ amount, reason }),
  timestamp: new Date(),
});

3. 敏感操作二次确认

对于高风险操作,建议前端实现二次确认:

// 前端示例
function BanUserButton({ userId }) {
  const handleBan = async () => {
    // 二次确认
    const confirmed = window.confirm(
      "确定要封禁此用户吗?此操作将立即生效。"
    );

    if (!confirmed) return;

    await fetch(`/api/admin/users/${userId}/ban`, { method: 'POST' });
  };

  return <button onClick={handleBan}>封禁用户</button>;
}

4. 限制管理员权限

不要给管理员过度权限:

// ✅ 正确: 管理员只能调整积分
await db.update(user)
  .set({ credits: sql`${user.credits} + ${delta}` })
  .where(eq(user.id, userId));

// ❌ 错误: 管理员可以修改任意字段
const { field, value } = await request.json();
await db.update(user)
  .set({ [field]: value }) // 不安全! 可能修改密码、邮箱等
  .where(eq(user.id, userId));

5. 防止管理员删除自己

export async function DELETE(
  request: NextRequest,
  { params }: { params: { userId: string } }
) {
  const currentUser = await getCurrentUserWithRole();

  // 防止自删
  if (currentUser?.id === params.userId) {
    return NextResponse.json(
      { error: "Cannot delete your own account" },
      { status: 400 }
    );
  }

  // 删除用户
  await db.delete(user).where(eq(user.id, params.userId));
}

常见操作示例

1. 批量发放积分

为所有活跃用户发放积分奖励:

import { db } from "@/lib/db";
import { user, creditLedger } from "@/lib/db/schema";
import { sql } from "drizzle-orm";

async function grantBonusToActiveUsers() {
  // 获取 30 天内活跃的用户
  const activeUsers = await db
    .select({ id: user.id })
    .from(user)
    .where(sql`${user.updatedAt} > NOW() - INTERVAL '30 days'`);

  const bonusAmount = 500;

  for (const u of activeUsers) {
    await db.transaction(async (tx) => {
      // 增加积分
      await tx
        .update(user)
        .set({ credits: sql`${user.credits} + ${bonusAmount}` })
        .where(eq(user.id, u.id));

      // 记录账本
      await tx.insert(creditLedger).values({
        id: randomUUID(),
        userId: u.id,
        delta: bonusAmount,
        reason: "monthly_active_bonus",
      });
    });
  }

  console.log(`✅ 已为 ${activeUsers.length} 个用户发放奖励`);
}

2. 导出用户数据

导出所有用户的基本信息和积分状态:

import { db } from "@/lib/db";
import { user } from "@/lib/db/schema";
import { writeFile } from "fs/promises";

async function exportUserData() {
  const users = await db
    .select({
      email: user.email,
      credits: user.credits,
      planKey: user.planKey,
      createdAt: user.createdAt,
    })
    .from(user);

  // 转换为 CSV
  const csv = [
    "Email,Credits,Plan,Created At",
    ...users.map(u =>
      `${u.email},${u.credits},${u.planKey},${u.createdAt.toISOString()}`
    ),
  ].join("\n");

  await writeFile("users-export.csv", csv);
  console.log("✅ 用户数据已导出到 users-export.csv");
}

3. 清理过期订阅

标记已过期但状态仍为 active 的订阅:

import { db } from "@/lib/db";
import { subscription } from "@/lib/db/schema";
import { sql } from "drizzle-orm";

async function cleanupExpiredSubscriptions() {
  const result = await db
    .update(subscription)
    .set({ status: "expired" })
    .where(
      sql`${subscription.status} = 'active' AND ${subscription.currentPeriodEnd} < NOW()`
    );

  console.log(`✅ 已清理 ${result.rowCount} 个过期订阅`);
}

4. 查看用户的完整活动历史

async function getUserActivityReport(userId: string) {
  const [userInfo, creditHistory, chatSessions, payments] = await Promise.all([
    // 用户基本信息
    db.select().from(user).where(eq(user.id, userId)),

    // 积分历史
    db
      .select()
      .from(creditLedger)
      .where(eq(creditLedger.userId, userId))
      .orderBy(desc(creditLedger.createdAt))
      .limit(50),

    // 聊天会话
    db
      .select()
      .from(chatSession)
      .where(eq(chatSession.userId, userId))
      .orderBy(desc(chatSession.createdAt))
      .limit(20),

    // 支付记录
    db
      .select()
      .from(payment)
      .where(eq(payment.userId, userId))
      .orderBy(desc(payment.createdAt)),
  ]);

  return {
    user: userInfo[0],
    credits: creditHistory,
    chats: chatSessions,
    payments,
  };
}

5. 测试用户初始化

为测试账号初始化大量积分:

async function setupTestUser(email: string) {
  const testUser = await db
    .select()
    .from(user)
    .where(eq(user.email, email))
    .limit(1);

  if (!testUser.length) {
    throw new Error("User not found");
  }

  const userId = testUser[0].id;
  const testCredits = 100000; // 10万测试积分

  await db.transaction(async (tx) => {
    await tx
      .update(user)
      .set({ credits: testCredits })
      .where(eq(user.id, userId));

    await tx.insert(creditLedger).values({
      id: randomUUID(),
      userId,
      delta: testCredits,
      reason: "test_account_setup",
    });
  });

  console.log(`✅ 测试账号 ${email} 已初始化 ${testCredits} 积分`);
}

最佳实践

1. 操作前备份

在执行批量操作前,先备份数据:

# PostgreSQL 备份
pg_dump -h localhost -U postgres -d sistine_db > backup_$(date +%Y%m%d).sql

# 或使用 Drizzle
pnpm db:push -- --dry-run # 预览变更

2. 使用事务

所有涉及多步操作的管理功能都应使用事务:

// ✅ 正确: 使用事务
await db.transaction(async (tx) => {
  await tx.update(user).set({ credits: 1000 }).where(eq(user.id, userId));
  await tx.insert(creditLedger).values({ ... });
});

// ❌ 错误: 分离操作可能导致不一致
await db.update(user).set({ credits: 1000 }).where(eq(user.id, userId));
await db.insert(creditLedger).values({ ... }); // 如果这里失败,积分已改变

3. 记录详细日志

console.log(`[Admin] User ${userId} credits adjusted by ${delta} (${reason})`);
console.log(`[Admin] ${adminEmail} banned user ${targetUserEmail}`);

4. 提供撤销机制

对于可逆操作,保留撤销能力:

// 记录操作 ID,以便后续撤销
const operationId = randomUUID();

await db.insert(creditLedger).values({
  id: operationId,
  userId,
  delta: amount,
  reason: "admin_adjustment",
});

// 撤销操作
async function undoOperation(operationId: string) {
  const original = await db
    .select()
    .from(creditLedger)
    .where(eq(creditLedger.id, operationId));

  if (original.length) {
    // 反向操作
    await refundCredits(original[0].userId, -original[0].delta, "undo");
  }
}

5. 定期审计

定期检查数据一致性:

// 每周运行审计脚本
async function weeklyAudit() {
  // 检查积分一致性
  const users = await db.select().from(user);

  for (const u of users) {
    const ledgerSum = await db
      .select({ total: sql`SUM(delta)` })
      .from(creditLedger)
      .where(eq(creditLedger.userId, u.id));

    if (ledgerSum[0].total !== u.credits) {
      console.error(`⚠️  Credits mismatch for user ${u.email}`);
    }
  }
}

故障排查

问题: 管理员无法访问后台

症状: 访问 /admin 被重定向到 /dashboard

原因:

  1. user.role 不是 'admin'
  2. 会话过期
  3. 数据库连接问题

解决:

// 检查用户角色
const user = await db
  .select({ role: user.role })
  .from(user)
  .where(eq(user.email, "admin@example.com"));

console.log(user[0]?.role); // 应该是 'admin'

// 如果不是,手动设置
await db
  .update(user)
  .set({ role: "admin" })
  .where(eq(user.email, "admin@example.com"));

问题: 积分调整失败

症状: 调整积分后余额未变化

原因:

  1. 事务失败未抛出错误
  2. 用户 ID 错误
  3. 数据库权限问题

解决:

// 添加详细日志
try {
  await db.transaction(async (tx) => {
    console.log(`Updating credits for user ${userId}`);

    const result = await tx
      .update(user)
      .set({ credits: sql`${user.credits} + ${delta}` })
      .where(eq(user.id, userId));

    console.log(`Update result:`, result);

    await tx.insert(creditLedger).values({
      id: randomUUID(),
      userId,
      delta,
      reason,
    });
  });
} catch (error) {
  console.error("Transaction failed:", error);
  throw error;
}

问题: 管理员被误删

症状: 唯一的管理员账户被删除

解决:

# 直接在数据库中创建新管理员
psql -d sistine_db

# 提升现有用户为管理员
UPDATE "user" SET "role" = 'admin' WHERE "email" = 'recovery@example.com';

# 或使用 pnpm 脚本
pnpm admin:setup

相关文档

总结

管理后台为管理员提供了:

  • 用户管理: 查看、调整积分、修改订阅、封禁用户
  • 订阅管理: 监控订阅状态和支付记录
  • 积分管理: 审计积分流向和账本记录
  • 统计分析: 系统整体运营数据

使用管理后台时,请遵循最佳实践:

  • ✅ 始终验证管理员权限
  • ✅ 使用事务保证数据一致性
  • ✅ 记录所有管理员操作
  • ✅ 定期审计数据完整性
  • ✅ 备份数据后再执行批量操作