ExamPdfExportService.php 72 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823
  1. <?php
  2. namespace App\Services;
  3. use App\DTO\ExamAnalysisDataDto;
  4. use App\DTO\ReportPayloadDto;
  5. use App\Models\Paper;
  6. use App\Models\PaperQuestion;
  7. use App\Models\Student;
  8. use Illuminate\Http\Request;
  9. use Illuminate\Support\Facades\DB;
  10. use Illuminate\Support\Facades\File;
  11. use Illuminate\Support\Facades\Http;
  12. use Illuminate\Support\Facades\Log;
  13. use Illuminate\Support\Facades\Storage;
  14. use Illuminate\Support\Facades\URL;
  15. use Symfony\Component\Process\Exception\ProcessSignaledException;
  16. use Symfony\Component\Process\Exception\ProcessTimedOutException;
  17. use Symfony\Component\Process\Process;
  18. /**
  19. * PDF导出服务(重构版)
  20. * 负责生成试卷PDF、判卷PDF和学情报告PDF
  21. */
  22. class ExamPdfExportService
  23. {
  24. public function __construct(
  25. private readonly LearningAnalyticsService $learningAnalyticsService,
  26. private readonly QuestionBankService $questionBankService,
  27. private readonly QuestionServiceApi $questionServiceApi,
  28. private readonly PdfStorageService $pdfStorageService,
  29. private readonly MasteryCalculator $masteryCalculator,
  30. private readonly PdfMerger $pdfMerger
  31. ) {}
  32. /**
  33. * 生成试卷 PDF(不含答案)
  34. */
  35. public function generateExamPdf(string $paperId): ?string
  36. {
  37. Log::info('generateExamPdf 开始:', ['paper_id' => $paperId]);
  38. // 返回页面URL(用于数据库保存)
  39. $pageUrl = route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']);
  40. Log::info('generateExamPdf 页面URL:', ['paper_id' => $paperId, 'url' => $pageUrl]);
  41. // 将页面URL写入数据库
  42. $this->savePdfUrlToDatabase($paperId, 'exam_pdf_url', $pageUrl);
  43. // 生成PDF文件(用于合并,不上传云存储)
  44. $pdfPath = storage_path("app/public/exams/{$paperId}_exam.pdf");
  45. Log::info('ExamPdfExportService: 开始生成试卷PDF', ['path' => $pdfPath, 'url' => $pageUrl]);
  46. $pdfBinary = $this->buildPdfFromUrl($pageUrl);
  47. if (!$pdfBinary) {
  48. Log::error('ExamPdfExportService: 生成试卷PDF失败', ['url' => $pageUrl]);
  49. return null;
  50. }
  51. Log::info('ExamPdfExportService: PDF生成成功,开始写入文件', ['path' => $pdfPath, 'size' => strlen($pdfBinary)]);
  52. $result = file_put_contents($pdfPath, $pdfBinary);
  53. if ($result === false) {
  54. Log::error('ExamPdfExportService: 写入试卷PDF文件失败', ['path' => $pdfPath]);
  55. return null;
  56. }
  57. // 【关键修复】验证文件是否真的写入成功
  58. if (!file_exists($pdfPath)) {
  59. Log::error('ExamPdfExportService: 文件写入后不存在', ['path' => $pdfPath, 'result' => $result]);
  60. return null;
  61. }
  62. Log::info('ExamPdfExportService: 试卷PDF文件写入成功', ['path' => $pdfPath, 'size' => $result]);
  63. // 返回页面URL(不是PDF URL)
  64. return $pageUrl;
  65. }
  66. /**
  67. * 生成判卷 PDF(含答案与解析)
  68. */
  69. public function generateGradingPdf(string $paperId): ?string
  70. {
  71. Log::info('generateGradingPdf 开始:', ['paper_id' => $paperId]);
  72. // 返回页面URL(用于数据库保存)
  73. $pageUrl = route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'true']);
  74. Log::info('generateGradingPdf 页面URL:', ['paper_id' => $paperId, 'url' => $pageUrl]);
  75. // 将页面URL写入数据库
  76. $this->savePdfUrlToDatabase($paperId, 'grading_pdf_url', $pageUrl);
  77. // 生成PDF文件(用于合并,不上传云存储)
  78. $pdfPath = storage_path("app/public/exams/{$paperId}_grading.pdf");
  79. Log::info('ExamPdfExportService: 开始生成判卷PDF', ['path' => $pdfPath, 'url' => $pageUrl]);
  80. $pdfBinary = $this->buildPdfFromUrl($pageUrl);
  81. if (!$pdfBinary) {
  82. Log::error('ExamPdfExportService: 生成判卷PDF失败', ['url' => $pageUrl]);
  83. return null;
  84. }
  85. Log::info('ExamPdfExportService: 判卷PDF生成成功,开始写入文件', ['path' => $pdfPath, 'size' => strlen($pdfBinary)]);
  86. $result = file_put_contents($pdfPath, $pdfBinary);
  87. if ($result === false) {
  88. Log::error('ExamPdfExportService: 写入判卷PDF文件失败', ['path' => $pdfPath]);
  89. return null;
  90. }
  91. // 【关键修复】验证文件是否真的写入成功
  92. if (!file_exists($pdfPath)) {
  93. Log::error('ExamPdfExportService: 判卷文件写入后不存在', ['path' => $pdfPath, 'result' => $result]);
  94. return null;
  95. }
  96. Log::info('ExamPdfExportService: 判卷PDF文件写入成功', ['path' => $pdfPath, 'size' => $result]);
  97. // 返回页面URL(不是PDF URL)
  98. return $pageUrl;
  99. }
  100. /**
  101. * 生成合并PDF(试卷 + 判卷)
  102. * 先分别生成两个PDF,然后合并
  103. * 【优化】添加进度回调支持和快速合并模式
  104. * 【修复】优化临时文件清理逻辑,确保合并成功后才删除源文件
  105. */
  106. public function generateMergedPdf(string $paperId, ?callable $progressCallback = null): ?string
  107. {
  108. Log::info('generateMergedPdf 开始:', ['paper_id' => $paperId]);
  109. // 【新增】快速幂等性检查:如果all_pdf_url已存在,直接返回
  110. $existingPaper = \App\Models\Paper::where('paper_id', $paperId)->first();
  111. if ($existingPaper && $existingPaper->all_pdf_url) {
  112. Log::info('【快速返回】合并PDF已存在,无需重新生成', [
  113. 'paper_id' => $paperId,
  114. 'existing_url' => $existingPaper->all_pdf_url
  115. ]);
  116. if ($progressCallback) {
  117. $progressCallback(100, '合并PDF已存在,直接返回');
  118. }
  119. return $existingPaper->all_pdf_url;
  120. }
  121. if ($progressCallback) {
  122. $progressCallback(0, '准备合并PDF...');
  123. }
  124. $tempDir = storage_path("app/temp");
  125. if (!is_dir($tempDir)) {
  126. mkdir($tempDir, 0755, true);
  127. }
  128. $examPdfPath = null;
  129. $gradingPdfPath = null;
  130. $mergedPdfPath = null;
  131. $mergeSuccess = false;
  132. $uploadSuccess = false;
  133. try {
  134. // 【修复】不重复生成PDF,直接使用已有的文件
  135. // 假设PDF已经通过generateExamPdf和generateGradingPdf生成过了
  136. // 获取数据库中的页面URL(用于记录)
  137. $paper = \App\Models\Paper::where('paper_id', $paperId)->first();
  138. $examPdfUrl = $paper?->exam_pdf_url;
  139. $gradingPdfUrl = $paper?->grading_pdf_url;
  140. if (!$examPdfUrl || !$gradingPdfUrl) {
  141. Log::warning('ExamPdfExportService: 未找到PDF页面URL,可能尚未生成', [
  142. 'paper_id' => $paperId,
  143. 'exam_pdf_url' => $examPdfUrl,
  144. 'grading_pdf_url' => $gradingPdfUrl
  145. ]);
  146. }
  147. Log::info('使用本地PDF文件进行合并', [
  148. 'exam_url' => $examPdfUrl,
  149. 'grading_url' => $gradingPdfUrl
  150. ]);
  151. if ($progressCallback) {
  152. $progressCallback(10, '验证PDF文件...');
  153. }
  154. // 直接使用本地PDF文件
  155. $examPdfPath = storage_path("app/public/exams/{$paperId}_exam.pdf");
  156. $gradingPdfPath = storage_path("app/public/exams/{$paperId}_grading.pdf");
  157. // 验证文件是否存在
  158. Log::info('ExamPdfExportService: 检查PDF文件是否存在', [
  159. 'exam_pdf' => $examPdfPath,
  160. 'exam_exists' => file_exists($examPdfPath),
  161. 'grading_pdf' => $gradingPdfPath,
  162. 'grading_exists' => file_exists($gradingPdfPath)
  163. ]);
  164. if (!file_exists($examPdfPath)) {
  165. Log::error('ExamPdfExportService: 试卷PDF文件不存在', [
  166. 'path' => $examPdfUrl,
  167. 'local_path' => $examPdfPath,
  168. 'directory_exists' => is_dir(dirname($examPdfPath)),
  169. 'directory_contents' => is_dir(dirname($examPdfPath)) ? scandir(dirname($examPdfPath)) : null
  170. ]);
  171. return null;
  172. }
  173. if (!file_exists($gradingPdfPath)) {
  174. Log::error('ExamPdfExportService: 判卷PDF文件不存在', [
  175. 'path' => $gradingPdfUrl,
  176. 'local_path' => $gradingPdfPath,
  177. 'directory_exists' => is_dir(dirname($gradingPdfPath)),
  178. 'directory_contents' => is_dir(dirname($gradingPdfPath)) ? scandir(dirname($gradingPdfPath)) : null
  179. ]);
  180. return null;
  181. }
  182. $examSize = filesize($examPdfPath);
  183. $gradingSize = filesize($gradingPdfPath);
  184. Log::info('PDF文件验证成功', [
  185. 'exam_pdf' => $examPdfPath,
  186. 'grading_pdf' => $gradingPdfPath,
  187. 'exam_size' => $examSize,
  188. 'grading_size' => $gradingSize,
  189. 'total_size' => $examSize + $gradingSize
  190. ]);
  191. if ($examSize < 1000 || $gradingSize < 1000) {
  192. Log::warning('ExamPdfExportService: PDF文件过小,可能生成不完整', [
  193. 'exam_size' => $examSize,
  194. 'grading_size' => $gradingSize
  195. ]);
  196. }
  197. if ($progressCallback) {
  198. $progressCallback(20, '开始合并PDF文件...');
  199. }
  200. // 【优化】合并PDF文件 - 使用快速合并模式
  201. $mergedPdfPath = $tempDir . "/{$paperId}_merged.pdf";
  202. $merged = $this->pdfMerger->mergeWithProgress([$examPdfPath, $gradingPdfPath], $mergedPdfPath, $progressCallback);
  203. if (!$merged) {
  204. Log::error('ExamPdfExportService: PDF文件合并失败', [
  205. 'tool' => $this->pdfMerger->getMergeTool(),
  206. 'exam_pdf' => $examPdfPath,
  207. 'grading_pdf' => $gradingPdfPath,
  208. 'output_pdf' => $mergedPdfPath
  209. ]);
  210. return null;
  211. }
  212. // 【新增】验证合并后的PDF内容
  213. if (!file_exists($mergedPdfPath)) {
  214. Log::error('ExamPdfExportService: 合并后PDF文件不存在', ['path' => $mergedPdfPath]);
  215. return null;
  216. }
  217. $mergedSize = filesize($mergedPdfPath);
  218. Log::info('ExamPdfExportService: 合并PDF验证', [
  219. 'merged_pdf' => $mergedPdfPath,
  220. 'merged_size' => $mergedSize,
  221. 'expected_min_size' => max($examSize, $gradingSize) + 1000,
  222. 'size_valid' => $mergedSize > max($examSize, $gradingSize)
  223. ]);
  224. // 验证合并后的PDF大小是否合理(应该大于任一源文件)
  225. if ($mergedSize <= max($examSize, $gradingSize)) {
  226. Log::warning('ExamPdfExportService: 合并PDF大小异常,可能合并失败', [
  227. 'merged_size' => $mergedSize,
  228. 'exam_size' => $examSize,
  229. 'grading_size' => $gradingSize,
  230. 'max_source_size' => max($examSize, $gradingSize)
  231. ]);
  232. }
  233. $mergeSuccess = true;
  234. if ($progressCallback) {
  235. $progressCallback(80, '上传合并PDF...');
  236. }
  237. // 读取合并后的PDF内容并上传到云存储
  238. $mergedPdfContent = file_get_contents($mergedPdfPath);
  239. if (strlen($mergedPdfContent) < 1000) {
  240. Log::error('ExamPdfExportService: 合并PDF内容过小,上传失败', [
  241. 'content_size' => strlen($mergedPdfContent),
  242. 'file_size' => $mergedSize
  243. ]);
  244. return null;
  245. }
  246. $path = "exams/{$paperId}_all.pdf";
  247. $mergedUrl = $this->pdfStorageService->put($path, $mergedPdfContent);
  248. if (!$mergedUrl) {
  249. Log::error('ExamPdfExportService: 保存合并PDF失败', ['path' => $path]);
  250. return null;
  251. }
  252. Log::info('ExamPdfExportService: 合并PDF上传成功', [
  253. 'url' => $mergedUrl,
  254. 'content_size' => strlen($mergedPdfContent),
  255. 'file_size' => $mergedSize
  256. ]);
  257. $uploadSuccess = true;
  258. // 保存到数据库的all_pdf_url字段
  259. $this->saveAllPdfUrlToDatabase($paperId, $mergedUrl);
  260. Log::info('generateMergedPdf 完成:', [
  261. 'paper_id' => $paperId,
  262. 'url' => $mergedUrl,
  263. 'tool' => $this->pdfMerger->getMergeTool(),
  264. 'exam_size' => $examSize,
  265. 'grading_size' => $gradingSize,
  266. 'merged_size' => $mergedSize
  267. ]);
  268. return $mergedUrl;
  269. } catch (\Throwable $e) {
  270. Log::error('ExamPdfExportService: 生成合并PDF失败', [
  271. 'paper_id' => $paperId,
  272. 'error' => $e->getMessage(),
  273. 'trace' => $e->getTraceAsString(),
  274. ]);
  275. if ($progressCallback) {
  276. $progressCallback(-1, '合并PDF失败: ' . $e->getMessage());
  277. }
  278. return null;
  279. } finally {
  280. // 【修复】优化临时文件清理逻辑:
  281. // 1. 合并失败时不删除源文件,便于重试
  282. // 2. 合并成功后才删除源文件
  283. // 3. 保留合并后的文件一段时间,便于调试
  284. if ($mergeSuccess && $uploadSuccess) {
  285. // 合并成功且上传成功,删除源文件
  286. $sourceFiles = [$examPdfPath, $gradingPdfPath];
  287. foreach ($sourceFiles as $file) {
  288. if ($file && file_exists($file)) {
  289. @unlink($file);
  290. Log::debug('删除源PDF文件', ['path' => $file]);
  291. }
  292. }
  293. // 保留合并文件30分钟后删除
  294. if ($mergedPdfPath && file_exists($mergedPdfPath)) {
  295. $deletionTime = time() + 1800; // 30分钟后
  296. @touch($mergedPdfPath, $deletionTime);
  297. Log::info('合并PDF文件保留30分钟用于调试', [
  298. 'path' => $mergedPdfPath,
  299. 'deletion_time' => date('Y-m-d H:i:s', $deletionTime)
  300. ]);
  301. }
  302. } else {
  303. // 合并失败或上传失败,保留所有文件用于调试
  304. Log::warning('PDF合并未完全成功,保留临时文件用于调试', [
  305. 'merge_success' => $mergeSuccess,
  306. 'upload_success' => $uploadSuccess,
  307. 'exam_pdf' => $examPdfPath,
  308. 'grading_pdf' => $gradingPdfPath,
  309. 'merged_pdf' => $mergedPdfPath,
  310. 'exam_exists' => $examPdfPath ? file_exists($examPdfPath) : false,
  311. 'grading_exists' => $gradingPdfPath ? file_exists($gradingPdfPath) : false,
  312. 'merged_exists' => $mergedPdfPath ? file_exists($mergedPdfPath) : false
  313. ]);
  314. }
  315. Log::debug('PDF合并流程完成', [
  316. 'merge_success' => $mergeSuccess,
  317. 'upload_success' => $uploadSuccess
  318. ]);
  319. }
  320. }
  321. /**
  322. * 将URL转换为本地文件路径
  323. */
  324. private function convertUrlToPath(string $url): ?string
  325. {
  326. // 如果是本地存储,URL格式类似:/storage/exams/paper_id_exam.pdf
  327. // 需要转换为绝对路径
  328. if (strpos($url, '/storage/') === 0) {
  329. return public_path(ltrim($url, '/'));
  330. }
  331. // 如果是完整路径,直接返回
  332. if (strpos($url, '/') === 0 && file_exists($url)) {
  333. return $url;
  334. }
  335. // 如果是相对路径,转换为绝对路径
  336. $path = public_path($url);
  337. if (file_exists($path)) {
  338. return $path;
  339. }
  340. return null;
  341. }
  342. /**
  343. * 保存合并PDF URL到数据库
  344. */
  345. private function saveAllPdfUrlToDatabase(string $paperId, string $url): void
  346. {
  347. try {
  348. \App\Models\Paper::where('paper_id', $paperId)->update([
  349. 'all_pdf_url' => $url
  350. ]);
  351. Log::debug('保存all_pdf_url成功', ['paper_id' => $paperId, 'url' => $url]);
  352. } catch (\Exception $e) {
  353. Log::error('保存all_pdf_url失败', [
  354. 'paper_id' => $paperId,
  355. 'url' => $url,
  356. 'error' => $e->getMessage()
  357. ]);
  358. throw $e;
  359. }
  360. }
  361. /**
  362. * 生成学情分析 PDF
  363. */
  364. public function generateAnalysisReportPdf(string $paperId, string $studentId, ?string $recordId = null): ?string
  365. {
  366. if (function_exists('set_time_limit')) {
  367. @set_time_limit(240);
  368. }
  369. try {
  370. // 【调试】打印输入参数
  371. Log::info('ExamPdfExportService: 开始生成学情分析PDF', [
  372. 'paper_id' => $paperId,
  373. 'student_id' => $studentId,
  374. 'record_id' => $recordId,
  375. ]);
  376. // 构建分析数据
  377. $analysisData = $this->buildAnalysisData($paperId, $studentId);
  378. if (!$analysisData) {
  379. Log::warning('ExamPdfExportService: buildAnalysisData返回空数据', [
  380. 'paper_id' => $paperId,
  381. 'student_id' => $studentId,
  382. ]);
  383. return null;
  384. }
  385. Log::info('ExamPdfExportService: buildAnalysisData返回数据', [
  386. 'paper_id' => $paperId,
  387. 'student_id' => $studentId,
  388. 'analysisData_keys' => array_keys($analysisData),
  389. 'mastery_count' => count($analysisData['mastery']['items'] ?? []),
  390. 'questions_count' => count($analysisData['questions'] ?? []),
  391. ]);
  392. // 创建DTO
  393. $dto = ExamAnalysisDataDto::fromArray($analysisData);
  394. $payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
  395. // 【调试】打印传给模板的数据
  396. $templateData = $payloadDto->toArray();
  397. Log::info('ExamPdfExportService: 传给模板的数据', [
  398. 'paper' => $templateData['paper'] ?? null,
  399. 'student' => $templateData['student'] ?? null,
  400. 'mastery' => $templateData['mastery'] ?? null,
  401. 'parent_mastery_levels' => $templateData['parent_mastery_levels'] ?? null, // 新增:检查父节点掌握度
  402. 'questions_count' => count($templateData['questions'] ?? []),
  403. 'insights_count' => count($templateData['question_insights'] ?? []),
  404. 'recommendations_count' => count($templateData['recommendations'] ?? []),
  405. ]);
  406. // 渲染HTML
  407. $html = view('exam-analysis.pdf-report', $templateData)->render();
  408. if (!$html) {
  409. Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
  410. return null;
  411. }
  412. // 生成PDF
  413. $pdfBinary = $this->buildPdf($html);
  414. if (!$pdfBinary) {
  415. return null;
  416. }
  417. // 保存PDF
  418. $version = time();
  419. $path = "analysis_reports/{$paperId}_{$studentId}_{$version}.pdf";
  420. $url = $this->pdfStorageService->put($path, $pdfBinary);
  421. if (!$url) {
  422. Log::error('ExamPdfExportService: 保存学情PDF失败', ['path' => $path]);
  423. return null;
  424. }
  425. // 保存URL到数据库
  426. $this->saveAnalysisPdfUrl($paperId, $studentId, $recordId, $url);
  427. return $url;
  428. } catch (\Throwable $e) {
  429. Log::error('ExamPdfExportService: 生成学情分析PDF失败', [
  430. 'paper_id' => $paperId,
  431. 'student_id' => $studentId,
  432. 'record_id' => $recordId,
  433. 'error' => $e->getMessage(),
  434. 'exception' => get_class($e),
  435. 'trace' => $e->getTraceAsString(),
  436. ]);
  437. return null;
  438. }
  439. }
  440. /**
  441. * 渲染并存储试卷PDF
  442. */
  443. private function renderAndStoreExamPdf(
  444. string $paperId,
  445. bool $includeAnswer,
  446. string $suffix,
  447. bool $useGradingView = false
  448. ): ?string {
  449. // 放宽脚本执行时间
  450. if (function_exists('set_time_limit')) {
  451. @set_time_limit(240);
  452. }
  453. try {
  454. $html = $this->renderExamHtml($paperId, $includeAnswer, $useGradingView);
  455. if (!$html) {
  456. Log::error('ExamPdfExportService: 渲染HTML为空', [
  457. 'paper_id' => $paperId,
  458. 'include_answer' => $includeAnswer,
  459. 'use_grading_view' => $useGradingView,
  460. ]);
  461. return null;
  462. }
  463. $pdfBinary = $this->buildPdf($html);
  464. if (!$pdfBinary) {
  465. Log::error('ExamPdfExportService: buildPdf为空', [
  466. 'paper_id' => $paperId,
  467. 'include_answer' => $includeAnswer,
  468. 'use_grading_view' => $useGradingView,
  469. ]);
  470. return null;
  471. }
  472. $path = "exams/{$paperId}_{$suffix}.pdf";
  473. $url = $this->pdfStorageService->put($path, $pdfBinary);
  474. if (!$url) {
  475. Log::error('ExamPdfExportService: 保存PDF失败', ['path' => $path]);
  476. return null;
  477. }
  478. return $url;
  479. } catch (\Throwable $e) {
  480. Log::error('ExamPdfExportService: 生成PDF失败', [
  481. 'paper_id' => $paperId,
  482. 'suffix' => $suffix,
  483. 'error' => $e->getMessage(),
  484. 'exception' => get_class($e),
  485. 'trace' => $e->getTraceAsString(),
  486. ]);
  487. return null;
  488. }
  489. }
  490. /**
  491. * 渲染试卷HTML(重构版)
  492. */
  493. private function renderExamHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
  494. {
  495. // 直接构造请求URL,使用路由生成HTML
  496. $routeName = $useGradingView
  497. ? 'filament.admin.auth.intelligent-exam.grading'
  498. : 'filament.admin.auth.intelligent-exam.pdf';
  499. $url = route($routeName, ['paper_id' => $paperId, 'answer' => $includeAnswer ? 'true' : 'false']);
  500. // 使用HTTP客户端获取渲染后的HTML
  501. try {
  502. $response = Http::get($url);
  503. if ($response->successful()) {
  504. $html = $response->body();
  505. if (!empty(trim($html))) {
  506. return $this->ensureUtf8Html($html);
  507. } else {
  508. Log::warning('ExamPdfExportService: HTTP返回的HTML为空,使用备用方案', [
  509. 'paper_id' => $paperId,
  510. 'url' => $url,
  511. ]);
  512. }
  513. }
  514. } catch (\Exception $e) {
  515. Log::warning('ExamPdfExportService: 通过HTTP获取HTML失败,使用备用方案', [
  516. 'paper_id' => $paperId,
  517. 'error' => $e->getMessage(),
  518. ]);
  519. }
  520. // 备用方案:直接渲染视图(如果路由不可用)
  521. try {
  522. $paper = Paper::with('questions')->find($paperId);
  523. if (!$paper) {
  524. Log::error('ExamPdfExportService: 试卷不存在,备用方案无法渲染', [
  525. 'paper_id' => $paperId,
  526. 'include_answer' => $includeAnswer,
  527. 'use_grading_view' => $useGradingView,
  528. ]);
  529. return null;
  530. }
  531. // 检查试卷是否有题目
  532. if ($paper->questions->isEmpty()) {
  533. Log::error('ExamPdfExportService: 试卷没有题目数据', [
  534. 'paper_id' => $paperId,
  535. 'question_count' => 0,
  536. ]);
  537. return null;
  538. }
  539. $viewName = $useGradingView ? 'exam-pdf.grading' : 'exam-pdf.student';
  540. $html = view($viewName, compact('paper'))->render();
  541. if (empty(trim($html))) {
  542. Log::error('ExamPdfExportService: 视图渲染结果为空', [
  543. 'paper_id' => $paperId,
  544. 'view_name' => $viewName,
  545. 'question_count' => $paper->questions->count(),
  546. ]);
  547. return null;
  548. }
  549. return $this->ensureUtf8Html($html);
  550. } catch (\Exception $e) {
  551. Log::error('ExamPdfExportService: 备用方案渲染失败', [
  552. 'paper_id' => $paperId,
  553. 'error' => $e->getMessage(),
  554. 'trace' => $e->getTraceAsString(),
  555. ]);
  556. return null;
  557. }
  558. }
  559. /**
  560. * 构建分析数据(重构版)
  561. * 优先使用本地MySQL数据,减少API依赖
  562. */
  563. private function buildAnalysisData(string $paperId, string $studentId): ?array
  564. {
  565. // 【关键调试】确认方法被调用
  566. Log::warning('ExamPdfExportService: buildAnalysisData方法被调用了!', [
  567. 'paper_id' => $paperId,
  568. 'student_id' => $studentId,
  569. 'timestamp' => now()->toISOString()
  570. ]);
  571. $paper = Paper::with(['questions' => function ($query) {
  572. $query->orderBy('question_number')->orderBy('id');
  573. }])->find($paperId);
  574. if (!$paper) {
  575. Log::warning('ExamPdfExportService: 未找到试卷,将尝试仅基于分析数据生成PDF', [
  576. 'paper_id' => $paperId,
  577. 'student_id' => $studentId,
  578. ]);
  579. // 【修复】即使试卷不存在,也尝试基于分析数据生成PDF
  580. $paper = new \stdClass();
  581. $paper->paper_id = $paperId;
  582. $paper->paper_name = "学情分析报告_{$studentId}_{$paperId}";
  583. $paper->question_count = 0;
  584. $paper->total_score = 0;
  585. $paper->created_at = now();
  586. $paper->questions = collect();
  587. }
  588. $student = Student::find($studentId);
  589. $studentInfo = [
  590. 'id' => $student?->student_id ?? $studentId,
  591. 'name' => $student?->name ?? $studentId,
  592. 'grade' => $student?->grade ?? '未知年级',
  593. 'class' => $student?->class_name ?? '未知班级',
  594. ];
  595. // 【修改】直接从本地数据库获取分析数据(不再调用API)
  596. $analysisData = [];
  597. // 首先尝试从paper->analysis_id获取
  598. if (!empty($paper->analysis_id)) {
  599. Log::info('ExamPdfExportService: 从本地数据库获取试卷分析数据', [
  600. 'paper_id' => $paperId,
  601. 'student_id' => $studentId,
  602. 'analysis_id' => $paper->analysis_id
  603. ]);
  604. $analysisRecord = \DB::table('exam_analysis_results')
  605. ->where('id', $paper->analysis_id)
  606. ->where('student_id', $studentId)
  607. ->first();
  608. if ($analysisRecord && !empty($analysisRecord->analysis_data)) {
  609. $analysisData = json_decode($analysisRecord->analysis_data, true);
  610. Log::info('ExamPdfExportService: 成功获取本地分析数据(通过analysis_id)', [
  611. 'data_size' => strlen($analysisRecord->analysis_data)
  612. ]);
  613. } else {
  614. Log::warning('ExamPdfExportService: 未找到本地分析数据,将尝试其他方式', [
  615. 'paper_id' => $paperId,
  616. 'student_id' => $studentId,
  617. 'analysis_id' => $paper->analysis_id
  618. ]);
  619. }
  620. }
  621. // 如果没有analysis_id或未找到数据,直接从exam_analysis_results表查询
  622. if (empty($analysisData)) {
  623. Log::info('ExamPdfExportService: 直接从exam_analysis_results表查询分析数据', [
  624. 'paper_id' => $paperId,
  625. 'student_id' => $studentId
  626. ]);
  627. $analysisRecord = \DB::table('exam_analysis_results')
  628. ->where('paper_id', $paperId)
  629. ->where('student_id', $studentId)
  630. ->first();
  631. if ($analysisRecord && !empty($analysisRecord->analysis_data)) {
  632. $analysisData = json_decode($analysisRecord->analysis_data, true);
  633. Log::info('ExamPdfExportService: 成功获取本地分析数据(直接查询)', [
  634. 'data_size' => strlen($analysisRecord->analysis_data),
  635. 'question_count' => count($analysisData['question_analysis'] ?? [])
  636. ]);
  637. } else {
  638. Log::warning('ExamPdfExportService: 未找到任何分析数据,将使用空数据', [
  639. 'paper_id' => $paperId,
  640. 'student_id' => $studentId
  641. ]);
  642. }
  643. }
  644. // 【修复】优先使用analysisData中的knowledge_point_analysis数据
  645. $masteryData = [];
  646. $parentMasteryLevels = []; // 新增:父节点掌握度数据
  647. Log::info('ExamPdfExportService: 开始处理掌握度数据', [
  648. 'student_id' => $studentId,
  649. 'analysisData_keys' => array_keys($analysisData),
  650. 'has_knowledge_point_analysis' => !empty($analysisData['knowledge_point_analysis']),
  651. ]);
  652. if (!empty($analysisData['knowledge_point_analysis'])) {
  653. // 将knowledge_point_analysis转换为buildMasterySummary期望的格式
  654. foreach ($analysisData['knowledge_point_analysis'] as $kp) {
  655. $masteryData[] = [
  656. 'kp_code' => $kp['kp_id'] ?? null,
  657. 'kp_name' => $kp['kp_id'] ?? '未知知识点',
  658. 'mastery_level' => $kp['mastery_level'] ?? 0,
  659. 'mastery_change' => $kp['change'] ?? null,
  660. ];
  661. }
  662. // 【修复】基于所有兄弟节点历史数据计算父节点掌握度,并获取掌握度变化
  663. try {
  664. // 获取本次考试涉及的知识点代码列表
  665. $examKpCodes = array_column($masteryData, 'kp_code');
  666. Log::info('ExamPdfExportService: 本次考试涉及的知识点', [
  667. 'count' => count($examKpCodes),
  668. 'kp_codes' => $examKpCodes
  669. ]);
  670. // 获取上一个快照的数据(用于计算变化)
  671. // 如果没有其他试卷的记录,使用同一试卷的上一次快照
  672. $lastSnapshot = DB::connection('mysql')
  673. ->table('knowledge_point_mastery_snapshots')
  674. ->where('student_id', $studentId)
  675. ->where('paper_id', $paper->paper_id)
  676. ->where('snapshot_id', '!=', "snap_{$paper->paper_id}_" . date('YmdHis'))
  677. ->latest('snapshot_time')
  678. ->first();
  679. $previousMasteryData = [];
  680. if ($lastSnapshot) {
  681. $previousMasteryJson = json_decode($lastSnapshot->mastery_data, true);
  682. foreach ($previousMasteryJson as $kpCode => $data) {
  683. $previousMasteryData[$kpCode] = [
  684. 'current_mastery' => $data['current_mastery'] ?? 0,
  685. 'previous_mastery' => $data['previous_mastery'] ?? null,
  686. ];
  687. }
  688. Log::info('ExamPdfExportService: 获取到上一次快照数据', [
  689. 'snapshot_time' => $lastSnapshot->snapshot_time,
  690. 'kp_count' => count($previousMasteryData)
  691. ]);
  692. }
  693. // 为当前知识点添加变化数据
  694. foreach ($masteryData as &$item) {
  695. $kpCode = $item['kp_code'];
  696. if (isset($previousMasteryData[$kpCode])) {
  697. $previous = floatval($previousMasteryData[$kpCode]['previous_mastery'] ?? 0);
  698. $current = floatval($item['mastery_level']);
  699. $item['mastery_change'] = $current - $previous;
  700. }
  701. }
  702. unset($item); // 解除引用
  703. // 获取所有父节点掌握度
  704. $masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
  705. $allParentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? [];
  706. // 计算与本次考试相关的父节点掌握度(基于所有兄弟节点)
  707. $parentMasteryLevels = [];
  708. // 【修复】使用数据库查询正确匹配父子关系,而不是字符串前缀
  709. foreach ($allParentMasteryLevels as $parentKpCode => $parentMastery) {
  710. // 查询这个父节点的所有子节点
  711. $childNodes = DB::connection('mysql')
  712. ->table('knowledge_points')
  713. ->where('parent_kp_code', $parentKpCode)
  714. ->pluck('kp_code')
  715. ->toArray();
  716. // 检查是否有子节点在本次考试中出现
  717. $relevantChildren = array_intersect($examKpCodes, $childNodes);
  718. if (!empty($relevantChildren)) {
  719. // 【修复】计算父节点变化:基于所有子节点的平均变化
  720. $childChanges = [];
  721. foreach ($relevantChildren as $childKpCode) {
  722. $previousChild = $previousMasteryData[$childKpCode]['previous_mastery'] ?? null;
  723. $currentChild = null;
  724. foreach ($masteryData as $item) {
  725. if ($item['kp_code'] === $childKpCode) {
  726. $currentChild = $item['mastery_level'];
  727. break;
  728. }
  729. }
  730. if ($previousChild !== null && $currentChild !== null) {
  731. $childChanges[] = floatval($currentChild) - floatval($previousChild);
  732. }
  733. }
  734. $avgChange = !empty($childChanges) ? array_sum($childChanges) / count($childChanges) : null;
  735. // 获取父节点中文名称
  736. $parentKpInfo = DB::connection('mysql')
  737. ->table('knowledge_points')
  738. ->where('kp_code', $parentKpCode)
  739. ->first();
  740. $parentMasteryLevels[$parentKpCode] = [
  741. 'kp_code' => $parentKpCode,
  742. 'kp_name' => $parentKpInfo->name ?? $parentKpCode,
  743. 'mastery_level' => $parentMastery,
  744. 'mastery_percentage' => round($parentMastery * 100, 1),
  745. 'mastery_change' => $avgChange,
  746. 'children' => $relevantChildren,
  747. ];
  748. }
  749. }
  750. Log::info('ExamPdfExportService: 过滤后的父节点掌握度', [
  751. 'all_parent_count' => count($allParentMasteryLevels),
  752. 'filtered_parent_count' => count($parentMasteryLevels),
  753. 'filtered_codes' => array_keys($parentMasteryLevels)
  754. ]);
  755. } catch (\Exception $e) {
  756. Log::warning('ExamPdfExportService: 获取父节点掌握度失败', [
  757. 'error' => $e->getMessage()
  758. ]);
  759. }
  760. Log::info('ExamPdfExportService: 使用analysisData中的掌握度数据', [
  761. 'count' => count($masteryData),
  762. 'masteryData_sample' => !empty($masteryData) ? array_slice($masteryData, 0, 2) : []
  763. ]);
  764. } else {
  765. // 如果没有knowledge_point_analysis,使用MasteryCalculator获取多层级掌握度概览
  766. try {
  767. Log::info('ExamPdfExportService: 获取学生多层级掌握度概览', [
  768. 'student_id' => $studentId
  769. ]);
  770. $masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
  771. $masteryData = $masteryOverview['details'] ?? [];
  772. $parentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? []; // 获取父节点掌握度
  773. // 【修复】将对象数组转换为关联数组(避免 stdClass 对象访问错误)
  774. if (!empty($masteryData) && is_array($masteryData)) {
  775. $masteryData = array_map(function($item) {
  776. if (is_object($item)) {
  777. return [
  778. 'kp_code' => $item->kp_code ?? null,
  779. 'kp_name' => $item->kp_name ?? null,
  780. 'mastery_level' => floatval($item->mastery_level ?? 0),
  781. 'mastery_change' => $item->mastery_change !== null ? floatval($item->mastery_change) : null,
  782. ];
  783. }
  784. return $item;
  785. }, $masteryData);
  786. }
  787. // 【修复】获取快照数据以计算掌握度变化
  788. $lastSnapshot = DB::connection('mysql')
  789. ->table('knowledge_point_mastery_snapshots')
  790. ->where('student_id', $studentId)
  791. ->latest('snapshot_time')
  792. ->first();
  793. if ($lastSnapshot) {
  794. $previousMasteryJson = json_decode($lastSnapshot->mastery_data, true);
  795. foreach ($masteryData as &$item) {
  796. $kpCode = $item['kp_code'];
  797. if (isset($previousMasteryJson[$kpCode])) {
  798. $previous = floatval($previousMasteryJson[$kpCode]['previous_mastery'] ?? 0);
  799. $current = floatval($item['mastery_level']);
  800. $item['mastery_change'] = $current - $previous;
  801. }
  802. }
  803. unset($item);
  804. }
  805. Log::info('ExamPdfExportService: 成功获取多层级掌握度数据', [
  806. 'count' => count($masteryData),
  807. 'parent_count' => count($parentMasteryLevels)
  808. ]);
  809. } catch (\Exception $e) {
  810. Log::error('ExamPdfExportService: 获取掌握度数据失败', [
  811. 'student_id' => $studentId,
  812. 'error' => $e->getMessage()
  813. ]);
  814. }
  815. }
  816. // 【修改】使用本地方法获取学习路径推荐(替代API调用)
  817. $recommendations = [];
  818. try {
  819. Log::info('ExamPdfExportService: 获取学习路径推荐', [
  820. 'student_id' => $studentId
  821. ]);
  822. $learningPaths = $this->learningAnalyticsService->recommendLearningPaths($studentId, 3);
  823. $recommendations = $learningPaths['recommendations'] ?? [];
  824. Log::info('ExamPdfExportService: 成功获取学习路径推荐', [
  825. 'count' => count($recommendations)
  826. ]);
  827. } catch (\Exception $e) {
  828. Log::error('ExamPdfExportService: 获取学习路径推荐失败', [
  829. 'student_id' => $studentId,
  830. 'error' => $e->getMessage()
  831. ]);
  832. }
  833. // 获取知识点名称映射
  834. $kpNameMap = $this->buildKnowledgePointNameMap();
  835. Log::info('ExamPdfExportService: 获取知识点名称映射', [
  836. 'kpNameMap_count' => count($kpNameMap),
  837. 'kpNameMap_keys_sample' => !empty($kpNameMap) ? array_slice(array_keys($kpNameMap), 0, 5) : []
  838. ]);
  839. // 【修复】直接从MySQL数据库获取题目详情(不通过API)
  840. $questionDetails = $this->getQuestionDetailsFromMySQL($paper);
  841. // 处理题目数据
  842. $questions = $this->processQuestionsForReport($paper, $questionDetails, $kpNameMap);
  843. // 【关键调试】查看buildMasterySummary的返回结果
  844. $masterySummary = $this->buildMasterySummary($masteryData, $kpNameMap);
  845. Log::info('ExamPdfExportService: buildMasterySummary返回结果', [
  846. 'masteryData_count' => count($masteryData),
  847. 'kpNameMap_count' => count($kpNameMap),
  848. 'masterySummary_keys' => array_keys($masterySummary),
  849. 'masterySummary_items_count' => count($masterySummary['items'] ?? []),
  850. 'masterySummary_items_sample' => !empty($masterySummary['items']) ? array_slice($masterySummary['items'], 0, 2) : []
  851. ]);
  852. // 【修复】处理父节点掌握度数据:过滤零值、转换名称、构建层级关系
  853. $examKpCodes = array_column($masteryData, 'kp_code'); // 本次考试涉及的知识点
  854. $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examKpCodes);
  855. Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
  856. 'raw_count' => count($parentMasteryLevels),
  857. 'processed_count' => count($processedParentMastery),
  858. 'processed_sample' => !empty($processedParentMastery) ? array_slice($processedParentMastery, 0, 3) : []
  859. ]);
  860. return [
  861. 'paper' => [
  862. 'id' => $paper->paper_id,
  863. 'name' => $paper->paper_name,
  864. 'total_questions' => $paper->question_count,
  865. 'total_score' => $paper->total_score,
  866. 'created_at' => $paper->created_at,
  867. ],
  868. 'student' => $studentInfo,
  869. 'questions' => $questions,
  870. 'mastery' => $masterySummary,
  871. 'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
  872. 'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
  873. 'recommendations' => $recommendations,
  874. 'analysis_data' => $analysisData,
  875. ];
  876. }
  877. /**
  878. * 【修复】直接从PaperQuestion表获取题目详情(不通过API)
  879. */
  880. private function getQuestionDetailsFromMySQL(Paper $paper): array
  881. {
  882. $details = [];
  883. Log::info('ExamPdfExportService: 从PaperQuestion表查询题目详情', [
  884. 'paper_id' => $paper->paper_id,
  885. 'question_count' => $paper->questions->count()
  886. ]);
  887. foreach ($paper->questions as $pq) {
  888. try {
  889. // 【关键修复】直接从PaperQuestion对象获取solution和correct_answer
  890. $detail = [
  891. 'id' => $pq->question_id,
  892. 'content' => $pq->question_text,
  893. 'question_type' => $pq->question_type,
  894. 'answer' => $pq->correct_answer ?? null, // 【修复】从PaperQuestion获取正确答案
  895. 'solution' => $pq->solution ?? null, // 【修复】从PaperQuestion获取解题思路
  896. ];
  897. $details[(string) ($pq->question_id ?? $pq->id)] = $detail;
  898. Log::debug('ExamPdfExportService: 成功获取题目详情', [
  899. 'paper_question_id' => $pq->id,
  900. 'question_id' => $pq->question_id,
  901. 'has_answer' => !empty($pq->correct_answer),
  902. 'has_solution' => !empty($pq->solution),
  903. 'answer_preview' => $pq->correct_answer ? substr($pq->correct_answer, 0, 50) : null
  904. ]);
  905. } catch (\Throwable $e) {
  906. Log::error('ExamPdfExportService: 获取题目详情失败', [
  907. 'paper_question_id' => $pq->id,
  908. 'error' => $e->getMessage(),
  909. ]);
  910. }
  911. }
  912. return $details;
  913. }
  914. /**
  915. * 处理题目数据(用于报告)
  916. */
  917. private function processQuestionsForReport($paper, array $questionDetails, array $kpNameMap): array
  918. {
  919. $grouped = [
  920. 'choice' => [],
  921. 'fill' => [],
  922. 'answer' => [],
  923. ];
  924. // 【修复】处理空的试卷(questions可能不存在)
  925. $questions = $paper->questions ?? collect();
  926. if ($questions->isEmpty()) {
  927. Log::info('ExamPdfExportService: 试卷没有题目,返回空数组');
  928. return $grouped;
  929. }
  930. $sortedQuestions = $questions
  931. ->sortBy(function ($q, int $idx) {
  932. $number = $q->question_number ?? $idx + 1;
  933. return is_numeric($number) ? (float) $number : ($q->id ?? $idx);
  934. });
  935. foreach ($sortedQuestions as $idx => $question) {
  936. $kpCode = $question->knowledge_point ?? '';
  937. $kpName = $kpNameMap[$kpCode] ?? $kpCode ?: '未标注';
  938. // 【修复】直接从PaperQuestion对象获取solution和correct_answer
  939. $answer = $question->correct_answer ?? null; // 直接从PaperQuestion获取
  940. $solution = $question->solution ?? null; // 直接从PaperQuestion获取
  941. $detail = $questionDetails[(string) ($question->question_id ?? $question->id)] ?? [];
  942. $typeRaw = $question->question_type ?? ($detail['question_type'] ?? $detail['type'] ?? '');
  943. $normalizedType = $this->normalizeQuestionType($typeRaw);
  944. $number = $question->question_number ?? ($idx + 1);
  945. $payload = [
  946. 'question_number' => $number,
  947. 'question_text' => is_array($question->question_text)
  948. ? json_encode($question->question_text, JSON_UNESCAPED_UNICODE)
  949. : ($question->question_text ?? ''),
  950. 'question_type' => $normalizedType,
  951. 'knowledge_point' => $kpCode,
  952. 'knowledge_point_name' => $kpName,
  953. 'score' => $question->score,
  954. 'answer' => $answer, // 正确答案
  955. 'solution' => $solution, // 解题思路
  956. 'student_answer' => $question->student_answer ?? null, // 【新增】学生答案
  957. 'correct_answer' => $answer, // 【新增】正确答案
  958. 'is_correct' => $question->is_correct ?? null, // 【新增】判分结果
  959. 'score_obtained' => $question->score_obtained ?? null, // 【新增】得分
  960. ];
  961. $grouped[$normalizedType][] = $payload;
  962. // 【调试】记录题目数据
  963. Log::debug('ExamPdfExportService: 处理题目数据', [
  964. 'paper_question_id' => $question->id,
  965. 'question_id' => $question->question_id,
  966. 'has_answer' => !empty($answer),
  967. 'has_solution' => !empty($solution),
  968. 'answer_preview' => $answer ? substr($answer, 0, 50) : null
  969. ]);
  970. }
  971. $ordered = array_merge($grouped['choice'], $grouped['fill'], $grouped['answer']);
  972. // 按卷面顺序重新编号
  973. foreach ($ordered as $i => &$q) {
  974. $q['display_number'] = $i + 1;
  975. }
  976. unset($q);
  977. return $ordered;
  978. }
  979. /**
  980. * 构建PDF
  981. */
  982. private function buildPdf(string $html): ?string
  983. {
  984. Log::info('ExamPdfExportService: buildPdf开始', ['html_size' => strlen($html)]);
  985. $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_') . '.html';
  986. Log::info('ExamPdfExportService: 创建临时HTML文件', ['tmp_html' => $tmpHtml]);
  987. $utf8Html = $this->ensureUtf8Html($html);
  988. file_put_contents($tmpHtml, $utf8Html);
  989. Log::info('ExamPdfExportService: HTML文件已写入', ['tmp_html' => $tmpHtml, 'size' => filesize($tmpHtml)]);
  990. // 仅使用Chrome渲染
  991. Log::info('ExamPdfExportService: 开始调用renderWithChrome', ['tmp_html' => $tmpHtml]);
  992. $chromePdf = $this->renderWithChrome($tmpHtml);
  993. Log::info('ExamPdfExportService: renderWithChrome完成', [
  994. 'pdf_size' => $chromePdf ? strlen($chromePdf) : 0,
  995. 'pdf_success' => !empty($chromePdf)
  996. ]);
  997. @unlink($tmpHtml);
  998. return $chromePdf;
  999. }
  1000. /**
  1001. * 从URL生成PDF
  1002. */
  1003. private function buildPdfFromUrl(string $url): ?string
  1004. {
  1005. Log::info('ExamPdfExportService: buildPdfFromUrl开始', ['url' => $url]);
  1006. try {
  1007. $response = Http::get($url);
  1008. Log::info('ExamPdfExportService: HTTP请求完成', [
  1009. 'url' => $url,
  1010. 'status' => $response->status(),
  1011. 'successful' => $response->successful()
  1012. ]);
  1013. if (!$response->successful()) {
  1014. Log::error('ExamPdfExportService: 获取URL内容失败', [
  1015. 'url' => $url,
  1016. 'status_code' => $response->status()
  1017. ]);
  1018. return null;
  1019. }
  1020. $html = $response->body();
  1021. $htmlSize = strlen($html);
  1022. Log::info('ExamPdfExportService: 获取HTML内容成功', [
  1023. 'url' => $url,
  1024. 'html_size' => $htmlSize,
  1025. 'html_preview' => substr($html, 0, 100)
  1026. ]);
  1027. if (empty($html)) {
  1028. Log::error('ExamPdfExportService: URL返回内容为空', ['url' => $url]);
  1029. return null;
  1030. }
  1031. Log::info('ExamPdfExportService: 开始调用buildPdf', ['html_size' => $htmlSize]);
  1032. $pdfBinary = $this->buildPdf($html);
  1033. Log::info('ExamPdfExportService: buildPdf完成', [
  1034. 'pdf_size' => $pdfBinary ? strlen($pdfBinary) : 0,
  1035. 'pdf_success' => !empty($pdfBinary)
  1036. ]);
  1037. return $pdfBinary;
  1038. } catch (\Exception $e) {
  1039. Log::error('ExamPdfExportService: buildPdfFromUrl异常', [
  1040. 'url' => $url,
  1041. 'error' => $e->getMessage(),
  1042. 'trace' => $e->getTraceAsString()
  1043. ]);
  1044. return null;
  1045. }
  1046. }
  1047. /**
  1048. * 使用Chrome渲染PDF
  1049. */
  1050. private function renderWithChrome(string $htmlPath): ?string
  1051. {
  1052. $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_') . '.pdf';
  1053. $userDataDir = sys_get_temp_dir() . '/chrome-profile-' . uniqid();
  1054. $chromeBinary = $this->findChromeBinary();
  1055. if (!$chromeBinary) {
  1056. Log::error('ExamPdfExportService: 未找到可用的Chrome/Chromium');
  1057. return null;
  1058. }
  1059. // 设置运行时目录
  1060. $runtimeHome = sys_get_temp_dir() . '/chrome-home';
  1061. $runtimeXdg = sys_get_temp_dir() . '/chrome-xdg';
  1062. if (!File::exists($runtimeHome)) {
  1063. @File::makeDirectory($runtimeHome, 0755, true);
  1064. }
  1065. if (!File::exists($runtimeXdg)) {
  1066. @File::makeDirectory($runtimeXdg, 0755, true);
  1067. }
  1068. $process = new Process([
  1069. $chromeBinary,
  1070. '--headless',
  1071. '--disable-gpu',
  1072. '--no-sandbox',
  1073. '--disable-setuid-sandbox',
  1074. '--disable-dev-shm-usage',
  1075. '--no-zygote',
  1076. '--disable-features=VizDisplayCompositor',
  1077. '--disable-software-rasterizer',
  1078. '--disable-extensions',
  1079. '--disable-background-networking',
  1080. '--disable-component-update',
  1081. '--disable-client-side-phishing-detection',
  1082. '--disable-default-apps',
  1083. '--disable-domain-reliability',
  1084. '--disable-sync',
  1085. '--safebrowsing-disable-auto-update',
  1086. '--no-first-run',
  1087. '--no-default-browser-check',
  1088. '--disable-crash-reporter',
  1089. '--disable-print-preview',
  1090. '--disable-features=PrintHeaderFooter',
  1091. '--disable-features=TranslateUI',
  1092. '--disable-features=OptimizationHints',
  1093. '--disable-ipc-flooding-protection',
  1094. '--disable-background-networking',
  1095. '--disable-background-timer-throttling',
  1096. '--disable-backgrounding-occluded-windows',
  1097. '--disable-renderer-backgrounding',
  1098. '--disable-features=AudioServiceOutOfProcess',
  1099. '--user-data-dir=' . $userDataDir,
  1100. '--print-to-pdf=' . $tmpPdf,
  1101. '--print-to-pdf-no-header',
  1102. '--allow-file-access-from-files',
  1103. 'file://' . $htmlPath,
  1104. ], null, [
  1105. 'HOME' => $runtimeHome,
  1106. 'XDG_RUNTIME_DIR' => $runtimeXdg,
  1107. ]);
  1108. $process->setTimeout(60);
  1109. $killSignal = \defined('SIGKILL') ? \SIGKILL : 9;
  1110. try {
  1111. $startedAt = microtime(true);
  1112. $process->start();
  1113. $pdfGenerated = false;
  1114. // 轮询检测PDF是否生成
  1115. $pollStart = microtime(true);
  1116. $maxPollSeconds = 30;
  1117. while ($process->isRunning() && (microtime(true) - $pollStart) < $maxPollSeconds) {
  1118. if (file_exists($tmpPdf) && filesize($tmpPdf) > 0) {
  1119. $pdfGenerated = true;
  1120. $process->stop(5, $killSignal);
  1121. break;
  1122. }
  1123. usleep(200_000);
  1124. }
  1125. if ($process->isRunning()) {
  1126. $process->stop(5, $killSignal);
  1127. }
  1128. $process->wait();
  1129. } catch (ProcessTimedOutException|ProcessSignaledException $e) {
  1130. if ($process->isRunning()) {
  1131. $process->stop(5, $killSignal);
  1132. }
  1133. return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, $startedAt);
  1134. } catch (\Throwable $e) {
  1135. if ($process->isRunning()) {
  1136. $process->stop(5, $killSignal);
  1137. }
  1138. return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
  1139. }
  1140. return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
  1141. }
  1142. /**
  1143. * 处理Chrome进程结果
  1144. */
  1145. private function handleChromeProcessResult(string $tmpPdf, string $userDataDir, Process $process, ?float $startedAt): ?string
  1146. {
  1147. $pdfExists = file_exists($tmpPdf);
  1148. $pdfSize = $pdfExists ? filesize($tmpPdf) : null;
  1149. if (!$process->isSuccessful()) {
  1150. if ($pdfExists && $pdfSize > 0) {
  1151. Log::warning('ExamPdfExportService: Chrome进程异常但生成了PDF', [
  1152. 'exit_code' => $process->getExitCode(),
  1153. 'tmp_pdf_size' => $pdfSize,
  1154. ]);
  1155. } else {
  1156. Log::error('ExamPdfExportService: Chrome渲染失败', [
  1157. 'exit_code' => $process->getExitCode(),
  1158. 'error' => $process->getErrorOutput(),
  1159. ]);
  1160. @unlink($tmpPdf);
  1161. File::deleteDirectory($userDataDir);
  1162. return null;
  1163. }
  1164. }
  1165. $pdfBinary = $pdfExists ? file_get_contents($tmpPdf) : null;
  1166. @unlink($tmpPdf);
  1167. File::deleteDirectory($userDataDir);
  1168. return $pdfBinary ?: null;
  1169. }
  1170. /**
  1171. * 查找Chrome二进制文件
  1172. */
  1173. private function findChromeBinary(): ?string
  1174. {
  1175. $candidates = [
  1176. env('PDF_CHROME_BINARY'),
  1177. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  1178. '/usr/bin/google-chrome-stable',
  1179. '/usr/bin/google-chrome',
  1180. '/usr/bin/chromium-browser',
  1181. '/usr/bin/chromium',
  1182. ];
  1183. foreach ($candidates as $path) {
  1184. if ($path && is_file($path) && is_executable($path)) {
  1185. return $path;
  1186. }
  1187. }
  1188. return null;
  1189. }
  1190. /**
  1191. * 确保HTML为UTF-8编码
  1192. */
  1193. private function ensureUtf8Html(string $html): string
  1194. {
  1195. $meta = '<meta charset="UTF-8">';
  1196. if (stripos($html, '<head>') !== false) {
  1197. return preg_replace('/<head>/i', "<head>{$meta}", $html, 1);
  1198. }
  1199. return $meta . $html;
  1200. }
  1201. /**
  1202. * 构建知识点名称映射
  1203. */
  1204. private function buildKnowledgePointNameMap(): array
  1205. {
  1206. try {
  1207. $options = $this->questionServiceApi->getKnowledgePointOptions();
  1208. return $options ?: [];
  1209. } catch (\Throwable $e) {
  1210. Log::warning('ExamPdfExportService: 获取知识点名称失败', [
  1211. 'error' => $e->getMessage(),
  1212. ]);
  1213. return [];
  1214. }
  1215. }
  1216. /**
  1217. * 构建掌握度摘要
  1218. */
  1219. private function buildMasterySummary(array $masteryData, array $kpNameMap): array
  1220. {
  1221. Log::info('ExamPdfExportService: buildMasterySummary开始处理', [
  1222. 'masteryData_count' => count($masteryData),
  1223. 'kpNameMap_count' => count($kpNameMap)
  1224. ]);
  1225. $items = [];
  1226. $total = 0;
  1227. $count = 0;
  1228. foreach ($masteryData as $row) {
  1229. $code = $row['kp_code'] ?? null;
  1230. // 【修复】使用kpNameMap转换名称为友好显示名
  1231. $name = $kpNameMap[$code] ?? $row['kp_name'] ?? $code ?: '未知知识点';
  1232. $level = (float)($row['mastery_level'] ?? 0);
  1233. $delta = $row['mastery_change'] ?? null;
  1234. $items[] = [
  1235. 'kp_code' => $code,
  1236. 'kp_name' => $name,
  1237. 'mastery_level' => $level,
  1238. 'mastery_change' => $delta,
  1239. ];
  1240. $total += $level;
  1241. $count++;
  1242. }
  1243. $average = $count > 0 ? round($total / $count, 2) : null;
  1244. // 按掌握度从低到高排序
  1245. if (!empty($items)) {
  1246. usort($items, fn($a, $b) => ($a['mastery_level'] <=> $b['mastery_level']));
  1247. }
  1248. $result = [
  1249. 'items' => $items,
  1250. 'average' => $average,
  1251. 'weak_list' => array_slice($items, 0, 5),
  1252. ];
  1253. Log::info('ExamPdfExportService: buildMasterySummary完成', [
  1254. 'total_count' => $count,
  1255. 'items_count' => count($items)
  1256. ]);
  1257. return $result;
  1258. }
  1259. /**
  1260. * 标准化题型
  1261. */
  1262. private function normalizeQuestionType(string $type): string
  1263. {
  1264. $t = strtolower(trim($type));
  1265. return match (true) {
  1266. str_contains($t, 'choice') || str_contains($t, '选择') => 'choice',
  1267. str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空') => 'fill',
  1268. default => 'answer',
  1269. };
  1270. }
  1271. /**
  1272. * 保存PDF URL到数据库
  1273. */
  1274. private function savePdfUrlToDatabase(string $paperId, string $field, string $url): void
  1275. {
  1276. try {
  1277. $paper = Paper::where('paper_id', $paperId)->first();
  1278. if ($paper) {
  1279. $paper->update([$field => $url]);
  1280. Log::info('ExamPdfExportService: PDF URL已写入数据库', [
  1281. 'paper_id' => $paperId,
  1282. 'field' => $field,
  1283. 'url' => $url,
  1284. ]);
  1285. }
  1286. } catch (\Throwable $e) {
  1287. Log::error('ExamPdfExportService: 写入PDF URL失败', [
  1288. 'paper_id' => $paperId,
  1289. 'field' => $field,
  1290. 'error' => $e->getMessage(),
  1291. ]);
  1292. }
  1293. }
  1294. /**
  1295. * 保存学情分析PDF URL
  1296. */
  1297. private function saveAnalysisPdfUrl(string $paperId, string $studentId, ?string $recordId, string $url): void
  1298. {
  1299. try {
  1300. if ($recordId) {
  1301. // OCR记录
  1302. $ocrRecord = \App\Models\OCRRecord::find($recordId);
  1303. if ($ocrRecord) {
  1304. $ocrRecord->update(['analysis_pdf_url' => $url]);
  1305. Log::info('ExamPdfExportService: OCR记录学情分析PDF URL已写入数据库', [
  1306. 'record_id' => $recordId,
  1307. 'paper_id' => $paperId,
  1308. 'student_id' => $studentId,
  1309. 'url' => $url,
  1310. ]);
  1311. }
  1312. } else {
  1313. // 【修复】同时更新 exam_analysis_results 表和分析报告表
  1314. $updated = \DB::connection('mysql')->table('exam_analysis_results')
  1315. ->where('student_id', $studentId)
  1316. ->where('paper_id', $paperId)
  1317. ->update([
  1318. 'analysis_pdf_url' => $url,
  1319. 'updated_at' => now(),
  1320. ]);
  1321. if ($updated) {
  1322. Log::info('ExamPdfExportService: 学情分析PDF URL已写入exam_analysis_results表', [
  1323. 'student_id' => $studentId,
  1324. 'paper_id' => $paperId,
  1325. 'url' => $url,
  1326. 'updated_rows' => $updated,
  1327. ]);
  1328. } else {
  1329. Log::warning('ExamPdfExportService: 未找到要更新的学情分析记录', [
  1330. 'student_id' => $studentId,
  1331. 'paper_id' => $paperId,
  1332. ]);
  1333. }
  1334. // 学生记录 - 使用新的 student_reports 表(备用)
  1335. \App\Models\StudentReport::updateOrCreate(
  1336. [
  1337. 'student_id' => $studentId,
  1338. 'report_type' => 'exam_analysis',
  1339. 'paper_id' => $paperId,
  1340. ],
  1341. [
  1342. 'pdf_url' => $url,
  1343. 'generation_status' => 'completed',
  1344. 'generated_at' => now(),
  1345. 'updated_at' => now(),
  1346. ]
  1347. );
  1348. Log::info('ExamPdfExportService: 学生学情报告PDF URL已保存到student_reports表(备用)', [
  1349. 'student_id' => $studentId,
  1350. 'paper_id' => $paperId,
  1351. 'url' => $url,
  1352. ]);
  1353. }
  1354. } catch (\Throwable $e) {
  1355. Log::error('ExamPdfExportService: 写入学情分析PDF URL失败', [
  1356. 'paper_id' => $paperId,
  1357. 'student_id' => $studentId,
  1358. 'record_id' => $recordId,
  1359. 'error' => $e->getMessage(),
  1360. ]);
  1361. }
  1362. }
  1363. /**
  1364. * 【修复】处理父节点掌握度数据
  1365. * 1. 过滤掉掌握度为0或null的父节点
  1366. * 2. 将kp_code转换为友好的kp_name
  1367. * 3. 构建父子层级关系(只显示本次考试相关的子节点)
  1368. */
  1369. private function processParentMasteryLevels(array $parentMasteryLevels, array $kpNameMap, array $examKpCodes = []): array
  1370. {
  1371. $processed = [];
  1372. foreach ($parentMasteryLevels as $kpCode => $masteryData) {
  1373. // 兼容不同数据结构:可能是数组或数字
  1374. $masteryLevel = is_array($masteryData) ? ($masteryData['mastery_level'] ?? 0) : $masteryData;
  1375. $masteryChange = is_array($masteryData) ? ($masteryData['mastery_change'] ?? null) : null;
  1376. // 过滤零值和空值
  1377. if ($masteryLevel === null || $masteryLevel === 0.0 || $masteryLevel <= 0.001) {
  1378. continue;
  1379. }
  1380. // 获取友好名称
  1381. $kpName = $kpNameMap[$kpCode] ?? $kpCode;
  1382. // 构建父节点数据,包含子节点信息(只显示本次考试相关的)
  1383. $processed[$kpCode] = [
  1384. 'kp_code' => $kpCode,
  1385. 'kp_name' => $kpName,
  1386. 'mastery_level' => round(floatval($masteryLevel), 4),
  1387. 'mastery_percentage' => round(floatval($masteryLevel) * 100, 2),
  1388. 'mastery_change' => $masteryChange !== null ? round(floatval($masteryChange), 4) : null,
  1389. // 【修复】只获取本次考试涉及的子节点
  1390. 'children' => $this->getChildKnowledgePoints($kpCode, $kpNameMap, $examKpCodes),
  1391. 'level' => $this->calculateKnowledgePointLevel($kpCode),
  1392. ];
  1393. }
  1394. // 按掌握度降序排序
  1395. uasort($processed, function($a, $b) {
  1396. return $b['mastery_level'] <=> $a['mastery_level'];
  1397. });
  1398. return $processed;
  1399. }
  1400. /**
  1401. * 【修复】获取子知识点列表(只返回本次考试涉及的)
  1402. */
  1403. private function getChildKnowledgePoints(string $parentKpCode, array $kpNameMap, array $examKpCodes = []): array
  1404. {
  1405. $children = [];
  1406. try {
  1407. $childCodes = DB::connection('mysql')
  1408. ->table('knowledge_points')
  1409. ->where('parent_kp_code', $parentKpCode)
  1410. ->pluck('kp_code')
  1411. ->toArray();
  1412. foreach ($childCodes as $childCode) {
  1413. // 只包含本次考试涉及的知识点
  1414. if (in_array($childCode, $examKpCodes)) {
  1415. $children[] = [
  1416. 'kp_code' => $childCode,
  1417. 'kp_name' => $kpNameMap[$childCode] ?? $childCode,
  1418. ];
  1419. }
  1420. }
  1421. } catch (\Exception $e) {
  1422. Log::warning('获取子知识点失败', [
  1423. 'parent_kp_code' => $parentKpCode,
  1424. 'error' => $e->getMessage(),
  1425. ]);
  1426. }
  1427. return $children;
  1428. }
  1429. /**
  1430. * 计算知识点层级深度
  1431. */
  1432. private function calculateKnowledgePointLevel(string $kpCode): int
  1433. {
  1434. // 根据kp_code前缀判断层级深度
  1435. // 例如: M (1级) -> M01 (2级) -> M01A (3级)
  1436. if (preg_match('/^[A-Z]+$/', $kpCode)) {
  1437. return 1; // 一级分类,如 M, S, E, G
  1438. } elseif (preg_match('/^[A-Z]+\d+$/', $kpCode)) {
  1439. return 2; // 二级分类,如 M01, S02
  1440. } elseif (preg_match('/^[A-Z]+\d+[A-Z]+$/', $kpCode)) {
  1441. return 3; // 三级分类,如 M01A, S02B
  1442. } elseif (preg_match('/^[A-Z]+\d+[A-Z]+\d+$/', $kpCode)) {
  1443. return 4; // 四级分类,如 M01A1
  1444. }
  1445. return 1; // 默认一级
  1446. }
  1447. /**
  1448. * 构建题目数据(用于PDF生成)
  1449. */
  1450. private function buildQuestionsData(Paper $paper): array
  1451. {
  1452. $paperQuestions = $paper->questions()->orderBy('question_number')->get();
  1453. $questionsData = [];
  1454. foreach ($paperQuestions as $pq) {
  1455. $questionsData[] = [
  1456. 'id' => $pq->question_bank_id,
  1457. 'kp_code' => $pq->knowledge_point,
  1458. 'question_type' => $pq->question_type ?? 'answer',
  1459. 'stem' => $pq->question_text ?? '题目内容缺失',
  1460. 'solution' => $pq->solution ?? '',
  1461. 'answer' => $pq->correct_answer ?? '',
  1462. 'difficulty' => $pq->difficulty ?? 0.5,
  1463. 'score' => $pq->score ?? 5,
  1464. 'tags' => '',
  1465. 'content' => $pq->question_text ?? '',
  1466. ];
  1467. }
  1468. // 获取完整题目详情
  1469. if (!empty($questionsData)) {
  1470. $questionIds = array_column($questionsData, 'id');
  1471. $questionsResponse = $this->questionServiceApi->getQuestionsByIds($questionIds);
  1472. $responseData = $questionsResponse['data'] ?? [];
  1473. if (!empty($responseData)) {
  1474. $responseDataMap = [];
  1475. foreach ($responseData as $respQ) {
  1476. $responseDataMap[$respQ['id']] = $respQ;
  1477. }
  1478. $questionsData = array_map(function($q) use ($responseDataMap) {
  1479. if (isset($responseDataMap[$q['id']])) {
  1480. $apiData = $responseDataMap[$q['id']];
  1481. $q['stem'] = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? '';
  1482. $q['content'] = $q['stem'];
  1483. $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? '';
  1484. $q['solution'] = $apiData['solution'] ?? $q['solution'] ?? '';
  1485. $q['tags'] = $apiData['tags'] ?? $q['tags'] ?? '';
  1486. $q['options'] = $apiData['options'] ?? [];
  1487. }
  1488. return $q;
  1489. }, $questionsData);
  1490. }
  1491. }
  1492. // 按题型分类
  1493. $classified = ['choice' => [], 'fill' => [], 'answer' => []];
  1494. foreach ($questionsData as $q) {
  1495. $type = $this->determineQuestionType($q);
  1496. $classified[$type][] = (object) $q;
  1497. }
  1498. return $classified;
  1499. }
  1500. /**
  1501. * 获取学生信息
  1502. */
  1503. private function getStudentInfo(?string $studentId): array
  1504. {
  1505. if (!$studentId) {
  1506. return [
  1507. 'name' => '未知学生',
  1508. 'grade' => '未知年级',
  1509. 'class' => '未知班级'
  1510. ];
  1511. }
  1512. try {
  1513. $student = DB::table('students')
  1514. ->where('student_id', $studentId)
  1515. ->first();
  1516. if ($student) {
  1517. return [
  1518. 'name' => $student->name ?? $studentId,
  1519. 'grade' => $student->grade ?? '未知',
  1520. 'class' => $student->class ?? '未知'
  1521. ];
  1522. }
  1523. } catch (\Exception $e) {
  1524. Log::warning('获取学生信息失败', [
  1525. 'student_id' => $studentId,
  1526. 'error' => $e->getMessage()
  1527. ]);
  1528. }
  1529. return [
  1530. 'name' => $studentId,
  1531. 'grade' => '未知',
  1532. 'class' => '未知'
  1533. ];
  1534. }
  1535. /**
  1536. * 获取教师信息
  1537. */
  1538. private function getTeacherInfo(?string $teacherId): array
  1539. {
  1540. if (!$teacherId) {
  1541. return [
  1542. 'name' => '未知老师',
  1543. 'subject' => '数学'
  1544. ];
  1545. }
  1546. try {
  1547. $teacher = DB::table('teachers')
  1548. ->where('teacher_id', $teacherId)
  1549. ->first();
  1550. if ($teacher) {
  1551. return [
  1552. 'name' => $teacher->name ?? $teacherId,
  1553. 'subject' => $teacher->subject ?? '数学'
  1554. ];
  1555. }
  1556. } catch (\Exception $e) {
  1557. Log::warning('获取教师信息失败', [
  1558. 'teacher_id' => $teacherId,
  1559. 'error' => $e->getMessage()
  1560. ]);
  1561. }
  1562. return [
  1563. 'name' => $teacherId,
  1564. 'subject' => '数学'
  1565. ];
  1566. }
  1567. /**
  1568. * 判断题目类型
  1569. */
  1570. private function determineQuestionType(array $question): string
  1571. {
  1572. $stem = $question['stem'] ?? $question['content'] ?? '';
  1573. $tags = $question['tags'] ?? '';
  1574. // 根据题干内容判断选择题
  1575. if (is_string($stem)) {
  1576. $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem) || preg_match('/^A[\.\s]/', $stem);
  1577. $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem) || preg_match('/^B[\.\s]/', $stem);
  1578. $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem) || preg_match('/^C[\.\s]/', $stem);
  1579. $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem) || preg_match('/^D[\.\s]/', $stem);
  1580. $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0);
  1581. if ($optionCount >= 2) {
  1582. return 'choice';
  1583. }
  1584. // 检查是否有填空标记
  1585. if (preg_match('/(\s*)|\(\s*\)/', $stem)) {
  1586. return 'fill';
  1587. }
  1588. }
  1589. // 根据已有类型字段判断
  1590. if (!empty($question['question_type'])) {
  1591. $type = strtolower(trim($question['question_type']));
  1592. if (in_array($type, ['choice', '选择题'])) return 'choice';
  1593. if (in_array($type, ['fill', '填空题'])) return 'fill';
  1594. if (in_array($type, ['answer', '解答题'])) return 'answer';
  1595. }
  1596. // 默认返回解答题
  1597. return 'answer';
  1598. }
  1599. }