Parcourir la source

智能出卷(错题本、追练)

yemeishu il y a 2 jours
Parent
commit
8a20cef8ff

+ 30 - 7
app/Http/Controllers/Api/IntelligentExamController.php

@@ -65,8 +65,8 @@ class IntelligentExamController extends Controller
             'grade' => 'required|integer|in:7,8,9',
             'grade' => 'required|integer|in:7,8,9',
             'student_name' => 'required|string|max:50',
             'student_name' => 'required|string|max:50',
             'teacher_name' => 'required|string|max:50',
             'teacher_name' => 'required|string|max:50',
-            'total_questions' => 'nullable|integer|min:6|max:100',
-            'difficulty_category' => 'nullable|string',
+            'total_questions' => 'nullable|integer|min:1|max:100',
+            'difficulty_category' => 'nullable|integer|in:1,2,3,4',
             'kp_codes' => 'nullable|array',
             'kp_codes' => 'nullable|array',
             'kp_codes.*' => 'string',
             'kp_codes.*' => 'string',
             'skills' => 'nullable|array',
             'skills' => 'nullable|array',
@@ -80,7 +80,11 @@ class IntelligentExamController extends Controller
             'mistake_question_ids.*' => 'string',
             'mistake_question_ids.*' => 'string',
             'callback_url' => 'nullable|url',  // 异步完成后推送通知的URL
             'callback_url' => 'nullable|url',  // 异步完成后推送通知的URL
             // 新增:组卷类型
             // 新增:组卷类型
+            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,6',
             'exam_type' => 'nullable|string|in:general,diagnostic,practice,mistake,textbook,knowledge,knowledge_points',
             'exam_type' => 'nullable|string|in:general,diagnostic,practice,mistake,textbook,knowledge,knowledge_points',
+            // 错题本类型专用参数
+            'paper_ids' => 'nullable|array',
+            'paper_ids.*' => 'string',
             // 新增:专项练习选项
             // 新增:专项练习选项
             'practice_options' => 'nullable|array',
             'practice_options' => 'nullable|array',
             'practice_options.weakness_threshold' => 'nullable|numeric|min:0|max:1',
             'practice_options.weakness_threshold' => 'nullable|numeric|min:0|max:1',
@@ -125,6 +129,8 @@ class IntelligentExamController extends Controller
         $difficultyCategory = $this->normalizeDifficultyCategory($data['difficulty_category'] ?? null);
         $difficultyCategory = $this->normalizeDifficultyCategory($data['difficulty_category'] ?? null);
         $mistakeIds = $data['mistake_ids'] ?? [];
         $mistakeIds = $data['mistake_ids'] ?? [];
         $mistakeQuestionIds = $data['mistake_question_ids'] ?? [];
         $mistakeQuestionIds = $data['mistake_question_ids'] ?? [];
+        $paperIds = $data['paper_ids'] ?? [];
+        $assembleType = $data['assemble_type'] ?? 4; // 默认为通用类型(4)
 
 
         try {
         try {
             $questions = [];
             $questions = [];
@@ -157,7 +163,7 @@ class IntelligentExamController extends Controller
                 $paperName = $data['paper_name'] ?? ('错题复习_' . $data['student_id'] . '_' . now()->format('Ymd_His'));
                 $paperName = $data['paper_name'] ?? ('错题复习_' . $data['student_id'] . '_' . now()->format('Ymd_His'));
             } else {
             } else {
                 // 第一步:生成智能试卷(同步)
                 // 第一步:生成智能试卷(同步)
-                $result = $this->learningAnalyticsService->generateIntelligentExam([
+                $params = [
                     'student_id' => $data['student_id'],
                     'student_id' => $data['student_id'],
                     'grade' => $data['grade'] ?? null,
                     'grade' => $data['grade'] ?? null,
                     'total_questions' => $data['total_questions'],
                     'total_questions' => $data['total_questions'],
@@ -165,10 +171,17 @@ class IntelligentExamController extends Controller
                     'skills' => $data['skills'] ?? [],
                     'skills' => $data['skills'] ?? [],
                     'question_type_ratio' => $questionTypeRatio,
                     'question_type_ratio' => $questionTypeRatio,
                     'difficulty_ratio' => $difficultyRatio,
                     'difficulty_ratio' => $difficultyRatio,
-                    'exam_type' => $data['exam_type'] ?? 'general', // 传递组卷类型
+                    'assemble_type' => $assembleType, // 新版组卷类型
+                    'exam_type' => $data['exam_type'] ?? 'general', // 兼容旧版参数
+                    'paper_ids' => $paperIds, // 错题本类型专用参数
+                    'textbook_id' => $data['textbook_id'] ?? null, // 摸底和智能组卷专用
+                    'chapter_id_list' => $data['chapter_id_list'] ?? null, // 教材组卷专用
+                    'kp_code_list' => $data['kp_code_list'] ?? null, // 知识点组卷专用
                     'practice_options' => $data['practice_options'] ?? null, // 传递专项练习选项
                     'practice_options' => $data['practice_options'] ?? null, // 传递专项练习选项
                     'mistake_options' => $data['mistake_options'] ?? null, // 传递错题选项
                     'mistake_options' => $data['mistake_options'] ?? null, // 传递错题选项
-                ]);
+                ];
+
+                $result = $this->learningAnalyticsService->generateIntelligentExam($params);
 
 
                 if (empty($result['success'])) {
                 if (empty($result['success'])) {
                     $errorMsg = $result['message'] ?? '智能出卷失败';
                     $errorMsg = $result['message'] ?? '智能出卷失败';
@@ -201,8 +214,18 @@ class IntelligentExamController extends Controller
                 ], 400);
                 ], 400);
             }
             }
 
 
-            $totalQuestions = min($data['total_questions'], count($questions));
-            $questions = array_slice($questions, 0, $totalQuestions);
+            // 错题本类型不需要限制题目数量,由错题数量决定
+            if ($assembleType === 5) {
+                // 错题本:使用所有错题,不限制数量
+                Log::info('错题本类型,使用所有错题', [
+                    'assemble_type' => $assembleType,
+                    'question_count' => count($questions)
+                ]);
+            } else {
+                // 其他类型:限制题目数量
+                $totalQuestions = min($data['total_questions'], count($questions));
+                $questions = array_slice($questions, 0, $totalQuestions);
+            }
 
 
             // 调整题目分值,确保符合中国中学卷子标准(总分100分)
             // 调整题目分值,确保符合中国中学卷子标准(总分100分)
             $questions = $this->adjustQuestionScores($questions, 100.0);
             $questions = $this->adjustQuestionScores($questions, 100.0);

+ 1 - 1
app/Http/Controllers/Api/PaperSubmitAnalysisController.php

@@ -420,7 +420,7 @@ class PaperSubmitAnalysisController extends Controller
             // 从数据库获取分析结果
             // 从数据库获取分析结果
             $result = DB::connection('mysql')
             $result = DB::connection('mysql')
                 ->table('exam_analysis_results')
                 ->table('exam_analysis_results')
-                ->where('exam_id', $paperId)
+                ->where('paper_id', $paperId)
                 ->where('student_id', $paper->student_id)
                 ->where('student_id', $paper->student_id)
                 ->orderBy('created_at', 'desc')
                 ->orderBy('created_at', 'desc')
                 ->first();
                 ->first();

+ 676 - 93
app/Services/ExamTypeStrategy.php

@@ -6,37 +6,66 @@ use Illuminate\Support\Facades\Log;
 use App\Models\StudentKnowledgeMastery;
 use App\Models\StudentKnowledgeMastery;
 use App\Models\MistakeRecord;
 use App\Models\MistakeRecord;
 use App\Models\KnowledgePoint;
 use App\Models\KnowledgePoint;
