ExamPdfController.php 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Services\QuestionBankService;
  4. use Illuminate\Http\Request;
  5. use Illuminate\Support\Facades\Cache;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Log;
  8. class ExamPdfController extends Controller
  9. {
  10. /**
  11. * 标准化选项格式为数组值列表
  12. * 支持格式:
  13. * 1. {"A": "0", "B": "5", "C": "-3", "D": "12"} -> ["0", "5", "-3", "12"]
  14. * 2. [["label": "A", "text": "选项A"], ...] -> ["选项A", "选项B", ...]
  15. * 3. ["A", "B", "C", "D"] -> ["A", "B", "C", "D"]
  16. */
  17. private function normalizeOptions($options): array
  18. {
  19. if (empty($options)) {
  20. return [];
  21. }
  22. // 如果是对象格式 {"A": "值1", "B": "值2", ...}
  23. if (is_array($options) && ! isset($options[0])) {
  24. return array_values($options);
  25. }
  26. // 如果是AI生成格式 [{"label": "A", "text": "选项A"}, ...]
  27. if (is_array($options) && isset($options[0]) && is_array($options[0])) {
  28. // 提取text字段,如果不存在则使用整个数组元素
  29. $normalized = [];
  30. foreach ($options as $opt) {
  31. if (isset($opt['text'])) {
  32. $normalized[] = $opt['text'];
  33. } elseif (isset($opt['value'])) {
  34. $normalized[] = $opt['value'];
  35. } else {
  36. // 如果既没有text也没有value,取数组的第一个值
  37. $normalized[] = is_array($opt) ? (string) reset($opt) : (string) $opt;
  38. }
  39. }
  40. return $normalized;
  41. }
  42. // 如果已经是简单数组格式 ["A", "B", "C", "D"]
  43. if (is_array($options)) {
  44. return array_values($options);
  45. }
  46. // 其他情况返回空数组
  47. return [];
  48. }
  49. /**
  50. * 根据题目内容或类型字段判断题型
  51. */
  52. private function determineQuestionType(array $question): string
  53. {
  54. // 优先根据题目内容判断(而不是数据库字段)
  55. $stem = $question['stem'] ?? $question['content'] ?? '';
  56. $tags = $question['tags'] ?? '';
  57. $skills = $question['skills'] ?? [];
  58. // 1. 根据题干内容判断 - 选择题特征:必须包含 A. B. C. D. 选项(至少2个)
  59. if (is_string($stem)) {
  60. // 选择题特征:必须包含 A. B. C. D. 四个选项(至少2个)
  61. $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem) || preg_match('/^A[\.\s]/', $stem);
  62. $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem) || preg_match('/^B[\.\s]/', $stem);
  63. $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem) || preg_match('/^C[\.\s]/', $stem);
  64. $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem) || preg_match('/^D[\.\s]/', $stem);
  65. $hasOptionE = preg_match('/\bE\s*[\.\、\:]/', $stem) || preg_match('/\(E\)/', $stem) || preg_match('/^E[\.\s]/', $stem);
  66. // 至少有2个选项就认为是选择题(降低阈值)
  67. $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0) + ($hasOptionE ? 1 : 0);
  68. if ($optionCount >= 2) {
  69. return 'choice';
  70. }
  71. // 检查是否有"( )"或"( )"括号,这通常是选择题的标志
  72. if (preg_match('/(\s*)|\(\s*\)/', $stem) && (strpos($stem, 'A.') !== false || strpos($stem, 'B.') !== false || strpos($stem, 'C.') !== false || strpos($stem, 'D.') !== false)) {
  73. return 'choice';
  74. }
  75. }
  76. // 2. 根据技能点判断
  77. if (is_array($skills)) {
  78. $skillsStr = implode(',', $skills);
  79. if (strpos($skillsStr, '选择题') !== false) {
  80. return 'choice';
  81. }
  82. if (strpos($skillsStr, '填空题') !== false) {
  83. return 'fill';
  84. }
  85. if (strpos($skillsStr, '解答题') !== false) {
  86. return 'answer';
  87. }
  88. }
  89. // 3. 根据题目已有类型字段判断(作为后备)
  90. if (! empty($question['question_type'])) {
  91. $type = strtolower(trim($question['question_type']));
  92. if (in_array($type, ['choice', '选择题', 'choice question'])) {
  93. return 'choice';
  94. }
  95. if (in_array($type, ['fill', '填空题', 'fill in the blank'])) {
  96. return 'fill';
  97. }
  98. if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) {
  99. return 'answer';
  100. }
  101. }
  102. if (! empty($question['type'])) {
  103. $type = strtolower(trim($question['type']));
  104. if (in_array($type, ['choice', '选择题', 'choice question'])) {
  105. return 'choice';
  106. }
  107. if (in_array($type, ['fill', '填空题', 'fill in the blank'])) {
  108. return 'fill';
  109. }
  110. if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) {
  111. return 'answer';
  112. }
  113. }
  114. // 4. 根据标签判断
  115. if (is_string($tags)) {
  116. if (strpos($tags, '选择') !== false || strpos($tags, '选择题') !== false) {
  117. return 'choice';
  118. }
  119. if (strpos($tags, '填空') !== false || strpos($tags, '填空题') !== false) {
  120. return 'fill';
  121. }
  122. if (strpos($tags, '解答') !== false || strpos($tags, '简答') !== false || strpos($tags, '证明') !== false) {
  123. return 'answer';
  124. }
  125. }
  126. // 5. 填空题特征:连续下划线或明显的填空括号
  127. if (is_string($stem)) {
  128. // 检查填空题特征:连续下划线
  129. if (preg_match('/_{3,}/', $stem) || strpos($stem, '____') !== false) {
  130. return 'fill';
  131. }
  132. // 空括号填空
  133. if (preg_match('/(\s*)/', $stem) || preg_match('/\(\s*\)/', $stem)) {
  134. return 'fill';
  135. }
  136. }
  137. // 6. 根据题干内容关键词判断
  138. if (is_string($stem)) {
  139. // 有证明、解答、计算、求证等关键词的是解答题
  140. if (preg_match('/(证明|求证|解方程|计算:|求解|推导|说明理由)/', $stem)) {
  141. return 'answer';
  142. }
  143. }
  144. // 默认是解答题(更安全的默认值)
  145. return 'answer';
  146. }
  147. private function normalizeQuestionTypeValue(string $type): string
  148. {
  149. $type = trim($type);
  150. $lower = strtolower($type);
  151. if (in_array($lower, ['choice', 'single_choice', 'multiple_choice'], true)) {
  152. return 'choice';
  153. }
  154. if (in_array($lower, ['fill', 'blank', 'fill_in_the_blank'], true)) {
  155. return 'fill';
  156. }
  157. if (in_array($lower, ['answer', 'calculation', 'word_problem', 'proof'], true)) {
  158. return 'answer';
  159. }
  160. if (in_array($type, ['CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) {
  161. return 'choice';
  162. }
  163. if (in_array($type, ['FILL', 'FILL_IN_THE_BLANK'], true)) {
  164. return 'fill';
  165. }
  166. if (in_array($type, ['CALCULATION', 'WORD_PROBLEM', 'PROOF'], true)) {
  167. return 'answer';
  168. }
  169. if (in_array($type, ['选择题'], true)) {
  170. return 'choice';
  171. }
  172. if (in_array($type, ['填空题'], true)) {
  173. return 'fill';
  174. }
  175. if (in_array($type, ['解答题', '计算题'], true)) {
  176. return 'answer';
  177. }
  178. return $lower ?: 'answer';
  179. }
  180. /**
  181. * 从题目内容中提取选项
  182. */
  183. private function extractOptions(string $content): array
  184. {
  185. $options = [];
  186. // 【修复】先移除SVG内容,避免误匹配SVG注释中的 BD:DC、A:B 等内容
  187. $contentWithoutSvg = preg_replace('/<svg[^>]*>.*?<\/svg>/is', '[SVG_PLACEHOLDER]', $content);
  188. // 1. 尝试匹配多种格式的选项:A. / A、/ A: / A.(中文句点)/ A.(无空格)
  189. // 【修复】选项标记必须在行首或空白后,避免误匹配 SVG 注释中的 BD:DC 等内容
  190. $pattern = '/(?:^|\s)([A-D])[\.、:.:]\s*(.+?)(?=(?:^|\s)[A-D][\.、:.:]|$)/su';
  191. if (preg_match_all($pattern, $contentWithoutSvg, $matches, PREG_SET_ORDER)) {
  192. foreach ($matches as $match) {
  193. $optionText = trim($match[2]);
  194. // 移除末尾的换行和空白
  195. $optionText = preg_replace('/\s+$/', '', $optionText);
  196. // 清理 LaTeX 格式但保留内容
  197. $optionText = preg_replace('/^\$\$\s*/', '', $optionText);
  198. $optionText = preg_replace('/\s*\$\$$/', '', $optionText);
  199. if (! empty($optionText)) {
  200. $options[] = $optionText;
  201. }
  202. }
  203. }
  204. // 2. 如果上面没提取到,尝试按换行分割
  205. if (empty($options)) {
  206. $lines = preg_split('/[\r\n]+/', $contentWithoutSvg);
  207. foreach ($lines as $line) {
  208. $line = trim($line);
  209. // 【修复】行首匹配选项标记
  210. if (preg_match('/^([A-D])[\.、:.:]\s*(.+)$/u', $line, $match)) {
  211. $optionText = trim($match[2]);
  212. if (! empty($optionText)) {
  213. $options[] = $optionText;
  214. }
  215. }
  216. }
  217. }
  218. Log::debug('选项提取结果', [
  219. 'content_preview' => mb_substr($content, 0, 150),
  220. 'options_count' => count($options),
  221. 'options' => $options,
  222. ]);
  223. return $options;
  224. }
  225. /**
  226. * 分离题干内容和选项
  227. */
  228. private function separateStemAndOptions(string $content): array
  229. {
  230. // 【修复】先移除SVG内容,避免误匹配SVG注释中的 BD:DC、A:B 等内容
  231. $contentWithoutSvg = preg_replace('/<svg[^>]*>.*?<\/svg>/is', '[SVG_PLACEHOLDER]', $content);
  232. // 【修复】检测是否有选项时,要求选项标记在行首或空白后
  233. $hasOptions = preg_match('/(?:^|\s)[A-D][\.、:.:]/u', $contentWithoutSvg);
  234. if (! $hasOptions) {
  235. return [$content, []];
  236. }
  237. // 提取选项
  238. $options = $this->extractOptions($content);
  239. // 如果提取到选项,分离题干
  240. if (! empty($options)) {
  241. // 【修复】找到第一个选项的位置,要求选项标记在行首或空白后
  242. if (preg_match('/^(.+?)(?=(?:^|\s)[A-D][\.、:.:])/su', $contentWithoutSvg, $match)) {
  243. $stem = trim($match[1]);
  244. // 如果题干中有SVG占位符,从原始内容中提取对应部分
  245. if (strpos($stem, '[SVG_PLACEHOLDER]') !== false) {
  246. // 找到原始内容中对应位置的题干
  247. $stemLength = mb_strlen(str_replace('[SVG_PLACEHOLDER]', '', $stem));
  248. // 使用更精确的方法:找到第一个有效选项标记的位置
  249. foreach (['A.', 'A、', 'A:', 'A.', 'A:'] as $marker) {
  250. // 只匹配在空白后的选项标记
  251. if (preg_match('/\s'.preg_quote($marker, '/').'/', $content, $m, PREG_OFFSET_CAPTURE)) {
  252. $pos = $m[0][1];
  253. if ($pos > 0) {
  254. $stem = trim(mb_substr($content, 0, $pos));
  255. break;
  256. }
  257. }
  258. }
  259. }
  260. } else {
  261. // 如果正则失败,尝试按位置分割
  262. $stem = $content;
  263. foreach (['A.', 'A、', 'A:', 'A.', 'A:'] as $marker) {
  264. // 【修复】只匹配在空白后的选项标记
  265. if (preg_match('/\s'.preg_quote($marker, '/').'/', $content, $m, PREG_OFFSET_CAPTURE)) {
  266. $pos = $m[0][1];
  267. if ($pos > 0) {
  268. $stem = trim(mb_substr($content, 0, $pos));
  269. break;
  270. }
  271. }
  272. }
  273. }
  274. // 移除末尾的括号或空白
  275. $stem = preg_replace('/()\s*$/', '', $stem);
  276. $stem = trim($stem);
  277. return [$stem, $options];
  278. }
  279. return [$content, []];
  280. }
  281. /**
  282. * 根据题型获取默认分数
  283. */
  284. private function getQuestionScore(string $type): int
  285. {
  286. switch ($type) {
  287. case 'choice':
  288. return 5; // 选择题5分
  289. case 'fill':
  290. return 5; // 填空题5分
  291. case 'answer':
  292. return 10; // 解答题10分
  293. default:
  294. return 5;
  295. }
  296. }
  297. /**
  298. * 获取学生信息
  299. */
  300. private function getStudentInfo(?string $studentId): array
  301. {
  302. if (! $studentId) {
  303. return [
  304. 'name' => '未知学生',
  305. 'grade' => '未知年级',
  306. 'class' => '未知班级',
  307. ];
  308. }
  309. try {
  310. $student = DB::table('students')
  311. ->where('student_id', $studentId)
  312. ->first();
  313. if ($student) {
  314. return [
  315. 'name' => $student->name ?? $studentId,
  316. 'grade' => $student->grade ?? '未知',
  317. 'class' => $student->class ?? '未知',
  318. ];
  319. }
  320. } catch (\Exception $e) {
  321. Log::warning('获取学生信息失败', [
  322. 'student_id' => $studentId,
  323. 'error' => $e->getMessage(),
  324. ]);
  325. }
  326. return [
  327. 'name' => $studentId,
  328. 'grade' => '未知',
  329. 'class' => '未知',
  330. ];
  331. }
  332. /**
  333. * 为 PDF 预览筛选题目(简化版)
  334. */
  335. private function selectBestQuestionsForPdf(array $questions, int $targetCount, string $difficultyCategory): array
  336. {
  337. if (count($questions) <= $targetCount) {
  338. return $questions;
  339. }
  340. // 1. 按题型分类题目
  341. $categorizedQuestions = [
  342. 'choice' => [],
  343. 'fill' => [],
  344. 'answer' => [],
  345. ];
  346. foreach ($questions as $question) {
  347. $type = $this->determineQuestionType($question);
  348. if (! isset($categorizedQuestions[$type])) {
  349. $type = 'answer';
  350. }
  351. $categorizedQuestions[$type][] = $question;
  352. }
  353. // 2. 默认题型配比
  354. $typeRatio = [
  355. '选择题' => 50, // 50%
  356. '填空题' => 30, // 30%
  357. '解答题' => 20, // 20%
  358. ];
  359. // 3. 根据配比选择题目
  360. $selectedQuestions = [];
  361. foreach ($typeRatio as $type => $ratio) {
  362. $typeKey = $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer');
  363. $countForType = floor($targetCount * $ratio / 100);
  364. if ($countForType > 0 && ! empty($categorizedQuestions[$typeKey])) {
  365. $availableCount = count($categorizedQuestions[$typeKey]);
  366. $takeCount = min($countForType, $availableCount, $targetCount - count($selectedQuestions));
  367. // 随机选择题目
  368. $keys = array_keys($categorizedQuestions[$typeKey]);
  369. shuffle($keys);
  370. $selectedKeys = array_slice($keys, 0, $takeCount);
  371. foreach ($selectedKeys as $key) {
  372. $selectedQuestions[] = $categorizedQuestions[$typeKey][$key];
  373. }
  374. }
  375. }
  376. // 4. 如果数量不足,随机补充
  377. while (count($selectedQuestions) < $targetCount) {
  378. $randomQuestion = $questions[array_rand($questions)];
  379. if (! in_array($randomQuestion, $selectedQuestions)) {
  380. $selectedQuestions[] = $randomQuestion;
  381. }
  382. }
  383. // 5. 限制数量并打乱
  384. shuffle($selectedQuestions);
  385. return array_slice($selectedQuestions, 0, $targetCount);
  386. }
  387. /**
  388. * 获取教师信息
  389. */
  390. private function getTeacherInfo(?string $teacherId): array
  391. {
  392. if (! $teacherId) {
  393. return [
  394. 'name' => '未知教师',
  395. ];
  396. }
  397. try {
  398. $teacher = DB::table('teachers')
  399. ->where('teacher_id', $teacherId)
  400. ->first();
  401. if ($teacher) {
  402. return [
  403. 'name' => $teacher->name ?? $teacherId,
  404. ];
  405. }
  406. } catch (\Exception $e) {
  407. Log::warning('获取教师信息失败', [
  408. 'teacher_id' => $teacherId,
  409. 'error' => $e->getMessage(),
  410. ]);
  411. }
  412. return [
  413. 'name' => $teacherId,
  414. ];
  415. }
  416. public function show(Request $request, $paper_id)
  417. {
  418. // 获取是否显示答案的参数,默认为true
  419. $includeAnswer = $request->query('answer', 'true') !== 'false';
  420. // 使用 Eloquent 模型获取试卷数据
  421. $paper = \App\Models\Paper::where('paper_id', $paper_id)->first();
  422. if (! $paper) {
  423. // 尝试从缓存中获取生成的试卷数据(用于 demo 试卷)
  424. $cached = Cache::get('generated_exam_'.$paper_id);
  425. if ($cached) {
  426. Log::info('从缓存获取试卷数据', [
  427. 'paper_id' => $paper_id,
  428. 'cached_count' => count($cached['questions'] ?? []),
  429. 'cached_question_types' => array_column($cached['questions'] ?? [], 'question_type'),
  430. ]);
  431. // 构造临时 Paper 对象
  432. $paper = (object) [
  433. 'paper_id' => $paper_id,
  434. 'paper_name' => $cached['paper_name'] ?? 'Demo Paper',
  435. 'student_id' => $cached['student_id'] ?? null,
  436. 'teacher_id' => $cached['teacher_id'] ?? null,
  437. ];
  438. // 对于 demo 试卷,需要检查题目数量并限制为用户要求的数量
  439. $questionsData = $cached['questions'] ?? [];
  440. $totalQuestions = $cached['total_questions'] ?? count($questionsData);
  441. $difficultyCategory = $cached['difficulty_category'] ?? '中等';
  442. // 为 demo 试卷获取完整的题目详情(包括选项)
  443. if (! empty($questionsData)) {
  444. $questionBankService = app(QuestionBankService::class);
  445. $questionIds = array_column($questionsData, 'id');
  446. $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
  447. $responseData = $questionsResponse['data'] ?? [];
  448. if (! empty($responseData)) {
  449. $responseDataMap = [];
  450. foreach ($responseData as $respQ) {
  451. $responseDataMap[$respQ['id']] = $respQ;
  452. }
  453. // 合并题库数据
  454. $questionsData = array_map(function ($q) use ($responseDataMap) {
  455. if (isset($responseDataMap[$q['id']])) {
  456. $apiData = $responseDataMap[$q['id']];
  457. $rawContent = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? '';
  458. // 分离题干和选项
  459. [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
  460. $q['stem'] = $stem;
  461. $q['content'] = $stem; // 同时设置content字段
  462. $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
  463. $q['solution'] = $apiData['solution'] ?? $q['solution'] ?? '';
  464. $q['tags'] = $apiData['tags'] ?? $q['tags'] ?? '';
  465. // 优先使用API选项,支持多种数据格式
  466. $apiOptions = $apiData['options'] ?? null;
  467. if (! empty($apiOptions)) {
  468. // 标准化options格式为数组值列表
  469. $q['options'] = $this->normalizeOptions($apiOptions);
  470. Log::debug('使用标准化API options', [
  471. 'question_id' => $q['id'],
  472. 'raw_options' => $apiOptions,
  473. 'normalized_options' => $q['options'],
  474. ]);
  475. } else {
  476. // 备选:从题干中提取的选项
  477. $q['options'] = $extractedOptions;
  478. Log::debug('使用提取的options', [
  479. 'question_id' => $q['id'],
  480. 'extracted_options' => $extractedOptions,
  481. ]);
  482. }
  483. }
  484. return $q;
  485. }, $questionsData);
  486. }
  487. }
  488. if (count($questionsData) > $totalQuestions) {
  489. Log::info('PDF预览时发现题目过多,进行筛选', [
  490. 'paper_id' => $paper_id,
  491. 'cached_count' => count($questionsData),
  492. 'required_count' => $totalQuestions,
  493. ]);
  494. $questionsData = $this->selectBestQuestionsForPdf($questionsData, $totalQuestions, $difficultyCategory);
  495. Log::info('筛选后题目数据', [
  496. 'paper_id' => $paper_id,
  497. 'filtered_count' => count($questionsData),
  498. 'filtered_types' => array_column($questionsData, 'question_type'),
  499. ]);
  500. }
  501. } else {
  502. abort(404, '试卷未找到');
  503. }
  504. } else {
  505. // 获取试卷题目
  506. $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paper_id)
  507. ->orderBy('question_number')
  508. ->get();
  509. Log::info('从数据库获取题目', [
  510. 'paper_id' => $paper_id,
  511. 'question_count' => $paperQuestions->count(),
  512. ]);
  513. // 将 paper_questions 表的数据转换为题库格式
  514. $questionsData = [];
  515. foreach ($paperQuestions as $pq) {
  516. $questionsData[] = [
  517. 'id' => $pq->question_bank_id,
  518. 'question_number' => $pq->question_number, // 【关键】保留题目序号,用于排序
  519. 'kp_code' => $pq->knowledge_point,
  520. 'question_type' => $pq->question_type ?? 'answer', // 包含题目类型
  521. 'stem' => $pq->question_text ?? '题目内容缺失', // 如果有存储题目文本
  522. 'solution' => $pq->solution ?? '', // 保存解题思路!
  523. 'answer' => $pq->correct_answer ?? '', // 保存正确答案
  524. 'difficulty' => $pq->difficulty ?? 0.5,
  525. 'score' => $pq->score ?? 5, // 包含已计算的分值
  526. 'tags' => '',
  527. 'content' => $pq->question_text ?? '',
  528. ];
  529. }
  530. Log::info('paper_questions表原始数据', [
  531. 'paper_id' => $paper_id,
  532. 'sample_questions' => array_slice($questionsData, 0, 3),
  533. 'all_types' => array_column($questionsData, 'question_type'),
  534. ]);
  535. // 如果需要完整题目详情(stem等),可以从题库获取
  536. // 但要严格限制只获取这8道题
  537. if (! empty($questionsData)) {
  538. $questionBankService = app(QuestionBankService::class);
  539. $questionIds = array_column($questionsData, 'id');
  540. $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
  541. $responseData = $questionsResponse['data'] ?? [];
  542. // 确保只返回请求的ID对应的题目,并保留数据库中的 question_type
  543. if (! empty($responseData)) {
  544. // 创建题库返回数据的映射
  545. $responseDataMap = [];
  546. foreach ($responseData as $respQ) {
  547. $responseDataMap[$respQ['id']] = $respQ;
  548. }
  549. // 遍历所有数据库中的题目,合并题库返回的数据
  550. $questionsData = array_map(function ($q) use ($responseDataMap, $paperQuestions) {
  551. // 从题库API获取的详细数据(如果有)
  552. if (isset($responseDataMap[$q['id']])) {
  553. $apiData = $responseDataMap[$q['id']];
  554. $rawContent = $apiData['stem'] ?? $q['stem'] ?? '题目内容缺失';
  555. // 分离题干和选项
  556. [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
  557. // 合并数据,优先使用题库API的 stem、answer、solution、options
  558. $q['stem'] = $stem;
  559. $q['content'] = $stem; // 同时设置content字段
  560. $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
  561. $q['solution'] = $apiData['solution'] ?? $q['solution'] ?? '';
  562. $q['tags'] = $apiData['tags'] ?? $q['tags'] ?? '';
  563. // 优先使用API选项,支持多种数据格式
  564. $apiOptions = $apiData['options'] ?? null;
  565. if (! empty($apiOptions)) {
  566. // 标准化options格式为数组值列表
  567. $q['options'] = $this->normalizeOptions($apiOptions);
  568. Log::debug('使用标准化API options', [
  569. 'question_id' => $q['id'],
  570. 'raw_options' => $apiOptions,
  571. 'normalized_options' => $q['options'],
  572. ]);
  573. } else {
  574. // 备选:从题干中提取的选项
  575. $q['options'] = $extractedOptions;
  576. Log::debug('使用提取的options', [
  577. 'question_id' => $q['id'],
  578. 'extracted_options' => $extractedOptions,
  579. ]);
  580. }
  581. }
  582. // 从数据库 paper_questions 表中获取 question_type(已在前面设置,这里确保有值)
  583. if (! isset($q['question_type']) || empty($q['question_type'])) {
  584. $dbQuestion = $paperQuestions->firstWhere('question_bank_id', $q['id']);
  585. if ($dbQuestion && $dbQuestion->question_type) {
  586. $q['question_type'] = $dbQuestion->question_type;
  587. }
  588. }
  589. return $q;
  590. }, $questionsData);
  591. }
  592. }
  593. }
  594. // 按题型分类(使用标准的中学数学试卷格式)
  595. $questions = ['choice' => [], 'fill' => [], 'answer' => []];
  596. foreach ($questionsData as $q) {
  597. // 题库API返回的是 stem 字段,不是 content
  598. $rawContent = $q['stem'] ?? $q['content'] ?? '题目内容缺失';
  599. // 分离题干和选项
  600. [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent);
  601. // 如果从题库API获取了选项,优先使用
  602. $options = $q['options'] ?? $extractedOptions;
  603. $answer = $q['answer'] ?? '';
  604. $solution = $q['solution'] ?? '';
  605. // 优先使用 question_type 字段,如果没有则根据内容智能判断
  606. $type = isset($q['question_type'])
  607. ? $this->normalizeQuestionTypeValue((string) $q['question_type'])
  608. : $this->determineQuestionType($q);
  609. // 详细调试:记录题目类型判断结果
  610. Log::info('题目类型判断', [
  611. 'question_id' => $q['id'] ?? '',
  612. 'has_question_type' => isset($q['question_type']),
  613. 'question_type_value' => $q['question_type'] ?? null,
  614. 'tags' => $q['tags'] ?? '',
  615. 'stem_length' => mb_strlen($content),
  616. 'stem_preview' => mb_substr($content, 0, 100),
  617. 'has_extracted_options' => ! empty($extractedOptions),
  618. 'extracted_options_count' => count($extractedOptions),
  619. 'has_api_options' => isset($q['options']) && ! empty($q['options']),
  620. 'api_options_count' => isset($q['options']) ? count($q['options']) : 0,
  621. 'final_options_count' => count($options),
  622. 'determined_type' => $type,
  623. ]);
  624. if (! isset($questions[$type])) {
  625. $type = 'answer';
  626. }
  627. // 统一处理数学公式和选项数据
  628. $questionData = [
  629. 'id' => $q['id'] ?? $q['question_bank_id'] ?? null,
  630. 'question_number' => $q['question_number'] ?? null, // 【关键】保留题目序号
  631. 'content' => $content,
  632. 'stem' => $content, // 同时提供stem字段
  633. 'answer' => $answer,
  634. 'solution' => $solution,
  635. 'difficulty' => $q['difficulty'] ?? 0.5,
  636. 'kp_code' => $q['kp_code'] ?? '',
  637. 'tags' => $q['tags'] ?? '',
  638. 'options' => $options, // 使用分离后的选项
  639. 'score' => $q['score'] ?? $this->getQuestionScore($type),
  640. 'question_type' => $type,
  641. ];
  642. // 统一处理数学公式 - 标记已处理,避免模板中重复处理
  643. $questionData = \App\Services\MathFormulaProcessor::processQuestionData($questionData);
  644. $questionData['math_processed'] = true; // 添加标记
  645. $qData = (object) $questionData;
  646. $questions[$type][] = $qData;
  647. }
  648. // 【关键】确保每个题型内的题目按 question_number 排序
  649. foreach (['choice', 'fill', 'answer'] as $type) {
  650. if (! empty($questions[$type])) {
  651. usort($questions[$type], function ($a, $b) {
  652. $aNum = $a->question_number ?? 0;
  653. $bNum = $b->question_number ?? 0;
  654. return $aNum <=> $bNum;
  655. });
  656. }
  657. }
  658. // 调试:记录最终分类结果
  659. Log::info('最终分类结果', [
  660. 'paper_id' => $paper_id,
  661. 'choice_count' => count($questions['choice']),
  662. 'fill_count' => count($questions['fill']),
  663. 'answer_count' => count($questions['answer']),
  664. 'total' => count($questions['choice']) + count($questions['fill']) + count($questions['answer']),
  665. ]);
  666. // 渲染视图
  667. $viewName = $includeAnswer ? 'pdf.exam-grading' : 'pdf.exam-paper';
  668. return view($viewName, [
  669. 'paper' => $paper,
  670. 'questions' => $questions,
  671. 'student' => $this->getStudentInfo($paper->student_id),
  672. 'teacher' => $this->getTeacherInfo($paper->teacher_id),
  673. 'includeAnswer' => $includeAnswer,
  674. ]);
  675. }
  676. /**
  677. * 判卷视图:题目前带方框,题后附"正确答案+解题思路"
  678. */
  679. public function showGrading(Request $request, $paper_id)
  680. {
  681. // 复用现有逻辑获取题目分类
  682. $includeAnswer = true;
  683. // 直接调用 show 的前置逻辑(简化复用)
  684. $request->merge(['answer' => 'true']);
  685. // 复用 show() 内逻辑获取 questions/paper
  686. // 为避免重复代码,简单调用 showData 方法(拆分为私有方法?暂直接重用现有方法流程)
  687. // 这里直接复制 show 的主体以保持兼容
  688. // 使用 Eloquent 模型获取试卷数据
  689. $paper = \App\Models\Paper::where('paper_id', $paper_id)->first();
  690. if (! $paper) {
  691. $cached = Cache::get('generated_exam_'.$paper_id);
  692. if (! $cached) {
  693. abort(404, '试卷未找到');
  694. }
  695. $paper = (object) [
  696. 'paper_id' => $paper_id,
  697. 'paper_name' => $cached['paper_name'] ?? 'Demo Paper',
  698. 'student_id' => $cached['student_id'] ?? null,
  699. 'teacher_id' => $cached['teacher_id'] ?? null,
  700. ];
  701. $questionsData = $cached['questions'] ?? [];
  702. $totalQuestions = $cached['total_questions'] ?? count($questionsData);
  703. $difficultyCategory = $cached['difficulty_category'] ?? '中等';
  704. if (! empty($questionsData)) {
  705. $questionBankService = app(QuestionBankService::class);
  706. $questionIds = array_column($questionsData, 'id');
  707. $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
  708. $responseData = $questionsResponse['data'] ?? [];
  709. if (! empty($responseData)) {
  710. $responseDataMap = [];
  711. foreach ($responseData as $respQ) {
  712. $responseDataMap[$respQ['id']] = $respQ;
  713. }
  714. $questionsData = array_map(function ($q) use ($responseDataMap) {
  715. if (isset($responseDataMap[$q['id']])) {
  716. $apiData = $responseDataMap[$q['id']];
  717. $rawContent = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? '';
  718. [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
  719. $q['stem'] = $stem;
  720. $q['content'] = $stem; // 同时设置content字段
  721. $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
  722. $q['solution'] = $apiData['solution'] ?? $q['solution'] ?? '';
  723. $q['tags'] = $apiData['tags'] ?? $q['tags'] ?? '';
  724. // 优先使用API选项,支持多种数据格式
  725. $apiOptions = $apiData['options'] ?? null;
  726. if (! empty($apiOptions)) {
  727. // 标准化options格式为数组值列表
  728. $q['options'] = $this->normalizeOptions($apiOptions);
  729. Log::debug('使用标准化API options', [
  730. 'question_id' => $q['id'],
  731. 'raw_options' => $apiOptions,
  732. 'normalized_options' => $q['options'],
  733. ]);
  734. } else {
  735. // 备选:从题干中提取的选项
  736. $q['options'] = $extractedOptions;
  737. Log::debug('使用提取的options', [
  738. 'question_id' => $q['id'],
  739. 'extracted_options' => $extractedOptions,
  740. ]);
  741. }
  742. }
  743. return $q;
  744. }, $questionsData);
  745. }
  746. }
  747. if (count($questionsData) > $totalQuestions) {
  748. $questionsData = $this->selectBestQuestionsForPdf($questionsData, $totalQuestions, $difficultyCategory);
  749. }
  750. } else {
  751. $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paper_id)
  752. ->orderBy('question_number')
  753. ->get();
  754. $questionsData = [];
  755. foreach ($paperQuestions as $pq) {
  756. $questionsData[] = [
  757. 'id' => $pq->question_bank_id,
  758. 'question_number' => $pq->question_number, // 【关键】保留题目序号
  759. 'kp_code' => $pq->knowledge_point,
  760. 'question_type' => $pq->question_type ?? 'answer',
  761. 'stem' => $pq->question_text ?? '题目内容缺失',
  762. 'solution' => $pq->solution ?? '', // 保存解题思路!
  763. 'answer' => $pq->correct_answer ?? '', // 保存正确答案
  764. 'difficulty' => $pq->difficulty ?? 0.5,
  765. 'score' => $pq->score ?? 5,
  766. 'tags' => '',
  767. 'content' => $pq->question_text ?? '',
  768. ];
  769. }
  770. if (! empty($questionsData)) {
  771. $questionBankService = app(QuestionBankService::class);
  772. $questionIds = array_column($questionsData, 'id');
  773. $questionsResponse = $questionBankService->getQuestionsByIds($questionIds);
  774. $responseData = $questionsResponse['data'] ?? [];
  775. if (! empty($responseData)) {
  776. $responseDataMap = [];
  777. foreach ($responseData as $respQ) {
  778. $responseDataMap[$respQ['id']] = $respQ;
  779. }
  780. $questionsData = array_map(function ($q) use ($responseDataMap, $paperQuestions) {
  781. if (isset($responseDataMap[$q['id']])) {
  782. $apiData = $responseDataMap[$q['id']];
  783. $rawContent = $apiData['stem'] ?? $q['stem'] ?? '题目内容缺失';
  784. [$stem, $extractedOptions] = $this->separateStemAndOptions($rawContent);
  785. $q['stem'] = $stem;
  786. $q['content'] = $stem; // 同时设置content字段
  787. $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
  788. $q['solution'] = $apiData['solution'] ?? $q['solution'] ?? '';
  789. $q['tags'] = $apiData['tags'] ?? $q['tags'] ?? '';
  790. // 优先使用API选项,支持多种数据格式
  791. $apiOptions = $apiData['options'] ?? null;
  792. if (! empty($apiOptions)) {
  793. // 标准化options格式为数组值列表
  794. $q['options'] = $this->normalizeOptions($apiOptions);
  795. Log::debug('使用标准化API options', [
  796. 'question_id' => $q['id'],
  797. 'raw_options' => $apiOptions,
  798. 'normalized_options' => $q['options'],
  799. ]);
  800. } else {
  801. // 备选:从题干中提取的选项
  802. $q['options'] = $extractedOptions;
  803. Log::debug('使用提取的options', [
  804. 'question_id' => $q['id'],
  805. 'extracted_options' => $extractedOptions,
  806. ]);
  807. }
  808. }
  809. if (! isset($q['question_type']) || empty($q['question_type'])) {
  810. $dbQuestion = $paperQuestions->firstWhere('question_bank_id', $q['id']);
  811. if ($dbQuestion && $dbQuestion->question_type) {
  812. $q['question_type'] = $dbQuestion->question_type;
  813. }
  814. }
  815. return $q;
  816. }, $questionsData);
  817. }
  818. }
  819. }
  820. $questions = ['choice' => [], 'fill' => [], 'answer' => []];
  821. foreach ($questionsData as $q) {
  822. $rawContent = $q['stem'] ?? $q['content'] ?? '题目内容缺失';
  823. [$content, $extractedOptions] = $this->separateStemAndOptions($rawContent);
  824. $options = $q['options'] ?? $extractedOptions;
  825. $answer = $q['answer'] ?? '';
  826. $solution = $q['solution'] ?? '';
  827. $type = isset($q['question_type'])
  828. ? $this->normalizeQuestionTypeValue((string) $q['question_type'])
  829. : $this->determineQuestionType($q);
  830. if (! isset($questions[$type])) {
  831. $type = 'answer';
  832. }
  833. // 统一处理数学公式和选项数据
  834. $questionData = [
  835. 'id' => $q['id'] ?? $q['question_bank_id'] ?? null,
  836. 'question_number' => $q['question_number'] ?? null, // 【关键】保留题目序号
  837. 'content' => $content,
  838. 'stem' => $content, // 同时提供stem字段
  839. 'answer' => $answer,
  840. 'solution' => $solution,
  841. 'difficulty' => $q['difficulty'] ?? 0.5,
  842. 'kp_code' => $q['kp_code'] ?? '',
  843. 'tags' => $q['tags'] ?? '',
  844. 'options' => $options,
  845. 'score' => $q['score'] ?? $this->getQuestionScore($type),
  846. 'question_type' => $type,
  847. ];
  848. // 统一处理数学公式 - 标记已处理,避免模板中重复处理
  849. $questionData = \App\Services\MathFormulaProcessor::processQuestionData($questionData);
  850. $questionData['math_processed'] = true; // 添加标记
  851. $qData = (object) $questionData;
  852. $questions[$type][] = $qData;
  853. }
  854. // 【关键】确保每个题型内的题目按 question_number 排序
  855. foreach (['choice', 'fill', 'answer'] as $type) {
  856. if (! empty($questions[$type])) {
  857. usort($questions[$type], function ($a, $b) {
  858. $aNum = $a->question_number ?? 0;
  859. $bNum = $b->question_number ?? 0;
  860. return $aNum <=> $bNum;
  861. });
  862. }
  863. }
  864. return view('pdf.exam-grading', [
  865. 'paper' => $paper,
  866. 'questions' => $questions,
  867. 'student' => $this->getStudentInfo($paper->student_id),
  868. 'teacher' => $this->getTeacherInfo($paper->teacher_id),
  869. 'includeAnswer' => true,
  870. ]);
  871. }
  872. /**
  873. * 知识点讲解视图
  874. */
  875. public function showKnowledgeExplanation(Request $request, $paper_id)
  876. {
  877. // 获取试卷数据
  878. $paper = \App\Models\Paper::where('paper_id', $paper_id)->first();
  879. if (! $paper) {
  880. abort(404, '试卷未找到');
  881. }
  882. // 提取15位paper_id数字部分作为学案编号
  883. $rawPaperId = $paper->paper_id ?? $paper_id;
  884. preg_match('/paper_(\d{15})/', (string) $rawPaperId, $matches);
  885. $examCode = $matches[1] ?? preg_replace('/[^0-9]/', '', (string) $rawPaperId);
  886. // 生成日期
  887. $generateDate = now()->locale('zh_CN')->isoFormat('M月D日');
  888. // 提取并去重知识点代码(优先 paper_questions.knowledge_point,缺失时回退到题库 kp_code)
  889. $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paper_id)->get();
  890. $kpCodes = [];
  891. $seen = [];
  892. $questionBankIds = $paperQuestions
  893. ->pluck('question_bank_id')
  894. ->filter()
  895. ->unique()
  896. ->values();
  897. $questionKpMap = [];
  898. if ($questionBankIds->isNotEmpty()) {
  899. $questionKpMap = \App\Models\Question::whereIn('id', $questionBankIds)
  900. ->pluck('kp_code', 'id')
  901. ->toArray();
  902. }
  903. foreach ($paperQuestions as $pq) {
  904. $kpCode = trim((string) ($pq->knowledge_point ?? ''));
  905. if ($kpCode === '' && ! empty($pq->question_bank_id)) {
  906. $kpCode = trim((string) ($questionKpMap[$pq->question_bank_id] ?? ''));
  907. }
  908. if ($kpCode === '') {
  909. continue;
  910. }
  911. if (isset($seen[$kpCode])) {
  912. continue;
  913. }
  914. $seen[$kpCode] = true;
  915. $kpCodes[] = $kpCode;
  916. }
  917. // 使用 ExamPdfExportService 构建知识点数据
  918. $pdfService = app(\App\Services\ExamPdfExportService::class);
  919. // 获取知识点名称映射
  920. $kpNameMap = [];
  921. try {
  922. $kpNameMap = app(\App\Services\QuestionServiceApi::class)->getKnowledgePointOptions();
  923. } catch (\Throwable $e) {
  924. // 静默失败,使用 code 作为名称
  925. }
  926. // 批量获取知识点讲解
  927. $explanations = $pdfService->buildExplanations($kpCodes);
  928. $knowledgePoints = [];
  929. foreach ($kpCodes as $kpCode) {
  930. $kpData = [
  931. 'kp_code' => $kpCode,
  932. 'kp_name' => $kpNameMap[$kpCode] ?? $kpCode,
  933. 'explanation' => $explanations[$kpCode] ?? '',
  934. ];
  935. $knowledgePoints[] = $kpData;
  936. }
  937. return view('pdf.exam-knowledge-explanation', [
  938. 'paperId' => $paper_id,
  939. 'examCode' => $examCode ?: $paper_id,
  940. 'generateDate' => $generateDate,
  941. 'knowledgePoints' => $knowledgePoints,
  942. ]);
  943. }
  944. /**
  945. * 重新生成 PDF(统一生成卷子和判卷)
  946. *
  947. * @param string $paper_id
  948. * @return \Illuminate\Http\JsonResponse
  949. */
  950. public function regeneratePdf(Request $request, $paper_id)
  951. {
  952. Log::info('RegeneratePdf: 开始重新生成PDF', ['paper_id' => $paper_id]);
  953. // 验证 paper_id 格式
  954. if (empty($paper_id) || ! preg_match('/^paper_\d+$/', $paper_id)) {
  955. return response()->json([
  956. 'success' => false,
  957. 'message' => '无效的试卷ID格式',
  958. 'paper_id' => $paper_id,
  959. ], 400);
  960. }
  961. try {
  962. // 调用 PDF 生成服务
  963. $pdfService = app(\App\Services\ExamPdfExportService::class);
  964. // 是否包含知识点讲解(可选):未传参则使用 config/pdf.php 默认值
  965. $includeKpExplain = null;
  966. if ($request->has('include_kp_explain')) {
  967. $includeKpExplain = filter_var(
  968. $request->input('include_kp_explain'),
  969. FILTER_VALIDATE_BOOLEAN,
  970. FILTER_NULL_ON_FAILURE
  971. );
  972. }
  973. // 生成统一 PDF(卷子 + 判卷)
  974. $pdfUrl = $pdfService->generateUnifiedPdf($paper_id, $includeKpExplain);
  975. if ($pdfUrl) {
  976. Log::info('RegeneratePdf: PDF重新生成成功', [
  977. 'paper_id' => $paper_id,
  978. 'url' => $pdfUrl,
  979. ]);
  980. return response()->json([
  981. 'success' => true,
  982. 'message' => 'PDF重新生成成功',
  983. 'paper_id' => $paper_id,
  984. 'pdf_url' => $pdfUrl,
  985. ]);
  986. }
  987. Log::error('RegeneratePdf: PDF生成失败', ['paper_id' => $paper_id]);
  988. return response()->json([
  989. 'success' => false,
  990. 'message' => 'PDF生成失败',
  991. 'paper_id' => $paper_id,
  992. ], 500);
  993. } catch (\Exception $e) {
  994. Log::error('RegeneratePdf: 异常错误', [
  995. 'paper_id' => $paper_id,
  996. 'error' => $e->getMessage(),
  997. 'trace' => $e->getTraceAsString(),
  998. ]);
  999. return response()->json([
  1000. 'success' => false,
  1001. 'message' => 'PDF生成异常:'.$e->getMessage(),
  1002. 'paper_id' => $paper_id,
  1003. ], 500);
  1004. }
  1005. }
  1006. /**
  1007. * 重新生成试卷 PDF(不含答案)
  1008. *
  1009. * @param string $paper_id
  1010. * @return \Illuminate\Http\JsonResponse
  1011. */
  1012. public function regenerateExamPdf(Request $request, $paper_id)
  1013. {
  1014. Log::info('RegenerateExamPdf: 开始重新生成试卷PDF', ['paper_id' => $paper_id]);
  1015. if (empty($paper_id) || ! preg_match('/^paper_\d+$/', $paper_id)) {
  1016. return response()->json([
  1017. 'success' => false,
  1018. 'message' => '无效的试卷ID格式',
  1019. 'paper_id' => $paper_id,
  1020. ], 400);
  1021. }
  1022. try {
  1023. $pdfService = app(\App\Services\ExamPdfExportService::class);
  1024. $pdfUrl = $pdfService->generateExamPdf($paper_id);
  1025. if ($pdfUrl) {
  1026. return response()->json([
  1027. 'success' => true,
  1028. 'message' => '试卷PDF重新生成成功',
  1029. 'paper_id' => $paper_id,
  1030. 'pdf_url' => $pdfUrl,
  1031. ]);
  1032. }
  1033. return response()->json([
  1034. 'success' => false,
  1035. 'message' => '试卷PDF生成失败',
  1036. 'paper_id' => $paper_id,
  1037. ], 500);
  1038. } catch (\Exception $e) {
  1039. Log::error('RegenerateExamPdf: 异常错误', [
  1040. 'paper_id' => $paper_id,
  1041. 'error' => $e->getMessage(),
  1042. ]);
  1043. return response()->json([
  1044. 'success' => false,
  1045. 'message' => 'PDF生成异常:'.$e->getMessage(),
  1046. 'paper_id' => $paper_id,
  1047. ], 500);
  1048. }
  1049. }
  1050. /**
  1051. * 重新生成判卷 PDF(含答案)
  1052. *
  1053. * @param string $paper_id
  1054. * @return \Illuminate\Http\JsonResponse
  1055. */
  1056. public function regenerateGradingPdf(Request $request, $paper_id)
  1057. {
  1058. Log::info('RegenerateGradingPdf: 开始重新生成判卷PDF', ['paper_id' => $paper_id]);
  1059. if (empty($paper_id) || ! preg_match('/^paper_\d+$/', $paper_id)) {
  1060. return response()->json([
  1061. 'success' => false,
  1062. 'message' => '无效的试卷ID格式',
  1063. 'paper_id' => $paper_id,
  1064. ], 400);
  1065. }
  1066. try {
  1067. $pdfService = app(\App\Services\ExamPdfExportService::class);
  1068. $pdfUrl = $pdfService->generateGradingPdf($paper_id);
  1069. if ($pdfUrl) {
  1070. return response()->json([
  1071. 'success' => true,
  1072. 'message' => '判卷PDF重新生成成功',
  1073. 'paper_id' => $paper_id,
  1074. 'pdf_url' => $pdfUrl,
  1075. ]);
  1076. }
  1077. return response()->json([
  1078. 'success' => false,
  1079. 'message' => '判卷PDF生成失败',
  1080. 'paper_id' => $paper_id,
  1081. ], 500);
  1082. } catch (\Exception $e) {
  1083. Log::error('RegenerateGradingPdf: 异常错误', [
  1084. 'paper_id' => $paper_id,
  1085. 'error' => $e->getMessage(),
  1086. ]);
  1087. return response()->json([
  1088. 'success' => false,
  1089. 'message' => 'PDF生成异常:'.$e->getMessage(),
  1090. 'paper_id' => $paper_id,
  1091. ], 500);
  1092. }
  1093. }
  1094. }