13 Commits 095395affd ... 72cb39bd87

Author SHA1 Message Date
  yemeishu 72cb39bd87 fix(pdf): derive KP explain list from questions, sort by KP tree 5 days ago
  yemeishu 7fea63df76 feat(assemble): add types 11/12/13 with KP explain PDF, map to 1/2/3 5 days ago
  yemeishu facfbc7ddc chore: remove verbose assemble/status and callback payload logs 5 days ago
  yemeishu a03f395786 fix(knowledge-explanation): fill paper_info fields and align exam_content stats 5 days ago
  yemeishu cc9a5b7c75 fix: log task status and score knowledge cases to 100 5 days ago
  yemeishu 83c62a97d9 fix: align knowledge exam_content with assembled paper schema 5 days ago
  yemeishu 5d8915e133 fix: provide structured exam_content for knowledge callbacks 5 days ago
  yemeishu fb9dd3aa37 fix: persist knowledge pdf url into papers table 5 days ago
  yemeishu 80f89b45fd fix: switch knowledge explanation ids to paper prefix 5 days ago
  yemeishu 99a57acf2c fix: harden knowledge callback persistence and payload urls 5 days ago
  yemeishu f1ddbd9247 fix: unify assemble_type 22 response paper_id 5 days ago
  yemeishu 270e2d3637 fix: align knowledge callback paper_id and center stem images 5 days ago
  yemeishu eab60dabf8 增加知识点讲解功能 5 days ago

+ 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

+ 54 - 27
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,11,12,13,15,16,22',
             'exam_type' => 'nullable|string|in:general,diagnostic,practice,mistake,textbook,knowledge,knowledge_points',
             // 错题本类型专用参数
             'paper_ids' => 'nullable|array',
@@ -150,10 +155,34 @@ 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(并对外映射为 paper_id 以保持接口一致)
+        $reservedPaperId = $assembleType === 22 ? null : $this->questionBankService->generatePaperId();
+        $reservedKnowledgeId = $assembleType === 22 ? $this->knowledgeExplanationService->generateKnowledgeId() : null;
+        $responsePaperId = $assembleType === 22 ? $reservedKnowledgeId : $reservedPaperId;
         $this->ensureStudentTeacherRelation($data);
 
         // 【修改】使用series_id、semester_code和grade获取textbook_id
@@ -169,48 +198,46 @@ class IntelligentExamController extends Controller
         }
 
         $taskPayload = array_merge($data, [
-            'paper_id' => $reservedPaperId,
+            'paper_id' => $responsePaperId,
+            'knowledge_id' => $reservedKnowledgeId,
             'request_trace_id' => $requestTraceId,
             'request_started_at' => now()->toISOString(),
             'request_payload_snapshot_raw' => $requestPayloadSnapshotRaw,
         ]);
 
-        Log::info('assemble.request', [
-            'trace_id' => $requestTraceId,
-            'student_id' => $taskPayload['student_id'] ?? null,
-            'teacher_id' => $taskPayload['teacher_id'] ?? null,
-            'grade' => $taskPayload['grade'] ?? null,
-            'assemble_type' => $assembleType,
-            'paper_id' => $reservedPaperId,
-            'textbook_id' => $taskPayload['textbook_id'] ?? null,
-            'chapter_id_list' => $taskPayload['chapter_id_list'] ?? [],
-            'kp_code_list' => $taskPayload['kp_code_list'] ?? [],
-            'kp_codes' => $taskPayload['kp_codes'] ?? [],
-            'total_questions' => $taskPayload['total_questions'] ?? null,
-        ]);
-
         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,
+                    'paper_id' => $responsePaperId,
+                    '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,

+ 279 - 33
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;
@@ -1078,6 +1081,180 @@ class ExamPdfController extends Controller
         ]);
     }
 
