// 模拟卷王(SurveyKing)社区版 API,用于端到端测试 const http = require('http'); const crypto = require('crypto'); const fs = require('fs'); const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 1024, publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs8', format: 'der' }, }); const PUB_B64 = Buffer.from(publicKey).toString('base64'); const PASSWORD = 'surveyking'; let TOKEN = ''; const roster = JSON.parse(fs.readFileSync(require('path').join(__dirname, '..', 'data', 'roster.json'), 'utf8')).students; // 构造答卷:前 30 人提交,其中 2 人姓名有空格/错别字 const answers = roster.slice(0, 30).map((s, i) => { let name = s.name; if (i === 3) name = ' ' + s.name + ' '; // 带空格 if (i === 7) name = s.name; // 正常 const start = Date.parse('2026-09-08T09:00:00+08:00') + i * 1000; const end = start + (600 + i * 37) * 1000; return { id: 'a' + (i + 1), projectId: 'p1', answer: { q1: name, q2: '高一(1)班', q3: 'B' }, examScore: [100, 92, 88, 76, 61, 59, 95, 83, 72, 68][i % 10] + (i % 3), tempSave: i === 5 ? 0 : 1, metaInfo: { answerInfo: { startTime: start, endTime: end }, clientInfo: { browser: 'Chrome' } }, createAt: new Date(end).toISOString().replace('T', ' ').slice(0, 19), createByName: '', }; }); // 一个不在名单里的答卷 answers.push({ id: 'ax', projectId: 'p1', answer: { q1: '路过的同学', q2: '其他', q3: 'A' }, examScore: 55, tempSave: 1, metaInfo: { answerInfo: { startTime: Date.parse('2026-09-08T09:30:00+08:00'), endTime: Date.parse('2026-09-08T09:41:20+08:00') } }, createAt: '2026-09-08 09:41:20', createByName: '', }); const SURVEY = { id: 'root', title: '高一数学月考', type: 'survey', surveySetting: {}, children: [ { id: 'pg1', type: 'pagination', title: '基本信息', children: [ { id: 'q1', type: 'text', title: '
姓名
' }, { id: 'q2', type: 'text', title: '班级' }, ]}, { id: 'pg2', type: 'pagination', title: '试题', children: [ { id: 'q3', type: 'radio', title: '1+1=?', children: [{ id: 'o1', title: 'A' }, { id: 'o2', title: 'B' }] }, ]}, ], }; const PROJECTS = [ { id: 'p1', name: '高一数学月考', mode: 'exam', status: 1, total: answers.length }, { id: 'p2', name: '课后满意度调查', mode: 'survey', status: 1, total: 5 }, ]; // p1 的「受访者」参与者 = 应考名单(真实卷王模型:t_project_partner type=RESPONDENT_IMP_USER=3) // status: 0未访问 / 1已访问未答 / 2已答题 const PARTNERS = { p1: [ { id: 'pp1', type: 3, userName: '90001', userId: '90001', status: 0 }, { id: 'pp2', type: 3, userName: '90002', userId: '90002', status: 0 }, { id: 'pp3', type: 3, userName: '90003', userId: '90003', status: 0 }, { id: 'pp4', type: 3, userName: '90004', userId: '90004', status: 0 }, { id: 'pp5', type: 3, userName: '90005', userId: '90005', status: 0 }, ], p2: [], }; function send(res, code, obj, headers) { res.writeHead(code, Object.assign({ 'Content-Type': 'application/json; charset=utf-8' }, headers || {})); res.end(JSON.stringify(obj)); } const server = http.createServer((req, res) => { const u = new URL(req.url, 'http://x'); const p = u.pathname; let body = ''; req.on('data', c => body += c); req.on('end', () => { // 鉴权:模拟社区版 v1.13 —— 只读 sk-token Cookie,不读 Authorization 头 if (p.startsWith('/api/') && !p.startsWith('/api/public/') && p !== '/api/system') { const jar = {}; (req.headers.cookie || '').split(';').forEach(kv => { const i = kv.indexOf('='); if (i > 0) jar[kv.slice(0, i).trim()] = kv.slice(i + 1).trim(); }); if (!jar['sk-token'] || jar['sk-token'] !== TOKEN) { return send(res, 200, { code: 401, data: null, msg: 'Full authentication is required to access this resource' }); } } if (p === '/api/system') { return send(res, 200, { code: 0, data: { name: '卷王', version: '1.13.0', publicKey: PUB_B64 } }); } if (p === '/api/public/login') { let j = {}; try { j = JSON.parse(body); } catch (e) {} let pwd = j.password; let plain = pwd; try { plain = crypto.privateDecrypt( { key: privateKey, format: 'der', type: 'pkcs8', padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(pwd, 'base64') ).toString('utf8'); } catch (e) { plain = '<<解密失败>>'; } if (j.username !== 'mock' || plain !== PASSWORD) { return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' }); } TOKEN = 'mock.jwt.token.' + Date.now(); // 真实 v1.13:Authorization 头和 Set-Cookie 都下发,但服务端鉴权只读 Cookie return send(res, 200, { code: 0, data: { userId: '1' } }, { 'Set-Cookie': 'sk-token=' + TOKEN + '; Path=/; HttpOnly; SameSite=Lax', Authorization: TOKEN, }); } if (p === '/api/project/list') { return send(res, 200, { code: 0, data: { total: PROJECTS.length, list: PROJECTS } }); } if (p === '/api/project') { const id = u.searchParams.get('id'); const proj = PROJECTS.find(x => x.id === id); if (!proj) return send(res, 200, { code: 404, data: null, msg: 'not found' }); return send(res, 200, { code: 0, data: Object.assign({}, proj, { survey: id === 'p1' ? SURVEY : null }) }); } if (p === '/api/answer/list') { const pid = u.searchParams.get('projectId'); const cur = parseInt(u.searchParams.get('current') || '1', 10); const size = parseInt(u.searchParams.get('pageSize') || '200', 10); const all = pid === 'p1' ? answers : []; const slice = all.slice((cur - 1) * size, cur * size); return send(res, 200, { code: 0, data: { total: all.length, list: slice } }); } if (p === '/api/project/partner/list') { const pid = u.searchParams.get('projectId'); const list = PARTNERS[pid] || []; return send(res, 200, { code: 0, data: { total: list.length, list } }); } return send(res, 200, { code: 404, data: null, msg: 'no route ' + p }); }); }); server.listen(19911, '127.0.0.1', () => { console.log('mock 卷王已启动 http://127.0.0.1:19911 密码=' + PASSWORD); });