| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698 |
- <?php
- namespace App\Services;
- use App\Models\MistakeRecord;
- use App\Models\Student;
- use App\Models\Teacher;
- use Illuminate\Support\Arr;
- use Illuminate\Support\Facades\Cache;
- use Illuminate\Support\Facades\Log;
- use Illuminate\Support\Facades\Http;
- class MistakeBookService
- {
- protected string $learningAnalyticsBase;
- protected int $timeout;
- // 缓存时间(秒)
- const CACHE_TTL_LIST = 300; // 5分钟
- const CACHE_TTL_SUMMARY = 600; // 10分钟
- const CACHE_TTL_PATTERNS = 1800; // 30分钟
- public function __construct(
- ?string $learningAnalyticsBase = null,
- ?int $timeout = null
- ) {
- // 已迁移到本地,不再使用LearningAnalytics
- $this->learningAnalyticsBase = '';
- $this->timeout = 20;
- }
- /**
- * 新增错题
- */
- public function createMistake(array $payload): array
- {
- $studentId = $payload['student_id'] ?? null;
- $questionId = $payload['question_id'] ?? null;
- $myAnswer = $payload['my_answer'] ?? null;
- $correctAnswer = $payload['correct_answer'] ?? null;
- $source = $payload['source'] ?? MistakeRecord::SOURCE_PRACTICE;
- $happenedAt = $payload['happened_at'] ?? now();
- if (!$studentId) {
- throw new \InvalidArgumentException('学生ID不能为空');
- }
- // 使用事务确保数据一致性
- return \DB::transaction(function () use ($studentId, $questionId, $myAnswer, $correctAnswer, $source, $happenedAt) {
- // 检查是否已存在相同错题(避免重复)
- $existingMistake = MistakeRecord::where('student_id', $studentId)
- ->where('question_id', $questionId)
- ->where('source', $source)
- ->where('created_at', '>=', now()->subDay())
- ->first();
- if ($existingMistake) {
- return [
- 'duplicate' => true,
- 'mistake_id' => $existingMistake->id,
- 'message' => '错题已存在',
- ];
- }
- // 创建错题记录
- $mistake = MistakeRecord::create([
- 'student_id' => $studentId,
- 'question_id' => $questionId,
- 'student_answer' => $myAnswer,
- 'correct_answer' => $correctAnswer,
- 'source' => $source,
- 'created_at' => $happenedAt,
- 'review_status' => MistakeRecord::REVIEW_STATUS_PENDING,
- 'review_count' => 0,
- ]);
- // 清除相关缓存
- $this->clearCache($studentId);
- return [
- 'duplicate' => false,
- 'mistake_id' => $mistake->id,
- 'created_at' => $mistake->created_at,
- ];
- });
- }
- /**
- * 获取错题列表
- */
- public function listMistakes(array $params = []): array
- {
- $studentId = $params['student_id'] ?? null;
- $page = (int) ($params['page'] ?? 1);
- $perPage = (int) ($params['per_page'] ?? 20);
- if (!$studentId) {
- return [
- 'data' => [],
- 'meta' => ['total' => 0, 'page' => $page, 'per_page' => $perPage],
- ];
- }
- // 构建缓存键
- $cacheKey = $this->buildCacheKey('list', $params);
- // 尝试从缓存获取
- if (Cache::has($cacheKey)) {
- return Cache::get($cacheKey);
- }
- try {
- $query = MistakeRecord::forStudent($studentId)
- ->with(['student']) // 预加载学生信息
- ->orderByDesc('created_at');
- // 应用筛选条件
- $this->applyFilters($query, $params);
- // 获取总数
- $total = $query->count();
- // 分页获取数据
- $mistakes = $query->skip(($page - 1) * $perPage)
- ->take($perPage)
- ->get();
- // 转换数据格式
- $data = $mistakes->map(function ($mistake) {
- return $this->transformMistakeRecord($mistake);
- })->toArray();
- $result = [
- 'data' => $data,
- 'meta' => [
- 'total' => $total,
- 'page' => $page,
- 'per_page' => $perPage,
- 'last_page' => (int) ceil($total / $perPage),
- ],
- ];
- // 缓存结果
- Cache::put($cacheKey, $result, self::CACHE_TTL_LIST);
- return $result;
- } catch (\Throwable $e) {
- Log::error('获取错题列表失败', [
- 'student_id' => $studentId,
- 'error' => $e->getMessage(),
- 'params' => $params,
- ]);
- return [
- 'data' => [],
- 'meta' => ['total' => 0, 'page' => $page, 'per_page' => $perPage],
- ];
- }
- }
- /**
- * 获取错题详情
- */
- public function getMistakeDetail(string $mistakeId, ?string $studentId = null): array
- {
- try {
- $query = MistakeRecord::with(['student']);
- if ($studentId) {
- $query->forStudent($studentId);
- }
- $mistake = $query->find($mistakeId);
- if (!$mistake) {
- return [];
- }
- return $this->transformMistakeRecord($mistake, true);
- } catch (\Throwable $e) {
- Log::error('获取错题详情失败', [
- 'mistake_id' => $mistakeId,
- 'student_id' => $studentId,
- 'error' => $e->getMessage(),
- ]);
- return [];
- }
- }
- /**
- * 获取错题统计概要
- */
- public function summarize(string $studentId): array
- {
- $cacheKey = "mistake_book:summary:{$studentId}";
- return Cache::remember($cacheKey, self::CACHE_TTL_SUMMARY, function () use ($studentId) {
- return MistakeRecord::getSummary($studentId);
- });
- }
- /**
- * 获取错误模式分析
- */
- public function getMistakePatterns(string $studentId): array
- {
- $cacheKey = "mistake_book:patterns:{$studentId}";
- return Cache::remember($cacheKey, self::CACHE_TTL_PATTERNS, function () use ($studentId) {
- return MistakeRecord::getMistakePatterns($studentId);
- });
- }
- /**
- * 收藏/取消收藏错题
- */
- public function toggleFavorite(string $mistakeId, bool $favorite = true): bool
- {
- try {
- $mistake = MistakeRecord::find($mistakeId);
- if (!$mistake) {
- return false;
- }
- $mistake->update(['is_favorite' => $favorite]);
- // 清除缓存
- $this->clearCache($mistake->student_id);
- return true;
- } catch (\Throwable $e) {
- Log::error('收藏错题失败', [
- 'mistake_id' => $mistakeId,
- 'favorite' => $favorite,
- 'error' => $e->getMessage(),
- ]);
- return false;
- }
- }
- /**
- * 标记已复习
- */
- public function markReviewed(string $mistakeId): bool
- {
- try {
- $mistake = MistakeRecord::find($mistakeId);
- if (!$mistake) {
- return false;
- }
- $mistake->markAsReviewed();
- // 清除缓存
- $this->clearCache($mistake->student_id);
- return true;
- } catch (\Throwable $e) {
- Log::error('标记已复习失败', [
- 'mistake_id' => $mistakeId,
- 'error' => $e->getMessage(),
- ]);
- return false;
- }
- }
- /**
- * 修改复习状态
- */
- public function updateReviewStatus(string $mistakeId, string $action = 'increment', bool $forceReview = false): array
- {
- try {
- $mistake = MistakeRecord::find($mistakeId);
- if (!$mistake) {
- return [
- 'success' => false,
- 'error' => '错题记录不存在',
- ];
- }
- match ($action) {
- 'increment' => $mistake->markAsReviewed(),
- 'mastered' => $mistake->markAsMastered(),
- 'reset' => $mistake->update([
- 'review_status' => MistakeRecord::REVIEW_STATUS_PENDING,
- 'review_count' => 0,
- 'mastery_level' => null,
- ]),
- default => throw new \InvalidArgumentException('无效的操作类型'),
- };
- // 清除缓存
- $this->clearCache($mistake->student_id);
- return [
- 'success' => true,
- 'mistake_id' => $mistakeId,
- 'review_status' => $mistake->review_status,
- 'review_count' => $mistake->review_count,
- 'message' => '复习状态更新成功',
- ];
- } catch (\Throwable $e) {
- Log::error('更新复习状态失败', [
- 'mistake_id' => $mistakeId,
- 'action' => $action,
- 'error' => $e->getMessage(),
- ]);
- return [
- 'success' => false,
- 'error' => $e->getMessage(),
- ];
- }
- }
- /**
- * 获取复习状态
- */
- public function getReviewStatus(string $mistakeId): array
- {
- try {
- $mistake = MistakeRecord::find($mistakeId);
- if (!$mistake) {
- return [
- 'success' => false,
- 'error' => '错题记录不存在',
- ];
- }
- return [
- 'success' => true,
- 'mistake_id' => $mistakeId,
- 'review_status' => $mistake->review_status,
- 'review_count' => $mistake->review_count,
- 'reviewed_at' => $mistake->reviewed_at?->toISOString(),
- 'next_review_at' => $mistake->next_review_at?->toISOString(),
- 'mastery_level' => $mistake->mastery_level,
- 'force_review' => $mistake->force_review,
- ];
- } catch (\Throwable $e) {
- Log::error('获取复习状态失败', [
- 'mistake_id' => $mistakeId,
- 'error' => $e->getMessage(),
- ]);
- return [
- 'success' => false,
- 'error' => $e->getMessage(),
- ];
- }
- }
- /**
- * 增加复习次数
- */
- public function incrementReviewCount(string $mistakeId, bool $forceReview = false): array
- {
- return $this->updateReviewStatus($mistakeId, 'increment', $forceReview);
- }
- /**
- * 重置为强制复习状态
- */
- public function resetReviewStatus(string $mistakeId): array
- {
- return $this->updateReviewStatus($mistakeId, 'reset');
- }
- /**
- * 添加到重练清单
- */
- public function addToRetryList(string $mistakeId): bool
- {
- try {
- $mistake = MistakeRecord::find($mistakeId);
- if (!$mistake) {
- return false;
- }
- $mistake->addToRetryList();
- // 清除缓存
- $this->clearCache($mistake->student_id);
- return true;
- } catch (\Throwable $e) {
- Log::error('加入重练清单失败', [
- 'mistake_id' => $mistakeId,
- 'error' => $e->getMessage(),
- ]);
- return false;
- }
- }
- /**
- * 批量操作错题
- */
- public function batchOperation(array $mistakeIds, string $operation, array $params = []): array
- {
- if (empty($mistakeIds)) {
- return [
- 'success' => false,
- 'error' => '请选择要操作的错题',
- ];
- }
- try {
- $mistakes = MistakeRecord::whereIn('id', $mistakeIds)->get();
- $successCount = 0;
- $errors = [];
- foreach ($mistakes as $mistake) {
- try {
- match ($operation) {
- 'favorite' => $mistake->toggleFavorite(),
- 'reviewed' => $mistake->markAsReviewed(),
- 'mastered' => $mistake->markAsMastered(),
- 'retry_list' => $mistake->addToRetryList(),
- 'remove_retry_list' => $mistake->removeFromRetryList(),
- 'set_error_type' => $mistake->update(['error_type' => $params['error_type'] ?? null]),
- 'set_importance' => $mistake->update(['importance' => $params['importance'] ?? 5]),
- default => throw new \InvalidArgumentException('不支持的操作'),
- };
- $successCount++;
- } catch (\Throwable $e) {
- $errors[] = [
- 'mistake_id' => $mistake->id,
- 'error' => $e->getMessage(),
- ];
- }
- }
- // 清除缓存
- if ($successCount > 0) {
- $studentIds = $mistakes->pluck('student_id')->unique();
- foreach ($studentIds as $studentId) {
- $this->clearCache($studentId);
- }
- }
- return [
- 'success' => true,
- 'total' => count($mistakeIds),
- 'success_count' => $successCount,
- 'error_count' => count($errors),
- 'errors' => $errors,
- ];
- } catch (\Throwable $e) {
- Log::error('批量操作失败', [
- 'operation' => $operation,
- 'mistake_ids' => $mistakeIds,
- 'error' => $e->getMessage(),
- ]);
- return [
- 'success' => false,
- 'error' => $e->getMessage(),
- ];
- }
- }
- /**
- * 基于错题推荐练习题(本地实现)
- */
- public function recommendPractice(string $studentId, array $kpIds = [], array $skillIds = []): array
- {
- try {
- // 获取学生未掌握的知识点
- $weakKnowledgePoints = MistakeRecord::forStudent($studentId)
- ->where('review_status', '!=', MistakeRecord::REVIEW_STATUS_MASTERED)
- ->pluck('knowledge_point')
- ->filter()
- ->unique()
- ->values()
- ->toArray();
- // 合并传入的知识点
- $allKpIds = array_unique(array_merge($kpIds, $weakKnowledgePoints));
- // TODO: 这里应该调用本地题库服务
- // 目前返回模拟数据
- $recommendations = [];
- foreach (array_slice($allKpIds, 0, 5) as $kpId) {
- $recommendations[] = [
- 'id' => "rec_{$kpId}_{$studentId}",
- 'kp_code' => $kpId,
- 'question_text' => "针对知识点 {$kpId} 的练习题",
- 'difficulty' => 0.5,
- 'source' => 'recommendation',
- ];
- }
- return ['data' => $recommendations];
- } catch (\Throwable $e) {
- Log::error('推荐练习题失败', [
- 'student_id' => $studentId,
- 'error' => $e->getMessage(),
- ]);
- return ['data' => []];
- }
- }
- /**
- * 获取仪表板快照数据
- */
- public function getPanelSnapshot(string $studentId, int $limit = 5): array
- {
- try {
- $recentMistakes = MistakeRecord::forStudent($studentId)
- ->orderByDesc('created_at')
- ->limit($limit)
- ->get()
- ->map(fn($m) => $this->transformMistakeRecord($m))
- ->toArray();
- $patterns = $this->getMistakePatterns($studentId);
- $summary = $this->summarize($studentId);
- return [
- 'recent' => $recentMistakes,
- 'weak_skills' => array_slice($patterns['error_types'] ?? [], 0, 5, true),
- 'weak_kps' => array_slice($patterns['knowledge_points'] ?? [], 0, 5, true),
- 'error_types' => $patterns['error_types'] ?? [],
- 'stats' => $summary,
- ];
- } catch (\Throwable $e) {
- Log::error('获取快照数据失败', [
- 'student_id' => $studentId,
- 'error' => $e->getMessage(),
- ]);
- return [
- 'recent' => [],
- 'weak_skills' => [],
- 'weak_kps' => [],
- 'error_types' => [],
- 'stats' => [
- 'total' => 0,
- 'pending' => 0,
- 'this_week' => 0,
- ],
- ];
- }
- }
- /**
- * 应用筛选条件
- */
- private function applyFilters($query, array $params): void
- {
- // 知识点筛选
- if (!empty($params['kp_ids'])) {
- $kpIds = is_array($params['kp_ids']) ? $params['kp_ids'] : explode(',', $params['kp_ids']);
- $query->byKnowledgePoint($kpIds);
- }
- // 技能筛选
- if (!empty($params['skill_ids'])) {
- $skillIds = is_array($params['skill_ids']) ? $params['skill_ids'] : explode(',', $params['skill_ids']);
- $query->where(function ($q) use ($skillIds) {
- foreach ($skillIds as $skillId) {
- $q->orWhereJsonContains('skill_ids', $skillId);
- }
- });
- }
- // 错误类型筛选
- if (!empty($params['error_types'])) {
- $errorTypes = is_array($params['error_types']) ? $params['error_types'] : explode(',', $params['error_types']);
- $query->whereIn('error_type', $errorTypes);
- }
- // 时间范围筛选
- if (!empty($params['time_range'])) {
- match ($params['time_range']) {
- 'last_7' => $query->where('created_at', '>=', now()->subDays(7)),
- 'last_30' => $query->where('created_at', '>=', now()->subDays(30)),
- 'last_90' => $query->where('created_at', '>=', now()->subDays(90)),
- default => null,
- };
- }
- // 自定义时间范围
- if (!empty($params['start_date'])) {
- $query->whereDate('created_at', '>=', $params['start_date']);
- }
- if (!empty($params['end_date'])) {
- $query->whereDate('created_at', '<=', $params['end_date']);
- }
- // 复习状态筛选
- if (!empty($params['unreviewed_only'])) {
- $query->pending();
- }
- if (!empty($params['favorite_only'])) {
- $query->favorites();
- }
- if (!empty($params['in_retry_list_only'])) {
- $query->inRetryList();
- }
- // 排序
- $sortBy = $params['sort_by'] ?? 'created_at_desc';
- match ($sortBy) {
- 'created_at_asc' => $query->orderBy('created_at'),
- 'created_at_desc' => $query->orderByDesc('created_at'),
- 'review_status_asc' => $query->orderBy('review_status'),
- 'difficulty_desc' => $query->orderByDesc('difficulty'),
- default => null,
- };
- }
- /**
- * 转换错题记录格式
- */
- private function transformMistakeRecord(MistakeRecord $mistake, bool $detailed = false): array
- {
- $data = [
- 'id' => $mistake->id,
- 'student_id' => $mistake->student_id,
- 'question_text' => $mistake->question_text,
- 'student_answer' => $mistake->student_answer,
- 'correct_answer' => $mistake->correct_answer,
- 'knowledge_point' => $mistake->knowledge_point,
- 'explanation' => $mistake->explanation,
- 'source' => $mistake->source,
- 'source_label' => $mistake->source_label,
- 'created_at' => $mistake->created_at?->toISOString(),
- 'review_status' => $mistake->review_status,
- 'review_status_label' => $mistake->review_status_label,
- 'review_count' => $mistake->review_count,
- 'is_favorite' => $mistake->is_favorite,
- 'in_retry_list' => $mistake->in_retry_list,
- 'reviewed_at' => $mistake->reviewed_at?->toISOString(),
- 'next_review_at' => $mistake->next_review_at?->toISOString(),
- 'error_type' => $mistake->error_type,
- 'error_type_label' => $mistake->error_type_label,
- 'kp_ids' => $mistake->kp_ids,
- 'skill_ids' => $mistake->skill_ids,
- 'difficulty' => $mistake->difficulty,
- 'difficulty_level' => $mistake->difficulty_level,
- 'importance' => $mistake->importance,
- 'mastery_level' => $mistake->mastery_level,
- ];
- if ($detailed) {
- $data['student'] = [
- 'id' => $mistake->student?->student_id,
- 'name' => $mistake->student?->name,
- 'grade' => $mistake->student?->grade,
- 'class_name' => $mistake->student?->class_name,
- ];
- }
- return $data;
- }
- /**
- * 构建缓存键
- */
- private function buildCacheKey(string $type, array $params): string
- {
- $key = "mistake_book:{$type}:" . md5(serialize($params));
- return $key;
- }
- /**
- * 清除缓存
- */
- private function clearCache(string|int $studentId): void
- {
- $patterns = [
- "mistake_book:list:*{$studentId}*",
- "mistake_book:summary:{$studentId}",
- "mistake_book:patterns:{$studentId}",
- ];
- foreach ($patterns as $pattern) {
- Cache::tags(['mistake_book', "student_{$studentId}"])->flush();
- }
- }
- }
|