ExamAnswerAnalysisController.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. <?php
  2. namespace App\Http\Controllers\Api;
  3. use App\Http\Controllers\Controller;
  4. use App\Jobs\ProcessExamAnswerAnalysisJob;
  5. use App\Services\ExamAnswerAnalysisService;
  6. use App\Services\TaskManager;
  7. use Illuminate\Http\Request;
  8. use Illuminate\Http\JsonResponse;
  9. use Illuminate\Support\Facades\Log;
  10. use Illuminate\Support\Facades\Validator;
  11. /**
  12. * 考试答题分析 API 控制器
  13. * 支持步骤级分析的 RESTful API
  14. */
  15. class ExamAnswerAnalysisController extends Controller
  16. {
  17. public function __construct(
  18. private readonly ExamAnswerAnalysisService $analysisService,
  19. private readonly TaskManager $taskManager
  20. ) {}
  21. /**
  22. * 分析考试答题数据
  23. *
  24. * POST /api/exam-answer-analysis
  25. *
  26. * @param Request $request
  27. * @return JsonResponse
  28. */
  29. public function analyze(Request $request): JsonResponse
  30. {
  31. // 临时增加执行时间限制(避免超时)
  32. set_time_limit(120);
  33. ini_set('max_execution_time', 120);
  34. try {
  35. // 验证请求数据
  36. $validator = Validator::make($request->all(), [
  37. 'paper_id' => 'required|string|max:255',
  38. 'student_id' => 'required|numeric',
  39. 'questions' => 'present|array', // present 允许空数组(全对时前端只传错题,全对则为空)
  40. 'questions.*.question_bank_id' => 'required|numeric',
  41. 'questions.*.student_answer' => 'sometimes|string',
  42. 'questions.*.is_correct' => 'sometimes|array',
  43. 'questions.*.teacher_comment' => 'sometimes|string|nullable',
  44. 'questions.*.score' => 'sometimes|numeric|min:0',
  45. 'questions.*.score_obtained' => 'sometimes|numeric|min:0',
  46. 'questions.*.steps' => 'sometimes|array',
  47. 'questions.*.steps.*.step_index' => 'required_with:questions.*.steps|integer|min:1',
  48. 'questions.*.steps.*.is_correct' => 'required_with:questions.*.steps|boolean',
  49. 'questions.*.steps.*.kp_id' => 'required_with:questions.*.steps|string|max:255',
  50. 'questions.*.steps.*.score' => 'required_with:questions.*.steps|numeric|min:0',
  51. ]);
  52. if ($validator->fails()) {
  53. Log::warning('exam-answer-analysis 验证失败', [
  54. 'errors' => $validator->errors()->toArray(),
  55. 'paper_id' => $request->input('paper_id'),
  56. 'student_id' => $request->input('student_id'),
  57. 'questions_count' => count($request->input('questions', [])),
  58. ]);
  59. return response()->json([
  60. 'success' => false,
  61. 'error' => 'Validation failed',
  62. 'details' => $validator->errors()
  63. ], 422);
  64. }
  65. $examData = $request->only(['paper_id', 'student_id', 'questions']);
  66. // 【修改】将 question_bank_id 转换为 question_id 供内部使用
  67. if (isset($examData['questions'])) {
  68. foreach ($examData['questions'] as &$question) {
  69. if (isset($question['question_bank_id'])) {
  70. $question['question_id'] = $question['question_bank_id'];
  71. unset($question['question_bank_id']);
  72. }
  73. }
  74. }
  75. $taskId = $this->taskManager->createTask(
  76. TaskManager::TASK_TYPE_ANALYSIS,
  77. array_merge($examData, ['type' => 'exam_answer_analysis'])
  78. );
  79. dispatch(new ProcessExamAnswerAnalysisJob($taskId, $examData));
  80. return response()->json([
  81. 'success' => true,
  82. 'data' => [
  83. 'task_id' => $taskId,
  84. 'paper_id' => $examData['paper_id'],
  85. 'student_id' => (string) $examData['student_id'],
  86. 'status' => 'processing',
  87. 'status_url' => route('api.tasks.status', ['taskId' => $taskId]),
  88. 'created_at' => now()->toISOString(),
  89. ],
  90. 'message' => '分析任务已提交,后台处理中'
  91. ], 202);
  92. } catch (\Exception $e) {
  93. Log::error('考试答题分析失败', [
  94. 'error' => $e->getMessage(),
  95. 'trace' => $e->getTraceAsString(),
  96. 'request_data' => $request->all()
  97. ]);
  98. return response()->json([
  99. 'success' => false,
  100. 'error' => '分析失败:' . $e->getMessage()
  101. ], 500);
  102. }
  103. }
  104. /**
  105. * 获取分析结果
  106. *
  107. * GET /api/exam-answer-analysis/{student_id}/{paper_id}
  108. *
  109. * @param string $studentId
  110. * @param string $paperId
  111. * @return JsonResponse
  112. */
  113. public function getAnalysisResult(string $studentId, string $paperId): JsonResponse
  114. {
  115. try {
  116. $result = \DB::connection('mysql')
  117. ->table('exam_analysis_results')
  118. ->where('student_id', $studentId)
  119. ->where('paper_id', $paperId)
  120. ->orderBy('created_at', 'desc')
  121. ->first();
  122. if (!$result) {
  123. return response()->json([
  124. 'success' => false,
  125. 'error' => '未找到分析结果'
  126. ], 404);
  127. }
  128. return response()->json([
  129. 'success' => true,
  130. 'data' => json_decode($result->analysis_data, true)
  131. ]);
  132. } catch (\Exception $e) {
  133. Log::error('获取分析结果失败', [
  134. 'student_id' => $studentId,
  135. 'paper_id' => $paperId,
  136. 'error' => $e->getMessage()
  137. ]);
  138. return response()->json([
  139. 'success' => false,
  140. 'error' => '获取分析结果失败:' . $e->getMessage()
  141. ], 500);
  142. }
  143. }
  144. /**
  145. * 获取学生历史分析记录
  146. *
  147. * GET /api/exam-answer-analysis/history/{student_id}
  148. *
  149. * @param string $studentId
  150. * @param Request $request
  151. * @return JsonResponse
  152. */
  153. public function getHistory(string $studentId, Request $request): JsonResponse
  154. {
  155. try {
  156. $limit = $request->input('limit', 10);
  157. $offset = $request->input('offset', 0);
  158. $results = \DB::connection('mysql')
  159. ->table('exam_analysis_results')
  160. ->where('student_id', $studentId)
  161. ->orderBy('created_at', 'desc')
  162. ->offset($offset)
  163. ->limit($limit)
  164. ->get()
  165. ->map(function ($item) {
  166. return [
  167. 'paper_id' => $item->paper_id,
  168. 'created_at' => $item->created_at,
  169. 'analysis_summary' => json_decode($item->analysis_data, true)['overall_summary'] ?? null
  170. ];
  171. });
  172. return response()->json([
  173. 'success' => true,
  174. 'data' => $results,
  175. 'pagination' => [
  176. 'limit' => $limit,
  177. 'offset' => $offset,
  178. 'total' => \DB::connection('mysql')
  179. ->table('exam_analysis_results')
  180. ->where('student_id', $studentId)
  181. ->count()
  182. ]
  183. ]);
  184. } catch (\Exception $e) {
  185. Log::error('获取历史分析记录失败', [
  186. 'student_id' => $studentId,
  187. 'error' => $e->getMessage()
  188. ]);
  189. return response()->json([
  190. 'success' => false,
  191. 'error' => '获取历史记录失败:' . $e->getMessage()
  192. ], 500);
  193. }
  194. }
  195. /**
  196. * 获取知识点掌握度趋势
  197. *
  198. * GET /api/exam-answer-analysis/mastery-trend/{student_id}
  199. *
  200. * @param string $studentId
  201. * @param Request $request
  202. * @return JsonResponse
  203. */
  204. public function getMasteryTrend(string $studentId, Request $request): JsonResponse
  205. {
  206. try {
  207. $kpCode = $request->input('kp_code');
  208. $query = \DB::connection('mysql')
  209. ->table('exam_analysis_results')
  210. ->where('student_id', $studentId)
  211. ->orderBy('created_at', 'desc');
  212. if ($kpCode) {
  213. $query->whereRaw("analysis_data->>'kp_code' = ?", [$kpCode]);
  214. }
  215. $results = $query->limit(20)->get();
  216. $trendData = [];
  217. foreach ($results as $result) {
  218. $analysisData = json_decode($result->analysis_data, true);
  219. if (isset($analysisData['mastery_vector'])) {
  220. foreach ($analysisData['mastery_vector'] as $kpId => $data) {
  221. if (!$kpCode || $kpId === $kpCode) {
  222. $trendData[$kpId][] = [
  223. 'date' => $result->created_at,
  224. 'mastery' => $data['current_mastery'],
  225. 'change' => $data['change'] ?? 0
  226. ];
  227. }
  228. }
  229. }
  230. }
  231. return response()->json([
  232. 'success' => true,
  233. 'data' => $trendData
  234. ]);
  235. } catch (\Exception $e) {
  236. Log::error('获取掌握度趋势失败', [
  237. 'student_id' => $studentId,
  238. 'error' => $e->getMessage()
  239. ]);
  240. return response()->json([
  241. 'success' => false,
  242. 'error' => '获取趋势数据失败:' . $e->getMessage()
  243. ], 500);
  244. }
  245. }
  246. /**
  247. * 获取智能出卷推荐
  248. *
  249. * GET /api/exam-answer-analysis/smart-quiz/{student_id}
  250. *
  251. * @param string $studentId
  252. * @param Request $request
  253. * @return JsonResponse
  254. */
  255. public function getSmartQuizRecommendation(string $studentId, Request $request): JsonResponse
  256. {
  257. try {
  258. // 获取最近一次分析结果
  259. $latestAnalysis = \DB::connection('mysql')
  260. ->table('exam_analysis_results')
  261. ->where('student_id', $studentId)
  262. ->orderBy('created_at', 'desc')
  263. ->first();
  264. if (!$latestAnalysis) {
  265. return response()->json([
  266. 'success' => false,
  267. 'error' => '未找到分析记录,无法生成推荐'
  268. ], 404);
  269. }
  270. $analysisData = json_decode($latestAnalysis->analysis_data, true);
  271. $recommendation = $analysisData['smart_quiz_recommendation'] ?? null;
  272. if (!$recommendation) {
  273. return response()->json([
  274. 'success' => false,
  275. 'error' => '未找到推荐数据'
  276. ], 404);
  277. }
  278. return response()->json([
  279. 'success' => true,
  280. 'data' => $recommendation,
  281. 'based_on_exam' => $latestAnalysis->paper_id,
  282. 'generated_at' => $latestAnalysis->created_at
  283. ]);
  284. } catch (\Exception $e) {
  285. Log::error('获取智能出卷推荐失败', [
  286. 'student_id' => $studentId,
  287. 'error' => $e->getMessage()
  288. ]);
  289. return response()->json([
  290. 'success' => false,
  291. 'error' => '获取推荐失败:' . $e->getMessage()
  292. ], 500);
  293. }
  294. }
  295. /**
  296. * 导出分析报告
  297. *
  298. * GET /api/exam-answer-analysis/export/{student_id}/{paper_id}
  299. *
  300. * @param string $studentId
  301. * @param string $paperId
  302. * @param Request $request
  303. * @return JsonResponse
  304. */
  305. public function export(string $studentId, string $paperId, Request $request): JsonResponse
  306. {
  307. try {
  308. $format = $request->input('format', 'json'); // json, pdf
  309. $result = \DB::connection('mysql')
  310. ->table('exam_analysis_results')
  311. ->where('student_id', $studentId)
  312. ->where('paper_id', $paperId)
  313. ->orderBy('created_at', 'desc')
  314. ->first();
  315. if (!$result) {
  316. return response()->json([
  317. 'success' => false,
  318. 'error' => '未找到分析结果'
  319. ], 404);
  320. }
  321. $analysisData = json_decode($result->analysis_data, true);
  322. if ($format === 'pdf') {
  323. // TODO: 实现 PDF 导出
  324. return response()->json([
  325. 'success' => false,
  326. 'error' => 'PDF 导出功能尚未实现'
  327. ], 501);
  328. }
  329. return response()->json([
  330. 'success' => true,
  331. 'data' => $analysisData,
  332. 'metadata' => [
  333. 'student_id' => $studentId,
  334. 'paper_id' => $paperId,
  335. 'generated_at' => $result->created_at,
  336. 'format' => $format
  337. ]
  338. ]);
  339. } catch (\Exception $e) {
  340. Log::error('导出分析报告失败', [
  341. 'student_id' => $studentId,
  342. 'paper_id' => $paperId,
  343. 'error' => $e->getMessage()
  344. ]);
  345. return response()->json([
  346. 'success' => false,
  347. 'error' => '导出失败:' . $e->getMessage()
  348. ], 500);
  349. }
  350. }
  351. /**
  352. * 批量分析多个学生的考试数据
  353. *
  354. * POST /api/exam-answer-analysis/batch
  355. *
  356. * @param Request $request
  357. * @return JsonResponse
  358. */
  359. public function batchAnalyze(Request $request): JsonResponse
  360. {
  361. try {
  362. $validator = Validator::make($request->all(), [
  363. 'exam_data_list' => 'required|array|min:1',
  364. 'exam_data_list.*.paper_id' => 'required|string',
  365. 'exam_data_list.*.student_id' => 'required|string',
  366. 'exam_data_list.*.questions' => 'required|array',
  367. ]);
  368. if ($validator->fails()) {
  369. return response()->json([
  370. 'success' => false,
  371. 'error' => 'Validation failed',
  372. 'details' => $validator->errors()
  373. ], 422);
  374. }
  375. $examDataList = $request->input('exam_data_list');
  376. $results = [];
  377. foreach ($examDataList as $examData) {
  378. try {
  379. $result = $this->analysisService->analyzeExamAnswers($examData);
  380. $results[] = [
  381. 'student_id' => $examData['student_id'],
  382. 'paper_id' => $examData['paper_id'],
  383. 'success' => true,
  384. 'data' => $result
  385. ];
  386. } catch (\Exception $e) {
  387. $results[] = [
  388. 'student_id' => $examData['student_id'],
  389. 'paper_id' => $examData['paper_id'],
  390. 'success' => false,
  391. 'error' => $e->getMessage()
  392. ];
  393. }
  394. }
  395. $successCount = count(array_filter($results, fn($r) => $r['success']));
  396. return response()->json([
  397. 'success' => true,
  398. 'data' => $results,
  399. 'summary' => [
  400. 'total' => count($results),
  401. 'success' => $successCount,
  402. 'failed' => count($results) - $successCount
  403. ],
  404. 'message' => "批量分析完成,成功 {$successCount} 条,失败 " . (count($results) - $successCount) . " 条"
  405. ]);
  406. } catch (\Exception $e) {
  407. Log::error('批量分析失败', [
  408. 'error' => $e->getMessage(),
  409. 'request_data' => $request->all()
  410. ]);
  411. return response()->json([
  412. 'success' => false,
  413. 'error' => '批量分析失败:' . $e->getMessage()
  414. ], 500);
  415. }
  416. }
  417. }