Pārlūkot izejas kodu

feat(exam): 追练 assemble_type=5 错题严格校验 + 组卷API入参日志

Made-with: Cursor
yemeishu 1 mēnesi atpakaļ
vecāks
revīzija
e6ca0dbdfd

+ 34 - 6
app/Http/Controllers/Api/IntelligentExamController.php

@@ -125,6 +125,11 @@ class IntelligentExamController extends Controller
         ]);
 
         if ($validator->fails()) {
+            Log::warning('IntelligentExamController: 组卷API参数校验失败', [
+                'trace_id' => $requestTraceId,
+                'normalized' => $normalized,
+                'errors' => $validator->errors()->toArray(),
+            ]);
             return response()->json([
                 'success' => false,
                 'message' => '参数错误',
@@ -133,6 +138,21 @@ class IntelligentExamController extends Controller
         }
 
         $data = $validator->validated();
+
+        // 追练:未显式传 assemble_type 时,exam_type 为 mistake/practice 且带 paper_ids(且无直指定错题)视为 assemble_type=5,
+        // 避免中间层/旧客户端只传 exam_type 时仍走通用组卷(4)。
+        if (
+            ! isset($data['assemble_type'])
+            && ! empty($data['paper_ids'])
+            && empty($data['mistake_ids'] ?? [])
+            && empty($data['mistake_question_ids'] ?? [])
+        ) {
+            $examType = $data['exam_type'] ?? 'general';
+            if (in_array($examType, ['mistake', 'practice'], true)) {
+                $data['assemble_type'] = 5;
+            }
+        }
+
         $assembleType = (int) ($data['assemble_type'] ?? 4);
         // API 固定题量:含追练(assemble_type=5),一律 default_total_questions,不使用请求题量参数
         $data['total_questions'] = (int) config('question_bank.default_total_questions');
@@ -152,13 +172,21 @@ class IntelligentExamController extends Controller
             $data['kp_codes'] = [];
         }
 
+        $taskPayload = array_merge($data, [
+            'paper_id' => $reservedPaperId,
+            'request_trace_id' => $requestTraceId,
+            'request_started_at' => now()->toISOString(),
+        ]);
+
+        Log::info('IntelligentExamController: 组卷API请求参数(校验并补全后,即将入队)', [
+            'trace_id' => $requestTraceId,
+            'assemble_type_resolved' => $assembleType,
+            'params' => $taskPayload,
+        ]);
+
         try {
             // 异步优化:同步仅返回 task_id/paper_id,重型组卷逻辑下沉到队列
-            $taskId = $this->taskManager->createTask(TaskManager::TASK_TYPE_EXAM, array_merge($data, [
-                'paper_id' => $reservedPaperId,
-                'request_trace_id' => $requestTraceId,
-                'request_started_at' => now()->toISOString(),
-            ]));
+            $taskId = $this->taskManager->createTask(TaskManager::TASK_TYPE_EXAM, $taskPayload);
 
             dispatch(new AssembleExamTaskJob($taskId));
 
@@ -383,7 +411,7 @@ class IntelligentExamController extends Controller
             $payload['skills'] = array_values(array_filter(array_map('trim', explode(',', $payload['skills']))));
         }
 
-        foreach (['mistake_ids', 'mistake_question_ids'] as $key) {
+        foreach (['mistake_ids', 'mistake_question_ids', 'paper_ids'] as $key) {
             if (isset($payload[$key])) {
                 if (is_string($payload[$key])) {
                     $raw = trim($payload[$key]);

+ 77 - 1
app/Jobs/AssembleExamTaskJob.php

@@ -63,7 +63,23 @@ class AssembleExamTaskJob implements ShouldQueue
             $explanationKpCodes = null;
 
             if (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
-                $questionIds = $this->resolveMistakeQuestionIds((string) $data['student_id'], $mistakeIds, $mistakeQuestionIds);
+                // assemble_type=5(按卷追练):显式传错题时必须校验 mistake_records 归属该学生,再组卷;
+                // 其它 assemble_type 保持原样(仅过滤能得到则拉题,不做全量校验)。
+                if ($assembleType === 5) {
+                    $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
+                        (string) $data['student_id'],
+                        $mistakeIds,
+                        $mistakeQuestionIds
+                    );
+                    if (! ($strict['ok'] ?? false)) {
+                        $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
+                        return;
+                    }
+                    $questionIds = $strict['question_ids'];
+                } else {
+                    $questionIds = $this->resolveMistakeQuestionIds((string) $data['student_id'], $mistakeIds, $mistakeQuestionIds);
+                }
+
                 if (empty($questionIds)) {
                     $taskManager->markTaskFailed($this->taskId, '未找到可用的错题题目');
                     return;
@@ -228,6 +244,66 @@ class AssembleExamTaskJob implements ShouldQueue
         return array_values(array_unique(array_filter($questionIds)));
     }
 
+    /**
+     * 追练(assemble_type=5)+ 指定错题:mistake_ids 须逐条命中该学生的 mistake_records;
+     * mistake_question_ids 须在该学生错题本中至少有一条记录。顺序:先按 mistake_ids 请求顺序,再追加题号列表(去重)。
+     *
+     * @return array{ok: bool, message?: string, question_ids?: array<int, string>}
+     */
+    private function resolveMistakeQuestionIdsStrictForStudent(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array
+    {
+        $mistakeIds = array_values(array_filter(array_map('strval', $mistakeIds), fn ($v) => $v !== ''));
+        $mistakeQuestionIds = array_values(array_filter(array_map('strval', $mistakeQuestionIds), fn ($v) => $v !== ''));
+
+        $orderedQuestionIds = [];
+        $seen = [];
+
+        if ($mistakeIds !== []) {
+            $rowIdSet = array_values(array_unique($mistakeIds));
+            $records = MistakeRecord::query()
+                ->where('student_id', $studentId)
+                ->whereIn('id', $rowIdSet)
+                ->get()
+                ->keyBy(fn ($r) => (string) $r->id);
+
+            foreach ($mistakeIds as $mid) {
+                $rec = $records[$mid] ?? null;
+                $qid = $rec && $rec->question_id !== null && $rec->question_id !== ''
+                    ? (string) $rec->question_id
+                    : '';
+                if ($qid === '') {
+                    return [
+                        'ok' => false,
+                        'message' => '部分错题记录不存在或不属于该学生: '.$mid,
+                    ];
+                }
+                if (! isset($seen[$qid])) {
+                    $seen[$qid] = true;
+                    $orderedQuestionIds[] = $qid;
+                }
+            }
+        }
+
+        foreach ($mistakeQuestionIds as $qid) {
+            $exists = MistakeRecord::query()
+                ->where('student_id', $studentId)
+                ->where('question_id', $qid)
+                ->exists();
+            if (! $exists) {
+                return [
+                    'ok' => false,
+                    'message' => '学生错题本中不存在题目: '.$qid,
+                ];
+            }
+            if (! isset($seen[$qid])) {
+                $seen[$qid] = true;
+                $orderedQuestionIds[] = $qid;
+            }
+        }
+
+        return ['ok' => true, 'question_ids' => $orderedQuestionIds];
+    }
+
     private function hydrateQuestions(array $questions, array $kpCodes): array
     {
         $normalized = [];