baseUrl = ''; $this->timeout = (int) config('question_bank.timeout', 60); $this->retry = (int) config('question_bank.retry', 2); $this->retryDelay = (int) config('question_bank.retry_delay', 500); $this->mode = 'local'; } private function http() { return Http::timeout($this->timeout) ->retry($this->retry, $this->retryDelay); } /** * 从题目内容中提取选项 */ private function extractOptions(string $content): array { // 匹配 A. B. C. D. 格式的选项 if (preg_match_all('/([A-D])\.\s*(.+?)(?=[A-D]\.|$)/s', $content, $matches, PREG_SET_ORDER)) { $options = []; foreach ($matches as $match) { $optionText = trim($match[2]); // 移除末尾的换行和空白 $optionText = preg_replace('/\s+$/', '', $optionText); $options[] = $optionText; } return $options; } return []; } /** * 分离题干内容和选项 */ private function separateStemAndOptions(string $content): array { // 如果没有选项,直接返回 if (!preg_match('/[A-D]\.\s+/m', $content)) { return [$content, []]; } // 提取选项 $options = $this->extractOptions($content); // 提取题干(选项前的部分) $stem = preg_replace('/[A-D]\.\s+.+?(?=[A-D]\.|$)/s', '', $content); $stem = trim($stem); // 移除末尾的括号或空白 $stem = preg_replace('/()\s*$/', '', $stem); $stem = trim($stem); return [$stem, $options]; } /** * 获取题目列表 */ public function listQuestions(int $page = 1, int $perPage = 50, array $filters = []): array { if ($this->useLocal()) { return $this->local()->listQuestions($page, $perPage, $filters); } try { $response = $this->http() ->get($this->baseUrl . '/questions', [ 'page' => $page, 'per_page' => $perPage, ...$filters ]); if ($response->successful()) { info("QuestionBankService::listQuestions", [$response->json()]); return $response->json(); } Log::warning('题库API调用失败', [ 'status' => $response->status() ]); } catch (\Exception $e) { Log::error('获取题目列表失败', [ 'error' => $e->getMessage() ]); } return ['data' => [], 'meta' => ['total' => 0]]; } /** * 获取题目详情 */ public function getQuestion(string $questionCode): ?array { if ($this->useLocal()) { return $this->local()->getQuestionByCode($questionCode); } try { $response = $this->http() ->get($this->baseUrl . "/questions/{$questionCode}"); if ($response->successful()) { return $response->json(); } Log::warning('获取题目详情失败', [ 'code' => $questionCode, 'status' => $response->status() ]); } catch (\Exception $e) { Log::error('获取题目详情异常', [ 'code' => $questionCode, 'error' => $e->getMessage() ]); } return null; } /** * 更新题目 */ public function updateQuestion(string $questionCode, array $payload): bool { if ($this->useLocal()) { return $this->local()->updateQuestionByCode($questionCode, $payload); } try { $response = Http::timeout(10) ->patch($this->baseUrl . "/questions/{$questionCode}", $payload); if ($response->successful()) { return true; } Log::warning('更新题目失败', [ 'code' => $questionCode, 'status' => $response->status(), 'body' => $response->json(), ]); } catch (\Exception $e) { Log::error('更新题目异常', [ 'code' => $questionCode, 'error' => $e->getMessage() ]); } return false; } /** * 筛选题目 (支持 kp_codes, skills 等高级筛选) */ public function filterQuestions(array $params): array { if ($this->useLocal()) { return $this->local()->listQuestions( (int) ($params['page'] ?? 1), (int) ($params['per_page'] ?? 50), $params ); } try { $response = Http::timeout(30) ->get($this->baseUrl . '/questions', $params); if ($response->successful()) { info("QuestionBankService::filterQuestions", [$response->json()]); return $response->json(); } Log::warning('筛选题目API调用失败', [ 'status' => $response->status(), 'params' => $params ]); } catch (\Exception $e) { Log::error('筛选题目异常', [ 'error' => $e->getMessage(), 'params' => $params ]); } return ['data' => []]; } /** * 批量获取题目详情(根据题目 ID 列表) */ public function getQuestionsByIds(array $ids): array { if (empty($ids)) { return ['data' => []]; } if ($this->useLocal()) { return $this->local()->getQuestionsByIds($ids); } try { $response = $this->http() ->get($this->baseUrl . '/questions', [ 'ids' => implode(',', $ids), ]); if ($response->successful()) { return $response->json(); } Log::warning('批量获取题目失败', [ 'ids' => $ids, 'status' => $response->status(), ]); } catch (\Exception $e) { Log::error('批量获取题目异常', [ 'ids' => $ids, 'error' => $e->getMessage(), ]); } return ['data' => []]; } /** * 智能生成题目(异步模式) */ public function generateIntelligentQuestions(array $params, ?string $callbackUrl = null): array { if ($this->useLocal()) { return $this->local()->generateQuestions($params); } try { // 添加回调 URL if ($callbackUrl) { $params['callback_url'] = $callbackUrl; } // 注意:这里的请求实际上是同步的,会等待响应 // 真正的异步应该使用 Http::async() $response = $this->http() ->post($this->baseUrl . '/ai/generate-intelligent-questions', $params); if ($response->successful()) { return $response->json(); } Log::warning('题目生成API调用失败', [ 'status' => $response->status(), 'body' => $response->body() ]); } catch (\Illuminate\Http\Client\ConnectionException $e) { // 连接超时或网络错误 Log::error('题目生成连接异常', [ 'error' => $e->getMessage(), 'message' => '可能的原因:1. AI服务未启动 2. 网络连接问题 3. 服务负载过高' ]); return [ 'success' => false, 'message' => '连接AI服务失败,请检查服务是否正常运行' ]; } catch (\Exception $e) { Log::error('题目生成异常', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); } return ['success' => false, 'message' => '生成失败']; } /** * 获取任务状态 */ public function getTaskStatus(string $taskId): ?array { if ($this->useLocal()) { return null; } try { $response = Http::timeout(10) ->get($this->baseUrl . '/tasks/' . $taskId); if ($response->successful()) { return $response->json(); } Log::warning('获取任务状态失败', [ 'task_id' => $taskId, 'status' => $response->status() ]); } catch (\Exception $e) { Log::error('获取任务状态异常', [ 'task_id' => $taskId, 'error' => $e->getMessage() ]); } return null; } /** * 获取任务列表 */ public function listTasks(?string $status = null, int $page = 1, int $perPage = 10): array { if ($this->useLocal()) { return ['data' => [], 'meta' => ['total' => 0]]; } try { $params = [ 'page' => $page, 'per_page' => $perPage ]; if ($status) { $params['status'] = $status; } $response = Http::timeout(10) ->get($this->baseUrl . '/tasks', $params); if ($response->successful()) { return $response->json(); } Log::warning('获取任务列表失败', [ 'status' => $response->status() ]); } catch (\Exception $e) { Log::error('获取任务列表异常', [ 'error' => $e->getMessage() ]); } return ['data' => [], 'meta' => ['total' => 0]]; } /** * 获取题目统计信息 */ public function getStatistics(): array { if ($this->useLocal()) { return $this->local()->getStatistics(); } try { $response = Http::timeout(10) ->get($this->baseUrl . '/questions/statistics'); if ($response->successful()) { return $response->json(); } Log::warning('获取题目统计失败', [ 'status' => $response->status() ]); } catch (\Exception $e) { Log::error('获取题目统计异常', [ 'error' => $e->getMessage() ]); } return [ 'total' => 0, 'by_difficulty' => [], 'by_kp' => [], 'by_source' => [] ]; } /** * 根据知识点获取题目 */ public function getQuestionsByKpCode(string $kpCode, int $limit = 100): array { if ($this->useLocal()) { return $this->local()->getQuestionsByKpCode($kpCode, $limit); } try { $response = Http::timeout(10) ->get($this->baseUrl . '/questions', [ 'kp_code' => $kpCode, 'limit' => $limit ]); if ($response->successful()) { return $response->json(); } } catch (\Exception $e) { Log::error('根据知识点获取题目失败', [ 'kp_code' => $kpCode, 'error' => $e->getMessage() ]); } return []; } /** * 删除题目 */ public function deleteQuestion(string $questionCode): bool { if ($this->useLocal()) { return $this->local()->deleteQuestionByCode($questionCode); } try { $response = Http::timeout(10) ->delete($this->baseUrl . "/questions/{$questionCode}"); // 只有返回204(删除成功)才返回true,404(不存在)返回false if ($response->status() === 204) { return true; } if ($response->status() === 404) { Log::warning('尝试删除不存在的题目', ['question_code' => $questionCode]); return false; } return false; } catch (\Exception $e) { Log::error('删除题目失败', [ 'question_code' => $questionCode, 'error' => $e->getMessage() ]); return false; } } /** * 智能选择试卷题目 */ public function selectQuestionsForExam(int $totalQuestions, array $filters): array { if ($this->useLocal()) { return $this->local()->selectQuestionsForExam($totalQuestions, $filters); } $logFile = __DIR__ . '/../../../../select_questions.log'; $startTime = microtime(true); try { $requestData = [ 'total_questions' => $totalQuestions, 'filters' => $filters ]; $logMsg = "=== " . date('Y-m-d H:i:s') . " ===\n"; $logMsg .= "开始调用 selectQuestionsForExam\n"; $logMsg .= "baseUrl: " . $this->baseUrl . "\n"; $logMsg .= "totalQuestions: $totalQuestions\n"; $logMsg .= "filters: " . json_encode($filters) . "\n\n"; file_put_contents($logFile, $logMsg, FILE_APPEND); $response = Http::timeout(30) ->post($this->baseUrl . '/exam/select-questions', $requestData); $logMsg = "API响应:\n"; $logMsg .= " status: " . $response->status() . "\n"; $logMsg .= " successful: " . ($response->successful() ? 'true' : 'false') . "\n"; $logMsg .= " body: " . $response->body() . "\n\n"; file_put_contents($logFile, $logMsg, FILE_APPEND); if ($response->successful()) { $data = $response->json('data', []); $logMsg = "成功解析JSON:\n"; $logMsg .= " data字段题目数量: " . count($data) . "\n"; $logMsg .= " 耗时: " . round((microtime(true) - $startTime) * 1000, 2) . "ms\n\n"; file_put_contents($logFile, $logMsg, FILE_APPEND); return $data; } $logMsg = "API调用失败! 状态码: " . $response->status() . "\n"; $logMsg .= "响应内容: " . $response->body() . "\n\n"; file_put_contents($logFile, $logMsg, FILE_APPEND); } catch (\Exception $e) { $logMsg = "异常: " . $e->getMessage() . "\n"; $logMsg .= "堆栈: " . $e->getTraceAsString() . "\n\n"; file_put_contents($logFile, $logMsg, FILE_APPEND); } $logMsg = "返回空数组\n"; $logMsg .= "=== 结束 ===\n\n"; file_put_contents($logFile, $logMsg, FILE_APPEND); return []; } /** * 保存试卷到数据库(本地 papers 表) */ public function saveExamToDatabase(array $examData): ?string { $logFile = __DIR__ . '/../../../../save_exam.log'; $logMsg = "=== " . date('Y-m-d H:i:s') . " ===\n"; $logMsg .= "saveExamToDatabase 被调用\n"; $logMsg .= "questions_count: " . count($examData['questions'] ?? []) . "\n"; $logMsg .= "paper_name: " . ($examData['paper_name'] ?? 'N/A') . "\n"; $logMsg .= "student_id: " . ($examData['student_id'] ?? 'N/A') . "\n"; if (!empty($examData['questions'])) { $logMsg .= "first_question_id: " . ($examData['questions'][0]['id'] ?? 'N/A') . "\n"; } file_put_contents($logFile, $logMsg, FILE_APPEND); // 数据完整性检查 if (empty($examData['questions'])) { $logMsg = "❌ 题目为空,返回null!\n"; $logMsg .= "这是导致生成demo ID的原因!\n\n"; file_put_contents($logFile, $logMsg, FILE_APPEND); return null; } try { // 使用数据库事务确保数据一致性 return \Illuminate\Support\Facades\DB::transaction(function () use ($examData) { // 使用行业标准的Snowflake ID生成12位唯一数字ID $uniqueId = PaperIdGenerator::generate(); $paperId = 'paper_' . $uniqueId; Log::info('开始保存试卷到数据库', [ 'paper_id' => $paperId, 'paper_name' => $examData['paper_name'] ?? '未命名试卷', 'question_count' => count($examData['questions']) ]); // 使用Laravel模型保存到 papers 表 $paper = \App\Models\Paper::create([ 'paper_id' => $paperId, 'student_id' => $examData['student_id'] ?? '', 'teacher_id' => $examData['teacher_id'] ?? '', 'paper_name' => $examData['paper_name'] ?? '未命名试卷', 'paper_type' => 'auto_generated', 'total_questions' => count($examData['questions']), // 使用实际题目数量 'total_score' => $examData['total_score'] ?? 0, 'status' => 'draft', 'difficulty_category' => $examData['difficulty_category'] ?? '基础', ]); // 获取所有题目的正确答案 $questionBankIds = array_filter(array_map(function($q) { return $q['id'] ?? $q['question_id'] ?? null; }, $examData['questions'])); $correctAnswersMap = []; if (!empty($questionBankIds)) { Log::info('从本地题库获取题目正确答案', [ 'paper_id' => $paperId, 'question_bank_ids' => $questionBankIds ]); $localQuestions = Question::query() ->whereIn('id', array_values($questionBankIds)) ->get(['id', 'answer']); foreach ($localQuestions as $detail) { $correctAnswersMap[$detail->id] = $detail->answer ?? ''; } } // 准备题目数据 $questionInsertData = []; foreach ($examData['questions'] as $index => $question) { // 验证题目基本数据 if (empty($question['stem']) && empty($question['content'])) { Log::warning('跳过没有内容的题目', [ 'paper_id' => $paperId, 'question_index' => $index ]); continue; } // 处理题目内容:分离题干和选项(如果存在) $rawContent = $question['stem'] ?? $question['content'] ?? ''; list($stem, $options) = $this->separateStemAndOptions($rawContent); // 将选项以换行符形式附加到题干末尾,方便后续渲染 if (!empty($options)) { $stemWithOptions = $stem . "\n" . implode("\n", array_map(function($opt, $idx) { return chr(65 + $idx) . '. ' . $opt; }, $options, array_keys($options))); $question['stem'] = $stemWithOptions; $question['options'] = $options; } else { $question['stem'] = $stem; } // 处理难度字段:如果是字符串则转换为数字 $difficultyValue = $question['difficulty'] ?? 0.5; if (is_string($difficultyValue)) { // 将中文难度转换为数字 if (strpos($difficultyValue, '基础') !== false || strpos($difficultyValue, '简单') !== false) { $difficultyValue = 0.3; } elseif (strpos($difficultyValue, '中等') !== false || strpos($difficultyValue, '一般') !== false) { $difficultyValue = 0.6; } elseif (strpos($difficultyValue, '拔高') !== false || strpos($difficultyValue, '困难') !== false) { $difficultyValue = 0.9; } else { $difficultyValue = 0.5; } } // 确保 knowledge_point 有值 $knowledgePoint = $question['kp'] ?? $question['kp_code'] ?? $question['knowledge_point'] ?? $question['knowledge_point_code'] ?? ''; if (empty($knowledgePoint) && isset($question['kp_code'])) { $knowledgePoint = $question['kp_code']; } // 获取题目类型 $questionType = $question['question_type'] ?? 'answer'; if (!$questionType) { // 如果没有类型,根据内容推断 $content = $question['stem'] ?? $question['content'] ?? ''; if (is_string($content)) { // 1. 优先检查填空题(下划线) if (strpos($content, '____') !== false || strpos($content, '______') !== false) { $questionType = 'fill'; } // 2. 检查选择题(必须有选项 A. B. C. D.) elseif (preg_match('/[A-D]\s*\./', $content) || preg_match('/\([A-D]\)/', $content)) { if (preg_match('/A\./', $content) && preg_match('/B\./', $content)) { $questionType = 'choice'; } else { // 只有括号没有选项,可能是填空 if (strpos($content, '()') !== false || strpos($content, '()') !== false) { $questionType = 'fill'; } else { $questionType = 'answer'; } } } // 3. 检查纯括号填空 elseif (strpos($content, '()') !== false || strpos($content, '()') !== false) { $questionType = 'fill'; } else { $questionType = 'answer'; } } else { $questionType = 'answer'; } } // 获取正确答案 $questionBankId = $question['id'] ?? $question['question_id'] ?? null; $correctAnswer = $correctAnswersMap[$questionBankId] ?? $question['answer'] ?? $question['correct_answer'] ?? ''; $questionInsertData[] = [ 'paper_id' => $paperId, 'question_id' => $question['question_code'] ?? $question['question_id'] ?? null, 'question_bank_id' => $question['id'] ?? $question['question_id'] ?? 0, 'knowledge_point' => $knowledgePoint, 'question_type' => $questionType, 'question_text' => is_array($question['stem'] ?? null) ? json_encode($question['stem'], JSON_UNESCAPED_UNICODE) : ($question['stem'] ?? $question['content'] ?? $question['question_text'] ?? ''), 'correct_answer' => is_array($correctAnswer) ? json_encode($correctAnswer, JSON_UNESCAPED_UNICODE) : $correctAnswer, // 保存正确答案 'solution' => is_array($question['solution'] ?? null) ? json_encode($question['solution'], JSON_UNESCAPED_UNICODE) : ($question['solution'] ?? ''), // 保存解题思路 'difficulty' => $difficultyValue, 'score' => $question['score'] ?? 5, // 默认5分 'estimated_time' => $question['estimated_time'] ?? 300, 'question_number' => $index + 1, ]; } // 验证是否有有效的题目数据 if (empty($questionInsertData)) { Log::error('没有有效的题目数据可以保存', ['paper_id' => $paperId]); throw new \Exception('没有有效的题目数据'); } // 调试:检查第一个题目的solution字段 if (!empty($questionInsertData)) { $firstQuestion = $questionInsertData[0]; Log::debug('试卷保存调试 - 第一个题目', [ 'paper_id' => $paperId, 'question_id' => $firstQuestion['question_id'] ?? '', 'question_bank_id' => $firstQuestion['question_bank_id'] ?? '', 'has_solution' => !empty($firstQuestion['solution']), 'solution_length' => strlen($firstQuestion['solution'] ?? ''), 'solution_preview' => substr($firstQuestion['solution'] ?? '', 0, 80) ]); } // 使用Laravel模型批量插入题目数据 \App\Models\PaperQuestion::insert($questionInsertData); // 验证插入结果,使用关联关系 $insertedQuestionCount = $paper->questions()->count(); if ($insertedQuestionCount !== count($questionInsertData)) { throw new \Exception("题目插入数量不匹配:预期 {$insertedQuestionCount},实际 " . count($questionInsertData)); } Log::info('试卷保存成功', [ 'paper_id' => $paperId, 'expected_questions' => count($questionInsertData), 'actual_questions' => $insertedQuestionCount, 'paper_name' => $paper->paper_name ]); return $paperId; }); } catch (\Exception $e) { Log::error('保存试卷到数据库失败', [ 'error' => $e->getMessage(), 'paper_name' => $examData['paper_name'] ?? '未命名试卷', 'student_id' => $examData['student_id'] ?? 'unknown', 'question_count' => count($examData['questions'] ?? []), 'trace' => $e->getTraceAsString() ]); } return null; } private function useLocal(): bool { return true; } private function local(): QuestionLocalService { return app(QuestionLocalService::class); } /** * 检查数据完整性 - 发现没有题目的试卷 */ public function checkDataIntegrity(): array { try { // 使用Laravel模型查找显示有题目但实际没有题目的试卷 $inconsistentPapers = \App\Models\Paper::where('question_count', '>', 0) ->whereDoesntHave('questions') ->get(); Log::warning('发现数据不一致的试卷', [ 'count' => $inconsistentPapers->count(), 'papers' => $inconsistentPapers->map(function($paper) { return [ 'paper_id' => $paper->paper_id, 'paper_name' => $paper->paper_name, 'expected_questions' => $paper->question_count, 'student_id' => $paper->student_id, 'created_at' => $paper->created_at ]; })->toArray() ]); return [ 'inconsistent_count' => $inconsistentPapers->count(), 'papers' => $inconsistentPapers->toArray() ]; } catch (\Exception $e) { Log::error('检查数据完整性失败', ['error' => $e->getMessage()]); return ['inconsistent_count' => 0, 'papers' => []]; } } /** * 清理没有题目的试卷记录 */ public function cleanupInconsistentPapers(): int { try { return \Illuminate\Support\Facades\DB::transaction(function () { // 使用Laravel模型查找显示有题目但实际没有题目的试卷 $inconsistentPapers = \App\Models\Paper::where('question_count', '>', 0) ->whereDoesntHave('questions') ->get(); if ($inconsistentPapers->isEmpty()) { return 0; } // 获取要删除的试卷ID列表 $deletedPaperIds = $inconsistentPapers->pluck('paper_id')->toArray(); // 使用Laravel模型删除这些不一致的试卷记录 $deletedCount = \App\Models\Paper::whereIn('paper_id', $deletedPaperIds)->delete(); Log::info('清理不一致的试卷记录', [ 'deleted_count' => $deletedCount, 'deleted_paper_ids' => $deletedPaperIds ]); return $deletedCount; }); } catch (\Exception $e) { Log::error('清理不一致试卷失败', ['error' => $e->getMessage()]); return 0; } } /** * 修复试卷的题目数量统计 */ public function fixPaperQuestionCounts(): int { try { $fixedCount = 0; // 使用Laravel模型获取所有试卷 $papers = \App\Models\Paper::all(); foreach ($papers as $paper) { // 计算实际的题目数量,使用关联关系 $actualQuestionCount = $paper->questions()->count(); // 如果题目数量不匹配,更新试卷 if ($paper->question_count !== $actualQuestionCount) { $paper->update([ 'question_count' => $actualQuestionCount, 'updated_at' => now() ]); $fixedCount++; Log::info('修复试卷题目数量', [ 'paper_id' => $paper->paper_id, 'old_count' => $paper->getOriginal('question_count'), 'new_count' => $actualQuestionCount ]); } } Log::info('试卷题目数量修复完成', ['fixed_count' => $fixedCount]); return $fixedCount; } catch (\Exception $e) { Log::error('修复试卷题目数量失败', ['error' => $e->getMessage()]); return 0; } } /** * 获取试卷列表 */ public function listExams(int $page = 1, int $perPage = 20): array { $query = Paper::query()->whereHas('questions'); $paginator = $query->orderByDesc('id')->paginate($perPage, ['*'], 'page', $page); $data = $paginator->getCollection()->map(function (Paper $paper) { return [ 'paper_id' => $paper->paper_id, 'paper_name' => $paper->paper_name, 'student_id' => $paper->student_id, 'teacher_id' => $paper->teacher_id, 'total_questions' => $paper->question_count ?? $paper->questions()->count(), 'total_score' => $paper->total_score, 'difficulty_category' => $paper->difficulty_category, 'status' => $paper->status, 'created_at' => $paper->created_at, 'updated_at' => $paper->updated_at, ]; })->toArray(); return [ 'data' => $data, 'meta' => [ 'page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), 'total_pages' => $paginator->lastPage(), ], ]; } /** * 获取试卷详情 */ public function getExamById(string $paperId): ?array { $paper = Paper::where('paper_id', $paperId)->first(); if (!$paper) { return null; } return app(PaperPayloadService::class)->buildPaperApiPayload($paper); } /** * 导出试卷为PDF */ public function exportExamToPdf(string $paperId): ?string { return app(ExamPdfExportService::class)->generateExamPdf($paperId); } /** * 检查服务健康状态 */ public function checkHealth(): bool { return true; } /** * 根据OCR识别的题目生成完整题目并保存到题库(异步模拟版本) * * @param array $questions OCR识别的题目列表 * @param string $gradeLevel 年级 * @param string $subject 科目 * @param int $ocrRecordId OCR记录ID,用于关联 * @param string|null $callbackUrl 回调URL(可选,如果不提供则自动生成) * @param string|null $callbackRouteName 回调路由名称(用于动态生成URL) * @return array 任务ID和状态 */ public function generateQuestionsFromOcrAsync( array $questions, string $gradeLevel = '高一', string $subject = '数学', int $ocrRecordId = null, string $callbackUrl = null, string $callbackRouteName = 'api.ocr.callback' ): array { try { // 如果没有提供回调URL,但提供了OCR记录ID,则动态生成回调URL if (!$callbackUrl && $ocrRecordId) { $callbackUrl = $this->generateCallbackUrl($callbackRouteName); Log::info('动态生成回调URL', [ 'route_name' => $callbackRouteName, 'generated_url' => $callbackUrl ]); } // 生成唯一的任务ID $taskId = 'ocr_' . $ocrRecordId . '_' . time() . '_' . substr(md5(uniqid()), 0, 8); // 更新OCR记录状态为生成中 if ($ocrRecordId) { \DB::table('ocr_question_results') ->where('ocr_record_id', $ocrRecordId) ->where('question_bank_id', null) // 只更新未关联的题目 ->update([ 'generation_status' => 'generating', 'generation_task_id' => $taskId, 'generation_error' => null ]); } // 启动后台任务(使用Laravel的队列) if ($ocrRecordId && $callbackUrl) { // 使用Laravel队列异步处理 $this->dispatchOcrGenerationJob($ocrRecordId, $questions, $gradeLevel, $subject, $callbackUrl, $taskId); } else { // 如果没有回调URL,使用同步方式 $response = $this->generateQuestionsFromOcr($questions, $gradeLevel, $subject); return $response; } Log::info('OCR题目生成任务已提交到队列', [ 'task_id' => $taskId, 'ocr_record_id' => $ocrRecordId, 'questions_count' => count($questions), 'callback_url' => $callbackUrl ]); return [ 'status' => 'processing', 'task_id' => $taskId, 'ocr_record_id' => $ocrRecordId, 'message' => '题目生成任务已启动,完成后将通过回调通知', 'estimated_time' => '约' . (count($questions) * 2) . '秒', 'callback_info' => [ 'will_callback' => !empty($callbackUrl), 'callback_url' => $callbackUrl ] ]; } catch (\Exception $e) { Log::error('OCR题目生成任务提交异常', [ 'error' => $e->getMessage(), 'ocr_record_id' => $ocrRecordId ]); return [ 'status' => 'error', 'message' => '任务提交失败: ' . $e->getMessage() ]; } } /** * 分发OCR生成任务到队列 */ private function dispatchOcrGenerationJob( int $ocrRecordId, array $questions, string $gradeLevel, string $subject, string $callbackUrl, string $taskId ): void { try { // 转换题目数据格式 $formattedQuestions = []; foreach ($questions as $q) { $formattedQuestions[] = [ 'id' => $q['id'] ?? uniqid(), 'content' => $q['content'] ?? '' ]; } // 直接调用QuestionBank API的异步端点,提供回调URL // 注意: baseUrl 已经包含 /api,所以这里只需要 /ocr/questions/generate-from-ocr $response = Http::timeout(60) ->post($this->baseUrl . '/ocr/questions/generate-from-ocr', [ 'ocr_record_id' => $ocrRecordId, 'questions' => $formattedQuestions, 'grade_level' => $gradeLevel, 'subject' => $subject, 'callback_url' => $callbackUrl ]); if (!$response->successful()) { Log::error('提交OCR题目生成任务失败', [ 'status' => $response->status(), 'body' => $response->body(), 'task_id' => $taskId ]); // 发送失败回调 $callbackData = [ 'task_id' => $taskId, 'ocr_record_id' => $ocrRecordId, 'status' => 'failed', 'error' => 'API调用失败: ' . $response->status(), 'timestamp' => now()->toISOString() ]; Http::timeout(10) ->post($callbackUrl, $callbackData); return; } $result = $response->json(); Log::info('OCR题目生成任务已提交到QuestionBank', [ 'task_id' => $taskId, 'questionbank_task_id' => $result['task_id'] ?? 'unknown', 'status' => $result['status'] ?? 'unknown', 'callback_url' => $callbackUrl ]); // QuestionBank API会异步处理并通过回调通知,这里不需要立即触发回调 // 回调会在题目生成完成后由QuestionBank API主动发送 } catch (\Exception $e) { Log::error('OCR生成任务处理失败', [ 'task_id' => $taskId, 'ocr_record_id' => $ocrRecordId, 'error' => $e->getMessage() ]); // 发送异常回调 try { $callbackData = [ 'task_id' => $taskId, 'ocr_record_id' => $ocrRecordId, 'status' => 'failed', 'error' => $e->getMessage(), 'timestamp' => now()->toISOString() ]; Http::timeout(10) ->post($callbackUrl, $callbackData); } catch (\Exception $callbackException) { Log::error('发送异常回调失败', [ 'error' => $callbackException->getMessage() ]); } } } /** * 动态生成回调URL * * @param string $routeName 路由名称 * @return string 完整的回调URL */ private function generateCallbackUrl(string $routeName): string { try { // 获取当前请求的域名 $appUrl = config('app.url', 'http://localhost'); // 如果是在命令行环境中运行,使用配置的域名 if (app()->runningInConsole()) { $domain = $appUrl; } else { $domain = request()->getSchemeAndHttpHost(); } // 确保domain不为null $domain = $domain ?? $appUrl; // 移除末尾的斜杠 $domain = rtrim($domain, '/'); // 生成完整的URL $callbackUrl = $domain . route($routeName, [], false); Log::info('生成回调URL', [ 'route_name' => $routeName, 'domain' => $domain, 'app_url' => $appUrl, 'callback_url' => $callbackUrl ]); return $callbackUrl; } catch (\Exception $e) { // 如果路由生成失败,使用默认URL Log::warning('路由生成失败,使用默认URL', [ 'route_name' => $routeName, 'error' => $e->getMessage() ]); $fallbackUrl = config('app.url', 'http://localhost'); if ($routeName === 'api.ocr.callback') { return $fallbackUrl . '/api/ocr-question-callback'; } return $fallbackUrl; } } /** * 根据OCR识别的题目生成题库题目(同步版本,向后兼容) * * @param array $questions OCR题目数组 [['question_number' => 1, 'question_text' => '...']] * @param string $gradeLevel 年级 * @param string $subject 科目 * @return array 生成结果 */ public function generateQuestionsFromOcr(array $questions, string $gradeLevel = '高一', string $subject = '数学'): array { return $this->generateQuestionsFromOcrAsync($questions, $gradeLevel, $subject); } /** * 检查题目生成任务状态 */ public function checkGenerationTaskStatus(string $taskId): array { return $this->getTaskStatus($taskId) ?? ['status' => 'unknown']; } /** * 获取知识点题目统计信息 * 根据知识点代码,统计该知识点及其子知识点和技能点的题目数量 */ public function getKnowledgePointStatistics(?string $kpCode = null): array { try { // 获取知识图谱数据和题目统计数据 $knowledgeGraph = $this->getKnowledgeGraph(); $nodes = $knowledgeGraph['nodes'] ?? []; $edges = $knowledgeGraph['edges'] ?? []; $questionStats = $this->getQuestionsStatisticsFromApi(); // 构建知识点索引 $nodeMap = []; foreach ($nodes as $node) { if (!empty($node['kp_code'])) { $nodeMap[$node['kp_code']] = $node; } } // 构建子知识点关系(从edges中提取) $childrenMap = []; $parentMap = []; foreach ($edges as $edge) { $source = $edge['source'] ?? ''; $target = $edge['target'] ?? ''; $direction = $edge['relation_direction'] ?? ''; if (!empty($source) && !empty($target)) { if ($direction === 'DOWNSTREAM') { $childrenMap[$source][] = $target; $parentMap[$target] = $source; } } } // 构建技能点统计 $skillStats = []; foreach ($questionStats as $stat) { $code = $stat['kp_code'] ?? ''; $skills = $stat['skills_list'] ?? []; if (!empty($code)) { foreach ($skills as $skillCode) { if (!empty($skillCode)) { if (!isset($skillStats[$code])) { $skillStats[$code] = []; } if (!isset($skillStats[$code][$skillCode])) { $skillStats[$code][$skillCode] = 0; } $skillStats[$code][$skillCode]++; } } } } // 如果指定了特定知识点,只返回该知识点的统计 if ($kpCode && isset($nodeMap[$kpCode])) { return $this->buildKnowledgePointStats($kpCode, $nodeMap, $childrenMap, $questionStats, $skillStats); } // 否则返回所有顶级知识点的统计 $result = []; $rootNodes = []; // 找出根节点(没有父节点的节点) foreach ($nodes as $node) { $code = $node['kp_code'] ?? ''; if (!empty($code) && !isset($parentMap[$code])) { $rootNodes[] = $code; } } foreach ($rootNodes as $rootCode) { $result[] = $this->buildKnowledgePointStats($rootCode, $nodeMap, $childrenMap, $questionStats, $skillStats); } // 按题目总数排序 usort($result, function($a, $b) { return ($b['total_questions'] ?? 0) <=> ($a['total_questions'] ?? 0); }); return $result; } catch (\Exception $e) { Log::error('获取知识点统计失败', [ 'kp_code' => $kpCode, 'error' => $e->getMessage() ]); return []; } } /** * 获取知识图谱数据 */ private function getKnowledgeGraph(): array { try { $knowledgeApiBase = config('services.knowledge_api.base_url', 'http://localhost:5011'); $response = Http::timeout(10) ->get($knowledgeApiBase . '/graph/export'); if ($response->successful()) { return $response->json(); } } catch (\Exception $e) { Log::error('获取知识图谱失败', ['error' => $e->getMessage()]); } return ['nodes' => [], 'edges' => []]; } /** * 从 API 获取题目统计 */ private function getQuestionsStatisticsFromApi(): array { try { // 调用题库 API 获取统计数据 $response = Http::timeout(30) ->get($this->baseUrl . '/questions/statistics'); if ($response->successful()) { $data = $response->json(); return $data['by_kp'] ?? []; } Log::warning('获取题目统计API失败', [ 'status' => $response->status(), 'url' => $this->baseUrl . '/questions/statistics' ]); } catch (\Exception $e) { Log::error('获取题目统计异常', [ 'error' => $e->getMessage(), 'url' => $this->baseUrl . '/questions/statistics' ]); } return []; } /** * 构建单个知识点的统计信息 */ private function buildKnowledgePointStats( string $kpCode, array $nodeMap, array $childrenMap, array $questionStats, array $skillStats ): array { $node = $nodeMap[$kpCode] ?? null; if (!$node) { return []; } // 获取直接子知识点 $children = $childrenMap[$kpCode] ?? []; $directQuestionCount = 0; // 查找当前知识点的题目数 foreach ($questionStats as $stat) { if ($stat['kp_code'] === $kpCode) { $directQuestionCount = $stat['question_count'] ?? 0; break; } } // 计算子知识点统计 $childrenStats = []; foreach ($children as $childCode) { $childStats = $this->buildKnowledgePointStats($childCode, $nodeMap, $childrenMap, $questionStats, $skillStats); if (!empty($childStats)) { $childrenStats[] = $childStats; } } // 计算子知识点题目总数 $childrenQuestionCount = 0; foreach ($childrenStats as $child) { $childrenQuestionCount += $child['total_questions'] ?? 0; } // 获取当前知识点的技能点统计 $skillsCount = 0; if (isset($skillStats[$kpCode])) { $skillsCount = array_sum($skillStats[$kpCode]); } return [ 'kp_code' => $kpCode, 'cn_name' => $node['cn_name'] ?? $kpCode, 'en_name' => $node['en_name'] ?? '', 'total_questions' => $directQuestionCount + $childrenQuestionCount, 'direct_questions' => $directQuestionCount, 'children_questions' => $childrenQuestionCount, 'children' => $childrenStats, 'skills_count' => count($skillStats[$kpCode] ?? []), 'skills_total_questions' => $skillsCount, 'skills' => array_map(function($skillCode, $count) use ($kpCode) { return [ 'kp_code' => $kpCode, 'skill_code' => $skillCode, 'question_count' => $count ]; }, array_keys($skillStats[$kpCode] ?? []), array_values($skillStats[$kpCode] ?? [])) ]; } /** * 获取所有试卷列表 */ public function getAllPapers(): array { try { $response = Http::timeout(10) ->get($this->baseUrl . '/papers'); if ($response->successful()) { return $response->json('data', []); } Log::warning('获取试卷列表失败', [ 'status' => $response->status(), 'response' => $response->body(), ]); return []; } catch (\Exception $e) { Log::error('获取试卷列表异常', [ 'error' => $e->getMessage(), ]); return []; } } /** * 获取指定试卷的题目 */ public function getPaperQuestions(string $paperId): array { try { $response = Http::timeout(10) ->get($this->baseUrl . '/papers/' . $paperId . '/questions'); if ($response->successful()) { return $response->json('data', []); } Log::warning('获取试卷题目失败', [ 'paper_id' => $paperId, 'status' => $response->status(), 'response' => $response->body(), ]); return []; } catch (\Exception $e) { Log::error('获取试卷题目异常', [ 'paper_id' => $paperId, 'error' => $e->getMessage(), ]); return []; } } }