MistakeBookService.php 33 KB

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