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}>`); return `\u0000${placeholders.length - 1}\u0000`; }); return text .replace(//g, '>') .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 };