- 数据库新增 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>
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
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;
|