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:
@@ -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, () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
9
packages/server/src/middleware/admin.ts
Normal file
9
packages/server/src/middleware/admin.ts
Normal 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();
|
||||
}
|
||||
305
packages/server/src/routes/admin.ts
Normal file
305
packages/server/src/routes/admin.ts
Normal 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;
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user