learningAnalyticsService = $learningAnalyticsService; $this->questionBankService = $questionBankService; $this->pdfExportService = $pdfExportService; } /** * 外部API:生成智能试卷(异步模式) * 立即返回任务ID,PDF生成在后台进行,完成后通过回调通知 */ public function store(Request $request): JsonResponse { $normalized = $this->normalizePayload($request->all()); $validator = validator($normalized, [ 'student_id' => 'required|string', 'teacher_id' => 'required|string', 'paper_name' => 'nullable|string|max:255', 'grade' => 'nullable|string|max:50', 'total_questions' => 'required|integer|min:6|max:100', 'difficulty_category' => 'nullable|string', 'kp_codes' => 'nullable|array', 'kp_codes.*' => 'string', 'skills' => 'array', 'skills.*' => 'string', 'question_type_ratio' => 'array', 'difficulty_ratio' => 'array', 'total_score' => 'nullable|numeric|min:1|max:1000', ]); if ($validator->fails()) { return response()->json([ 'success' => false, 'message' => '参数错误', 'errors' => $validator->errors()->toArray(), ], 422); } $data = $validator->validated(); // 确保 kp_codes 是数组,如果为空则设置为空数组 $data['kp_codes'] = $data['kp_codes'] ?? []; if (!is_array($data['kp_codes'])) { $data['kp_codes'] = []; } $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []); $difficultyRatio = $this->normalizeDifficultyRatio($data['difficulty_ratio'] ?? []); $paperName = $data['paper_name'] ?? ('智能试卷_' . now()->format('Ymd_His')); $difficultyCategory = $this->normalizeDifficultyCategory($data['difficulty_category'] ?? null); try { // 第一步:生成智能试卷(同步) $result = $this->learningAnalyticsService->generateIntelligentExam([ 'student_id' => $data['student_id'], 'grade' => $data['grade'] ?? null, 'total_questions' => $data['total_questions'], 'kp_codes' => $data['kp_codes'], 'skills' => $data['skills'] ?? [], 'question_type_ratio' => $questionTypeRatio, 'difficulty_ratio' => $difficultyRatio, ]); if (empty($result['success'])) { return response()->json([ 'success' => false, 'message' => $result['message'] ?? '智能出卷失败', ], 400); } $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes']); if (empty($questions)) { return response()->json([ 'success' => false, 'message' => '未能生成有效题目,请检查知识点或题库数据', ], 400); } $totalScore = array_sum(array_column($questions, 'score')); // 第二步:保存试卷到数据库(同步) $paperId = $this->questionBankService->saveExamToDatabase([ 'paper_name' => $paperName, 'student_id' => $data['student_id'], 'teacher_id' => $data['teacher_id'], 'difficulty_category' => $difficultyCategory, 'total_score' => $data['total_score'] ?? $totalScore, 'questions' => $questions, ]); if (!$paperId) { return response()->json([ 'success' => false, 'message' => '试卷保存失败', ], 500); } // 第三步:创建异步任务(异步) $taskId = $this->createAsyncTask($paperId, $data); // 立即返回完整的试卷数据(不等待PDF生成) $examContent = $this->buildCompleteExamContent($paperId); $payload = [ 'success' => true, 'message' => '智能试卷创建成功,PDF正在后台生成...', 'data' => [ 'task_id' => $taskId, 'paper_id' => $paperId, 'status' => 'processing', 'exam_content' => $examContent, 'urls' => [ // 通过paper_id获取HTML预览 'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]), 'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']), ], 'pdfs' => [ // PDF生成完成后通过状态查询获取 'exam_paper_pdf' => null, 'grading_pdf' => null, ], 'stats' => $result['stats'] ?? null, 'created_at' => now()->toISOString(), ], ]; return response()->json($payload, 200, [], JSON_UNESCAPED_SLASHES); } catch (\Exception $e) { Log::error('Intelligent exam API failed', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'success' => false, 'message' => '服务异常,请稍后重试', ], 500); } } /** * 轮询任务状态 */ public function status(string $taskId): JsonResponse { try { $task = $this->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); } } /** * 创建异步任务 */ private function createAsyncTask(string $paperId, array $data): string { $taskId = 'task_' . uniqid() . '_' . substr(md5($paperId . time()), 0, 8); // 保存任务信息到缓存 $taskData = [ 'task_id' => $taskId, 'paper_id' => $paperId, 'status' => 'processing', 'created_at' => now()->toISOString(), 'updated_at' => now()->toISOString(), 'progress' => 0, 'message' => '正在生成试卷...', 'data' => $data, 'callback_url' => $data['callback_url'] ?? null, // 支持回调URL ]; // 保存到缓存,24小时过期 cache()->put("exam_task:{$taskId}", $taskData, now()->addDay()); // 触发后台处理(在实际项目中,这里应该使用队列) // dispatch(new GenerateExamPdfJob($taskId, $paperId)); // 目前使用同步调用来模拟异步 $this->processPdfGeneration($taskId, $paperId); return $taskId; } /** * 获取任务状态 */ private function getTaskStatus(string $taskId): ?array { return cache()->get("exam_task:{$taskId}"); } /** * 处理PDF生成(模拟后台任务) * 在实际项目中,这个方法应该在队列worker中执行 */ private function processPdfGeneration(string $taskId, string $paperId): void { try { // 更新任务状态 $this->updateTaskStatus($taskId, [ 'status' => 'processing', 'progress' => 10, 'message' => '开始生成试卷PDF...', ]); // 生成试卷PDF $pdfUrl = $this->pdfExportService->generateExamPdf($paperId) ?? $this->questionBankService->exportExamToPdf($paperId) ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']); $this->updateTaskStatus($taskId, [ 'progress' => 50, 'message' => '试卷PDF生成完成,开始生成判卷PDF...', ]); // 生成判卷PDF $gradingPdfUrl = $this->pdfExportService->generateGradingPdf($paperId) ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'true']); // 构建完整的试卷内容 $examContent = $this->buildCompleteExamContent($paperId); // 更新任务状态为完成 $this->updateTaskStatus($taskId, [ 'status' => 'completed', 'progress' => 100, 'message' => 'PDF生成完成', 'exam_content' => $examContent, // 包含完整试卷数据 'pdfs' => [ 'exam_paper_pdf' => $pdfUrl, 'grading_pdf' => $gradingPdfUrl, ], 'completed_at' => now()->toISOString(), ]); Log::info('异步任务完成', [ 'task_id' => $taskId, 'paper_id' => $paperId, 'pdf_url' => $pdfUrl, 'grading_pdf_url' => $gradingPdfUrl, ]); // 发送回调通知(如果提供了callback_url) $this->sendCallbackNotification($taskId); } catch (\Exception $e) { Log::error('PDF生成失败', [ 'task_id' => $taskId, 'paper_id' => $paperId, 'error' => $e->getMessage(), ]); // 更新任务状态为失败 $this->updateTaskStatus($taskId, [ 'status' => 'failed', 'progress' => 0, 'message' => 'PDF生成失败: ' . $e->getMessage(), 'error' => $e->getMessage(), ]); } } /** * 更新任务状态 */ private function updateTaskStatus(string $taskId, array $updates): void { $task = $this->getTaskStatus($taskId); if (!$task) { return; } $updatedTask = array_merge($task, $updates, [ 'updated_at' => now()->toISOString(), ]); cache()->put("exam_task:{$taskId}", $updatedTask, now()->addDay()); } /** * 发送回调通知 */ private function sendCallbackNotification(string $taskId): void { $task = $this->getTaskStatus($taskId); if (!$task || !$task['callback_url']) { return; // 没有回调URL,不需要发送通知 } try { $payload = [ 'task_id' => $task['task_id'], 'paper_id' => $task['paper_id'], 'status' => $task['status'], 'exam_content' => $task['exam_content'] ?? null, 'pdfs' => $task['pdfs'] ?? null, 'stats' => $task['stats'] ?? null, 'completed_at' => $task['completed_at'], 'callback_type' => 'exam_pdf_generated', ]; $response = Http::timeout(30) ->post($task['callback_url'], $payload); if ($response->successful()) { Log::info('回调通知发送成功', [ 'task_id' => $taskId, 'callback_url' => $task['callback_url'], ]); } else { Log::warning('回调通知发送失败', [ 'task_id' => $taskId, 'callback_url' => $task['callback_url'], 'status' => $response->status(), ]); } } catch (\Exception $e) { Log::error('回调通知异常', [ 'task_id' => $taskId, 'callback_url' => $task['callback_url'] ?? 'unknown', 'error' => $e->getMessage(), ]); } } /** * 兼容字符串/数组入参 */ private function normalizePayload(array $payload): array { // 处理 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'])))); } return $payload; } 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'])) { return '选择题'; } if (in_array($key, ['fill', '填空题', 'blank'])) { return '填空题'; } if (in_array($key, ['answer', '解答题', '计算题'])) { 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 normalizeDifficultyCategory(?string $category): string { if (!$category) { return '基础'; } $category = trim($category); if (in_array($category, ['基础', '进阶', '中等', 'easy'])) { return $category === 'easy' ? '基础' : $category; } if (in_array($category, ['拔高', '困难', 'hard', '竞赛'])) { return '拔高'; } return '基础'; } 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 '解答题'; } private function defaultScore(string $type): int { if ($type === '选择题' || $type === '填空题') { return 5; } return 10; } /** * 构建完整的试卷信息(包含所有题目详情) */ private function buildCompleteExamContent(string $paperId): array { $paper = Paper::with('questions')->find($paperId); $questions = $paper ? $paper->questions : collect(); return [ // 试卷基本信息 'paper_info' => [ 'paper_id' => $paperId, 'paper_name' => $paper?->paper_name ?? '', 'student_id' => $paper?->student_id ?? '', 'teacher_id' => $paper?->teacher_id ?? '', 'total_questions' => $questions->count(), 'total_score' => $paper?->total_score ?? 0, 'difficulty_category' => $paper?->difficulty_category ?? '基础', 'created_at' => $paper?->created_at?->toISOString(), 'updated_at' => $paper?->updated_at?->toISOString(), ], // 完整题目信息 'questions' => $questions->map(function (PaperQuestion $q) { // 构建选择题选项(如果适用) $options = []; if ($q->question_type === 'choice') { // 从题目文本中提取选项 $questionText = $q->question_text ?? ''; preg_match_all('/([A-D])\s*[\.\、\:]\s*([^A-D]+?)(?=[A-D]\s*[\.\、\:]|$)/u', $questionText, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $options[] = [ 'label' => $match[1], 'content' => trim($match[2]), ]; } } return [ // 基本信息 'question_number' => $q->question_number, 'question_id' => $q->question_id, 'question_bank_id' => $q->question_bank_id, 'question_type' => $q->question_type, 'knowledge_point' => $q->knowledge_point, 'difficulty' => $q->difficulty, 'score' => $q->score, 'estimated_time' => $q->estimated_time, // 题目内容 'stem' => $q->question_text ?? '', 'options' => $options, // 答案和解析 'correct_answer' => $q->correct_answer ?? '', 'solution' => $q->solution ?? '', // 元数据 'student_answer' => $q->student_answer, 'is_correct' => $q->is_correct, 'score_obtained' => $q->score_obtained, 'score_ratio' => $q->score_ratio, 'teacher_comment' => $q->teacher_comment, 'graded_at' => $q->graded_at?->toISOString(), 'graded_by' => $q->graded_by, // 题目属性 'metadata' => [ 'has_solution' => !empty($q->solution), 'is_choice' => $q->question_type === 'choice', 'is_fill' => $q->question_type === 'fill', 'is_answer' => $q->question_type === 'answer', 'difficulty_label' => $this->getDifficultyLabel($q->difficulty), 'question_type_label' => $this->getQuestionTypeLabel($q->question_type), ], ]; })->toArray(), // 统计信息 'statistics' => [ 'type_distribution' => $this->getTypeDistribution($questions), 'difficulty_distribution' => $this->getDifficultyDistribution($questions), 'knowledge_point_distribution' => $this->getKnowledgePointDistribution($questions), 'total_score' => $questions->sum('score'), 'average_difficulty' => $questions->avg('difficulty'), 'total_estimated_time' => $questions->sum('estimated_time'), ], // 知识点和技能标签 'knowledge_points' => $questions->pluck('knowledge_point')->unique()->filter()->values()->toArray(), 'skills' => $this->extractSkillsFromQuestions($questions), ]; } /** * 获取题型中文标签 */ private function getQuestionTypeLabel(string $type): string { return match($type) { 'choice' => '选择题', 'fill' => '填空题', 'answer' => '解答题', default => '未知题型' }; } /** * 获取难度中文标签 */ private function getDifficultyLabel(?float $difficulty): string { if ($difficulty === null) return '未知'; if ($difficulty <= 0.4) return '基础'; if ($difficulty <= 0.7) return '中等'; return '拔高'; } /** * 获取题型分布 */ private function getTypeDistribution($questions): array { $distribution = []; foreach ($questions as $q) { $type = $q->question_type; $distribution[$type] = ($distribution[$type] ?? 0) + 1; } return $distribution; } /** * 获取难度分布 */ private function getDifficultyDistribution($questions): array { $distribution = []; foreach ($questions as $q) { $label = $this->getDifficultyLabel($q->difficulty); $distribution[$label] = ($distribution[$label] ?? 0) + 1; } return $distribution; } /** * 获取知识点分布 */ private function getKnowledgePointDistribution($questions): array { $distribution = []; foreach ($questions as $q) { $kp = $q->knowledge_point; if ($kp) { $distribution[$kp] = ($distribution[$kp] ?? 0) + 1; } } return $distribution; } /** * 从题目中提取技能标签 */ private function extractSkillsFromQuestions($questions): array { $skills = []; // 注意:由于题库在PostgreSQL中,MySQL的questions表可能不存在 // 我们从PaperQuestion的solution或metadata中提取技能信息 foreach ($questions as $q) { // 从解题过程中提取技能关键词 $solution = $q->solution ?? ''; if ($solution) { // 简单的技能提取(基于常见关键词) $skillKeywords = ['代入法', '配方法', '因式分解', '换元法', '判别式', '求根公式', '韦达定理']; foreach ($skillKeywords as $keyword) { if (strpos($solution, $keyword) !== false) { $skills[] = $keyword; } } } // 从题目文本中提取技能标签(如果存在) $stem = $q->question_text ?? ''; if ($stem) { // 尝试从题干中提取技能信息(格式如:{技能1,技能2}) preg_match_all('/\{([^}]+)\}/', $stem, $matches); foreach ($matches[1] as $match) { $skillList = array_map('trim', explode(',', $match)); $skills = array_merge($skills, $skillList); } } } return array_unique(array_filter($skills)); } }