feat: 添加 Admin 管理后台

- 数据库新增 Role 枚举、disabled 字段和 McpCallLog 调用日志表
- 后端新增 requireAdmin 中间件和 /api/admin/* 管理接口(统计、用户、项目、日志)
- MCP 工具调用自动记录详细日志(耗时、参数、响应大小、客户端IP、token估算)
- 前端新增 /admin 路由区域:仪表盘、用户管理、项目管理、调用日志四个页面
- JWT 携带 role 字段,登录/OAuth 增加禁用账号检查
- nginx 配置补充 X-Forwarded-For 透传真实客户端 IP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 13:04:44 +08:00
parent d45cc45815
commit 6fe04f4893
25 changed files with 1847 additions and 20 deletions

View File

@@ -0,0 +1,305 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { prisma } from '@agent-fox/shared';
import { requireAuth } from '../middleware/auth.js';
import { requireAdmin } from '../middleware/admin.js';
const router: RouterType = Router();
router.use(requireAuth, requireAdmin);
function parsePagination(query: Record<string, any>, defaultLimit = 20) {
return {
page: Math.max(1, parseInt(query.page as string) || 1),
limit: Math.min(100, Math.max(1, parseInt(query.limit as string) || defaultLimit)),
};
}
// ─── Dashboard Stats ────────────────────────────────────────────────
router.get('/stats', async (_req, res) => {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const sevenDaysAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const [
totalUsers,
todayUsers,
totalProjects,
todayProjects,
totalCalls,
todayCalls,
callStats,
successCount,
activeProjectUsers,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { createdAt: { gte: todayStart } } }),
prisma.project.count(),
prisma.project.count({ where: { createdAt: { gte: todayStart } } }),
prisma.mcpCallLog.count(),
prisma.mcpCallLog.count({ where: { calledAt: { gte: todayStart } } }),
prisma.mcpCallLog.aggregate({
_avg: { durationMs: true },
_count: { id: true },
where: { calledAt: { gte: sevenDaysAgo } },
}),
prisma.mcpCallLog.count({
where: { calledAt: { gte: sevenDaysAgo }, success: true },
}),
prisma.mcpCallLog.groupBy({
by: ['projectId'],
where: { calledAt: { gte: sevenDaysAgo } },
}),
]);
// Resolve unique user IDs from active projects
const activeProjectIds = activeProjectUsers.map(g => g.projectId);
let activeUserCount = 0;
if (activeProjectIds.length > 0) {
activeUserCount = await prisma.project.groupBy({
by: ['userId'],
where: { id: { in: activeProjectIds } },
}).then(r => r.length);
}
const recentTotal = callStats._count.id;
const successRate = recentTotal > 0 ? Math.round((successCount / recentTotal) * 100) : 100;
res.json({
success: true,
data: {
totalUsers,
todayUsers,
totalProjects,
todayProjects,
totalCalls,
todayCalls,
avgResponseTime: Math.round(callStats._avg.durationMs ?? 0),
successRate,
activeUsers: activeUserCount,
},
});
});
// ─── Trends (7d / 30d) ─────────────────────────────────────────────
router.get('/stats/trends', async (req, res) => {
const days = req.query.days === '30' ? 30 : 7;
const since = new Date();
since.setDate(since.getDate() - days);
since.setHours(0, 0, 0, 0);
const rows = await prisma.$queryRaw<
{ date: string; total: bigint; success_count: bigint; avg_duration: number }[]
>`
SELECT
TO_CHAR("calledAt", 'YYYY-MM-DD') AS date,
COUNT(*)::bigint AS total,
SUM(CASE WHEN success THEN 1 ELSE 0 END)::bigint AS success_count,
COALESCE(AVG("durationMs"), 0)::int AS avg_duration
FROM "McpCallLog"
WHERE "calledAt" >= ${since}
GROUP BY TO_CHAR("calledAt", 'YYYY-MM-DD')
ORDER BY date
`;
// Build full date range with zeros for missing days
const dataMap = new Map(rows.map(r => [r.date, r]));
const trends = [];
for (let i = 0; i < days; i++) {
const d = new Date(since);
d.setDate(d.getDate() + i);
const key = d.toISOString().slice(0, 10);
const row = dataMap.get(key);
const total = row ? Number(row.total) : 0;
const successCnt = row ? Number(row.success_count) : 0;
trends.push({
date: key,
calls: total,
successRate: total > 0 ? Math.round((successCnt / total) * 100) : 100,
avgDuration: row ? row.avg_duration : 0,
});
}
res.json({ success: true, data: trends });
});
// ─── User Management ────────────────────────────────────────────────
router.get('/users', async (req, res) => {
const { page, limit } = parsePagination(req.query);
const search = (req.query.search as string) || '';
const sortBy = (req.query.sortBy as string) || 'createdAt';
const order = req.query.order === 'asc' ? 'asc' as const : 'desc' as const;
const where = search
? { OR: [{ name: { contains: search, mode: 'insensitive' as const } }, { email: { contains: search, mode: 'insensitive' as const } }] }
: {};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: {
id: true, email: true, name: true, role: true, disabled: true, createdAt: true, avatarUrl: true,
_count: { select: { projects: true } },
},
orderBy: { [sortBy]: order },
skip: (page - 1) * limit,
take: limit,
}),
prisma.user.count({ where }),
]);
res.json({ success: true, data: { users, total, page, limit } });
});
router.get('/users/:id', async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
select: {
id: true, email: true, name: true, role: true, disabled: true, createdAt: true, avatarUrl: true,
oauthAccounts: { select: { provider: true, createdAt: true } },
projects: {
select: { id: true, name: true, description: true, createdAt: true, _count: { select: { endpoints: true, modules: true } } },
orderBy: { createdAt: 'desc' },
},
},
});
if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
return;
}
res.json({ success: true, data: user });
});
const disableSchema = z.object({ disabled: z.boolean() });
router.patch('/users/:id/disable', async (req, res) => {
const parsed = disableSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Invalid request body' } });
return;
}
if (req.params.id === req.user!.userId) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Cannot disable your own account' } });
return;
}
const user = await prisma.user.update({
where: { id: req.params.id },
data: { disabled: parsed.data.disabled },
select: { id: true, disabled: true },
});
res.json({ success: true, data: user });
});
// ─── Project Management ─────────────────────────────────────────────
router.get('/projects', async (req, res) => {
const { page, limit } = parsePagination(req.query);
const search = (req.query.search as string) || '';
const where = search
? { name: { contains: search, mode: 'insensitive' as const } }
: {};
const [projects, total] = await Promise.all([
prisma.project.findMany({
where,
select: {
id: true, name: true, description: true, openApiVersion: true, createdAt: true,
user: { select: { id: true, name: true, email: true } },
_count: { select: { endpoints: true, modules: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.project.count({ where }),
]);
res.json({ success: true, data: { projects, total, page, limit } });
});
router.get('/projects/:id', async (req, res) => {
const project = await prisma.project.findUnique({
where: { id: req.params.id },
select: {
id: true, name: true, description: true, baseUrl: true, openApiVersion: true, createdAt: true, updatedAt: true,
user: { select: { id: true, name: true, email: true } },
modules: { select: { id: true, name: true, description: true, source: true, _count: { select: { endpoints: true } } }, orderBy: { sortOrder: 'asc' } },
_count: { select: { endpoints: true, modules: true, mcpCallLogs: true } },
},
});
if (!project) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
return;
}
res.json({ success: true, data: project });
});
router.delete('/projects/:id', async (req, res) => {
await prisma.project.delete({ where: { id: req.params.id } });
res.json({ success: true, data: { message: 'Project deleted' } });
});
// ─── Call Logs ──────────────────────────────────────────────────────
router.get('/call-logs', async (req, res) => {
const { page, limit } = parsePagination(req.query, 30);
const projectId = req.query.projectId as string | undefined;
const toolName = req.query.toolName as string | undefined;
const success = req.query.success as string | undefined;
const dateStart = req.query.dateStart as string | undefined;
const dateEnd = req.query.dateEnd as string | undefined;
const where: any = {};
if (projectId) where.projectId = projectId;
if (toolName) where.toolName = toolName;
if (success === 'true') where.success = true;
if (success === 'false') where.success = false;
if (dateStart || dateEnd) {
where.calledAt = {};
if (dateStart) where.calledAt.gte = new Date(dateStart);
if (dateEnd) where.calledAt.lte = new Date(dateEnd);
}
const [logs, total] = await Promise.all([
prisma.mcpCallLog.findMany({
where,
select: {
id: true, toolName: true, calledAt: true, durationMs: true, success: true,
responseSize: true, clientIp: true, estimatedTokens: true,
project: { select: { id: true, name: true } },
},
orderBy: { calledAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.mcpCallLog.count({ where }),
]);
res.json({ success: true, data: { logs, total, page, limit } });
});
router.get('/call-logs/recent', async (_req, res) => {
const logs = await prisma.mcpCallLog.findMany({
select: {
id: true, toolName: true, calledAt: true, durationMs: true, success: true,
project: { select: { name: true } },
},
orderBy: { calledAt: 'desc' },
take: 10,
});
res.json({ success: true, data: logs });
});
export default router;