PaperSubmitAnalysisController.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  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\ExamAnswerAnalysisService;
  7. use App\Services\MistakeBookService;
  8. use App\Services\QuestionBankService;
  9. use Illuminate\Http\JsonResponse;
  10. use Illuminate\Http\Request;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Support\Facades\Log;
  13. use Illuminate\Support\Facades\Validator;
  14. /**
  15. * 试卷提交分析控制器
  16. *
  17. * 接收前端提交的试卷答题数据,进行学情分析并写入错题本
  18. */
  19. class PaperSubmitAnalysisController extends Controller
  20. {
  21. public function __construct(
  22. private readonly ExamAnswerAnalysisService $analysisService,
  23. private readonly MistakeBookService $mistakeBookService,
  24. private readonly QuestionBankService $questionBankService
  25. ) {}
  26. /**
  27. * 提交试卷答题数据进行分析
  28. *
  29. * POST /api/paper-submit-analysis
  30. *
  31. * 请求格式:
  32. * {
  33. * "paper_id": "paper_661325736792",
  34. * "questions": [
  35. * {
  36. * "question_id": 876, // 前端使用 question_id
  37. * "student_answer": "C",
  38. * "is_correct": [0], // 0=错, 1=对;简答题分步骤 [1,0,1]
  39. * "teacher_comment": null
  40. * }
  41. * ]
  42. * }
  43. */
  44. public function analyze(Request $request): JsonResponse
  45. {
  46. try {
  47. // 1. 验证请求数据(前端使用 question_id)
  48. $validator = Validator::make($request->all(), [
  49. 'paper_id' => 'required|string|max:255',
  50. 'questions' => 'required|array|min:1',
  51. 'questions.*.question_id' => 'required|integer', // 前端使用 question_id
  52. 'questions.*.student_answer' => 'nullable|string',
  53. 'questions.*.is_correct' => 'required|array|min:1',
  54. 'questions.*.is_correct.*' => 'integer|in:0,1',
  55. 'questions.*.teacher_comment' => 'nullable|string',
  56. ]);
  57. if ($validator->fails()) {
  58. return response()->json([
  59. 'success' => false,
  60. 'error' => '参数验证失败',
  61. 'details' => $validator->errors()
  62. ], 422);
  63. }
  64. $paperId = $request->input('paper_id');
  65. $questionsData = $request->input('questions');
  66. Log::info('开始处理试卷提交分析', [
  67. 'paper_id' => $paperId,
  68. 'questions_count' => count($questionsData)
  69. ]);
  70. // 2. 通过 paperId 查询试卷获取 student_id
  71. $paper = Paper::where('paper_id', $paperId)->first();
  72. if (!$paper) {
  73. return response()->json([
  74. 'success' => false,
  75. 'error' => '试卷不存在',
  76. 'paper_id' => $paperId
  77. ], 404);
  78. }
  79. $studentId = $paper->student_id;
  80. if (!$studentId) {
  81. return response()->json([
  82. 'success' => false,
  83. 'error' => '试卷未关联学生',
  84. 'paper_id' => $paperId
  85. ], 400);
  86. }
  87. // 3. 转换前端数据:将 question_id 转换为 question_bank_id
  88. $questionsData = $this->convertQuestionIds($questionsData);
  89. // 4. 获取题目详情并转换数据格式
  90. $transformedQuestions = $this->transformQuestionsData($questionsData, $paperId);
  91. // 4. 调用学情分析服务(可能失败,不影响错题本写入)
  92. $analysisResult = [];
  93. $analysisError = null;
  94. try {
  95. $examData = [
  96. 'paper_id' => $paperId,
  97. 'student_id' => $studentId,
  98. 'questions' => $transformedQuestions['questions'],
  99. ];
  100. $analysisResult = $this->analysisService->analyzeExamAnswers($examData);
  101. } catch (\Exception $e) {
  102. $analysisError = $e->getMessage();
  103. Log::warning('学情分析失败,继续处理错题本', [
  104. 'paper_id' => $paperId,
  105. 'error' => $analysisError
  106. ]);
  107. }
  108. // 5. 将错题写入错题本
  109. $mistakesAdded = $this->addMistakesToBook(
  110. $studentId,
  111. $paperId,
  112. $questionsData,
  113. $transformedQuestions['question_details']
  114. );
  115. // 6. 更新试卷状态为已完成
  116. $this->updatePaperStatus($paperId, $questionsData);
  117. Log::info('试卷提交分析完成', [
  118. 'paper_id' => $paperId,
  119. 'student_id' => $studentId,
  120. 'questions_analyzed' => count($transformedQuestions['questions']),
  121. 'mistakes_added' => $mistakesAdded
  122. ]);
  123. return response()->json([
  124. 'success' => true,
  125. 'data' => [
  126. 'paper_id' => $paperId,
  127. 'student_id' => $studentId,
  128. 'analysis_summary' => $analysisResult['overall_summary'] ?? null,
  129. 'knowledge_point_analysis' => $analysisResult['knowledge_point_analysis'] ?? [],
  130. 'smart_quiz_recommendation' => $analysisResult['smart_quiz_recommendation'] ?? null,
  131. 'mastery_vector' => $analysisResult['mastery_vector'] ?? [],
  132. 'mistakes_added' => $mistakesAdded,
  133. 'total_questions' => count($questionsData),
  134. 'correct_count' => $this->countCorrectQuestions($questionsData),
  135. 'incorrect_count' => $mistakesAdded,
  136. 'analysis_error' => $analysisError, // 如果学情分析失败,返回错误信息
  137. ],
  138. 'message' => "分析完成,新增 {$mistakesAdded} 条错题记录" . ($analysisError ? "(学情分析暂不可用)" : "")
  139. ]);
  140. } catch (\Exception $e) {
  141. Log::error('试卷提交分析失败', [
  142. 'error' => $e->getMessage(),
  143. 'trace' => $e->getTraceAsString(),
  144. 'request_data' => $request->all()
  145. ]);
  146. return response()->json([
  147. 'success' => false,
  148. 'error' => '分析失败:' . $e->getMessage()
  149. ], 500);
  150. }
  151. }
  152. /**
  153. * 转换前端数据格式为分析服务所需格式
  154. * 【增强】处理未提交的题目,标记为"回答正确"
  155. */
  156. private function transformQuestionsData(array $questionsData, string $paperId): array
  157. {
  158. $transformedQuestions = [];
  159. $questionDetails = [];
  160. // 获取试卷中的所有题目
  161. $allPaperQuestions = PaperQuestion::where('paper_id', $paperId)->get();
  162. $allQuestionBankIds = $allPaperQuestions->pluck('question_bank_id')->toArray();
  163. // 区分已提交和未提交的题目
  164. $submittedIds = array_column($questionsData, 'question_bank_id'); // 转换后使用 question_bank_id
  165. $notSubmittedIds = array_diff($allQuestionBankIds, $submittedIds);
  166. Log::info('题目提交情况分析', [
  167. 'paper_id' => $paperId,
  168. 'total_questions' => count($allQuestionBankIds),
  169. 'submitted_count' => count($submittedIds),
  170. 'not_submitted_count' => count($notSubmittedIds),
  171. 'submitted_ids' => $submittedIds,
  172. 'not_submitted_ids' => $notSubmittedIds,
  173. ]);
  174. // 批量获取所有题目的详情(包括未提交的)
  175. // 【优化】使用新的通用方法直接从题库关联知识点
  176. $allQuestionBankData = [];
  177. foreach ($allQuestionBankIds as $questionBankId) {
  178. $questionData = $this->questionBankService->getQuestionKnowledgePoint($questionBankId);
  179. $allQuestionBankData[$questionBankId] = $questionData;
  180. // 调试日志
  181. Log::info('获取题目知识点', [
  182. 'question_bank_id' => $questionBankId,
  183. 'kp_code' => $questionData['kp_code'] ?? null,
  184. 'kp_name' => $questionData['kp_name'] ?? null,
  185. ]);
  186. }
  187. // 获取所有题目的分数信息
  188. $allPaperQuestionsCollection = PaperQuestion::where('paper_id', $paperId)
  189. ->get()
  190. ->keyBy('question_bank_id');
  191. // 处理已提交的题目
  192. foreach ($questionsData as $index => $questionData) {
  193. $questionBankId = $questionData['question_bank_id'];
  194. $isCorrectArray = $questionData['is_correct'];
  195. $studentAnswer = $questionData['student_answer'] ?? null;
  196. $teacherComment = $questionData['teacher_comment'] ?? null;
  197. // 获取题目详情
  198. $qbData = $allQuestionBankData[$questionBankId] ?? null;
  199. $paperQuestion = $allPaperQuestionsCollection->get($questionBankId);
  200. // 确定题目分数
  201. $maxScore = $paperQuestion->score ?? 5.0;
  202. $kpCode = $qbData['kp_code'] ?? null;
  203. if (!$kpCode) {
  204. throw new \Exception("题目 {$questionBankId} 缺少知识点代码");
  205. }
  206. $kpName = $qbData['kp_name'] ?? $kpCode;
  207. $questionType = $qbData['question_type'] ?? 'unknown';
  208. // 保存详情供后续使用
  209. $questionDetails[$questionBankId] = [
  210. 'question_bank_id' => $questionBankId,
  211. 'stem' => $qbData['question_content'] ?? ($paperQuestion->question_text ?? ''),
  212. 'answer' => $qbData['question_answer'] ?? ($paperQuestion->correct_answer ?? ''),
  213. 'solution' => $paperQuestion->solution ?? '',
  214. 'kp_code' => $kpCode,
  215. 'kp_name' => $kpName,
  216. 'question_type' => $questionType,
  217. 'max_score' => $maxScore,
  218. 'skills' => [],
  219. 'is_submitted' => true,
  220. ];
  221. // 转换 is_correct 数组为 steps 格式
  222. $steps = [];
  223. $totalSteps = count($isCorrectArray);
  224. $scorePerStep = $maxScore / $totalSteps;
  225. $scoreObtained = 0;
  226. foreach ($isCorrectArray as $stepIndex => $isCorrect) {
  227. $stepCorrect = (bool) $isCorrect;
  228. if ($stepCorrect) {
  229. $scoreObtained += $scorePerStep;
  230. }
  231. $steps[] = [
  232. 'step_index' => $stepIndex + 1,
  233. 'is_correct' => $stepCorrect,
  234. 'kp_id' => $kpCode,
  235. 'score' => $scorePerStep,
  236. 'weight' => 1.0,
  237. ];
  238. }
  239. $transformedQuestions[] = [
  240. 'question_id' => (string) $questionBankId,
  241. 'score' => $maxScore,
  242. 'score_obtained' => round($scoreObtained, 2),
  243. 'steps' => $totalSteps > 1 ? $steps : [], // 单步骤不传steps
  244. 'kp_code' => $kpCode, // 【修复】添加知识点代码,供ExamAnswerAnalysisService使用
  245. 'kp_name' => $kpName, // 【修复】添加知识点名称
  246. ];
  247. }
  248. // 【新增】处理未提交的题目:标记为"回答正确"
  249. foreach ($notSubmittedIds as $questionBankId) {
  250. // 获取题目详情
  251. $qbData = $allQuestionBankData[$questionBankId] ?? null;
  252. $paperQuestion = $allPaperQuestionsCollection->get($questionBankId);
  253. // 确定题目分数
  254. $maxScore = $paperQuestion->score ?? 5.0;
  255. $kpCode = $qbData['kp_code'] ?? null;
  256. if (!$kpCode) {
  257. throw new \Exception("题目 {$questionBankId} 缺少知识点代码");
  258. }
  259. $kpName = $qbData['kp_name'] ?? $kpCode;
  260. $questionType = $qbData['question_type'] ?? 'unknown';
  261. // 保存详情供后续使用
  262. $questionDetails[$questionBankId] = [
  263. 'question_bank_id' => $questionBankId,
  264. 'stem' => $qbData['question_content'] ?? ($paperQuestion->question_text ?? ''),
  265. 'answer' => $qbData['question_answer'] ?? ($paperQuestion->correct_answer ?? ''),
  266. 'solution' => $paperQuestion->solution ?? '',
  267. 'kp_code' => $kpCode,
  268. 'kp_name' => $kpName,
  269. 'question_type' => $questionType,
  270. 'max_score' => $maxScore,
  271. 'skills' => [],
  272. 'is_submitted' => false, // 未提交标记
  273. ];
  274. // 未提交的题目:标记为完全正确(说明学生已经掌握,不需要作答)
  275. $transformedQuestions[] = [
  276. 'question_id' => (string) $questionBankId,
  277. 'score' => $maxScore,
  278. 'score_obtained' => $maxScore, // 获得满分
  279. 'steps' => [], // 不需要步骤分析
  280. 'is_correct' => true, // 标记为正确
  281. 'is_submitted' => false, // 标记为未提交
  282. 'kp_code' => $kpCode, // 【修复】添加知识点代码,供ExamAnswerAnalysisService使用
  283. 'kp_name' => $kpName, // 【修复】添加知识点名称
  284. ];
  285. Log::info('未提交题目处理', [
  286. 'question_bank_id' => $questionBankId,
  287. 'kp_code' => $kpCode,
  288. 'max_score' => $maxScore,
  289. 'reason' => '未作答视为已掌握',
  290. ]);
  291. }
  292. Log::info('题目数据转换完成', [
  293. 'paper_id' => $paperId,
  294. 'total_transformed' => count($transformedQuestions),
  295. 'submitted_count' => count($submittedIds),
  296. 'not_submitted_count' => count($notSubmittedIds),
  297. ]);
  298. return [
  299. 'questions' => $transformedQuestions,
  300. 'question_details' => $questionDetails,
  301. ];
  302. }
  303. /**
  304. * 批量获取题库题目详情
  305. */
  306. /**
  307. * 将错题写入错题本
  308. * 【正确逻辑】只处理已提交的题目,未提交的题目默认正确,不写入错题本
  309. */
  310. private function addMistakesToBook(
  311. string $studentId,
  312. string $paperId,
  313. array $questionsData,
  314. array $questionDetails
  315. ): int {
  316. $mistakesAdded = 0;
  317. // 只有已提交的题目才处理错题本
  318. foreach ($questionsData as $questionData) {
  319. $isCorrectArray = $questionData['is_correct'];
  320. // 判断是否有错误(数组中存在0)
  321. $hasError = in_array(0, $isCorrectArray, true);
  322. if (!$hasError) {
  323. continue; // 全对,跳过
  324. }
  325. $questionBankId = $questionData['question_bank_id'];
  326. $detail = $questionDetails[$questionBankId] ?? [];
  327. $mistakesAdded += $this->createMistakeRecord($studentId, $paperId, $questionData, $detail);
  328. }
  329. // 【说明】未提交的题目不写入错题本,因为默认正确(已掌握)
  330. Log::info('错题本写入完成', [
  331. 'paper_id' => $paperId,
  332. 'submitted_questions' => count($questionsData),
  333. 'mistakes_added' => $mistakesAdded,
  334. 'note' => '未提交题目默认正确,未写入错题本',
  335. ]);
  336. return $mistakesAdded;
  337. }
  338. /**
  339. * 创建单个错题记录
  340. */
  341. private function createMistakeRecord(
  342. string $studentId,
  343. string $paperId,
  344. array $questionData,
  345. array $detail
  346. ): int {
  347. try {
  348. $payload = [
  349. 'student_id' => $studentId,
  350. 'question_id' => $questionData['question_bank_id'],
  351. 'paper_id' => $paperId,
  352. 'my_answer' => $questionData['student_answer'] ?? '',
  353. 'correct_answer' => $detail['answer'] ?? '',
  354. 'question_text' => $detail['stem'] ?? '',
  355. 'knowledge_point' => $detail['kp_name'] ?? '',
  356. 'explanation' => $detail['solution'] ?? '',
  357. 'kp_ids' => [$detail['kp_code']],
  358. 'source' => "paper:{$paperId}",
  359. 'happened_at' => now()->toISOString(),
  360. ];
  361. $result = $this->mistakeBookService->createMistake($payload);
  362. // 如果不是重复记录,则计数
  363. if (!($result['duplicate'] ?? false)) {
  364. Log::debug('错题写入成功', [
  365. 'student_id' => $studentId,
  366. 'question_bank_id' => $questionData['question_bank_id'],
  367. 'duplicate' => $result['duplicate'] ?? false
  368. ]);
  369. return 1;
  370. }
  371. return 0;
  372. } catch (\Exception $e) {
  373. Log::error('写入错题本失败', [
  374. 'student_id' => $studentId,
  375. 'question_bank_id' => $questionData['question_bank_id'],
  376. 'error' => $e->getMessage()
  377. ]);
  378. return 0;
  379. }
  380. }
  381. /**
  382. * 更新试卷状态和题目作答信息
  383. * 【正确逻辑】未提交题目视为正确(已掌握),已提交错误题目标记为错误
  384. */
  385. private function updatePaperStatus(string $paperId, array $questionsData): void
  386. {
  387. try {
  388. // 更新试卷状态为已完成
  389. Paper::where('paper_id', $paperId)->update([
  390. 'status' => 'completed',
  391. 'completed_at' => now(),
  392. ]);
  393. // 获取试卷中的所有题目(包括未提交的)
  394. $allPaperQuestions = PaperQuestion::where('paper_id', $paperId)->get();
  395. $allQuestionIds = $allPaperQuestions->pluck('question_bank_id')->toArray();
  396. // 区分已提交和未提交的题目
  397. $submittedIds = array_column($questionsData, 'question_bank_id');
  398. $notSubmittedIds = array_diff($allQuestionIds, $submittedIds);
  399. // 更新已提交题目的作答信息
  400. foreach ($questionsData as $questionData) {
  401. $questionBankId = $questionData['question_bank_id'];
  402. $isCorrectArray = $questionData['is_correct'];
  403. // 计算得分比例
  404. $correctCount = array_sum($isCorrectArray);
  405. $totalSteps = count($isCorrectArray);
  406. $scoreRatio = $totalSteps > 0 ? $correctCount / $totalSteps : 0;
  407. // 判断是否全对
  408. $isFullyCorrect = !in_array(0, $isCorrectArray, true);
  409. PaperQuestion::where('paper_id', $paperId)
  410. ->where('question_bank_id', $questionBankId)
  411. ->update([
  412. 'student_answer' => $questionData['student_answer'] ?? null,
  413. 'is_correct' => $isFullyCorrect,
  414. 'score_ratio' => $scoreRatio,
  415. 'score_obtained' => DB::raw("score * {$scoreRatio}"),
  416. 'teacher_comment' => $questionData['teacher_comment'] ?? null,
  417. 'graded_at' => now(),
  418. ]);
  419. }
  420. // 【修正】更新未提交题目的状态:视为正确(已掌握)
  421. foreach ($notSubmittedIds as $questionBankId) {
  422. // 未提交题目:视为正确(已掌握),获得满分
  423. PaperQuestion::where('paper_id', $paperId)
  424. ->where('question_bank_id', $questionBankId)
  425. ->update([
  426. 'student_answer' => null,
  427. 'is_correct' => true, // 视为正确
  428. 'score_ratio' => 1.0, // 获得满分
  429. 'score_obtained' => DB::raw('score'), // 获得满分
  430. 'teacher_comment' => '未作答(已掌握)',
  431. 'graded_at' => now(),
  432. ]);
  433. Log::debug('未提交题目状态更新', [
  434. 'paper_id' => $paperId,
  435. 'question_bank_id' => $questionBankId,
  436. 'is_correct' => true,
  437. 'score_ratio' => 1.0,
  438. 'note' => '未提交题目视为正确(已掌握)',
  439. ]);
  440. }
  441. Log::info('试卷状态更新完成', [
  442. 'paper_id' => $paperId,
  443. 'total_questions' => count($allQuestionIds),
  444. 'submitted_count' => count($submittedIds),
  445. 'not_submitted_count' => count($notSubmittedIds),
  446. 'note' => '未提交题目视为正确(已掌握)',
  447. ]);
  448. } catch (\Exception $e) {
  449. Log::error('更新试卷状态失败', [
  450. 'paper_id' => $paperId,
  451. 'error' => $e->getMessage()
  452. ]);
  453. }
  454. }
  455. /**
  456. * 统计正确题目数量
  457. */
  458. private function countCorrectQuestions(array $questionsData): int
  459. {
  460. $correctCount = 0;
  461. foreach ($questionsData as $questionData) {
  462. $isCorrectArray = $questionData['is_correct'];
  463. // 全对才算正确
  464. if (!in_array(0, $isCorrectArray, true)) {
  465. $correctCount++;
  466. }
  467. }
  468. return $correctCount;
  469. }
  470. /**
  471. * 获取试卷分析结果
  472. *
  473. * GET /api/paper-submit-analysis/{paperId}
  474. */
  475. public function getResult(string $paperId): JsonResponse
  476. {
  477. try {
  478. $paper = Paper::where('paper_id', $paperId)->first();
  479. if (!$paper) {
  480. return response()->json([
  481. 'success' => false,
  482. 'error' => '试卷不存在'
  483. ], 404);
  484. }
  485. // 从数据库获取分析结果
  486. $result = DB::connection('mysql')
  487. ->table('exam_analysis_results')
  488. ->where('paper_id', $paperId)
  489. ->where('student_id', $paper->student_id)
  490. ->orderBy('created_at', 'desc')
  491. ->first();
  492. if (!$result) {
  493. return response()->json([
  494. 'success' => false,
  495. 'error' => '未找到分析结果,请先提交试卷进行分析'
  496. ], 404);
  497. }
  498. return response()->json([
  499. 'success' => true,
  500. 'data' => json_decode($result->analysis_data, true)
  501. ]);
  502. } catch (\Exception $e) {
  503. Log::error('获取试卷分析结果失败', [
  504. 'paper_id' => $paperId,
  505. 'error' => $e->getMessage()
  506. ]);
  507. return response()->json([
  508. 'success' => false,
  509. 'error' => '获取分析结果失败:' . $e->getMessage()
  510. ], 500);
  511. }
  512. }
  513. /**
  514. * 转换前端 question_id 为后端 question_bank_id
  515. * 前端使用 question_id,后端转换为 question_bank_id 进行处理
  516. */
  517. private function convertQuestionIds(array $questionsData): array
  518. {
  519. $converted = [];
  520. foreach ($questionsData as $question) {
  521. // 将 question_id 转换为 question_bank_id
  522. if (isset($question['question_id'])) {
  523. $question['question_bank_id'] = $question['question_id'];
  524. unset($question['question_id']);
  525. }
  526. $converted[] = $question;
  527. }
  528. Log::info('转换 question_id 为 question_bank_id', [
  529. 'original_count' => count($questionsData),
  530. 'converted_count' => count($converted),
  531. 'sample_conversion' => !empty($converted) ? [
  532. 'from' => $questionsData[0]['question_id'] ?? null,
  533. 'to' => $converted[0]['question_bank_id'] ?? null
  534. ] : null
  535. ]);
  536. return $converted;
  537. }
  538. }