learningAnalyticsService = $learningAnalyticsService; $this->questionBankService = $questionBankService; $this->pdfExportService = $pdfExportService; $this->paperPayloadService = $paperPayloadService; $this->taskManager = $taskManager; $this->externalIdService = $externalIdService; } /** * 外部API:生成智能试卷(异步模式) * 立即返回任务ID,PDF生成在后台进行,完成后通过回调通知 */ public function store(Request $request): JsonResponse { $requestStartedAt = microtime(true); $requestTraceId = 'exam_req_' . substr(md5(uniqid('', true)), 0, 10); Log::info('IntelligentExamController: request started', [ 'trace_id' => $requestTraceId, 'path' => $request->path(), 'method' => $request->method(), ]); // 优先从body获取数据,不使用query params(入口仍记录 query,便于对照网关是否拼错) $jsonPayload = $request->json()->all(); $bodySource = 'json'; $payload = $jsonPayload; if (empty($payload)) { $payload = $request->all(); $bodySource = 'request_all'; } $queryParams = $request->query(); $rawBody = $request->getContent(); $rawBodyLen = strlen($rawBody); $rawBodyLog = null; if ($rawBodyLen > 0) { $rawCap = 65536; if ($rawBodyLen <= $rawCap) { $rawBodyLog = $rawBody; } else { $rawBodyLog = substr($rawBody, 0, $rawCap); } } Log::info('IntelligentExamController: 组卷API原始请求参数(入口,未经 normalizePayload)', [ 'trace_id' => $requestTraceId, 'content_type' => $request->header('Content-Type'), 'body_source' => $bodySource, 'body_payload' => $payload, 'query_params' => $queryParams, 'raw_body_length' => $rawBodyLen, 'raw_body' => $rawBodyLog, 'raw_body_truncated' => $rawBodyLen > 65536, ]); $normalized = $this->normalizePayload($payload); $validator = validator($normalized, [ 'student_id' => 'required|string|min:1|regex:/^\\d+$/', // 接受字符串或数字类型,如"1764913638"或1764913638 'teacher_id' => 'required|string|min:1|regex:/^\\d+$/', 'paper_name' => 'nullable|string|max:255', 'grade' => 'required|integer|min:1|max:12', // 支持小学1-6、初中7-9、高中10-12 'student_name' => 'required|string|max:50', 'teacher_name' => 'required|string|max:50', 'difficulty_category' => 'nullable|integer|in:0,1,2,3,4', 'kp_codes' => 'nullable|array', 'kp_codes.*' => 'string', 'skills' => 'nullable|array', 'skills.*' => 'string', 'question_type_ratio' => 'nullable|array', // 'difficulty_ratio' 参数已废弃,使用 difficulty_category 控制难度分布 'total_score' => 'nullable|numeric|min:1|max:1000', 'mistake_ids' => 'nullable|array', 'mistake_ids.*' => 'string', 'mistake_question_ids' => 'nullable|array', 'mistake_question_ids.*' => 'string', 'callback_url' => 'nullable|url', // 异步完成后推送通知的URL // 新增:组卷类型 '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', 'paper_ids.*' => 'string', // 修改:使用series_id + semester_code + grade替代textbook_id 'series_id' => 'nullable|integer|min:1', // 教材系列ID(替代textbook_id) 'semester_code' => 'nullable|integer|in:1,2', // 上下册:1=上册,2=下册 // 新增:各组卷类型的专用参数 'chapter_id_list' => 'nullable|array', // 教材组卷专用 'chapter_id_list.*' => 'integer|min:1', 'kp_code_list' => 'nullable|array', // 知识点组卷专用 'kp_code_list.*' => 'string', 'end_catalog_id' => 'nullable|integer|min:1', // 摸底专用:截止章节ID // 新增:专项练习选项 'practice_options' => 'nullable|array', 'practice_options.weakness_threshold' => 'nullable|numeric|min:0|max:1', 'practice_options.intensity' => 'nullable|string|in:low,medium,high', 'practice_options.include_new_questions' => 'nullable|boolean', 'practice_options.focus_weaknesses' => 'nullable|boolean', // 新增:错题选项 'mistake_options' => 'nullable|array', 'mistake_options.weakness_threshold' => 'nullable|numeric|min:0|max:1', 'mistake_options.review_mistakes' => 'nullable|boolean', 'mistake_options.intensity' => 'nullable|string|in:low,medium,high', 'mistake_options.include_new_questions' => 'nullable|boolean', 'mistake_options.focus_weaknesses' => 'nullable|boolean', // 新增:按知识点组卷选项 'knowledge_points_options' => 'nullable|array', 'knowledge_points_options.weakness_threshold' => 'nullable|numeric|min:0|max:1', 'knowledge_points_options.intensity' => 'nullable|string|in:low,medium,high', 'knowledge_points_options.focus_weaknesses' => 'nullable|boolean', ]); if ($validator->fails()) { Log::warning('IntelligentExamController: 组卷API参数校验失败', [ 'trace_id' => $requestTraceId, 'normalized' => $normalized, 'errors' => $validator->errors()->toArray(), ]); return response()->json([ 'success' => false, 'message' => '参数错误', 'errors' => $validator->errors()->toArray(), ], 422); } $data = $validator->validated(); $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); } // API 固定题量:含按卷追练(5)、错题再练(15) 等,一律 default_total_questions,不使用请求题量参数 $data['total_questions'] = (int) config('question_bank.default_total_questions'); // 预分配 paper_id,保证接口语义稳定(后续异步化时也可继续同步返回) $reservedPaperId = $this->questionBankService->generatePaperId(); $this->ensureStudentTeacherRelation($data); // 【修改】使用series_id、semester_code和grade获取textbook_id $textbookId = $this->resolveTextbookId($data); if ($textbookId) { $data['textbook_id'] = $textbookId; } // 确保 kp_codes 是数组 $data['kp_codes'] = $data['kp_codes'] ?? []; if (! is_array($data['kp_codes'])) { $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, $taskPayload); dispatch(new AssembleExamTaskJob($taskId)); $codes = $this->paperPayloadService->generatePaperCodes($reservedPaperId); $payload = [ 'success' => true, 'message' => '智能试卷任务已创建,正在后台组卷并生成PDF...', 'data' => [ 'task_id' => $taskId, 'paper_id' => $reservedPaperId, 'status' => 'processing', 'exam_code' => $codes['exam_code'], 'grading_code' => $codes['grading_code'], 'paper_id_num' => $codes['paper_id_num'], 'exam_content' => [], 'urls' => [ 'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $reservedPaperId]), 'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $reservedPaperId, 'answer' => 'false']), 'knowledge_explanation_url' => route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $reservedPaperId]), ], 'pdfs' => [ 'exam_paper_pdf' => null, 'grading_pdf' => null, 'all_pdf' => null, ], 'stats' => null, 'created_at' => now()->toISOString(), ], ]; Log::info('IntelligentExamController: async task dispatched', [ 'trace_id' => $requestTraceId, 'task_id' => $taskId, 'paper_id' => $reservedPaperId, 'sync_elapsed_ms_total' => (int) round((microtime(true) - $requestStartedAt) * 1000), ]); $this->taskManager->updateTaskStatus($taskId, [ 'request_trace_id' => $requestTraceId, 'sync_elapsed_ms_total' => (int) round((microtime(true) - $requestStartedAt) * 1000), ]); return response()->json($payload, 200, [], JSON_UNESCAPED_SLASHES); } catch (\Exception $e) { Log::error('Intelligent exam API failed', [ 'trace_id' => $requestTraceId, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), 'sync_elapsed_ms_before_error' => (int) round((microtime(true) - $requestStartedAt) * 1000), ]); // 返回更具体的错误信息 $errorMessage = $e->getMessage(); if (strpos($errorMessage, 'Connection') !== false || strpos($errorMessage, 'connection') !== false) { $errorMessage = '依赖服务连接失败,请检查服务状态'; } elseif (strpos($errorMessage, 'timeout') !== false || strpos($errorMessage, '超时') !== false) { $errorMessage = '服务响应超时,请稍后重试'; } elseif (strpos($errorMessage, 'not found') !== false || strpos($errorMessage, '未找到') !== false) { $errorMessage = '请求的资源不存在'; } elseif (strpos($errorMessage, 'invalid') !== false || strpos($errorMessage, '无效') !== false) { $errorMessage = '请求参数无效'; } return response()->json([ 'success' => false, 'message' => $errorMessage ?: '服务异常,请稍后重试', ], 500); } } /** * 轮询任务状态 */ public function status(string $taskId): JsonResponse { try { $task = $this->taskManager->getTaskStatus($taskId); if (! $task) { return response()->json([ 'success' => false, 'message' => '任务不存在', ], 404); } return response()->json([ 'success' => true, 'data' => $task, ]); } catch (\Exception $e) { Log::error('查询任务状态失败', [ 'task_id' => $taskId, 'error' => $e->getMessage(), ]); return response()->json([ 'success' => false, 'message' => '查询失败,请稍后重试', ], 500); } } /** * 触发PDF生成 * 使用队列进行异步处理 */ private function triggerPdfGeneration(string $taskId, string $paperId): void { // 异步处理PDF生成 - 将任务放入队列 try { dispatch(new \App\Jobs\GenerateExamPdfJob($taskId, $paperId)); Log::info('PDF生成任务已加入队列', [ 'task_id' => $taskId, 'paper_id' => $paperId, 'queue_connection' => config('queue.default'), 'queue_name' => 'pdf', ]); } catch (\Exception $e) { Log::error('PDF生成任务队列失败,不回退到同步处理', [ 'task_id' => $taskId, 'paper_id' => $paperId, 'error' => $e->getMessage(), 'note' => '依赖队列重试机制,不进行同步处理以避免并发冲突', ]); $this->taskManager->markTaskFailed($taskId, 'PDF任务入队失败: ' . $e->getMessage()); } } /** * 处理PDF生成(模拟后台任务) * 在实际项目中,这个方法应该在队列worker中执行 */ private function processPdfGeneration(string $taskId, string $paperId): void { try { $this->taskManager->updateTaskProgress($taskId, 10, '开始生成试卷PDF...'); // 生成试卷PDF $pdfUrl = $this->pdfExportService->generateExamPdf($paperId) ?? $this->questionBankService->exportExamToPdf($paperId) ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']); $this->taskManager->updateTaskProgress($taskId, 50, '试卷PDF生成完成,开始生成判卷PDF...'); // 生成判卷PDF $gradingPdfUrl = $this->pdfExportService->generateGradingPdf($paperId) ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'true']); // 构建完整的试卷内容 $paperModel = Paper::with('questions')->find($paperId); $examContent = $paperModel ? $this->paperPayloadService->buildExamContent($paperModel) : []; // 标记任务完成 $this->taskManager->markTaskCompleted($taskId, [ 'exam_content' => $examContent, 'pdfs' => [ 'exam_paper_pdf' => $pdfUrl, 'grading_pdf' => $gradingPdfUrl, ], ]); Log::info('异步任务完成', [ 'task_id' => $taskId, 'paper_id' => $paperId, 'pdf_url' => $pdfUrl, 'grading_pdf_url' => $gradingPdfUrl, ]); // 发送回调通知 $this->taskManager->sendCallback($taskId); } catch (\Exception $e) { Log::error('PDF生成失败', [ 'task_id' => $taskId, 'paper_id' => $paperId, 'error' => $e->getMessage(), ]); $this->taskManager->markTaskFailed($taskId, $e->getMessage()); } } /** * 兼容字符串/数组入参 */ private function normalizePayload(array $payload): array { unset($payload['total_questions'], $payload['question_count']); // 将student_id转换为字符串(支持数字和字符串输入) if (isset($payload['student_id'])) { $payload['student_id'] = (string) $payload['student_id']; } if (isset($payload['teacher_id'])) { $payload['teacher_id'] = (string) $payload['teacher_id']; } if (isset($payload['grade'])) { $payload['grade'] = (string) $payload['grade']; } // 处理 kp_codes:空字符串或null转换为空数组 if (isset($payload['kp_codes'])) { if (is_string($payload['kp_codes'])) { $kpCodes = trim($payload['kp_codes']); if (empty($kpCodes)) { $payload['kp_codes'] = []; } else { $payload['kp_codes'] = array_values(array_filter(array_map('trim', explode(',', $kpCodes)))); } } elseif (! is_array($payload['kp_codes'])) { $payload['kp_codes'] = []; } } else { $payload['kp_codes'] = []; } if (isset($payload['skills']) && is_string($payload['skills'])) { $payload['skills'] = array_values(array_filter(array_map('trim', explode(',', $payload['skills'])))); } foreach (['mistake_ids', 'mistake_question_ids', 'paper_ids'] as $key) { if (isset($payload[$key])) { if (is_string($payload[$key])) { $raw = trim($payload[$key]); $payload[$key] = $raw === '' ? [] : array_values(array_filter(array_map('trim', explode(',', $raw)))); } 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 !== '')); } } } // 新增:处理组卷专用参数 foreach (['chapter_id_list', 'kp_code_list'] as $key) { if (isset($payload[$key])) { if (is_string($payload[$key])) { $raw = trim($payload[$key]); $payload[$key] = $raw === '' ? [] : array_values(array_filter(array_map('trim', explode(',', $raw)))); } elseif (! is_array($payload[$key])) { $payload[$key] = []; } } else { $payload[$key] = []; } } // 【修改】处理series_id:字符串转换为整数 if (isset($payload['series_id'])) { if (is_string($payload['series_id'])) { $payload['series_id'] = (int) trim($payload['series_id']); if ($payload['series_id'] <= 0) { unset($payload['series_id']); } } elseif (! is_int($payload['series_id']) || $payload['series_id'] <= 0) { unset($payload['series_id']); } } // 【新增】处理semester_code:确保是1或2 if (isset($payload['semester_code'])) { if (is_string($payload['semester_code'])) { $payload['semester_code'] = (int) trim($payload['semester_code']); } // 只保留1或2,其他值都移除 if (! in_array($payload['semester_code'], [1, 2], true)) { unset($payload['semester_code']); } } // 新增:处理组卷类型,默认值为 general if (! isset($payload['exam_type'])) { $payload['exam_type'] = 'general'; } // 新增:处理专项练习选项 if (isset($payload['practice_options'])) { if (is_string($payload['practice_options'])) { $decoded = json_decode($payload['practice_options'], true); $payload['practice_options'] = is_array($decoded) ? $decoded : []; } elseif (! is_array($payload['practice_options'])) { $payload['practice_options'] = []; } // 设置默认值 $payload['practice_options'] = array_merge([ 'weakness_threshold' => 0.7, 'intensity' => 'medium', 'include_new_questions' => true, 'focus_weaknesses' => true, ], $payload['practice_options']); } else { // 如果没有提供 practice_options,创建默认值 $payload['practice_options'] = [ 'weakness_threshold' => 0.7, 'intensity' => 'medium', 'include_new_questions' => true, 'focus_weaknesses' => true, ]; } // 新增:处理错题选项 if (isset($payload['mistake_options'])) { if (is_string($payload['mistake_options'])) { $decoded = json_decode($payload['mistake_options'], true); $payload['mistake_options'] = is_array($decoded) ? $decoded : []; } elseif (! is_array($payload['mistake_options'])) { $payload['mistake_options'] = []; } // 设置默认值 $payload['mistake_options'] = array_merge([ 'weakness_threshold' => 0.7, 'review_mistakes' => true, 'intensity' => 'medium', 'include_new_questions' => true, 'focus_weaknesses' => true, ], $payload['mistake_options']); } else { // 如果没有提供 mistake_options,创建默认值 $payload['mistake_options'] = [ 'weakness_threshold' => 0.7, 'review_mistakes' => true, 'intensity' => 'medium', 'include_new_questions' => true, 'focus_weaknesses' => true, ]; } // 新增:处理按知识点组卷选项 if (isset($payload['knowledge_points_options'])) { if (is_string($payload['knowledge_points_options'])) { $decoded = json_decode($payload['knowledge_points_options'], true); $payload['knowledge_points_options'] = is_array($decoded) ? $decoded : []; } elseif (! is_array($payload['knowledge_points_options'])) { $payload['knowledge_points_options'] = []; } // 设置默认值 $payload['knowledge_points_options'] = array_merge([ 'weakness_threshold' => 0.7, 'intensity' => 'medium', 'focus_weaknesses' => true, ], $payload['knowledge_points_options']); } else { // 如果没有提供 knowledge_points_options,创建默认值 $payload['knowledge_points_options'] = [ 'weakness_threshold' => 0.7, 'intensity' => 'medium', 'focus_weaknesses' => true, ]; } return $payload; } private function ensureStudentTeacherRelation(array $data): void { $studentId = (int) $data['student_id']; $teacherId = (int) $data['teacher_id']; $studentName = (string) ($data['student_name'] ?? '未知学生'); $teacherName = (string) ($data['teacher_name'] ?? '未知教师'); $grade = (string) ($data['grade'] ?? '未知年级'); $teacher = $this->externalIdService->handleTeacherExternalId($teacherId, [ 'name' => $teacherName, 'subject' => '数学', ]); $student = Student::where('student_id', $studentId)->first(); if ($student) { $updates = []; if ($studentName !== '' && $student->name !== $studentName) { $updates['name'] = $studentName; } if ($grade !== '' && $student->grade !== $grade) { $updates['grade'] = $grade; } if ($teacherId > 0 && (int) $student->teacher_id !== $teacherId) { $updates['teacher_id'] = $teacherId; } if (! empty($updates)) { $student->update($updates); } return; } $this->externalIdService->handleStudentExternalId($studentId, [ 'name' => $studentName, 'grade' => $grade, 'teacher_id' => $teacherId, ]); } private function normalizeQuestionTypeRatio(array $input): array { // 默认按 4:2:4 $defaults = [ '选择题' => 40, '填空题' => 20, '解答题' => 40, ]; $normalized = []; foreach ($input as $key => $value) { if (! is_numeric($value)) { continue; } $type = $this->normalizeQuestionTypeKey($key); if ($type) { $normalized[$type] = (float) $value; } } $merged = array_merge($defaults, $normalized); // 归一化到 100% $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 normalizeDifficultyRatio(array $input): array { $defaults = [ '基础' => 50, '中等' => 35, '拔高' => 15, ]; $normalized = []; foreach ($input as $key => $value) { if (! is_numeric($value)) { continue; } $label = trim($key); if (in_array($label, ['基础', 'easy', '简单'])) { $normalized['基础'] = (float) $value; } elseif (in_array($label, ['中等', 'medium'])) { $normalized['中等'] = (float) $value; } elseif (in_array($label, ['拔高', 'hard', '困难', '竞赛'])) { $normalized['拔高'] = (float) $value; } } return array_merge($defaults, $normalized); } 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 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 '解答题'; } /** * 根据题目类型获取默认分值(中国中学卷子标准) * 选择题:5分/题,填空题:5分/题,解答题:10分/题 */ private function defaultScore(string $type): int { return match ($type) { '选择题' => 5, '填空题' => 5, '解答题' => 10, default => 5, }; } /** * 计算试卷总分并调整各题目分值,确保总分接近目标分数 * 符合中国中学卷子标准: * - 选择题:约40%总分(每题4-6分,整数分值) * - 填空题:约25%总分(每题4-6分,整数分值) * - 解答题:约35%总分(每题8-12分,整数分值) * 使用组合优化算法确保: * 1. 所有分值都是整数(无小数点) * 2. 同类型题目分值均匀 * 3. 总分精确匹配目标分数(或最接近) */ 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); // 确定题型处理顺序(基于 sortedQuestions 中的顺序) $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) { // 第一个题型:拿平均分,然后都-1 $thisBase = $globalBaseScore; foreach ($typeIndexes[$type] as $idx) { $questionScores[$idx] = $thisBase; } // 都-1 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; } // 余数补偿:分散到多道题,从后往前各+1 $total = $thisBase * $count; $remainder = $remainingBudget - $total; if ($remainder > 0) { // 从最后一道题开始,往前 $remainder 道题各+1 $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; // 从前面题型扣分(每道最多扣2分) $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) { // 余数分散到多道题,从后往前各+1 $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; } /** * 标准化题目类型 */ 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 resolveMistakeQuestionIds(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array { $questionIds = []; if (! empty($mistakeQuestionIds)) { $questionIds = array_merge($questionIds, $mistakeQuestionIds); } if (! empty($mistakeIds)) { $mistakeQuestionIdsFromDb = MistakeRecord::query() ->where('student_id', $studentId) ->whereIn('id', $mistakeIds) ->pluck('question_id') ->filter() ->values() ->all(); $questionIds = array_merge($questionIds, $mistakeQuestionIdsFromDb); } $questionIds = array_values(array_unique(array_filter($questionIds))); return $questionIds; } private function sortQuestionsByRequestedIds(array $questions, array $requestedIds): array { if (empty($requestedIds)) { return $questions; } $order = array_flip($requestedIds); usort($questions, function ($a, $b) use ($order) { $aId = (string) ($a['id'] ?? ''); $bId = (string) ($b['id'] ?? ''); $aPos = $order[$aId] ?? PHP_INT_MAX; $bPos = $order[$bId] ?? PHP_INT_MAX; return $aPos <=> $bPos; }); return $questions; } /** * 每个题型内按难度升序排序,并统一重排题号 * 题型顺序固定:选择题 -> 填空题 -> 解答题 */ private function sortQuestionsWithinTypeByDifficulty(array $questions): array { if (empty($questions)) { return $questions; } $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 { $aDifficulty = (float) ($a['difficulty'] ?? 0.5); $bDifficulty = (float) ($b['difficulty'] ?? 0.5); if ($aDifficulty !== $bDifficulty) { return $aDifficulty <=> $bDifficulty; } $aId = (int) ($a['id'] ?? $a['question_id'] ?? 0); $bId = (int) ($b['id'] ?? $b['question_id'] ?? 0); return $aId <=> $bId; }; 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); Log::debug('题目已按题型内难度排序', [ 'choice_difficulty' => array_map(fn ($q) => $q['difficulty'] ?? null, $grouped['choice']), 'fill_difficulty' => array_map(fn ($q) => $q['difficulty'] ?? null, $grouped['fill']), 'answer_difficulty' => array_map(fn ($q) => $q['difficulty'] ?? null, $grouped['answer']), ]); return $sorted; } /** * 【新增】根据series_id、semester_code和grade获取textbook_id * 替代原来直接传入textbook_id的方式 */ private function resolveTextbookId(array $data): ?int { // 如果提供了series_id和semester_code,则查询textbook_id $seriesId = $data['series_id'] ?? null; $semesterCode = $data['semester_code'] ?? null; $grade = $data['grade'] ?? null; // 如果没有提供series_id或semester_code,则不设置textbook_id if (! $seriesId || ! $semesterCode) { return null; } try { // 根据series_id、semester_code和grade查询textbooks表 $query = DB::connection('mysql') ->table('textbooks') ->where('series_id', $seriesId) ->where('semester', $semesterCode); // 如果提供了grade,可以作为额外筛选条件 if ($grade) { $query->where('grade', $grade); } $textbook = $query->first(); if ($textbook) { Log::info('成功解析textbook_id', [ 'series_id' => $seriesId, 'semester_code' => $semesterCode, 'grade' => $grade, 'textbook_id' => $textbook->id, ]); return (int) $textbook->id; } Log::warning('未找到匹配的教材', [ 'series_id' => $seriesId, 'semester_code' => $semesterCode, 'grade' => $grade, ]); return null; } catch (\Exception $e) { Log::error('查询textbook_id失败', [ 'series_id' => $seriesId, 'semester_code' => $semesterCode, 'grade' => $grade, 'error' => $e->getMessage(), ]); return null; } } }