MistakeBookService.php 37 KB

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