+    /**
+     * 按题号顺序从试卷题目反推知识点编码,去重(集合顺序用于同级兜底)。
+     * 展示顺序由 {@see sortKnowledgePointCodesByHierarchy} 按知识点树 DFS 决定。
+     *
+     * @return array<int,string>
+     */
+    private function extractKnowledgePointCodesFromPaperQuestions(string $paperId): array
+    {
+        $paperQuestions = \App\Models\PaperQuestion::query()
+            ->where('paper_id', $paperId)
+            ->orderBy('question_number')
+            ->get();
+
+        if ($paperQuestions->isEmpty()) {
+            return [];
+        }
+
+        $questionBankIds = $paperQuestions
+            ->pluck('question_bank_id')
+            ->filter()
+            ->unique()
+            ->values();
+
+        $questionKpMap = [];
+        if ($questionBankIds->isNotEmpty()) {
+            $questionKpMap = \App\Models\Question::query()
+                ->whereIn('id', $questionBankIds)
+                ->pluck('kp_code', 'id')
+                ->toArray();
+        }
+
+        $ordered = [];
+        $seen = [];
+
+        foreach ($paperQuestions as $pq) {
+            $kpCode = trim((string) ($pq->knowledge_point ?? ''));
+            if ($kpCode === '' && ! empty($pq->question_bank_id)) {
+                $kpCode = trim((string) ($questionKpMap[$pq->question_bank_id] ?? ''));
+            }
+            if ($kpCode === '') {
+                continue;
+            }
+            if (isset($seen[$kpCode])) {
+                continue;
+            }
+            $seen[$kpCode] = true;
+            $ordered[] = $kpCode;
+        }
+
+        return $ordered;
+    }
+
+    /**
+     * papers.params 中的 kp_codes / kp_code_list(仅作无 paper_questions 时的兜底,例如未入库题目的讲解卷)。
+     *
+     * @param  array<string,mixed>|null  $params
+     * @return array<int,string>
+     */
+    private function kpCodesFromAssembleRequestPayload(?array $params): array
+    {
+        if ($params === null || $params === []) {
+            return [];
+        }
+
+        $seen = [];
+        foreach (['kp_codes', 'kp_code_list'] as $key) {
+            if (! isset($params[$key])) {
+                continue;
+            }
+            $raw = $params[$key];
+            if (is_string($raw)) {
+                $parts = array_filter(array_map('trim', explode(',', $raw)));
+                foreach ($parts as $c) {
+                    if ($c !== '') {
+                        $seen[$c] = true;
+                    }
+                }
+            } elseif (is_array($raw)) {
+                foreach ($raw as $c) {
+                    $c = trim((string) $c);
+                    if ($c !== '') {
+                        $seen[$c] = true;
+                    }
+                }
+            }
+        }
+
+        return array_keys($seen);
+    }
+
+    /**
+     * 按知识点树层级排序:根在前,同层兄弟按 kp_code 字典序,深度优先。
+     * 库中不存在的编码保留在末尾,顺序与原列表一致。
+     *
+     * @param  array<int,string>  $kpCodes
+     * @return array<int,string>
+     */
+    private function sortKnowledgePointCodesByHierarchy(array $kpCodes): array
+    {
+        $kpCodes = array_values(array_unique(array_filter(array_map(static fn ($c) => trim((string) $c), $kpCodes), static fn ($c) => $c !== '')));
+        if (count($kpCodes) <= 1) {
+            return $kpCodes;
+        }
+
+        $byCode = [];
+        $pending = $kpCodes;
+        $guard = 0;
+        while ($pending !== [] && $guard < 100) {
+            $guard++;
+            $rows = \App\Models\KnowledgePoint::query()
+                ->whereIn('kp_code', $pending)
+                ->get()
+                ->keyBy('kp_code');
+            $next = [];
+            foreach ($rows as $code => $row) {
+                $byCode[$code] = $row;
+                $p = trim((string) ($row->parent_kp_code ?? ''));
+                if ($p !== '' && ! isset($byCode[$p])) {
+                    $next[$p] = true;
+                }
+            }
+            $pending = array_keys($next);
+        }
+
+        if ($byCode === []) {
+            return $kpCodes;
+        }
+
+        $childrenByParent = [];
+        foreach ($byCode as $code => $row) {
+            $p = trim((string) ($row->parent_kp_code ?? ''));
+            if ($p !== '' && isset($byCode[$p])) {
+                $childrenByParent[$p][] = $code;
+            }
+        }
+        foreach ($childrenByParent as &$kids) {
+            sort($kids, SORT_STRING);
+        }
+        unset($kids);
+
+        $roots = [];
+        foreach ($byCode as $code => $row) {
+            $p = trim((string) ($row->parent_kp_code ?? ''));
+            if ($p === '' || ! isset($byCode[$p])) {
+                $roots[] = $code;
+            }
+        }
+        sort($roots, SORT_STRING);
+
+        $want = array_flip($kpCodes);
+        $out = [];
+
+        $visit = function (string $node) use (&$visit, &$out, &$want, &$childrenByParent): void {
+            if (isset($want[$node])) {
+                $out[] = $node;
+            }
+            foreach ($childrenByParent[$node] ?? [] as $child) {
+                $visit($child);
+            }
+        };
+
+        foreach ($roots as $r) {
+            $visit($r);
+        }
+
+        foreach ($kpCodes as $c) {
+            if (! isset($byCode[$c])) {
+                $out[] = $c;
+            }
+        }
+
+        return $out;
+    }
+
     /**
      * 知识点讲解视图
      */
