|
@@ -3,11 +3,17 @@
|
|
|
namespace App\Services;
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
use App\Http\Controllers\ExamPdfController;
|
|
use App\Http\Controllers\ExamPdfController;
|
|
|
|
|
+use App\Models\Paper;
|
|
|
|
|
+use App\Models\PaperQuestion;
|
|
|
|
|
+use App\Models\Student;
|
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Request;
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\File;
|
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
use Illuminate\Support\Facades\URL;
|
|
use Illuminate\Support\Facades\URL;
|
|
|
|
|
+use App\Services\LearningAnalyticsService;
|
|
|
|
|
+use App\Services\QuestionBankService;
|
|
|
|
|
+use App\Services\QuestionServiceApi;
|
|
|
use Symfony\Component\Process\Exception\ProcessSignaledException;
|
|
use Symfony\Component\Process\Exception\ProcessSignaledException;
|
|
|
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
|
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
|
|
use Symfony\Component\Process\Process;
|
|
use Symfony\Component\Process\Process;
|
|
@@ -15,10 +21,18 @@ use Symfony\Component\Process\Process;
|
|
|
class ExamPdfExportService
|
|
class ExamPdfExportService
|
|
|
{
|
|
{
|
|
|
private ExamPdfController $controller;
|
|
private ExamPdfController $controller;
|
|
|
|
|
+ private LearningAnalyticsService $learningAnalyticsService;
|
|
|
|
|
+ private QuestionBankService $questionBankService;
|
|
|
|
|
|
|
|
- public function __construct(ExamPdfController $controller)
|
|
|
|
|
|
|
+ public function __construct(
|
|
|
|
|
+ ExamPdfController $controller,
|
|
|
|
|
+ LearningAnalyticsService $learningAnalyticsService,
|
|
|
|
|
+ QuestionBankService $questionBankService
|
|
|
|
|
+ )
|
|
|
{
|
|
{
|
|
|
$this->controller = $controller;
|
|
$this->controller = $controller;
|
|
|
|
|
+ $this->learningAnalyticsService = $learningAnalyticsService;
|
|
|
|
|
+ $this->questionBankService = $questionBankService;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -37,6 +51,43 @@ class ExamPdfExportService
|
|
|
return $this->renderAndStore($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true);
|
|
return $this->renderAndStore($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 生成学情分析 PDF
|
|
|
|
|
+ */
|
|
|
|
|
+ public function generateAnalysisReportPdf(string $paperId, string $studentId): ?string
|
|
|
|
|
+ {
|
|
|
|
|
+ if (function_exists('set_time_limit')) {
|
|
|
|
|
+ @set_time_limit(240);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ $payload = $this->buildAnalysisPayload($paperId, $studentId);
|
|
|
|
|
+ if (!$payload) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $html = view('exam-analysis.pdf-report', $payload)->render();
|
|
|
|
|
+ $pdfBinary = $this->buildPdf($html);
|
|
|
|
|
+ if (!$pdfBinary) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $path = "analysis_reports/{$paperId}_{$studentId}.pdf";
|
|
|
|
|
+ Storage::disk('public')->put($path, $pdfBinary);
|
|
|
|
|
+
|
|
|
|
|
+ return URL::to(Storage::url($path));
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: 生成学情分析 PDF 失败', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ 'exception' => get_class($e),
|
|
|
|
|
+ 'trace' => $e->getTraceAsString(),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private function renderAndStore(
|
|
private function renderAndStore(
|
|
|
string $paperId,
|
|
string $paperId,
|
|
|
bool $includeAnswer,
|
|
bool $includeAnswer,
|
|
@@ -144,6 +195,16 @@ class ExamPdfExportService
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 为无权限环境设置可写的 HOME/XDG 目录,避免创建 /var/www/.local 报错
|
|
|
|
|
+ $runtimeHome = sys_get_temp_dir() . '/chrome-home';
|
|
|
|
|
+ $runtimeXdg = sys_get_temp_dir() . '/chrome-xdg';
|
|
|
|
|
+ if (!File::exists($runtimeHome)) {
|
|
|
|
|
+ @File::makeDirectory($runtimeHome, 0755, true);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!File::exists($runtimeXdg)) {
|
|
|
|
|
+ @File::makeDirectory($runtimeXdg, 0755, true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
$process = new Process([
|
|
$process = new Process([
|
|
|
$chromeBinary,
|
|
$chromeBinary,
|
|
|
'--headless',
|
|
'--headless',
|
|
@@ -166,11 +227,15 @@ class ExamPdfExportService
|
|
|
'--no-default-browser-check',
|
|
'--no-default-browser-check',
|
|
|
'--disable-crash-reporter',
|
|
'--disable-crash-reporter',
|
|
|
'--disable-print-preview',
|
|
'--disable-print-preview',
|
|
|
|
|
+ '--disable-features=PrintHeaderFooter',
|
|
|
'--user-data-dir=' . $userDataDir,
|
|
'--user-data-dir=' . $userDataDir,
|
|
|
'--print-to-pdf=' . $tmpPdf,
|
|
'--print-to-pdf=' . $tmpPdf,
|
|
|
'--print-to-pdf-no-header',
|
|
'--print-to-pdf-no-header',
|
|
|
'--allow-file-access-from-files',
|
|
'--allow-file-access-from-files',
|
|
|
'file://' . $htmlPath,
|
|
'file://' . $htmlPath,
|
|
|
|
|
+ ], null, [
|
|
|
|
|
+ 'HOME' => $runtimeHome,
|
|
|
|
|
+ 'XDG_RUNTIME_DIR' => $runtimeXdg,
|
|
|
]);
|
|
]);
|
|
|
$process->setTimeout(60);
|
|
$process->setTimeout(60);
|
|
|
|
|
|
|
@@ -319,6 +384,197 @@ class ExamPdfExportService
|
|
|
return $pdfBinary ?: null;
|
|
return $pdfBinary ?: null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private function buildAnalysisPayload(string $paperId, string $studentId): ?array
|
|
|
|
|
+ {
|
|
|
|
|
+ $paper = Paper::with(['questions' => function ($query) {
|
|
|
|
|
+ $query->orderBy('question_number');
|
|
|
|
|
+ }])->find($paperId);
|
|
|
|
|
+ if (!$paper) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: 未找到试卷,无法生成学情报告', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $student = Student::find($studentId);
|
|
|
|
|
+ $studentInfo = [
|
|
|
|
|
+ 'id' => $student?->student_id ?? $studentId,
|
|
|
|
|
+ 'name' => $student?->name ?? $studentId,
|
|
|
|
|
+ 'grade' => $student?->grade ?? '未知年级',
|
|
|
|
|
+ 'class' => $student?->class_name ?? '未知班级',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ // 调用学习分析服务获取本卷分析与掌握度
|
|
|
|
|
+ $analysisData = [];
|
|
|
|
|
+ if (!empty($paper->analysis_id)) {
|
|
|
|
|
+ $analysis = $this->learningAnalyticsService->getAnalysisResult($paper->analysis_id);
|
|
|
|
|
+ if (!empty($analysis['data'])) {
|
|
|
|
|
+ $analysisData = $analysis['data'];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $masteryData = [];
|
|
|
|
|
+ $masteryResponse = $this->learningAnalyticsService->getStudentMastery($studentId);
|
|
|
|
|
+ if (!empty($masteryResponse['data'])) {
|
|
|
|
|
+ $masteryData = $masteryResponse['data'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $recommendations = [];
|
|
|
|
|
+ $recommendationResponse = $this->learningAnalyticsService->getLearningRecommendations($studentId);
|
|
|
|
|
+ if (!empty($recommendationResponse['data'])) {
|
|
|
|
|
+ $recommendations = $recommendationResponse['data'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $kpNameMap = $this->buildKnowledgePointNameMap();
|
|
|
|
|
+
|
|
|
|
|
+ // 预取题库详情用于解析/解题思路
|
|
|
|
|
+ $questionDetails = [];
|
|
|
|
|
+ $questionIds = $paper->questions->pluck('question_id')->filter()->unique()->values();
|
|
|
|
|
+ foreach ($questionIds as $qid) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ $detail = $this->questionBankService->getQuestion((string) $qid);
|
|
|
|
|
+ if (!empty($detail)) {
|
|
|
|
|
+ $questionDetails[(string) $qid] = $detail;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ Log::warning('ExamPdfExportService: 获取题库题目详情失败', [
|
|
|
|
|
+ 'question_id' => $qid,
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $questions = $paper->questions
|
|
|
|
|
+ ->map(function (PaperQuestion $question) use ($kpNameMap, $questionDetails) {
|
|
|
|
|
+ $kpCode = $question->knowledge_point ?? '';
|
|
|
|
|
+ $kpName = $kpNameMap[$kpCode] ?? $kpCode ?: '未标注';
|
|
|
|
|
+ $detail = $questionDetails[(string) ($question->question_id ?? '')] ?? [];
|
|
|
|
|
+ $solution = $detail['solution'] ?? $detail['解析'] ?? $detail['analysis'] ?? null;
|
|
|
|
|
+ $typeRaw = $detail['question_type'] ?? $detail['type'] ?? $question->question_type ?? '';
|
|
|
|
|
+ $normalizedType = $this->normalizeQuestionType($typeRaw);
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'question_number' => $question->question_number,
|
|
|
|
|
+ 'question_text' => is_array($question->question_text) ? json_encode($question->question_text, JSON_UNESCAPED_UNICODE) : ($question->question_text ?? ''),
|
|
|
|
|
+ 'question_type' => $normalizedType,
|
|
|
|
|
+ 'knowledge_point' => $kpCode,
|
|
|
|
|
+ 'knowledge_point_name' => $kpName,
|
|
|
|
|
+ 'score' => $question->score,
|
|
|
|
|
+ 'solution' => $solution,
|
|
|
|
|
+ ];
|
|
|
|
|
+ })
|
|
|
|
|
+ ->sortBy('question_number')
|
|
|
|
|
+ ->values()
|
|
|
|
|
+ ->toArray();
|
|
|
|
|
+
|
|
|
|
|
+ $questionInsights = $analysisData['question_results'] ?? [];
|
|
|
|
|
+ $masterySummary = $this->buildMasterySummary($masteryData, $kpNameMap);
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'paper' => [
|
|
|
|
|
+ 'id' => $paper->paper_id,
|
|
|
|
|
+ 'name' => $paper->paper_name,
|
|
|
|
|
+ 'total_questions' => $paper->question_count,
|
|
|
|
|
+ 'total_score' => $paper->total_score,
|
|
|
|
|
+ 'created_at' => $paper->created_at,
|
|
|
|
|
+ ],
|
|
|
|
|
+ 'student' => $studentInfo,
|
|
|
|
|
+ 'questions' => $questions,
|
|
|
|
|
+ 'mastery' => $masterySummary,
|
|
|
|
|
+ 'question_insights' => $questionInsights,
|
|
|
|
|
+ 'recommendations' => $recommendations,
|
|
|
|
|
+ 'analysis_data' => $analysisData,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function buildKnowledgePointNameMap(): array
|
|
|
|
|
+ {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 优先使用 QuestionServiceApi(已有知识点名称缓存)
|
|
|
|
|
+ if (class_exists(QuestionServiceApi::class)) {
|
|
|
|
|
+ /** @var QuestionServiceApi $service */
|
|
|
|
|
+ $service = app(QuestionServiceApi::class);
|
|
|
|
|
+ $options = $service->getKnowledgePointOptions();
|
|
|
|
|
+ if (!empty($options)) {
|
|
|
|
|
+ return $options;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 退回 QuestionBankService(可能缺少此方法)
|
|
|
|
|
+ if (method_exists($this->questionBankService, 'getKnowledgePointOptions')) {
|
|
|
|
|
+ $options = $this->questionBankService->getKnowledgePointOptions();
|
|
|
|
|
+ $map = [];
|
|
|
|
|
+ foreach ($options as $item) {
|
|
|
|
|
+ if (is_array($item)) {
|
|
|
|
|
+ $code = $item['kp_code'] ?? null;
|
|
|
|
|
+ $name = $item['kp_name'] ?? $item['name'] ?? null;
|
|
|
|
|
+ if ($code && $name) {
|
|
|
|
|
+ $map[$code] = $name;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!empty($map)) {
|
|
|
|
|
+ return $map;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ Log::warning('ExamPdfExportService: 获取知识点名称失败,退回使用编码', [
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function buildMasterySummary(array $masteryData, array $kpNameMap): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $items = [];
|
|
|
|
|
+ $total = 0;
|
|
|
|
|
+ $count = 0;
|
|
|
|
|
+ $hasMap = !empty($kpNameMap);
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($masteryData as $row) {
|
|
|
|
|
+ $code = $row['kp_code'] ?? null;
|
|
|
|
|
+ if ($hasMap && $code && !isset($kpNameMap[$code])) {
|
|
|
|
|
+ // 不在知识图谱中的知识点不呈现
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $name = $row['kp_name'] ?? ($code ? ($kpNameMap[$code] ?? $code) : '未知知识点');
|
|
|
|
|
+ $level = (float) ($row['mastery_level'] ?? 0);
|
|
|
|
|
+ $delta = $row['mastery_change'] ?? null;
|
|
|
|
|
+ $items[] = [
|
|
|
|
|
+ 'kp_code' => $code,
|
|
|
|
|
+ 'kp_name' => $name,
|
|
|
|
|
+ 'mastery_level' => $level,
|
|
|
|
|
+ 'mastery_change' => $delta,
|
|
|
|
|
+ ];
|
|
|
|
|
+ $total += $level;
|
|
|
|
|
+ $count++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $average = $count > 0 ? round($total / $count, 2) : null;
|
|
|
|
|
+
|
|
|
|
|
+ // 按掌握度从低到高排序,便于突出薄弱点
|
|
|
|
|
+ usort($items, fn($a, $b) => ($a['mastery_level'] <=> $b['mastery_level']));
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'items' => $items,
|
|
|
|
|
+ 'average' => $average,
|
|
|
|
|
+ 'weak_list' => array_slice($items, 0, 5),
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function normalizeQuestionType(?string $type): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $t = strtolower(trim((string) $type));
|
|
|
|
|
+ return match (true) {
|
|
|
|
|
+ str_contains($t, 'choice') || str_contains($t, '选择') => 'choice',
|
|
|
|
|
+ str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空') => 'fill',
|
|
|
|
|
+ default => 'answer',
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private function ensureUtf8Html(string $html): string
|
|
private function ensureUtf8Html(string $html): string
|
|
|
{
|
|
{
|
|
|
$meta = '<meta charset="UTF-8">';
|
|
$meta = '<meta charset="UTF-8">';
|