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

@@ -7,6 +7,7 @@ import importRouter from './routes/import.js';
import moduleRouter from './routes/modules.js';
import endpointRouter from './routes/endpoints.js';
import fetchSpecRouter from './routes/fetch-spec.js';
import adminRouter from './routes/admin.js';
const app = express();
app.use(cors());
@@ -24,6 +25,7 @@ app.use('/api/projects', projectRouter);
app.use('/api/projects', importRouter);
app.use('/api/projects', moduleRouter);
app.use('/api/projects', endpointRouter);
app.use('/api/admin', adminRouter);
const port = process.env.SERVER_PORT || 3000;
app.listen(port, () => {

View File

@@ -1,4 +1,5 @@
import jwt from 'jsonwebtoken';
import type { Role } from '@agent-fox/shared';
const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret';
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
@@ -8,6 +9,7 @@ const REFRESH_EXPIRY = '7d';
export type TokenPayload = {
userId: string;
email: string;
role: Role;
};
export function generateAccessToken(payload: TokenPayload): string {

View File

@@ -0,0 +1,9 @@
import type { Request, Response, NextFunction } from 'express';
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.user || req.user.role !== 'ADMIN') {
res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: 'Admin access required' } });
return;
}
next();
}

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;

View File

@@ -40,8 +40,8 @@ router.post('/register', async (req, res) => {
data: { email, passwordHash, name },
});
const tokens = generateTokenPair({ userId: user.id, email: user.email });
res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name, role: user.role }, ...tokens } });
});
router.post('/login', async (req, res) => {
@@ -59,14 +59,19 @@ router.post('/login', async (req, res) => {
return;
}
if (user.disabled) {
res.status(403).json({ success: false, error: { code: 'DISABLED', message: 'Account has been disabled' } });
return;
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
return;
}
const tokens = generateTokenPair({ userId: user.id, email: user.email });
res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name, role: user.role }, ...tokens } });
});
router.post('/refresh', async (req, res) => {
@@ -83,7 +88,11 @@ router.post('/refresh', async (req, res) => {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } });
return;
}
const tokens = generateTokenPair({ userId: user.id, email: user.email });
if (user.disabled) {
res.status(403).json({ success: false, error: { code: 'DISABLED', message: 'Account has been disabled' } });
return;
}
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
res.json({ success: true, data: tokens });
} catch {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } });
@@ -170,7 +179,7 @@ router.put('/profile', requireAuth, async (req, res) => {
router.get('/me', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.userId },
select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true },
select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true, role: true },
});
if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });

View File

@@ -72,7 +72,11 @@ async function handleOAuthCallback(
}
const user = await findOrCreateUser(provider, providerUser);
const tokens = generateTokenPair({ userId: user.id, email: user.email });
if (user.disabled) {
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Account has been disabled')}`);
return;
}
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
const redirectParam = stateResult.redirect ? `&redirect=${encodeURIComponent(stateResult.redirect)}` : '';
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`);