|
@@ -29,7 +29,8 @@ class ExamPdfExportService
|
|
|
private readonly QuestionBankService $questionBankService,
|
|
private readonly QuestionBankService $questionBankService,
|
|
|
private readonly QuestionServiceApi $questionServiceApi,
|
|
private readonly QuestionServiceApi $questionServiceApi,
|
|
|
private readonly PdfStorageService $pdfStorageService,
|
|
private readonly PdfStorageService $pdfStorageService,
|
|
|
- private readonly MasteryCalculator $masteryCalculator
|
|
|
|
|
|
|
+ private readonly MasteryCalculator $masteryCalculator,
|
|
|
|
|
+ private readonly PdfMerger $pdfMerger
|
|
|
) {}
|
|
) {}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -64,6 +65,164 @@ class ExamPdfExportService
|
|
|
return $url;
|
|
return $url;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 生成合并PDF(试卷 + 判卷)
|
|
|
|
|
+ * 先分别生成两个PDF,然后合并
|
|
|
|
|
+ */
|
|
|
|
|
+ public function generateMergedPdf(string $paperId): ?string
|
|
|
|
|
+ {
|
|
|
|
|
+ Log::info('generateMergedPdf 开始:', ['paper_id' => $paperId]);
|
|
|
|
|
+
|
|
|
|
|
+ $tempDir = storage_path("app/temp");
|
|
|
|
|
+ if (!is_dir($tempDir)) {
|
|
|
|
|
+ mkdir($tempDir, 0755, true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $examPdfPath = null;
|
|
|
|
|
+ $gradingPdfPath = null;
|
|
|
|
|
+ $mergedPdfPath = null;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 先生成试卷PDF
|
|
|
|
|
+ $examPdfUrl = $this->generateExamPdf($paperId);
|
|
|
|
|
+ if (!$examPdfUrl) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: 生成试卷PDF失败', ['paper_id' => $paperId]);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 再生成判卷PDF
|
|
|
|
|
+ $gradingPdfUrl = $this->generateGradingPdf($paperId);
|
|
|
|
|
+ if (!$gradingPdfUrl) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: 生成判卷PDF失败', ['paper_id' => $paperId]);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 【修复】下载PDF文件到本地临时目录
|
|
|
|
|
+ Log::info('开始下载PDF文件到本地', [
|
|
|
|
|
+ 'exam_url' => $examPdfUrl,
|
|
|
|
|
+ 'grading_url' => $gradingPdfUrl
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $examPdfPath = $tempDir . "/{$paperId}_exam.pdf";
|
|
|
|
|
+ $gradingPdfPath = $tempDir . "/{$paperId}_grading.pdf";
|
|
|
|
|
+
|
|
|
|
|
+ // 下载试卷PDF
|
|
|
|
|
+ $examContent = Http::get($examPdfUrl)->body();
|
|
|
|
|
+ if (empty($examContent)) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: 下载试卷PDF失败', ['url' => $examPdfUrl]);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ file_put_contents($examPdfPath, $examContent);
|
|
|
|
|
+
|
|
|
|
|
+ // 下载判卷PDF
|
|
|
|
|
+ $gradingContent = Http::get($gradingPdfUrl)->body();
|
|
|
|
|
+ if (empty($gradingContent)) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: 下载判卷PDF失败', ['url' => $gradingPdfUrl]);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ file_put_contents($gradingPdfPath, $gradingContent);
|
|
|
|
|
+
|
|
|
|
|
+ Log::info('PDF文件下载完成', [
|
|
|
|
|
+ 'exam_size' => filesize($examPdfPath),
|
|
|
|
|
+ 'grading_size' => filesize($gradingPdfPath)
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // 合并PDF文件
|
|
|
|
|
+ $mergedPdfPath = $tempDir . "/{$paperId}_merged.pdf";
|
|
|
|
|
+ $merged = $this->pdfMerger->merge([$examPdfPath, $gradingPdfPath], $mergedPdfPath);
|
|
|
|
|
+
|
|
|
|
|
+ if (!$merged) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: PDF文件合并失败', [
|
|
|
|
|
+ 'tool' => $this->pdfMerger->getMergeTool()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 读取合并后的PDF内容并上传到云存储
|
|
|
|
|
+ $mergedPdfContent = file_get_contents($mergedPdfPath);
|
|
|
|
|
+ $path = "exams/{$paperId}_all.pdf";
|
|
|
|
|
+ $mergedUrl = $this->pdfStorageService->put($path, $mergedPdfContent);
|
|
|
|
|
+
|
|
|
|
|
+ if (!$mergedUrl) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: 保存合并PDF失败', ['path' => $path]);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 保存到数据库的all_pdf_url字段
|
|
|
|
|
+ $this->saveAllPdfUrlToDatabase($paperId, $mergedUrl);
|
|
|
|
|
+
|
|
|
|
|
+ Log::info('generateMergedPdf 完成:', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'url' => $mergedUrl,
|
|
|
|
|
+ 'tool' => $this->pdfMerger->getMergeTool()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ return $mergedUrl;
|
|
|
|
|
+
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: 生成合并PDF失败', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ 'trace' => $e->getTraceAsString(),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ // 【修复】清理临时文件
|
|
|
|
|
+ $tempFiles = [$examPdfPath, $gradingPdfPath, $mergedPdfPath];
|
|
|
|
|
+ foreach ($tempFiles as $file) {
|
|
|
|
|
+ if ($file && file_exists($file)) {
|
|
|
|
|
+ @unlink($file);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Log::debug('清理临时文件完成');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 将URL转换为本地文件路径
|
|
|
|
|
+ */
|
|
|
|
|
+ private function convertUrlToPath(string $url): ?string
|
|
|
|
|
+ {
|
|
|
|
|
+ // 如果是本地存储,URL格式类似:/storage/exams/paper_id_exam.pdf
|
|
|
|
|
+ // 需要转换为绝对路径
|
|
|
|
|
+ if (strpos($url, '/storage/') === 0) {
|
|
|
|
|
+ return public_path(ltrim($url, '/'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果是完整路径,直接返回
|
|
|
|
|
+ if (strpos($url, '/') === 0 && file_exists($url)) {
|
|
|
|
|
+ return $url;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果是相对路径,转换为绝对路径
|
|
|
|
|
+ $path = public_path($url);
|
|
|
|
|
+ if (file_exists($path)) {
|
|
|
|
|
+ return $path;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 保存合并PDF URL到数据库
|
|
|
|
|
+ */
|
|
|
|
|
+ private function saveAllPdfUrlToDatabase(string $paperId, string $url): void
|
|
|
|
|
+ {
|
|
|
|
|
+ try {
|
|
|
|
|
+ \App\Models\Paper::where('paper_id', $paperId)->update([
|
|
|
|
|
+ 'all_pdf_url' => $url
|
|
|
|
|
+ ]);
|
|
|
|
|
+ Log::debug('保存all_pdf_url成功', ['paper_id' => $paperId, 'url' => $url]);
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ Log::error('保存all_pdf_url失败', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'url' => $url,
|
|
|
|
|
+ 'error' => $e->getMessage()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ throw $e;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 生成学情分析 PDF
|
|
* 生成学情分析 PDF
|
|
|
*/
|
|
*/
|
|
@@ -1227,4 +1386,176 @@ class ExamPdfExportService
|
|
|
|
|
|
|
|
return 1; // 默认一级
|
|
return 1; // 默认一级
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 构建题目数据(用于PDF生成)
|
|
|
|
|
+ */
|
|
|
|
|
+ private function buildQuestionsData(Paper $paper): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $paperQuestions = $paper->questions()->orderBy('question_number')->get();
|
|
|
|
|
+ $questionsData = [];
|
|
|
|
|
+ foreach ($paperQuestions as $pq) {
|
|
|
|
|
+ $questionsData[] = [
|
|
|
|
|
+ 'id' => $pq->question_bank_id,
|
|
|
|
|
+ 'kp_code' => $pq->knowledge_point,
|
|
|
|
|
+ 'question_type' => $pq->question_type ?? 'answer',
|
|
|
|
|
+ 'stem' => $pq->question_text ?? '题目内容缺失',
|
|
|
|
|
+ 'solution' => $pq->solution ?? '',
|
|
|
|
|
+ 'answer' => $pq->correct_answer ?? '',
|
|
|
|
|
+ 'difficulty' => $pq->difficulty ?? 0.5,
|
|
|
|
|
+ 'score' => $pq->score ?? 5,
|
|
|
|
|
+ 'tags' => '',
|
|
|
|
|
+ 'content' => $pq->question_text ?? '',
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取完整题目详情
|
|
|
|
|
+ if (!empty($questionsData)) {
|
|
|
|
|
+ $questionIds = array_column($questionsData, 'id');
|
|
|
|
|
+ $questionsResponse = $this->questionServiceApi->getQuestionsByIds($questionIds);
|
|
|
|
|
+ $responseData = $questionsResponse['data'] ?? [];
|
|
|
|
|
+
|
|
|
|
|
+ if (!empty($responseData)) {
|
|
|
|
|
+ $responseDataMap = [];
|
|
|
|
|
+ foreach ($responseData as $respQ) {
|
|
|
|
|
+ $responseDataMap[$respQ['id']] = $respQ;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $questionsData = array_map(function($q) use ($responseDataMap) {
|
|
|
|
|
+ if (isset($responseDataMap[$q['id']])) {
|
|
|
|
|
+ $apiData = $responseDataMap[$q['id']];
|
|
|
|
|
+ $q['stem'] = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? '';
|
|
|
|
|
+ $q['content'] = $q['stem'];
|
|
|
|
|
+ $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
|
|
|
|
|
+ $q['solution'] = $apiData['solution'] ?? $q['solution'] ?? '';
|
|
|
|
|
+ $q['tags'] = $apiData['tags'] ?? $q['tags'] ?? '';
|
|
|
|
|
+ $q['options'] = $apiData['options'] ?? [];
|
|
|
|
|
+ }
|
|
|
|
|
+ return $q;
|
|
|
|
|
+ }, $questionsData);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 按题型分类
|
|
|
|
|
+ $classified = ['choice' => [], 'fill' => [], 'answer' => []];
|
|
|
|
|
+ foreach ($questionsData as $q) {
|
|
|
|
|
+ $type = $this->determineQuestionType($q);
|
|
|
|
|
+ $classified[$type][] = (object) $q;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $classified;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取学生信息
|
|
|
|
|
+ */
|
|
|
|
|
+ private function getStudentInfo(?string $studentId): array
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!$studentId) {
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'name' => '未知学生',
|
|
|
|
|
+ 'grade' => '未知年级',
|
|
|
|
|
+ 'class' => '未知班级'
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ $student = DB::table('students')
|
|
|
|
|
+ ->where('student_id', $studentId)
|
|
|
|
|
+ ->first();
|
|
|
|
|
+
|
|
|
|
|
+ if ($student) {
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'name' => $student->name ?? $studentId,
|
|
|
|
|
+ 'grade' => $student->grade ?? '未知',
|
|
|
|
|
+ 'class' => $student->class ?? '未知'
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ Log::warning('获取学生信息失败', [
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'error' => $e->getMessage()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'name' => $studentId,
|
|
|
|
|
+ 'grade' => '未知',
|
|
|
|
|
+ 'class' => '未知'
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取教师信息
|
|
|
|
|
+ */
|
|
|
|
|
+ private function getTeacherInfo(?string $teacherId): array
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!$teacherId) {
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'name' => '未知老师',
|
|
|
|
|
+ 'subject' => '数学'
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ $teacher = DB::table('teachers')
|
|
|
|
|
+ ->where('teacher_id', $teacherId)
|
|
|
|
|
+ ->first();
|
|
|
|
|
+
|
|
|
|
|
+ if ($teacher) {
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'name' => $teacher->name ?? $teacherId,
|
|
|
|
|
+ 'subject' => $teacher->subject ?? '数学'
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Exception $e) {
|
|
|
|
|
+ Log::warning('获取教师信息失败', [
|
|
|
|
|
+ 'teacher_id' => $teacherId,
|
|
|
|
|
+ 'error' => $e->getMessage()
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'name' => $teacherId,
|
|
|
|
|
+ 'subject' => '数学'
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 判断题目类型
|
|
|
|
|
+ */
|
|
|
|
|
+ private function determineQuestionType(array $question): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $stem = $question['stem'] ?? $question['content'] ?? '';
|
|
|
|
|
+ $tags = $question['tags'] ?? '';
|
|
|
|
|
+
|
|
|
|
|
+ // 根据题干内容判断选择题
|
|
|
|
|
+ if (is_string($stem)) {
|
|
|
|
|
+ $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem) || preg_match('/^A[\.\s]/', $stem);
|
|
|
|
|
+ $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem) || preg_match('/^B[\.\s]/', $stem);
|
|
|
|
|
+ $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem) || preg_match('/^C[\.\s]/', $stem);
|
|
|
|
|
+ $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem) || preg_match('/^D[\.\s]/', $stem);
|
|
|
|
|
+
|
|
|
|
|
+ $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0);
|
|
|
|
|
+ if ($optionCount >= 2) {
|
|
|
|
|
+ return 'choice';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 检查是否有填空标记
|
|
|
|
|
+ if (preg_match('/(\s*)|\(\s*\)/', $stem)) {
|
|
|
|
|
+ return 'fill';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 根据已有类型字段判断
|
|
|
|
|
+ if (!empty($question['question_type'])) {
|
|
|
|
|
+ $type = strtolower(trim($question['question_type']));
|
|
|
|
|
+ if (in_array($type, ['choice', '选择题'])) return 'choice';
|
|
|
|
|
+ if (in_array($type, ['fill', '填空题'])) return 'fill';
|
|
|
|
|
+ if (in_array($type, ['answer', '解答题'])) return 'answer';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 默认返回解答题
|
|
|
|
|
+ return 'answer';
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|