MistakeBookService.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  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 = 300; // 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. $myAnswer = $payload['my_answer'] ?? null;
  34. $correctAnswer = $payload['correct_answer'] ?? null;
  35. $source = $payload['source'] ?? MistakeRecord::SOURCE_PRACTICE;
  36. $happenedAt = $payload['happened_at'] ?? now();
  37. if (!$studentId) {
  38. throw new \InvalidArgumentException('学生ID不能为空');
  39. }
  40. // 使用事务确保数据一致性
  41. return \DB::transaction(function () use ($studentId, $questionId, $myAnswer, $correctAnswer, $source, $happenedAt) {
  42. // 检查是否已存在相同错题(避免重复)
  43. $existingMistake = MistakeRecord::where('student_id', $studentId)
  44. ->where('question_id', $questionId)
  45. ->where('source', $source)
  46. ->where('created_at', '>=', now()->subDay())
  47. ->first();
  48. if ($existingMistake) {
  49. return [
  50. 'duplicate' => true,
  51. 'mistake_id' => $existingMistake->id,
  52. 'message' => '错题已存在',
  53. ];
  54. }
  55. // 创建错题记录
  56. $mistake = MistakeRecord::create([
  57. 'student_id' => $studentId,
  58. 'question_id' => $questionId,
  59. 'student_answer' => $myAnswer,
  60. 'correct_answer' => $correctAnswer,
  61. 'source' => $source,
  62. 'created_at' => $happenedAt,
  63. 'review_status' => MistakeRecord::REVIEW_STATUS_PENDING,
  64. 'review_count' => 0,
  65. ]);
  66. // 清除相关缓存
  67. $this->clearCache($studentId);
  68. return [
  69. 'duplicate' => false,
  70. 'mistake_id' => $mistake->id,
  71. 'created_at' => $mistake->created_at,
  72. ];
  73. });
  74. }
  75. /**
  76. * 获取错题列表
  77. */
  78. public function listMistakes(array $params = []): array
  79. {
  80. $studentId = $params['student_id'] ?? null;
  81. $page = (int) ($params['page'] ?? 1);
  82. $perPage = (int) ($params['per_page'] ?? 20);
  83. if (!$studentId) {
  84. return [
  85. 'data' => [],
  86. 'meta' => ['total' => 0, 'page' => $page, 'per_page' => $perPage],
  87. ];
  88. }
  89. // 构建缓存键
  90. $cacheKey = $this->buildCacheKey('list', $params);
  91. // 尝试从缓存获取
  92. if (Cache::has($cacheKey)) {
  93. return Cache::get($cacheKey);
  94. }
  95. try {
  96. $query = MistakeRecord::forStudent($studentId)
  97. ->with(['student']) // 预加载学生信息
  98. ->orderByDesc('created_at');
  99. // 应用筛选条件
  100. $this->applyFilters($query, $params);
  101. // 获取总数
  102. $total = $query->count();
  103. // 分页获取数据
  104. $mistakes = $query->skip(($page - 1) * $perPage)
  105. ->take($perPage)
  106. ->get();
  107. // 转换数据格式
  108. $data = $mistakes->map(function ($mistake) {
  109. return $this->transformMistakeRecord($mistake);
  110. })->toArray();
  111. $result = [
  112. 'data' => $data,
  113. 'meta' => [
  114. 'total' => $total,
  115. 'page' => $page,
  116. 'per_page' => $perPage,
  117. 'last_page' => (int) ceil($total / $perPage),
  118. ],
  119. ];
  120. // 缓存结果
  121. Cache::put($cacheKey, $result, self::CACHE_TTL_LIST);
  122. return $result;
  123. } catch (\Throwable $e) {
  124. Log::error('获取错题列表失败', [
  125. 'student_id' => $studentId,
  126. 'error' => $e->getMessage(),
  127. 'params' => $params,
  128. ]);
  129. return [
  130. 'data' => [],
  131. 'meta' => ['total' => 0, 'page' => $page, 'per_page' => $perPage],
  132. ];
  133. }
  134. }
  135. /**
  136. * 获取错题详情
  137. */
  138. public function getMistakeDetail(string $mistakeId, ?string $studentId = null): array
  139. {
  140. try {
  141. $query = MistakeRecord::with(['student']);
  142. if ($studentId) {
  143. $query->forStudent($studentId);
  144. }
  145. $mistake = $query->find($mistakeId);
  146. if (!$mistake) {
  147. return [];
  148. }
  149. return $this->transformMistakeRecord($mistake, true);
  150. } catch (\Throwable $e) {
  151. Log::error('获取错题详情失败', [
  152. 'mistake_id' => $mistakeId,
  153. 'student_id' => $studentId,
  154. 'error' => $e->getMessage(),
  155. ]);
  156. return [];
  157. }
  158. }
  159. /**
  160. * 获取错题统计概要
  161. */
  162. public function summarize(string $studentId): array
  163. {
  164. $cacheKey = "mistake_book:summary:{$studentId}";
  165. return Cache::remember($cacheKey, self::CACHE_TTL_SUMMARY, function () use ($studentId) {
  166. return MistakeRecord::getSummary($studentId);
  167. });
  168. }
  169. /**
  170. * 获取错误模式分析
  171. */
  172. public function getMistakePatterns(string $studentId): array
  173. {
  174. $cacheKey = "mistake_book:patterns:{$studentId}";
  175. return Cache::remember($cacheKey, self::CACHE_TTL_PATTERNS, function () use ($studentId) {
  176. return MistakeRecord::getMistakePatterns($studentId);
  177. });
  178. }
  179. /**
  180. * 收藏/取消收藏错题
  181. */
  182. public function toggleFavorite(string $mistakeId, bool $favorite = true): bool
  183. {
  184. try {
  185. $mistake = MistakeRecord::find($mistakeId);
  186. if (!$mistake) {
  187. return false;
  188. }
  189. $mistake->update(['is_favorite' => $favorite]);
  190. // 清除缓存
  191. $this->clearCache($mistake->student_id);
  192. return true;
  193. } catch (\Throwable $e) {
  194. Log::error('收藏错题失败', [
  195. 'mistake_id' => $mistakeId,
  196. 'favorite' => $favorite,
  197. 'error' => $e->getMessage(),
  198. ]);
  199. return false;
  200. }
  201. }
  202. /**
  203. * 标记已复习
  204. */
  205. public function markReviewed(string $mistakeId): bool
  206. {
  207. try {
  208. $mistake = MistakeRecord::find($mistakeId);
  209. if (!$mistake) {
  210. return false;
  211. }
  212. $mistake->markAsReviewed();
  213. // 清除缓存
  214. $this->clearCache($mistake->student_id);
  215. return true;
  216. } catch (\Throwable $e) {
  217. Log::error('标记已复习失败', [
  218. 'mistake_id' => $mistakeId,
  219. 'error' => $e->getMessage(),
  220. ]);
  221. return false;
  222. }
  223. }
  224. /**
  225. * 修改复习状态
  226. */
  227. public function updateReviewStatus(string $mistakeId, string $action = 'increment', bool $forceReview = false): array
  228. {
  229. try {
  230. $mistake = MistakeRecord::find($mistakeId);
  231. if (!$mistake) {
  232. return [
  233. 'success' => false,
  234. 'error' => '错题记录不存在',
  235. ];
  236. }
  237. match ($action) {
  238. 'increment' => $mistake->markAsReviewed(),
  239. 'mastered' => $mistake->markAsMastered(),
  240. 'reset' => $mistake->update([
  241. 'review_status' => MistakeRecord::REVIEW_STATUS_PENDING,
  242. 'review_count' => 0,
  243. 'mastery_level' => null,
  244. ]),
  245. default => throw new \InvalidArgumentException('无效的操作类型'),
  246. };
  247. // 清除缓存
  248. $this->clearCache($mistake->student_id);
  249. return [
  250. 'success' => true,
  251. 'mistake_id' => $mistakeId,
  252. 'review_status' => $mistake->review_status,
  253. 'review_count' => $mistake->review_count,
  254. 'message' => '复习状态更新成功',
  255. ];
  256. } catch (\Throwable $e) {
  257. Log::error('更新复习状态失败', [
  258. 'mistake_id' => $mistakeId,
  259. 'action' => $action,
  260. 'error' => $e->getMessage(),
  261. ]);
  262. return [
  263. 'success' => false,
  264. 'error' => $e->getMessage(),
  265. ];
  266. }
  267. }
  268. /**
  269. * 获取复习状态
  270. */
  271. public function getReviewStatus(string $mistakeId): array
  272. {
  273. try {
  274. $mistake = MistakeRecord::find($mistakeId);
  275. if (!$mistake) {
  276. return [
  277. 'success' => false,
  278. 'error' => '错题记录不存在',
  279. ];
  280. }
  281. return [
  282. 'success' => true,
  283. 'mistake_id' => $mistakeId,
  284. 'review_status' => $mistake->review_status,
  285. 'review_count' => $mistake->review_count,
  286. 'reviewed_at' => $mistake->reviewed_at?->toISOString(),
  287. 'next_review_at' => $mistake->next_review_at?->toISOString(),
  288. 'mastery_level' => $mistake->mastery_level,
  289. 'force_review' => $mistake->force_review,
  290. ];
  291. } catch (\Throwable $e) {
  292. Log::error('获取复习状态失败', [
  293. 'mistake_id' => $mistakeId,
  294. 'error' => $e->getMessage(),
  295. ]);
  296. return [
  297. 'success' => false,
  298. 'error' => $e->getMessage(),
  299. ];
  300. }
  301. }
  302. /**
  303. * 增加复习次数
  304. */
  305. public function incrementReviewCount(string $mistakeId, bool $forceReview = false): array
  306. {
  307. return $this->updateReviewStatus($mistakeId, 'increment', $forceReview);
  308. }
  309. /**
  310. * 重置为强制复习状态
  311. */
  312. public function resetReviewStatus(string $mistakeId): array
  313. {
  314. return $this->updateReviewStatus($mistakeId, 'reset');
  315. }
  316. /**
  317. * 添加到重练清单
  318. */
  319. public function addToRetryList(string $mistakeId): bool
  320. {
  321. try {
  322. $mistake = MistakeRecord::find($mistakeId);
  323. if (!$mistake) {
  324. return false;
  325. }
  326. $mistake->addToRetryList();
  327. // 清除缓存
  328. $this->clearCache($mistake->student_id);
  329. return true;
  330. } catch (\Throwable $e) {
  331. Log::error('加入重练清单失败', [
  332. 'mistake_id' => $mistakeId,
  333. 'error' => $e->getMessage(),
  334. ]);
  335. return false;
  336. }
  337. }
  338. /**
  339. * 批量操作错题
  340. */
  341. public function batchOperation(array $mistakeIds, string $operation, array $params = []): array
  342. {
  343. if (empty($mistakeIds)) {
  344. return [
  345. 'success' => false,
  346. 'error' => '请选择要操作的错题',
  347. ];
  348. }
  349. try {
  350. $mistakes = MistakeRecord::whereIn('id', $mistakeIds)->get();
  351. $successCount = 0;
  352. $errors = [];
  353. foreach ($mistakes as $mistake) {
  354. try {
  355. match ($operation) {
  356. 'favorite' => $mistake->toggleFavorite(),
  357. 'reviewed' => $mistake->markAsReviewed(),
  358. 'mastered' => $mistake->markAsMastered(),
  359. 'retry_list' => $mistake->addToRetryList(),
  360. 'remove_retry_list' => $mistake->removeFromRetryList(),
  361. 'set_error_type' => $mistake->update(['error_type' => $params['error_type'] ?? null]),
  362. 'set_importance' => $mistake->update(['importance' => $params['importance'] ?? 5]),
  363. default => throw new \InvalidArgumentException('不支持的操作'),
  364. };
  365. $successCount++;
  366. } catch (\Throwable $e) {
  367. $errors[] = [
  368. 'mistake_id' => $mistake->id,
  369. 'error' => $e->getMessage(),
  370. ];
  371. }
  372. }
  373. // 清除缓存
  374. if ($successCount > 0) {
  375. $studentIds = $mistakes->pluck('student_id')->unique();
  376. foreach ($studentIds as $studentId) {
  377. $this->clearCache($studentId);
  378. }
  379. }
  380. return [
  381. 'success' => true,
  382. 'total' => count($mistakeIds),
  383. 'success_count' => $successCount,
  384. 'error_count' => count($errors),
  385. 'errors' => $errors,
  386. ];
  387. } catch (\Throwable $e) {
  388. Log::error('批量操作失败', [
  389. 'operation' => $operation,
  390. 'mistake_ids' => $mistakeIds,
  391. 'error' => $e->getMessage(),
  392. ]);
  393. return [
  394. 'success' => false,
  395. 'error' => $e->getMessage(),
  396. ];
  397. }
  398. }
  399. /**
  400. * 基于错题推荐练习题(本地实现)
  401. */
  402. public function recommendPractice(string $studentId, array $kpIds = [], array $skillIds = []): array
  403. {
  404. try {
  405. // 获取学生未掌握的知识点
  406. $weakKnowledgePoints = MistakeRecord::forStudent($studentId)
  407. ->where('review_status', '!=', MistakeRecord::REVIEW_STATUS_MASTERED)
  408. ->pluck('knowledge_point')
  409. ->filter()
  410. ->unique()
  411. ->values()
  412. ->toArray();
  413. // 合并传入的知识点
  414. $allKpIds = array_unique(array_merge($kpIds, $weakKnowledgePoints));
  415. // TODO: 这里应该调用本地题库服务
  416. // 目前返回模拟数据
  417. $recommendations = [];
  418. foreach (array_slice($allKpIds, 0, 5) as $kpId) {
  419. $recommendations[] = [
  420. 'id' => "rec_{$kpId}_{$studentId}",
  421. 'kp_code' => $kpId,
  422. 'question_text' => "针对知识点 {$kpId} 的练习题",
  423. 'difficulty' => 0.5,
  424. 'source' => 'recommendation',
  425. ];
  426. }
  427. return ['data' => $recommendations];
  428. } catch (\Throwable $e) {
  429. Log::error('推荐练习题失败', [
  430. 'student_id' => $studentId,
  431. 'error' => $e->getMessage(),
  432. ]);
  433. return ['data' => []];
  434. }
  435. }
  436. /**
  437. * 获取仪表板快照数据
  438. */
  439. public function getPanelSnapshot(string $studentId, int $limit = 5): array
  440. {
  441. try {
  442. $recentMistakes = MistakeRecord::forStudent($studentId)
  443. ->orderByDesc('created_at')
  444. ->limit($limit)
  445. ->get()
  446. ->map(fn($m) => $this->transformMistakeRecord($m))
  447. ->toArray();
  448. $patterns = $this->getMistakePatterns($studentId);
  449. $summary = $this->summarize($studentId);
  450. return [
  451. 'recent' => $recentMistakes,
  452. 'weak_skills' => array_slice($patterns['error_types'] ?? [], 0, 5, true),
  453. 'weak_kps' => array_slice($patterns['knowledge_points'] ?? [], 0, 5, true),
  454. 'error_types' => $patterns['error_types'] ?? [],
  455. 'stats' => $summary,
  456. ];
  457. } catch (\Throwable $e) {
  458. Log::error('获取快照数据失败', [
  459. 'student_id' => $studentId,
  460. 'error' => $e->getMessage(),
  461. ]);
  462. return [
  463. 'recent' => [],
  464. 'weak_skills' => [],
  465. 'weak_kps' => [],
  466. 'error_types' => [],
  467. 'stats' => [
  468. 'total' => 0,
  469. 'pending' => 0,
  470. 'this_week' => 0,
  471. ],
  472. ];
  473. }
  474. }
  475. /**
  476. * 应用筛选条件
  477. */
  478. private function applyFilters($query, array $params): void
  479. {
  480. // 知识点筛选
  481. if (!empty($params['kp_ids'])) {
  482. $kpIds = is_array($params['kp_ids']) ? $params['kp_ids'] : explode(',', $params['kp_ids']);
  483. $query->byKnowledgePoint($kpIds);
  484. }
  485. // 技能筛选
  486. if (!empty($params['skill_ids'])) {
  487. $skillIds = is_array($params['skill_ids']) ? $params['skill_ids'] : explode(',', $params['skill_ids']);
  488. $query->where(function ($q) use ($skillIds) {
  489. foreach ($skillIds as $skillId) {
  490. $q->orWhereJsonContains('skill_ids', $skillId);
  491. }
  492. });
  493. }
  494. // 错误类型筛选
  495. if (!empty($params['error_types'])) {
  496. $errorTypes = is_array($params['error_types']) ? $params['error_types'] : explode(',', $params['error_types']);
  497. $query->whereIn('error_type', $errorTypes);
  498. }
  499. // 时间范围筛选
  500. if (!empty($params['time_range'])) {
  501. match ($params['time_range']) {
  502. 'last_7' => $query->where('created_at', '>=', now()->subDays(7)),
  503. 'last_30' => $query->where('created_at', '>=', now()->subDays(30)),
  504. 'last_90' => $query->where('created_at', '>=', now()->subDays(90)),
  505. default => null,
  506. };
  507. }
  508. // 自定义时间范围
  509. if (!empty($params['start_date'])) {
  510. $query->whereDate('created_at', '>=', $params['start_date']);
  511. }
  512. if (!empty($params['end_date'])) {
  513. $query->whereDate('created_at', '<=', $params['end_date']);
  514. }
  515. // 复习状态筛选
  516. if (!empty($params['unreviewed_only'])) {
  517. $query->pending();
  518. }
  519. if (!empty($params['favorite_only'])) {
  520. $query->favorites();
  521. }
  522. if (!empty($params['in_retry_list_only'])) {
  523. $query->inRetryList();
  524. }
  525. // 排序
  526. $sortBy = $params['sort_by'] ?? 'created_at_desc';
  527. match ($sortBy) {
  528. 'created_at_asc' => $query->orderBy('created_at'),
  529. 'created_at_desc' => $query->orderByDesc('created_at'),
  530. 'review_status_asc' => $query->orderBy('review_status'),
  531. 'difficulty_desc' => $query->orderByDesc('difficulty'),
  532. default => null,
  533. };
  534. }
  535. /**
  536. * 转换错题记录格式
  537. */
  538. private function transformMistakeRecord(MistakeRecord $mistake, bool $detailed = false): array
  539. {
  540. $data = [
  541. 'id' => $mistake->id,
  542. 'student_id' => $mistake->student_id,
  543. 'question_text' => $mistake->question_text,
  544. 'student_answer' => $mistake->student_answer,
  545. 'correct_answer' => $mistake->correct_answer,
  546. 'knowledge_point' => $mistake->knowledge_point,
  547. 'explanation' => $mistake->explanation,
  548. 'source' => $mistake->source,
  549. 'source_label' => $mistake->source_label,
  550. 'created_at' => $mistake->created_at?->toISOString(),
  551. 'review_status' => $mistake->review_status,
  552. 'review_status_label' => $mistake->review_status_label,
  553. 'review_count' => $mistake->review_count,
  554. 'is_favorite' => $mistake->is_favorite,
  555. 'in_retry_list' => $mistake->in_retry_list,
  556. 'reviewed_at' => $mistake->reviewed_at?->toISOString(),
  557. 'next_review_at' => $mistake->next_review_at?->toISOString(),
  558. 'error_type' => $mistake->error_type,
  559. 'error_type_label' => $mistake->error_type_label,
  560. 'kp_ids' => $mistake->kp_ids,
  561. 'skill_ids' => $mistake->skill_ids,
  562. 'difficulty' => $mistake->difficulty,
  563. 'difficulty_level' => $mistake->difficulty_level,
  564. 'importance' => $mistake->importance,
  565. 'mastery_level' => $mistake->mastery_level,
  566. ];
  567. if ($detailed) {
  568. $data['student'] = [
  569. 'id' => $mistake->student?->student_id,
  570. 'name' => $mistake->student?->name,
  571. 'grade' => $mistake->student?->grade,
  572. 'class_name' => $mistake->student?->class_name,
  573. ];
  574. }
  575. return $data;
  576. }
  577. /**
  578. * 构建缓存键
  579. */
  580. private function buildCacheKey(string $type, array $params): string
  581. {
  582. $key = "mistake_book:{$type}:" . md5(serialize($params));
  583. return $key;
  584. }
  585. /**
  586. * 清除缓存
  587. */
  588. private function clearCache(string|int $studentId): void
  589. {
  590. $patterns = [
  591. "mistake_book:list:*{$studentId}*",
  592. "mistake_book:summary:{$studentId}",
  593. "mistake_book:patterns:{$studentId}",
  594. ];
  595. foreach ($patterns as $pattern) {
  596. Cache::tags(['mistake_book', "student_{$studentId}"])->flush();
  597. }
  598. }
  599. }