@@ -1096,40 +1273,24 @@ class ExamPdfController extends Controller
         // 生成时间(格式:2026年01月30日 15:04:05)
         $generateDateTime = now()->format('Y年m月d日 H:i:s');
 
-        // 优先使用 paper 中保存的 explanation_kp_codes(组卷时指定的知识点,最多2个)
-        $kpCodes = $paper->explanation_kp_codes ?? [];
-
-        // 如果没有保存 explanation_kp_codes,回退到从题目中提取(兼容旧数据)
-        if (empty($kpCodes)) {
-            $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paper_id)->get();
-            $seen = [];
-
-            $questionBankIds = $paperQuestions
-                ->pluck('question_bank_id')
-                ->filter()
-                ->unique()
-                ->values();
-            $questionKpMap = [];
-            if ($questionBankIds->isNotEmpty()) {
-                $questionKpMap = \App\Models\Question::whereIn('id', $questionBankIds)
-                    ->pluck('kp_code', 'id')
-                    ->toArray();
-            }
+        // 1) 主路径:由题目反推知识点(去重后有几个展示几块)
+        $kpCodes = $this->extractKnowledgePointCodesFromPaperQuestions((string) $paper_id);
 
-            foreach ($paperQuestions as $pq) {
-                $kpCode = trim((string) ($pq->knowledge_point ?? ''));
-                if ($kpCode === '' && ! empty($pq->question_bank_id)) {
-                    $kpCode = trim((string) ($questionKpMap[$pq->question_bank_id] ?? ''));
-                }
-                if ($kpCode === '') {
-                    continue;
-                }
-                if (isset($seen[$kpCode])) {
-                    continue;
-                }
-                $seen[$kpCode] = true;
-                $kpCodes[] = $kpCode;
-            }
+        // 2) 无题目行时:策略层 explanation_kp_codes
+        if ($kpCodes === []) {
+            $kpCodes = $paper->explanation_kp_codes ?? [];
+        }
+        if (! is_array($kpCodes)) {
+            $kpCodes = [];
+        }
+
+        // 3) 仍为空:params 快照(无 paper_questions 的边界场景)
+        if ($kpCodes === []) {
+            $kpCodes = $this->kpCodesFromAssembleRequestPayload(is_array($paper->params) ? $paper->params : null);
+        }
+
+        if (count($kpCodes) > 1) {
+            $kpCodes = $this->sortKnowledgePointCodesByHierarchy($kpCodes);
         }
 
         // 使用 ExamPdfExportService 构建知识点数据
