Files
2026-08-17 23:57:43 +08:00

459 lines
17 KiB
JavaScript
Raw Permalink 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.
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const PORT = process.env.PORT || 3031;
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS || 'admin123';
const DATA_FILE = path.join(__dirname, 'questions.json');
const MAX_SET_NAME = 80;
const MAX_SET_DESCRIPTION = 240;
const SAFE_HTML_TAGS = new Set([
'b', 'strong', 'i', 'em', 'code', 'br', 'span', 'div', 'p',
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'pre', 'u', 's', 'mark', 'small', 'sub', 'sup'
]);
const SAFE_TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)>/g;
const app = express();
app.use(express.json({ limit: '2mb' }));
const DIST = path.join(__dirname, 'dist');
if (fs.existsSync(DIST)) {
app.use(express.static(DIST));
app.get(/^\/(?!api\/).*/, (req, res) => {
res.sendFile(path.join(DIST, 'index.html'));
});
} else {
console.warn('未找到 dist/ 目录,请先执行 npm run build 再访问页面');
}
function loadData() {
if (!fs.existsSync(DATA_FILE)) return { sets: [], nextSetId: 1, nextQId: 1 };
try {
let raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
let migratedFromArray = false;
if (Array.isArray(raw)) {
raw = {
sets: [{ id: 1, name: 'Dart & Flutter 学习测验', questions: raw }],
nextSetId: 2,
nextQId: 1
};
migratedFromArray = true;
}
if (!raw || !Array.isArray(raw.sets)) throw new Error('根节点必须包含 sets 数组');
const setIds = new Set();
const questionIds = new Set();
const setNames = new Set();
let maxSetId = 0;
let maxQId = 0;
for (const set of raw.sets) {
if (!Number.isInteger(set.id) || set.id < 1 || setIds.has(set.id)) throw new Error('套题 ID 缺失、非法或重复');
if (!set.name || typeof set.name !== 'string' || !Array.isArray(set.questions)) throw new Error(`套题 #${set.id} 结构不完整`);
const normalizedName = set.name.trim().toLowerCase();
if (!normalizedName) throw new Error(`套题 #${set.id} 的名称不能为空`);
if (setNames.has(normalizedName)) throw new Error(`套题名称重复:${set.name}`);
if (set.description !== undefined && typeof set.description !== 'string') throw new Error(`套题 #${set.id} 的简介必须为字符串`);
if (set.published !== undefined && typeof set.published !== 'boolean') throw new Error(`套题 #${set.id} 的 published 必须为布尔值`);
if (set.published === undefined) set.published = set.questions.length > 0;
if (!set.questions.length) set.published = false;
const lvErr = validateLvNames(set.lvNames);
if (lvErr) throw new Error(`套题 #${set.id}${lvErr}`);
setIds.add(set.id);
setNames.add(normalizedName);
maxSetId = Math.max(maxSetId, set.id);
for (const q of set.questions) {
if (!Number.isInteger(q.id) || q.id < 1 || questionIds.has(q.id)) throw new Error('题目 ID 缺失、非法或重复');
const questionErr = validateBody(q);
if (questionErr) throw new Error(`套题 #${set.id} 的题目 #${q.id}${questionErr}`);
questionIds.add(q.id);
maxQId = Math.max(maxQId, q.id);
}
}
raw.nextSetId = Math.max(Number.isInteger(raw.nextSetId) ? raw.nextSetId : 1, maxSetId + 1);
raw.nextQId = Math.max(Number.isInteger(raw.nextQId) ? raw.nextQId : 1, maxQId + 1);
if (migratedFromArray) {
const tempFile = DATA_FILE + '.tmp';
try {
fs.writeFileSync(tempFile, JSON.stringify(raw, null, 2), 'utf8');
fs.renameSync(tempFile, DATA_FILE);
} finally {
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
}
}
return raw;
} catch (err) {
throw new Error(`无法加载 questions.json${err.message}`);
}
}
const data = loadData();
let savedDataSnapshot = JSON.stringify(data);
function saveData() {
const tempFile = DATA_FILE + '.tmp';
const serialized = JSON.stringify(data, null, 2);
try {
fs.writeFileSync(tempFile, serialized, 'utf8');
fs.renameSync(tempFile, DATA_FILE);
savedDataSnapshot = serialized;
} catch (err) {
// 路由会先修改内存再落盘。使用最近一次成功保存的内存快照回滚,
// 即使磁盘此时也不可读,失败操作也不会泄漏到后续请求。
const restored = JSON.parse(savedDataSnapshot);
for (const key of Object.keys(data)) delete data[key];
Object.assign(data, restored);
throw err;
} finally {
try {
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
} catch (cleanupErr) {
console.error('清理题库临时文件失败:', cleanupErr);
}
}
}
const tokens = new Set();
function findQuestion(qid) {
for (const set of data.sets) {
const q = set.questions.find(x => x.id === qid);
if (q) return { set, q };
}
return null;
}
function findSet(id) {
return data.sets.find(s => s.id === id) || null;
}
function isPublished(set) {
return set.published !== false && set.questions.length > 0;
}
function setVersion(set) {
return crypto.createHash('sha1')
.update(JSON.stringify({ lvNames: set.lvNames || null, questions: set.questions }))
.digest('hex')
.slice(0, 12);
}
function levelCounts(set) {
const counts = [0, 0, 0, 0, 0, 0];
for (const q of set.questions) {
if (Number.isInteger(q.lv) && q.lv >= 1 && q.lv <= 6) counts[q.lv - 1]++;
}
return counts;
}
function toSetSummary(set, admin = false) {
const summary = {
id: set.id,
name: set.name,
description: set.description || '',
count: set.questions.length,
lvNames: set.lvNames || null,
levelCounts: levelCounts(set),
version: setVersion(set)
};
if (admin) summary.published = isPublished(set);
return summary;
}
function validateSetFields(body, excludeId = null) {
const { name, description } = body || {};
if (!name || typeof name !== 'string' || name.trim() === '') return '套题名称不能为空';
if (name.trim().length > MAX_SET_NAME) return `套题名称不能超过 ${MAX_SET_NAME} 个字符`;
if (description !== undefined && typeof description !== 'string') return '套题简介必须为字符串';
if ((description || '').trim().length > MAX_SET_DESCRIPTION) return `套题简介不能超过 ${MAX_SET_DESCRIPTION} 个字符`;
if (data.sets.some(s => s.id !== excludeId && s.name.trim().toLowerCase() === name.trim().toLowerCase())) {
return '已存在同名套题';
}
return validateLvNames(body.lvNames);
}
function applySetFields(set, body) {
set.name = body.name.trim();
set.description = (body.description || '').trim();
if (body.lvNames === null) delete set.lvNames;
else if (body.lvNames !== undefined) set.lvNames = body.lvNames.map(x => x.trim());
}
function uniqueCopyName(name) {
const names = new Set(data.sets.map(s => s.name.trim().toLowerCase()));
let index = 1;
while (true) {
const suffix = index === 1 ? '(副本)' : `(副本 ${index}`;
const candidate = name.slice(0, MAX_SET_NAME - suffix.length).trimEnd() + suffix;
if (!names.has(candidate.toLowerCase())) return candidate;
index++;
}
}
// 答题页需要整套缓存以支持离线判分,因此下发答案与解析。
// 保留显式白名单:以后给题目加内部字段(如出题备注)不会顺手漏出去。
function toPublic(q) {
return { id: q.id, lv: q.lv, q: q.q, opts: q.opts, ans: q.ans, exp: q.exp };
}
function validateBody(body) {
const { lv, q, opts, ans, exp } = body || {};
if (!q || typeof q !== 'string' || q.trim() === '') return '题目内容不能为空';
if (!Array.isArray(opts) || opts.length < 2) return '至少需要 2 个选项';
if (opts.some(o => typeof o !== 'string' || o.trim() === '')) return '选项不能为空';
const normalizedOpts = opts.map(o => o.trim());
if (new Set(normalizedOpts).size !== normalizedOpts.length) return '选项不能重复';
if (typeof ans !== 'number' || !Number.isInteger(ans) || ans < 0 || ans >= opts.length) return '答案序号不正确';
if (lv !== undefined && (!Number.isInteger(lv) || lv < 1 || lv > 6)) return '难度需为 1-6 的整数';
if (typeof exp !== 'string' || exp.trim() === '') return '答案解析不能为空';
return null;
}
function sanitizeAuthoredHtml(html) {
const placeholders = [];
const text = String(html).replace(SAFE_TAG_RE, (match, tag) => {
const normalizedTag = tag.toLowerCase();
if (!SAFE_HTML_TAGS.has(normalizedTag)) return match;
placeholders.push(match.startsWith('</') ? `</${normalizedTag}>` : `<${normalizedTag}>`);
return `\u0000${placeholders.length - 1}\u0000`;
});
return text
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\u0000(\d+)\u0000/g, (_, i) => placeholders[Number(i)]);
}
function buildQuestion(body) {
const { lv, q, opts, ans, exp } = body;
return {
id: data.nextQId++,
lv: lv === undefined ? 1 : lv,
q: sanitizeAuthoredHtml(q.trim()),
opts: opts.map(o => o.trim()),
ans,
exp: sanitizeAuthoredHtml(exp.trim())
};
}
function validateLvNames(v) {
if (v === undefined || v === null) return null;
if (!Array.isArray(v) || v.length !== 6 || v.some(x => typeof x !== 'string' || !x.trim())) {
return '难度名称必须为 6 个非空字符串';
}
return null;
}
app.get('/api/sets', (req, res) => {
const sets = data.sets.filter(isPublished).map(s => toSetSummary(s));
res.json({ sets, total: sets.length });
});
app.get('/api/questions', (req, res) => {
const publishedSets = data.sets.filter(isPublished);
const setId = req.query.set ? Number(req.query.set) : (publishedSets[0] ? publishedSets[0].id : null);
if (req.query.set && !Number.isInteger(setId)) return res.status(400).json({ error: '套题 ID 格式不正确' });
const set = publishedSets.find(s => s.id === setId);
if (!set) return res.status(404).json({ error: '套题不存在、尚未发布或暂无题目' });
res.json({
set: toSetSummary(set),
count: set.questions.length,
questions: set.questions.map(toPublic)
});
});
app.post('/api/check', (req, res) => {
const { id, answer } = req.body || {};
const hit = findQuestion(id);
if (!hit || !isPublished(hit.set)) return res.status(404).json({ error: '题目不存在' });
if (!Number.isInteger(answer)) return res.status(400).json({ error: 'answer 必须为数字' });
res.json({ correct: hit.q.ans === answer, answer: hit.q.ans, exp: hit.q.exp });
});
app.post('/api/admin/login', (req, res) => {
const { username, password } = req.body || {};
if (username === ADMIN_USER && password === ADMIN_PASS) {
const token = crypto.randomBytes(24).toString('hex');
tokens.add(token);
res.json({ token });
} else {
res.status(401).json({ error: '用户名或密码错误' });
}
});
function requireAuth(req, res, next) {
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
if (token && tokens.has(token)) return next();
res.status(401).json({ error: '未登录或登录已过期' });
}
app.get('/api/admin/sets', requireAuth, (req, res) => {
res.json({ sets: data.sets.map(s => toSetSummary(s, true)) });
});
app.get('/api/admin/sets/:id', requireAuth, (req, res) => {
const set = findSet(Number(req.params.id));
if (!set) return res.status(404).json({ error: '套题不存在' });
res.json({ set: { ...toSetSummary(set, true), questions: set.questions } });
});
app.post('/api/admin/sets', requireAuth, (req, res) => {
const err = validateSetFields(req.body);
if (err) return res.status(400).json({ error: err });
const set = { id: data.nextSetId++, name: '', description: '', published: false, questions: [] };
applySetFields(set, req.body);
data.sets.push(set);
saveData();
res.status(201).json({ set: toSetSummary(set, true) });
});
app.put('/api/admin/sets/:id', requireAuth, (req, res) => {
const id = Number(req.params.id);
const set = findSet(id);
if (!set) return res.status(404).json({ error: '套题不存在' });
const err = validateSetFields(req.body, id);
if (err) return res.status(400).json({ error: err });
applySetFields(set, req.body);
saveData();
res.json({ set: toSetSummary(set, true) });
});
app.patch('/api/admin/sets/:id/publish', requireAuth, (req, res) => {
const set = findSet(Number(req.params.id));
if (!set) return res.status(404).json({ error: '套题不存在' });
if (typeof req.body?.published !== 'boolean') return res.status(400).json({ error: 'published 必须为布尔值' });
if (req.body.published && !set.questions.length) return res.status(400).json({ error: '空套题不能发布,请先添加题目' });
set.published = req.body.published;
saveData();
res.json({ set: toSetSummary(set, true) });
});
app.post('/api/admin/sets/:id/duplicate', requireAuth, (req, res) => {
const source = findSet(Number(req.params.id));
if (!source) return res.status(404).json({ error: '套题不存在' });
const copy = {
id: data.nextSetId++,
name: uniqueCopyName(source.name),
description: source.description || '',
published: false,
questions: source.questions.map(q => ({ ...q, id: data.nextQId++, opts: [...q.opts] }))
};
if (source.lvNames) copy.lvNames = [...source.lvNames];
data.sets.push(copy);
saveData();
res.status(201).json({ set: toSetSummary(copy, true) });
});
app.delete('/api/admin/sets/:id', requireAuth, (req, res) => {
const id = Number(req.params.id);
const idx = data.sets.findIndex(s => s.id === id);
if (idx === -1) return res.status(404).json({ error: '套题不存在' });
const deleted = data.sets[idx];
data.sets.splice(idx, 1);
saveData();
res.json({ ok: true, deleted: { id: deleted.id, name: deleted.name } });
});
app.post('/api/admin/import', requireAuth, (req, res) => {
const { questions } = req.body || {};
const setErr = validateSetFields(req.body);
if (setErr) return res.status(400).json({ error: setErr });
if (!Array.isArray(questions)) return res.status(400).json({ error: 'questions 必须为数组' });
for (let i = 0; i < questions.length; i++) {
const err = validateBody(questions[i]);
if (err) return res.status(400).json({ error: '第 ' + (i + 1) + ' 题格式错误:' + err });
}
const set = {
id: data.nextSetId++,
name: '',
description: '',
published: questions.length > 0,
questions: questions.map(buildQuestion)
};
applySetFields(set, req.body);
data.sets.push(set);
saveData();
res.status(201).json({ set: toSetSummary(set, true), imported: set.questions.length });
});
app.get('/api/admin/sets/:id/export', requireAuth, (req, res) => {
const id = Number(req.params.id);
const set = data.sets.find(s => s.id === id);
if (!set) return res.status(404).json({ error: '套题不存在' });
res.json({
name: set.name,
description: set.description || '',
lvNames: set.lvNames || null,
exportedAt: new Date().toISOString(),
questions: set.questions.map(q => ({ lv: q.lv, q: q.q, opts: q.opts, ans: q.ans, exp: q.exp }))
});
});
app.post('/api/admin/sets/:id/questions', requireAuth, (req, res) => {
const id = Number(req.params.id);
const set = data.sets.find(s => s.id === id);
if (!set) return res.status(404).json({ error: '套题不存在' });
const err = validateBody(req.body);
if (err) return res.status(400).json({ error: err });
const item = buildQuestion(req.body);
set.questions.push(item);
saveData();
res.status(201).json({ question: item, set: toSetSummary(set, true) });
});
app.put('/api/admin/questions/:qid', requireAuth, (req, res) => {
const hit = findQuestion(Number(req.params.qid));
if (!hit) return res.status(404).json({ error: '题目不存在' });
const err = validateBody(req.body);
if (err) return res.status(400).json({ error: err });
const { lv, q, opts, ans, exp } = req.body;
const item = hit.q;
item.lv = lv === undefined ? item.lv : lv;
item.q = sanitizeAuthoredHtml(q.trim());
item.opts = opts.map(o => o.trim());
item.ans = ans;
item.exp = sanitizeAuthoredHtml(exp.trim());
saveData();
res.json({ question: item, set: toSetSummary(hit.set, true) });
});
app.delete('/api/admin/questions/:qid', requireAuth, (req, res) => {
const hit = findQuestion(Number(req.params.qid));
if (!hit) return res.status(404).json({ error: '题目不存在' });
const idx = hit.set.questions.indexOf(hit.q);
hit.set.questions.splice(idx, 1);
if (!hit.set.questions.length) hit.set.published = false;
saveData();
res.json({ ok: true, set: toSetSummary(hit.set, true) });
});
app.use('/api', (req, res) => {
res.status(404).json({ error: '接口不存在', code: 'NOT_FOUND' });
});
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: '请求 JSON 格式不正确', code: 'INVALID_JSON' });
}
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: '请求内容超过 2MB 限制', code: 'PAYLOAD_TOO_LARGE' });
}
console.error(err);
res.status(500).json({ error: '服务暂时不可用,请稍后重试', code: 'INTERNAL_ERROR' });
});
function startServer(port = PORT) {
return app.listen(port, '0.0.0.0', () => {
console.log('测验系统已启动: http://localhost:' + port);
console.log('答题页: http://localhost:' + port + '/');
console.log('管理后台: http://localhost:' + port + '/#/admin');
console.log('管理员账号: ' + ADMIN_USER + ' / ' + ADMIN_PASS);
});
}
if (require.main === module) startServer();
module.exports = { app, startServer };