소스 검색

增加知识点讲解功能

yemeishu 6 일 전
부모
커밋
eab60dabf8

+ 14 - 5
.dockerignore

@@ -3,22 +3,31 @@ npm-debug.log
 yarn.lock
 package-lock.json
 bun.lock
-public/build/assets
+public/build
 public/hot
 public/storage
-storage/logs
-storage/framework/cache
-storage/framework/sessions
-storage/framework/views
+storage
 bootstrap/cache
 vendor
 .env
 .env.*
 docker-compose.yml
+docker-compose*.yml
 Dockerfile
 .git
+.codex_worktrees
 .gitignore
 README.md
 database
 tests
 phpunit.xml
+temp_backup
+database_backups
+*.tar.gz
+*.zip
+*.log
+.DS_Store
+.idea
+.vscode
+.phpunit.result.cache
+.playwright-cli

+ 0 - 1
.gitignore

@@ -34,7 +34,6 @@ Thumbs.db
 CLAUDE.md
 
 # 本地开发调试文件
-docker-compose.local.yml
 docker/Dockerfile.local
 docker/xdebug.ini
 docs/local-development.md

+ 122 - 6
Dockerfile

@@ -97,16 +97,14 @@ RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoload
 RUN npm config set registry https://registry.npmmirror.com && \
     npm install -g katex@0.16.9
 
-# 复制应用代码(排除 node_modules, vendor 等)
-COPY . .
+# 复制应用代码(排除 node_modules, vendor, storage 等)
+COPY --chown=www-data:www-data . .
 
 # 创建必要目录
 RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache
 
-# 设置权限
-RUN chown -R www-data:www-data /app && \
-    chmod -R 755 /app && \
-    chmod -R 777 /app/storage /app/bootstrap/cache
+# 设置运行时可写目录权限,避免 chown -R /app 复制出巨大镜像层
+RUN chmod -R 775 /app/storage /app/bootstrap/cache
 
 # 通用入口脚本(queue worker 模式仅执行命令,不启动 nginx)
 COPY docker-entrypoint.sh /usr/local/bin/
@@ -148,3 +146,121 @@ CMD ["nginx", "-g", "daemon off;"]
 FROM base-runtime AS pdfworker
 
 CMD ["php", "artisan", "queue:work", "--queue=pdf", "--sleep=3", "--tries=2", "--max-time=300", "--max-jobs=10"]