@@ -1258,6 +1419,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_|paper_)[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(不含答案)
      *

+ 19 - 11
app/Jobs/AssembleExamTaskJob.php

@@ -9,6 +9,7 @@ use App\Services\QuestionBankService;
 use App\Services\QuestionPayloadMapper;
 use App\Services\TaskManager;
 use App\Services\WrongQuestionPracticePlanService;
+use App\Support\AssembleType;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
@@ -54,7 +55,8 @@ class AssembleExamTaskJob implements ShouldQueue
         try {
             $taskManager->updateTaskProgress($this->taskId, 5, '开始异步组卷...');
 
-            $assembleType = (int) ($data['assemble_type'] ?? 4);
+            $requestedAssembleType = (int) ($data['assemble_type'] ?? 4);
+            $strategyAssembleType = AssembleType::toStrategyType($requestedAssembleType);
             $difficultyCategory = $data['difficulty_category'] ?? 1;
             $paperName = $data['paper_name'] ?? ('智能试卷_'.now()->format('Ymd_His'));
             $mistakeIds = $data['mistake_ids'] ?? [];
@@ -68,16 +70,16 @@ class AssembleExamTaskJob implements ShouldQueue
             $explanationKpCodes = null;
             $wrongQuestionPracticePlan = null;
 
-            if (in_array($assembleType, [15, 16], true)) {
+            if (in_array($requestedAssembleType, [15, 16], true)) {
                 // assemble_type=15(错题再练):paper_ids 为题库 question_id,直组原错题。
                 // assemble_type=16(错题追练):paper_ids 仍为题库 question_id,但只用来生成知识点组卷计划。
                 $questionIdList = $this->normalizeBankQuestionIdsList($paperIds);
                 if ($questionIdList === []) {
-                    $taskManager->markTaskFailed($this->taskId, ($assembleType === 16 ? '错题追练' : '错题再练').'组卷需提供 paper_ids(题库题目 id)');
+                    $taskManager->markTaskFailed($this->taskId, ($requestedAssembleType === 16 ? '错题追练' : '错题再练').'组卷需提供 paper_ids(题库题目 id)');
                     return;
                 }
 
-                if ($assembleType === 16) {
+                if ($requestedAssembleType === 16) {
                     $wrongQuestionPracticePlan = $wrongQuestionPracticePlanService->build(
                         (string) $data['student_id'],
                         $questionIdList,
@@ -153,7 +155,7 @@ class AssembleExamTaskJob implements ShouldQueue
                 }
             } elseif (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
                 // assemble_type=5 时 mistake_ids / mistake_question_ids 须严格归属该学生;其它类型走宽松解析。
-                if ($assembleType === 5) {
+                if ($requestedAssembleType === 5) {
                     $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
                         (string) $data['student_id'],
                         $mistakeIds,
@@ -187,17 +189,17 @@ class AssembleExamTaskJob implements ShouldQueue
                     'student_id' => $data['student_id'],
                     'grade' => $data['grade'] ?? null,
                     'total_questions' => $data['total_questions'],
-                    'kp_codes' => $assembleType === 3 ? null : ($data['kp_codes'] ?? null),
+                    'kp_codes' => $strategyAssembleType === 3 ? null : ($data['kp_codes'] ?? null),
                     'skills' => $data['skills'] ?? [],
                     'question_type_ratio' => $questionTypeRatio,
                     'difficulty_category' => $difficultyCategory,
-                    'assemble_type' => $assembleType,
+                    'assemble_type' => $strategyAssembleType,
                     'exam_type' => $data['exam_type'] ?? 'general',
                     'paper_ids' => $paperIds,
                     'textbook_id' => $data['textbook_id'] ?? null,
                     'end_catalog_id' => $data['end_catalog_id'] ?? null,
                     'chapter_id_list' => $data['chapter_id_list'] ?? null,
-                    'kp_code_list' => $assembleType === 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []),
+                    'kp_code_list' => $strategyAssembleType === 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []),
                     'practice_options' => $data['practice_options'] ?? null,
                     'mistake_options' => $data['mistake_options'] ?? null,
                 ];
@@ -219,7 +221,8 @@ class AssembleExamTaskJob implements ShouldQueue
                 'task_id' => $this->taskId,
                 'phase' => 'select_and_prepare_questions',
                 'elapsed_ms' => (int) round((microtime(true) - $phaseStartedAt) * 1000),
-                'assemble_type' => $assembleType,
+                'assemble_type' => $requestedAssembleType,
+                'strategy_assemble_type' => $strategyAssembleType,
                 'question_count' => count($questions),
             ]);
 
@@ -235,7 +238,12 @@ class AssembleExamTaskJob implements ShouldQueue
             $questions = $this->adjustQuestionScores($questions, $targetTotalScore);
             $totalScore = array_sum(array_column($questions, 'score'));
 
-            $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
+            $finalAssembleType = ($result !== null && isset($result['assemble_type']))
+                ? (int) $result['assemble_type']
+                : $requestedAssembleType;
+            if (in_array($requestedAssembleType, [11, 12, 13], true)) {
+                $finalAssembleType = $requestedAssembleType;
+            }
             if ($finalAssembleType === 16) {
                 $difficultyCategory = $this->deriveDifficultyCategoryFromSelectedDistribution($questions);
             }
@@ -268,7 +276,7 @@ class AssembleExamTaskJob implements ShouldQueue
 
             $finalStats = $result['stats'] ?? [
                 'total_selected' => count($questions),
-                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || in_array($assembleType, [15, 16], true),
+                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || in_array($requestedAssembleType, [15, 16], true),
             ];
             if ($wrongQuestionPracticePlan !== null) {
                 $finalStats['wrong_question_practice_plan'] = $wrongQuestionPracticePlan;

+ 323 - 0
app/Jobs/GenerateKnowledgeExplanationPdfJob.php

@@ -0,0 +1,323 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\KnowledgeExplanation;
+use App\Models\Paper;
+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(),
+            ]);
+
+            $taskSnapshot = $taskManager->getTaskStatus($this->taskId);
+            $taskData = is_array($taskSnapshot) && isset($taskSnapshot['data']) && is_array($taskSnapshot['data'])
+                ? $taskSnapshot['data']
+                : [];
+
+            $examContent = $this->buildKnowledgeExamContent($record, $pdfUrl, $taskData);
+            $this->syncPaperRecord($record, $pdfUrl, $examContent);
+
+            $difficultyCategoryForStats = (int) ($examContent['paper_info']['difficulty_category'] ?? 2);
+
+            $taskManager->markTaskCompleted($this->taskId, [
+                'paper_id' => $this->knowledgeId,
+                'knowledge_id' => $this->knowledgeId,
+                'pdfs' => [
+                    'all_pdf' => $pdfUrl,
+                ],
+                'exam_content' => $examContent,
+                'stats' => [
+                    'difficulty_category' => $difficultyCategoryForStats,
+                    'total_selected' => count($examContent['questions'] ?? []),
+                    'difficulty_distribution_applied' => true,
+                ],
+            ]);
+            $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());
+    }
+
+    private function syncPaperRecord(KnowledgeExplanation $record, string $pdfUrl, array $examContent): void
+    {
+        $paperId = (string) ($record->knowledge_id ?? '');
+        if ($paperId === '') {
+            return;
+        }
+
+        $displayCode = (string) preg_replace('/^(paper_|knowledge_)/', '', $paperId);
+        if ($displayCode === '') {
+            $displayCode = $paperId;
+        }
+
+        $paperInfo = $examContent['paper_info'] ?? [];
+        $totalQuestions = (int) ($paperInfo['total_questions'] ?? 0);
+        $difficultyCategory = $paperInfo['difficulty_category'] ?? null;
+
+        Paper::query()->updateOrCreate(
+            ['paper_id' => $paperId],
+            [
+                'student_id' => (string) ($record->student_id ?? ''),
+                'teacher_id' => (string) ($record->teacher_id ?? ''),
+                'params' => [
+                    'source' => 'knowledge_explanation',
+                    'knowledge_id' => $paperId,
+                    'kp_codes' => is_array($record->kp_codes) ? $record->kp_codes : [],
+                ],
+                'paper_name' => '知识点讲解_' . $displayCode,
+                'paper_type' => 22,
+                'total_questions' => $totalQuestions,
+                'total_score' => 100,
+                'status' => 'completed',
+                'difficulty_category' => $difficultyCategory !== null && $difficultyCategory !== ''
+                    ? (string) $difficultyCategory
+                    : null,
+                'exam_pdf_url' => $pdfUrl,
+                'grading_pdf_url' => null,
+                'all_pdf_url' => $pdfUrl,
+                'completed_at' => now(),
+            ]
+        );
+    }
+
+    private function buildKnowledgeExamContent(KnowledgeExplanation $record, string $pdfUrl, array $taskData = []): array
+    {
+        $paperId = (string) ($record->knowledge_id ?? '');
+        $displayCode = (string) preg_replace('/^(paper_|knowledge_)/', '', $paperId);
+        if ($displayCode === '') {
+            $displayCode = $paperId;
+        }
+        $kpCodes = is_array($record->kp_codes) ? array_values($record->kp_codes) : [];
+        $difficultyCategoryRaw = $taskData['difficulty_category'] ?? null;
+        $difficultyCategoryStr = $difficultyCategoryRaw !== null && $difficultyCategoryRaw !== ''
+            ? (string) $difficultyCategoryRaw
+            : '2';
+
+        $questions = $this->buildKnowledgeQuestions(100);
+        $knowledgeDistribution = [];
+        foreach ($kpCodes as $kpCode) {
+            $knowledgeDistribution[(string) $kpCode] = 0;
+        }
+        foreach ($questions as $q) {
+            $kp = (string) ($q['knowledge_point'] ?? '');
+            if ($kp === '') {
+                continue;
+            }
+            $knowledgeDistribution[$kp] = (int) ($knowledgeDistribution[$kp] ?? 0) + 1;
+        }
+
+        $typeDistribution = ['choice' => 0, 'fill' => 0, 'answer' => 0];
+        foreach ($questions as $q) {
+            $t = (string) ($q['question_type'] ?? 'answer');
+            if ($t === 'choice') {
+                $typeDistribution['choice']++;
+            } elseif ($t === 'fill') {
+                $typeDistribution['fill']++;
+            } else {
+                $typeDistribution['answer']++;
+            }
+        }
+
+        $difficultyDistribution = [];
+        $difficultySum = 0.0;
+        $difficultyCount = 0;
+        foreach ($questions as $q) {
+            $dl = $q['metadata']['difficulty_label'] ?? '';
+            if ($dl !== '') {
+                $difficultyDistribution[$dl] = ($difficultyDistribution[$dl] ?? 0) + 1;
+            }
+            if (isset($q['difficulty']) && is_numeric($q['difficulty'])) {
+                $difficultySum += (float) $q['difficulty'];
+                $difficultyCount++;
+            }
+        }
+
+        $averageDifficulty = $difficultyCount > 0 ? $difficultySum / $difficultyCount : null;
+        $totalEstimatedTime = count($questions) * 300;
+
+        return [
+            'paper_info' => [
+                'paper_id' => $paperId,
+                'paper_name' => '知识点讲解_' . $displayCode,
+                'student_id' => (string) ($record->student_id ?? ''),
+                'teacher_id' => (string) ($record->teacher_id ?? ''),
+                'total_questions' => count($questions),
+                'total_score' => 100,
+                'difficulty_category' => $difficultyCategoryStr,
+                'created_at' => optional($record->created_at)->toISOString(),
+                'updated_at' => optional($record->updated_at)->toISOString(),
+                'exam_code' => $displayCode,
+                'grading_code' => $displayCode,
+                'paper_id_num' => $displayCode,
+            ],
+            'questions' => $questions,
+            'knowledge_points' => $kpCodes,
+            'statistics' => [
+                'type_distribution' => $typeDistribution,
+                'difficulty_distribution' => $difficultyDistribution,
+                'knowledge_point_distribution' => $knowledgeDistribution,
+                'average_difficulty' => $averageDifficulty,
+                'total_estimated_time' => $totalEstimatedTime,
+            ],
+            'pdfs' => [
+                'all_pdf' => $pdfUrl,
+            ],
+            'source' => 'knowledge_explanation',
+        ];
+    }
+
+    private function buildKnowledgeQuestions(int $totalScore = 100): array
+    {
+        $questions = [];
+        $seq = 1;
+        foreach ($this->knowledgePoints as $point) {
+            $kpCode = (string) ($point['kp_code'] ?? '');
+            $kpName = (string) ($point['kp_name'] ?? $kpCode);
+            $cases = is_array($point['cases'] ?? null) ? $point['cases'] : [];
+            foreach ($cases as $case) {
+                $questionId = (int) ($case['question_id'] ?? 0);
+                $qType = (string) ($case['question_type'] ?? 'answer');
+                $solutionText = (string) ($case['solution'] ?? '');
+                $difficultyVal = isset($case['difficulty']) && is_numeric($case['difficulty']) ? (float) $case['difficulty'] : null;
+                $questions[] = [
+                    'question_number' => $seq++,
+                    'question_id' => $questionId > 0 ? (string) $questionId : '',
+                    'question_bank_id' => $questionId > 0 ? $questionId : null,
+                    'question_type' => $qType,
+                    'knowledge_point' => $kpCode,
+                    'knowledge_point_name' => $kpName,
+                    'difficulty' => $difficultyVal,
+                    'score' => 0,
+                    'estimated_time' => 300,
+                    'stem' => (string) ($case['stem'] ?? ''),
+                    'options' => is_array($case['options'] ?? null) ? $case['options'] : [],
+                    'correct_answer' => (string) ($case['answer'] ?? ''),
+                    'solution' => $solutionText,
+                    'metadata' => [
+                        'source_type' => (string) ($case['source_type'] ?? ''),
+                        'source_label' => (string) ($case['source_label'] ?? ''),
+                        'is_wrong_case' => (bool) ($case['is_wrong_case'] ?? false),
+                        'child_kp_code' => $case['child_kp_code'] ?? null,
+                        'child_kp_name' => $case['child_kp_name'] ?? null,
+                        'has_solution' => $solutionText !== '',
+                        'is_choice' => $qType === 'choice',
+                        'is_fill' => $qType === 'fill',
+                        'is_answer' => $qType === 'answer',
+                        'difficulty_label' => $this->difficultyLabelForPayload($difficultyVal),
+                        'question_type_label' => $this->questionTypeLabelForPayload($qType),
+                    ],
+                ];
+            }
+        }
+
+        $count = count($questions);
+        if ($count > 0) {
+            $baseScore = intdiv($totalScore, $count);
+            $remainder = $totalScore - ($baseScore * $count);
+            foreach ($questions as &$q) {
+                $q['score'] = $baseScore;
+            }
+            unset($q);
+            if ($remainder > 0) {
+                // 余数分散到末尾若干题,保证总分精确为 totalScore
+                for ($i = $count - $remainder; $i < $count; $i++) {
+                    if ($i >= 0 && isset($questions[$i])) {
+                        $questions[$i]['score'] += 1;
+                    }
+                }
+            }
+        }
+
+        return $questions;
+    }
+
+    private function questionTypeLabelForPayload(?string $type): string
+    {
+        return match ($type) {
+            'choice' => '选择题',
+            'fill' => '填空题',
+            'answer' => '解答题',
+            default => '未知题型',
+        };
+    }
+
+    private function difficultyLabelForPayload(?float $difficulty): string
+    {
+        if ($difficulty === null) {
+            return '未知';
+        }
+        if ($difficulty <= 0.4) {
+            return '基础';
+        }
+        if ($difficulty <= 0.7) {
+            return '中等';
+        }
+
+        return '拔高';
+    }
+}

