2026-01-05 12:02:14 +08:00

404 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<TrackerPayload>;
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<RecordPayload>;
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);