IntelligentExamController.php 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081
  1. <?php
  2. namespace App\Http\Controllers\Api;
  3. use App\Http\Controllers\Controller;
  4. use App\Models\Paper;
  5. use App\Models\PaperQuestion;
  6. use App\Services\LearningAnalyticsService;
  7. use App\Services\ExamPdfExportService;
  8. use App\Services\ExternalIdService;
  9. use App\Services\QuestionBankService;
  10. use App\Services\PaperPayloadService;
  11. use App\Services\TaskManager;
  12. use App\Models\MistakeRecord;
  13. use App\Models\Student;
  14. use App\Models\Teacher;
  15. use Illuminate\Http\JsonResponse;
  16. use Illuminate\Http\Request;
  17. use Illuminate\Support\Facades\DB;
  18. use Illuminate\Support\Facades\Http;
  19. use Illuminate\Support\Facades\Log;
  20. use Illuminate\Support\Facades\URL;
  21. class IntelligentExamController extends Controller
  22. {
  23. private LearningAnalyticsService $learningAnalyticsService;
  24. private QuestionBankService $questionBankService;
  25. private ExamPdfExportService $pdfExportService;
  26. private PaperPayloadService $paperPayloadService;
  27. private TaskManager $taskManager;
  28. private ExternalIdService $externalIdService;
  29. public function __construct(
  30. LearningAnalyticsService $learningAnalyticsService,
  31. QuestionBankService $questionBankService,
  32. ExamPdfExportService $pdfExportService,
  33. PaperPayloadService $paperPayloadService,
  34. TaskManager $taskManager,
  35. ExternalIdService $externalIdService
  36. ) {
  37. $this->learningAnalyticsService = $learningAnalyticsService;
  38. $this->questionBankService = $questionBankService;
  39. $this->pdfExportService = $pdfExportService;
  40. $this->paperPayloadService = $paperPayloadService;
  41. $this->taskManager = $taskManager;
  42. $this->externalIdService = $externalIdService;
  43. }
  44. /**
  45. * 外部API:生成智能试卷(异步模式)
  46. * 立即返回任务ID,PDF生成在后台进行,完成后通过回调通知
  47. */
  48. public function store(Request $request): JsonResponse
  49. {
  50. // 优先从body获取数据,不使用query params
  51. $payload = $request->json()->all();
  52. if (empty($payload)) {
  53. $payload = $request->all();
  54. }
  55. $normalized = $this->normalizePayload($payload);
  56. $validator = validator($normalized, [
  57. 'student_id' => 'required|string|min:1|regex:/^\\d+$/', // 接受字符串或数字类型,如"1764913638"或1764913638
  58. 'teacher_id' => 'required|string|min:1|regex:/^\\d+$/',
  59. 'paper_name' => 'nullable|string|max:255',
  60. 'grade' => 'required|integer|in:7,8,9',
  61. 'student_name' => 'required|string|max:50',
  62. 'teacher_name' => 'required|string|max:50',
  63. 'total_questions' => 'nullable|integer|min:1|max:100',
  64. 'difficulty_category' => 'nullable|integer|in:1,2,3,4',
  65. 'kp_codes' => 'nullable|array',
  66. 'kp_codes.*' => 'string',
  67. 'skills' => 'nullable|array',
  68. 'skills.*' => 'string',
  69. 'question_type_ratio' => 'nullable|array',
  70. // 'difficulty_ratio' 参数已废弃,使用 difficulty_category 控制难度分布
  71. 'total_score' => 'nullable|numeric|min:1|max:1000',
  72. 'mistake_ids' => 'nullable|array',
  73. 'mistake_ids.*' => 'string',
  74. 'mistake_question_ids' => 'nullable|array',
  75. 'mistake_question_ids.*' => 'string',
  76. 'callback_url' => 'nullable|url', // 异步完成后推送通知的URL
  77. // 新增:组卷类型
  78. 'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,6',
  79. 'exam_type' => 'nullable|string|in:general,diagnostic,practice,mistake,textbook,knowledge,knowledge_points',
  80. // 错题本类型专用参数
  81. 'paper_ids' => 'nullable|array',
  82. 'paper_ids.*' => 'string',
  83. // 修改:使用series_id + semester_code + grade替代textbook_id
  84. 'series_id' => 'nullable|integer|min:1', // 教材系列ID(替代textbook_id)
  85. 'semester_code' => 'nullable|integer|in:1,2', // 上下册:1=上册,2=下册
  86. // 新增:各组卷类型的专用参数
  87. 'chapter_id_list' => 'nullable|array', // 教材组卷专用
  88. 'chapter_id_list.*' => 'integer|min:1',
  89. 'kp_code_list' => 'nullable|array', // 知识点组卷专用
  90. 'kp_code_list.*' => 'string',
  91. 'end_catalog_id' => 'nullable|integer|min:1', // 摸底专用:截止章节ID
  92. // 新增:专项练习选项
  93. 'practice_options' => 'nullable|array',
  94. 'practice_options.weakness_threshold' => 'nullable|numeric|min:0|max:1',
  95. 'practice_options.intensity' => 'nullable|string|in:low,medium,high',
  96. 'practice_options.include_new_questions' => 'nullable|boolean',
  97. 'practice_options.focus_weaknesses' => 'nullable|boolean',
  98. // 新增:错题选项
  99. 'mistake_options' => 'nullable|array',
  100. 'mistake_options.weakness_threshold' => 'nullable|numeric|min:0|max:1',
  101. 'mistake_options.review_mistakes' => 'nullable|boolean',
  102. 'mistake_options.intensity' => 'nullable|string|in:low,medium,high',
  103. 'mistake_options.include_new_questions' => 'nullable|boolean',
  104. 'mistake_options.focus_weaknesses' => 'nullable|boolean',
  105. // 新增:按知识点组卷选项
  106. 'knowledge_points_options' => 'nullable|array',
  107. 'knowledge_points_options.weakness_threshold' => 'nullable|numeric|min:0|max:1',
  108. 'knowledge_points_options.intensity' => 'nullable|string|in:low,medium,high',
  109. 'knowledge_points_options.focus_weaknesses' => 'nullable|boolean',
  110. ]);
  111. if ($validator->fails()) {
  112. return response()->json([
  113. 'success' => false,
  114. 'message' => '参数错误',
  115. 'errors' => $validator->errors()->toArray(),
  116. ], 422);
  117. }
  118. $data = $validator->validated();
  119. $data['total_questions'] = $data['total_questions'] ?? 20;
  120. $this->ensureStudentTeacherRelation($data);
  121. // 【修改】使用series_id、semester_code和grade获取textbook_id
  122. $textbookId = $this->resolveTextbookId($data);
  123. if ($textbookId) {
  124. $data['textbook_id'] = $textbookId;
  125. }
  126. // 确保 kp_codes 是数组
  127. $data['kp_codes'] = $data['kp_codes'] ?? [];
  128. if (!is_array($data['kp_codes'])) {
  129. $data['kp_codes'] = [];
  130. }
  131. $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []);
  132. // 注意: difficulty_ratio 参数已废弃,使用 difficulty_category 控制难度分布
  133. $paperName = $data['paper_name'] ?? ('智能试卷_' . now()->format('Ymd_His'));
  134. $difficultyCategory = $data['difficulty_category'] ?? 1; // 直接使用数字,不转换
  135. $mistakeIds = $data['mistake_ids'] ?? [];
  136. $mistakeQuestionIds = $data['mistake_question_ids'] ?? [];
  137. $paperIds = $data['paper_ids'] ?? [];
  138. $assembleType = $data['assemble_type'] ?? 4; // 默认为通用类型(4)
  139. try {
  140. $questions = [];
  141. $result = null;
  142. if (!empty($mistakeIds) || !empty($mistakeQuestionIds)) {
  143. $questionIds = $this->resolveMistakeQuestionIds(
  144. $data['student_id'],
  145. $mistakeIds,
  146. $mistakeQuestionIds
  147. );
  148. if (empty($questionIds)) {
  149. return response()->json([
  150. 'success' => false,
  151. 'message' => '未找到可用的错题题目,请检查错题ID或学生ID',
  152. ], 400);
  153. }
  154. $bankQuestions = $this->questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
  155. if (empty($bankQuestions)) {
  156. return response()->json([
  157. 'success' => false,
  158. 'message' => '错题对应的题库题目不存在或不可用',
  159. ], 400);
  160. }
  161. $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes']);
  162. $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
  163. $paperName = $data['paper_name'] ?? ('错题复习_' . $data['student_id'] . '_' . now()->format('Ymd_His'));
  164. } else {
  165. // 第一步:生成智能试卷(同步)
  166. $params = [
  167. 'student_id' => $data['student_id'],
  168. 'grade' => $data['grade'] ?? null,
  169. 'total_questions' => $data['total_questions'],
  170. // 【修复】教材组卷时不使用用户传入的kp_codes,只使用章节关联的知识点
  171. 'kp_codes' => $assembleType == 3 ? null : ($data['kp_codes'] ?? null),
  172. 'kp_code_list' => $assembleType == 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []),
  173. 'skills' => $data['skills'] ?? [],
  174. 'question_type_ratio' => $questionTypeRatio,
  175. 'difficulty_category' => $difficultyCategory, // 传递难度分类(数字)
  176. 'assemble_type' => $assembleType, // 新版组卷类型
  177. 'exam_type' => $data['exam_type'] ?? 'general', // 兼容旧版参数
  178. 'paper_ids' => $paperIds, // 错题本类型专用参数
  179. 'textbook_id' => $data['textbook_id'] ?? null, // 摸底和智能组卷专用
  180. 'end_catalog_id' => $data['end_catalog_id'] ?? null, // 摸底专用:截止章节ID
  181. 'chapter_id_list' => $data['chapter_id_list'] ?? null, // 教材组卷专用
  182. 'kp_code_list' => $assembleType == 3 ? null : ($data['kp_code_list'] ?? null), // 知识点组卷专用
  183. 'practice_options' => $data['practice_options'] ?? null, // 传递专项练习选项
  184. 'mistake_options' => $data['mistake_options'] ?? null, // 传递错题选项
  185. ];
  186. $result = $this->learningAnalyticsService->generateIntelligentExam($params);
  187. if (empty($result['success'])) {
  188. $errorMsg = $result['message'] ?? '智能出卷失败';
  189. Log::error('智能出卷失败', [
  190. 'student_id' => $data['student_id'],
  191. 'error' => $result
  192. ]);
  193. // 提供更详细的错误信息
  194. if (strpos($errorMsg, '超时') !== false) {
  195. $errorMsg = '服务响应超时,请稍后重试';
  196. } elseif (strpos($errorMsg, '连接') !== false) {
  197. $errorMsg = '依赖服务连接失败,请检查服务状态';
  198. }
  199. return response()->json([
  200. 'success' => false,
  201. 'message' => $errorMsg,
  202. 'details' => $result['details'] ?? null,
  203. ], 400);
  204. }
  205. $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes']);
  206. }
  207. if (empty($questions)) {
  208. return response()->json([
  209. 'success' => false,
  210. 'message' => '未能生成有效题目,请检查知识点或题库数据',
  211. ], 400);
  212. }
  213. // 错题本类型不需要限制题目数量,由错题数量决定
  214. if ($assembleType === 5) {
  215. // 错题本:使用所有错题,不限制数量
  216. Log::info('错题本类型,使用所有错题', [
  217. 'assemble_type' => $assembleType,
  218. 'question_count' => count($questions)
  219. ]);
  220. } else {
  221. // 其他类型:限制题目数量
  222. $totalQuestions = min($data['total_questions'], count($questions));
  223. $questions = array_slice($questions, 0, $totalQuestions);
  224. }
  225. // 调整题目分值,确保符合中国中学卷子标准(总分100分)
  226. $questions = $this->adjustQuestionScores($questions, 100.0);
  227. // 计算总分
  228. $totalScore = array_sum(array_column($questions, 'score'));
  229. // 第二步:保存试卷到数据库(同步)
  230. $paperId = $this->questionBankService->saveExamToDatabase([
  231. 'paper_name' => $paperName,
  232. 'student_id' => $data['student_id'],
  233. 'teacher_id' => $data['teacher_id'] ?? null,
  234. 'difficulty_category' => $difficultyCategory,
  235. 'total_score' => $data['total_score'] ?? 100.0, // 默认100分
  236. 'questions' => $questions,
  237. ]);
  238. if (!$paperId) {
  239. return response()->json([
  240. 'success' => false,
  241. 'message' => '试卷保存失败',
  242. ], 500);
  243. }
  244. // 第三步:创建异步任务(使用TaskManager)
  245. // 注意:callback_url会在TaskManager中被提取并保存
  246. $taskId = $this->taskManager->createTask(TaskManager::TASK_TYPE_EXAM, array_merge($data, ['paper_id' => $paperId]));
  247. // 生成识别码
  248. $codes = $this->paperPayloadService->generatePaperCodes($paperId);
  249. // 立即返回完整的试卷数据(不等待PDF生成)
  250. $paperModel = Paper::with('questions')->find($paperId);
  251. $examContent = $paperModel
  252. ? $this->paperPayloadService->buildExamContent($paperModel)
  253. : [];
  254. // 触发后台PDF生成
  255. $this->triggerPdfGeneration($taskId, $paperId);
  256. $payload = [
  257. 'success' => true,
  258. 'message' => '智能试卷创建成功,PDF正在后台生成...',
  259. 'data' => [
  260. 'task_id' => $taskId,
  261. 'paper_id' => $paperId,
  262. 'status' => 'processing',
  263. // 识别码
  264. 'exam_code' => $codes['exam_code'], // 试卷识别码 (1+12位)
  265. 'grading_code' => $codes['grading_code'], // 判卷识别码 (2+12位)
  266. 'paper_id_num' => $codes['paper_id_num'], // 12位数字ID
  267. 'exam_content' => $examContent,
  268. 'urls' => [
  269. 'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]),
  270. 'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']),
  271. ],
  272. 'pdfs' => [
  273. 'exam_paper_pdf' => null,
  274. 'grading_pdf' => null,
  275. ],
  276. 'stats' => $result['stats'] ?? [
  277. 'total_selected' => count($questions),
  278. 'mistake_based' => !empty($mistakeIds) || !empty($mistakeQuestionIds),
  279. ],
  280. 'created_at' => now()->toISOString(),
  281. ],
  282. ];
  283. return response()->json($payload, 200, [], JSON_UNESCAPED_SLASHES);
  284. } catch (\Exception $e) {
  285. Log::error('Intelligent exam API failed', [
  286. 'error' => $e->getMessage(),
  287. 'trace' => $e->getTraceAsString(),
  288. ]);
  289. // 返回更具体的错误信息
  290. $errorMessage = $e->getMessage();
  291. if (strpos($errorMessage, 'Connection') !== false || strpos($errorMessage, 'connection') !== false) {
  292. $errorMessage = '依赖服务连接失败,请检查服务状态';
  293. } elseif (strpos($errorMessage, 'timeout') !== false || strpos($errorMessage, '超时') !== false) {
  294. $errorMessage = '服务响应超时,请稍后重试';
  295. } elseif (strpos($errorMessage, 'not found') !== false || strpos($errorMessage, '未找到') !== false) {
  296. $errorMessage = '请求的资源不存在';
  297. } elseif (strpos($errorMessage, 'invalid') !== false || strpos($errorMessage, '无效') !== false) {
  298. $errorMessage = '请求参数无效';
  299. }
  300. return response()->json([
  301. 'success' => false,
  302. 'message' => $errorMessage ?: '服务异常,请稍后重试',
  303. ], 500);
  304. }
  305. }
  306. /**
  307. * 轮询任务状态
  308. */
  309. public function status(string $taskId): JsonResponse
  310. {
  311. try {
  312. $task = $this->taskManager->getTaskStatus($taskId);
  313. if (!$task) {
  314. return response()->json([
  315. 'success' => false,
  316. 'message' => '任务不存在',
  317. ], 404);
  318. }
  319. return response()->json([
  320. 'success' => true,
  321. 'data' => $task,
  322. ]);
  323. } catch (\Exception $e) {
  324. Log::error('查询任务状态失败', [
  325. 'task_id' => $taskId,
  326. 'error' => $e->getMessage(),
  327. ]);
  328. return response()->json([
  329. 'success' => false,
  330. 'message' => '查询失败,请稍后重试',
  331. ], 500);
  332. }
  333. }
  334. /**
  335. * 触发PDF生成
  336. * 使用队列进行异步处理
  337. */
  338. private function triggerPdfGeneration(string $taskId, string $paperId): void
  339. {
  340. // 异步处理PDF生成 - 将任务放入队列
  341. try {
  342. dispatch(new \App\Jobs\GenerateExamPdfJob($taskId, $paperId));
  343. Log::info('PDF生成任务已加入队列', [
  344. 'task_id' => $taskId,
  345. 'paper_id' => $paperId
  346. ]);
  347. } catch (\Exception $e) {
  348. Log::error('PDF生成任务队列失败,不回退到同步处理', [
  349. 'task_id' => $taskId,
  350. 'paper_id' => $paperId,
  351. 'error' => $e->getMessage(),
  352. 'note' => '依赖队列重试机制,不进行同步处理以避免并发冲突'
  353. ]);
  354. // 【优化】不回退到同步处理,避免与队列任务并发冲突
  355. // 队列系统有重试机制,会自动处理失败情况
  356. // $this->processPdfGeneration($taskId, $paperId);
  357. }
  358. }
  359. /**
  360. * 处理PDF生成(模拟后台任务)
  361. * 在实际项目中,这个方法应该在队列worker中执行
  362. */
  363. private function processPdfGeneration(string $taskId, string $paperId): void
  364. {
  365. try {
  366. $this->taskManager->updateTaskProgress($taskId, 10, '开始生成试卷PDF...');
  367. // 生成试卷PDF
  368. $pdfUrl = $this->pdfExportService->generateExamPdf($paperId)
  369. ?? $this->questionBankService->exportExamToPdf($paperId)
  370. ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']);
  371. $this->taskManager->updateTaskProgress($taskId, 50, '试卷PDF生成完成,开始生成判卷PDF...');
  372. // 生成判卷PDF
  373. $gradingPdfUrl = $this->pdfExportService->generateGradingPdf($paperId)
  374. ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'true']);
  375. // 构建完整的试卷内容
  376. $paperModel = Paper::with('questions')->find($paperId);
  377. $examContent = $paperModel
  378. ? $this->paperPayloadService->buildExamContent($paperModel)
  379. : [];
  380. // 标记任务完成
  381. $this->taskManager->markTaskCompleted($taskId, [
  382. 'exam_content' => $examContent,
  383. 'pdfs' => [
  384. 'exam_paper_pdf' => $pdfUrl,
  385. 'grading_pdf' => $gradingPdfUrl,
  386. ],
  387. ]);
  388. Log::info('异步任务完成', [
  389. 'task_id' => $taskId,
  390. 'paper_id' => $paperId,
  391. 'pdf_url' => $pdfUrl,
  392. 'grading_pdf_url' => $gradingPdfUrl,
  393. ]);
  394. // 发送回调通知
  395. $this->taskManager->sendCallback($taskId);
  396. } catch (\Exception $e) {
  397. Log::error('PDF生成失败', [
  398. 'task_id' => $taskId,
  399. 'paper_id' => $paperId,
  400. 'error' => $e->getMessage(),
  401. ]);
  402. $this->taskManager->markTaskFailed($taskId, $e->getMessage());
  403. }
  404. }
  405. /**
  406. * 兼容字符串/数组入参
  407. */
  408. private function normalizePayload(array $payload): array
  409. {
  410. // 处理 question_count 参数:转换为 total_questions
  411. if (isset($payload['question_count']) && !isset($payload['total_questions'])) {
  412. $payload['total_questions'] = $payload['question_count'];
  413. unset($payload['question_count']);
  414. }
  415. // 将student_id转换为字符串(支持数字和字符串输入)
  416. if (isset($payload['student_id'])) {
  417. $payload['student_id'] = (string) $payload['student_id'];
  418. }
  419. if (isset($payload['teacher_id'])) {
  420. $payload['teacher_id'] = (string) $payload['teacher_id'];
  421. }
  422. if (isset($payload['grade'])) {
  423. $payload['grade'] = (string) $payload['grade'];
  424. }
  425. // 处理 kp_codes:空字符串或null转换为空数组
  426. if (isset($payload['kp_codes'])) {
  427. if (is_string($payload['kp_codes'])) {
  428. $kpCodes = trim($payload['kp_codes']);
  429. if (empty($kpCodes)) {
  430. $payload['kp_codes'] = [];
  431. } else {
  432. $payload['kp_codes'] = array_values(array_filter(array_map('trim', explode(',', $kpCodes))));
  433. }
  434. } elseif (!is_array($payload['kp_codes'])) {
  435. $payload['kp_codes'] = [];
  436. }
  437. } else {
  438. $payload['kp_codes'] = [];
  439. }
  440. if (isset($payload['skills']) && is_string($payload['skills'])) {
  441. $payload['skills'] = array_values(array_filter(array_map('trim', explode(',', $payload['skills']))));
  442. }
  443. foreach (['mistake_ids', 'mistake_question_ids'] as $key) {
  444. if (isset($payload[$key])) {
  445. if (is_string($payload[$key])) {
  446. $raw = trim($payload[$key]);
  447. $payload[$key] = $raw === ''
  448. ? []
  449. : array_values(array_filter(array_map('trim', explode(',', $raw))));
  450. } elseif (!is_array($payload[$key])) {
  451. $payload[$key] = [];
  452. }
  453. }
  454. }
  455. // 新增:处理组卷专用参数
  456. foreach (['chapter_id_list', 'kp_code_list'] as $key) {
  457. if (isset($payload[$key])) {
  458. if (is_string($payload[$key])) {
  459. $raw = trim($payload[$key]);
  460. $payload[$key] = $raw === ''
  461. ? []
  462. : array_values(array_filter(array_map('trim', explode(',', $raw))));
  463. } elseif (!is_array($payload[$key])) {
  464. $payload[$key] = [];
  465. }
  466. } else {
  467. $payload[$key] = [];
  468. }
  469. }
  470. // 【修改】处理series_id:字符串转换为整数
  471. if (isset($payload['series_id'])) {
  472. if (is_string($payload['series_id'])) {
  473. $payload['series_id'] = (int) trim($payload['series_id']);
  474. if ($payload['series_id'] <= 0) {
  475. unset($payload['series_id']);
  476. }
  477. } elseif (!is_int($payload['series_id']) || $payload['series_id'] <= 0) {
  478. unset($payload['series_id']);
  479. }
  480. }
  481. // 【新增】处理semester_code:确保是1或2
  482. if (isset($payload['semester_code'])) {
  483. if (is_string($payload['semester_code'])) {
  484. $payload['semester_code'] = (int) trim($payload['semester_code']);
  485. }
  486. // 只保留1或2,其他值都移除
  487. if (!in_array($payload['semester_code'], [1, 2], true)) {
  488. unset($payload['semester_code']);
  489. }
  490. }
  491. // 新增:处理组卷类型,默认值为 general
  492. if (!isset($payload['exam_type'])) {
  493. $payload['exam_type'] = 'general';
  494. }
  495. // 新增:处理专项练习选项
  496. if (isset($payload['practice_options'])) {
  497. if (is_string($payload['practice_options'])) {
  498. $decoded = json_decode($payload['practice_options'], true);
  499. $payload['practice_options'] = is_array($decoded) ? $decoded : [];
  500. } elseif (!is_array($payload['practice_options'])) {
  501. $payload['practice_options'] = [];
  502. }
  503. // 设置默认值
  504. $payload['practice_options'] = array_merge([
  505. 'weakness_threshold' => 0.7,
  506. 'intensity' => 'medium',
  507. 'include_new_questions' => true,
  508. 'focus_weaknesses' => true,
  509. ], $payload['practice_options']);
  510. } else {
  511. // 如果没有提供 practice_options,创建默认值
  512. $payload['practice_options'] = [
  513. 'weakness_threshold' => 0.7,
  514. 'intensity' => 'medium',
  515. 'include_new_questions' => true,
  516. 'focus_weaknesses' => true,
  517. ];
  518. }
  519. // 新增:处理错题选项
  520. if (isset($payload['mistake_options'])) {
  521. if (is_string($payload['mistake_options'])) {
  522. $decoded = json_decode($payload['mistake_options'], true);
  523. $payload['mistake_options'] = is_array($decoded) ? $decoded : [];
  524. } elseif (!is_array($payload['mistake_options'])) {
  525. $payload['mistake_options'] = [];
  526. }
  527. // 设置默认值
  528. $payload['mistake_options'] = array_merge([
  529. 'weakness_threshold' => 0.7,
  530. 'review_mistakes' => true,
  531. 'intensity' => 'medium',
  532. 'include_new_questions' => true,
  533. 'focus_weaknesses' => true,
  534. ], $payload['mistake_options']);
  535. } else {
  536. // 如果没有提供 mistake_options,创建默认值
  537. $payload['mistake_options'] = [
  538. 'weakness_threshold' => 0.7,
  539. 'review_mistakes' => true,
  540. 'intensity' => 'medium',
  541. 'include_new_questions' => true,
  542. 'focus_weaknesses' => true,
  543. ];
  544. }
  545. // 新增:处理按知识点组卷选项
  546. if (isset($payload['knowledge_points_options'])) {
  547. if (is_string($payload['knowledge_points_options'])) {
  548. $decoded = json_decode($payload['knowledge_points_options'], true);
  549. $payload['knowledge_points_options'] = is_array($decoded) ? $decoded : [];
  550. } elseif (!is_array($payload['knowledge_points_options'])) {
  551. $payload['knowledge_points_options'] = [];
  552. }
  553. // 设置默认值
  554. $payload['knowledge_points_options'] = array_merge([
  555. 'weakness_threshold' => 0.7,
  556. 'intensity' => 'medium',
  557. 'focus_weaknesses' => true,
  558. ], $payload['knowledge_points_options']);
  559. } else {
  560. // 如果没有提供 knowledge_points_options,创建默认值
  561. $payload['knowledge_points_options'] = [
  562. 'weakness_threshold' => 0.7,
  563. 'intensity' => 'medium',
  564. 'focus_weaknesses' => true,
  565. ];
  566. }
  567. return $payload;
  568. }
  569. private function ensureStudentTeacherRelation(array $data): void
  570. {
  571. $studentId = (int) $data['student_id'];
  572. $teacherId = (int) $data['teacher_id'];
  573. $studentName = (string) ($data['student_name'] ?? '未知学生');
  574. $teacherName = (string) ($data['teacher_name'] ?? '未知教师');
  575. $grade = (string) ($data['grade'] ?? '未知年级');
  576. $teacher = $this->externalIdService->handleTeacherExternalId($teacherId, [
  577. 'name' => $teacherName,
  578. 'subject' => '数学',
  579. ]);
  580. $student = Student::where('student_id', $studentId)->first();
  581. if ($student) {
  582. $updates = [];
  583. if ($studentName !== '' && $student->name !== $studentName) {
  584. $updates['name'] = $studentName;
  585. }
  586. if ($grade !== '' && $student->grade !== $grade) {
  587. $updates['grade'] = $grade;
  588. }
  589. if ($teacherId > 0 && (int) $student->teacher_id !== $teacherId) {
  590. $updates['teacher_id'] = $teacherId;
  591. }
  592. if (!empty($updates)) {
  593. $student->update($updates);
  594. }
  595. return;
  596. }
  597. $this->externalIdService->handleStudentExternalId($studentId, [
  598. 'name' => $studentName,
  599. 'grade' => $grade,
  600. 'teacher_id' => $teacherId,
  601. ]);
  602. }
  603. private function normalizeQuestionTypeRatio(array $input): array
  604. {
  605. // 默认按 4:2:4
  606. $defaults = [
  607. '选择题' => 40,
  608. '填空题' => 20,
  609. '解答题' => 40,
  610. ];
  611. $normalized = [];
  612. foreach ($input as $key => $value) {
  613. if (!is_numeric($value)) {
  614. continue;
  615. }
  616. $type = $this->normalizeQuestionTypeKey($key);
  617. if ($type) {
  618. $normalized[$type] = (float) $value;
  619. }
  620. }
  621. $merged = array_merge($defaults, $normalized);
  622. // 归一化到 100%
  623. $sum = array_sum($merged);
  624. if ($sum > 0) {
  625. foreach ($merged as $k => $v) {
  626. $merged[$k] = round(($v / $sum) * 100, 2);
  627. }
  628. }
  629. return $merged;
  630. }
  631. private function normalizeQuestionTypeKey(string $key): ?string
  632. {
  633. $key = trim($key);
  634. if (in_array($key, ['choice', '选择题', 'single_choice', 'multiple_choice', 'CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) {
  635. return '选择题';
  636. }
  637. if (in_array($key, ['fill', '填空题', 'blank', 'FILL_IN_THE_BLANK', 'FILL'], true)) {
  638. return '填空题';
  639. }
  640. if (in_array($key, ['answer', '解答题', '计算题', 'CALCULATION', 'WORD_PROBLEM', 'PROOF'], true)) {
  641. return '解答题';
  642. }
  643. return null;
  644. }
  645. private function normalizeDifficultyRatio(array $input): array
  646. {
  647. $defaults = [
  648. '基础' => 50,
  649. '中等' => 35,
  650. '拔高' => 15,
  651. ];
  652. $normalized = [];
  653. foreach ($input as $key => $value) {
  654. if (!is_numeric($value)) {
  655. continue;
  656. }
  657. $label = trim($key);
  658. if (in_array($label, ['基础', 'easy', '简单'])) {
  659. $normalized['基础'] = (float) $value;
  660. } elseif (in_array($label, ['中等', 'medium'])) {
  661. $normalized['中等'] = (float) $value;
  662. } elseif (in_array($label, ['拔高', 'hard', '困难', '竞赛'])) {
  663. $normalized['拔高'] = (float) $value;
  664. }
  665. }
  666. return array_merge($defaults, $normalized);
  667. }
  668. private function hydrateQuestions(array $questions, array $kpCodes): array
  669. {
  670. $normalized = [];
  671. foreach ($questions as $question) {
  672. $type = $this->normalizeQuestionTypeKey($question['question_type'] ?? $question['type'] ?? '') ?? $this->guessType($question);
  673. $score = $question['score'] ?? $this->defaultScore($type);
  674. $normalized[] = [
  675. 'id' => $question['id'] ?? $question['question_id'] ?? null,
  676. 'question_id' => $question['question_id'] ?? null,
  677. 'question_type' => $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer'),
  678. 'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''),
  679. 'content' => $question['content'] ?? $question['stem'] ?? '',
  680. 'options' => $question['options'] ?? ($question['choices'] ?? []),
  681. 'answer' => $question['answer'] ?? $question['correct_answer'] ?? '',
  682. 'solution' => $question['solution'] ?? '',
  683. 'difficulty' => isset($question['difficulty']) ? (float) $question['difficulty'] : 0.5,
  684. 'score' => $score,
  685. 'estimated_time' => $question['estimated_time'] ?? 300,
  686. 'kp' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
  687. 'kp_code' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
  688. ];
  689. }
  690. return array_values(array_filter($normalized, fn ($q) => !empty($q['id'])));
  691. }
  692. private function guessType(array $question): string
  693. {
  694. if (!empty($question['options']) && is_array($question['options'])) {
  695. return '选择题';
  696. }
  697. $content = $question['stem'] ?? $question['content'] ?? '';
  698. if (is_string($content) && (strpos($content, '____') !== false || strpos($content, '()') !== false)) {
  699. return '填空题';
  700. }
  701. return '解答题';
  702. }
  703. /**
  704. * 根据题目类型获取默认分值(中国中学卷子标准)
  705. * 选择题:5分/题,填空题:5分/题,解答题:10分/题
  706. */
  707. private function defaultScore(string $type): int
  708. {
  709. return match ($type) {
  710. '选择题' => 5,
  711. '填空题' => 5,
  712. '解答题' => 10,
  713. default => 5,
  714. };
  715. }
  716. /**
  717. * 计算试卷总分并调整各题目分值,确保总分接近目标分数
  718. * 符合中国中学卷子标准:
  719. * - 选择题:约40%总分(每题4-6分,整数分值)
  720. * - 填空题:约25%总分(每题4-6分,整数分值)
  721. * - 解答题:约35%总分(每题8-12分,整数分值)
  722. * 使用组合优化算法确保:
  723. * 1. 所有分值都是整数(无小数点)
  724. * 2. 同类型题目分值均匀
  725. * 3. 总分精确匹配目标分数(或最接近)
  726. */
  727. private function adjustQuestionScores(array $questions, float $targetTotalScore = 100.0): array
  728. {
  729. if (empty($questions)) {
  730. return $questions;
  731. }
  732. // 统计各类型题目数量
  733. $typeCounts = ['choice' => 0, 'fill' => 0, 'answer' => 0];
  734. foreach ($questions as $question) {
  735. $type = $question['question_type'] ?? 'answer';
  736. if (in_array($type, ['CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) {
  737. $type = 'choice';
  738. } elseif (in_array($type, ['FILL_IN_THE_BLANK', 'FILL'], true)) {
  739. $type = 'fill';
  740. } elseif (in_array($type, ['CALCULATION', 'WORD_PROBLEM', 'PROOF', 'ANSWER'], true)) {
  741. $type = 'answer';
  742. }
  743. if (isset($typeCounts[$type])) {
  744. $typeCounts[$type]++;
  745. }
  746. }
  747. // 标准分值范围
  748. $standardScoreRanges = [
  749. 'choice' => ['min' => 4, 'max' => 6],
  750. 'fill' => ['min' => 4, 'max' => 6],
  751. 'answer' => ['min' => 8, 'max' => 12],
  752. ];
  753. // 目标比例
  754. $typeRatios = ['choice' => 0.40, 'fill' => 0.25, 'answer' => 0.35];
  755. // 检查可用题型
  756. $availableTypes = array_filter($typeCounts, fn($count) => $count > 0);
  757. $availableTypeCount = count($availableTypes);
  758. $isPartialTypes = $availableTypeCount < 3 && $availableTypeCount > 0;
  759. if ($isPartialTypes) {
  760. $equalRatio = 1.0 / $availableTypeCount;
  761. foreach ($typeCounts as $type => $count) {
  762. if ($count > 0) {
  763. $typeRatios[$type] = $equalRatio;
  764. } else {
  765. $typeRatios[$type] = 0;
  766. }
  767. }
  768. }
  769. $typeQuestionIndexes = ['choice' => [], 'fill' => [], 'answer' => []];
  770. // 记录每种题型的题目索引
  771. foreach ($questions as $index => $question) {
  772. $type = $question['question_type'] ?? 'answer';
  773. if (in_array($type, ['CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) {
  774. $type = 'choice';
  775. } elseif (in_array($type, ['FILL_IN_THE_BLANK', 'FILL'], true)) {
  776. $type = 'fill';
  777. } elseif (in_array($type, ['CALCULATION', 'WORD_PROBLEM', 'PROOF', 'ANSWER'], true)) {
  778. $type = 'answer';
  779. }
  780. $typeQuestionIndexes[$type][] = $index;
  781. }
  782. // 生成每种题型的可能分值选项
  783. $typeScoreOptions = [];
  784. foreach ($typeQuestionIndexes as $type => $indexes) {
  785. if (empty($indexes)) {
  786. continue;
  787. }
  788. $typeQuestionCount = count($indexes);
  789. $minScore = $standardScoreRanges[$type]['min'];
  790. $maxScore = $standardScoreRanges[$type]['max'];
  791. $targetTotal = $targetTotalScore * $typeRatios[$type];
  792. $idealPerQuestion = $targetTotal / $typeQuestionCount;
  793. $options = [];
  794. // 添加标准范围内的选项
  795. for ($score = $minScore; $score <= $maxScore; $score++) {
  796. $total = $score * $typeQuestionCount;
  797. $options[] = [
  798. 'score' => $score,
  799. 'total' => $total,
  800. 'difference' => abs($targetTotalScore - $total),
  801. ];
  802. }
  803. // 如果是部分题型,大幅扩展搜索范围
  804. if ($isPartialTypes) {
  805. $idealScore = (int) round($idealPerQuestion);
  806. $searchMin = max($minScore, $idealScore - 10);
  807. $searchMax = $idealScore + 10;
  808. for ($score = $searchMin; $score <= $searchMax; $score++) {
  809. if ($score >= $minScore) {
  810. $total = $score * $typeQuestionCount;
  811. if (!in_array($total, array_column($options, 'total'))) {
  812. $options[] = [
  813. 'score' => $score,
  814. 'total' => $total,
  815. 'difference' => abs($targetTotalScore - $total),
  816. ];
  817. }
  818. }
  819. }
  820. }
  821. $typeScoreOptions[$type] = $options;
  822. }
  823. // 生成所有可能的组合
  824. $types = array_keys(array_filter($typeQuestionIndexes, fn($indexes) => !empty($indexes)));
  825. $allCombinations = [[]];
  826. foreach ($types as $type) {
  827. $newCombinations = [];
  828. foreach ($allCombinations as $combo) {
  829. foreach ($typeScoreOptions[$type] as $option) {
  830. $newCombo = $combo;
  831. $newCombo[$type] = $option;
  832. $newCombinations[] = $newCombo;
  833. }
  834. }
  835. $allCombinations = $newCombinations;
  836. }
  837. // 找到最佳组合(优先精确匹配,其次最接近)
  838. $bestCombination = null;
  839. $bestDifference = PHP_FLOAT_MAX;
  840. $exactMatchFound = false;
  841. foreach ($allCombinations as $combo) {
  842. $totalScore = array_sum(array_column($combo, 'total'));
  843. $difference = abs($targetTotalScore - $totalScore);
  844. if ($difference == 0) {
  845. $bestCombination = $combo;
  846. $exactMatchFound = true;
  847. break;
  848. }
  849. if ($difference < $bestDifference) {
  850. $bestDifference = $difference;
  851. $bestCombination = $combo;
  852. }
  853. }
  854. // 应用最佳组合
  855. $adjustedQuestions = [];
  856. if ($bestCombination) {
  857. foreach ($bestCombination as $type => $option) {
  858. $score = $option['score'];
  859. foreach ($typeQuestionIndexes[$type] as $index) {
  860. $question = $questions[$index];
  861. $question['score'] = $score;
  862. $adjustedQuestions[$index] = $question;
  863. }
  864. }
  865. }
  866. return array_values($adjustedQuestions);
  867. }
  868. private function resolveMistakeQuestionIds(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array
  869. {
  870. $questionIds = [];
  871. if (!empty($mistakeQuestionIds)) {
  872. $questionIds = array_merge($questionIds, $mistakeQuestionIds);
  873. }
  874. if (!empty($mistakeIds)) {
  875. $mistakeQuestionIdsFromDb = MistakeRecord::query()
  876. ->where('student_id', $studentId)
  877. ->whereIn('id', $mistakeIds)
  878. ->pluck('question_id')
  879. ->filter()
  880. ->values()
  881. ->all();
  882. $questionIds = array_merge($questionIds, $mistakeQuestionIdsFromDb);
  883. }
  884. $questionIds = array_values(array_unique(array_filter($questionIds)));
  885. return $questionIds;
  886. }
  887. private function sortQuestionsByRequestedIds(array $questions, array $requestedIds): array
  888. {
  889. if (empty($requestedIds)) {
  890. return $questions;
  891. }
  892. $order = array_flip($requestedIds);
  893. usort($questions, function ($a, $b) use ($order) {
  894. $aId = (string) ($a['id'] ?? '');
  895. $bId = (string) ($b['id'] ?? '');
  896. $aPos = $order[$aId] ?? PHP_INT_MAX;
  897. $bPos = $order[$bId] ?? PHP_INT_MAX;
  898. return $aPos <=> $bPos;
  899. });
  900. return $questions;
  901. }
  902. /**
  903. * 【新增】根据series_id、semester_code和grade获取textbook_id
  904. * 替代原来直接传入textbook_id的方式
  905. */
  906. private function resolveTextbookId(array $data): ?int
  907. {
  908. // 如果提供了series_id和semester_code,则查询textbook_id
  909. $seriesId = $data['series_id'] ?? null;
  910. $semesterCode = $data['semester_code'] ?? null;
  911. $grade = $data['grade'] ?? null;
  912. // 如果没有提供series_id或semester_code,则不设置textbook_id
  913. if (!$seriesId || !$semesterCode) {
  914. return null;
  915. }
  916. try {
  917. // 根据series_id、semester_code和grade查询textbooks表
  918. $query = DB::connection('mysql')
  919. ->table('textbooks')
  920. ->where('series_id', $seriesId)
  921. ->where('semester', $semesterCode);
  922. // 如果提供了grade,可以作为额外筛选条件
  923. if ($grade) {
  924. $query->where('grade', $grade);
  925. }
  926. $textbook = $query->first();
  927. if ($textbook) {
  928. Log::info('成功解析textbook_id', [
  929. 'series_id' => $seriesId,
  930. 'semester_code' => $semesterCode,
  931. 'grade' => $grade,
  932. 'textbook_id' => $textbook->id
  933. ]);
  934. return (int) $textbook->id;
  935. }
  936. Log::warning('未找到匹配的教材', [
  937. 'series_id' => $seriesId,
  938. 'semester_code' => $semesterCode,
  939. 'grade' => $grade
  940. ]);
  941. return null;
  942. } catch (\Exception $e) {
  943. Log::error('查询textbook_id失败', [
  944. 'series_id' => $seriesId,
  945. 'semester_code' => $semesterCode,
  946. 'grade' => $grade,
  947. 'error' => $e->getMessage()
  948. ]);
  949. return null;
  950. }
  951. }
  952. }