+ 82 - 0
app/Jobs/GenerateKnowledgeExplanationTaskJob.php

@@ -0,0 +1,82 @@
+<?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, [
+                'paper_id' => $knowledgeId,
+                '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' => '组卷类型;11/12/13 分别等同 1/2/3 选题规则且 PDF 含知识点讲解;15=错题再练;16=错题追练;22=知识点讲解PDF'],
                             ['name' => 'paper_ids', 'type' => 'array', 'required' => false, 'description' => 'assemble_type=15/16 时临时承载题库题目 question_id 列表'],
                         ],
                     ],

+ 92 - 124
app/Services/ExamPdfExportService.php

@@ -5,9 +5,12 @@ 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\Support\AssembleType;
 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 +53,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;
@@ -160,11 +162,11 @@ class ExamPdfExportService
             $lastMark = $now;
         };
 
-        // 与组卷规则保持一致:仅知识点组卷类型(paper_type=2)包含知识点讲解
+        // assemble_type 11/12/13:与同基准 1/2/3 组卷相同,但 PDF 叠加知识点讲解;1/2/3 本身不带讲解页
         $paperType = Paper::query()
             ->where('paper_id', $paperId)
             ->value('paper_type');
-        $shouldIncludeKpExplain = ((int) $paperType) === 2;
+        $shouldIncludeKpExplain = AssembleType::includesKnowledgeExplanationPdf((int) $paperType);
         $mark('load_paper_type_ms');
 
         Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', [
