Преглед на файлове

feat(exam): assemble_type=15 错题本组卷(paper_ids为题库题目id) 与追练分离

Made-with: Cursor
yemeishu преди 1 месец
родител
ревизия
f7d84c239e
променени са 4 файла, в които са добавени 101 реда и са изтрити 21 реда
  1. 25 15
      app/Http/Controllers/Api/IntelligentExamController.php
  2. 73 4
      app/Jobs/AssembleExamTaskJob.php
  3. 2 2
      app/Services/ExamTypeStrategy.php
  4. 1 0
      app/Support/PaperNaming.php

+ 25 - 15
app/Http/Controllers/Api/IntelligentExamController.php

@@ -117,7 +117,7 @@ class IntelligentExamController extends Controller
             'mistake_question_ids.*' => 'string',
             'callback_url' => 'nullable|url',  // 异步完成后推送通知的URL
             // 新增:组卷类型
-            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9',
+            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9,15',
             'exam_type' => 'nullable|string|in:general,diagnostic,practice,mistake,textbook,knowledge,knowledge_points',
             // 错题本类型专用参数
             'paper_ids' => 'nullable|array',
@@ -166,22 +166,16 @@ 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);
+        if ($assembleType === 15 && empty($data['paper_ids'] ?? [])) {
+            return response()->json([
+                'success' => false,
+                'message' => '参数错误',
+                'errors' => ['paper_ids' => ['assemble_type 为 15(错题本组卷)时,paper_ids 须为非空数组,元素为题库题目 question_id,且该学生错题本中须存在对应错题记录']], 
+            ], 422);
         }
 
-        $assembleType = (int) ($data['assemble_type'] ?? 4);
-        // API 固定题量:含追练(assemble_type=5),一律 default_total_questions,不使用请求题量参数
+        // API 固定题量:含追练(5)、错题本组卷(15) 等,一律 default_total_questions,不使用请求题量参数
         $data['total_questions'] = (int) config('question_bank.default_total_questions');
         // 预分配 paper_id,保证接口语义稳定(后续异步化时也可继续同步返回)
         $reservedPaperId = $this->questionBankService->generatePaperId();
@@ -448,6 +442,22 @@ class IntelligentExamController extends Controller
                 } elseif (! is_array($payload[$key])) {
                     $payload[$key] = [];
                 }
+                // JSON 常把 id 写成数字,与 validator 的 string 规则对齐,避免 422
+                if (is_array($payload[$key])) {
+                    $payload[$key] = array_values(array_filter(array_map(
+                        static function ($v) {
+                            if ($v === null || $v === '') {
+                                return null;
+                            }
+                            if (is_scalar($v)) {
+                                return (string) $v;
+                            }
+
+                            return null;
+                        },
+                        $payload[$key]
+                    ), static fn ($v) => $v !== null && $v !== ''));
+                }
             }
         }
 

+ 73 - 4
app/Jobs/AssembleExamTaskJob.php

@@ -62,9 +62,36 @@ class AssembleExamTaskJob implements ShouldQueue
             $diagnosticChapterId = null;
             $explanationKpCodes = null;
 
