404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
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); |