diff --git a/test-concurrent-assessments.mjs b/test-concurrent-assessments.mjs new file mode 100644 index 0000000..c0e7518 --- /dev/null +++ b/test-concurrent-assessments.mjs @@ -0,0 +1,243 @@ +/** + * ============================================================ + * 考核并发性能实验 + * + * 场景:多人同时参加考核,验证系统是否产生数据竞争 + * - 并发启动考核会话 → 验证出题不冲突、会话唯一 + * - 并发提交答案 → 验证评分不串号 + * - 验证最终分数合理性 + * + * 检查指标: + * 1. 并发创建考生是否冲突 + * 2. Session ID 是否唯一 + * 3. 异步出题是否每个会话都拿到正确题数 + * 4. 跨会话题目是否有重叠(去重问题) + * 5. 维度分布是否合理 + * 6. 最终分数是否正常 + * ============================================================ + */ +const API = 'http://localhost:3001'; +const TENANT_ID = 'a140a68e-f70a-44d3-b753-fa33d48cf234'; + +let pass = 0, fail = 0, warn = 0; + +function ok(l, d) { pass++; console.log(` ✅ ${l}${d?' — '+d:''}`); } +function no(l, d) { fail++; console.log(` ❌ ${l}${d?' — '+d:''}`); } +function soft(l, d) { warn++; console.log(` ⚠️ ${l}${d?' — '+d:''}`); } + +async function login(u, p) { + const r = await fetch(`${API}/api/auth/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})}); + return r.ok ? (await r.json()).access_token : null; +} + +async function call(token, method, path, body=null) { + const opts = {method,headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'}}; + if(body) opts.body = JSON.stringify(body); + const r = await fetch(`${API}/api${path}`,opts); + return {status:r.status,data:await r.json().catch(()=>null)}; +} + +async function run() { + console.log('\n' + '█'.repeat(70)); + console.log(' 🔬 考核并发性能实验'); + console.log('█'.repeat(70)); + + const t0 = Date.now(); + const adminT = await login('admin','admin123'); + ok('管理员登录', !!adminT); + + // 1. 创建/获取 20 个考生 + console.log('\n─── 1. 创建 20 个考生(或获取已有)───'); + const N = 20; + const candidates = []; + // 先查已有用户 + const allUsers = await fetch(`${API}/api/users`,{headers:{Authorization:`Bearer ${adminT}`}}).then(r=>r.json()); + const userList = Array.isArray(allUsers) ? allUsers : (allUsers.data||[]); + + for (let i = 0; i < N; i++) { + const uname = 'z-perf-' + String(i+1).padStart(2,'0'); + let existing = userList.find(u => u.username === uname); + if (existing) { + candidates.push({name:uname, id:existing.id}); + } else { + const r = await call(adminT,'POST','/users',{username:uname,password:'conc123',displayName:'考生'+i}); + const id = r.data?.user?.id || r.data?.id; + if (id) { + candidates.push({name:uname,id}); + await call(adminT,'POST',`/v1/tenants/${TENANT_ID}/members`,{userId:id,role:'USER'}); + } + } + } + ok(`就绪 ${candidates.length}/${N} 考生`, `${Date.now()-t0}ms`); + + // 2. 并发启动考核 + console.log('\n─── 2. 并发启动考核(异步出题)───'); + const starts = candidates.map(c => login(c.name,'conc123').then(token => { + if (!token) return {name:c.name,err:'login_fail'}; + return fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:'eefe8c6c-d082-4a8c-b884-76577dde3249',language:'zh'})}) + .then(r => r.json().then(d => ({name:c.name,token,sessionId:d.id,status:r.status,data:d}))) + .catch(e => ({name:c.name,token,err:e.message})); + })); + + const started = await Promise.all(starts); + const sOk = started.filter(r => r.sessionId && r.status < 300); + const sFail = started.filter(r => !r.sessionId); + ok(`启动考核 ${sOk.length}/${N}`, `失败${sFail.length}`); + sFail.forEach(r => soft(`${r.name} 启动失败`, r.err||'?')); + + // Session ID 唯一性 + const ids = sOk.map(r => r.sessionId); + ok('Session ID 唯一', new Set(ids).size === ids.length); + + // 3. 等待异步出题 + 验证 + console.log('\n─── 3. 等待异步出题并验证 ───'); + const sessions = []; + let timedOut = 0; + for (const r of sOk) { + let questions = []; + for (let w = 0; w < 45; w++) { + try { + const sr = await fetch(`${API}/api/assessment/${r.sessionId}/state`,{headers:{Authorization:`Bearer ${r.token}`}}); + if (sr.ok) { + const st = await sr.json(); + questions = st.questions || []; + if (questions.length > 0) break; + } + } catch(e) {} + await new Promise(r => setTimeout(r,2000)); + } + if (questions.length === 0) timedOut++; + sessions.push({name:r.name, sessionId:r.sessionId, token:r.token, questions, data:r.data}); + } + ok(`异步出题完成 ${sessions.length - timedOut}/${sessions.length}`, `超时 ${timedOut}`); + + // 题数检查 + const qNums = sessions.filter(s => s.questions.length > 0).map(s => s.questions.length); + if (qNums.length > 0) { + const allSame = qNums.every(n => n === qNums[0]); + ok(`会话题数一致`, allSame ? `均为 ${qNums[0]} 题` : `不一致: ${qNums.slice(0,10).join(',')}`); + } + + // 维度分布 + const hasQuestions = sessions.filter(s => s.questions.length > 0); + for (const s of hasQuestions.slice(0,3)) { + const dims = {}; + s.questions.forEach(q => { dims[q.dimension] = (dims[q.dimension]||0) + 1; }); + ok(`${s.name} 维度`, Object.entries(dims).map(([k,v])=>`${k}:${v}`).join(',')); + } + ok('都有 PROMPT', hasQuestions.every(s => s.questions.some(q => q.dimension === 'PROMPT'))); + ok('都有 LLM', hasQuestions.every(s => s.questions.some(q => q.dimension === 'LLM'))); + + // 查询总题库大小 + let allItemsCount = '?'; + try { + const bankR = await fetch(`${API}/api/question-banks`,{headers:{Authorization:`Bearer ${adminT}`}}); + if (bankR.ok) { + const bankD = await bankR.json(); + // 查找关联 AI 协作模板的题库 + const targetBank = (Array.isArray(bankD)?bankD:bankD.data||[]).find(b => b.templateId === 'eefe8c6c-d082-4a8c-b884-76577dde3249'); + if (targetBank) allItemsCount = targetBank.items?.length || targetBank._count?.items || targetBank.itemCount || '?'; + } + } catch(e) {} + + // 题目重叠检查(计算概率) + if (hasQuestions.length >= 5) { + let totalPairs = 0, overlappedPairs = 0, totalOverlapCount = 0; + for (let i = 0; i < 5; i++) { + for (let j = i+1; j < 5; j++) { + totalPairs++; + const a = new Set(hasQuestions[i].questions.map(q => q.id)); + const b = new Set(hasQuestions[j].questions.map(q => q.id)); + const o = [...a].filter(id => b.has(id)).length; + if (o > 0) { overlappedPairs++; totalOverlapCount += o; } + } + } + const overlapRate = totalPairs > 0 ? (totalOverlapCount / (totalPairs * 20) * 100).toFixed(1) : '0'; + ok('题目重叠检查完成', `题库 ${allItemsCount} 题, 20人×20题需400, 重叠率 ${overlapRate}%`); + if (overlappedPairs === 0) ok('零重叠', ''); + else soft(`${overlappedPairs}/${totalPairs} 对重叠`, `共 ${totalOverlapCount} 题次`); + } + + // 4. 并发提交答案 + console.log('\n─── 4. 并发提交答案 ───'); + const qGroups = sessions.filter(s => s.questions && s.questions.length >= 4).slice(0,6); + if (qGroups.length === 0) { soft('无足够题目的会话', `总会话${sessions.length}个`); } + else { + const submits = qGroups.map(s => + (async () => { + const results = []; + for (let qi = 0; qi < 4; qi++) { + const q = s.questions[qi]; + if (!q) continue; + const isChoice = q.questionType === 'MULTIPLE_CHOICE' || q.questionType === 'TRUE_FALSE'; + await new Promise(r => setTimeout(r, 500 + Math.random() * 1500)); + try { + const r = await fetch(`${API}/api/assessment/${s.sessionId}/answer`,{method:'POST',headers:{Authorization:`Bearer ${s.token}`,'Content-Type':'application/json'},body:JSON.stringify({answer:isChoice?'A':'并发测试回答',language:'zh'})}); + results.push({qi, ok: r.ok, status: r.status}); + } catch(e) { results.push({qi, ok: false, error: e.message}); } + } + return {name:s.name, results}; + })() + ); + const subResults = (await Promise.all(submits)).filter(Boolean); + ok(`并发答题 ${subResults.length}/${qGroups.length} 完成`, ''); + for (const sr of subResults) { + const okAll = sr.results.every(r => r.ok); + const n = sr.results.filter(r => r.ok).length; + if (okAll) ok(`${sr.name} 全部提交成功`); + else soft(`${sr.name} ${n}/4 成功`); + } + } + + // 5. 等待评分并检查最终分数 + console.log('\n─── 5. 最终分数检查 ───'); + let scored = 0, totalScoreCheck = 0; + for (const s of qGroups) { + await new Promise(r => setTimeout(r, 15000)); + try { + const st = await fetch(`${API}/api/assessment/${s.sessionId}/state`,{headers:{Authorization:`Bearer ${s.token}`}}).then(r=>r.json()); + // state 返回的是 evaluation state + const isDone = st.currentQuestionIndex >= (st.questionCount || st.questions?.length || 20); + // 也查一下 session 的状态 + const sessionR = await fetch(`${API}/api/assessment/${s.sessionId}`,{headers:{Authorization:`Bearer ${s.token}`}}).catch(()=>null); + const session = sessionR?.ok ? await sessionR.json() : null; + const status = session?.status || st._sessionStatus || (isDone ? '猜测完成' : '进行中'); + const finalScore = session?.finalScore ?? st.finalScore; + const passed = session?.passed ?? st.passed; + if (status === 'COMPLETED' || (isDone && finalScore !== undefined)) { + totalScoreCheck++; + if (finalScore !== undefined && finalScore !== null && !isNaN(finalScore)) { + scored++; + ok(`${s.name} 已完成`, `分数=${finalScore}, 合格=${!!passed}`); + } else { + soft(`${s.name} 分数待定`, `status=${status}, score=${finalScore}`); + } + } else { + soft(`${s.name} ${status}`, `分数=${finalScore}`); + } + } catch(e) { soft(`${s.name} 查询失败`, e.message); } + } + ok(`评分完成 ${scored}/${totalScoreCheck}`, '已评分/已检查'); + + // 6. 清理 + console.log('\n─── 6. 清理 ───'); + let cleaned = 0; + for (const c of candidates) { + await call(adminT,'DELETE',`/users/${c.id}`).catch(()=>{}); + cleaned++; + } + ok(`清理 ${cleaned} 个考生`, ''); + + // 报告 + const elapsed = Math.round((Date.now()-t0)/1000); + console.log('\n' + '█'.repeat(70)); + console.log(` 📊 并发测试报告 (${elapsed}秒)`); + console.log(` ✅ ${pass} ⚠️ ${warn} ❌ ${fail}`); + console.log('█'.repeat(70)); + + if (fail > 0) process.exit(1); + else if (warn > 0) console.log('\n ⚠️ 有警告(非致命)'); + else console.log('\n 🎉 全部通过!并发表现正常'); +} + +run().catch(e => { console.error('\n💥', e.message); process.exit(1); });