+
+# ========================================
+# 第五阶段:Slim Runtime(不安装 Chromium,配合 Gotenberg/远端 PDF worker)
+# ========================================
+FROM php:8.3-fpm-alpine AS base-runtime-local
+
+RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
+    apk add --no-cache \
+        git \
+        curl \
+        ca-certificates \
+        libzip-dev \
+        libpng-dev \
+        oniguruma-dev \
+        libjpeg-turbo-dev \
+        freetype-dev \
+        icu-dev \
+        sqlite-dev \
+        # 字体(HTML渲染/回退字体)
+        ttf-freefont \
+        font-noto-cjk \
+        font-noto \
+        ttf-dejavu \
+        fontconfig \
+        # Node.js(KaTeX 服务端渲染需要)
+        nodejs \
+        npm \
+        && rm -rf /var/cache/apk/* \
+        && fc-cache -fv
+
+RUN docker-php-ext-configure gd --with-freetype --with-jpeg && \
+    docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_sqlite gd zip intl pcntl
+
+RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
+    && pecl install redis \
+    && docker-php-ext-enable redis \
+    && apk del .build-deps
+
+COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+RUN composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/
+
+WORKDIR /app
+
+COPY composer.json composer.lock ./
+RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
+
+RUN npm config set registry https://registry.npmmirror.com && \
+    npm install -g katex@0.16.9 && \
+    npm cache clean --force
+
+RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache && \
+    chmod -R 775 storage bootstrap/cache
+
+COPY docker-entrypoint.sh /usr/local/bin/
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+
+# 热加载 app 目标:源码由 mount compose 挂载,不复制项目代码
+FROM base-runtime-local AS app-runtime-local-hot
+
+RUN apk add --no-cache nginx && \
+    mkdir -p /run/nginx /var/log/nginx
+
+COPY --from=frontend-builder /app/public/build ./public/build
+COPY docker/nginx.conf /etc/nginx/nginx.conf
+COPY docker/www.conf /usr/local/etc/php-fpm.d/www.conf
+
+EXPOSE 8000
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+    CMD curl -f http://127.0.0.1:8000/health || exit 1
+
+CMD ["nginx", "-g", "daemon off;"]
+
+# 热加载 worker 目标:源码由 mount compose 挂载
+FROM base-runtime-local AS worker-local-hot
+
+CMD ["php", "artisan", "queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]
+
+# Slim app 目标(无 Chromium)
+FROM base-runtime-local AS app-runtime-local
+
+RUN apk add --no-cache nginx && \
+    mkdir -p /run/nginx /var/log/nginx
+
+COPY --chown=www-data:www-data . .
+COPY --from=frontend-builder /app/public/build ./public/build
+RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache && \
+    chmod -R 775 storage bootstrap/cache
+
+RUN php artisan route:cache && \
+    php artisan view:cache && \
+    php artisan filament:upgrade || true
+
+COPY docker/nginx.conf /etc/nginx/nginx.conf
+COPY docker/www.conf /usr/local/etc/php-fpm.d/www.conf
+
+EXPOSE 8000
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+    CMD curl -f http://127.0.0.1:8000/health || exit 1
+
+CMD ["nginx", "-g", "daemon off;"]
+
+# Slim worker 目标(无 Chromium)
+FROM base-runtime-local AS worker-local
+
+COPY --chown=www-data:www-data . .
+RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache && \
+    chmod -R 775 storage bootstrap/cache
+
+CMD ["php", "artisan", "queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]
+
+# API 服务器目标(无 Chromium,PDF 走 Gotenberg/远端 worker 策略)
+FROM app-runtime-local AS app-runtime-api
+
+# API 服务器代码挂载目标(用于测试/联调热更新)
+FROM app-runtime-local-hot AS app-runtime-api-hot

+ 52 - 11
app/Http/Controllers/Api/IntelligentExamController.php

@@ -3,6 +3,7 @@
 namespace App\Http\Controllers\Api;
 
 use App\Jobs\AssembleExamTaskJob;
+use App\Jobs\GenerateKnowledgeExplanationTaskJob;
 use App\Http\Controllers\Controller;
 use App\Models\MistakeRecord;
 use App\Models\Paper;
@@ -10,6 +11,7 @@ use App\Models\Student;
 use App\Services\ExamPdfExportService;
 use App\Services\ExternalIdService;
 use App\Services\LearningAnalyticsService;
+use App\Services\KnowledgeExplanationService;
 use App\Services\PaperPayloadService;
 use App\Services\QuestionBankService;
 use App\Services\QuestionPayloadMapper;
@@ -32,6 +34,7 @@ class IntelligentExamController extends Controller
     private TaskManager $taskManager;
 
     private ExternalIdService $externalIdService;
+    private KnowledgeExplanationService $knowledgeExplanationService;
 
     public function __construct(
         LearningAnalyticsService $learningAnalyticsService,
@@ -39,7 +42,8 @@ class IntelligentExamController extends Controller
         ExamPdfExportService $pdfExportService,
         PaperPayloadService $paperPayloadService,
         TaskManager $taskManager,
-        ExternalIdService $externalIdService
+        ExternalIdService $externalIdService,
+        KnowledgeExplanationService $knowledgeExplanationService
     ) {
         $this->learningAnalyticsService = $learningAnalyticsService;
         $this->questionBankService = $questionBankService;
@@ -47,6 +51,7 @@ class IntelligentExamController extends Controller
         $this->paperPayloadService = $paperPayloadService;
         $this->taskManager = $taskManager;
         $this->externalIdService = $externalIdService;
+        $this->knowledgeExplanationService = $knowledgeExplanationService;
     }
 
     /**
@@ -87,7 +92,7 @@ class IntelligentExamController extends Controller
             'mistake_question_ids.*' => 'string',
             'callback_url' => 'nullable|url',  // 异步完成后推送通知的URL
             // 新增:组卷类型
-            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9,15,16',
+            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9,15,16,22',
             'exam_type' => 'nullable|string|in:general,diagnostic,practice,mistake,textbook,knowledge,knowledge_points',
             // 错题本类型专用参数
             'paper_ids' => 'nullable|array',
@@ -150,10 +155,33 @@ class IntelligentExamController extends Controller
             ], 422);
         }
 
+        if ($assembleType === 22) {
+            $kpCodes = array_merge(
+                is_array($data['kp_codes'] ?? null) ? $data['kp_codes'] : [],
+                is_array($data['kp_code_list'] ?? null) ? $data['kp_code_list'] : []
+            );
+            $kpCodes = array_values(array_unique(array_filter(array_map(
+                static fn ($v) => trim((string) $v),
+                $kpCodes
+            ), static fn ($v) => $v !== '')));
+
+            if ($kpCodes === []) {
+                return response()->json([
+                    'success' => false,
+                    'message' => '参数错误',
+                    'errors' => ['kp_codes' => ['assemble_type 为 22(知识点讲解)时,kp_codes/kp_code_list 不能为空']],
+                ], 422);
+            }
+
+            // 统一到 kp_codes,避免后续链路字段口径不一致
+            $data['kp_codes'] = $kpCodes;
+        }
+
         // API 固定题量:含按卷追练(5)、错题再练(15)、错题追练(16) 等,一律 default_total_questions,不使用请求题量参数
         $data['total_questions'] = (int) config('question_bank.default_total_questions');
-        // 预分配 paper_id,保证接口语义稳定(后续异步化时也可继续同步返回)
-        $reservedPaperId = $this->questionBankService->generatePaperId();
+        // 预分配ID:组卷类型使用 paper_id;知识点讲解类型使用 knowledge_id
+        $reservedPaperId = $assembleType === 22 ? null : $this->questionBankService->generatePaperId();
+        $reservedKnowledgeId = $assembleType === 22 ? $this->knowledgeExplanationService->generateKnowledgeId() : null;
         $this->ensureStudentTeacherRelation($data);
 
         // 【修改】使用series_id、semester_code和grade获取textbook_id
@@ -170,6 +198,7 @@ class IntelligentExamController extends Controller
 
         $taskPayload = array_merge($data, [
             'paper_id' => $reservedPaperId,
+            'knowledge_id' => $reservedKnowledgeId,
             'request_trace_id' => $requestTraceId,
             'request_started_at' => now()->toISOString(),
             'request_payload_snapshot_raw' => $requestPayloadSnapshotRaw,
@@ -182,6 +211,7 @@ class IntelligentExamController extends Controller
             'grade' => $taskPayload['grade'] ?? null,
             'assemble_type' => $assembleType,
             'paper_id' => $reservedPaperId,
+            'knowledge_id' => $reservedKnowledgeId,
             'textbook_id' => $taskPayload['textbook_id'] ?? null,
             'chapter_id_list' => $taskPayload['chapter_id_list'] ?? [],
             'kp_code_list' => $taskPayload['kp_code_list'] ?? [],
@@ -190,27 +220,38 @@ class IntelligentExamController extends Controller
         ]);
 
         try {
-            // 异步优化:同步仅返回 task_id/paper_id,重型组卷逻辑下沉到队列
+            // 异步优化:同步仅返回 task_id,重型逻辑下沉到队列
             $taskId = $this->taskManager->createTask(TaskManager::TASK_TYPE_EXAM, $taskPayload);
 
-            dispatch(new AssembleExamTaskJob($taskId));
+            if ($assembleType === 22) {
+                dispatch(new GenerateKnowledgeExplanationTaskJob($taskId));
+            } else {
+                dispatch(new AssembleExamTaskJob($taskId));
+            }
 
-            $codes = $this->paperPayloadService->generatePaperCodes($reservedPaperId);
+            $codes = $reservedPaperId ? $this->paperPayloadService->generatePaperCodes($reservedPaperId) : [
+                'exam_code' => null,
+                'grading_code' => null,
+                'paper_id_num' => null,
+            ];
             $payload = [
                 'success' => true,
-                'message' => '智能试卷任务已创建,正在后台组卷并生成PDF...',
+                'message' => $assembleType === 22
+                    ? '知识点讲解任务已创建,正在后台生成PDF...'
+                    : '智能试卷任务已创建,正在后台组卷并生成PDF...',
                 'data' => [
                     'task_id' => $taskId,
                     'paper_id' => $reservedPaperId,
+                    'knowledge_id' => $reservedKnowledgeId,
                     'status' => 'processing',
                     'exam_code' => $codes['exam_code'],
                     'grading_code' => $codes['grading_code'],
                     'paper_id_num' => $codes['paper_id_num'],
                     'exam_content' => [],
                     'urls' => [
-                        'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $reservedPaperId]),
-                        'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $reservedPaperId, 'answer' => 'false']),
-                        'knowledge_explanation_url' => route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $reservedPaperId]),
+                        'grading_url' => $reservedPaperId ? route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $reservedPaperId]) : null,
+                        'student_exam_url' => $reservedPaperId ? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $reservedPaperId, 'answer' => 'false']) : null,
+                        'knowledge_explanation_url' => $reservedPaperId ? route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $reservedPaperId]) : null,
                     ],
                     'pdfs' => [
                         'exam_paper_pdf' => null,

+ 88 - 0
app/Http/Controllers/ExamPdfController.php

@@ -3,7 +3,10 @@
 namespace App\Http\Controllers;
 
 use App\Jobs\RegeneratePdfJob;
+use App\Models\KnowledgeExplanation;
 use App\Models\Paper;
+use App\Services\ExamPdfExportService;
+use App\Services\KnowledgeExplanationService;
 use App\Services\PaperIdGenerator;
 use App\Support\PaperNaming;
 use App\Services\QuestionBankService;
@@ -1258,6 +1261,91 @@ class ExamPdfController extends Controller
         }
     }
 
+    /**
+     * 重新生成知识点讲解 PDF(保持同一个 knowledge_id)。
+     *
+     * @param  string  $knowledge_id
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function regenerateKnowledgeExplanationPdf(
+        Request $request,
+        string $knowledge_id,
+        KnowledgeExplanationService $knowledgeExplanationService,
+        ExamPdfExportService $pdfService
+    ) {
+        Log::info('RegenerateKnowledgeExplanationPdf: 开始重新生成PDF', ['knowledge_id' => $knowledge_id]);
+
+        if (empty($knowledge_id) || ! preg_match('/^knowledge_[1-9]\d{14}$/', $knowledge_id)) {
+            return response()->json([
+                'success' => false,
+                'message' => '无效的知识点讲解ID格式',
+                'knowledge_id' => $knowledge_id,
+            ], 400);
+        }
+
+        try {
+            $record = KnowledgeExplanation::query()
+                ->where('knowledge_id', $knowledge_id)
+                ->first();
+
+            if (! $record) {
+                return response()->json([
+                    'success' => false,
+                    'message' => '知识点讲解记录不存在',
+                    'knowledge_id' => $knowledge_id,
+                ], 404);
+            }
+
+            $record->update(['status' => 'processing']);
+            $knowledgePoints = $knowledgeExplanationService->rebuildKnowledgePointsForRecord($record->refresh());
+            $pdfUrl = $pdfService->generateKnowledgeExplanationStandalonePdf($record, $knowledgePoints);
+
+            if (! $pdfUrl) {
+                $record->update(['status' => 'failed']);
+
+                return response()->json([
+                    'success' => false,
+                    'message' => '知识点讲解PDF生成失败',
+                    'knowledge_id' => $knowledge_id,
+                ], 500);
+            }
+
+            $record->update([
+                'status' => 'completed',
+                'pdf_url' => $pdfUrl,
+                'generated_at' => now(),
+            ]);
+
+            Log::info('RegenerateKnowledgeExplanationPdf: PDF重新生成成功', [
+                'knowledge_id' => $knowledge_id,
+                'url' => $pdfUrl,
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'message' => '知识点讲解PDF重新生成成功',
+                'knowledge_id' => $knowledge_id,
+                'pdf_url' => $pdfUrl,
+            ]);
+        } catch (\Throwable $e) {
+            KnowledgeExplanation::query()
+                ->where('knowledge_id', $knowledge_id)
+                ->update(['status' => 'failed']);
+
+            Log::error('RegenerateKnowledgeExplanationPdf: 异常错误', [
+                'knowledge_id' => $knowledge_id,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '知识点讲解PDF生成异常:'.$e->getMessage(),
+                'knowledge_id' => $knowledge_id,
+            ], 500);
+        }
+    }
+
     /**
      * 重新生成试卷 PDF(不含答案)
      *

+ 88 - 0
app/Jobs/GenerateKnowledgeExplanationPdfJob.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\KnowledgeExplanation;
+use App\Services\ExamPdfExportService;
+use App\Services\TaskManager;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class GenerateKnowledgeExplanationPdfJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public function __construct(
+        public string $taskId,
+        public string $knowledgeId,
+        public array $knowledgePoints
+    ) {
+        $this->onQueue('pdf');
+        $this->afterCommit();
+    }
+
+    public int $tries = 3;
+
+    public int $timeout = 300;
+
+    public function handle(
+        ExamPdfExportService $examPdfExportService,
+        TaskManager $taskManager
+    ): void {
+        try {
+            $record = KnowledgeExplanation::query()
+                ->where('knowledge_id', $this->knowledgeId)
+                ->first();
+            if (! $record) {
+                $taskManager->markTaskFailed($this->taskId, '知识点讲解记录不存在');
+                return;
+            }
+
+            $taskManager->updateTaskProgress($this->taskId, 70, '开始渲染知识点讲解PDF...');
+            $pdfUrl = $examPdfExportService->generateKnowledgeExplanationStandalonePdf($record, $this->knowledgePoints);
+            if (! $pdfUrl) {
+                $record->update(['status' => 'failed']);
+                $taskManager->markTaskFailed($this->taskId, '知识点讲解PDF生成失败');
+                return;
+            }
+
+            $record->update([
+                'status' => 'completed',
+                'pdf_url' => $pdfUrl,
+                'generated_at' => now(),
+            ]);
+
+            $taskManager->markTaskCompleted($this->taskId, [
+                'knowledge_id' => $this->knowledgeId,
+                'pdfs' => [
+                    'all_pdf' => $pdfUrl,
+                ],
+                'exam_content' => [],
+            ]);
+            $taskManager->sendCallback($this->taskId);
+        } catch (\Throwable $e) {
+            Log::error('GenerateKnowledgeExplanationPdfJob 失败', [
+                'task_id' => $this->taskId,
+                'knowledge_id' => $this->knowledgeId,
+                'error' => $e->getMessage(),
+            ]);
+            KnowledgeExplanation::query()
+                ->where('knowledge_id', $this->knowledgeId)
+                ->update(['status' => 'failed']);
+            $taskManager->markTaskFailed($this->taskId, $e->getMessage());
+        }
+    }
+
+    public function failed(Throwable $exception): void
+    {
+        KnowledgeExplanation::query()
+            ->where('knowledge_id', $this->knowledgeId)
+            ->update(['status' => 'failed']);
+        app(TaskManager::class)->markTaskFailed($this->taskId, $exception->getMessage());
+    }
+}

+ 81 - 0
app/Jobs/GenerateKnowledgeExplanationTaskJob.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\KnowledgeExplanation;
+use App\Services\KnowledgeExplanationService;
+use App\Services\TaskManager;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class GenerateKnowledgeExplanationTaskJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public function __construct(public string $taskId)
+    {
+        $this->onQueue('logic');
+        $this->afterCommit();
+    }
+
+    public int $tries = 2;
+
+    public int $timeout = 180;
+
+    public function handle(
+        KnowledgeExplanationService $knowledgeExplanationService,
+        TaskManager $taskManager
+    ): void {
+        $task = $taskManager->getTaskStatus($this->taskId);
+        if (! is_array($task) || ! isset($task['data']) || ! is_array($task['data'])) {
+            $taskManager->markTaskFailed($this->taskId, '任务数据不存在');
+            return;
+        }
+
+        $payload = $task['data'];
+        try {
+            $taskManager->updateTaskProgress($this->taskId, 15, '开始生成知识点讲解与案例...');
+            $prepared = $knowledgeExplanationService->prepareKnowledgeExplanation($payload);
+            $knowledgeId = (string) $prepared['knowledge_id'];
+            $knowledgePoints = (array) $prepared['knowledge_points'];
+
+            $taskManager->updateTaskStatus($this->taskId, [
+                'knowledge_id' => $knowledgeId,
+                'kp_codes' => array_map(static fn (array $kp) => $kp['kp_code'] ?? '', $knowledgePoints),
+            ]);
+            $taskManager->updateTaskProgress($this->taskId, 55, '案例筛选完成,开始生成PDF...');
+
+            dispatch(new GenerateKnowledgeExplanationPdfJob($this->taskId, $knowledgeId, $knowledgePoints));
+        } catch (\Throwable $e) {
+            Log::error('GenerateKnowledgeExplanationTaskJob 失败', [
+                'task_id' => $this->taskId,
+                'error' => $e->getMessage(),
+            ]);
+            $knowledgeId = (string) ($payload['knowledge_id'] ?? '');
+            if ($knowledgeId !== '') {
+                KnowledgeExplanation::query()
+                    ->where('knowledge_id', $knowledgeId)
+                    ->update(['status' => 'failed']);
+            }
+            $taskManager->markTaskFailed($this->taskId, $e->getMessage());
+        }
+    }
+
+    public function failed(Throwable $exception): void
+    {
+        $taskManager = app(TaskManager::class);
+        $task = $taskManager->getTaskStatus($this->taskId);
+        $knowledgeId = is_array($task) ? (string) ($task['data']['knowledge_id'] ?? '') : '';
+        if ($knowledgeId !== '') {
+            KnowledgeExplanation::query()
+                ->where('knowledge_id', $knowledgeId)
+                ->update(['status' => 'failed']);
+        }
+        $taskManager->markTaskFailed($this->taskId, $exception->getMessage());
+    }
+}

+ 33 - 0
app/Models/KnowledgeExplanation.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class KnowledgeExplanation extends Model
+{
+    protected $table = 'knowledge_explanations';
+
+    protected $fillable = [
+        'knowledge_id',
+        'teacher_id',
+        'student_id',
+        'assemble_type',
+        'status',
+        'kp_codes',
+        'case_payload',
+        'content_hash',
+        'pdf_url',
+        'generated_at',
+    ];
+
+    protected $casts = [
+        'id' => 'integer',
+        'teacher_id' => 'string',
+        'student_id' => 'string',
+        'assemble_type' => 'integer',
+        'kp_codes' => 'array',
+        'case_payload' => 'array',
+        'generated_at' => 'datetime',
+    ];
+}

+ 1 - 1
app/Services/ApiDocumentation.php

@@ -277,7 +277,7 @@ class ApiDocumentation
                             ['name' => 'grade', 'type' => 'string', 'required' => true, 'description' => '年级(7/8/9)'],
                             ['name' => 'knowledge_points', 'type' => 'array', 'required' => false, 'description' => '指定知识点列表'],
                             ['name' => 'difficulty', 'type' => 'string', 'required' => false, 'description' => '难度偏好:easy/medium/hard/adaptive'],
-                            ['name' => 'assemble_type', 'type' => 'integer', 'required' => false, 'description' => '组卷类型;15=错题再练,16=错题追练'],
+                            ['name' => 'assemble_type', 'type' => 'integer', 'required' => false, 'description' => '组卷类型;15=错题再练,16=错题追练,22=知识点讲解PDF'],
                             ['name' => 'paper_ids', 'type' => 'array', 'required' => false, 'description' => 'assemble_type=15/16 时临时承载题库题目 question_id 列表'],
                         ],
                     ],

+ 89 - 122
app/Services/ExamPdfExportService.php

@@ -5,9 +5,11 @@ namespace App\Services;
 use App\DTO\ExamAnalysisDataDto;
 use App\DTO\ReportPayloadDto;
 use App\Http\Controllers\ExamPdfController;
+use App\Models\KnowledgeExplanation;
 use App\Models\Paper;
 use App\Models\Question;
 use App\Models\Student;
+use App\Models\Teacher;
 use App\Services\Analytics\QuestionDifficultyCalibrationAnalyzer;
 use App\Support\GradingStyleQuestionStem;
 use App\Support\PaperNaming;
@@ -50,8 +52,7 @@ class ExamPdfExportService
         private readonly QuestionBankService $questionBankService,
         private readonly QuestionServiceApi $questionServiceApi,
         private readonly PdfStorageService $pdfStorageService,
-        private readonly MasteryCalculator $masteryCalculator,
-        private readonly PdfMerger $pdfMerger
+        private readonly MasteryCalculator $masteryCalculator
     ) {
         // 延迟初始化 KatexRenderer(避免循环依赖)
         $this->katexRenderer = new KatexRenderer;
@@ -326,6 +327,53 @@ class ExamPdfExportService
         }
     }
 
+    /**
+     * 生成独立的知识点讲解PDF(assemble_type=22)。
+     */
+    public function generateKnowledgeExplanationStandalonePdf(KnowledgeExplanation $record, array $knowledgePoints): ?string
+    {
+        try {
+            $studentName = $this->resolveStudentNameForKnowledgeExplanation($record);
+            $teacherName = $this->resolveTeacherNameForKnowledgeExplanation($record);
+            $generateDateTime = now()->format('Y年m月d日 H:i:s');
+            $displayCode = (string) preg_replace('/^knowledge_/', '', (string) $record->knowledge_id);
+            if ($displayCode === '') {
+                $displayCode = (string) $record->knowledge_id;
+            }
+            $pdfMeta = [
+                'header_title' => $displayCode,
+            ];
+            $examCode = $displayCode;
+
+            $html = view('pdf.knowledge-explanation-standalone', [
+                'knowledgeId' => $record->knowledge_id,
+                'knowledgePoints' => $knowledgePoints,
+                'studentName' => $studentName,
+                'teacherName' => $teacherName,
+                'generateDateTime' => $generateDateTime,
+                'pdfMeta' => $pdfMeta,
+                'examCode' => $examCode,
+            ])->render();
+
+            $pdfBinary = $this->buildPdf($html, false, false);
+            if ($pdfBinary === null || $pdfBinary === '') {
+                return null;
+            }
+
+            $fileName = $this->buildKnowledgeExplanationPdfFileName($record, $knowledgePoints);
+            $path = 'exams/' . $fileName;
+
+            return $this->pdfStorageService->put($path, $pdfBinary);
+        } catch (\Throwable $e) {
+            Log::error('generateKnowledgeExplanationStandalonePdf 失败', [
+                'knowledge_id' => $record->knowledge_id ?? null,
+                'error' => $e->getMessage(),
+            ]);
+
+            return null;
+        }
+    }
+
     /**
      * 生成学情分析 PDF
      */
