yemeishu 2 недель назад
Родитель
Сommit
73e1bde024

+ 324 - 0
app/Http/Controllers/Api/IntelligentExamController.php

@@ -0,0 +1,324 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Models\Paper;
+use App\Models\PaperQuestion;
+use App\Services\LearningAnalyticsService;
+use App\Services\ExamPdfExportService;
+use App\Services\QuestionBankService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\URL;
+
+class IntelligentExamController extends Controller
+{
+    private LearningAnalyticsService $learningAnalyticsService;
+    private QuestionBankService $questionBankService;
+    private ExamPdfExportService $pdfExportService;
+
+    public function __construct(
+        LearningAnalyticsService $learningAnalyticsService,
+        QuestionBankService $questionBankService,
+        ExamPdfExportService $pdfExportService
+    ) {
+        $this->learningAnalyticsService = $learningAnalyticsService;
+        $this->questionBankService = $questionBankService;
+        $this->pdfExportService = $pdfExportService;
+    }
+
+    /**
+     * 外部API:生成智能试卷,保存并返回 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' => 'required|array|min:1',
+            '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();
+
+        $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);
+            }
+
+            $paperPayload = $this->buildPaperPayload($paperId);
+
+            // 生成真实 PDF(试卷 + 判卷),若失败则回退到 HTML 预览
+            $pdfUrl = $this->pdfExportService->generateExamPdf($paperId)
+                ?? $this->questionBankService->exportExamToPdf($paperId)
+                ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']);
+
+            $gradingUrl = $this->pdfExportService->generateGradingPdf($paperId)
+                ?? route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]);
+
+            $payload = [
+                'success' => true,
+                'message' => '智能试卷生成成功',
+                'data' => [
+                    'paper' => $paperPayload,
+                    'pdf_url' => $pdfUrl,
+                    'grading_url' => $gradingUrl,
+                    'stats' => $result['stats'] ?? null,
+                ],
+            ];
+
+            // 返回不转义的完整 URL
+            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);
+        }
+    }
+
+    /**
+     * 兼容字符串/数组入参
+     */
+    private function normalizePayload(array $payload): array
+    {
+        if (isset($payload['kp_codes']) && is_string($payload['kp_codes'])) {
+            $payload['kp_codes'] = array_values(array_filter(array_map('trim', explode(',', $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
+    {
+        $defaults = [
+            '选择题' => 40,
+            '填空题' => 30,
+            '解答题' => 30,
+        ];
+
+        $normalized = [];
+        foreach ($input as $key => $value) {
+            if (!is_numeric($value)) {
+                continue;
+            }
+            $type = $this->normalizeQuestionTypeKey($key);
+            if ($type) {
+                $normalized[$type] = (float) $value;
+            }
+        }
+
+        return array_merge($defaults, $normalized);
+    }
+
+    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 buildPaperPayload(string $paperId): array
+    {
+        $paper = Paper::with('questions')->find($paperId);
+        $questions = $paper ? $paper->questions : collect();
+
+        return [
+            '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 ?? '基础',
+            'questions' => $questions->map(function (PaperQuestion $q) {
+                return [
+                    'question_bank_id' => $q->question_bank_id,
+                    'question_number' => $q->question_number,
+                    'question_type' => $q->question_type,
+                    'knowledge_point' => $q->knowledge_point,
+                    'difficulty' => $q->difficulty,
+                    'score' => $q->score,
+                    'estimated_time' => $q->estimated_time,
+                ];
+            })->toArray(),
+        ];
+    }
+}

+ 156 - 0
app/Services/ExamPdfExportService.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace App\Services;
+
+use App\Http\Controllers\ExamPdfController;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\URL;
+use Symfony\Component\Process\Process;
+
+class ExamPdfExportService
+{
+    private ExamPdfController $controller;
+
+    public function __construct(ExamPdfController $controller)
+    {
+        $this->controller = $controller;
+    }
+
+    /**
+        生成试卷 PDF(不含答案)
+     */
+    public function generateExamPdf(string $paperId): ?string
+    {
+        return $this->renderAndStore($paperId, includeAnswer: false, suffix: 'exam');
+    }
+
+    /**
+        生成判卷 PDF(含答案与解析)
+     */
+    public function generateGradingPdf(string $paperId): ?string
+    {
+        return $this->renderAndStore($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true);
+    }
+
+    private function renderAndStore(
+        string $paperId,
+        bool $includeAnswer,
+        string $suffix,
+        bool $useGradingView = false
+    ): ?string {
+        try {
+            $html = $this->renderHtml($paperId, $includeAnswer, $useGradingView);
+            if (!$html) {
+                return null;
+            }
+
+            $pdfBinary = $this->buildPdf($html);
+            if (!$pdfBinary) {
+                return null;
+            }
+
+            $path = "exams/{$paperId}_{$suffix}.pdf";
+            Storage::disk('public')->put($path, $pdfBinary);
+
+            return URL::to(Storage::url($path));
+        } catch (\Throwable $e) {
+            Log::error('ExamPdfExportService: 生成 PDF 失败', [
+                'paper_id' => $paperId,
+                'suffix' => $suffix,
+                'error' => $e->getMessage()
+            ]);
+            return null;
+        }
+    }
+
+    private function renderHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
+    {
+        // 复用已有控制器的渲染逻辑,保证版式一致
+        $request = Request::create(
+            '/admin/intelligent-exam/' . ($useGradingView ? 'grading' : 'pdf') . '/' . $paperId,
+            'GET',
+            ['answer' => $includeAnswer ? 'true' : 'false']
+        );
+
+        $view = $useGradingView
+            ? $this->controller->showGrading($request, $paperId)
+            : $this->controller->show($request, $paperId);
+
+        if (is_object($view) && method_exists($view, 'render')) {
+            return $this->ensureUtf8Html($view->render());
+        }
+
+        return null;
+    }
+
+    private function buildPdf(string $html): ?string
+    {
+        // 使用无头 Chrome 渲染 HTML,保留前端样式并彻底解决大量空白页问题
+        $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_') . '.html';
+        $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_') . '.pdf';
+
+        file_put_contents($tmpHtml, $this->ensureUtf8Html($html));
+
+        $chromeBinary = env('PDF_CHROME_BINARY');
+        if (!$chromeBinary) {
+            // 默认优先 Mac,本地开发;不存在则尝试常见 Linux 路径
+            $candidates = [
+                '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+                '/usr/bin/chromium-browser',
+                '/usr/bin/chromium',
+                '/usr/bin/google-chrome',
+            ];
+            foreach ($candidates as $path) {
+                if (is_file($path) && is_executable($path)) {
+                    $chromeBinary = $path;
+                    break;
+                }
+            }
+        }
+
+        if (!$chromeBinary) {
+            Log::error('ExamPdfExportService: 未找到可用的 Chrome/Chromium 可执行文件');
+            return null;
+        }
+        $process = new Process([
+            $chromeBinary,
+            '--headless',
+            '--disable-gpu',
+            '--no-sandbox',
+            '--print-to-pdf=' . $tmpPdf,
+            '--print-to-pdf-no-header',
+            '--allow-file-access-from-files',
+            'file://' . $tmpHtml,
+        ]);
+        $process->setTimeout(30);
+        $process->run();
+
+        if (!$process->isSuccessful() || !file_exists($tmpPdf)) {
+            Log::error('ExamPdfExportService: Chrome 渲染失败', [
+                'error' => $process->getErrorOutput(),
+                'output' => $process->getOutput(),
+            ]);
+            @unlink($tmpHtml);
+            @unlink($tmpPdf);
+            return null;
+        }
+
+        $pdfBinary = file_get_contents($tmpPdf);
+        @unlink($tmpHtml);
+        @unlink($tmpPdf);
+
+        return $pdfBinary ?: null;
+    }
+
+    private function ensureUtf8Html(string $html): string
+    {
+        $meta = '<meta charset="UTF-8">';
+        if (stripos($html, '<head>') !== false) {
+            return preg_replace('/<head>/i', "<head>{$meta}", $html, 1);
+        }
+        return $meta . $html;
+    }
+
+}

+ 2 - 1
resources/views/components/exam/paper-body.blade.php

@@ -21,7 +21,8 @@
     };
 
     $renderBoxes = function($num) {
-        return str_repeat('<span style="display:inline-block;width:14px;height:14px;line-height:14px;border:1px solid #333;margin-right:4px;vertical-align:middle;"></span>', $num);
+        // 判卷方框放大 1.2 倍,保持单行布局
+        return str_repeat('<span style="display:inline-block;width:17px;height:17px;line-height:17px;border:1px solid #333;margin-right:4px;vertical-align:middle;"></span>', $num);
     };
 @endphp
 

+ 6 - 1
routes/api.php

@@ -1,8 +1,9 @@
 <?php
 
+use App\Http\Controllers\Api\IntelligentExamController;
 use App\Services\QuestionServiceApi;
-use Illuminate\Support\Facades\Route;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Route;
 use App\Events\QuestionGenerationCompleted;
 use App\Events\QuestionGenerationFailed;
 
@@ -402,6 +403,10 @@ Route::get('/knowledge-points', function (QuestionServiceApi $service) {
     }
 });
 
+// 智能出卷对外接口:生成试卷并返回PDF/判卷地址
+Route::post('/intelligent-exams', [IntelligentExamController::class, 'store'])
+    ->name('api.intelligent-exams.store');
+
 /*
 |--------------------------------------------------------------------------
 | MathRecSys 集成 API 路由