-            if (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
-                // assemble_type=5(按卷追练):显式传错题时必须校验 mistake_records 归属该学生,再组卷;
-                // 其它 assemble_type 保持原样(仅过滤能得到则拉题,不做全量校验)。
+            if ($assembleType === 15) {
+                // 错题本组卷:paper_ids 传题库题目 question_id(须在该学生错题本 mistake_records 中存在),与 assemble_type=5 按卷追练(真实试卷 paper_id)分离
+                $questionIdList = $this->normalizeBankQuestionIdsList($paperIds);
+                if ($questionIdList === []) {
+                    $taskManager->markTaskFailed($this->taskId, '错题本组卷需提供 paper_ids(题库题目 id)');
+                    return;
+                }
+
+                $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
+                    (string) $data['student_id'],
+                    [],
+                    array_map(static fn ($id) => (string) $id, $questionIdList)
+                );
+                if (! ($strict['ok'] ?? false)) {
+                    $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
+                    return;
+                }
+                $questionIds = $strict['question_ids'];
+
+                $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
+                if (empty($bankQuestions)) {
+                    $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
+                    return;
+                }
+
+                $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
+                $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
+                $paperName = $data['paper_name'] ?? ('错题本组卷_'.$data['student_id'].'_'.now()->format('Ymd_His'));
+            } elseif (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
+                // assemble_type=5 时 mistake_ids / mistake_question_ids 须严格归属该学生;其它类型走宽松解析。
                 if ($assembleType === 5) {
                     $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
                         (string) $data['student_id'],
@@ -161,7 +188,7 @@ class AssembleExamTaskJob implements ShouldQueue
 
             $finalStats = $result['stats'] ?? [
                 'total_selected' => count($questions),
-                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds),
+                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || $assembleType === 15,
             ];
             if (! isset($finalStats['difficulty_category'])) {
                 $finalStats['difficulty_category'] = $difficultyCategory;
@@ -247,6 +274,7 @@ class AssembleExamTaskJob implements ShouldQueue
     /**
      * 追练(assemble_type=5)+ 指定错题:mistake_ids 须逐条命中该学生的 mistake_records;
      * mistake_question_ids 须在该学生错题本中至少有一条记录。顺序:先按 mistake_ids 请求顺序,再追加题号列表(去重)。
+     * 错题本组卷(assemble_type=15)将 paper_ids 解析为题库题目 id 后,仅使用本方法的 mistake_question_ids 分支做校验。
      *
      * @return array{ok: bool, message?: string, question_ids?: array<int, string>}
      */
@@ -304,6 +332,47 @@ class AssembleExamTaskJob implements ShouldQueue
         return ['ok' => true, 'question_ids' => $orderedQuestionIds];
     }
 
+    /**
+     * assemble_type=15 时 paper_ids 承载题库题目 id:纯数字字符串转为 int,去重并保持首次出现顺序。
+     *
+     * @return array<int, int|string>
+     */
+    private function normalizeBankQuestionIdsList(array $raw): array
+    {
+        $out = [];
+        $seen = [];
+        foreach ($raw as $v) {
+            if ($v === null) {
+                continue;
+            }
+            if (is_string($v)) {
+                $v = trim($v);
+                if ($v === '') {
+                    continue;
+                }
+            }
+            if (is_int($v)) {
+                $normalized = $v;
+            } elseif (is_float($v) && floor($v) == $v) {
+                $normalized = (int) $v;
+            } else {
+                $s = trim((string) $v);
+                if ($s === '') {
+                    continue;
+                }
+                $normalized = preg_match('/^-?\d+$/', $s) ? (int) $s : $s;
+            }
+            $dedupeKey = is_int($normalized) ? 'i:'.$normalized : 's:'.(string) $normalized;
+            if (isset($seen[$dedupeKey])) {
+                continue;
+            }
+            $seen[$dedupeKey] = true;
+            $out[] = $normalized;
+        }
+
+        return $out;
+    }
+
     private function hydrateQuestions(array $questions, array $kpCodes): array
     {
         $normalized = [];

+ 2 - 2
app/Services/ExamTypeStrategy.php

@@ -35,12 +35,12 @@ class ExamTypeStrategy
 
     /**
      * 根据组卷类型构建参数
-     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-错题本, 8-智能组卷(新), 9-原摸底
+     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-按卷追练(paper_ids=试卷), 8-智能组卷(新), 9-原摸底, 15-错题本组卷(paper_ids=题库题目id,由队列单独处理)
      *
      * 映射规则(前端不改,后端动态处理):
      * - 0, 9(摸底)→ 章节摸底(新逻辑)
      * - 1, 8(智能组卷)→ 按知识点顺序学习(新逻辑)
-     * - 2, 3, 4, 5 → 保持原有逻辑不变
+     * - 2, 3, 4, 5 → 保持原有逻辑不变(5 的 paper_ids 为试卷 ID;15 不入此策略,由 AssembleExamTaskJob 直组)
      */
     public function buildParams(array $baseParams, int $assembleType): array
     {

+ 1 - 0
app/Support/PaperNaming.php

@@ -15,6 +15,7 @@ class PaperNaming
             2 => '知识点组题',
             3 => '教材组题',
             5 => '智能追练',
+            15 => '错题本组卷',
             default => throw new InvalidArgumentException("不支持的 assemble_type: {$assembleType}"),
         };
     }