import express from 'express'; import cors from 'cors'; import { PrismaClient } from '@prisma/client'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const prisma = new PrismaClient(); const app = express(); // dev 环境下允许跨域,便于前端本地调试 app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: '2mb' })); // ========================= // 静态文件:托管前端打包产物(Vite dist) // ========================= const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // 可通过环境变量覆盖:STATIC_DIR=D:\path\to\dist const staticDir = process.env.STATIC_DIR ? path.resolve(process.env.STATIC_DIR) : path.resolve(__dirname, 'dist'); app.get('/health', (_req, res) => { res.status(200).json({ ok: true }); }); // ========================= // Admin(管理页面 + 管理 API) // ========================= const adminToken = (process.env.ADMIN_TOKEN ?? '').trim(); const requireAdmin: express.RequestHandler = (req, res, next) => { if (!adminToken) { res .status(500) .json({ ok: false, error: 'ADMIN_TOKEN is not set on server. Set env ADMIN_TOKEN to enable admin.' }); return; } const auth = typeof req.headers.authorization === 'string' ? req.headers.authorization : ''; const bearer = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : ''; const headerToken = typeof req.headers['x-admin-token'] === 'string' ? req.headers['x-admin-token'].trim() : ''; const queryToken = typeof req.query.token === 'string' ? req.query.token.trim() : ''; const token = bearer || headerToken || queryToken; if (!token || token !== adminToken) { res.status(401).json({ ok: false, error: 'unauthorized' }); return; } next(); }; const toInt = (v: unknown, fallback: number) => { const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN; return Number.isFinite(n) ? Math.trunc(n) : fallback; }; const toDate = (v: unknown): Date | null => { if (typeof v !== 'string' || !v.trim()) return null; const d = new Date(v); return Number.isNaN(d.getTime()) ? null : d; }; const safeContains = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null); app.get('/admin', (req, res) => { // 直接返回静态 HTML(不依赖前端构建),并复用 requireAdmin 的 token 逻辑 // 这里不强制 requireAdmin(避免每次打开都要手动带 header),页面内会用 token 访问管理 API res.sendFile(path.join(__dirname, 'admin.html')); }); app.get('/admin/students', (_req, res) => { res.sendFile(path.join(__dirname, 'students.html')); }); app.get('/api/admin/stats', requireAdmin, async (_req, res) => { const now = new Date(); const d24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); const d7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); try { const [trackerTotal, recordTotal, tracker24h, record24h, tracker7d, record7d] = await Promise.all([ prisma.trackerEvent.count(), prisma.recordSubmission.count(), prisma.trackerEvent.count({ where: { createdAt: { gte: d24h } } }), prisma.recordSubmission.count({ where: { createdAt: { gte: d24h } } }), prisma.trackerEvent.count({ where: { createdAt: { gte: d7d } } }), prisma.recordSubmission.count({ where: { createdAt: { gte: d7d } } }), ]); res.status(200).json({ ok: true, tracker: { total: trackerTotal, last24h: tracker24h, last7d: tracker7d }, record: { total: recordTotal, last24h: record24h, last7d: record7d }, }); } catch (e: any) { res.status(500).json({ ok: false, error: String(e?.message ?? e) }); } }); app.get('/api/admin/tracker', requireAdmin, async (req, res) => { const page = Math.max(1, toInt(req.query.page, 1)); const pageSize = Math.min(200, Math.max(1, toInt(req.query.pageSize, 50))); const skip = (page - 1) * pageSize; const sessionId = safeContains(req.query.sessionId); const event = safeContains(req.query.event); const step = safeContains(req.query.step); const studentId = safeContains(req.query.studentId); const from = toDate(req.query.from); const to = toDate(req.query.to); const where = { ...(sessionId ? { sessionId: { contains: sessionId, mode: 'insensitive' as const } } : {}), ...(studentId ? { studentId: { contains: studentId, mode: 'insensitive' as const } } : {}), ...(event ? { event: { contains: event, mode: 'insensitive' as const } } : {}), ...(step ? { step: { contains: step, mode: 'insensitive' as const } } : {}), ...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}), }, } : {}), }; try { const [total, rows] = await Promise.all([ prisma.trackerEvent.count({ where }), prisma.trackerEvent.findMany({ where, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip, take: pageSize, select: { id: true, createdAt: true, event: true, step: true, sessionId: true, studentId: true, ip: true, userAgent: true, }, }), ]); res.status(200).json({ ok: true, page, pageSize, total, rows }); } catch (e: any) { res.status(500).json({ ok: false, error: String(e?.message ?? e) }); } }); app.get('/api/admin/tracker/:id', requireAdmin, async (req, res) => { const id = req.params.id; try { const row = await prisma.trackerEvent.findUnique({ where: { id } }); if (!row) { res.status(404).json({ ok: false, error: 'not found' }); return; } res.status(200).json({ ok: true, row }); } catch (e: any) { res.status(500).json({ ok: false, error: String(e?.message ?? e) }); } }); app.get('/api/admin/record', requireAdmin, async (req, res) => { const page = Math.max(1, toInt(req.query.page, 1)); const pageSize = Math.min(200, Math.max(1, toInt(req.query.pageSize, 50))); const skip = (page - 1) * pageSize; const sessionId = safeContains(req.query.sessionId); const studentId = safeContains(req.query.studentId); const from = toDate(req.query.from); const to = toDate(req.query.to); const notes = safeContains(req.query.notes); const where = { ...(sessionId ? { sessionId: { contains: sessionId, mode: 'insensitive' as const } } : {}), ...(studentId ? { studentId: { contains: studentId, mode: 'insensitive' as const } } : {}), ...(notes === 'has' ? { notes: { not: null } } : {}), ...(notes === 'empty' ? { notes: null } : {}), ...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}), }, } : {}), }; try { const [total, rows] = await Promise.all([ prisma.recordSubmission.count({ where }), prisma.recordSubmission.findMany({ where, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip, take: pageSize, select: { id: true, createdAt: true, sessionId: true, studentId: true, notes: true, ip: true, userAgent: true, }, }), ]); res.status(200).json({ ok: true, page, pageSize, total, rows }); } catch (e: any) { res.status(500).json({ ok: false, error: String(e?.message ?? e) }); } }); app.get('/api/admin/students', requireAdmin, async (req, res) => { const studentId = safeContains(req.query.studentId); const from = toDate(req.query.from); const to = toDate(req.query.to); const where = { studentId: { not: null as any }, ...(studentId ? { studentId: { contains: studentId, mode: 'insensitive' as const } } : {}), ...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}), }, } : {}), }; try { const rows = await prisma.recordSubmission.groupBy({ by: ['studentId'], where: where as any, _count: { _all: true }, _max: { createdAt: true }, orderBy: [{ _max: { createdAt: 'desc' } }], }); res.status(200).json({ ok: true, total: rows.length, rows: rows.map((r) => ({ studentId: r.studentId, count: r._count._all, lastAt: r._max.createdAt, })), }); } catch (e: any) { res.status(500).json({ ok: false, error: String(e?.message ?? e) }); } }); app.get('/api/admin/record/:id', requireAdmin, async (req, res) => { const id = req.params.id; try { const row = await prisma.recordSubmission.findUnique({ where: { id } }); if (!row) { res.status(404).json({ ok: false, error: 'not found' }); return; } res.status(200).json({ ok: true, row }); } catch (e: any) { res.status(500).json({ ok: false, error: String(e?.message ?? e) }); } }); type TrackerPayload = { event: string; data?: unknown; ts?: number; sessionId?: string; studentId?: string; step?: string; }; app.post('/api/tracker', async (req, res) => { const body = (req.body ?? {}) as Partial; const event = typeof body.event === 'string' && body.event.trim() ? body.event.trim() : null; if (!event) { res.status(400).json({ ok: false, error: 'event is required' }); return; } const sessionId = typeof body.sessionId === 'string' ? body.sessionId : null; const step = typeof body.step === 'string' ? body.step : null; const studentId = typeof body.studentId === 'string' && body.studentId.trim() ? body.studentId.trim() : null; const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null; const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip; try { await prisma.trackerEvent.create({ data: { event, step, sessionId, studentId, data: (body.data ?? null) as any, ip: ip ?? null, userAgent, }, }); res.status(200).json({ ok: true }); } catch (e: any) { res.status(500).json({ ok: false, error: String(e?.message ?? e) }); } }); type RecordPayload = { ts?: number; studentId?: string; raw: unknown; computed: unknown; context: unknown; notes?: string; }; app.post('/api/record', async (req, res) => { const body = (req.body ?? {}) as Partial; if (body.raw === undefined || body.computed === undefined || body.context === undefined) { res.status(400).json({ ok: false, error: 'raw/computed/context are required' }); return; } const notes = typeof body.notes === 'string' ? body.notes : null; const studentId = typeof body.studentId === 'string' && body.studentId.trim() ? body.studentId.trim() : (typeof (body as any)?.context?.studentId === 'string' && (body as any).context.studentId.trim() ? (body as any).context.studentId.trim() : null); const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null; const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip; // 可选:从 context 里取 sessionId(前端提交会带 tracker 的 sessionId) const sessionId = typeof (body as any)?.context?.trackerSessionId === 'string' ? (body as any).context.trackerSessionId : (typeof (body as any)?.sessionId === 'string' ? (body as any).sessionId : null); try { const row = await prisma.recordSubmission.create({ data: { raw: body.raw as any, computed: body.computed as any, context: body.context as any, notes, sessionId, studentId, ip: ip ?? null, userAgent, }, select: { id: true, createdAt: true }, }); res.status(200).json({ ok: true, id: row.id, createdAt: row.createdAt }); } catch (e: any) { res.status(500).json({ ok: false, error: String(e?.message ?? e) }); } }); // 先挂静态资源(js/css/img 等) app.use(express.static(staticDir, { index: false, fallthrough: true, maxAge: '1h', })); // SPA fallback:非 /api/* 且非静态资源命中时,返回 dist/index.html app.get(/^(?!\/api\/).*/, (_req, res) => { res.sendFile(path.join(staticDir, 'index.html')); }); const port = Number(process.env.PORT ?? 3001); app.listen(port, () => { // eslint-disable-next-line no-console console.log(`API server listening on http://localhost:${port}`); }); const shutdown = async () => { try { await prisma.$disconnect(); } catch { // ignore } process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);