IntelligentExamController.php 40 KB

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