Files
ruoyi-vue-pro/tools/education-student-harness/server.js

81 lines
16 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT = __dirname;
const PORT = Number(process.env.PORT || 4173);
const HOST = '127.0.0.1';
const TOKENS = {
'tenant-a-student-1': { tenantId: 'tenant-a', userId: 'student-a1', displayName: 'Student A1', tenantName: 'Tenant Alpha' },
'tenant-a-student-2': { tenantId: 'tenant-a', userId: 'student-a2', displayName: 'Student A2', tenantName: 'Tenant Alpha' },
'tenant-b-student-1': { tenantId: 'tenant-b', userId: 'student-b1', displayName: 'Student B1', tenantName: 'Tenant Beta' },
'tenant-b-student-2': { tenantId: 'tenant-b', userId: 'student-b2', displayName: 'Student B2', tenantName: 'Tenant Beta' },
};
const QUESTION_DATA = [
{ id: 'q-a-1', tenantId: 'tenant-a', collectionId: 'col-a-core', stem: 'Which layer owns the API contract?', type: 'SINGLE', options: ['Controller', 'Database', 'Browser'], answer: 'A', explanation: 'The controller owns the API boundary.' },
{ id: 'q-a-2', tenantId: 'tenant-a', collectionId: 'col-a-core', stem: 'What prevents a stale answer overwrite?', type: 'SINGLE', options: ['Version check', 'Random delay', 'Client tenant ID'], answer: 'A', explanation: 'The session version is checked atomically.' },
{ id: 'q-a-3', tenantId: 'tenant-a', collectionId: 'col-a-core', stem: 'Which response shape is paged?', type: 'SINGLE', options: ['PageResult', 'String', 'Token'], answer: 'A', explanation: 'PageResult carries list and total.' },
{ id: 'q-b-1', tenantId: 'tenant-b', collectionId: 'col-b-core', stem: 'Which boundary carries tenant context?', type: 'SINGLE', options: ['Auth context', 'Question stem', 'Answer text'], answer: 'A', explanation: 'Tenant context comes from authentication.' },
{ id: 'q-b-2', tenantId: 'tenant-b', collectionId: 'col-b-core', stem: 'When are explanations visible?', type: 'SINGLE', options: ['After submit', 'Before auth', 'Never'], answer: 'A', explanation: 'Reports reveal explanations after submission.' },
{ id: 'q-b-3', tenantId: 'tenant-b', collectionId: 'col-b-core', stem: 'Which operation is idempotent?', type: 'SINGLE', options: ['Save answer', 'Changing tenant', 'Reading a secret'], answer: 'A', explanation: 'Answer saves use an idempotency key.' },
];
let state;
function resetState() {
state = { sessions: new Map(), answers: new Map(), reports: new Map(), wrong: new Map(), favorites: new Map(), next: 1 };
}
resetState();
const id = (prefix) => `${prefix}-${state.next++}`;
const json = (res, status, data, msg = '成功') => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Request-Id': id('req') }); res.end(JSON.stringify({ code: status >= 400 ? status : 0, msg, data: data === undefined ? null : data })); };
const safe = (q) => { const { answer, explanation, ...result } = q; return result; };
const page = (list, query) => ({ list, total: list.length, pageNo: Number(query.get('pageNo') || 1), pageSize: Number(query.get('pageSize') || list.length || 10) });
function auth(req) {
const match = /^Bearer\s+(.+)$/.exec(req.headers.authorization || '');
return match && TOKENS[match[1]] ? TOKENS[match[1]] : null;
}
function body(req) { return new Promise((resolve, reject) => { let raw = ''; req.on('data', c => { raw += c; if (raw.length > 1024 * 1024) reject(new Error('body too large')); }); req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('invalid json')); } }); req.on('error', reject); }); }
function fault(req, name) { return req.headers['x-harness-fault'] === name || new URL(req.url, 'http://127.0.0.1').searchParams.get('fault') === name; }
function own(ctx, resource) { return resource && resource.tenantId === ctx.tenantId && resource.userId === ctx.userId; }
function sessionView(session, submitted = false) { return { id: session.id, clientSessionId: session.clientSessionId, tenantId: session.tenantId, userId: session.userId, collectionId: session.collectionId, status: session.status, sessionVersion: session.version, serverVersion: session.version, acceptedSequence: session.acceptedSequence, questionCount: session.questions.length, questions: session.questions.map(q => ({ sequence: q.sequence, questionId: q.questionId, stem: q.stem, type: q.type, options: q.options, selectedAnswer: q.selectedAnswer || null, ...(submitted ? { answer: q.answer, explanation: q.explanation, isCorrect: q.selectedAnswer === q.answer } : {}) })) }; }
function findSession(ctx, value) { const s = state.sessions.get(String(value)); return own(ctx, s) ? s : null; }
function findQuestion(ctx, qid) { return QUESTION_DATA.find(q => q.id === String(qid) && q.tenantId === ctx.tenantId); }
async function handler(req, res) {
const url = new URL(req.url, `http://${HOST}`); const p = url.pathname;
if (p === '/' || p === '/index.html') return serve(res, p === '/' ? '/index.html' : p);
if (p === '/styles.css' || p === '/app.js' || p.startsWith('/fixtures/') || p === '/endpoint-matrix.md') return serve(res, p);
if (!p.startsWith('/app-api/')) return json(res, 404, null, 'Not found');
if (p === '/app-api/education/tenant/resolve' && req.method === 'GET') return json(res, 200, { tenantId: 'tenant-a', tenantName: 'Tenant Alpha', resolved: true });
const ctx = auth(req); if (!ctx) return json(res, 401, null, '未认证');
if (p === '/app-api/education/context' && req.method === 'GET') return json(res, 200, ctx);
if (fault(req, 'upstream-failure') && p.includes('/catalog/')) return json(res, 503, null, 'upstream failure');
if (p === '/app-api/education/catalog/regions' && req.method === 'GET') return json(res, 200, [{ id: `${ctx.tenantId}-region-1`, name: ctx.tenantName + ' Region' }]);
if (p === '/app-api/education/catalog/categories' && req.method === 'GET') return json(res, 200, [{ id: `${ctx.tenantId}-category-1`, name: 'Core' }]);
if (p === '/app-api/education/catalog/subjects' && req.method === 'GET') return json(res, 200, [{ id: `${ctx.tenantId}-subject-1`, name: 'Engineering' }]);
if (p === '/app-api/education/catalog/question-collections' && req.method === 'GET') return json(res, 200, [{ id: `col-${ctx.tenantId.slice(-1)}-core`, name: 'Core Loop', questionCount: 3, status: 'AVAILABLE', tenantId: ctx.tenantId }]);
if (p === '/app-api/education/questions/page' && req.method === 'GET') { const list = QUESTION_DATA.filter(q => q.tenantId === ctx.tenantId && (!url.searchParams.get('collectionId') || q.collectionId === url.searchParams.get('collectionId'))).map(safe); return json(res, 200, page(list, url.searchParams)); }
if (p === '/app-api/education/practice-config/preview' && req.method === 'GET') return json(res, 200, { valid: true, questionCount: Math.min(Number(url.searchParams.get('questionCount') || 3), 3), collectionId: url.searchParams.get('collectionId') || `col-${ctx.tenantId.slice(-1)}-core` });
if (p === '/app-api/education/practice-session/create' && req.method === 'POST') { const b = await body(req); if (!b.clientSessionId || !b.collectionId) return json(res, 400, null, 'clientSessionId and collectionId required'); const existing = [...state.sessions.values()].find(s => own(ctx, s) && s.clientSessionId === b.clientSessionId); if (existing) return json(res, 200, sessionView(existing)); const qs = QUESTION_DATA.filter(q => q.tenantId === ctx.tenantId && q.collectionId === b.collectionId).slice(0, Math.max(1, Math.min(Number(b.questionCount || 3), 3))); if (!qs.length) return json(res, 404, null, 'collection not found'); const s = { id: id('session'), tenantId: ctx.tenantId, userId: ctx.userId, clientSessionId: b.clientSessionId, collectionId: b.collectionId, status: 'ACTIVE', version: 0, acceptedSequence: 0, questions: qs.map((q, i) => ({ ...q, questionId: q.id, sequence: i + 1, selectedAnswer: null })) }; state.sessions.set(s.id, s); return json(res, 200, sessionView(s)); }
if (p === '/app-api/education/practice-session/current' && req.method === 'GET') { const s = [...state.sessions.values()].reverse().find(s => own(ctx, s) && s.status === 'ACTIVE'); return json(res, 200, s ? sessionView(s) : null); }
if (p === '/app-api/education/practice-session/get' && req.method === 'GET') { const s = findSession(ctx, url.searchParams.get('id')); return s ? json(res, 200, sessionView(s, s.status === 'SUBMITTED')) : json(res, 404, null, 'session not found'); }
if (p === '/app-api/education/practice-session/answer' && req.method === 'PUT') { const b = await body(req); const s = findSession(ctx, b.sessionId); if (!s) return json(res, 404, null, 'session not found'); if (s.status !== 'ACTIVE') return json(res, 409, null, 'submitted session is immutable'); const key = `${s.id}:${b.idempotencyKey}`; if (state.answers.has(key)) { if (fault(req, 'answer-timeout-after-commit')) return json(res, 504, null, 'timeout after commit'); return json(res, 200, state.answers.get(key)); } if (b.expectedSessionVersion !== s.version) return json(res, 409, { currentVersion: s.version }, 'stale session version'); const q = s.questions.find(q => q.sequence === Number(b.questionSequence)); if (!q || typeof b.selectedAnswer !== 'string') return json(res, 400, null, 'invalid answer'); q.selectedAnswer = b.selectedAnswer; s.version++; s.acceptedSequence = Math.max(s.acceptedSequence, Number(b.clientSequence) || 0); const result = { sessionId: s.id, questionSequence: q.sequence, selectedAnswer: q.selectedAnswer, sessionVersion: s.version, serverVersion: s.version, acceptedSequence: s.acceptedSequence }; state.answers.set(key, result); if (fault(req, 'answer-timeout-after-commit')) return json(res, 504, null, 'timeout after commit'); return json(res, 200, result); }
if (p === '/app-api/education/practice-session/submit' && req.method === 'POST') { const b = await body(req); const s = findSession(ctx, b.sessionId); if (!s) return json(res, 404, null, 'session not found'); if (s.status === 'SUBMITTED') return json(res, 200, state.reports.get(s.id)); if (b.expectedSessionVersion !== s.version) return json(res, 409, { currentVersion: s.version }, 'stale session version'); if (!b.idempotencyKey) return json(res, 400, null, 'idempotencyKey required'); const details = s.questions.map(q => ({ questionId: q.questionId, selectedAnswer: q.selectedAnswer, answer: q.answer, explanation: q.explanation, isCorrect: q.selectedAnswer === q.answer })); const report = { id: id('report'), sessionId: s.id, score: details.filter(x => x.isCorrect).length, total: details.length, details }; s.status = 'SUBMITTED'; s.version++; state.reports.set(s.id, report); details.filter(x => !x.isCorrect).forEach(x => { const k = `${ctx.tenantId}:${ctx.userId}:${x.questionId}`; const w = state.wrong.get(k) || { id: id('wrong'), tenantId: ctx.tenantId, userId: ctx.userId, questionId: x.questionId, questionStem: QUESTION_DATA.find(q => q.id === x.questionId)?.stem, stem: QUESTION_DATA.find(q => q.id === x.questionId)?.stem, errorCount: 0, masterStatus: 'UNMASTERED' }; w.errorCount++; state.wrong.set(k, w); }); return json(res, 200, { ...report, sessionVersion: s.version, status: s.status }); }
if (p === '/app-api/education/practice-session/report' && req.method === 'GET') { const s = findSession(ctx, url.searchParams.get('sessionId')); const r = s && state.reports.get(s.id); return r ? json(res, 200, r) : json(res, 404, null, 'report not found'); }
if (p === '/app-api/education/practice-session/reports' && req.method === 'GET') return json(res, 200, page([...state.reports].map(([sid, r]) => { const s = state.sessions.get(sid); return own(ctx, s) ? r : null; }).filter(Boolean), url.searchParams));
if (p === '/app-api/education/wrong-question/page' && req.method === 'GET') { let list = [...state.wrong.values()].filter(w => own(ctx, w)); if (url.searchParams.get('masterStatus')) list = list.filter(w => w.masterStatus === url.searchParams.get('masterStatus')); return json(res, 200, page(list, url.searchParams)); }
if (p === '/app-api/education/wrong-question/get' && req.method === 'GET') { const w = [...state.wrong.values()].find(w => own(ctx, w) && w.id === url.searchParams.get('id')); const q = w && findQuestion(ctx, w.questionId); return w && q ? json(res, 200, { ...w, questionId: q.id, stem: q.stem, answer: q.answer, explanation: q.explanation }) : json(res, 404, null, 'wrong question not found'); }
if ((p.endsWith('/master') || p.endsWith('/unmaster')) && req.method === 'PUT') { const b = await body(req); const w = [...state.wrong.values()].find(w => own(ctx, w) && w.id === String(b.id || url.searchParams.get('id'))); if (!w) return json(res, 404, null, 'wrong question not found'); w.masterStatus = p.endsWith('/master') ? 'MASTERED' : 'UNMASTERED'; return json(res, 200, { mastered: w.masterStatus === 'MASTERED', masterStatus: w.masterStatus }); }
if (p === '/app-api/education/wrong-question/review-session' && req.method === 'POST') { const b = await body(req); const ids = Array.isArray(b.wrongQuestionIds) ? b.wrongQuestionIds : []; const qs = ids.map(x => [...state.wrong.values()].find(w => own(ctx, w) && w.id === String(x))).filter(Boolean).map(w => findQuestion(ctx, w.questionId)).filter(Boolean); if (!qs.length) return json(res, 400, null, 'no owned wrong questions'); const s = { id: id('session'), tenantId: ctx.tenantId, userId: ctx.userId, clientSessionId: b.clientSessionId || id('client'), collectionId: 'wrong-review', status: 'ACTIVE', version: 0, acceptedSequence: 0, questions: qs.map((q, i) => ({ ...q, questionId: q.id, sequence: i + 1, selectedAnswer: null })) }; state.sessions.set(s.id, s); return json(res, 200, sessionView(s)); }
if (p === '/app-api/education/favorite/page' && req.method === 'GET') return json(res, 200, page([...state.favorites.values()].filter(f => own(ctx, f)), url.searchParams));
if (p === '/app-api/education/favorite/create' && req.method === 'POST') { const b = await body(req); const q = findQuestion(ctx, b.targetId); if (b.targetType !== 'QUESTION' || !q) return json(res, 404, null, 'question not found'); const key = `${ctx.tenantId}:${ctx.userId}:${q.id}`; const f = state.favorites.get(key) || { id: id('favorite'), tenantId: ctx.tenantId, userId: ctx.userId, targetType: 'QUESTION', targetId: q.id, questionId: q.id, questionStem: q.stem, status: 'ACTIVE' }; f.status = 'ACTIVE'; state.favorites.set(key, f); return json(res, 200, f); }
if (p === '/app-api/education/favorite/delete' && req.method === 'DELETE') { const b = await body(req); const f = [...state.favorites.values()].find(f => own(ctx, f) && (b.id && f.id === String(b.id) || b.targetId && f.targetId === String(b.targetId))); if (f) f.status = 'DELETED'; return json(res, 200, { deleted: true }); }
if (p === '/app-api/education/favorite/status' && req.method === 'POST') { const b = await body(req); const ids = (b.questionIds || []).filter(qid => findQuestion(ctx, qid)); return json(res, 200, { questionIds: ids.filter(qid => [...state.favorites.values()].some(f => own(ctx, f) && f.status === 'ACTIVE' && f.targetId === String(qid))) }); }
return json(res, 404, null, 'Not found');
}
function serve(res, requestPath) { const file = path.resolve(ROOT, requestPath.slice(1)); if (!file.startsWith(path.resolve(ROOT)) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return json(res, 404, null, 'Not found'); const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json' }; res.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream' }); fs.createReadStream(file).pipe(res); }
function createServer() { return http.createServer((req, res) => { const original = req.headers.authorization; if (original) req.headers.authorization = original; handler(req, res).catch(err => json(res, 400, null, err.message)); }); }
if (require.main === module) createServer().listen(PORT, HOST, () => process.stdout.write(`education harness listening on http://${HOST}:${PORT}\n`));
module.exports = { createServer, resetState, TOKENS };