@@ -1947,129 +1995,12 @@ class ExamPdfExportService
 
     /**
      * 生成合并PDF(试卷 + 判卷)
-     * 先分别生成两个PDF,然后合并
+     * 兼容旧调用,统一转为 generateUnifiedPdf(不再依赖本地 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);
-            $paper = Paper::where('paper_id', $paperId)->first();
-            if (! $paper) {
-                Log::error('ExamPdfExportService: 合并PDF失败,未找到试卷', ['paper_id' => $paperId]);
-
-                return null;
-            }
-            $allPdfName = $this->buildPdfFileName($paper);
-            $path = "exams/{$allPdfName}";
-            $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('清理临时文件完成');
-        }
+        Log::info('generateMergedPdf 走统一PDF路径', ['paper_id' => $paperId]);
+        return $this->generateUnifiedPdf($paperId);
     }
 
     /**
@@ -4355,6 +4286,42 @@ class ExamPdfExportService
         return "{$safe}.pdf";
     }
 
+    private function buildKnowledgeExplanationPdfFileName(KnowledgeExplanation $record, array $knowledgePoints = [], ?string $stamp = null): string
+    {
+        $studentName = $this->resolveStudentNameForKnowledgeExplanation($record);
+        $rawKnowledgeId = (string) ($record->knowledge_id ?? '');
+        $examCode = (string) preg_replace('/^knowledge_/', '', $rawKnowledgeId);
+        if ($examCode === '') {
+            $examCode = $rawKnowledgeId;
+        }
+
+        $assembleTypeLabel = PaperNaming::assembleTypeLabel(22);
+        $basePrefix = "{$studentName}_{$examCode}_{$assembleTypeLabel}";
+        $stamp = $stamp ?: now()->format('YmdHis').strtoupper(Str::random(4));
+        $base = "{$basePrefix}_{$stamp}";
+        $safe = PaperNaming::toSafeFilename($base);
+
+        return "{$safe}.pdf";
+    }
+
+    private function resolveStudentNameForKnowledgeExplanation(KnowledgeExplanation $record): string
+    {
+        $studentId = (string) ($record->student_id ?? '');
+
+        return Student::query()
+            ->where('student_id', $studentId)
+            ->value('name') ?? ($studentId !== '' ? $studentId : '________');
+    }
+
+    private function resolveTeacherNameForKnowledgeExplanation(KnowledgeExplanation $record): string
+    {
+        $teacherId = (string) ($record->teacher_id ?? '');
+
+        return Teacher::query()
+            ->where('teacher_id', $teacherId)
+            ->value('name') ?? ($teacherId !== '' ? $teacherId : '________');
+    }
+
     private function extractUploadStamp(string $url): ?string
     {
         $path = parse_url($url, PHP_URL_PATH);

+ 605 - 0
app/Services/KnowledgeExplanationService.php

@@ -0,0 +1,605 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\KnowledgeExplanation;
+use App\Models\KnowledgePoint;
+use App\Models\MistakeRecord;
+use App\Models\PaperQuestion;
+use App\Models\Question;
+use Illuminate\Support\Collection;
+
+class KnowledgeExplanationService
+{
+    public function __construct(
+        private readonly ExamPdfExportService $examPdfExportService
+    ) {
+    }
+
+    public function generateKnowledgeId(): string
+    {
+        // 对齐 paper_id 的数字段生成规则(PaperIdGenerator),并增加唯一性兜底
+        for ($i = 0; $i < 5; $i++) {
+            $numericId = PaperIdGenerator::generate();
+            $knowledgeId = 'knowledge_' . $numericId;
+            if (! $this->validateKnowledgeId($knowledgeId)) {
+                continue;
+            }
+            $exists = KnowledgeExplanation::query()
+                ->where('knowledge_id', $knowledgeId)
+                ->exists();
+            if (! $exists) {
+                return $knowledgeId;
+            }
+        }
+
+        throw new \RuntimeException('无法生成唯一的 knowledge_id');
+    }
+
+    public function prepareKnowledgeExplanation(array $payload): array
+    {
+        $knowledgeId = (string) ($payload['knowledge_id'] ?? $this->generateKnowledgeId());
+        if (! $this->validateKnowledgeId($knowledgeId)) {
+            throw new \InvalidArgumentException('knowledge_id 格式非法,必须为 knowledge_ + 15位数字');
+        }
+        $studentId = (string) ($payload['student_id'] ?? '');
+        $teacherId = (string) ($payload['teacher_id'] ?? '');
+        $difficultyCategory = isset($payload['difficulty_category']) ? (int) $payload['difficulty_category'] : null;
+        $kpCodes = $this->resolveKpCodes($payload);
+        $knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
+
+        $history = $this->loadStudentQuestionHistory($studentId);
+        $casePayload = [];
+        foreach ($knowledgePoints as &$point) {
+            $kpCode = (string) ($point['kp_code'] ?? '');
+            if ($kpCode === '') {
+                $point['cases'] = [];
+                continue;
+            }
+            $cases = $this->pickCasesForKnowledgePoint($kpCode, $history['done'], $history['wrong'], 5, $difficultyCategory);
+            $point['cases'] = $cases;
+            $casePayload[$kpCode] = array_map(static function (array $item): array {
+                return [
+                    'question_id' => $item['question_id'],
+                    'source_type' => $item['source_type'],
+                    'is_wrong_case' => $item['is_wrong_case'],
+                    'child_kp_code' => $item['child_kp_code'] ?? null,
+                    'child_kp_name' => $item['child_kp_name'] ?? null,
+                    'source_label' => $item['source_label'] ?? null,
+                ];
+            }, $cases);
+        }
+        unset($point);
+
+        $contentHash = hash('sha256', json_encode([
+            'student_id' => $studentId,
+            'kp_codes' => $kpCodes,
+            'case_payload' => $casePayload,
+        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+
+        $record = KnowledgeExplanation::updateOrCreate([
+            'knowledge_id' => $knowledgeId,
+        ], [
+            'teacher_id' => $teacherId,
+            'student_id' => $studentId,
+            'assemble_type' => 22,
+            'status' => 'processing',
+            'kp_codes' => $kpCodes,
+            'case_payload' => $casePayload,
+            'content_hash' => $contentHash,
+            'pdf_url' => null,
+            'generated_at' => null,
+        ]);
+
+        return [
+            'knowledge_id' => $knowledgeId,
+            'record' => $record,
+            'knowledge_points' => $knowledgePoints,
+        ];
+    }
+
+    /**
+     * 仅用于本地模板调试预览:不落库,直接返回渲染数据。
+     */
+    public function previewKnowledgeExplanation(array $payload): array
+    {
+        $knowledgeId = (string) ($payload['knowledge_id'] ?? $this->generateKnowledgeId());
+        if (! $this->validateKnowledgeId($knowledgeId)) {
+            throw new \InvalidArgumentException('knowledge_id 格式非法,必须为 knowledge_ + 15位数字');
+        }
+
+        $studentId = (string) ($payload['student_id'] ?? '');
+        $teacherId = (string) ($payload['teacher_id'] ?? '');
+        $difficultyCategory = isset($payload['difficulty_category']) ? (int) $payload['difficulty_category'] : null;
+        $kpCodes = $this->resolveKpCodes($payload);
+        $knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
+        $history = $this->loadStudentQuestionHistory($studentId);
+
+        foreach ($knowledgePoints as &$point) {
+            $kpCode = (string) ($point['kp_code'] ?? '');
+            if ($kpCode === '') {
+                $point['cases'] = [];
+                continue;
+            }
+            $point['cases'] = $this->pickCasesForKnowledgePoint($kpCode, $history['done'], $history['wrong'], 5, $difficultyCategory);
+        }
+        unset($point);
+
+        return [
+            'knowledge_id' => $knowledgeId,
+            'student_id' => $studentId,
+            'teacher_id' => $teacherId,
+            'knowledge_points' => $knowledgePoints,
+        ];
+    }
+
+    /**
+     * 使用已保存的 knowledge_id/kp_codes/case_payload 重建 PDF 渲染数据。
+     * 知识点正文会读取当前库中最新内容,案例题目按 case_payload 中的 question_id 复原。
+     */
+    public function rebuildKnowledgePointsForRecord(KnowledgeExplanation $record): array
+    {
+        $kpCodes = is_array($record->kp_codes) ? $record->kp_codes : [];
+        $knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
+        $casePayload = is_array($record->case_payload) ? $record->case_payload : [];
+
+        if (! empty($casePayload)) {
+            $questionIds = [];
+            foreach ($casePayload as $items) {
+                if (! is_array($items)) {
+                    continue;
+                }
+                foreach ($items as $item) {
+                    $questionId = (int) ($item['question_id'] ?? 0);
+                    if ($questionId > 0) {
+                        $questionIds[$questionId] = true;
+                    }
+                }
+            }
+
+            $questionsById = empty($questionIds)
+                ? collect()
+                : Question::query()
+                    ->whereIn('id', array_keys($questionIds))
+                    ->get(['id', 'kp_code', 'stem', 'options', 'meta', 'answer', 'solution', 'question_type', 'difficulty'])
+                    ->keyBy('id');
+
+            foreach ($knowledgePoints as &$point) {
+                $kpCode = (string) ($point['kp_code'] ?? '');
+                $point['cases'] = [];
+                $items = $casePayload[$kpCode] ?? [];
+                if (! is_array($items)) {
+                    continue;
+                }
+
+                foreach ($items as $item) {
+                    if (! is_array($item)) {
+                        continue;
+                    }
+                    $questionId = (int) ($item['question_id'] ?? 0);
+                    $question = $questionsById->get($questionId);
+                    if (! $question instanceof Question) {
+                        continue;
+                    }
+
+                    $sourceType = (string) ($item['source_type'] ?? 'fallback');
+                    $case = $this->formatCaseQuestion($question, $sourceType, (bool) ($item['is_wrong_case'] ?? false));
+                    $case['child_kp_code'] = $item['child_kp_code'] ?? null;
+                    $case['child_kp_name'] = $item['child_kp_name'] ?? null;
+                    $case['source_label'] = $item['source_label'] ?? ($case['source_label'] ?? null);
+                    $point['cases'][] = $case;
+                }
+            }
+            unset($point);
+
+            return $knowledgePoints;
+        }
+
+        $payload = [
+            'knowledge_id' => $record->knowledge_id,
+            'student_id' => $record->student_id,
+            'teacher_id' => $record->teacher_id,
+            'kp_codes' => $kpCodes,
+        ];
+
+        return (array) ($this->previewKnowledgeExplanation($payload)['knowledge_points'] ?? []);
+    }
+
+    private function validateKnowledgeId(string $knowledgeId): bool
+    {
+        if (! preg_match('/^knowledge_([1-9]\d{14})$/', $knowledgeId, $matches)) {
+            return false;
+        }
+
+        return PaperIdGenerator::validate($matches[1]);
+    }
+
+    private function resolveKpCodes(array $payload): array
+    {
+        $raw = $payload['kp_codes'] ?? $payload['kp_code_list'] ?? [];
+        if (! is_array($raw)) {
+            return [];
+        }
+        $codes = [];
+        foreach ($raw as $code) {
+            $value = trim((string) $code);
+            if ($value === '') {
+                continue;
+            }
+            $codes[$value] = true;
+        }
+
+        return array_keys($codes);
+    }
+
+    private function loadStudentQuestionHistory(string $studentId): array
+    {
+        $done = PaperQuestion::query()
+            ->select('paper_questions.question_bank_id')
+            ->join('papers', 'papers.paper_id', '=', 'paper_questions.paper_id')
+            ->where('papers.student_id', $studentId)
+            ->whereNotNull('paper_questions.question_bank_id')
+            ->pluck('paper_questions.question_bank_id')
+            ->map(static fn ($id) => (int) $id)
+            ->filter(static fn ($id) => $id > 0)
+            ->unique()
+            ->values()
+            ->all();
+
+        $wrong = MistakeRecord::query()
+            ->where('student_id', $studentId)
+            ->whereNotNull('question_id')
+            ->pluck('question_id')
+            ->map(static fn ($id) => (int) $id)
+            ->filter(static fn ($id) => $id > 0)
+            ->unique()
+            ->values()
+            ->all();
+
+        return [
+            'done' => $done,
+            'wrong' => $wrong,
+        ];
+    }
+
+    private function pickCasesForKnowledgePoint(string $kpCode, array $doneIds, array $wrongIds, int $limit, ?int $difficultyCategory = null): array
+    {
+        $children = KnowledgePoint::query()
+            ->where('parent_kp_code', $kpCode)
+            ->whereNotNull('kp_code')
+            ->where('kp_code', '!=', '')
+            ->orderBy('id')
+            ->limit($limit)
+            ->get(['kp_code', 'name']);
+
+        if ($children->isNotEmpty()) {
+            $selected = collect();
+            $usedQuestionIds = [];
+            foreach ($children as $child) {
+                if ($selected->count() >= $limit) {
+                    break;
+                }
+                $case = $this->pickSingleCaseForKnowledgePoint((string) $child->kp_code, $doneIds, $wrongIds, $usedQuestionIds, $difficultyCategory);
+                if ($case === null) {
+                    continue;
+                }
+                $case['child_kp_code'] = (string) $child->kp_code;
+                $case['child_kp_name'] = (string) ($child->name ?: $child->kp_code);
+                $case['source_label'] = (string) ($child->name ?: $child->kp_code);
+                $selected->push($case);
+                $usedQuestionIds[] = (int) $case['question_id'];
+            }
+
+            return $selected->values()->all();
+        }
+
+        $selected = collect();
+
+        $pick = function (Collection $bucket, string $sourceType, bool $isWrong) use ($selected, $limit): void {
+            foreach ($bucket as $question) {
+                if ($selected->count() >= $limit) {
+                    break;
+                }
+                if ($selected->contains('question_id', (int) $question->id)) {
+                    continue;
+                }
+                $selected->push($this->formatCaseQuestion($question, $sourceType, $isWrong));
+            }
+        };
+
+        $pick($this->queryBucket($kpCode, static function ($query) use ($doneIds) {
+            if (! empty($doneIds)) {
+                $query->whereNotIn('id', $doneIds);
+            }
+        }, $difficultyCategory), 'new', false);
+
+        if ($selected->count() < $limit) {
+            $pick($this->queryBucket($kpCode, static function ($query) use ($wrongIds) {
+                if (empty($wrongIds)) {
+                    $query->whereRaw('1=0');
+                    return;
+                }
+                $query->whereIn('id', $wrongIds);
+            }, $difficultyCategory), 'wrong', true);
+        }
+
+        if ($selected->count() < $limit) {
+            $pick($this->queryBucket($kpCode, static function ($query) use ($doneIds) {
+                if (empty($doneIds)) {
+                    $query->whereRaw('1=0');
+                    return;
+                }
+                $query->whereIn('id', $doneIds);
+            }, $difficultyCategory), 'reviewed', false);
+        }
+
+        if ($selected->count() < $limit) {
+            $excluded = $selected->pluck('question_id')->all();
+            $pick($this->queryBucket($kpCode, static function ($query) use ($excluded) {
+                if (! empty($excluded)) {
+                    $query->whereNotIn('id', $excluded);
+                }
+            }, $difficultyCategory), 'fallback', false);
+        }
+
+        return $selected->values()->all();
+    }
+
+    private function pickSingleCaseForKnowledgePoint(string $kpCode, array $doneIds, array $wrongIds, array $excludedIds = [], ?int $difficultyCategory = null): ?array
+    {
+        $pickOne = function (callable $mutator, string $sourceType, bool $isWrong) use ($kpCode, $difficultyCategory): ?array {
+            $bucket = $this->queryBucket($kpCode, $mutator, $difficultyCategory);
+            $question = $bucket->first();
+            if (! $question) {
+                return null;
+            }
+
+            return $this->formatCaseQuestion($question, $sourceType, $isWrong);
+        };
+
+        $baseExclude = $excludedIds;
+
+        $case = $pickOne(static function ($query) use ($doneIds, $baseExclude) {
+            if (! empty($doneIds)) {
+                $query->whereNotIn('id', $doneIds);
+            }
+            if (! empty($baseExclude)) {
+                $query->whereNotIn('id', $baseExclude);
+            }
+        }, 'new', false);
+        if ($case) {
+            return $case;
+        }
+
+        $case = $pickOne(static function ($query) use ($wrongIds, $baseExclude) {
+            if (empty($wrongIds)) {
+                $query->whereRaw('1=0');
+                return;
+            }
+            $query->whereIn('id', $wrongIds);
+            if (! empty($baseExclude)) {
+                $query->whereNotIn('id', $baseExclude);
+            }
+        }, 'wrong', true);
+        if ($case) {
+            return $case;
+        }
+
+        $case = $pickOne(static function ($query) use ($doneIds, $baseExclude) {
+            if (empty($doneIds)) {
+                $query->whereRaw('1=0');
+                return;
+            }
+            $query->whereIn('id', $doneIds);
+            if (! empty($baseExclude)) {
+                $query->whereNotIn('id', $baseExclude);
+            }
+        }, 'reviewed', false);
+        if ($case) {
+            return $case;
+        }
+
+        return $pickOne(static function ($query) use ($baseExclude) {
+            if (! empty($baseExclude)) {
+                $query->whereNotIn('id', $baseExclude);
+            }
+        }, 'fallback', false);
+    }
+
+    private function queryBucket(string $kpCode, callable $mutator, ?int $difficultyCategory = null): Collection
+    {
+        $query = Question::query()
+            ->where('kp_code', $kpCode)
+            ->whereNotNull('stem')
+            ->where('stem', '!=', '')
+            ->whereNotNull('answer')
+            ->whereNotNull('solution')
+            ->inRandomOrder()
+            ->limit(80);
+
+        $mutator($query);
+
+        $candidates = $query->get(['id', 'kp_code', 'stem', 'options', 'meta', 'answer', 'solution', 'question_type', 'difficulty']);
+
+        return $this->rankCandidates($candidates, $difficultyCategory, 30);
+    }
+
+    private function rankCandidates(Collection $candidates, ?int $difficultyCategory, int $limit): Collection
+    {
+        if ($candidates->isEmpty()) {
+            return collect();
+        }
+
+        // 无难度偏好时:随机抽样
+        if ($difficultyCategory === null || $difficultyCategory < 0 || $difficultyCategory > 4) {
+            return $candidates->shuffle()->take($limit)->values();
+        }
+
+        $target = $this->targetDifficultyByCategory($difficultyCategory);
+
+        // 先按难度贴合度排序,再加随机扰动,避免每次都返回同题
+        $ranked = $candidates
+            ->shuffle()
+            ->map(function (Question $q) use ($target): array {
+                $difficulty = $this->normalizeDifficultyValue($q->difficulty);
+                $distance = abs($difficulty - $target);
+                $jitter = mt_rand(0, 1000) / 10000;
+                return [
+                    'question' => $q,
+                    'rank_score' => $distance + $jitter,
+                ];
+            })
+            ->sortBy('rank_score')
+            ->pluck('question')
+            ->take($limit)
+            ->values();
+
+        return $ranked;
+    }
+
+    private function targetDifficultyByCategory(int $difficultyCategory): float
+    {
+        return match ($difficultyCategory) {
+            0 => 0.25,
+            1 => 0.40,
+            2 => 0.55,
+            3 => 0.70,
+            4 => 0.85,
+            default => 0.55,
+        };
+    }
+
+    private function normalizeDifficultyValue(mixed $difficulty): float
+    {
+        if (! is_numeric($difficulty)) {
+            return 0.55;
+        }
+
+        $value = (float) $difficulty;
+        if ($value > 1) {
+            $value = $value / 5;
+        }
+
+        return max(0.0, min(1.0, $value));
+    }
+
+    private function formatCaseQuestion(Question $question, string $sourceType, bool $isWrongCase): array
+    {
+        $sourceLabel = match ($sourceType) {
+            'wrong' => '错题讲解',
+            'reviewed' => '已做题',
+            'fallback' => '补充题',
+            default => '新题',
+        };
+
+        $stemRaw = (string) ($question->stem ?? '');
+        $options = $this->normalizeQuestionOptions($question->options);
+        if (empty($options) && is_array($question->meta ?? null)) {
+            $meta = (array) $question->meta;
+            $options = $this->normalizeQuestionOptions($meta['options'] ?? $meta['question_options'] ?? null);
+        }
+        if (empty($options)) {
+            [$stemWithoutExtractedOptions, $extractedOptions] = $this->extractChoiceOptionsFromStem((string) ($question->stem ?? ''));
+            if (! empty($extractedOptions)) {
+                $stemRaw = $stemWithoutExtractedOptions;
+                $options = $extractedOptions;
+            }
+        }
+
+        return [
+            'question_id' => (int) $question->id,
+            'source_type' => $sourceType,
+            'is_wrong_case' => $isWrongCase,
+            'source_label' => $sourceLabel,
+            'stem' => $stemRaw,
+            'options' => $options,
+            'answer' => (string) ($question->answer ?? ''),
+            'solution' => (string) ($question->solution ?? ''),
+            'question_type' => (string) ($question->question_type ?? ''),
+            'difficulty' => is_numeric($question->difficulty) ? (float) $question->difficulty : null,
+        ];
+    }
+
+    /**
+     * 标准化选择题选项,输出为 ['A' => '...', 'B' => '...']。
+     */
+    private function normalizeQuestionOptions(mixed $rawOptions): array
+    {
+        if (is_string($rawOptions)) {
+            $decoded = json_decode($rawOptions, true);
+            if (is_array($decoded)) {
+                $rawOptions = $decoded;
+            }
+        }
+
+        if (! is_array($rawOptions) || empty($rawOptions)) {
+            return [];
+        }
+
+        $normalized = [];
+        foreach ($rawOptions as $key => $value) {
+            $label = strtoupper(trim((string) $key));
+            $content = '';
+
+            if (is_array($value)) {
+                // 兼容多种选项结构:['A' => '...'] / [['label'=>'A','content'=>'...']]
+                $candidateLabel = (string) ($value['label'] ?? $value['key'] ?? '');
+                if ($candidateLabel !== '') {
+                    $label = strtoupper(trim($candidateLabel));
+                }
+                $content = (string) ($value['content'] ?? $value['value'] ?? $value['text'] ?? '');
+            } else {
+                $content = (string) $value;
+            }
+
+            if (trim($content) === '') {
+                continue;
+            }
+
+            if (! preg_match('/^[A-Z]$/', $label)) {
+                $idx = count($normalized);
+                $label = chr(ord('A') + $idx);
+            }
+
+            // 选项文本保持原样,公式与 HTML 转义由卷子共用 partial(exam-choice-options)处理
+            $normalized[$label] = $content;
+        }
+
+        return $normalized;
+    }
+
+    /**
+     * 兜底:从题干中提取 A/B/C/D 选项文本(兼容旧库数据)。
+     */
+    private function extractChoiceOptionsFromStem(string $stem): array
+    {
+        if (trim($stem) === '') {
+            return [$stem, []];
+        }
+
+        $pattern = '/(?:^|<br\s*\/?>|\r?\n)\s*([A-H])\s*[\..、::]\s*(.+?)(?=(?:<br\s*\/?>|\r?\n)\s*[A-H]\s*[\..、::]\s*|$)/isu';
+        preg_match_all($pattern, $stem, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
+        if (empty($matches)) {
+            return [$stem, []];
+        }
+
+        $options = [];
+        foreach ($matches as $m) {
+            $label = strtoupper(trim((string) ($m[1][0] ?? '')));
+            $content = trim((string) ($m[2][0] ?? ''));
+            if ($label === '' || $content === '') {
+                continue;
+            }
+            $options[$label] = $content;
+        }
+
+        if (empty($options)) {
+            return [$stem, []];
+        }
+
+        $firstOptionOffset = (int) ($matches[0][0][1] ?? 0);
+        $stemWithoutOptions = trim(substr($stem, 0, $firstOptionOffset));
+
+        return [$stemWithoutOptions !== '' ? $stemWithoutOptions : $stem, $options];
+    }
+}