+use App\Models\Paper;
+use App\Models\PaperQuestion;
+use App\Models\Question;
+use Illuminate\Support\Facades\DB;
 
 
 class ExamTypeStrategy
 class ExamTypeStrategy
 {
 {
     protected QuestionExpansionService $questionExpansionService;
     protected QuestionExpansionService $questionExpansionService;
+    protected QuestionLocalService $questionLocalService;
 
 
-    public function __construct(QuestionExpansionService $questionExpansionService)
+    public function __construct(QuestionExpansionService $questionExpansionService, QuestionLocalService $questionLocalService = null)
     {
     {
         $this->questionExpansionService = $questionExpansionService;
         $this->questionExpansionService = $questionExpansionService;
+        $this->questionLocalService = $questionLocalService ?? app(QuestionLocalService::class);
     }
     }
 
 
     /**
     /**
      * 根据组卷类型构建参数
      * 根据组卷类型构建参数
+     * assembleType: 0-摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-错题本, 6-按知识点组卷
      */
      */
-    public function buildParams(array $baseParams, string $examType): array
+    public function buildParams(array $baseParams, int $assembleType): array
     {
     {
         Log::info('ExamTypeStrategy: 构建组卷参数', [
         Log::info('ExamTypeStrategy: 构建组卷参数', [
-            'exam_type' => $examType,
+            'assemble_type' => $assembleType,
             'base_params_keys' => array_keys($baseParams)
             'base_params_keys' => array_keys($baseParams)
         ]);
         ]);
 
 
-        return match($examType) {
-            'diagnostic' => $this->buildDiagnosticParams($baseParams),
-            'practice' => $this->buildPracticeParams($baseParams),
-            'mistake' => $this->buildMistakeParams($baseParams),
-            'textbook' => $this->buildTextbookParams($baseParams),
-            'knowledge' => $this->buildKnowledgeParams($baseParams),
-            'knowledge_points' => $this->buildKnowledgePointsParams($baseParams),
-            default => $this->buildGeneralParams($baseParams)
+        return match($assembleType) {
+            0 => $this->applyDifficultyDistribution($this->buildDiagnosticParams($baseParams)), // 摸底
+            1 => $this->applyDifficultyDistribution($this->buildIntelligentAssembleParams($baseParams)), // 智能组卷
+            2 => $this->applyDifficultyDistribution($this->buildKnowledgePointAssembleParams($baseParams)), // 知识点组卷
+            3 => $this->applyDifficultyDistribution($this->buildTextbookAssembleParams($baseParams)), // 教材组卷
+            4 => $this->applyDifficultyDistribution($this->buildGeneralParams($baseParams)), // 通用
+            5 => $this->buildMistakeParams($baseParams), // 错题本不应用难度分布
+            6 => $this->applyDifficultyDistribution($this->buildKnowledgePointsParams($baseParams)), // 按知识点组卷
+            default => $this->applyDifficultyDistribution($this->buildGeneralParams($baseParams))
         };
         };
     }
     }
 
 
+    /**
+     * 根据组卷类型构建参数(兼容旧版 exam_type 参数)
+     * @deprecated 使用 buildParams(array, int) 替代
+     */
+    public function buildParamsLegacy(array $baseParams, string $examType): array
+    {
+        // 兼容旧版 exam_type 参数
+        $assembleType = match($examType) {
+            'diagnostic' => 0,
+            'general' => 4,
+            'practice' => 5,
+            'mistake' => 5,
+            'textbook' => 3,
+            'knowledge' => 2,
+            'knowledge_points' => 6,
+            default => 4
+        };
+
+        return $this->buildParams($baseParams, $assembleType);
+    }
+
     /**
     /**
      * 通用智能出卷(原有行为)
      * 通用智能出卷(原有行为)
      */
      */
@@ -44,10 +73,219 @@ class ExamTypeStrategy
     {
     {
         Log::info('ExamTypeStrategy: 通用智能出卷参数', $params);
         Log::info('ExamTypeStrategy: 通用智能出卷参数', $params);
 
 
-        // 返回原始参数,不做特殊处理
+        // 返回原始参数,难度分布逻辑在 buildParams 中统一处理
         return $params;
         return $params;
     }
     }
 
 
+    /**
+     * 应用难度系数分布逻辑
+     * 根据 difficulty_category 参数实现分层选题策略
+     *
+     * @param array $params 基础参数
+     * @param bool $forceApply 是否强制应用(默认false,不对错题本类型应用)
+     * @return array 增强后的参数
+     */
+    private function applyDifficultyDistribution(array $params, bool $forceApply = false): array
+    {
+        // 检查是否为排除类型(错题本 assembleType=5)
+        $assembleType = (int) ($params['assemble_type'] ?? 4);
+        $isExcludedType = ($assembleType === 5); // 只有错题本类型不应用难度分布
+
+        // 如果不是强制应用且为排除类型,则不应用难度分布
+        if (!$forceApply && $isExcludedType) {
+            Log::info('ExamTypeStrategy: 跳过难度分布(错题本类型)', [
+                'assemble_type' => $assembleType
+            ]);
+            return $params;
+        }
+
+        $difficultyCategory = (int) ($params['difficulty_category'] ?? 1);
+        $totalQuestions = (int) ($params['total_questions'] ?? 20);
+
+        Log::info('ExamTypeStrategy: 应用难度系数分布', [
+            'difficulty_category' => $difficultyCategory,
+            'total_questions' => $totalQuestions,
+            'assemble_type' => $assembleType
+        ]);
+
+        // 根据难度类别计算题目分布
+        $distribution = $this->calculateDifficultyDistribution($difficultyCategory, $totalQuestions);
+
+        // 构建难度分布配置
+        $difficultyDistributionConfig = [
+            'strategy' => 'difficulty分层选题',
+            'category' => $difficultyCategory,
+            'total_questions' => $totalQuestions,
+            'distribution' => $distribution,
+            'ranges' => $this->getDifficultyRanges($difficultyCategory),
+            'use_question_local_service' => true, // 标记使用新的独立方法
+        ];
+
+        $enhanced = array_merge($params, [
+            'difficulty_distribution_config' => $difficultyDistributionConfig,
+            // 保留原有的 difficulty_ratio 以兼容性
+            'difficulty_ratio' => [
+                '基础' => $distribution['low']['percentage'],
+                '中等' => $distribution['medium']['percentage'],
+                '拔高' => $distribution['high']['percentage'],
+            ],
+            // 新增:启用难度分布选题标志
+            'enable_difficulty_distribution' => true,
+            'difficulty_category' => $difficultyCategory,
+        ]);
+
+        Log::info('ExamTypeStrategy: 难度分布应用完成', [
+            'category' => $difficultyCategory,
+            'distribution' => $distribution,
+            'ranges' => $difficultyDistributionConfig['ranges']
+        ]);
+
+        return $enhanced;
+    }
+
+    /**
+     * 应用难度分布到题目集合
+     * 这是一个独立的公共方法,供外部调用
+     *
+     * @param array $questions 候选题目数组
+     * @param int $totalQuestions 总题目数
+     * @param int $difficultyCategory 难度类别 (1-4)
+     * @param array $filters 其他筛选条件
+     * @return array 分布后的题目
+     */
+    public function applyDifficultyDistributionToQuestions(array $questions, int $totalQuestions, int $difficultyCategory = 1, array $filters = []): array
+    {
+        Log::info('ExamTypeStrategy: 应用难度分布到题目集合', [
+            'total_questions' => $totalQuestions,
+            'difficulty_category' => $difficultyCategory,
+            'input_questions' => count($questions)
+        ]);
+
+        // 使用 QuestionLocalService 的独立方法
+        return $this->questionLocalService->selectQuestionsByDifficultyDistribution(
+            $questions,
+            $totalQuestions,
+            $difficultyCategory,
+            $filters
+        );
+    }
+
+    /**
+     * 根据难度类别计算题目分布
+     *
+     * @param int $category 难度类别 (1-4)
+     * @param int $totalQuestions 总题目数
+     * @return array 分布配置
+     */
+    private function calculateDifficultyDistribution(int $category, int $totalQuestions): array
+    {
+        // 标准化:25% 低级,50% 基准,25% 拔高
+        $lowPercentage = 25;
+        $mediumPercentage = 50;
+        $highPercentage = 25;
+
+        // 根据难度类别调整分布
+        switch ($category) {
+            case 1:
+                // 基础型:0-0.25占50%,其他占50%
+                $mediumPercentage = 50; // 0-0.25作为基准
+                $lowPercentage = 25;    // 其他低难度
+                $highPercentage = 25;   // 其他高难度
+                break;
+
+            case 2:
+                // 进阶型:0.25-0.5占50%,<0.25占25%,>0.5占25%
+                $mediumPercentage = 50; // 0.25-0.5作为基准
+                $lowPercentage = 25;    // <0.25
+                $highPercentage = 25;   // >0.5
+                break;
+
+            case 3:
+                // 中等型:0.5-0.75占50%,<0.5占25%,>0.75占25%
+                $mediumPercentage = 50; // 0.5-0.75作为基准
+                $lowPercentage = 25;    // <0.5
+                $highPercentage = 25;   // >0.75
+                break;
+
+            case 4:
+                // 拔高型:0.75-1占50%,其他占50%
+                $mediumPercentage = 50; // 0.75-1作为基准
+                $lowPercentage = 25;    // 其他低难度
+                $highPercentage = 25;   // 其他高难度
+                break;
+        }
+
+        // 计算题目数量
+        $lowCount = (int) round($totalQuestions * $lowPercentage / 100);
+        $mediumCount = (int) round($totalQuestions * $mediumPercentage / 100);
+        $highCount = $totalQuestions - $lowCount - $mediumCount;
+
+        return [
+            'low' => [
+                'percentage' => $lowPercentage,
+                'count' => $lowCount,
+                'label' => '低级难度'
+            ],
+            'medium' => [
+                'percentage' => $mediumPercentage,
+                'count' => $mediumCount,
+                'label' => '基准难度'
+            ],
+            'high' => [
+                'percentage' => $highPercentage,
+                'count' => $highCount,
+                'label' => '拔高难度'
+            ]
+        ];
+    }
+
+    /**
+     * 获取难度范围配置
+     *
+     * @param int $category 难度类别
+     * @return array 难度范围配置
+     */
+    private function getDifficultyRanges(int $category): array
+    {
+        switch ($category) {
+            case 1:
+                return [
+                    'primary' => ['min' => 0.0, 'max' => 0.25, 'percentage' => 50],
+                    'secondary' => ['min' => 0.25, 'max' => 1.0, 'percentage' => 50],
+                    'description' => '基础型:0-0.25占比50%,其他占比50%'
+                ];
+
+            case 2:
+                return [
+                    'primary' => ['min' => 0.25, 'max' => 0.5, 'percentage' => 50],
+                    'low' => ['min' => 0.0, 'max' => 0.25, 'percentage' => 25],
+                    'high' => ['min' => 0.5, 'max' => 1.0, 'percentage' => 25],
+                    'description' => '进阶型:0.25-0.5占比50%,<0.25占比25%,>0.5占比25%'
+                ];
+
+            case 3:
+                return [
+                    'primary' => ['min' => 0.5, 'max' => 0.75, 'percentage' => 50],
+                    'low' => ['min' => 0.0, 'max' => 0.5, 'percentage' => 25],
+                    'high' => ['min' => 0.75, 'max' => 1.0, 'percentage' => 25],
+                    'description' => '中等型:0.5-0.75占比50%,<0.5占比25%,>0.75占比25%'
+                ];
+
+            case 4:
+                return [
+                    'primary' => ['min' => 0.75, 'max' => 1.0, 'percentage' => 50],
+                    'secondary' => ['min' => 0.0, 'max' => 0.75, 'percentage' => 50],
+                    'description' => '拔高型:0.75-1占比50%,其他占比50%'
+                ];
+
+            default:
+                return [
+                    'primary' => ['min' => 0.0, 'max' => 1.0, 'percentage' => 100],
+                    'description' => '默认:全难度范围'
+                ];
+        }
+    }
+
     /**
     /**
      * 摸底测试:评估当前水平
      * 摸底测试:评估当前水平
      */
      */
@@ -85,109 +323,71 @@ class ExamTypeStrategy
     }
     }
 
 
     /**
     /**
-     * 错题:针对薄弱点强化
-     * 使用 QuestionExpansionService 按优先级扩展题目
+     * 错题本 (assembleType=5)
+     * 根据 paper_ids 数组查询卷子中的错题,组合成新卷子
+     * 不需要 total_questions 参数
      */
      */
     private function buildMistakeParams(array $params): array
     private function buildMistakeParams(array $params): array
     {
     {
-        Log::info('ExamTypeStrategy: 构建错题参数', $params);
+        Log::info('ExamTypeStrategy: 构建错题参数', $params);
 
 
+        $paperIds = $params['paper_ids'] ?? [];
         $studentId = $params['student_id'] ?? null;
         $studentId = $params['student_id'] ?? null;
-        $totalQuestions = $params['total_questions'] ?? 20;
-        $mistakeOptions = $params['mistake_options'] ?? [];
-        $weaknessThreshold = $mistakeOptions['weakness_threshold'] ?? 0.7;
-        $intensity = $mistakeOptions['intensity'] ?? 'medium';
-        $focusWeaknesses = $mistakeOptions['focus_weaknesses'] ?? true;
-
-        // 根据强度调整难度配比
-        $difficultyRatio = match($intensity) {
-            'low' => [
-                '基础' => 60,
-                '中等' => 35,
-                '拔高' => 5,
-            ],
-            'medium' => [
-                '基础' => 45,
-                '中等' => 40,
-                '拔高' => 15,
-            ],
-            'high' => [
-                '基础' => 30,
-                '中等' => 45,
-                '拔高' => 25,
-            ],
-            default => [
-                '基础' => 45,
-                '中等' => 40,
-                '拔高' => 15,
-            ]
-        };
 
 
-        // 获取学生薄弱点
-        $weaknessFilter = [];
-        if ($studentId && $focusWeaknesses) {
-            $weaknessFilter = $this->getStudentWeaknesses($studentId, $weaknessThreshold);
-            Log::info('ExamTypeStrategy: 获取到学生薄弱点', [
-                'student_id' => $studentId,
-                'weakness_threshold' => $weaknessThreshold,
-                'weakness_count' => count($weaknessFilter)
-            ]);
+        // 检查是否有 paper_ids 参数
+        if (empty($paperIds)) {
+            Log::warning('ExamTypeStrategy: 错题本需要 paper_ids 参数');
+            return $this->buildGeneralParams($params);
         }
         }
 
 
-        // 使用 QuestionExpansionService 按优先级扩展题目
-        $questionStrategy = $this->questionExpansionService->expandQuestions(
-            $params,
-            $studentId,
-            $weaknessFilter,
-            $totalQuestions
-        );
+        Log::info('ExamTypeStrategy: 错题本组卷', [
+            'paper_ids' => $paperIds,
+            'student_id' => $studentId,
+            'paper_count' => count($paperIds)
+        ]);
 
 
-        // 获取错题知识点
-        $mistakeKnowledgePoints = [];
-        if ($studentId && !empty($questionStrategy['mistake_question_ids'])) {
-            $mistakeRecords = MistakeRecord::forStudent($studentId)
-                ->whereIn('question_id', $questionStrategy['mistake_question_ids'])
-                ->select(['knowledge_point'])
-                ->get();
-            $mistakeKnowledgePoints = array_unique(array_filter($mistakeRecords->pluck('knowledge_point')->toArray()));
-            Log::info('ExamTypeStrategy: 获取错题知识点', [
-                'knowledge_points' => $mistakeKnowledgePoints
+        // 通过 paper_ids 查询卷子中的错题
+        $mistakeQuestionIds = $this->getMistakeQuestionsFromPapers($paperIds, $studentId);
+
+        if (empty($mistakeQuestionIds)) {
+            Log::warning('ExamTypeStrategy: 未找到错题', [
+                'paper_ids' => $paperIds
             ]);
             ]);
+            return $this->buildGeneralParams($params);
         }
         }
 
 
-        // 获取扩展统计
-        $expansionStats = $this->questionExpansionService->getExpansionStats($questionStrategy);
+        Log::info('ExamTypeStrategy: 获取到错题', [
+            'paper_count' => count($paperIds),
+            'mistake_count' => count($mistakeQuestionIds),
+            'mistake_question_ids' => array_slice($mistakeQuestionIds, 0, 10) // 只记录前10个
+        ]);
 
 
+        // 获取错题知识点
+        $mistakeKnowledgePoints = $this->getKnowledgePointsFromQuestions($mistakeQuestionIds);
+
+        // 组装增强参数
         $enhanced = array_merge($params, [
         $enhanced = array_merge($params, [
-            'difficulty_ratio' => $difficultyRatio,
-            'mistake_ids' => $questionStrategy['mistake_ids'],
-            'mistake_question_ids' => $questionStrategy['mistake_question_ids'],
-            // 错题回顾的知识点优先级
-            'priority_knowledge_points' => array_merge(
-                array_values($mistakeKnowledgePoints), // 错题知识点优先
-                array_column($weaknessFilter, 'kp_code') // 然后是薄弱点
-            ),
-            // 错题回顾更注重针对性
+            'mistake_question_ids' => $mistakeQuestionIds,
+            'paper_ids' => $paperIds,
+            'priority_knowledge_points' => array_values($mistakeKnowledgePoints),
+            'paper_name' => $params['paper_name'] ?? ('错题本_' . now()->format('Ymd_His')),
+            // 错题本:保持原有题型配比
             'question_type_ratio' => [
             'question_type_ratio' => [
                 '选择题' => 35,
                 '选择题' => 35,
                 '填空题' => 30,
                 '填空题' => 30,
                 '解答题' => 35,
                 '解答题' => 35,
             ],
             ],
-            'paper_name' => $params['paper_name'] ?? ('错题_' . now()->format('Ymd_His')),
-            // 标记这是错题,用于后续处理
+            // 错题本不应用难度分布
             'is_mistake_exam' => true,
             'is_mistake_exam' => true,
-            'weakness_filter' => $weaknessFilter,
-            // 题目扩展统计
-            'question_expansion_stats' => $expansionStats
+            'is_paper_based_mistake' => true, // 标记是基于卷子的错题本
+            'mistake_count' => count($mistakeQuestionIds),
+            'knowledge_points_count' => count($mistakeKnowledgePoints),
         ]);
         ]);
 
 
-        Log::info('ExamTypeStrategy: 错题参数构建完成', [
-            'intensity' => $intensity,
-            'total_questions_needed' => $totalQuestions,
-            'mistake_question_ids_count' => count($enhanced['mistake_question_ids']),
-            'priority_knowledge_points_count' => count($enhanced['priority_knowledge_points']),
-            'question_expansion_stats' => $enhanced['question_expansion_stats'],
-            'weakness_count' => count($weaknessFilter)
+        Log::info('ExamTypeStrategy: 错题本参数构建完成', [
+            'paper_count' => count($paperIds),
+            'mistake_count' => count($mistakeQuestionIds),
+            'knowledge_points_count' => count($mistakeKnowledgePoints)
         ]);
         ]);
 
 
         return $enhanced;
         return $enhanced;
@@ -516,4 +716,387 @@ class ExamTypeStrategy
             return [];
             return [];
         }
         }
     }
     }