@@ -326,6 +328,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_|paper_)/', '', (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 +1996,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 +4287,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_|paper_)/', '', $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);

+ 1 - 1
app/Services/ExamTypeStrategy.php

@@ -35,7 +35,7 @@ class ExamTypeStrategy
 
     /**
      * 根据组卷类型构建参数
-     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-按卷追练(paper_ids=试卷), 8-智能组卷(新), 9-原摸底, 15-错题再练(paper_ids=题库题目id,由 AssembleExamTaskJob 单独处理), 16-错题追练(paper_ids=题库题目id,由 AssembleExamTaskJob 单独处理)
+     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-按卷追练(paper_ids=试卷), 8-智能组卷(新), 9-原摸底, 11/12/13-含知识点讲解变体(入参先映射为 1/2/3 再走下列规则), 15-错题再练, 16-错题追练
      *
      * 映射规则(前端不改,后端动态处理):
      * - 0, 9(摸底)→ 章节摸底(新逻辑)

+ 659 - 0
app/Services/KnowledgeExplanationService.php

@@ -0,0 +1,659 @@
+<?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\Database\QueryException;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
+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 = 'paper_' . $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 格式非法,必须为 paper_ + 15位数字(兼容 knowledge_ 前缀)');
+        }
+        $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));
+
+        $recordPayload = [
+            '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,
+        ];
+
+        try {
+            $record = KnowledgeExplanation::updateOrCreate([
+                'knowledge_id' => $knowledgeId,
+            ], $recordPayload);
+        } catch (QueryException $e) {
+            if (! $this->isDuplicatePrimaryKeyError($e)) {
+                throw $e;
+            }
+
+            // 兼容线上历史表主键异常(id 非正常自增):
+            // 1) 若 knowledge_id 已存在则直接更新;
+            // 2) 否则手动分配一个递增 id 再插入,避免任务失败。
+            $record = $this->persistWithManualIdFallback($knowledgeId, $recordPayload);
+        }
+
+        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 格式非法,必须为 paper_ + 15位数字(兼容 knowledge_ 前缀)');
+        }
+
+        $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('/^(?:paper_|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 isDuplicatePrimaryKeyError(QueryException $e): bool
+    {
+        $message = (string) $e->getMessage();
+
+        return str_contains($message, 'Integrity constraint violation: 1062')
+            && str_contains($message, 'knowledge_explanations.PRIMARY');
+    }
+
+    private function persistWithManualIdFallback(string $knowledgeId, array $recordPayload): KnowledgeExplanation
+    {
+        $existing = KnowledgeExplanation::query()
+            ->where('knowledge_id', $knowledgeId)
+            ->first();
+        if ($existing) {
+            $existing->fill($recordPayload);
+            $existing->save();
+
+            return $existing;
+        }
+
+        return DB::transaction(function () use ($knowledgeId, $recordPayload): KnowledgeExplanation {
+            $table = (new KnowledgeExplanation())->getTable();
+            $maxId = (int) DB::table($table)->lockForUpdate()->max('id');
+            $nextId = $maxId + 1;
+            $now = now();
+
+            DB::table($table)->insert(array_merge($recordPayload, [
+                'id' => $nextId,
+                'knowledge_id' => $knowledgeId,
+                'created_at' => $now,
+                'updated_at' => $now,
+            ]));
+
+            return KnowledgeExplanation::query()
+                ->where('knowledge_id', $knowledgeId)
+                ->firstOrFail();
+        });
+    }
+
+    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...');

+ 12 - 1
app/Services/TaskManager.php

@@ -288,8 +288,19 @@ class TaskManager
         // 根据任务类型添加特定数据
         if ($task['type'] === self::TASK_TYPE_EXAM) {
             $basePayload['callback_type'] = 'exam_pdf_generated';
-            $basePayload['paper_id'] = $task['data']['paper_id'] ?? null;
+            $basePayload['paper_id'] = $task['data']['paper_id']
+                ?? ($task['paper_id'] ?? null)
+                ?? ($task['data']['knowledge_id'] ?? null)
+                ?? ($task['knowledge_id'] ?? null);
+            // 兼容历史调用方(新调用方统一读取 paper_id)
+            $basePayload['knowledge_id'] = $task['data']['knowledge_id'] ?? ($task['knowledge_id'] ?? null);
             $basePayload['pdfs'] = $task['pdfs'] ?? null;
+            // 兼容旧回调消费方:同时提供顶层 URL 字段,避免只读 pdf_url 导致“回调成功但前端无链接”
+            $basePayload['pdf_url'] = $task['pdfs']['all_pdf']
+                ?? $task['pdfs']['exam_paper_pdf']
+                ?? ($task['pdf_url'] ?? null);
+            $basePayload['grading_pdf_url'] = $task['pdfs']['grading_pdf']
+                ?? ($task['grading_pdf_url'] ?? null);
             $basePayload['exam_content'] = $task['exam_content'] ?? null;
             $basePayload['stats'] = $task['stats'] ?? null;
         } elseif ($task['type'] === self::TASK_TYPE_ANALYSIS) {

+ 32 - 0
app/Support/AssembleType.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Support;
+
+/**
+ * assemble_type 约定:
+ * - 1/2/3(及 8 等既有类型):组卷逻辑按原语义;统一 PDF 不带知识点讲解前置页。
+ * - 11/12/13:分别与 1/2/3 相同的选题与策略,但 paper_type 存 11/12/13,且 PDF 叠加知识点讲解。
+ */
+final class AssembleType
+{
+    /**
+     * 「含讲解」变体 → 策略层类型(传给 ExamTypeStrategy / generateIntelligentExam)。
+     */
+    public static function toStrategyType(int $assembleType): int
+    {
+        return match ($assembleType) {
+            11 => 1,
+            12 => 2,
+            13 => 3,
+            default => $assembleType,
+        };
+    }
+
+    /**
+     * 统一 PDF(generateUnifiedPdf)是否在试卷前插入知识点讲解 HTML。
+     */
+    public static function includesKnowledgeExplanationPdf(int $paperType): bool
+    {
+        return in_array($paperType, [11, 12, 13], true);
+    }
+}

+ 4 - 3
app/Support/PaperNaming.php

@@ -11,12 +11,13 @@ class PaperNaming
     {
         return match ($assembleType) {
             0, 9 => '智能摸底',
-            1, 4, 8 => '智能组题',
-            2 => '知识点组题',
-            3 => '教材组题',
+            1, 4, 8, 11 => '智能组题',
+            2, 12 => '知识点组题',
+            3, 13 => '教材组题',
             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

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

@@ -0,0 +1,470 @@
+<!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;
+        }
+        /* 题干插图:局部居中,避免影响其它 PDF 模板 */
+        .case-list .kp-case-stem img,
+        .case-list .kp-case-stem svg {
+            display: block;
+            margin: 6px auto;
+        }
+
+        /* ========== 解析区 ========== */
+        .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_|paper_)\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_|paper_)/', '', $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');