update
This commit is contained in:
@@ -7,6 +7,14 @@ 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' }));
|
||||
@@ -24,28 +32,87 @@ if (fs.existsSync(DIST)) {
|
||||
function loadData() {
|
||||
if (!fs.existsSync(DATA_FILE)) return { sets: [], nextSetId: 1, nextQId: 1 };
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||
let raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||
let migratedFromArray = false;
|
||||
if (Array.isArray(raw)) {
|
||||
const qs = raw;
|
||||
const migrated = {
|
||||
sets: [{ id: 1, name: 'Dart & Flutter 学习测验', questions: qs }],
|
||||
raw = {
|
||||
sets: [{ id: 1, name: 'Dart & Flutter 学习测验', questions: raw }],
|
||||
nextSetId: 2,
|
||||
nextQId: qs.length ? Math.max(...qs.map(q => q.id)) + 1 : 1
|
||||
nextQId: 1
|
||||
};
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(migrated, null, 2), 'utf8');
|
||||
return migrated;
|
||||
migratedFromArray = true;
|
||||
}
|
||||
if (raw && Array.isArray(raw.sets)) return raw;
|
||||
return { sets: [], nextSetId: 1, nextQId: 1 };
|
||||
} catch {
|
||||
return { sets: [], nextSetId: 1, nextQId: 1 };
|
||||
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() {
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||||
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();
|
||||
@@ -58,6 +125,73 @@ function findQuestion(qid) {
|
||||
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) {
|
||||
@@ -69,14 +203,38 @@ function validateBody(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, '<')
|
||||
.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: q.trim(), opts, ans, exp: (exp || '').trim() };
|
||||
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) {
|
||||
@@ -88,15 +246,18 @@ function validateLvNames(v) {
|
||||
}
|
||||
|
||||
app.get('/api/sets', (req, res) => {
|
||||
res.json({ sets: data.sets.map(s => ({ id: s.id, name: s.name, count: s.questions.length, lvNames: s.lvNames || null })) });
|
||||
const sets = data.sets.filter(isPublished).map(s => toSetSummary(s));
|
||||
res.json({ sets, total: sets.length });
|
||||
});
|
||||
|
||||
app.get('/api/questions', (req, res) => {
|
||||
const setId = req.query.set ? Number(req.query.set) : (data.sets[0] ? data.sets[0].id : null);
|
||||
const set = data.sets.find(s => s.id === setId);
|
||||
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||
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: { id: set.id, name: set.name, lvNames: set.lvNames || null },
|
||||
set: toSetSummary(set),
|
||||
count: set.questions.length,
|
||||
questions: set.questions.map(toPublic)
|
||||
});
|
||||
@@ -105,7 +266,7 @@ app.get('/api/questions', (req, res) => {
|
||||
app.post('/api/check', (req, res) => {
|
||||
const { id, answer } = req.body || {};
|
||||
const hit = findQuestion(id);
|
||||
if (!hit) return res.status(404).json({ error: '题目不存在' });
|
||||
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 });
|
||||
});
|
||||
@@ -128,59 +289,92 @@ function requireAuth(req, res, next) {
|
||||
}
|
||||
|
||||
app.get('/api/admin/sets', requireAuth, (req, res) => {
|
||||
res.json({ sets: data.sets });
|
||||
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 { name } = req.body || {};
|
||||
if (!name || typeof name !== 'string' || name.trim() === '') return res.status(400).json({ error: '套题名称不能为空' });
|
||||
const lvErr = validateLvNames(req.body.lvNames);
|
||||
if (lvErr) return res.status(400).json({ error: lvErr });
|
||||
const set = { id: data.nextSetId++, name: name.trim(), questions: [] };
|
||||
if (req.body.lvNames !== undefined) set.lvNames = req.body.lvNames;
|
||||
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.json(set);
|
||||
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 = data.sets.find(s => s.id === id);
|
||||
const set = findSet(id);
|
||||
if (!set) return res.status(404).json({ error: '套题不存在' });
|
||||
const { name } = req.body || {};
|
||||
if (!name || typeof name !== 'string' || name.trim() === '') return res.status(400).json({ error: '套题名称不能为空' });
|
||||
const lvErr = validateLvNames(req.body.lvNames);
|
||||
if (lvErr) return res.status(400).json({ error: lvErr });
|
||||
set.name = name.trim();
|
||||
if (req.body.lvNames !== undefined) set.lvNames = req.body.lvNames;
|
||||
const err = validateSetFields(req.body, id);
|
||||
if (err) return res.status(400).json({ error: err });
|
||||
applySetFields(set, req.body);
|
||||
saveData();
|
||||
res.json(set);
|
||||
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 });
|
||||
res.json({ ok: true, deleted: { id: deleted.id, name: deleted.name } });
|
||||
});
|
||||
|
||||
app.post('/api/admin/import', requireAuth, (req, res) => {
|
||||
const { name, questions } = req.body || {};
|
||||
if (!name || typeof name !== 'string' || name.trim() === '') return res.status(400).json({ error: '套题名称不能为空' });
|
||||
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 必须为数组' });
|
||||
const lvErr = validateLvNames(req.body.lvNames);
|
||||
if (lvErr) return res.status(400).json({ error: lvErr });
|
||||
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: name.trim(), questions: questions.map(buildQuestion) };
|
||||
if (req.body.lvNames !== undefined) set.lvNames = req.body.lvNames;
|
||||
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.json({ set: { id: set.id, name: set.name, count: set.questions.length }, imported: set.questions.length });
|
||||
res.status(201).json({ set: toSetSummary(set, true), imported: set.questions.length });
|
||||
});
|
||||
|
||||
app.get('/api/admin/sets/:id/export', requireAuth, (req, res) => {
|
||||
@@ -189,6 +383,7 @@ app.get('/api/admin/sets/:id/export', requireAuth, (req, res) => {
|
||||
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 }))
|
||||
@@ -204,7 +399,7 @@ app.post('/api/admin/sets/:id/questions', requireAuth, (req, res) => {
|
||||
const item = buildQuestion(req.body);
|
||||
set.questions.push(item);
|
||||
saveData();
|
||||
res.json(item);
|
||||
res.status(201).json({ question: item, set: toSetSummary(set, true) });
|
||||
});
|
||||
|
||||
app.put('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
||||
@@ -215,12 +410,12 @@ app.put('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
||||
const { lv, q, opts, ans, exp } = req.body;
|
||||
const item = hit.q;
|
||||
item.lv = lv === undefined ? item.lv : lv;
|
||||
item.q = q.trim();
|
||||
item.opts = opts;
|
||||
item.q = sanitizeAuthoredHtml(q.trim());
|
||||
item.opts = opts.map(o => o.trim());
|
||||
item.ans = ans;
|
||||
item.exp = (exp || '').trim();
|
||||
item.exp = sanitizeAuthoredHtml(exp.trim());
|
||||
saveData();
|
||||
res.json(item);
|
||||
res.json({ question: item, set: toSetSummary(hit.set, true) });
|
||||
});
|
||||
|
||||
app.delete('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
||||
@@ -228,13 +423,36 @@ app.delete('/api/admin/questions/:qid', requireAuth, (req, res) => {
|
||||
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 });
|
||||
res.json({ ok: true, set: toSetSummary(hit.set, true) });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log('测验系统已启动: http://localhost:' + PORT);
|
||||
console.log('答题页: http://localhost:' + PORT + '/');
|
||||
console.log('管理后台: http://localhost:' + PORT + '/admin.html');
|
||||
console.log('管理员账号: ' + ADMIN_USER + ' / ' + ADMIN_PASS);
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user