+
+    /**
+     * 智能组卷 (assembleType=1)
+     * 根据 textbook_id 查询章节,获取知识点,然后组卷
+     * 增加年级概念选题逻辑
+     */
+    private function buildIntelligentAssembleParams(array $params): array
+    {
+        Log::info('ExamTypeStrategy: 构建智能组卷参数', $params);
+
+        $textbookId = $params['textbook_id'] ?? null;
+        $grade = $params['grade'] ?? null; // 年级信息
+        $totalQuestions = $params['total_questions'] ?? 20;
+
+        if (!$textbookId) {
+            Log::warning('ExamTypeStrategy: 智能组卷需要 textbook_id 参数');
+            return $this->buildGeneralParams($params);
+        }
+
+        // 第一步:根据 textbook_id 查询章节
+        $catalogChapterIds = $this->getTextbookChapterIds($textbookId);
+
+        if (empty($catalogChapterIds)) {
+            Log::warning('ExamTypeStrategy: 未找到课本章节', ['textbook_id' => $textbookId]);
+            return $this->buildGeneralParams($params);
+        }
+
+        Log::info('ExamTypeStrategy: 获取到课本章节', [
+            'textbook_id' => $textbookId,
+            'chapter_count' => count($catalogChapterIds)
+        ]);
+
+        // 第二步:根据章节ID查询知识点关联
+        $kpCodes = $this->getKnowledgePointsFromChapters($catalogChapterIds, 25);
+
+        if (empty($kpCodes)) {
+            Log::warning('ExamTypeStrategy: 未找到知识点关联', [
+                'textbook_id' => $textbookId,
+                'chapter_ids' => $catalogChapterIds
+            ]);
+            return $this->buildGeneralParams($params);
+        }
+
+        Log::info('ExamTypeStrategy: 获取到知识点', [
+            'kp_count' => count($kpCodes),
+            'kp_codes' => array_slice($kpCodes, 0, 5) // 只记录前5个
+        ]);
+
+        // 组装增强参数
+        $enhanced = array_merge($params, [
+            'kp_codes' => $kpCodes,
+            'textbook_id' => $textbookId,
+            'grade' => $grade,
+            'catalog_chapter_ids' => $catalogChapterIds,
+            'paper_name' => $params['paper_name'] ?? ('智能组卷_' . now()->format('Ymd_His')),
+            // 智能组卷:平衡的题型和难度配比
+            'question_type_ratio' => [
+                '选择题' => 40,
+                '填空题' => 30,
+                '解答题' => 30,
+            ],
+            'difficulty_ratio' => [
+                '基础' => 25,
+                '中等' => 50,
+                '拔高' => 25,
+            ],
+            'question_category' => 1, // question_category=1 代表摸底题目
+            'is_intelligent_assemble' => true,
+        ]);
+
+        Log::info('ExamTypeStrategy: 智能组卷参数构建完成', [
+            'textbook_id' => $textbookId,
+            'grade' => $grade,
+            'kp_count' => count($kpCodes),
+            'total_questions' => $totalQuestions
+        ]);
+
+        return $enhanced;
+    }
+
+    /**
+     * 知识点组卷 (assembleType=2)
+     * 直接根据 kp_code_list 查询题目,排除已做过的题目
+     */
+    private function buildKnowledgePointAssembleParams(array $params): array
+    {
+        Log::info('ExamTypeStrategy: 构建知识点组卷参数', $params);
+
+        $kpCodeList = $params['kp_code_list'] ?? [];
+        $studentId = $params['student_id'] ?? null;
+        $totalQuestions = $params['total_questions'] ?? 20;
+
+        if (empty($kpCodeList)) {
+            Log::warning('ExamTypeStrategy: 知识点组卷需要 kp_code_list 参数');
+            return $this->buildGeneralParams($params);
+        }
+
+        Log::info('ExamTypeStrategy: 知识点组卷', [
+            'kp_code_list' => $kpCodeList,
+            'student_id' => $studentId,
+            'total_questions' => $totalQuestions
+        ]);
+
+        // 如果有学生ID,获取已做过的题目ID列表(用于排除)
+        $answeredQuestionIds = [];
+        if ($studentId) {
+            $answeredQuestionIds = $this->getStudentAnsweredQuestionIds($studentId, $kpCodeList);
+            Log::info('ExamTypeStrategy: 获取学生已答题目', [
+                'student_id' => $studentId,
+                'answered_count' => count($answeredQuestionIds)
+            ]);
+        }
+
+        // 组装增强参数
+        $enhanced = array_merge($params, [
+            'kp_codes' => $kpCodeList,
+            'exclude_question_ids' => $answeredQuestionIds,
+            'paper_name' => $params['paper_name'] ?? ('知识点组卷_' . now()->format('Ymd_His')),
+            // 知识点组卷:注重题型平衡
+            'question_type_ratio' => [
+                '选择题' => 35,
+                '填空题' => 30,
+                '解答题' => 35,
+            ],
+            'difficulty_ratio' => [
+                '基础' => 25,
+                '中等' => 50,
+                '拔高' => 25,
+            ],
+            'question_category' => 0, // question_category=0 代表普通题目
+            'is_knowledge_point_assemble' => true,
+        ]);
+
+        Log::info('ExamTypeStrategy: 知识点组卷参数构建完成', [
+            'kp_count' => count($kpCodeList),
+            'exclude_count' => count($answeredQuestionIds),
+            'total_questions' => $totalQuestions
+        ]);
+
+        return $enhanced;
+    }
+
+    /**
+     * 教材组卷 (assembleType=3)
+     * 根据 chapter_id_list 查询课本章节,获取知识点,然后组卷
+     */
+    private function buildTextbookAssembleParams(array $params): array
+    {
+        Log::info('ExamTypeStrategy: 构建教材组卷参数', $params);
+
+        $chapterIdList = $params['chapter_id_list'] ?? [];
+        $studentId = $params['student_id'] ?? null;
+        $totalQuestions = $params['total_questions'] ?? 20;
+
+        if (empty($chapterIdList)) {
+            Log::warning('ExamTypeStrategy: 教材组卷需要 chapter_id_list 参数');
+            return $this->buildGeneralParams($params);
+        }
+
+        Log::info('ExamTypeStrategy: 教材组卷', [
+            'chapter_id_list' => $chapterIdList,
+            'student_id' => $studentId,
+            'total_questions' => $totalQuestions
+        ]);
+
+        // 第一步:根据章节ID查询知识点关联
+        $kpCodes = $this->getKnowledgePointsFromChapters($chapterIdList);
+
+        if (empty($kpCodes)) {
+            Log::warning('ExamTypeStrategy: 未找到章节知识点关联', [
+                'chapter_id_list' => $chapterIdList
+            ]);
+            return $this->buildGeneralParams($params);
+        }
+
+        Log::info('ExamTypeStrategy: 获取章节知识点', [
+            'kp_count' => count($kpCodes),
+            'kp_codes' => array_slice($kpCodes, 0, 5)
+        ]);
+
+        // 第二步:如果有学生ID,获取已做过的题目ID列表(用于排除)
+        $answeredQuestionIds = [];
+        if ($studentId) {
+            $answeredQuestionIds = $this->getStudentAnsweredQuestionIds($studentId, $kpCodes);
+            Log::info('ExamTypeStrategy: 获取学生已答题目', [
+                'student_id' => $studentId,
+                'answered_count' => count($answeredQuestionIds)
+            ]);
+        }
+
+        // 组装增强参数
+        $enhanced = array_merge($params, [
+            'kp_codes' => $kpCodes,
+            'chapter_id_list' => $chapterIdList,
+            'exclude_question_ids' => $answeredQuestionIds,
+            'paper_name' => $params['paper_name'] ?? ('教材组卷_' . now()->format('Ymd_His')),
+            // 教材组卷:按教材特点分配题型
+            'question_type_ratio' => [
+                '选择题' => 40,
+                '填空题' => 30,
+                '解答题' => 30,
+            ],
+            'difficulty_ratio' => [
+                '基础' => 25,
+                '中等' => 50,
+                '拔高' => 25,
+            ],
+            'question_category' => 0, // question_category=0 代表普通题目
+            'is_textbook_assemble' => true,
+        ]);
+
+        Log::info('ExamTypeStrategy: 教材组卷参数构建完成', [
+            'chapter_count' => count($chapterIdList),
+            'kp_count' => count($kpCodes),
+            'exclude_count' => count($answeredQuestionIds),
+            'total_questions' => $totalQuestions
+        ]);
+
+        return $enhanced;
+    }
+
+    /**
+     * 根据课本ID获取章节ID列表
+     */
+    private function getTextbookChapterIds(int $textbookId): array
+    {
+        try {
+            // 查询 text_book_catalog_nodes 表
+            $chapterIds = DB::table('textbook_catalog_nodes')
+                ->where('textbook_id', $textbookId)
+                ->where('node_type', 'section') // nodeType='section'
+                ->orderBy('sort_order')
+                ->pluck('id')
+                ->toArray();
+
+            Log::debug('ExamTypeStrategy: 查询课本章节ID', [
+                'textbook_id' => $textbookId,
+                'found_count' => count($chapterIds)
+            ]);
+
+            return $chapterIds;
+        } catch (\Exception $e) {
+            Log::error('ExamTypeStrategy: 查询课本章节ID失败', [
+                'textbook_id' => $textbookId,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
+    /**
+     * 根据章节ID列表获取知识点代码列表
+     */
+    private function getKnowledgePointsFromChapters(array $chapterIds, int $limit = 25): array
+    {
+        try {
+            // 查询 textbook_chapter_knowledge_relation 表
+            $kpCodes = DB::table('textbook_chapter_knowledge_relation')
+                ->whereIn('catalog_chapter_id', $chapterIds)
+                ->limit($limit)
+                ->distinct()
+                ->pluck('kp_code')
+                ->toArray();
+
+            Log::debug('ExamTypeStrategy: 查询章节知识点', [
+                'chapter_count' => count($chapterIds),
+                'found_kp_count' => count($kpCodes)
+            ]);
+
+            return array_filter($kpCodes); // 移除空值
+        } catch (\Exception $e) {
+            Log::error('ExamTypeStrategy: 查询章节知识点失败', [
+                'chapter_ids' => $chapterIds,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
+    /**
+     * 获取学生已答题目ID列表(用于排除)
+     */
+    private function getStudentAnsweredQuestionIds(string $studentId, array $kpCodes): array
+    {
+        try {
+            // 查询 student_answer_questions 表
+            $questionIds = DB::table('student_answer_questions')
+                ->where('student_id', $studentId)
+                ->whereIn('kp_code', $kpCodes)
+                ->distinct()
+                ->pluck('question_id')
+                ->toArray();
+
+            Log::debug('ExamTypeStrategy: 查询学生已答题目', [
+                'student_id' => $studentId,
+                'kp_count' => count($kpCodes),
+                'answered_count' => count($questionIds)
+            ]);
+
+            return array_filter($questionIds); // 移除空值
+        } catch (\Exception $e) {
+            Log::error('ExamTypeStrategy: 查询学生已答题目失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
+    /**
+     * 通过卷子ID列表查询错题
+     * 注意:paper_ids 使用的是 papers 表中的 paper_id 字段,不是 id 字段
+     * 错题数据从 mistake_records 表中获取,该表已包含 paper_id 字段
+     * 注意:不按学生ID过滤,因为卷子可能包含其他同学的错题,需要收集所有错题
+     */
+    private function getMistakeQuestionsFromPapers(array $paperIds, ?string $studentId = null): array
+    {
+        try {
+            // 使用 Eloquent 模型查询,从 mistake_records 表中获取错题记录
+            // 不按 student_id 过滤,因为要收集所有学生的错题
+            $mistakeRecords = MistakeRecord::query()
+                ->whereIn('paper_id', $paperIds)
+                ->get();
+
+            if ($mistakeRecords->isEmpty()) {
+                Log::warning('ExamTypeStrategy: 卷子中未找到错题记录', [
+                    'paper_ids' => $paperIds
+                ]);
+                return [];
+            }
+
+            // 收集所有错题的 question_id
+            $questionIds = $mistakeRecords->pluck('question_id')
+                ->filter()
+                ->unique()
+                ->values()
+                ->toArray();
+
+            Log::debug('ExamTypeStrategy: 查询卷子错题完成', [
+                'paper_count' => count($paperIds),
+                'mistake_record_count' => $mistakeRecords->count(),
+                'unique_question_count' => count($questionIds)
+            ]);
+
+            return array_filter($questionIds);
+
+        } catch (\Exception $e) {
+            Log::error('ExamTypeStrategy: 查询卷子错题失败', [
+                'paper_ids' => $paperIds,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
+    /**
+     * 通过题目ID列表获取知识点代码列表
+     */
+    private function getKnowledgePointsFromQuestions(array $questionIds): array
+    {
+        try {
+            // 使用 Eloquent 模型查询,获取题目的知识点
+            $questions = Question::whereIn('id', $questionIds)
+                ->whereNotNull('kp_code')
+                ->where('kp_code', '!=', '')
+                ->distinct()
+                ->pluck('kp_code')
+                ->toArray();
+
+            Log::debug('ExamTypeStrategy: 查询题目知识点', [
+                'question_count' => count($questionIds),
+                'kp_count' => count($questions)
+            ]);
+
+            return array_filter($questions);
+        } catch (\Exception $e) {
+            Log::error('ExamTypeStrategy: 查询题目知识点失败', [
+                'question_ids' => array_slice($questionIds, 0, 10), // 只记录前10个
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
 }
 }

+ 90 - 20
app/Services/LearningAnalyticsService.php

@@ -1208,39 +1208,62 @@ class LearningAnalyticsService
         $startTime = microtime(true);
         $startTime = microtime(true);
 
 
         try {
         try {
-            // 新增:应用组卷类型策略
-            $examType = $params['exam_type'] ?? 'general';
+            // 应用组卷类型策略
+            $assembleType = (int) ($params['assemble_type'] ?? 4); // 默认为通用类型(4)
+            $examTypeLegacy = $params['exam_type'] ?? 'general'; // 兼容旧版参数
+
             Log::info('LearningAnalyticsService: 检查组卷策略', [
             Log::info('LearningAnalyticsService: 检查组卷策略', [
-                'exam_type' => $examType,
+                'assemble_type' => $assembleType,
+                'exam_type_legacy' => $examTypeLegacy,
                 'has_question_expansion_service' => !empty($this->questionExpansionService)
                 'has_question_expansion_service' => !empty($this->questionExpansionService)
             ]);
             ]);
 
 
-            if ($examType !== 'general') {
+            // 如果有 assemble_type 参数,优先使用新的参数系统
+            if (isset($params['assemble_type'])) {
                 try {
                 try {
-                    // 确保QuestionExpansionService可用
-                    $questionExpansionService = $this->questionExpansionService;
-                    if (!$questionExpansionService) {
-                        $questionExpansionService = app(QuestionExpansionService::class);
-                        Log::info('LearningAnalyticsService: 从容器获取QuestionExpansionService实例');
-                    }
+                    // 确保QuestionExpansionService和QuestionLocalService可用
+                    $questionExpansionService = $this->questionExpansionService ?? app(QuestionExpansionService::class);
+                    $questionLocalService = app(QuestionLocalService::class);
+                    Log::info('LearningAnalyticsService: 从容器获取服务实例');
 
 
-                    $strategy = new ExamTypeStrategy($questionExpansionService);
-                    $params = $strategy->buildParams($params, $examType);
+                    $strategy = new ExamTypeStrategy($questionExpansionService, $questionLocalService);
+                    $params = $strategy->buildParams($params, $assembleType);
 
 
                     Log::info('LearningAnalyticsService: 已应用组卷策略', [
                     Log::info('LearningAnalyticsService: 已应用组卷策略', [
-                        'exam_type' => $examType,
+                        'assemble_type' => $assembleType,
+                        'enhanced_params_keys' => array_keys($params)
+                    ]);
+                } catch (Exception $e) {
+                    Log::warning('LearningAnalyticsService: 组卷策略应用失败,使用默认策略', [
+                        'assemble_type' => $assembleType,
+                        'error' => $e->getMessage(),
+                        'trace' => $e->getTraceAsString()
+                    ]);
+                }
+            } elseif ($examTypeLegacy !== 'general') {
+                // 兼容旧版 exam_type 参数
+                try {
+                    $questionExpansionService = $this->questionExpansionService ?? app(QuestionExpansionService::class);
+                    $questionLocalService = app(QuestionLocalService::class);
+                    Log::info('LearningAnalyticsService: 从容器获取服务实例(兼容模式)');
+
+                    $strategy = new ExamTypeStrategy($questionExpansionService, $questionLocalService);
+                    $params = $strategy->buildParamsLegacy($params, $examTypeLegacy);
+
+                    Log::info('LearningAnalyticsService: 已应用组卷策略(兼容模式)', [
+                        'exam_type' => $examTypeLegacy,
                         'enhanced_params_keys' => array_keys($params)
                         'enhanced_params_keys' => array_keys($params)
                     ]);
                     ]);
                 } catch (Exception $e) {
                 } catch (Exception $e) {
                     Log::warning('LearningAnalyticsService: 组卷策略应用失败,使用默认策略', [
                     Log::warning('LearningAnalyticsService: 组卷策略应用失败,使用默认策略', [
-                        'exam_type' => $examType,
+                        'exam_type' => $examTypeLegacy,
                         'error' => $e->getMessage(),
                         'error' => $e->getMessage(),
                         'trace' => $e->getTraceAsString()
                         'trace' => $e->getTraceAsString()
                     ]);
                     ]);
                 }
                 }
             } else {
             } else {
                 Log::info('LearningAnalyticsService: 跳过组卷策略', [
                 Log::info('LearningAnalyticsService: 跳过组卷策略', [
-                    'reason' => 'general类型不需要策略'
+                    'reason' => '通用类型不需要特殊策略'
                 ]);
                 ]);
             }
             }
 
 
@@ -1267,7 +1290,8 @@ class LearningAnalyticsService
                 'total_questions' => $totalQuestions,
                 'total_questions' => $totalQuestions,
                 'kp_codes' => $kpCodes,
                 'kp_codes' => $kpCodes,
                 'skills' => $skills,
                 'skills' => $skills,
-                'exam_type' => $examType,
+                'assemble_type' => $assembleType,
+                'exam_type_legacy' => $examTypeLegacy,
             ]);
             ]);
 
 
             // 1. 如果指定了学生,获取学生的薄弱点
             // 1. 如果指定了学生,获取学生的薄弱点
@@ -1324,15 +1348,18 @@ class LearningAnalyticsService
                 }
                 }
             }
             }
 
 
-            // 3. 如果错题数量不足,补充其他题目
+            // 3. 如果错题数量不足,补充其他题目(错题本类型不补充)
             $allQuestions = $priorityQuestions;
             $allQuestions = $priorityQuestions;
-            if (count($priorityQuestions) < $totalQuestions) {
+            $isMistakeBook = ($assembleType === 5); // 错题本类型不补充题目
+
+            if (!$isMistakeBook && count($priorityQuestions) < $totalQuestions) {
                 try {
                 try {
                     Log::info('开始调用 getQuestionsFromBank 补充题目', [
                     Log::info('开始调用 getQuestionsFromBank 补充题目', [
                         'kp_codes_count' => count($kpCodes),
                         'kp_codes_count' => count($kpCodes),
                         'skills_count' => count($skills),
                         'skills_count' => count($skills),
                         'has_mistake_priority' => !empty($mistakeQuestionIds),
                         'has_mistake_priority' => !empty($mistakeQuestionIds),
-                        'need_more' => $totalQuestions - count($priorityQuestions)
+                        'need_more' => $totalQuestions - count($priorityQuestions),
+                        'assemble_type' => $assembleType
                     ]);
                     ]);
 
 
                     $additionalQuestions = $this->getQuestionsFromBank($kpCodes, $skills, $studentId, $questionTypeRatio, $difficultyRatio, 200);
                     $additionalQuestions = $this->getQuestionsFromBank($kpCodes, $skills, $studentId, $questionTypeRatio, $difficultyRatio, 200);
@@ -1357,6 +1384,13 @@ class LearningAnalyticsService
 
 
                     throw $e;
                     throw $e;
                 }
                 }
+            } elseif ($isMistakeBook) {
+                // 错题本类型:不补充题目,只使用错题
+                Log::info('错题本类型:不补充题目,只使用错题', [
+                    'assemble_type' => $assembleType,
+                    'mistake_questions_count' => count($priorityQuestions),
+                    'total_questions_requested' => $totalQuestions
+                ]);
             }
             }
 
 
             if (empty($allQuestions)) {
             if (empty($allQuestions)) {
@@ -1417,6 +1451,40 @@ class LearningAnalyticsService
                 ];
                 ];
             }
             }
 
 
+            // 如果启用了难度分布且不是排除类型,则应用难度分布
+            $difficultyCategory = $params['difficulty_category'] ?? 1;
+            $enableDistribution = $params['enable_difficulty_distribution'] ?? false;
+            $isExcludedType = ($assembleType === 5); // 只有错题本类型(assembleType=5)不应用难度分布
+
+            if ($enableDistribution && !$isExcludedType) {
+                Log::info('LearningAnalyticsService: 应用难度系数分布', [
+                    'difficulty_category' => $difficultyCategory,
+                    'assemble_type' => $assembleType,
+                    'before_count' => count($selectedQuestions)
+                ]);
+
+                try {
+                    // 使用 ExamTypeStrategy 的独立方法应用难度分布
+                    $questionExpansionService = $this->questionExpansionService ?? app(QuestionExpansionService::class);
+                    $examStrategy = new ExamTypeStrategy($questionExpansionService);
+
+                    $selectedQuestions = $examStrategy->applyDifficultyDistributionToQuestions(
+                        $selectedQuestions,
+                        $totalQuestions,
+                        $difficultyCategory,
+                        $params
+                    );
+
+                    Log::info('LearningAnalyticsService: 难度分布应用完成', [
+                        'after_count' => count($selectedQuestions)
+                    ]);
+                } catch (\Exception $e) {
+                    Log::warning('LearningAnalyticsService: 难度分布应用失败,继续使用原结果', [
+                        'error' => $e->getMessage()
+                    ]);
+                }
+            }
+
             return [
             return [
                 'success' => true,
                 'success' => true,
                 'message' => '智能出卷成功',
                 'message' => '智能出卷成功',
@@ -1426,7 +1494,9 @@ class LearningAnalyticsService
                     'source_questions' => count($allQuestions),
                     'source_questions' => count($allQuestions),
                     'weakness_targeted' => $studentId && !empty($weaknessFilter) ? count(array_filter($selectedQuestions, function($q) use ($weaknessFilter) {
                     'weakness_targeted' => $studentId && !empty($weaknessFilter) ? count(array_filter($selectedQuestions, function($q) use ($weaknessFilter) {
                         return in_array($q['kp_code'] ?? '', $weaknessFilter);
                         return in_array($q['kp_code'] ?? '', $weaknessFilter);
-                    })) : 0
+                    })) : 0,
+                    'difficulty_distribution_applied' => $enableDistribution && !$isExcludedType,
+                    'difficulty_category' => $difficultyCategory
                 ]
                 ]
             ];
             ];
         } catch (\Exception $e) {
         } catch (\Exception $e) {

+ 290 - 0
app/Services/QuestionLocalService.php

@@ -8,6 +8,7 @@ use App\Services\KnowledgeServiceApi;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 use Illuminate\Support\Str;
 
 
 class QuestionLocalService
 class QuestionLocalService
@@ -552,4 +553,293 @@ class QuestionLocalService
             return $kpMap;
             return $kpMap;
         });
         });
     }
     }
+
+    /**
+     * 根据难度系数分布选择题目
+     *
+     * @param array $questions 候选题目数组
+     * @param int $totalQuestions 总题目数
+     * @param int $difficultyCategory 难度类别 (1-4)
+     *   - 1: 0-0.25范围占50%,其他占50%
+     *   - 2: 0.25-0.5范围占50%,<0.25占25%,>0.5占25%
+     *   - 3: 0.5-0.75范围占50%,<0.5占25%,>0.75占25%
+     *   - 4: 0.75-1范围占50%,其他占50%
+     * @param array $filters 其他筛选条件
+     * @return array 分布后的题目
+     */
+    public function selectQuestionsByDifficultyDistribution(array $questions, int $totalQuestions, int $difficultyCategory = 1, array $filters = []): array
+    {
+        Log::info('QuestionLocalService: 根据难度系数分布选择题目', [
+            'total_questions' => $totalQuestions,
+            'difficulty_category' => $difficultyCategory,
+            'input_questions' => count($questions)
+        ]);
+
+        if (empty($questions)) {
+            Log::warning('QuestionLocalService: 输入题目为空');
+            return [];
+        }
+
+        // 计算目标分布
+        $distribution = $this->calculateDifficultyDistribution($difficultyCategory, $totalQuestions);
+
+        Log::info('QuestionLocalService: 难度分布计算', [
+            'distribution' => $distribution
+        ]);
+
+        // 按难度范围分桶
+        $buckets = $this->groupQuestionsByDifficultyRange($questions, $difficultyCategory);
+
+        Log::info('QuestionLocalService: 题目分桶', [
+            'buckets' => array_map(fn($bucket) => count($bucket), $buckets)
+        ]);
+
+        // 根据分布选择题目
+        $selected = [];
+        $usedIndices = [];
+
+        foreach ($distribution as $level => $config) {
+            $targetCount = $config['count'];
+            if ($targetCount <= 0) {
+                continue;
+            }
+
+            $rangeKey = $this->mapDifficultyLevelToRangeKey($level, $difficultyCategory);
+            $bucket = $buckets[$rangeKey] ?? [];
+
+            // 随机打乱
+            shuffle($bucket);
+
+            // 选择题目
+            $takeCount = min($targetCount, count($bucket));
+            for ($i = 0; $i < $takeCount; $i++) {
+                if (isset($bucket[$i])) {
+                    $selected[] = $bucket[$i];
+                    $usedIndices[] = $bucket[$i]['id'] ?? $i;
+                }
+            }
+
+            Log::debug('QuestionLocalService: 难度层级选择', [
+                'level' => $level,
+                'target' => $targetCount,
+                'actual' => $takeCount,
+                'bucket_size' => count($bucket)
+            ]);
+        }
+
+        // 如果数量不足,从剩余题目中补充
+        if (count($selected) < $totalQuestions) {
+            $remaining = [];
+            foreach ($questions as $q) {
+                $id = $q['id'] ?? null;
+                if ($id && !in_array($id, $usedIndices)) {
+                    $remaining[] = $q;
+                }
+            }
+
+            shuffle($remaining);
+            $needMore = $totalQuestions - count($selected);
+            $selected = array_merge($selected, array_slice($remaining, 0, $needMore));
+        }
+
+        // 截断至目标数量
+        $selected = array_slice($selected, 0, $totalQuestions);
+
+        Log::info('QuestionLocalService: 难度分布选择完成', [
+            'final_count' => count($selected),
+            'target_count' => $totalQuestions
+        ]);
+
+        return $selected;
+    }
+
+    /**
+     * 计算难度分布配置
+     *
+     * @param int $category 难度类别 (1-4)
+     * @param int $totalQuestions 总题目数
+     * @return array 分布配置
+     */
+    private function calculateDifficultyDistribution(int $category, int $totalQuestions): array
+    {
+        // 标准化:25% 低级,50% 基准,25% 拔高
+        $lowPercentage = 25;
+        $mediumPercentage = 50;
+        $highPercentage = 25;
+
+        // 根据难度类别调整分布
+        switch ($category) {
+            case 1:
+                // 基础型:0-0.25占50%,其他占50%
+                $mediumPercentage = 50; // 0-0.25作为基准
+                $lowPercentage = 25;    // 其他低难度
+                $highPercentage = 25;   // 其他高难度
+                break;
+
+            case 2:
+                // 进阶型:0.25-0.5占50%,<0.25占25%,>0.5占25%
+                $mediumPercentage = 50; // 0.25-0.5作为基准
+                $lowPercentage = 25;    // <0.25
+                $highPercentage = 25;   // >0.5
+                break;
+
+            case 3:
+                // 中等型:0.5-0.75占50%,<0.5占25%,>0.75占25%
+                $mediumPercentage = 50; // 0.5-0.75作为基准
+                $lowPercentage = 25;    // <0.5
+                $highPercentage = 25;   // >0.75
+                break;
+
+            case 4:
+                // 拔高型:0.75-1占50%,其他占50%
+                $mediumPercentage = 50; // 0.75-1作为基准
+                $lowPercentage = 25;    // 其他低难度
+                $highPercentage = 25;   // 其他高难度
+                break;
+        }
+
+        // 计算题目数量
+        $lowCount = (int) round($totalQuestions * $lowPercentage / 100);
+        $mediumCount = (int) round($totalQuestions * $mediumPercentage / 100);
+        $highCount = $totalQuestions - $lowCount - $mediumCount;
+
+        return [
+            'low' => [
+                'percentage' => $lowPercentage,
+                'count' => $lowCount,
+                'label' => '低级难度'
+            ],
+            'medium' => [
+                'percentage' => $mediumPercentage,
+                'count' => $mediumCount,
+                'label' => '基准难度'
+            ],
+            'high' => [
+                'percentage' => $highPercentage,
+                'count' => $highCount,
+                'label' => '拔高难度'
+            ]
+        ];
+    }
+
+    /**
+     * 将题目按难度范围分桶
+     *
+     * @param array $questions 题目数组
+     * @param int $category 难度类别
+     * @return array 分桶结果
+     */
+    private function groupQuestionsByDifficultyRange(array $questions, int $category): array
+    {
+        $buckets = [
+            'primary_low' => [],    // 主要低难度范围
+            'primary_medium' => [], // 主要中等难度范围
+            'primary_high' => [],   // 主要高难度范围
+            'secondary' => [],      // 次要范围
+            'other' => []           // 其他
+        ];
+
+        foreach ($questions as $question) {
+            $difficulty = (float) ($question['difficulty'] ?? 0);
+            $rangeKey = $this->classifyQuestionByDifficulty($difficulty, $category);
+            $buckets[$rangeKey][] = $question;
+        }
+
+        return $buckets;
+    }
+
+    /**
+     * 根据难度值和类别分类题目
+     *
+     * @param float $difficulty 难度值 (0-1)
+     * @param int $category 难度类别 (1-4)
+     * @return string 范围键
+     */
+    private function classifyQuestionByDifficulty(float $difficulty, int $category): string
+    {
+        switch ($category) {
+            case 1:
+                // 基础型:0-0.25作为主要中等,0.25-1作为其他
+                if ($difficulty >= 0 && $difficulty <= 0.25) {
+                    return 'primary_medium';
+                }
+                return 'other';
+
+            case 2:
+                // 进阶型:0.25-0.5作为主要中等,<0.25作为主要低,>0.5作为主要高
+                if ($difficulty >= 0.25 && $difficulty <= 0.5) {
+                    return 'primary_medium';
+                } elseif ($difficulty < 0.25) {
+                    return 'primary_low';
+                }
+                return 'primary_high';
+
+            case 3:
+                // 中等型:0.5-0.75作为主要中等,<0.5作为主要低,>0.75作为主要高
+                if ($difficulty >= 0.5 && $difficulty <= 0.75) {
+                    return 'primary_medium';
+                } elseif ($difficulty < 0.5) {
+                    return 'primary_low';
+                }
+                return 'primary_high';
+
+            case 4:
+                // 拔高型:0.75-1作为主要中等,0-0.75作为其他
+                if ($difficulty >= 0.75 && $difficulty <= 1.0) {
+                    return 'primary_medium';
+                }
+                return 'other';
+
+            default:
+                return 'other';
+        }
+    }
+
+    /**
+     * 将难度层级映射到范围键
+     *
+     * @param string $level 难度层级 (low/medium/high)
+     * @param int $category 难度类别
+     * @return string 范围键
+     */
+    private function mapDifficultyLevelToRangeKey(string $level, int $category): string
+    {
+        // 根据类别和层级确定范围键
+        switch ($category) {
+            case 1:
+                return match($level) {
+                    'low' => 'other',
+                    'medium' => 'primary_medium',
+                    'high' => 'other',
+                    default => 'other'
+                };
+
+            case 2:
+                return match($level) {
+                    'low' => 'primary_low',
+                    'medium' => 'primary_medium',
+                    'high' => 'primary_high',
+                    default => 'other'
+                };
+
+            case 3:
+                return match($level) {
+                    'low' => 'primary_low',
+                    'medium' => 'primary_medium',
+                    'high' => 'primary_high',
+                    default => 'other'
+                };
+
+            case 4:
+                return match($level) {
+                    'low' => 'other',
+                    'medium' => 'primary_medium',
+                    'high' => 'other',
+                    default => 'other'
+                };
+
+            default:
+                return 'other';
+        }
+    }
 }
 }