learningAnalyticsBase = ''; $this->timeout = 20; } /** * 新增错题 */ public function createMistake(array $payload): array { $studentId = $payload['student_id'] ?? null; $questionId = $payload['question_id'] ?? null; $paperId = $payload['paper_id'] ?? null; $myAnswer = $payload['my_answer'] ?? null; $correctAnswer = $payload['correct_answer'] ?? null; $questionText = $payload['question_text'] ?? null; $knowledgePoint = $payload['knowledge_point'] ?? null; $explanation = $payload['explanation'] ?? null; $kpIds = $payload['kp_ids'] ?? 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, $paperId, $myAnswer, $correctAnswer, $questionText, $knowledgePoint, $explanation, $kpIds, $source, $happenedAt ) { // 【修复】检查是否已存在相同错题(避免重复) // 重复定义:同一学生 + 同一题目 + 同一卷子 的组合 // 这样允许同一题目在不同卷子中重复,也允许同一卷子中的新题目被记录 $query = MistakeRecord::where('student_id', $studentId) ->where('source', $source); // 如果有 question_id 和 paper_id,用它们去重 // 否则用 question_text 去重 if ($questionId && $paperId) { $query->where('question_id', $questionId) ->where('paper_id', $paperId); Log::debug('错题记录重复检查', [ 'student_id' => $studentId, 'question_id' => $questionId, 'paper_id' => $paperId, 'check_type' => 'question_id + paper_id' ]); } elseif ($questionId) { $query->where('question_id', $questionId); Log::debug('错题记录重复检查', [ 'student_id' => $studentId, 'question_id' => $questionId, 'check_type' => 'question_id only' ]); } elseif ($questionText) { $query->where('question_text', $questionText); Log::debug('错题记录重复检查', [ 'student_id' => $studentId, 'question_text' => substr($questionText, 0, 50) . '...', 'check_type' => 'question_text' ]); } else { Log::warning('错题记录保存失败:缺少题目ID和题目文本', [ 'student_id' => $studentId, 'paper_id' => $paperId ]); return [ 'duplicate' => false, 'error' => '缺少题目ID和题目文本,无法创建错题记录' ]; } $existingMistake = $query->first(); if ($existingMistake) { Log::debug('发现重复错题记录,合并知识点', [ 'student_id' => $studentId, 'question_id' => $questionId, 'paper_id' => $paperId, 'existing_mistake_id' => $existingMistake->id ]); // 合并知识点到已有记录中 if ($kpIds) { $existingKpIds = $existingMistake->kp_ids ?? []; $newKpIds = is_array($kpIds) ? $kpIds : [$kpIds]; // 合并并去重 $mergedKpIds = array_values(array_unique(array_merge($existingKpIds, $newKpIds))); // 更新记录(仅更新 kp_ids) $existingMistake->update([ 'kp_ids' => $mergedKpIds, ]); Log::info('错题记录知识点合并成功', [ 'student_id' => $studentId, 'question_id' => $questionId, 'paper_id' => $paperId, 'existing_kp_ids' => $existingKpIds, 'new_kp_ids' => $newKpIds, 'merged_kp_ids' => $mergedKpIds ]); } return [ 'duplicate' => true, 'mistake_id' => $existingMistake->id, 'message' => '错题已存在,已合并知识点', ]; } // 创建错题记录(合并知识点到数组) $mistake = MistakeRecord::create([ 'student_id' => $studentId, 'question_id' => $questionId, 'paper_id' => $paperId, 'student_answer' => $myAnswer, 'correct_answer' => $correctAnswer, 'question_text' => $questionText, 'knowledge_point' => $knowledgePoint, 'explanation' => $explanation, 'kp_ids' => is_array($kpIds) ? $kpIds : ($kpIds ? [$kpIds] : null), // 确保是数组 '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], 'statistics' => [ // ✅ 无 student_id 时也返回空统计 'total' => 0, 'pending' => 0, 'reviewed' => 0, 'mastered' => 0, 'favorites' => 0, 'in_retry_list' => 0, 'this_week' => 0, 'mastery_rate' => 0.0, ], ]; } // 构建缓存键 $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, false); })->toArray(); // 获取统计信息 $summary = $this->summarize($studentId); $result = [ 'data' => $data, 'meta' => [ 'total' => $total, 'page' => $page, 'per_page' => $perPage, 'last_page' => (int) ceil($total / $perPage), ], 'statistics' => $summary, // ✅ 添加统计信息 ]; // 缓存结果 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], 'statistics' => [ // ✅ 错误时也返回空统计 'total' => 0, 'pending' => 0, 'reviewed' => 0, 'mastered' => 0, 'favorites' => 0, 'in_retry_list' => 0, 'this_week' => 0, 'mastery_rate' => 0.0, ], ]; } } /** * 获取错题详情 */ 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, 'reviewed_at' => null, 'next_review_at' => null, ]), default => throw new \InvalidArgumentException('无效的操作类型'), }; // ⚠️ 重要:重新加载模型数据以获取最新状态 $mistake->refresh(); 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, '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 { // 【新增】通过 question_id 关联获取题目详情 $questionDetails = $this->getQuestionDetails($mistake->question_id); // 【新增】通过 kp_ids 或 textbook_catalog_nodes_id 获取知识点信息 $knowledgePoints = $this->getKnowledgePoints( $mistake->kp_ids, $mistake->question_id, $questionDetails['textbook_catalog_nodes_id'] ?? null ); $data = [ // ========== 错题记录基本信息 ========== 'id' => $mistake->id, 'student_id' => $mistake->student_id, 'question_id' => $mistake->question_id, 'paper_id' => $mistake->paper_id, '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, 'explanation' => $mistake->explanation, 'skill_ids' => $mistake->skill_ids, 'difficulty' => $mistake->difficulty, 'difficulty_level' => $mistake->difficulty_level, 'importance' => $mistake->importance, 'mastery_level' => $mistake->mastery_level, // ========== 题目信息(优先呈现)========== // 题目内容:优先使用 questions.stem,其次使用错题本自带 'question_text' => $questionDetails['stem'] ?? $mistake->question_text, // 答案:优先使用 questions.answer,其次使用错题本自带 'correct_answer' => $questionDetails['answer'] ?? $mistake->correct_answer, // 解题过程/解析:优先使用 questions.solution 'solution' => $questionDetails['solution'] ?? $mistake->explanation, // 选项 'options' => $questionDetails['options'] ?? null, // 题目类型 'question_type' => $questionDetails['question_type'] ?? null, // 题目难度(从题目表获取) 'question_difficulty' => $questionDetails['difficulty'] ?? null, // 题目标签 'question_tags' => $questionDetails['tags'] ?? null, // 题目来源 'question_source' => $questionDetails['source'] ?? null, // 学生答案(始终使用错题本中的记录) 'student_answer' => $mistake->student_answer, // ========== 知识点信息 ========== 'knowledge_points' => $knowledgePoints, ]; 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 { try { $patterns = [ "mistake_book:list:{$studentId}", "mistake_book:summary:{$studentId}", "mistake_book:patterns:{$studentId}", ]; foreach ($patterns as $key) { Cache::forget($key); } } catch (\Exception $e) { // 缓存清除失败不影响主流程 Log::debug('缓存清除失败(可忽略)', ['error' => $e->getMessage()]); } } /** * 【新增】通过 question_id 获取题目详情 */ private function getQuestionDetails(?string $questionId): ?array { if (!$questionId) { return null; } try { $question = \DB::connection('mysql') ->table('questions') ->where('id', $questionId) ->first(); if (!$question) { return null; } return [ 'stem' => $question->stem ?? null, // 题目内容(题干) 'options' => $question->options ?? null, // 选项 'answer' => $question->answer ?? null, // 答案 'solution' => $question->solution ?? null, // 解题过程/解析 'difficulty' => $question->difficulty ?? null, 'question_type' => $question->question_type ?? null, 'textbook_catalog_nodes_id' => $question->textbook_catalog_nodes_id ?? null, 'tags' => $question->tags ?? null, // 标签 'source' => $question->source ?? null, // 来源 ]; } catch (\Exception $e) { Log::warning('获取题目详情失败', [ 'question_id' => $questionId, 'error' => $e->getMessage() ]); return null; } } /** * 【新增】通过 kp_ids 或 textbook_catalog_nodes_id 获取知识点信息 * * 逻辑: * 1. 如果有 kp_ids,直接从 knowledge_points 表查询 * 2. 如果没有 kp_ids,通过 textbook_catalog_nodes_id 关联 textbook_chapter_knowledge_relation 表获取 kp_id */ private function getKnowledgePoints(?array $kpIds, ?string $questionId = null, ?int $catalogNodesId = null): array { // 如果有 kp_ids,直接查询 if (!empty($kpIds)) { return $this->queryKnowledgePointsByCodes($kpIds); } // 如果没有 kp_ids,通过题目ID和目录节点ID关联查询 if ($questionId || $catalogNodesId) { $kpCodesFromRelation = $this->getKpCodesFromRelation($questionId, $catalogNodesId); if (!empty($kpCodesFromRelation)) { return $this->queryKnowledgePointsByCodes($kpCodesFromRelation); } } return []; } /** * 通过 kp_codes 查询知识点信息 */ private function queryKnowledgePointsByCodes(array $kpCodes): array { try { $knowledgePoints = \DB::connection('mysql') ->table('knowledge_points') ->whereIn('kp_code', $kpCodes) ->select(['kp_code', 'name', 'parent_kp_code']) ->get() ->toArray(); return array_map(function ($kp) { return [ 'kp_code' => $kp->kp_code, 'name' => $kp->name ?? $kp->kp_code, 'parent_kp_code' => $kp->parent_kp_code, ]; }, $knowledgePoints); } catch (\Exception $e) { Log::warning('通过kp_codes获取知识点信息失败', [ 'kp_codes' => $kpCodes, 'error' => $e->getMessage() ]); return []; } } /** * 通过 textbook_catalog_nodes_id 关联 textbook_chapter_knowledge_relation 表获取 kp_codes */ private function getKpCodesFromRelation(?string $questionId, ?int $catalogNodesId): array { try { $query = \DB::connection('mysql') ->table('textbook_chapter_knowledge_relation') ->where('is_deleted', 0); if ($catalogNodesId) { $query->where('catalog_chapter_id', $catalogNodesId); } elseif ($questionId) { // 如果没有catalog_nodes_id,尝试从questions表获取 $question = \DB::connection('mysql') ->table('questions') ->where('id', $questionId) ->first(); if ($question && $question->textbook_catalog_nodes_id) { $query->where('catalog_chapter_id', $question->textbook_catalog_nodes_id); } } $relations = $query->select('kp_code')->get(); if ($relations->isNotEmpty()) { Log::debug('通过目录节点关联获取到知识点', [ 'question_id' => $questionId, 'catalog_nodes_id' => $catalogNodesId, 'kp_codes' => $relations->pluck('kp_code')->toArray() ]); } return $relations->pluck('kp_code')->toArray(); } catch (\Exception $e) { Log::warning('通过目录节点关联获取kp_codes失败', [ 'question_id' => $questionId, 'catalog_nodes_id' => $catalogNodesId, 'error' => $e->getMessage() ]); return []; } } }