Kaynağa Gözat

fix(组卷): 教材章节走关联表选知识点、补 renderAndStoreExamPdf

- ExamTypeStrategy: chapter_id_list 反查 textbook_id
- LearningAnalyticsService: 智能补充用 textbook_chapter_knowledge_relation 限定 kp_code,不再按题目 textbook_catalog_nodes_id 筛
- ExamPdfExportService: 实现 renderAndStoreExamPdf(供 generateExamPdf/generateGradingPdf 上传存储)

Made-with: Cursor
yemeishu 6 gün önce
ebeveyn
işleme
9daa4cb8c3

+ 53 - 0
app/Services/ExamPdfExportService.php

@@ -71,6 +71,59 @@ class ExamPdfExportService
         return $url;
     }
 
+    /**
+     * 渲染试卷 HTML → 生成 PDF → 上传存储(generateExamPdf / generateGradingPdf 共用)
+     */
+    private function renderAndStoreExamPdf(string $paperId, bool $includeAnswer, string $suffix, bool $useGradingView = false): ?string
+    {
+        $html = $this->renderExamHtml($paperId, $includeAnswer, $useGradingView);
+        if ($html === null || trim($html) === '') {
+            Log::error('renderAndStoreExamPdf: HTML 为空', [
+                'paper_id' => $paperId,
+                'suffix' => $suffix,
+            ]);
+
+            return null;
+        }
+
+        $pdfBinary = $this->buildPdf($html);
+        if ($pdfBinary === null || $pdfBinary === '') {
+            Log::error('renderAndStoreExamPdf: buildPdf 失败', [
+                'paper_id' => $paperId,
+                'suffix' => $suffix,
+            ]);
+
+            return null;
+        }
+
+        $paper = Paper::query()->where('paper_id', $paperId)->first();
+        if (! $paper) {
+            Log::error('renderAndStoreExamPdf: 试卷不存在', ['paper_id' => $paperId]);
+
+            return null;
+        }
+
+        $stamp = now()->format('YmdHis').strtoupper(Str::random(4));
+        $base = $this->buildPaperNamePrefix($paper).'_'.$suffix.'_'.$stamp;
+        $safe = PaperNaming::toSafeFilename($base).'.pdf';
+        $path = 'exams/'.$safe;
+
+        $url = $this->pdfStorageService->put($path, $pdfBinary);
+        if (! $url) {
+            Log::error('renderAndStoreExamPdf: 上传失败', ['paper_id' => $paperId, 'path' => $path]);
+
+            return null;
+        }
+
+        Log::info('renderAndStoreExamPdf: 完成', [
+            'paper_id' => $paperId,
+            'suffix' => $suffix,
+            'url' => $url,
+        ]);
+
+        return $url;
+    }
+
     /**
      * 【优化方案】生成统一PDF(卷子 + 判卷一页完成)
      * 效率提升40-50%,只需生成一次PDF

+ 55 - 2
app/Services/ExamTypeStrategy.php

@@ -1145,7 +1145,7 @@ class ExamTypeStrategy
         if (empty($kpCodes)) {
             Log::warning('ExamTypeStrategy: 未找到章节知识点关联,但保留章节筛选参数', [
                 'chapter_id_list' => $chapterIdList,
-                'note' => '将在LearningAnalyticsService中按textbook_catalog_nodes_id字段筛选题目'
+                'note' => 'LearningAnalyticsService 将按 textbook_chapter_knowledge_relation 解析知识点后按 kp_code 选题',
             ]);
         } else {
             Log::info('ExamTypeStrategy: 获取章节知识点(教材组卷严格限制)', [
@@ -1169,8 +1169,19 @@ class ExamTypeStrategy
         // 在LearningAnalyticsService中会使用这些参数进行题目筛选
         $textbookCatalogNodeIds = $chapterIdList; // 直接使用章节ID作为textbook_catalog_node_id筛选条件
 
-        // 【重要】确保 grade 和 textbook_id 传给智能补充;grade 缺失时从教材推断
+        // 【重要】确保 grade 和 textbook_id 传给智能补充;章节节点归属教材,可由 chapter_id_list 反查 textbook_id
         $textbookId = (int) ($params['textbook_id'] ?? 0);
+        if ($textbookId <= 0 && ! empty($chapterIdList)) {
+            $resolvedTextbookId = $this->resolveTextbookIdFromChapterNodes($chapterIdList);
+            if ($resolvedTextbookId !== null) {
+                $textbookId = $resolvedTextbookId;
+                Log::info('ExamTypeStrategy: 由 chapter_id_list 推断 textbook_id', [
+                    'textbook_id' => $textbookId,
+                    'chapter_id_list' => $chapterIdList,
+                ]);
+            }
+        }
+
         $grade = $params['grade'] ?? null;
         if ($textbookId && $grade === null) {
             $grade = DB::table('textbooks')->where('id', $textbookId)->value('grade');
@@ -1217,6 +1228,48 @@ class ExamTypeStrategy
         return $enhanced;
     }
 
+    /**
+     * 章节节点 textbook_catalog_nodes 均归属某本教材;未传 textbook_id 时可根据节点 id 反查。
+     *
+     * @param  array<int|string>  $chapterIdList  解析后的章节/小节节点 id 列表
+     * @return int|null  唯一教材 id;无法解析或节点不存在时返回 null
+     */
+    private function resolveTextbookIdFromChapterNodes(array $chapterIdList): ?int
+    {
+        $ids = array_values(array_unique(array_filter(array_map(
+            static fn ($id) => (int) $id,
+            $chapterIdList
+        ), static fn (int $id) => $id > 0)));
+
+        if ($ids === []) {
+            return null;
+        }
+
+        $textbookIds = DB::table('textbook_catalog_nodes')
+            ->whereIn('id', $ids)
+            ->pluck('textbook_id')
+            ->unique()
+            ->values()
+            ->all();
+
+        if ($textbookIds === []) {
+            Log::warning('ExamTypeStrategy: chapter_id_list 在 textbook_catalog_nodes 中无匹配行', [
+                'chapter_ids' => $ids,
+            ]);
+
+            return null;
+        }
+
+        if (count($textbookIds) > 1) {
+            Log::warning('ExamTypeStrategy: chapter_id_list 对应多本教材,取首个 textbook_id', [
+                'textbook_ids' => $textbookIds,
+                'chapter_ids' => $ids,
+            ]);
+        }
+
+        return (int) $textbookIds[0];
+    }
+
     /**
      * 根据课本ID获取章节ID列表
      * @param int $textbookId 教材ID

+ 60 - 16
app/Services/LearningAnalyticsService.php

@@ -2957,6 +2957,39 @@ class LearningAnalyticsService
                 return [];
             }
 
+            // 有教材且带章节:用 textbook_chapter_knowledge_relation 由「前章节」解析知识点,再按 kp_code 选题(不用 questions.textbook_catalog_nodes_id)
+            $effectiveKpCodes = $gradeKpCodes;
+            if ($textbookId && ! empty($textbookCatalogNodeIds)) {
+                $allowedNodeIds = $this->getEarlierChapterNodeIds((int) $textbookId, $textbookCatalogNodeIds);
+                if (empty($allowedNodeIds)) {
+                    Log::warning('getSupplementaryQuestionsForGrade: 未找到前章节节点,跳过补充');
+
+                    return [];
+                }
+                $chapterKpCodes = $this->getKpCodesForCatalogChapterIds($allowedNodeIds);
+                if (empty($chapterKpCodes)) {
+                    Log::warning('getSupplementaryQuestionsForGrade: 前章节在 textbook_chapter_knowledge_relation 中无知识点', [
+                        'allowed_node_ids' => $allowedNodeIds,
+                    ]);
+
+                    return [];
+                }
+                $effectiveKpCodes = array_values(array_intersect($gradeKpCodes, $chapterKpCodes));
+                if (empty($effectiveKpCodes)) {
+                    Log::warning('getSupplementaryQuestionsForGrade: 教材年级知识点与章节关联知识点无交集', [
+                        'grade_kp_count' => count($gradeKpCodes),
+                        'chapter_kp_count' => count($chapterKpCodes),
+                    ]);
+
+                    return [];
+                }
+                Log::info('getSupplementaryQuestionsForGrade: 按章节关联知识点缩小补充范围', [
+                    'allowed_chapter_nodes' => count($allowedNodeIds),
+                    'chapter_kp_count' => count($chapterKpCodes),
+                    'effective_kp_count' => count($effectiveKpCodes),
+                ]);
+            }
+
             // 查询同年级其他知识点的题目
             $query = \App\Models\Question::query();
 
@@ -2982,22 +3015,7 @@ class LearningAnalyticsService
                 $query->whereNotIn('kp_code', $existingKpCodes);
             }
 
-            $query->whereIn('kp_code', $gradeKpCodes);
-
-            // 【新增】仅从同教材前章节补充:部分章节尚未学过,不补充未学章节的题目
-            if ($textbookId && !empty($textbookCatalogNodeIds)) {
-                $allowedNodeIds = $this->getEarlierChapterNodeIds((int) $textbookId, $textbookCatalogNodeIds);
-                if (!empty($allowedNodeIds)) {
-                    $query->whereIn('textbook_catalog_nodes_id', $allowedNodeIds);
-                    Log::info('getSupplementaryQuestionsForGrade: 限制为前章节', [
-                        'allowed_node_count' => count($allowedNodeIds),
-                        'max_sort_order' => '同选中章节及之前'
-                    ]);
-                } else {
-                    Log::warning('getSupplementaryQuestionsForGrade: 未找到前章节节点,跳过补充');
-                    return [];
-                }
-            }
+            $query->whereIn('kp_code', $effectiveKpCodes);
 
             // 筛选有解题思路的题目
             $query->whereNotNull('solution')
@@ -3180,6 +3198,32 @@ class LearningAnalyticsService
         }
     }
 
+    /**
+     * 由 catalog 章节节点 ID 列表,从 textbook_chapter_knowledge_relation 取关联的知识点编码(用于选题,而非题目表上的章节字段)。
+     *
+     * @param  array<int>  $catalogChapterIds  textbook_catalog_nodes.id
+     * @return list<string>
+     */
+    private function getKpCodesForCatalogChapterIds(array $catalogChapterIds): array
+    {
+        $ids = array_values(array_unique(array_filter(array_map(
+            static fn ($id) => (int) $id,
+            $catalogChapterIds
+        ), static fn (int $id) => $id > 0)));
+
+        if ($ids === []) {
+            return [];
+        }
+
+        return array_values(array_filter(array_unique(DB::table('textbook_chapter_knowledge_relation')
+            ->whereIn('catalog_chapter_id', $ids)
+            ->where(function ($q) {
+                $q->where('is_deleted', 0)->orWhereNull('is_deleted');
+            })
+            ->pluck('kp_code')
+            ->toArray())));
+    }
+
     private function getGradeKnowledgePoints(int $grade, ?int $textbookId = null): array
     {
         try {