feat: 优化考试记录接口,支持从任务反推科目名称;更新测试用例以验证新逻辑

This commit is contained in:
2025-12-30 12:00:32 +08:00
parent 7fff13afb7
commit 8cd6950631
7 changed files with 119 additions and 7 deletions

View File

@@ -163,12 +163,24 @@ export class QuizController {
const result = await QuizModel.submitQuiz({ userId, answers: processedAnswers });
if (subjectId || taskId) {
let finalSubjectId: string | null = subjectId || null;
const finalTaskId: string | null = taskId || null;
// 任务考试场景下如果前端未传subjectId则从任务中反查
if (!finalSubjectId && finalTaskId) {
const { ExamTaskModel } = await import('../models/examTask');
const task = await ExamTaskModel.findById(finalTaskId);
if (task?.subjectId) {
finalSubjectId = task.subjectId;
}
}
const sql = `
UPDATE quiz_records
SET subject_id = ?, task_id = ?
WHERE id = ?
`;
await import('../database').then(({ run }) => run(sql, [subjectId || null, taskId || null, result.record.id]));
await import('../database').then(({ run }) => run(sql, [finalSubjectId, finalTaskId, result.record.id]));
}
res.json({

View File

@@ -140,11 +140,13 @@ export class QuizModel {
const recordsSql = `
SELECT r.id, r.user_id as userId, r.total_score as totalScore, r.correct_count as correctCount, r.total_count as totalCount,
r.score_percentage as scorePercentage, r.status, r.created_at as createdAt,
r.subject_id as subjectId, s.name as subjectName,
COALESCE(r.subject_id, t.subject_id) as subjectId,
COALESCE(s.name, ts.name) as subjectName,
r.task_id as taskId, t.name as taskName
FROM quiz_records r
LEFT JOIN exam_subjects s ON r.subject_id = s.id
LEFT JOIN exam_tasks t ON r.task_id = t.id
LEFT JOIN exam_subjects s ON r.subject_id = s.id
LEFT JOIN exam_subjects ts ON t.subject_id = ts.id
WHERE r.user_id = ?
ORDER BY r.created_at DESC
LIMIT ? OFFSET ?
@@ -169,11 +171,16 @@ export class QuizModel {
SELECT r.id, r.user_id as userId, u.name as userName, u.phone as userPhone,
r.total_score as totalScore, r.correct_count as correctCount, r.total_count as totalCount,
r.score_percentage as scorePercentage, r.status,
r.created_at as createdAt, r.subject_id as subjectId, s.name as subjectName,
r.task_id as taskId
r.created_at as createdAt,
COALESCE(r.subject_id, t.subject_id) as subjectId,
COALESCE(s.name, ts.name) as subjectName,
r.task_id as taskId,
t.name as taskName
FROM quiz_records r
JOIN users u ON r.user_id = u.id
LEFT JOIN exam_tasks t ON r.task_id = t.id
LEFT JOIN exam_subjects s ON r.subject_id = s.id
LEFT JOIN exam_subjects ts ON t.subject_id = ts.id
ORDER BY r.created_at DESC
LIMIT ? OFFSET ?
`;

Binary file not shown.

View File

@@ -10,7 +10,7 @@
"build": "tsc && vite build",
"preview": "vite preview",
"start": "node dist/api/server.js",
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts",
"test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts test/user-records-subjectname.test.ts",
"check": "tsc --noEmit"
},
"dependencies": {

View File

@@ -128,7 +128,7 @@ const AdminLayout = ({ children }: { children: React.ReactNode }) => {
<div className="flex items-center gap-4">
<span className="text-gray-600 hidden sm:block">
{admin?.username}
{admin?.username}
</span>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" arrow>
<Avatar

View File

@@ -44,6 +44,7 @@ const StatusIcon = ({ status }: { status: QuizRecord['status'] }) => {
d="M12 3.6l2.63 5.33 5.89.86-4.26 4.15 1.01 5.86L12 17.69 6.73 19.8l1.01-5.86-4.26-4.15 5.89-.86L12 3.6z"
fill="currentColor"
stroke="none"
className="text-yellow-500"
/>
);
}

View File

@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
process.env.NODE_ENV = 'test';
process.env.DB_PATH = ':memory:';
const jsonFetch = async (
baseUrl: string,
path: string,
options?: { method?: string; body?: unknown },
) => {
const res = await fetch(`${baseUrl}${path}`, {
method: options?.method ?? 'GET',
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
body: options?.body ? JSON.stringify(options.body) : undefined,
});
const text = await res.text();
let json: any = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { status: res.status, json, text };
};
test('用户答题记录接口应返回subjectName任务记录可从任务反推科目', async () => {
const { initDatabase, run } = await import('../api/database');
await initDatabase();
const { app } = await import('../api/server');
const server = app.listen(0);
try {
const addr = server.address();
assert.ok(addr && typeof addr === 'object');
const baseUrl = `http://127.0.0.1:${addr.port}`;
const userId = randomUUID();
const subjectId = randomUUID();
const taskId = randomUUID();
const recordId = randomUUID();
await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [
userId,
'测试用户',
'13800138111',
'',
]);
await run(
`INSERT INTO exam_subjects (id, name, type_ratios, category_ratios, total_score, duration_minutes)
VALUES (?, ?, ?, ?, ?, ?)`,
[
subjectId,
'测试科目-任务关联',
JSON.stringify({ single: 100 }),
JSON.stringify({ 通用: 100 }),
100,
60,
],
);
const now = new Date().toISOString();
await run(
`INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at, selection_config)
VALUES (?, ?, ?, ?, ?, ?)`,
[taskId, '测试任务', subjectId, now, now, null],
);
// 模拟旧数据quiz_records.subject_id 为空,仅有 task_id
await run(
`INSERT INTO quiz_records (id, user_id, subject_id, task_id, total_score, correct_count, total_count, created_at, score_percentage, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[recordId, userId, null, taskId, 80, 16, 20, now, 80, '优秀'],
);
const res = await jsonFetch(baseUrl, `/api/quiz/records/${userId}`);
assert.equal(res.status, 200);
assert.equal(res.json?.success, true);
assert.ok(Array.isArray(res.json?.data));
const row = (res.json?.data as any[]).find((r) => r.id === recordId);
assert.ok(row);
assert.equal(row.taskName, '测试任务');
assert.equal(row.subjectName, '测试科目-任务关联');
} finally {
server.close();
}
});