taskId = $taskId; // 组卷任务放到默认队列,与 PDF 队列解耦,降低排队等待 $this->onQueue('default'); $this->afterCommit(); } public function handle( LearningAnalyticsService $learningAnalyticsService, QuestionBankService $questionBankService, TaskManager $taskManager ): void { $task = $taskManager->getTaskStatus($this->taskId); if (!is_array($task) || empty($task['data']) || !is_array($task['data'])) { $taskManager->markTaskFailed($this->taskId, '任务数据不存在'); return; } $data = $task['data']; $assembleStartedAt = microtime(true); try { $taskManager->updateTaskProgress($this->taskId, 5, '开始异步组卷...'); $assembleType = (int) ($data['assemble_type'] ?? 4); $difficultyCategory = $data['difficulty_category'] ?? 1; $paperName = $data['paper_name'] ?? ('智能试卷_'.now()->format('Ymd_His')); $mistakeIds = $data['mistake_ids'] ?? []; $mistakeQuestionIds = $data['mistake_question_ids'] ?? []; $paperIds = $data['paper_ids'] ?? []; $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []); $questions = []; $result = null; $diagnosticChapterId = null; $explanationKpCodes = null; if ($assembleType === 15) { // assemble_type=15(展示类型「错题再练」):paper_ids 为题库 question_id,须在该学生 mistake_records 中存在;与 assemble_type=5(卷 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'], $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; } $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')); } else { $params = [ 'student_id' => $data['student_id'], 'grade' => $data['grade'] ?? null, 'total_questions' => $data['total_questions'], 'kp_codes' => $assembleType === 3 ? null : ($data['kp_codes'] ?? null), 'skills' => $data['skills'] ?? [], 'question_type_ratio' => $questionTypeRatio, 'difficulty_category' => $difficultyCategory, 'assemble_type' => $assembleType, 'exam_type' => $data['exam_type'] ?? 'general', 'paper_ids' => $paperIds, 'textbook_id' => $data['textbook_id'] ?? null, 'end_catalog_id' => $data['end_catalog_id'] ?? null, 'chapter_id_list' => $data['chapter_id_list'] ?? null, 'kp_code_list' => $assembleType === 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []), 'practice_options' => $data['practice_options'] ?? null, 'mistake_options' => $data['mistake_options'] ?? null, ]; $result = $learningAnalyticsService->generateIntelligentExam($params); if (empty($result['success'])) { $taskManager->markTaskFailed($this->taskId, $result['message'] ?? '智能出卷失败'); return; } if (isset($result['stats']['difficulty_category'])) { $difficultyCategory = $result['stats']['difficulty_category']; } $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null; $explanationKpCodes = $result['explanation_kp_codes'] ?? null; $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes'] ?? []); } if (empty($questions)) { $taskManager->markTaskFailed($this->taskId, '未能生成有效题目'); return; } $totalQuestions = min((int) ($data['total_questions'] ?? 10), count($questions)); $questions = array_slice($questions, 0, $totalQuestions); $questions = $this->sortQuestionsWithinTypeByDifficulty($questions); $targetTotalScore = (float) ($data['total_score'] ?? 100.0); $questions = $this->adjustQuestionScores($questions, $targetTotalScore); $totalScore = array_sum(array_column($questions, 'score')); $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType; $paperId = $questionBankService->saveExamToDatabase([ 'paper_id' => $data['paper_id'] ?? null, 'paper_name' => $paperName, 'student_id' => $data['student_id'], 'teacher_id' => $data['teacher_id'] ?? null, 'assembleType' => $finalAssembleType, 'difficulty_category' => $difficultyCategory, 'total_score' => $totalScore, 'questions' => $questions, 'diagnostic_chapter_id' => $diagnosticChapterId, 'explanation_kp_codes' => $explanationKpCodes, ]); if (! $paperId) { $taskManager->markTaskFailed($this->taskId, '试卷保存失败'); return; } $finalStats = $result['stats'] ?? [ 'total_selected' => count($questions), 'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || $assembleType === 15, ]; if (! isset($finalStats['difficulty_category'])) { $finalStats['difficulty_category'] = $difficultyCategory; } $taskManager->updateTaskStatus($this->taskId, [ 'paper_id' => $paperId, 'stats' => $finalStats, 'assemble_elapsed_ms' => (int) round((microtime(true) - $assembleStartedAt) * 1000), ]); $taskManager->updateTaskProgress($this->taskId, 40, '组卷完成,开始生成PDF...'); dispatch(new GenerateExamPdfJob($this->taskId, $paperId)); Log::info('AssembleExamTaskJob: 组卷任务完成并已触发PDF任务', [ 'task_id' => $this->taskId, 'paper_id' => $paperId, ]); } catch (\Exception $e) { Log::error('AssembleExamTaskJob: 异常', [ 'task_id' => $this->taskId, 'error' => $e->getMessage(), ]); $taskManager->markTaskFailed($this->taskId, $e->getMessage()); } } public function failed(Throwable $exception): void { app(TaskManager::class)->markTaskFailed($this->taskId, $exception->getMessage()); } private function normalizeQuestionTypeRatio(array $input): array { $defaults = ['选择题' => 40, '填空题' => 20, '解答题' => 40]; $normalized = []; foreach ($input as $key => $value) { if (! is_numeric($value)) { continue; } $type = $this->normalizeQuestionTypeKey((string) $key); if ($type) { $normalized[$type] = (float) $value; } } $merged = array_merge($defaults, $normalized); $sum = array_sum($merged); if ($sum > 0) { foreach ($merged as $k => $v) { $merged[$k] = round(($v / $sum) * 100, 2); } } return $merged; } private function normalizeQuestionTypeKey(string $key): ?string { $key = trim($key); if (in_array($key, ['choice', '选择题', 'single_choice', 'multiple_choice', 'CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) { return '选择题'; } if (in_array($key, ['fill', '填空题', 'blank', 'FILL_IN_THE_BLANK', 'FILL'], true)) { return '填空题'; } if (in_array($key, ['answer', '解答题', '计算题', 'CALCULATION', 'WORD_PROBLEM', 'PROOF'], true)) { return '解答题'; } return null; } private function resolveMistakeQuestionIds(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array { $questionIds = []; if (! empty($mistakeQuestionIds)) { $questionIds = array_merge($questionIds, $mistakeQuestionIds); } if (! empty($mistakeIds)) { $fromDb = MistakeRecord::query()->where('student_id', $studentId)->whereIn('id', $mistakeIds)->pluck('question_id')->filter()->values()->all(); $questionIds = array_merge($questionIds, $fromDb); } return array_values(array_unique(array_filter($questionIds))); } /** * 追练(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} */ 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]; } /** * assemble_type=15 时 paper_ids 承载题库题目 id:纯数字字符串转为 int,去重并保持首次出现顺序。 * * @return array */ 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 = []; foreach ($questions as $question) { $type = $this->normalizeQuestionTypeKey($question['question_type'] ?? $question['type'] ?? '') ?? $this->guessType($question); $score = $question['score'] ?? $this->defaultScore($type); $normalized[] = [ 'id' => $question['id'] ?? $question['question_id'] ?? null, 'question_id' => $question['question_id'] ?? null, 'question_type' => $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer'), 'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''), 'content' => $question['content'] ?? $question['stem'] ?? '', 'options' => $question['options'] ?? ($question['choices'] ?? []), 'answer' => $question['answer'] ?? $question['correct_answer'] ?? '', 'solution' => $question['solution'] ?? '', 'difficulty' => isset($question['difficulty']) ? (float) $question['difficulty'] : 0.5, 'score' => $score, 'estimated_time' => $question['estimated_time'] ?? 300, 'kp' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''), 'kp_code' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''), ]; } return array_values(array_filter($normalized, fn ($q) => ! empty($q['id']))); } private function sortQuestionsByRequestedIds(array $questions, array $requestedIds): array { if (empty($requestedIds)) { return $questions; } $order = array_flip($requestedIds); usort($questions, function ($a, $b) use ($order) { $aPos = $order[(string) ($a['id'] ?? '')] ?? PHP_INT_MAX; $bPos = $order[(string) ($b['id'] ?? '')] ?? PHP_INT_MAX; return $aPos <=> $bPos; }); return $questions; } private function guessType(array $question): string { if (! empty($question['options']) && is_array($question['options'])) { return '选择题'; } $content = $question['stem'] ?? $question['content'] ?? ''; if (is_string($content) && (strpos($content, '____') !== false || strpos($content, '()') !== false)) { return '填空题'; } return '解答题'; } private function defaultScore(string $type): int { return match ($type) { '选择题' => 5, '填空题' => 5, '解答题' => 10, default => 5, }; } private function sortQuestionsWithinTypeByDifficulty(array $questions): array { $grouped = ['choice' => [], 'fill' => [], 'answer' => []]; foreach ($questions as $question) { $type = $this->normalizeQuestionType((string) ($question['question_type'] ?? 'answer')); $grouped[$type][] = $question; } $sortFn = function (array $a, array $b): int { $ad = (float) ($a['difficulty'] ?? 0.5); $bd = (float) ($b['difficulty'] ?? 0.5); if ($ad !== $bd) { return $ad <=> $bd; } return ((int) ($a['id'] ?? $a['question_id'] ?? 0)) <=> ((int) ($b['id'] ?? $b['question_id'] ?? 0)); }; usort($grouped['choice'], $sortFn); usort($grouped['fill'], $sortFn); usort($grouped['answer'], $sortFn); $sorted = array_merge($grouped['choice'], $grouped['fill'], $grouped['answer']); foreach ($sorted as $idx => &$question) { $question['question_number'] = $idx + 1; } unset($question); return $sorted; } private function normalizeQuestionType(string $type): string { $type = strtolower(trim($type)); if (in_array($type, ['choice', 'single_choice', 'multiple_choice', '选择题', '单选', '多选'], true)) { return 'choice'; } if (in_array($type, ['fill', 'fill_in_the_blank', 'blank', '填空题', '填空'], true)) { return 'fill'; } return 'answer'; } private function adjustQuestionScores(array $questions, float $targetTotalScore = 100.0): array { if (empty($questions)) { return $questions; } // 第一步:按题型排序 $sortedQuestions = []; $choiceQuestions = []; $fillQuestions = []; $answerQuestions = []; foreach ($questions as $question) { $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer'); if ($type === 'choice') { $choiceQuestions[] = $question; } elseif ($type === 'fill') { $fillQuestions[] = $question; } else { $answerQuestions[] = $question; } } $sortedQuestions = array_merge($choiceQuestions, $fillQuestions, $answerQuestions); Log::debug('adjustQuestionScores 开始', [ 'choice_count' => count($choiceQuestions), 'fill_count' => count($fillQuestions), 'answer_count' => count($answerQuestions), ]); foreach ($sortedQuestions as $idx => &$question) { $question['question_number'] = $idx + 1; } unset($question); $typeCounts = [ 'choice' => count($choiceQuestions), 'fill' => count($fillQuestions), 'answer' => count($answerQuestions), ]; $typeIndexes = ['choice' => [], 'fill' => [], 'answer' => []]; foreach ($sortedQuestions as $index => $question) { $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer'); $typeIndexes[$type][] = $index; } $questionScores = []; $totalQuestions = $typeCounts['choice'] + $typeCounts['fill'] + $typeCounts['answer']; $globalBaseScore = floor($targetTotalScore / $totalQuestions); $globalBaseScore = max(1, $globalBaseScore); $typeOrder = []; foreach ($sortedQuestions as $question) { $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer'); if (! in_array($type, $typeOrder)) { $typeOrder[] = $type; } } $remainingBudget = $targetTotalScore; foreach ($typeOrder as $typeIndex => $type) { $count = $typeCounts[$type]; if ($count === 0) { continue; } if ($typeIndex === 0) { $thisBase = $globalBaseScore; foreach ($typeIndexes[$type] as $idx) { $questionScores[$idx] = $thisBase; } foreach ($typeIndexes[$type] as $idx) { $questionScores[$idx] = max(1, $questionScores[$idx] - 1); } $allocated = 0; foreach ($typeIndexes[$type] as $idx) { $allocated += $questionScores[$idx]; } $remainingBudget -= $allocated; } elseif ($typeIndex === count($typeOrder) - 1) { $thisBase = floor($remainingBudget / $count); $thisBase = max(1, $thisBase); foreach ($typeIndexes[$type] as $idx) { $questionScores[$idx] = $thisBase; } $total = $thisBase * $count; $remainder = $remainingBudget - $total; if ($remainder > 0) { $answerIndexes = array_values($typeIndexes[$type]); $startIdx = max(0, count($answerIndexes) - $remainder); for ($i = $startIdx; $i < count($answerIndexes); $i++) { $questionScores[$answerIndexes[$i]] += 1; } } } else { $thisBase = $globalBaseScore; foreach ($typeIndexes[$type] as $idx) { $questionScores[$idx] = $thisBase; } $allocated = 0; foreach ($typeIndexes[$type] as $idx) { $allocated += $questionScores[$idx]; } $remainingBudget -= $allocated; } } if (count($typeOrder) > 1) { $lastType = end($typeOrder); $otherTypes = array_slice($typeOrder, 0, -1); $maxOtherScore = 0; foreach ($otherTypes as $type) { foreach ($typeIndexes[$type] as $idx) { $maxOtherScore = max($maxOtherScore, $questionScores[$idx]); } } $minLastScore = PHP_INT_MAX; foreach ($typeIndexes[$lastType] as $idx) { $minLastScore = min($minLastScore, $questionScores[$idx]); } if ($minLastScore <= $maxOtherScore) { $diff = $maxOtherScore - $minLastScore + 1; $reductionPerQuestion = min($diff, 2); foreach ($otherTypes as $type) { foreach ($typeIndexes[$type] as $idx) { $questionScores[$idx] = max(1, $questionScores[$idx] - $reductionPerQuestion); } } $reallocated = $targetTotalScore; foreach ($typeIndexes[$lastType] as $idx) { $reallocated -= $questionScores[$idx]; } foreach ($otherTypes as $type) { foreach ($typeIndexes[$type] as $idx) { $reallocated -= $questionScores[$idx]; } } if ($reallocated > 0) { $newBase = floor($reallocated / $typeCounts[$lastType]); foreach ($typeIndexes[$lastType] as $idx) { $questionScores[$idx] = $newBase; } $total = $newBase * $typeCounts[$lastType]; $remainder = $reallocated - $total; if ($remainder > 0) { $lastIndexes = array_values($typeIndexes[$lastType]); $startIdx = max(0, count($lastIndexes) - $remainder); for ($i = $startIdx; $i < count($lastIndexes); $i++) { $questionScores[$lastIndexes[$i]] += 1; } } } } } $adjustedQuestions = []; foreach ($sortedQuestions as $index => $question) { $adjustedQuestions[$index] = $question; $adjustedQuestions[$index]['score'] = $questionScores[$index] ?? 5; } $total = array_sum(array_column($adjustedQuestions, 'score')); $diff = (int) $targetTotalScore - (int) $total; if ($diff !== 0 && ! empty($adjustedQuestions)) { $count = count($adjustedQuestions); $i = $count - 1; while ($diff !== 0) { $score = $adjustedQuestions[$i]['score']; if ($diff > 0) { $adjustedQuestions[$i]['score'] = $score + 1; $diff--; } else { if ($score > 1) { $adjustedQuestions[$i]['score'] = $score - 1; $diff++; } } $i--; if ($i < 0) { $i = $count - 1; if ($diff < 0) { $minScore = min(array_column($adjustedQuestions, 'score')); if ($minScore <= 1) { break; } } } } } return $adjustedQuestions; } }