MistakeBookService.php 22 KB

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