api.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. <?php
  2. use App\Http\Controllers\Api\IntelligentExamController;
  3. use App\Http\Controllers\Api\PreQuestionApiController;
  4. use App\Http\Controllers\Api\TextbookApiController;
  5. use App\Services\QuestionServiceApi;
  6. use Illuminate\Support\Facades\Log;
  7. use Illuminate\Support\Facades\Route;
  8. use App\Events\QuestionGenerationCompleted;
  9. use App\Events\QuestionGenerationFailed;
  10. use Illuminate\Auth\Middleware\Authenticate;
  11. use App\Http\Controllers\Api\ExamAnalysisApiController;
  12. /*
  13. |--------------------------------------------------------------------------
  14. | 题库管理 API 路由
  15. |--------------------------------------------------------------------------
  16. */
  17. // 给 Python/内部服务消费的“筛选题库”API(需要 X-Internal-Token)
  18. Route::middleware('internal.token')->get('/pre-questions', [PreQuestionApiController::class, 'index'])
  19. ->name('api.pre-questions.index');
  20. // 接收题目生成回调
  21. Route::post('/questions/callback', function () {
  22. try {
  23. $data = request()->all();
  24. Log::info('Received question generation callback', $data);
  25. // 验证回调数据
  26. if (!isset($data['task_id']) || !isset($data['status'])) {
  27. return response()->json(['error' => 'Invalid callback data'], 400);
  28. }
  29. // 处理回调数据并存储通知到session
  30. if ($data['status'] === 'completed') {
  31. $result = $data['result'] ?? [];
  32. $total = $result['total'] ?? $data['total'] ?? ($result['saved'] ?? 0);
  33. $kpCode = $result['kp_code'] ?? $data['kp_code'] ?? '';
  34. // 将成功通知存储到session,供下次页面刷新时显示
  35. session()->flash('notification', [
  36. 'type' => 'success',
  37. 'title' => '✅ 题目生成完成',
  38. 'body' => "任务 ID: {$data['task_id']}\n生成题目: {$total} 道" . ($kpCode ? "\n知识点: {$kpCode}" : ''),
  39. 'color' => 'success'
  40. ]);
  41. Log::info("题目生成成功通知已存储", [
  42. 'task_id' => $data['task_id'],
  43. 'total' => $total,
  44. 'kp_code' => $kpCode
  45. ]);
  46. } elseif ($data['status'] === 'failed') {
  47. $error = $data['error'] ?? '未知错误';
  48. // 将失败通知存储到session
  49. session()->flash('notification', [
  50. 'type' => 'error',
  51. 'title' => '❌ 题目生成失败',
  52. 'body' => "任务 ID: {$data['task_id']}\n错误: {$error}",
  53. 'color' => 'danger'
  54. ]);
  55. Log::error("题目生成失败通知已存储", [
  56. 'task_id' => $data['task_id'],
  57. 'error' => $error
  58. ]);
  59. }
  60. return response()->json([
  61. 'success' => true,
  62. 'message' => 'Callback received and notification stored',
  63. 'status' => $data['status']
  64. ]);
  65. } catch (\Exception $e) {
  66. Log::error('Callback processing failed: ' . $e->getMessage());
  67. return response()->json(['error' => $e->getMessage()], 500);
  68. }
  69. })->name('api.questions.callback');
  70. // 接收OCR题目生成回调
  71. Route::post('/ocr-question-callback', function () {
  72. try {
  73. $data = request()->all();
  74. Log::info('Received OCR question generation callback', $data);
  75. // 验证必要的回调数据
  76. if (!isset($data['task_id']) || !isset($data['status']) || !isset($data['ocr_record_id'])) {
  77. Log::error('OCR callback missing required fields', $data);
  78. return response()->json([
  79. 'success' => false,
  80. 'error' => 'Missing required fields: task_id, status, ocr_record_id'
  81. ], 400);
  82. }
  83. $taskId = $data['task_id'];
  84. $ocrRecordId = $data['ocr_record_id'];
  85. $status = $data['status'];
  86. // 将回调结果存储到缓存中,供前端查询(保留30秒)
  87. $cacheKey = "ocr_callback_{$ocrRecordId}_{$taskId}";
  88. cache([$cacheKey => $data], now()->addSeconds(30));
  89. Log::info("OCR callback cached with key: {$cacheKey}", [
  90. 'ocr_record_id' => $ocrRecordId,
  91. 'task_id' => $taskId,
  92. 'status' => $status,
  93. 'total_generated' => $data['result']['total_generated'] ?? 0,
  94. 'total_saved' => $data['result']['total_saved'] ?? 0
  95. ]);
  96. // 处理题目关联逻辑
  97. if ($status === 'completed') {
  98. $updatedCount = 0;
  99. // 从result中提取question_mappings(QuestionBank API将它放在result字段中)
  100. $mappings = $data['result']['question_mappings'] ?? $data['question_mappings'] ?? [];
  101. Log::info("Processing OCR question associations", [
  102. 'ocr_record_id' => $ocrRecordId,
  103. 'task_id' => $taskId,
  104. 'mappings_count' => count($mappings)
  105. ]);
  106. // 更新ocr_question_results表中的关联关系
  107. foreach ($mappings as $mapping) {
  108. try {
  109. $ocrQuestionNumber = $mapping['ocr_question_number'] ?? null;
  110. $questionBankId = $mapping['question_bank_id'] ?? null;
  111. $questionCode = $mapping['question_code'] ?? null;
  112. if ($ocrQuestionNumber && $questionBankId) {
  113. // 查找对应的OCR题目结果并更新
  114. $updated = DB::table('ocr_question_results')
  115. ->where('ocr_record_id', $ocrRecordId)
  116. ->where('question_number', $ocrQuestionNumber)
  117. ->update([
  118. 'question_bank_id' => $questionBankId,
  119. 'generation_status' => 'completed',
  120. 'generation_task_id' => $taskId,
  121. 'generation_error' => null,
  122. ]);
  123. if ($updated) {
  124. $updatedCount++;
  125. Log::info("Updated OCR question association", [
  126. 'ocr_record_id' => $ocrRecordId,
  127. 'question_number' => $ocrQuestionNumber,
  128. 'question_bank_id' => $questionBankId,
  129. 'question_code' => $questionCode
  130. ]);
  131. } else {
  132. Log::warning("No OCR question result found for association", [
  133. 'ocr_record_id' => $ocrRecordId,
  134. 'question_number' => $ocrQuestionNumber
  135. ]);
  136. }
  137. }
  138. } catch (\Exception $e) {
  139. Log::error("Failed to update OCR question association", [
  140. 'mapping' => $mapping,
  141. 'error' => $e->getMessage()
  142. ]);
  143. }
  144. }
  145. Log::info("OCR question association completed", [
  146. 'ocr_record_id' => $ocrRecordId,
  147. 'task_id' => $taskId,
  148. 'total_mappings' => count($mappings),
  149. 'updated_count' => $updatedCount
  150. ]);
  151. // 更新OCR记录的整体状态为已完成
  152. try {
  153. DB::table('ocr_records')
  154. ->where('id', $ocrRecordId)
  155. ->update([
  156. 'status' => 'completed',
  157. 'processed_at' => now(),
  158. 'updated_at' => now()
  159. ]);
  160. Log::info("Updated OCR record status to completed", [
  161. 'ocr_record_id' => $ocrRecordId,
  162. 'task_id' => $taskId
  163. ]);
  164. } catch (\Exception $e) {
  165. Log::error("Failed to update OCR record status", [
  166. 'ocr_record_id' => $ocrRecordId,
  167. 'error' => $e->getMessage()
  168. ]);
  169. }
  170. } elseif ($status === 'failed') {
  171. // 更新所有相关的OCR题目结果为失败状态
  172. try {
  173. $updated = DB::table('ocr_question_results')
  174. ->where('ocr_record_id', $ocrRecordId)
  175. ->where('generation_status', 'pending') // 只更新待处理的
  176. ->update([
  177. 'generation_status' => 'failed',
  178. 'generation_task_id' => $taskId,
  179. 'generation_error' => $data['error'] ?? 'Unknown error',
  180. ]);
  181. Log::info("Updated OCR questions to failed status", [
  182. 'ocr_record_id' => $ocrRecordId,
  183. 'task_id' => $taskId,
  184. 'updated_count' => $updated,
  185. 'error' => $data['error'] ?? 'Unknown error'
  186. ]);
  187. // 更新OCR记录的状态为失败
  188. DB::table('ocr_records')
  189. ->where('id', $ocrRecordId)
  190. ->update([
  191. 'status' => 'failed',
  192. 'error_message' => $data['error'] ?? 'Question generation failed',
  193. 'updated_at' => now()
  194. ]);
  195. Log::info("Updated OCR record status to failed", [
  196. 'ocr_record_id' => $ocrRecordId,
  197. 'task_id' => $taskId,
  198. 'error' => $data['error'] ?? 'Unknown error'
  199. ]);
  200. } catch (\Exception $e) {
  201. Log::error("Failed to update OCR questions to failed status", [
  202. 'ocr_record_id' => $ocrRecordId,
  203. 'error' => $e->getMessage()
  204. ]);
  205. }
  206. }
  207. return response()->json([
  208. 'success' => true,
  209. 'message' => 'OCR callback received and processed',
  210. 'data' => [
  211. 'task_id' => $taskId,
  212. 'ocr_record_id' => $ocrRecordId,
  213. 'status' => $status,
  214. 'cache_key' => $cacheKey,
  215. 'associations_processed' => $status === 'completed' ? count($data['question_mappings'] ?? []) : 0
  216. ]
  217. ]);
  218. } catch (\Exception $e) {
  219. Log::error('OCR callback processing failed: ' . $e->getMessage());
  220. Log::error('Exception details: ' . $e->getTraceAsString());
  221. return response()->json([
  222. 'success' => false,
  223. 'error' => 'Callback processing failed: ' . $e->getMessage()
  224. ], 500);
  225. }
  226. })->name('api.ocr.callback');
  227. // 获取题目生成回调结果
  228. Route::get('/questions/callback/{taskId}', function (string $taskId) {
  229. // ✅ 优先从缓存读取(跨域友好)
  230. $callbackData = cache($taskId);
  231. if ($callbackData) {
  232. // 清除已读取的回调数据
  233. cache()->forget($taskId);
  234. session()->forget('question_gen_callback_' . $taskId);
  235. return response()->json($callbackData);
  236. }
  237. // 备选:从session读取
  238. $sessionData = session('question_gen_callback_' . $taskId);
  239. if ($sessionData) {
  240. // 清除已读取的回调数据
  241. session()->forget('question_gen_callback_' . $taskId);
  242. return response()->json($sessionData);
  243. }
  244. // 未收到回调
  245. return response()->json(['status' => 'pending'], 202);
  246. })->name('api.questions.callback.get');
  247. // 获取OCR题目生成回调结果
  248. Route::get('/ocr-question-callback/{ocrRecordId}/{taskId}', function (int $ocrRecordId, string $taskId) {
  249. $cacheKey = "ocr_callback_{$ocrRecordId}_{$taskId}";
  250. $callbackData = cache($cacheKey);
  251. if ($callbackData) {
  252. // 清除已读取的回调数据
  253. cache()->forget($cacheKey);
  254. return response()->json([
  255. 'success' => true,
  256. 'data' => $callbackData
  257. ]);
  258. }
  259. return response()->json([
  260. 'success' => false,
  261. 'status' => 'pending',
  262. 'message' => 'OCR callback not received yet'
  263. ], 202);
  264. })->name('api.ocr.callback.get');
  265. // 题目相关 API
  266. Route::get('/questions', function (QuestionServiceApi $service) {
  267. try {
  268. $page = (int) request()->get('page', 1);
  269. $perPage = (int) request()->get('per_page', 25);
  270. $filters = [
  271. 'kp_code' => request()->get('kp_code'),
  272. 'difficulty' => request()->get('difficulty'),
  273. 'search' => request()->get('search'),
  274. ];
  275. $response = $service->listQuestions($page, $perPage, $filters);
  276. return response()->json($response);
  277. } catch (\Exception $e) {
  278. \Log::error('Failed to fetch questions: ' . $e->getMessage());
  279. return response()->json([
  280. 'data' => [],
  281. 'meta' => [
  282. 'page' => 1,
  283. 'per_page' => 25,
  284. 'total' => 0,
  285. 'total_pages' => 0,
  286. ],
  287. 'error' => $e->getMessage(),
  288. ], 500);
  289. }
  290. });
  291. // 获取题目统计信息
  292. Route::get('/questions/statistics', function (QuestionServiceApi $service) {
  293. try {
  294. $stats = $service->getStatistics();
  295. return response()->json($stats);
  296. } catch (\Exception $e) {
  297. \Log::error('Failed to get question statistics: ' . $e->getMessage());
  298. return response()->json(['error' => $e->getMessage()], 500);
  299. }
  300. });
  301. // 语义搜索题目
  302. Route::post('/questions/search', function (QuestionServiceApi $service) {
  303. try {
  304. $data = request()->only(['query', 'limit']);
  305. $results = $service->searchQuestions($data['query'], $data['limit'] ?? 20);
  306. return response()->json($results);
  307. } catch (\Exception $e) {
  308. \Log::error('Question search failed: ' . $e->getMessage());
  309. return response()->json(['error' => $e->getMessage()], 500);
  310. }
  311. });
  312. // 获取单个题目详情
  313. Route::get('/questions/{id}', function (int $id, QuestionServiceApi $service) {
  314. try {
  315. $question = $service->getQuestionById($id);
  316. if (!$question) {
  317. return response()->json(['error' => 'Question not found'], 404);
  318. }
  319. return response()->json($question);
  320. } catch (\Exception $e) {
  321. \Log::error("Failed to get question {$id}: " . $e->getMessage());
  322. return response()->json(['error' => $e->getMessage()], 500);
  323. }
  324. });
  325. // AI 生成题目
  326. Route::post('/questions/generate', function (QuestionServiceApi $service) {
  327. try {
  328. $data = request()->only(['kp_code', 'keyword', 'count', 'strategy']);
  329. $result = $service->generateQuestions($data);
  330. return response()->json($result);
  331. } catch (\Exception $e) {
  332. \Log::error('Question generation failed: ' . $e->getMessage());
  333. return response()->json([
  334. 'success' => false,
  335. 'message' => $e->getMessage(),
  336. ], 500);
  337. }
  338. });
  339. // 删除题目
  340. Route::delete('/questions/{id}', function (int $id, QuestionServiceApi $service) {
  341. try {
  342. $deleted = $service->deleteQuestion($id);
  343. return response()->json([
  344. 'success' => $deleted,
  345. 'message' => $deleted ? 'Question deleted' : 'Failed to delete',
  346. ]);
  347. } catch (\Exception $e) {
  348. \Log::error("Failed to delete question {$id}: " . $e->getMessage());
  349. return response()->json([
  350. 'success' => false,
  351. 'message' => $e->getMessage(),
  352. ], 500);
  353. }
  354. });
  355. // 获取知识点选项
  356. Route::get('/knowledge-points', function (QuestionServiceApi $service) {
  357. try {
  358. $points = $service->getKnowledgePointOptions();
  359. return response()->json($points);
  360. } catch (\Exception $e) {
  361. \Log::error('Failed to get knowledge points: ' . $e->getMessage());
  362. return response()->json([], 500);
  363. }
  364. });
  365. // 智能出卷对外接口:生成试卷并返回PDF/判卷地址
  366. Route::post('/intelligent-exams', [IntelligentExamController::class, 'store'])
  367. ->withoutMiddleware([
  368. Authenticate::class,
  369. 'auth',
  370. 'auth:sanctum',
  371. 'auth:api',
  372. ])
  373. ->name('api.intelligent-exams.store');
  374. // 智能出卷任务状态查询
  375. Route::get('/intelligent-exams/status/{taskId}', [IntelligentExamController::class, 'status'])
  376. ->withoutMiddleware([
  377. Authenticate::class,
  378. 'auth',
  379. 'auth:sanctum',
  380. 'auth:api',
  381. ])
  382. ->name('api.intelligent-exams.status');
  383. // 学情报告对外接口:生成并返回学情报告 PDF
  384. Route::post('/exam-analysis/report', [ExamAnalysisApiController::class, 'store'])
  385. ->withoutMiddleware([
  386. Authenticate::class,
  387. 'auth',
  388. 'auth:sanctum',
  389. 'auth:api',
  390. ])
  391. ->name('api.exam-analysis.report');
  392. // 学情报告任务状态查询
  393. Route::get('/exam-analysis/status/{taskId}', [ExamAnalysisApiController::class, 'status'])
  394. ->withoutMiddleware([
  395. Authenticate::class,
  396. 'auth',
  397. 'auth:sanctum',
  398. 'auth:api',
  399. ])
  400. ->name('api.exam-analysis.status');
  401. /*
  402. |--------------------------------------------------------------------------
  403. | 教材管理 API 路由
  404. |--------------------------------------------------------------------------
  405. */
  406. // 获取教材列表(按年级排序)
  407. Route::get('/textbooks', [TextbookApiController::class, 'index'])
  408. ->name('api.textbooks.index');
  409. // 根据年级获取教材
  410. Route::get('/textbooks/grade/{grade}', [TextbookApiController::class, 'getByGrade'])
  411. ->name('api.textbooks.by-grade');
  412. // 获取教材系列列表(必须在 {id} 路由之前定义)
  413. Route::get('/textbooks/series', [TextbookApiController::class, 'getSeries'])
  414. ->name('api.textbooks.series');
  415. // 获取单个教材详情
  416. Route::get('/textbooks/{id}', [TextbookApiController::class, 'show'])
  417. ->name('api.textbooks.show');
  418. // 获取教材目录
  419. Route::get('/textbooks/{id}/catalog', [TextbookApiController::class, 'getCatalog'])
  420. ->name('api.textbooks.catalog');
  421. /*
  422. |--------------------------------------------------------------------------
  423. | MathRecSys 集成 API 路由
  424. |--------------------------------------------------------------------------
  425. */
  426. use App\Http\Controllers\Api\StudentController;
  427. // 健康检查
  428. Route::get('/mathrecsys/health', [StudentController::class, 'checkServiceHealth'])->name('api.mathrecsys.health');
  429. // 学生相关 API
  430. Route::prefix('mathrecsys/students')->name('api.mathrecsys.students.')->group(function () {
  431. // 获取学生完整信息
  432. Route::get('{studentId}', [StudentController::class, 'show'])->name('show');
  433. // 获取个性化推荐
  434. Route::get('{studentId}/recommendations', [StudentController::class, 'getRecommendations'])->name('recommendations');
  435. // 获取学习轨迹
  436. Route::get('{studentId}/trajectory', [StudentController::class, 'getTrajectory'])->name('trajectory');
  437. // 获取学习建议
  438. Route::get('{studentId}/suggestions', [StudentController::class, 'getSuggestions'])->name('suggestions');
  439. // 智能分析题目
  440. Route::post('{studentId}/analyze', [StudentController::class, 'analyzeQuestion'])->name('analyze');
  441. // 更新掌握度
  442. Route::put('{studentId}/mastery', [StudentController::class, 'updateMastery'])->name('update-mastery');
  443. });
  444. // 班级分析 API
  445. Route::prefix('mathrecsys/classes')->name('api.mathrecsys.classes.')->group(function () {
  446. Route::get('{classId}/analysis', [StudentController::class, 'classAnalysis'])->name('analysis');
  447. });
  448. // 测试 API
  449. Route::get('/mathrecsys/test', function () {
  450. return response()->json([
  451. 'success' => true,
  452. 'message' => 'MathRecSys API integration is working',
  453. 'timestamp' => now()->toISOString()
  454. ]);
  455. })->name('api.mathrecsys.test');
  456. // 测试OCR题目生成API调用
  457. Route::post('/test-ocr-generation', function () {
  458. try {
  459. $service = new \App\Services\QuestionBankService();
  460. // 模拟前端传递的OCR题目数据
  461. $questions = [
  462. [
  463. 'id' => 1,
  464. 'content' => '计算:2+3-4'
  465. ],
  466. [
  467. 'id' => 2,
  468. 'content' => '解方程:x+5=10'
  469. ]
  470. ];
  471. Log::info('开始测试OCR题目生成', [
  472. 'questions_count' => count($questions),
  473. 'ocr_record_id' => 12
  474. ]);
  475. // 使用异步API,系统自动生成回调URL
  476. $response = $service->generateQuestionsFromOcrAsync(
  477. $questions,
  478. '高一',
  479. '数学',
  480. 12, // OCR记录ID
  481. null, // 让系统自动生成回调URL
  482. 'api.ocr.callback' // 回调路由名称
  483. );
  484. Log::info('OCR题目生成响应', [
  485. 'response' => $response,
  486. 'status' => $response['status'] ?? 'unknown',
  487. 'task_id' => $response['task_id'] ?? 'N/A'
  488. ]);
  489. return response()->json([
  490. 'success' => true,
  491. 'message' => 'OCR题目生成测试完成',
  492. 'data' => $response
  493. ]);
  494. } catch (\Exception $e) {
  495. Log::error('测试OCR题目生成失败', [
  496. 'error' => $e->getMessage(),
  497. 'trace' => $e->getTraceAsString()
  498. ]);
  499. return response()->json([
  500. 'success' => false,
  501. 'error' => $e->getMessage()
  502. ], 500);
  503. }
  504. })->name('api.test.ocr.generation');