+ 10 - 1
app/Services/PdfMerger.php

@@ -39,7 +39,8 @@ class PdfMerger
             return 'qpdf';
         }
 
-        throw new \Exception('未找到PDF合并工具(pdfunite或qpdf)');
+        Log::warning('未检测到PDF合并工具(pdfunite/qpdf),仅在需要本地合并时才会报错');
+        return '';
     }
 
     /**
@@ -103,6 +104,10 @@ class PdfMerger
      */
     public function merge(array $pdfPaths, string $outputPath): bool
     {
+        if ($this->mergeTool === '') {
+            throw new \Exception('未找到PDF合并工具(pdfunite或qpdf)');
+        }
+
         // 验证输入文件
         foreach ($pdfPaths as $path) {
             if (!file_exists($path)) {
@@ -167,6 +172,10 @@ class PdfMerger
      */
     public function mergeWithProgress(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool
     {
+        if ($this->mergeTool === '') {
+            throw new \Exception('未找到PDF合并工具(pdfunite或qpdf)');
+        }
+
         // 进度回调:开始
         if ($progressCallback) {
             $progressCallback(0, '开始合并PDF...');

+ 1 - 0
app/Services/TaskManager.php

@@ -289,6 +289,7 @@ class TaskManager
         if ($task['type'] === self::TASK_TYPE_EXAM) {
             $basePayload['callback_type'] = 'exam_pdf_generated';
             $basePayload['paper_id'] = $task['data']['paper_id'] ?? null;
+            $basePayload['knowledge_id'] = $task['data']['knowledge_id'] ?? ($task['knowledge_id'] ?? null);
             $basePayload['pdfs'] = $task['pdfs'] ?? null;
             $basePayload['exam_content'] = $task['exam_content'] ?? null;
             $basePayload['stats'] = $task['stats'] ?? null;

+ 1 - 0
app/Support/PaperNaming.php

@@ -17,6 +17,7 @@ class PaperNaming
             5 => '智能追练',
             15 => '错题再练',
             16 => '错题追练',
+            22 => '知识点讲解',
             default => throw new InvalidArgumentException("不支持的 assemble_type: {$assembleType}"),
         };
     }

+ 4 - 1
docker-compose.api.mount.yml

@@ -1,10 +1,13 @@
 # API 服务器的代码卷映射配置
-# 用于快速部署:只需 git pull + 重启容器,无需 build
+# 用于快速部署/联调首次 build 后,日常只需 git pull + 重启容器
 #
 # 使用方式:docker compose -f docker-compose.api.yml -f docker-compose.api.mount.yml up -d
 
 services:
   app:
+    build:
+      context: .
+      target: app-runtime-api-hot
     volumes:
       - .:/app                          # 代码目录映射
       - /app/vendor                     # 排除 vendor(用镜像里的)

+ 6 - 1
docker-compose.api.yml

@@ -1,12 +1,17 @@
 services:
   # 纯 API 服务(用于负载均衡扩展)
   app:
-    build: .
+    build:
+      context: .
+      target: app-runtime-api
     container_name: math_cms_app
     ports:
       - "5019:8000"
     env_file:
       - .env
+    environment:
+      # API 节点不内置 Chromium;PDF 生成应走 PDF/worker 节点或远端 Gotenberg。
+      PDF_FALLBACK_TO_CHROME: "false"
     volumes:
       - ./storage:/app/storage
       - ./.env:/app/.env

+ 52 - 0
docker-compose.local.mount.yml

@@ -0,0 +1,52 @@
+# Local 热加载挂载配置
+# 使用方式:
+# docker compose -f docker-compose.local.yml -f docker-compose.local.mount.yml up -d
+
+services:
+  app:
+    build:
+      context: .
+      target: app-runtime-local-hot
+    volumes:
+      - .:/app
+      - /app/vendor
+      - /app/node_modules
+      - /app/public/build
+      - ./storage:/app/storage
+      - ./.env:/app/.env
+
+  queue:
+    build:
+      context: .
+      target: worker-local-hot
+    volumes:
+      - .:/app
+      - /app/vendor
+      - /app/node_modules
+      - /app/public/build
+      - ./storage:/app/storage
+      - ./.env:/app/.env
+
+  pdf-worker:
+    build:
+      context: .
+      target: worker-local-hot
+    volumes:
+      - .:/app
+      - /app/vendor
+      - /app/node_modules
+      - /app/public/build
+      - ./storage:/app/storage
+      - ./.env:/app/.env
+
+  logic-worker:
+    build:
+      context: .
+      target: worker-local-hot
+    volumes:
+      - .:/app
+      - /app/vendor
+      - /app/node_modules
+      - /app/public/build
+      - ./storage:/app/storage
+      - ./.env:/app/.env

+ 108 - 0
docker-compose.local.yml

@@ -0,0 +1,108 @@
+services:
+  app:
+    build:
+      context: .
+      target: app-runtime-local
+    container_name: math_cms_app_local
+    ports:
+      - "5019:8000"
+    env_file:
+      - .env
+    volumes:
+      - ./storage:/app/storage
+      - ./.env:/app/.env
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 40s
+
+  queue:
+    build:
+      context: .
+      target: worker-local
+    container_name: math_cms_queue_local
+    command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
+    env_file:
+      - .env
+    volumes:
+      - ./storage:/app/storage
+      - ./.env:/app/.env
+    restart: unless-stopped
+    stop_grace_period: 60s
+    depends_on:
+      app:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 10s
+
+  gotenberg:
+    image: gotenberg/gotenberg:8
+    container_name: math_cms_gotenberg_local
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 20s
+
+  pdf-worker:
+    build:
+      context: .
+      target: worker-local
+    container_name: math_cms_pdf_local
+    command: php artisan queue:work --queue=pdf --sleep=3 --tries=2 --max-time=300 --max-jobs=10
+    env_file:
+      - .env
+    volumes:
+      - ./storage:/app/storage
+      - ./.env:/app/.env
+    restart: unless-stopped
+    stop_grace_period: 120s
+    depends_on:
+      app:
+        condition: service_healthy
+      gotenberg:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 10s
+
+  logic-worker:
+    build:
+      context: .
+      target: worker-local
+    container_name: math_cms_logic_local
+    command: php artisan queue:work --queue=logic --sleep=1 --tries=2 --max-time=600 --max-jobs=50
+    env_file:
+      - .env
+    volumes:
+      - ./storage:/app/storage
+      - ./.env:/app/.env
+    restart: unless-stopped
+    stop_grace_period: 120s
+    depends_on:
+      app:
+        condition: service_healthy
+      gotenberg:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 10s
+
+networks:
+  default:
+    driver: bridge

+ 6 - 81
resources/views/components/exam/paper-body.blade.php

@@ -175,87 +175,12 @@
                     </div>
                 @endif
                 @if((!$gradingMode || $showGradingStem) && !empty($options))
-                    @php
-                        $layoutMeta = $layoutDeciderService->decide(
-                            $options,
-                            $gradingMode ? 'grading' : 'exam'
-                        );
-                        $optionsClass = $layoutMeta['class'];
-                        $layoutDesc = $layoutMeta['layout'];
-                        $hasImageOptionInQuestion = false;
-                        foreach ($options as $optRaw) {
-                            if (preg_match('/<(img|image|svg)\\b|data:image\\//i', (string) $optRaw) === 1) {
-                                $hasImageOptionInQuestion = true;
-                                break;
-                            }
-                        }
-                        if ($hasImageOptionInQuestion) {
-                            // 简化规则:图片选项固定四列同一行展示
-                            $optionsClass = 'options-grid-4';
-                            $layoutDesc = '4列布局(图片选项固定)';
-                        }
-
-                        \Illuminate\Support\Facades\Log::debug('选择题布局决策', [
-                            'question_number' => $questionNumber,
-                            'context' => $gradingMode ? 'grading' : 'exam',
-                            'opt_count' => $layoutMeta['opt_count'],
-                            'max_length' => $layoutMeta['max_length'],
-                            'has_complex_formula' => $layoutMeta['has_complex_formula'],
-                            'has_image_option' => $hasImageOptionInQuestion,
-                            'selected_class' => $optionsClass,
-                            'layout' => $layoutDesc
-                        ]);
-                    @endphp
-                    <div class="question-lead spacer"></div>
-                    <div class="{{ $optionsClass }}">
-                        @foreach($options as $optIndex => $opt)
-                            @php
-                                // 兼容两种格式:数字索引 (0,1,2,3) 或字母键 (A,B,C,D)
-                                if (is_numeric($optIndex)) {
-                                    $label = chr(65 + (int)$optIndex);
-                                } else {
-                                    $label = strtoupper($optIndex);
-                                }
-                                // 【修复】根据是否已预处理决定处理方式
-                                $normalizedOpt = (string) $opt;
-                                // 选项内优先使用行内分式,避免 \dfrac 导致单个选项视觉突兀
-                                $normalizedOpt = str_replace('\\dfrac', '\\frac', $normalizedOpt);
-                                $normalizedOpt = str_replace('\\displaystyle', '', $normalizedOpt);
-                                $normalizedOpt = $layoutDeciderService->normalizeCompactMathForDisplay($normalizedOpt);
-                                // 清理来源HTML里可能携带的超大字号,避免单题选项异常放大
-                                $normalizedOpt = preg_replace('/font-size\s*:[^;"]+;?/iu', '', $normalizedOpt);
-                                $normalizedOpt = preg_replace('/line-height\s*:[^;"]+;?/iu', '', $normalizedOpt);
-                                $normalizedOpt = preg_replace('/style\s*=\s*([\'"])\s*\1/iu', '', $normalizedOpt);
-
-                                if ($mathProcessed) {
-                                    // 已预处理:数据已包含处理好的 <img> 和公式,直接使用
-                                    $renderedOpt = $normalizedOpt;
-                                } else {
-                                    // 未预处理:先转义保护,processFormulas() 内部会解码并处理
-                                    $encodedOpt = htmlspecialchars($normalizedOpt, ENT_QUOTES | ENT_HTML5, 'UTF-8');
-                                    $renderedOpt = \App\Services\MathFormulaProcessor::processFormulas($encodedOpt);
-                                }
-                                // 仅针对“选项图片”覆盖公式处理器默认的题干尺寸,避免四列布局被 220px 宽图撑出边界
-                                $renderedOpt = preg_replace('/max-width\s*:\s*220px\s*;?/iu', 'max-width:100%;', (string) $renderedOpt);
-                                $renderedOpt = preg_replace('/max-height\s*:\s*60mm\s*;?/iu', 'max-height:28mm;', (string) $renderedOpt);
-                                // 标记选项内图片,供 PDF 全局宽图放大逻辑识别并跳过
-                                $renderedOpt = preg_replace('/<img\b(?![^>]*\bdata-option-image=)/iu', '<img data-option-image="1"', (string) $renderedOpt);
-                                // 兼容未来选项直接使用 <svg> 的场景,同样打标走选项专用规则
-                                $renderedOpt = preg_replace('/<svg\b(?![^>]*\bdata-option-image=)/iu', '<svg data-option-image="1"', (string) $renderedOpt);
-
-                                // 细粒度控制:短选项(如 1/2、-1/3、x、-x)尽量单行展示,长选项允许换行
-                                $rawOptText = html_entity_decode(strip_tags((string) $opt), ENT_QUOTES | ENT_HTML5, 'UTF-8');
-                                $rawOptText = preg_replace('/\s+/u', '', $rawOptText ?? '');
-                                $rawOptLen = mb_strlen((string) $rawOptText, 'UTF-8');
-                                $isShortOption = $rawOptLen <= 8;
-                            @endphp
-                            @php $hasImageOption = preg_match('/<(img|image|svg)\\b|data:image\\//i', (string) $renderedOpt) === 1; @endphp
-                            <div class="option option-compact {{ $hasImageOption ? 'option-with-image' : '' }}">
-                                <strong>{{ $label }}.</strong>
-                                <span class="option-value {{ $isShortOption ? 'option-short' : 'option-long' }}">{!! $renderedOpt !!}</span>
-                            </div>
-                        @endforeach
-                    </div>
+                    @include('pdf.partials.exam-choice-options', [
+                        'options' => $options,
+                        'gradingMode' => $gradingMode,
+                        'mathProcessed' => $mathProcessed,
+                        'logQuestionNumber' => $questionNumber,
+                    ])
                 @endif
                 @if($gradingMode)
                     @php

+ 464 - 0
resources/views/pdf/knowledge-explanation-standalone.blade.php

@@ -0,0 +1,464 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>数学知识点讲解</title>
+    <link rel="stylesheet" href="/css/katex/katex.min.css">
+    @include('pdf.partials.kp-explain-styles')
+    <style>
+        :root {
+            --pdf-space-xxs: 2px;
+            --pdf-space-xs: 4px;
+            --pdf-space-sm: 6px;
+            --pdf-space-md: 10px;
+
+            --pdf-border-light: #d8d8d8;
+            --pdf-border-strong: #777;
+
+            --pdf-text-primary: #000;
+            --pdf-text-secondary: #555;
+
+            --pdf-radius-soft: 2px;
+
+            --pdf-line-normal: 1.75;
+            --pdf-line-relaxed: 1.9;
+        }
+
+        /* 与 exam-paper 一致:题干网格、选项网格、判卷答案区基础样式 */
+        @include('pdf.partials.paper-body-core-styles')
+        .case-list .option-compact { line-height: inherit; }
+        .case-list .option .katex {
+            font-size: 1em !important;
+            vertical-align: 0;
+        }
+        .case-list .option .katex .mfrac {
+            font-size: 1em !important;
+        }
+        .case-list .option .katex .mfrac .mtight {
+            font-size: 1em !important;
+        }
+        .case-list .option .katex .frac-line {
+            border-bottom-width: 0.055em !important;
+        }
+        .case-list .option .katex .mfrac .vlist > span:nth-child(1) {
+            transform: translateY(0.24em) !important;
+        }
+        .case-list .option .katex .mfrac .vlist > span:nth-child(3) {
+            transform: translateY(-0.16em) !important;
+        }
+        .case-list .option .katex-display {
+            display: inline;
+            margin: 0 !important;
+            vertical-align: baseline;
+        }
+        .case-list .answer-meta {
+            font-size: 12px;
+            color: #2f2f2f;
+            line-height: var(--pdf-line-normal);
+            margin-top: var(--pdf-space-xs);
+            page-break-inside: auto;
+            break-inside: auto;
+        }
+        .case-list .answer-line + .answer-line { margin-top: var(--pdf-space-xs); }
+        .case-list .solution-content { display: inline-block; line-height: var(--pdf-line-normal); }
+
+        /* ========== 案例区整体 ========== */
+        .case-list { margin-top: 14px; }
+        .case-list .case-item {
+            margin: 10px 0 14px;
+            padding: 8px var(--pdf-space-md);
+            border: none;
+            border-left: 1.5px solid var(--pdf-border-light);
+            border-radius: var(--pdf-radius-soft);
+            background: #fff;
+            line-height: var(--pdf-line-normal);
+            break-inside: auto;
+            page-break-inside: auto;
+            orphans: 2;
+            widows: 2;
+        }
+        .case-list .case-item + .case-item { margin-top: 9px; }
+
+        /* ========== 题干区域 ========== */
+        .case-list .kp-case-row {
+            font-size: 14.5px;
+            line-height: 1.8;
+            margin-bottom: 3px;
+            break-inside: auto;
+            page-break-inside: auto;
+            break-after: auto;
+            page-break-after: auto;
+            orphans: 2;
+            widows: 2;
+        }
+        .case-list .kp-case-head-inline {
+            display: inline;
+        }
+        .case-list .kp-case-prefix {
+            font-weight: 700;
+            white-space: nowrap;
+        }
+        .case-list .kp-case-title {
+            font-size: 15px;
+            font-weight: 700;
+            color: var(--pdf-text-primary);
+            margin-right: var(--pdf-space-xxs);
+            white-space: nowrap;
+        }
+        .case-list .kp-case-source {
+            font-size: 13px;
+            color: var(--pdf-text-secondary);
+            margin-right: 3px;
+            font-weight: 700;
+        }
+        .case-list .kp-case-head-content {
+            display: inline;
+        }
+        .case-list .kp-case-stem {
+            font-size: 14.5px;
+            font-weight: 400;
+            line-height: 1.85;
+            word-break: normal;
+            overflow-wrap: break-word;
+            break-inside: auto;
+            page-break-inside: auto;
+            orphans: 2;
+            widows: 2;
+        }
+
+        /* ========== 解析区 ========== */
+        .case-list .kp-case-meta-block { margin-top: 3px; line-height: 1.85; }
+        .case-list .kp-case-meta-row {
+            margin-top: 3px;
+            text-indent: 0;
+            page-break-inside: auto;
+            break-inside: auto;
+        }
+        .case-list .kp-case-content {
+            display: block;
+            font-size: 14px;
+            line-height: var(--pdf-line-relaxed);
+            color: #222;
+            white-space: normal;
+            word-break: break-word;
+            overflow-wrap: anywhere;
+            orphans: 2;
+            widows: 2;
+        }
+        .case-list .kp-case-answer-row {
+            margin: var(--pdf-space-xs) 0 var(--pdf-space-sm);
+            padding: 3px var(--pdf-space-sm);
+            background: #f6f6f6;
+            border-radius: var(--pdf-radius-soft);
+        }
+        .case-list .kp-case-solution-row .solution-content {
+            display: block;
+            margin-top: 2px;
+        }
+        /* 仅提升案例区 display 公式呼吸感,不动全局 KaTeX 行高 */
+        .case-list .kp-case-content .katex-display,
+        .case-list .kp-case-stem .katex-display {
+            margin-top: 0.45em !important;
+            margin-bottom: 0.5em !important;
+        }
+        .case-list .kp-case-content .complex-display-math,
+        .case-list .kp-case-stem .complex-display-math {
+            margin-top: 0.6em !important;
+            margin-bottom: 0.65em !important;
+        }
+
+        /* ========== 小标题 ========== */
+        .case-list .case-subtitle {
+            display: block;
+            font-weight: 700;
+            font-size: 14px;
+            margin-top: 8px;
+            margin-bottom: var(--pdf-space-xxs);
+            padding-left: 5px;
+            border-left: 2px solid var(--pdf-border-strong);
+            line-height: 1.35;
+            color: var(--pdf-text-primary);
+            break-after: avoid;
+            page-break-after: avoid;
+        }
+        /* 标题+首段语义绑定:只对真正短小的纯文本节 avoid;含图/大公式自动分页,减少大面积空白 */
+        .case-list .case-section {
+            margin-top: var(--pdf-space-xs);
+            break-inside: auto;
+            page-break-inside: auto;
+        }
+        .case-list .case-section + .case-section { margin-top: 5px; }
+        .case-list .case-section.case-section-keep {
+            break-inside: avoid;
+            page-break-inside: avoid;
+        }
+        .case-list .case-section.case-section-long {
+            break-inside: auto;
+            page-break-inside: auto;
+        }
+        .case-list .case-section-content { margin-top: 1px; }
+
+        /* ========== 分页控制 ========== */
+        .case-list .case-analysis {
+            break-inside: auto;
+            page-break-inside: auto;
+        }
+        .case-list .case-detail {
+            break-inside: auto;
+            page-break-inside: auto;
+        }
+
+        /* ========== 多小题 ========== */
+        .case-list .sub-question {
+            display: block;
+            margin: 4px 0;
+            padding-left: 2em;
+            text-indent: -2em;
+            line-height: 1.8;
+        }
+
+        /* 图片:与 exam-paper / 判卷一致 */
+        @include('pdf.partials.paper-exam-shared-image-styles')
+        /* 案例区优先节省纸张:不要让图和后续小题/答案强绑定到下一页 */
+        .case-list .pdf-figure {
+            break-inside: auto !important;
+            page-break-inside: auto !important;
+            -webkit-column-break-inside: auto !important;
+            break-before: auto !important;
+            break-after: auto !important;
+            page-break-before: auto !important;
+            page-break-after: auto !important;
+            margin: 4px 0 !important;
+            min-height: 0 !important;
+            max-height: none !important;
+        }
+        .case-list .pdf-figure img,
+        .case-list .kp-case-stem img {
+            margin-top: 4px !important;
+            margin-bottom: 4px !important;
+            max-height: 48mm !important;
+        }
+    </style>
+</head>
+<body>
+<div class="page">
+    <div class="kp-explain-header">
+        <div class="kp-explain-title">数学知识点讲解</div>
+        <div class="kp-explain-subtitle">围绕目标知识点生成讲解与案例,帮助学生高效复习。</div>
+    </div>
+
+    {{-- 知识点正文:与 pdf.exam-knowledge-explanation(知识点组卷前置梳理)同一套容器与数据(buildExplanations + normalizeKpExplanation) --}}
+    @if(empty($knowledgePoints))
+        <div class="kp-empty">暂无知识点数据</div>
+    @else
+        <div class="kp-list">
+    @foreach($knowledgePoints as $point)
+        <div class="kp-section">
+            <div class="kp-section-head">
+                <div class="kp-section-name">{{ $loop->iteration }}、{{ $point['kp_name'] ?? ($point['kp_code'] ?? '未命名知识点') }}</div>
+            </div>
+            <div class="kp-section-body">
+                @if(!empty($point['explanation']))
+                    {!! $point['explanation'] !!}
+                @endif
+            </div>
+
+            @if(!empty($point['cases']))
+                @php
+                    // 与卷子题干一致的小题号断行逻辑(仅在至少2个小题编号时生效)
+                    $formatStemLikePaper = function (?string $text): string {
+                        $stem = trim((string) $text);
+                        if ($stem === '') {
+                            return '—';
+                        }
+                        preg_match_all('/[((][1-9][0-9]*[))]/u', $stem, $subQuestionMatches);
+                        $subQuestionCount = count($subQuestionMatches[0] ?? []);
+                        if ($subQuestionCount >= 2) {
+                            $stem = preg_replace('/^\s*([((][1-9][0-9]*[))])\s*/u', '$1 ', $stem) ?? $stem;
+                            // 断行后保留两个中文空格缩进
+                            $stem = preg_replace('/([。;;!?!?::.])\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>  $2 ', $stem) ?? $stem;
+                            $stem = preg_replace('/(?:(?:\\\\r\\\\n|\\\\n)|(?:\r?\n)|(?:<br\s*\/?>)|\s)+\s*([((][1-9][0-9]*[))])\s*/u', '<br>  $1 ', $stem) ?? $stem;
+                            $stem = preg_replace('/(求出|求解|求|写出|计算|证明|判断|化简)\s*([((][1-9][0-9]*[))])\s*/u', '$1<br>  $2 ', $stem) ?? $stem;
+                        }
+                        return $stem;
+                    };
+
+                    $markComplexDisplayMath = function (string $html): string {
+                        return preg_replace_callback(
+                            '/<span\b([^>]*\bclass="[^"]*\bkatex-display\b[^"]*"[^>]*)>(.*?)<\/span>/isu',
+                            static function (array $matches): string {
+                                $attrs = $matches[1] ?? '';
+                                $content = $matches[2] ?? '';
+                                $isComplex = str_contains($content, 'mfrac') || str_contains($content, 'vlist');
+                                if (!$isComplex || str_contains($attrs, 'complex-display-math')) {
+                                    return $matches[0];
+                                }
+
+                                $attrs = preg_replace('/\bclass="([^"]*)"/u', 'class="$1 complex-display-math"', $attrs, 1) ?? $attrs;
+
+                                return '<span' . $attrs . '>' . $content . '</span>';
+                            },
+                            $html
+                        ) ?? $html;
+                    };
+
+                    // 解析:移除“讲解/解析”前缀,结构化分析/详解小标题,并保守处理步骤换行
+                    $formatSolution = function (?string $text) use ($markComplexDisplayMath): string {
+                        $solution = trim((string) $text);
+                        if ($solution === '') {
+                            return '—';
+                        }
+                        $solution = preg_replace('/^\s*[【\[]?\s*(讲解|解析)\s*[】\]]?\s*[::]\s*/u', '', $solution) ?? $solution;
+                        $solution = preg_replace('/\s*【\s*(分析|详解|点睛)\s*】\s*/u', '<div class="case-subtitle">$1</div>', $solution) ?? $solution;
+                        $solution = preg_replace('/\s*\[\s*(分析|详解|点睛)\s*\]\s*/u', '<div class="case-subtitle">$1</div>', $solution) ?? $solution;
+                        $solution = preg_replace('/\s*(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?)/u', '<br>$1', $solution) ?? $solution;
+                        $solution = preg_replace('/\s*(第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::]?)/u', '<br>$1', $solution) ?? $solution;
+                        $plainLength = mb_strlen(strip_tags($solution), 'UTF-8');
+                        if ($plainLength > 160) {
+                            $solution = preg_replace('/([。;])\s*/u', '$1<br>', $solution) ?? $solution;
+                        }
+                        $solution = preg_replace('/(<\/div>)\s*<br>\s*(步骤\s*[0-9一二三四五六七八九十百零两]+\s*[::]?)/u', '$1$2', $solution) ?? $solution;
+                        $solution = preg_replace('/(<\/div>)\s*<br>\s*(第\s*[0-9一二三四五六七八九十百零两]+\s*步\s*[::]?)/u', '$1$2', $solution) ?? $solution;
+                        $solution = preg_replace('/^(?:\s*<br\s*\/?>\s*)+/iu', '', $solution) ?? $solution;
+                        $solution = preg_replace('/(?:<br>\s*){2,}/u', '<br>', $solution) ?? $solution;
+                        $solution = preg_replace('/(?:\s*<br>\s*)*(<div class="case-subtitle">)/u', '$1', $solution) ?? $solution;
+                        $solution = $markComplexDisplayMath($solution);
+
+                        // 将「分析/详解」转换为可分页语义块:标题 + 内容。
+                        // 仅短小纯文本节绑定;含图/display 公式的节允许自然分页,减少整块推页空白。
+                        $caseSectionClass = static function (string $body, bool $hasTitle = true): string {
+                            $plainLength = mb_strlen(trim(strip_tags($body)), 'UTF-8');
+                            $containsImage = str_contains($body, '<img') || str_contains($body, 'pdf-figure');
+                            $containsDisplayMath = str_contains($body, 'katex-display');
+                            $isShortSection = $plainLength < 120 && !$containsImage && !$containsDisplayMath;
+
+                            if ($hasTitle) {
+                                return $isShortSection ? 'case-section case-section-keep' : 'case-section case-section-long';
+                            }
+
+                            return $isShortSection ? 'case-section case-section-keep case-section-plain' : 'case-section case-section-long case-section-plain';
+                        };
+                        $chunks = preg_split('/<div class="case-subtitle">(.*?)<\/div>/u', $solution, -1, PREG_SPLIT_DELIM_CAPTURE);
+                        if (is_array($chunks) && count($chunks) > 1) {
+                            $sectionHtml = '';
+                            $lead = trim((string) ($chunks[0] ?? ''));
+                            if ($lead !== '') {
+                                $leadClass = $caseSectionClass($lead, false);
+                                $sectionHtml .= '<div class="' . $leadClass . '"><div class="case-section-content">' . $lead . '</div></div>';
+                            }
+                            for ($i = 1; $i < count($chunks); $i += 2) {
+                                $title = trim((string) ($chunks[$i] ?? ''));
+                                $body = trim((string) ($chunks[$i + 1] ?? ''));
+                                if ($title === '' && $body === '') {
+                                    continue;
+                                }
+                                $sectionClass = $caseSectionClass($body, true);
+                                $sectionHtml .= '<div class="' . $sectionClass . '"><div class="case-subtitle">' . $title . '</div><div class="case-section-content">' . $body . '</div></div>';
+                            }
+                            if ($sectionHtml !== '') {
+                                $solution = $sectionHtml;
+                            }
+                        } else {
+                            $plainClass = $caseSectionClass($solution, false);
+                            $solution = '<div class="' . $plainClass . '"><div class="case-section-content">' . $solution . '</div></div>';
+                        }
+                        return $solution;
+                    };
+                @endphp
+                <div class="kp-markdown"><h3>案例分析</h3></div>
+                <div class="case-list">
+                    @foreach($point['cases'] as $case)
+                        @php
+                            $sourceText = '';
+                            if (!empty($case['child_kp_name'])) {
+                                // 只展示子知识点来源,不展示父知识点名称
+                                $sourceText = trim((string) $case['child_kp_name']);
+                                $parentName = trim((string) ($point['kp_name'] ?? ''));
+                                if ($parentName !== '') {
+                                    $escapedParent = preg_quote($parentName, '/');
+                                    $sourceText = preg_replace('/^' . $escapedParent . '\s*[-—-\/]\s*/u', '', $sourceText) ?? $sourceText;
+                                }
+                                if (preg_match('/[-—-\/]/u', $sourceText)) {
+                                    $parts = preg_split('/\s*[-—-\/]\s*/u', $sourceText);
+                                    if (is_array($parts) && !empty($parts)) {
+                                        $sourceText = trim((string) end($parts));
+                                    }
+                                }
+                            } elseif (!empty($case['is_wrong_case'])) {
+                                $sourceText = '错题讲解';
+                            } elseif (($case['source_type'] ?? '') === 'reviewed') {
+                                $sourceText = '已做题';
+                            } elseif (($case['source_type'] ?? '') === 'fallback') {
+                                $sourceText = '补充题';
+                            }
+                        @endphp
+                        <div class="case-item">
+                            <div class="kp-case-row">
+                                @php
+                                    $stemLine = trim((string) ($case['stem'] ?? ''));
+                                    if ($stemLine === '') {
+                                        $renderedStemHtml = '—';
+                                    } else {
+                                        [$renderedStemHtml] = \App\Support\BlankPlaceholderRenderer::replaceToBlankSpan($stemLine, null, true, false);
+                                        $renderedStemHtml = \App\Support\BlankPlaceholderRenderer::normalizeTerminalPunctuation($renderedStemHtml, 'remove');
+                                        $renderedStemHtml = $formatStemLikePaper($renderedStemHtml);
+                                        $renderedStemHtml = \App\Services\MathFormulaProcessor::processFormulas($renderedStemHtml);
+                                        $renderedStemHtml = $markComplexDisplayMath($renderedStemHtml);
+                                    }
+                                @endphp
+                                <div class="kp-case-head-inline">
+                                    <span class="kp-case-prefix">
+                                        <span class="kp-case-title">例{{ $loop->iteration }}.</span>
+                                        @if($sourceText !== '')
+                                            <span class="kp-case-source">({{ $sourceText }})</span>
+                                        @endif
+                                    </span>
+                                    <span class="kp-case-head-content">
+                                        <span class="kp-case-stem">{!! $renderedStemHtml !!}</span>
+                                    </span>
+                                </div>
+                                @php
+                                    $options = (array) ($case['options'] ?? []);
+                                @endphp
+                                @if(!empty($options))
+                                    @include('pdf.partials.exam-choice-options', [
+                                        'options' => $options,
+                                        'gradingMode' => false,
+                                        'mathProcessed' => false,
+                                        'logQuestionNumber' => 'kp-' . ($case['question_id'] ?? $loop->iteration),
+                                        'showLeadSpacer' => false,
+                                    ])
+                                @endif
+                            </div>
+                            <div class="kp-case-meta-block">
+                                @php
+                                    $layoutDeciderService = app(\App\Support\OptionLayoutDecider::class);
+                                    $choiceAnswerRaw = $layoutDeciderService->normalizeCompactMathForDisplay(trim((string) ($case['answer'] ?? '')));
+                                    $solutionText = trim((string) ($case['solution'] ?? ''));
+                                    $solutionText = preg_replace('/^【?\s*解题思路\s*】?\s*[::]?\s*/u', '', $solutionText) ?? $solutionText;
+                                    $solutionProcessed = $solutionText === ''
+                                        ? ''
+                                        : \App\Services\MathFormulaProcessor::processFormulas($solutionText);
+                                    $solutionHtml = $solutionProcessed === ''
+                                        ? '<span style="color:#999;font-style:italic;">(暂无解题思路)</span>'
+                                        : $formatSolution($solutionProcessed);
+                                    $answerLineHtml = $choiceAnswerRaw === ''
+                                        ? '—'
+                                        : \App\Services\MathFormulaProcessor::processFormulas($choiceAnswerRaw);
+                                @endphp
+                                <div class="answer-meta">
+                                    <div class="answer-line kp-case-answer-row"><strong>正确答案:</strong><span class="solution-content">{!! $answerLineHtml !!}</span></div>
+                                    <div class="answer-line kp-case-solution-row"><strong>解题思路:</strong><span class="solution-content kp-case-content">{!! $solutionHtml !!}</span></div>
+                                </div>
+                            </div>
+                        </div>
+                    @endforeach
+                </div>
+            @endif
+        </div>
+    @endforeach
+        </div>
+    @endif
+</div>
+</body>
+</html>

+ 98 - 0
resources/views/pdf/partials/exam-choice-options.blade.php

@@ -0,0 +1,98 @@
+{{--
+  与 components/exam/paper-body 选择题选项渲染完全一致(布局 + 公式预处理)。
+  参数:
+  - $options: array 选项(数字下标或 A/B/C/D 键)
+  - $gradingMode: bool 是否判卷上下文(影响 OptionLayoutDecider)
+  - $mathProcessed: bool 题目是否已整体预处理公式
+  - $logQuestionNumber: string|int 仅用于日志标识
+  - $showLeadSpacer: bool 是否在选项前插入与 question-grid 对齐的隐形占位(嵌入案例行等非 grid 场景可设为 false)
+--}}
+@php
+    /** @var array $options */
+    /** @var bool $gradingMode */
+    /** @var bool $mathProcessed */
+    /** @var string|int|null $logQuestionNumber */
+    /** @var bool $showLeadSpacer */
+    $showLeadSpacer = $showLeadSpacer ?? true;
+    $layoutDeciderService = app(\App\Support\OptionLayoutDecider::class);
+    $layoutMeta = $layoutDeciderService->decide(
+        $options,
+        $gradingMode ? 'grading' : 'exam'
+    );
+    $optionsClass = $layoutMeta['class'];
+    $layoutDesc = $layoutMeta['layout'];
+    $hasImageOptionInQuestion = false;
+    foreach ($options as $optRaw) {
+        if (preg_match('/<(img|image|svg)\\b|data:image\\//i', (string) $optRaw) === 1) {
+            $hasImageOptionInQuestion = true;
+            break;
+        }
+    }
+    if ($hasImageOptionInQuestion) {
+        $optionsClass = 'options-grid-4';
+        $layoutDesc = '4列布局(图片选项固定)';
+    }
+
+    \Illuminate\Support\Facades\Log::debug('选择题布局决策', [
+        'question_number' => $logQuestionNumber ?? null,
+        'context' => $gradingMode ? 'grading' : 'exam',
+        'opt_count' => $layoutMeta['opt_count'],
+        'max_length' => $layoutMeta['max_length'],
+        'has_complex_formula' => $layoutMeta['has_complex_formula'],
+        'has_image_option' => $hasImageOptionInQuestion,
+        'selected_class' => $optionsClass,
+        'layout' => $layoutDesc,
+    ]);
+@endphp
+@if($showLeadSpacer)
+    <div class="question-lead spacer"></div>
+@endif
+<div class="{{ $optionsClass }}">
+    @foreach($options as $optIndex => $opt)
+        @php
+            // 兼容两种格式:数字索引 (0,1,2,3) 或字母键 (A,B,C,D)
+            if (is_numeric($optIndex)) {
+                $label = chr(65 + (int) $optIndex);
+            } else {
+                $label = strtoupper($optIndex);
+            }
+            // 【修复】根据是否已预处理决定处理方式
+            $normalizedOpt = (string) $opt;
+            // 选项内优先使用行内分式,避免 \dfrac 导致单个选项视觉突兀
+            $normalizedOpt = str_replace('\\dfrac', '\\frac', $normalizedOpt);
+            $normalizedOpt = str_replace('\\displaystyle', '', $normalizedOpt);
+            $normalizedOpt = $layoutDeciderService->normalizeCompactMathForDisplay($normalizedOpt);
+            // 清理来源HTML里可能携带的超大字号,避免单题选项异常放大
+            $normalizedOpt = preg_replace('/font-size\s*:[^;"]+;?/iu', '', $normalizedOpt);
+            $normalizedOpt = preg_replace('/line-height\s*:[^;"]+;?/iu', '', $normalizedOpt);
+            $normalizedOpt = preg_replace('/style\s*=\s*([\'"])\s*\1/iu', '', $normalizedOpt);
+
+            if ($mathProcessed) {
+                // 已预处理:数据已包含处理好的 <img> 和公式,直接使用
+                $renderedOpt = $normalizedOpt;
+            } else {
+                // 未预处理:先转义保护,processFormulas() 内部会解码并处理
+                $encodedOpt = htmlspecialchars($normalizedOpt, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+                $renderedOpt = \App\Services\MathFormulaProcessor::processFormulas($encodedOpt);
+            }
+            // 仅针对“选项图片”覆盖公式处理器默认的题干尺寸,避免四列布局被 220px 宽图撑出边界
+            $renderedOpt = preg_replace('/max-width\s*:\s*220px\s*;?/iu', 'max-width:100%;', (string) $renderedOpt);
+            $renderedOpt = preg_replace('/max-height\s*:\s*60mm\s*;?/iu', 'max-height:28mm;', (string) $renderedOpt);
+            // 标记选项内图片,供 PDF 全局宽图放大逻辑识别并跳过
+            $renderedOpt = preg_replace('/<img\b(?![^>]*\bdata-option-image=)/iu', '<img data-option-image="1"', (string) $renderedOpt);
+            // 兼容未来选项直接使用 <svg> 的场景,同样打标走选项专用规则
+            $renderedOpt = preg_replace('/<svg\b(?![^>]*\bdata-option-image=)/iu', '<svg data-option-image="1"', (string) $renderedOpt);
+
+            // 细粒度控制:短选项(如 1/2、-1/3、x、-x)尽量单行展示,长选项允许换行
+            $rawOptText = html_entity_decode(strip_tags((string) $opt), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+            $rawOptText = preg_replace('/\s+/u', '', $rawOptText ?? '');
+            $rawOptLen = mb_strlen((string) $rawOptText, 'UTF-8');
+            $isShortOption = $rawOptLen <= 8;
+        @endphp
+        @php $hasImageOption = preg_match('/<(img|image|svg)\\b|data:image\\//i', (string) $renderedOpt) === 1; @endphp
+        <div class="option option-compact {{ $hasImageOption ? 'option-with-image' : '' }}">
+            <strong>{{ $label }}.</strong>
+            <span class="option-value {{ $isShortOption ? 'option-short' : 'option-long' }}">{!! $renderedOpt !!}</span>
+        </div>
+    @endforeach
+</div>

+ 6 - 0
routes/api.php

@@ -1200,6 +1200,12 @@ Route::post('/papers/{paper_id}/regenerate-grading', [\App\Http\Controllers\Exam
     ->where('paper_id', '^paper_\d+$')
     ->name('api.papers.regenerate-grading');
 
+// 重新生成知识点讲解 PDF(保持同一个 knowledge_id)
+Route::post('/knowledge-explanations/{knowledge_id}/regenerate', [\App\Http\Controllers\ExamPdfController::class, 'regenerateKnowledgeExplanationPdf'])
+    ->withoutMiddleware([Authenticate::class, 'auth', 'auth:sanctum', 'auth:api'])
+    ->where('knowledge_id', '^knowledge_\d+$')
+    ->name('api.knowledge-explanations.regenerate');
+
 /*
 |--------------------------------------------------------------------------
 | 以下为旧代码(已迁移到 Controller,保留注释供参考)

+ 37 - 0
routes/web.php

@@ -3,6 +3,9 @@
 use App\Http\Controllers\ImportStreamController;
 use App\Http\Controllers\MenuVisibilityController;
 use App\Http\Controllers\NotificationController;
+use App\Services\KatexRenderer;
+use App\Services\KnowledgeExplanationService;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Route;
 
 Route::get('/', function () {
@@ -51,3 +54,37 @@ Route::get('/tools/question-preview', [\App\Http\Controllers\QuestionPreviewCont
     ->name('tools.question-preview');
 Route::post('/tools/question-preview/pdf', [\App\Http\Controllers\QuestionPreviewController::class, 'generatePdf'])
     ->name('tools.question-preview.pdf');
+
+// 知识点讲解模板本地预览(仅本地环境,避免影响线上)
+Route::get('/tools/knowledge-explanation-preview', function (Request $request, KnowledgeExplanationService $knowledgeExplanationService, KatexRenderer $katexRenderer) {
+    abort_unless(app()->environment('local'), 404);
+
+    $kpCodes = (string) $request->query('kp_codes', 'M04A');
+    $payload = [
+        'kp_codes' => array_values(array_filter(array_map('trim', explode(',', $kpCodes)))),
+        'student_id' => (string) $request->query('student_id', '1764913911'),
+        'teacher_id' => (string) $request->query('teacher_id', '45'),
+        'difficulty_category' => $request->query('difficulty_category') !== null
+            ? (int) $request->query('difficulty_category')
+            : null,
+    ];
+
+    $prepared = $knowledgeExplanationService->previewKnowledgeExplanation($payload);
+    $knowledgeId = (string) ($prepared['knowledge_id'] ?? '');
+    $displayCode = (string) preg_replace('/^knowledge_/', '', $knowledgeId);
+
+    $html = view('pdf.knowledge-explanation-standalone', [
+        'knowledgeId' => $knowledgeId,
+        'knowledgePoints' => (array) ($prepared['knowledge_points'] ?? []),
+        'studentName' => (string) ($prepared['student_id'] ?? ''),
+        'teacherName' => (string) ($prepared['teacher_id'] ?? ''),
+        'generateDateTime' => now()->format('Y年m月d日 H:i:s'),
+        'pdfMeta' => ['header_title' => $displayCode !== '' ? $displayCode : $knowledgeId],
+        'examCode' => $displayCode !== '' ? $displayCode : $knowledgeId,
+    ])->render();
+
+    // 与 PDF 生成链路一致:服务端 KaTeX 预渲染,避免预览页出现原始 $...$ 公式
+    $html = $katexRenderer->renderHtml($html);
+
+    return response($html);
+})->name('tools.knowledge-explanation-preview');