MistakeBookService.php 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  1. <?php
  2. namespace App\Services;
  3. use App\Models\MistakeRecord;
  4. use App\Models\Student;
  5. use App\Models\Teacher;
  6. use Illuminate\Support\Arr;
  7. use Illuminate\Support\Facades\Cache;
  8. use Illuminate\Support\Facades\Log;
  9. use Illuminate\Support\Facades\Http;
  10. class MistakeBookService
  11. {
  12. protected string $learningAnalyticsBase;
  13. protected int $timeout;
  14. // 缓存时间(秒)
  15. const CACHE_TTL_LIST = 60; // 5分钟
  16. const CACHE_TTL_SUMMARY = 600; // 10分钟
  17. const CACHE_TTL_PATTERNS = 1800; // 30分钟
  18. public function __construct(
  19. ?string $learningAnalyticsBase = null,
  20. ?int $timeout = null
  21. ) {
  22. // 已迁移到本地,不再使用LearningAnalytics
  23. $this->learningAnalyticsBase = '';
  24. $this->timeout = 20;
  25. }
  26. /**
  27. * 新增错题
  28. */
  29. public function createMistake(array $payload): array
  30. {
  31. $studentId = $payload['student_id'] ?? null;
  32. $questionId = $payload['question_id'] ?? null;
  33. $paperId = $payload['paper_id'] ?? null;
  34. $myAnswer = $payload['my_answer'] ?? null;
  35. $correctAnswer = $payload['correct_answer'] ?? null;
  36. $questionText = $payload['question_text'] ?? null;
  37. $knowledgePoint = $payload['knowledge_point'] ?? null;
  38. $explanation = $payload['explanation'] ?? null;
  39. $kpIds = $payload['kp_ids'] ?? null;
  40. $source = $payload['source'] ?? MistakeRecord::SOURCE_PRACTICE;
  41. $happenedAt = $payload['happened_at'] ?? now();
  42. if (!$studentId) {
  43. throw new \InvalidArgumentException('学生ID不能为空');
  44. }
  45. // 使用事务确保数据一致性
  46. return \DB::transaction(function () use (
  47. $studentId, $questionId, $paperId, $myAnswer, $correctAnswer,
  48. $questionText, $knowledgePoint, $explanation, $kpIds, $source, $happenedAt
  49. ) {
  50. // 检查是否已存在相同错题(避免重复)
  51. $query = MistakeRecord::where('student_id', $studentId)
  52. ->where('source', $source)
  53. ->where('created_at', '>=', now()->subDay());
  54. // 如果有 question_id,用它去重;否则用 question_text
  55. if ($questionId) {
  56. $query->where('question_id', $questionId);
  57. } elseif ($questionText) {
  58. $query->where('question_text', $questionText);
  59. }
  60. $existingMistake = $query->first();
  61. if ($existingMistake) {
  62. // 合并知识点到已有记录中
  63. if ($kpIds) {
  64. $existingKpIds = $existingMistake->kp_ids ?? [];
  65. $newKpIds = is_array($kpIds) ? $kpIds : [$kpIds];
  66. // 合并并去重
  67. $mergedKpIds = array_values(array_unique(array_merge($existingKpIds, $newKpIds)));
  68. // 更新记录(仅更新 kp_ids)
  69. $existingMistake->update([
  70. 'kp_ids' => $mergedKpIds,
  71. ]);
  72. }
  73. return [
  74. 'duplicate' => true,
  75. 'mistake_id' => $existingMistake->id,
  76. 'message' => '错题已存在,已合并知识点',
  77. ];
  78. }
  79. // 创建错题记录(合并知识点到数组)
  80. $mistake = MistakeRecord::create([
  81. 'student_id' => $studentId,
  82. 'question_id' => $questionId,
  83. 'paper_id' => $paperId,
  84. 'student_answer' => $myAnswer,
  85. 'correct_answer' => $correctAnswer,
  86. 'question_text' => $questionText,
  87. 'knowledge_point' => $knowledgePoint,
  88. 'explanation' => $explanation,
  89. 'kp_ids' => is_array($kpIds) ? $kpIds : ($kpIds ? [$kpIds] : null), // 确保是数组
  90. 'source' => $source,
  91. 'created_at' => $happenedAt,
  92. 'review_status' => MistakeRecord::REVIEW_STATUS_PENDING,
  93. 'review_count' => 0,
  94. ]);
  95. // 清除相关缓存
  96. $this->clearCache($studentId);
  97. return [
  98. 'duplicate' => false,
  99. 'mistake_id' => $mistake->id,
  100. 'created_at' => $mistake->created_at,
  101. ];
  102. });
  103. }
  104. /**
  105. * 获取错题列表
  106. */
  107. public function listMistakes(array $params = []): array
  108. {
  109. $studentId = $params['student_id'] ?? null;
  110. $page = (int) ($params['page'] ?? 1);
  111. $perPage = (int) ($params['per_page'] ?? 20);
  112. if (!$studentId) {
  113. return [
  114. 'data' => [],
  115. 'meta' => ['total' => 0, 'page' => $page, 'per_page' => $perPage],
  116. 'statistics' => [ // ✅ 无 student_id 时也返回空统计
  117. 'total' => 0,
  118. 'pending' => 0,
  119. 'reviewed' => 0,
  120. 'mastered' => 0,
  121. 'favorites' => 0,
  122. 'in_retry_list' => 0,
  123. 'this_week' => 0,
  124. 'mastery_rate' => 0.0,
  125. ],
  126. ];
  127. }
  128. // 构建缓存键
  129. $cacheKey = $this->buildCacheKey('list', $params);
  130. // 尝试从缓存获取
  131. if (Cache::has($cacheKey)) {
  132. return Cache::get($cacheKey);
  133. }
  134. try {
  135. $query = MistakeRecord::forStudent($studentId)
  136. ->with(['student']) // 预加载学生信息
  137. ->orderByDesc('created_at');
  138. // 应用筛选条件
  139. $this->applyFilters($query, $params);
  140. // 获取总数
  141. $total = $query->count();
  142. // 分页获取数据
  143. $mistakes = $query->skip(($page - 1) * $perPage)
  144. ->take($perPage)
  145. ->get();
  146. // 转换数据格式
  147. $data = $mistakes->map(function ($mistake) {
  148. return $this->transformMistakeRecord($mistake, false);
  149. })->toArray();
  150. // 获取统计信息
  151. $summary = $this->summarize($studentId);
  152. $result = [
  153. 'data' => $data,
  154. 'meta' => [
  155. 'total' => $total,
  156. 'page' => $page,
  157. 'per_page' => $perPage,
  158. 'last_page' => (int) ceil($total / $perPage),
  159. ],
  160. 'statistics' => $summary, // ✅ 添加统计信息
  161. ];
  162. // 缓存结果
  163. Cache::put($cacheKey, $result, self::CACHE_TTL_LIST);
  164. return $result;
  165. } catch (\Throwable $e) {
  166. Log::error('获取错题列表失败', [
  167. 'student_id' => $studentId,
  168. 'error' => $e->getMessage(),
  169. 'params' => $params,
  170. ]);
  171. return [
  172. 'data' => [],
  173. 'meta' => ['total' => 0, 'page' => $page, 'per_page' => $perPage],
  174. 'statistics' => [ // ✅ 错误时也返回空统计
  175. 'total' => 0,
  176. 'pending' => 0,
  177. 'reviewed' => 0,
  178. 'mastered' => 0,
  179. 'favorites' => 0,
  180. 'in_retry_list' => 0,
  181. 'this_week' => 0,
  182. 'mastery_rate' => 0.0,
  183. ],
  184. ];
  185. }
  186. }
  187. /**
  188. * 获取错题详情
  189. */
  190. public function getMistakeDetail(string $mistakeId, ?string $studentId = null): array
  191. {
  192. try {
  193. $query = MistakeRecord::with(['student']);
  194. if ($studentId) {
  195. $query->forStudent($studentId);
  196. }
  197. $mistake = $query->find($mistakeId);
  198. if (!$mistake) {
  199. return [];
  200. }
  201. return $this->transformMistakeRecord($mistake, true);
  202. } catch (\Throwable $e) {
  203. Log::error('获取错题详情失败', [
  204. 'mistake_id' => $mistakeId,
  205. 'student_id' => $studentId,
  206. 'error' => $e->getMessage(),
  207. ]);
  208. return [];
  209. }
  210. }
  211. /**
  212. * 获取错题统计概要
  213. */
  214. public function summarize(string $studentId): array
  215. {
  216. $cacheKey = "mistake_book:summary:{$studentId}";
  217. return Cache::remember($cacheKey, self::CACHE_TTL_SUMMARY, function () use ($studentId) {
  218. return MistakeRecord::getSummary($studentId);
  219. });
  220. }
  221. /**
  222. * 获取错误模式分析
  223. */
  224. public function getMistakePatterns(string $studentId): array
  225. {
  226. $cacheKey = "mistake_book:patterns:{$studentId}";
  227. return Cache::remember($cacheKey, self::CACHE_TTL_PATTERNS, function () use ($studentId) {
  228. return MistakeRecord::getMistakePatterns($studentId);
  229. });
  230. }
  231. /**
  232. * 收藏/取消收藏错题
  233. */
  234. public function toggleFavorite(string $mistakeId, bool $favorite = true): bool
  235. {
  236. try {
  237. $mistake = MistakeRecord::find($mistakeId);
  238. if (!$mistake) {
  239. return false;
  240. }
  241. $mistake->update(['is_favorite' => $favorite]);
  242. // 清除缓存
  243. $this->clearCache($mistake->student_id);
  244. return true;
  245. } catch (\Throwable $e) {
  246. Log::error('收藏错题失败', [
  247. 'mistake_id' => $mistakeId,
  248. 'favorite' => $favorite,
  249. 'error' => $e->getMessage(),
  250. ]);
  251. return false;
  252. }
  253. }
  254. /**
  255. * 标记已复习
  256. */
  257. public function markReviewed(string $mistakeId): bool
  258. {
  259. try {
  260. $mistake = MistakeRecord::find($mistakeId);
  261. if (!$mistake) {
  262. return false;
  263. }
  264. $mistake->markAsReviewed();
  265. // 清除缓存
  266. $this->clearCache($mistake->student_id);
  267. return true;
  268. } catch (\Throwable $e) {
  269. Log::error('标记已复习失败', [
  270. 'mistake_id' => $mistakeId,
  271. 'error' => $e->getMessage(),
  272. ]);
  273. return false;
  274. }
  275. }
  276. /**
  277. * 修改复习状态
  278. */
  279. public function updateReviewStatus(string $mistakeId, string $action = 'increment', bool $forceReview = false): array
  280. {
  281. try {
  282. $mistake = MistakeRecord::find($mistakeId);
  283. if (!$mistake) {
  284. return [
  285. 'success' => false,
  286. 'error' => '错题记录不存在',
  287. ];
  288. }
  289. match ($action) {
  290. 'increment' => $mistake->markAsReviewed(),
  291. 'mastered' => $mistake->markAsMastered(),
  292. 'reset' => $mistake->update([
  293. 'review_status' => MistakeRecord::REVIEW_STATUS_PENDING,
  294. 'review_count' => 0,
  295. 'mastery_level' => null,
  296. 'reviewed_at' => null,
  297. 'next_review_at' => null,
  298. ]),
  299. default => throw new \InvalidArgumentException('无效的操作类型'),
  300. };
  301. // ⚠️ 重要:重新加载模型数据以获取最新状态
  302. $mistake->refresh();
  303. return [
  304. 'success' => true,
  305. 'mistake_id' => $mistakeId,
  306. 'review_status' => $mistake->review_status,
  307. 'review_count' => $mistake->review_count,
  308. 'reviewed_at' => $mistake->reviewed_at?->toISOString(),
  309. 'next_review_at' => $mistake->next_review_at?->toISOString(),
  310. 'mastery_level' => $mistake->mastery_level,
  311. 'message' => '复习状态更新成功',
  312. ];
  313. } catch (\Throwable $e) {
  314. Log::error('更新复习状态失败', [
  315. 'mistake_id' => $mistakeId,
  316. 'action' => $action,
  317. 'error' => $e->getMessage(),
  318. ]);
  319. return [
  320. 'success' => false,
  321. 'error' => $e->getMessage(),
  322. ];
  323. }
  324. }
  325. /**
  326. * 获取复习状态
  327. */
  328. public function getReviewStatus(string $mistakeId): array
  329. {
  330. try {
  331. $mistake = MistakeRecord::find($mistakeId);
  332. if (!$mistake) {
  333. return [
  334. 'success' => false,
  335. 'error' => '错题记录不存在',
  336. ];
  337. }
  338. return [
  339. 'success' => true,
  340. 'mistake_id' => $mistakeId,
  341. 'review_status' => $mistake->review_status,
  342. 'review_count' => $mistake->review_count,
  343. 'reviewed_at' => $mistake->reviewed_at?->toISOString(),
  344. 'next_review_at' => $mistake->next_review_at?->toISOString(),
  345. 'mastery_level' => $mistake->mastery_level,
  346. 'force_review' => $mistake->force_review,
  347. ];
  348. } catch (\Throwable $e) {
  349. Log::error('获取复习状态失败', [
  350. 'mistake_id' => $mistakeId,
  351. 'error' => $e->getMessage(),
  352. ]);
  353. return [
  354. 'success' => false,
  355. 'error' => $e->getMessage(),
  356. ];
  357. }
  358. }
  359. /**
  360. * 增加复习次数
  361. */
  362. public function incrementReviewCount(string $mistakeId, bool $forceReview = false): array
  363. {
  364. return $this->updateReviewStatus($mistakeId, 'increment', $forceReview);
  365. }
  366. /**
  367. * 重置为强制复习状态
  368. */
  369. public function resetReviewStatus(string $mistakeId): array
  370. {
  371. return $this->updateReviewStatus($mistakeId, 'reset');
  372. }
  373. /**
  374. * 添加到重练清单
  375. */
  376. public function addToRetryList(string $mistakeId): bool
  377. {
  378. try {
  379. $mistake = MistakeRecord::find($mistakeId);
  380. if (!$mistake) {
  381. return false;
  382. }
  383. $mistake->addToRetryList();
  384. // 清除缓存
  385. $this->clearCache($mistake->student_id);
  386. return true;
  387. } catch (\Throwable $e) {
  388. Log::error('加入重练清单失败', [
  389. 'mistake_id' => $mistakeId,
  390. 'error' => $e->getMessage(),
  391. ]);
  392. return false;
  393. }
  394. }
  395. /**
  396. * 批量操作错题
  397. */
  398. public function batchOperation(array $mistakeIds, string $operation, array $params = []): array
  399. {
  400. if (empty($mistakeIds)) {
  401. return [
  402. 'success' => false,
  403. 'error' => '请选择要操作的错题',
  404. ];
  405. }
  406. try {
  407. $mistakes = MistakeRecord::whereIn('id', $mistakeIds)->get();
  408. $successCount = 0;
  409. $errors = [];
  410. foreach ($mistakes as $mistake) {
  411. try {
  412. match ($operation) {
  413. 'favorite' => $mistake->toggleFavorite(),
  414. 'reviewed' => $mistake->markAsReviewed(),
  415. 'mastered' => $mistake->markAsMastered(),
  416. 'retry_list' => $mistake->addToRetryList(),
  417. 'remove_retry_list' => $mistake->removeFromRetryList(),
  418. 'set_error_type' => $mistake->update(['error_type' => $params['error_type'] ?? null]),
  419. 'set_importance' => $mistake->update(['importance' => $params['importance'] ?? 5]),
  420. default => throw new \InvalidArgumentException('不支持的操作'),
  421. };
  422. $successCount++;
  423. } catch (\Throwable $e) {
  424. $errors[] = [
  425. 'mistake_id' => $mistake->id,
  426. 'error' => $e->getMessage(),
  427. ];
  428. }
  429. }
  430. // 清除缓存
  431. if ($successCount > 0) {
  432. $studentIds = $mistakes->pluck('student_id')->unique();
  433. foreach ($studentIds as $studentId) {
  434. $this->clearCache($studentId);
  435. }
  436. }
  437. return [
  438. 'success' => true,
  439. 'total' => count($mistakeIds),
  440. 'success_count' => $successCount,
  441. 'error_count' => count($errors),
  442. 'errors' => $errors,
  443. ];
  444. } catch (\Throwable $e) {
  445. Log::error('批量操作失败', [
  446. 'operation' => $operation,
  447. 'mistake_ids' => $mistakeIds,
  448. 'error' => $e->getMessage(),
  449. ]);
  450. return [
  451. 'success' => false,
  452. 'error' => $e->getMessage(),
  453. ];
  454. }
  455. }
  456. /**
  457. * 基于错题推荐练习题(本地实现)
  458. */
  459. public function recommendPractice(string $studentId, array $kpIds = [], array $skillIds = []): array
  460. {
  461. try {
  462. // 获取学生未掌握的知识点
  463. $weakKnowledgePoints = MistakeRecord::forStudent($studentId)
  464. ->where('review_status', '!=', MistakeRecord::REVIEW_STATUS_MASTERED)
  465. ->pluck('knowledge_point')
  466. ->filter()
  467. ->unique()
  468. ->values()
  469. ->toArray();
  470. // 合并传入的知识点
  471. $allKpIds = array_unique(array_merge($kpIds, $weakKnowledgePoints));
  472. // TODO: 这里应该调用本地题库服务
  473. // 目前返回模拟数据
  474. $recommendations = [];
  475. foreach (array_slice($allKpIds, 0, 5) as $kpId) {
  476. $recommendations[] = [
  477. 'id' => "rec_{$kpId}_{$studentId}",
  478. 'kp_code' => $kpId,
  479. 'question_text' => "针对知识点 {$kpId} 的练习题",
  480. 'difficulty' => 0.5,
  481. 'source' => 'recommendation',
  482. ];
  483. }
  484. return ['data' => $recommendations];
  485. } catch (\Throwable $e) {
  486. Log::error('推荐练习题失败', [
  487. 'student_id' => $studentId,
  488. 'error' => $e->getMessage(),
  489. ]);
  490. return ['data' => []];
  491. }
  492. }
  493. /**
  494. * 获取仪表板快照数据
  495. */
  496. public function getPanelSnapshot(string $studentId, int $limit = 5): array
  497. {
  498. try {
  499. $recentMistakes = MistakeRecord::forStudent($studentId)
  500. ->orderByDesc('created_at')
  501. ->limit($limit)
  502. ->get()
  503. ->map(fn($m) => $this->transformMistakeRecord($m))
  504. ->toArray();
  505. $patterns = $this->getMistakePatterns($studentId);
  506. $summary = $this->summarize($studentId);
  507. return [
  508. 'recent' => $recentMistakes,
  509. 'weak_skills' => array_slice($patterns['error_types'] ?? [], 0, 5, true),
  510. 'weak_kps' => array_slice($patterns['knowledge_points'] ?? [], 0, 5, true),
  511. 'error_types' => $patterns['error_types'] ?? [],
  512. 'stats' => $summary,
  513. ];
  514. } catch (\Throwable $e) {
  515. Log::error('获取快照数据失败', [
  516. 'student_id' => $studentId,
  517. 'error' => $e->getMessage(),
  518. ]);
  519. return [
  520. 'recent' => [],
  521. 'weak_skills' => [],
  522. 'weak_kps' => [],
  523. 'error_types' => [],
  524. 'stats' => [
  525. 'total' => 0,
  526. 'pending' => 0,
  527. 'this_week' => 0,
  528. ],
  529. ];
  530. }
  531. }
  532. /**
  533. * 应用筛选条件
  534. */
  535. private function applyFilters($query, array $params): void
  536. {
  537. // 知识点筛选
  538. if (!empty($params['kp_ids'])) {
  539. $kpIds = is_array($params['kp_ids']) ? $params['kp_ids'] : explode(',', $params['kp_ids']);
  540. $query->byKnowledgePoint($kpIds);
  541. }
  542. // 技能筛选
  543. if (!empty($params['skill_ids'])) {
  544. $skillIds = is_array($params['skill_ids']) ? $params['skill_ids'] : explode(',', $params['skill_ids']);
  545. $query->where(function ($q) use ($skillIds) {
  546. foreach ($skillIds as $skillId) {
  547. $q->orWhereJsonContains('skill_ids', $skillId);
  548. }
  549. });
  550. }
  551. // 错误类型筛选
  552. if (!empty($params['error_types'])) {
  553. $errorTypes = is_array($params['error_types']) ? $params['error_types'] : explode(',', $params['error_types']);
  554. $query->whereIn('error_type', $errorTypes);
  555. }
  556. // 时间范围筛选
  557. if (!empty($params['time_range'])) {
  558. match ($params['time_range']) {
  559. 'last_7' => $query->where('created_at', '>=', now()->subDays(7)),
  560. 'last_30' => $query->where('created_at', '>=', now()->subDays(30)),
  561. 'last_90' => $query->where('created_at', '>=', now()->subDays(90)),
  562. default => null,
  563. };
  564. }
  565. // 自定义时间范围
  566. if (!empty($params['start_date'])) {
  567. $query->whereDate('created_at', '>=', $params['start_date']);
  568. }
  569. if (!empty($params['end_date'])) {
  570. $query->whereDate('created_at', '<=', $params['end_date']);
  571. }
  572. // 复习状态筛选
  573. if (!empty($params['unreviewed_only'])) {
  574. $query->pending();
  575. }
  576. if (!empty($params['favorite_only'])) {
  577. $query->favorites();
  578. }
  579. if (!empty($params['in_retry_list_only'])) {
  580. $query->inRetryList();
  581. }
  582. // 排序
  583. $sortBy = $params['sort_by'] ?? 'created_at_desc';
  584. match ($sortBy) {
  585. 'created_at_asc' => $query->orderBy('created_at'),
  586. 'created_at_desc' => $query->orderByDesc('created_at'),
  587. 'review_status_asc' => $query->orderBy('review_status'),
  588. 'difficulty_desc' => $query->orderByDesc('difficulty'),
  589. default => null,
  590. };
  591. }
  592. /**
  593. * 转换错题记录格式
  594. */
  595. private function transformMistakeRecord(MistakeRecord $mistake, bool $detailed = false): array
  596. {
  597. // 【新增】通过 question_id 关联获取题目详情
  598. $questionDetails = $this->getQuestionDetails($mistake->question_id);
  599. // 【新增】通过 kp_ids 或 textbook_catalog_nodes_id 获取知识点信息
  600. $knowledgePoints = $this->getKnowledgePoints(
  601. $mistake->kp_ids,
  602. $mistake->question_id,
  603. $questionDetails['textbook_catalog_nodes_id'] ?? null
  604. );
  605. $data = [
  606. // ========== 错题记录基本信息 ==========
  607. 'id' => $mistake->id,
  608. 'student_id' => $mistake->student_id,
  609. 'question_id' => $mistake->question_id,
  610. 'paper_id' => $mistake->paper_id,
  611. 'created_at' => $mistake->created_at?->toISOString(),
  612. 'review_status' => $mistake->review_status,
  613. 'review_status_label' => $mistake->review_status_label,
  614. 'review_count' => $mistake->review_count,
  615. 'is_favorite' => $mistake->is_favorite,
  616. 'in_retry_list' => $mistake->in_retry_list,
  617. 'reviewed_at' => $mistake->reviewed_at?->toISOString(),
  618. 'next_review_at' => $mistake->next_review_at?->toISOString(),
  619. 'error_type' => $mistake->error_type,
  620. 'error_type_label' => $mistake->error_type_label,
  621. 'explanation' => $mistake->explanation,
  622. 'skill_ids' => $mistake->skill_ids,
  623. 'difficulty' => $mistake->difficulty,
  624. 'difficulty_level' => $mistake->difficulty_level,
  625. 'importance' => $mistake->importance,
  626. 'mastery_level' => $mistake->mastery_level,
  627. // ========== 题目信息(优先呈现)==========
  628. // 题目内容:优先使用 questions.stem,其次使用错题本自带
  629. 'question_text' => $questionDetails['stem'] ?? $mistake->question_text,
  630. // 答案:优先使用 questions.answer,其次使用错题本自带
  631. 'correct_answer' => $questionDetails['answer'] ?? $mistake->correct_answer,
  632. // 解题过程/解析:优先使用 questions.solution
  633. 'solution' => $questionDetails['solution'] ?? $mistake->explanation,
  634. // 选项
  635. 'options' => $questionDetails['options'] ?? null,
  636. // 题目类型
  637. 'question_type' => $questionDetails['question_type'] ?? null,
  638. // 题目难度(从题目表获取)
  639. 'question_difficulty' => $questionDetails['difficulty'] ?? null,
  640. // 题目标签
  641. 'question_tags' => $questionDetails['tags'] ?? null,
  642. // 题目来源
  643. 'question_source' => $questionDetails['source'] ?? null,
  644. // 学生答案(始终使用错题本中的记录)
  645. 'student_answer' => $mistake->student_answer,
  646. // ========== 知识点信息 ==========
  647. 'knowledge_points' => $knowledgePoints,
  648. ];
  649. if ($detailed) {
  650. $data['student'] = [
  651. 'id' => $mistake->student?->student_id,
  652. 'name' => $mistake->student?->name,
  653. 'grade' => $mistake->student?->grade,
  654. 'class_name' => $mistake->student?->class_name,
  655. ];
  656. }
  657. return $data;
  658. }
  659. /**
  660. * 构建缓存键
  661. */
  662. private function buildCacheKey(string $type, array $params): string
  663. {
  664. $key = "mistake_book:{$type}:" . md5(serialize($params));
  665. return $key;
  666. }
  667. /**
  668. * 清除缓存
  669. */
  670. private function clearCache(string|int $studentId): void
  671. {
  672. try {
  673. $patterns = [
  674. "mistake_book:list:{$studentId}",
  675. "mistake_book:summary:{$studentId}",
  676. "mistake_book:patterns:{$studentId}",
  677. ];
  678. foreach ($patterns as $key) {
  679. Cache::forget($key);
  680. }
  681. } catch (\Exception $e) {
  682. // 缓存清除失败不影响主流程
  683. Log::debug('缓存清除失败(可忽略)', ['error' => $e->getMessage()]);
  684. }
  685. }
  686. /**
  687. * 【新增】通过 question_id 获取题目详情
  688. */
  689. private function getQuestionDetails(?string $questionId): ?array
  690. {
  691. if (!$questionId) {
  692. return null;
  693. }
  694. try {
  695. $question = \DB::connection('mysql')
  696. ->table('questions')
  697. ->where('id', $questionId)
  698. ->first();
  699. if (!$question) {
  700. return null;
  701. }
  702. return [
  703. 'stem' => $question->stem ?? null, // 题目内容(题干)
  704. 'options' => $question->options ?? null, // 选项
  705. 'answer' => $question->answer ?? null, // 答案
  706. 'solution' => $question->solution ?? null, // 解题过程/解析
  707. 'difficulty' => $question->difficulty ?? null,
  708. 'question_type' => $question->question_type ?? null,
  709. 'textbook_catalog_nodes_id' => $question->textbook_catalog_nodes_id ?? null,
  710. 'tags' => $question->tags ?? null, // 标签
  711. 'source' => $question->source ?? null, // 来源
  712. ];
  713. } catch (\Exception $e) {
  714. Log::warning('获取题目详情失败', [
  715. 'question_id' => $questionId,
  716. 'error' => $e->getMessage()
  717. ]);
  718. return null;
  719. }
  720. }
  721. /**
  722. * 【新增】通过 kp_ids 或 textbook_catalog_nodes_id 获取知识点信息
  723. *
  724. * 逻辑:
  725. * 1. 如果有 kp_ids,直接从 knowledge_points 表查询
  726. * 2. 如果没有 kp_ids,通过 textbook_catalog_nodes_id 关联 textbook_chapter_knowledge_relation 表获取 kp_id
  727. */
  728. private function getKnowledgePoints(?array $kpIds, ?string $questionId = null, ?int $catalogNodesId = null): array
  729. {
  730. // 如果有 kp_ids,直接查询
  731. if (!empty($kpIds)) {
  732. return $this->queryKnowledgePointsByCodes($kpIds);
  733. }
  734. // 如果没有 kp_ids,通过题目ID和目录节点ID关联查询
  735. if ($questionId || $catalogNodesId) {
  736. $kpCodesFromRelation = $this->getKpCodesFromRelation($questionId, $catalogNodesId);
  737. if (!empty($kpCodesFromRelation)) {
  738. return $this->queryKnowledgePointsByCodes($kpCodesFromRelation);
  739. }
  740. }
  741. return [];
  742. }
  743. /**
  744. * 通过 kp_codes 查询知识点信息
  745. */
  746. private function queryKnowledgePointsByCodes(array $kpCodes): array
  747. {
  748. try {
  749. $knowledgePoints = \DB::connection('mysql')
  750. ->table('knowledge_points')
  751. ->whereIn('kp_code', $kpCodes)
  752. ->select(['kp_code', 'name', 'parent_kp_code'])
  753. ->get()
  754. ->toArray();
  755. return array_map(function ($kp) {
  756. return [
  757. 'kp_code' => $kp->kp_code,
  758. 'name' => $kp->name ?? $kp->kp_code,
  759. 'parent_kp_code' => $kp->parent_kp_code,
  760. ];
  761. }, $knowledgePoints);
  762. } catch (\Exception $e) {
  763. Log::warning('通过kp_codes获取知识点信息失败', [
  764. 'kp_codes' => $kpCodes,
  765. 'error' => $e->getMessage()
  766. ]);
  767. return [];
  768. }
  769. }
  770. /**
  771. * 通过 textbook_catalog_nodes_id 关联 textbook_chapter_knowledge_relation 表获取 kp_codes
  772. */
  773. private function getKpCodesFromRelation(?string $questionId, ?int $catalogNodesId): array
  774. {
  775. try {
  776. $query = \DB::connection('mysql')
  777. ->table('textbook_chapter_knowledge_relation')
  778. ->where('is_deleted', 0);
  779. if ($catalogNodesId) {
  780. $query->where('catalog_chapter_id', $catalogNodesId);
  781. } elseif ($questionId) {
  782. // 如果没有catalog_nodes_id,尝试从questions表获取
  783. $question = \DB::connection('mysql')
  784. ->table('questions')
  785. ->where('id', $questionId)
  786. ->first();
  787. if ($question && $question->textbook_catalog_nodes_id) {
  788. $query->where('catalog_chapter_id', $question->textbook_catalog_nodes_id);
  789. }
  790. }
  791. $relations = $query->select('kp_code')->get();
  792. if ($relations->isNotEmpty()) {
  793. Log::debug('通过目录节点关联获取到知识点', [
  794. 'question_id' => $questionId,
  795. 'catalog_nodes_id' => $catalogNodesId,
  796. 'kp_codes' => $relations->pluck('kp_code')->toArray()
  797. ]);
  798. }
  799. return $relations->pluck('kp_code')->toArray();
  800. } catch (\Exception $e) {
  801. Log::warning('通过目录节点关联获取kp_codes失败', [
  802. 'question_id' => $questionId,
  803. 'catalog_nodes_id' => $catalogNodesId,
  804. 'error' => $e->getMessage()
  805. ]);
  806. return [];
  807. }
  808. }
  809. }