learningAnalyticsBase = rtrim( $learningAnalyticsBase ?: config('services.learning_analytics.url', env('LEARNING_ANALYTICS_API_BASE', 'http://localhost:5016')), '/' ); $this->questionBankBase = rtrim( $questionBankBase ?: config('services.question_bank.base_url', env('QUESTION_BANK_API_BASE', 'http://localhost:5015')), '/' ); $this->timeout = $timeout ?? (int) config('services.learning_analytics.timeout', 20); } /** * 获取错题列表(支持多维筛选) */ public function listMistakes(array $params = []): array { $query = array_filter([ 'student_id' => $params['student_id'] ?? null, 'kp_ids' => $this->implodeIfArray($params['kp_ids'] ?? null), 'skill_ids' => $this->implodeIfArray($params['skill_ids'] ?? null), 'error_types' => $this->implodeIfArray($params['error_types'] ?? null), 'time_range' => $params['time_range'] ?? null, 'start_date' => $params['start_date'] ?? null, 'end_date' => $params['end_date'] ?? null, 'incorrect_only' => true, // ✅ 只获取错误记录(错题本的核心功能) 'page' => $params['page'] ?? 1, 'per_page' => $params['per_page'] ?? 20, ], fn ($value) => filled($value)); try { $response = Http::timeout($this->timeout) ->get($this->learningAnalyticsBase . '/api/mistake-book', $query); if ($response->successful()) { info("MistakeBookService::listMistakes", [$response->json()]); $body = $response->json(); $result = is_array($body) ? $body : ['data' => $body]; // 补充知识点名称、技能信息和难度 $result = $this->enrichMistakeData($result); // 获取统计数据 if (!empty($params['student_id'])) { $summary = $this->summarize($params['student_id']); $result['statistics'] = [ 'total_mistakes' => $summary['total'] ?? 0, 'this_week' => $summary['this_week'] ?? 0, 'pending_review' => $summary['pending_review'] ?? 0, 'mastery_rate' => $summary['mastery_rate'] ?? 0.0, ]; } return $result; } Log::warning('MistakeBook list failed', [ 'status' => $response->status(), 'body' => $response->body(), 'query' => $query, ]); } catch (\Throwable $e) { Log::error('MistakeBook list exception', [ 'error' => $e->getMessage(), 'query' => $query, ]); } return [ 'data' => [], 'meta' => ['total' => 0, 'page' => 1, 'per_page' => $query['per_page'] ?? 20], ]; } /** * 获取单条错题详情(可选带学生ID保证隔离) */ public function getMistakeDetail(string $mistakeId, ?string $studentId = null): array { $query = array_filter([ 'student_id' => $studentId, ], fn ($value) => filled($value)); try { $response = Http::timeout($this->timeout) ->get($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId, $query); if ($response->successful()) { $body = $response->json(); $result = is_array($body) ? $body : []; // 补充知识点名称和技能信息 $result = $this->enrichSingleMistakeData($result); return $result; } Log::warning('Mistake detail request failed', [ 'mistake_id' => $mistakeId, 'status' => $response->status(), 'body' => $response->body(), ]); // 兼容:LearningAnalytics 当前未提供单条详情接口时,从列表中过滤 if ($studentId) { $fallback = $this->listMistakes([ 'student_id' => $studentId, 'per_page' => 100, // 兼容后端限制(<=100) ]); $matched = collect($fallback['data'] ?? [])->firstWhere('id', (int) $mistakeId); if ($matched) { return $matched; } } } catch (\Throwable $e) { Log::error('Mistake detail request exception', [ 'mistake_id' => $mistakeId, 'student_id' => $studentId, 'error' => $e->getMessage(), ]); } return []; } /** * 获取错题统计概要 */ public function summarize(string $studentId): array { try { $response = Http::timeout($this->timeout) ->get($this->learningAnalyticsBase . '/api/mistake-book/summary', [ 'student_id' => $studentId, ]); if ($response->successful()) { $body = $response->json(); return is_array($body) ? $body : []; } Log::warning('MistakeBook summary failed', [ 'status' => $response->status(), 'body' => $response->body(), 'student_id' => $studentId, ]); } catch (\Throwable $e) { Log::error('MistakeBook summary exception', [ 'student_id' => $studentId, 'error' => $e->getMessage(), ]); } // fallback 返回一个空结构,避免前端崩溃 return [ 'total' => 0, 'this_week' => 0, 'pending_review' => 0, 'mastery_rate' => null, ]; } /** * 获取错误模式与推荐路径 */ public function getMistakePatterns(string $studentId): array { try { $response = Http::timeout($this->timeout) ->get($this->learningAnalyticsBase . '/api/analytics/mistake-pattern', [ 'student_id' => $studentId, ]); if ($response->successful()) { $body = $response->json(); return is_array($body) ? $body : []; } Log::warning('Mistake pattern request failed', [ 'status' => $response->status(), 'body' => $response->body(), 'student_id' => $studentId, ]); } catch (\Throwable $e) { Log::error('Mistake pattern request exception', [ 'student_id' => $studentId, 'error' => $e->getMessage(), ]); } return []; } /** * 收藏/取消收藏错题 */ public function toggleFavorite(string $mistakeId, bool $favorite = true): bool { try { $response = Http::timeout($this->timeout) ->post($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/favorite', [ 'favorite' => $favorite, ]); return $response->successful(); } catch (\Throwable $e) { Log::error('Toggle favorite failed', [ 'mistake_id' => $mistakeId, 'favorite' => $favorite, 'error' => $e->getMessage(), ]); } return false; } /** * 标记已复习(向后兼容) */ public function markReviewed(string $mistakeId): bool { try { $response = Http::timeout($this->timeout) ->post($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/review'); return $response->successful(); } catch (\Throwable $e) { Log::error('Mark reviewed failed', [ 'mistake_id' => $mistakeId, 'error' => $e->getMessage(), ]); } return false; } /** * 修改复习状态 * * @param string $mistakeId 错题ID * @param string $action 操作类型:'increment' 或 'reset' * @param bool $forceReview 是否强制复习(仅对 increment 有效) * @return array 复习状态信息 */ public function updateReviewStatus(string $mistakeId, string $action = 'increment', bool $forceReview = false): array { try { $payload = array_filter([ 'action' => $action, 'force_review' => $forceReview, ]); $response = Http::timeout($this->timeout) ->post($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/review-status', $payload); if ($response->successful()) { $body = $response->json(); return is_array($body) ? $body : []; } Log::warning('Update review status failed', [ 'mistake_id' => $mistakeId, 'action' => $action, 'force_review' => $forceReview, 'status' => $response->status(), 'body' => $response->body(), ]); } catch (\Throwable $e) { Log::error('Update review status exception', [ 'mistake_id' => $mistakeId, 'action' => $action, 'force_review' => $forceReview, 'error' => $e->getMessage(), ]); } return [ 'success' => false, 'error' => '更新复习状态失败', ]; } /** * 获取复习状态 * * @param string $mistakeId 错题ID * @return array 复习状态信息 */ public function getReviewStatus(string $mistakeId): array { try { $response = Http::timeout($this->timeout) ->get($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/review-status'); if ($response->successful()) { $body = $response->json(); return is_array($body) ? $body : []; } Log::warning('Get review status failed', [ 'mistake_id' => $mistakeId, 'status' => $response->status(), 'body' => $response->body(), ]); } catch (\Throwable $e) { Log::error('Get review status exception', [ 'mistake_id' => $mistakeId, 'error' => $e->getMessage(), ]); } return [ 'success' => false, 'error' => '获取复习状态失败', ]; } /** * 增加复习次数 * * @param string $mistakeId 错题ID * @param bool $forceReview 是否强制复习 * @return array 复习状态信息 */ public function incrementReviewCount(string $mistakeId, bool $forceReview = false): array { return $this->updateReviewStatus($mistakeId, 'increment', $forceReview); } /** * 重置为强制复习状态 * * @param string $mistakeId 错题ID * @return array 复习状态信息 */ public function resetReviewStatus(string $mistakeId): array { return $this->updateReviewStatus($mistakeId, 'reset'); } /** * 添加到重练清单 */ public function addToRetryList(string $mistakeId): bool { try { $response = Http::timeout($this->timeout) ->post($this->learningAnalyticsBase . '/api/mistake-book/' . $mistakeId . '/retry-list'); return $response->successful(); } catch (\Throwable $e) { Log::error('Add to retry list failed', [ 'mistake_id' => $mistakeId, 'error' => $e->getMessage(), ]); } return false; } /** * 基于错题推荐练习题 */ public function recommendPractice(string $studentId, array $kpIds = [], array $skillIds = []): array { $payload = array_filter([ 'student_id' => $studentId, 'kp_ids' => array_values(array_unique($kpIds)), 'skill_ids' => array_values(array_unique($skillIds)), ], fn ($value) => $value !== null); try { $response = Http::timeout($this->timeout) ->post($this->questionBankBase . '/api/questions/recommend', $payload); if ($response->successful()) { $body = $response->json(); return is_array($body) ? $body : ['data' => $body]; } Log::warning('Recommend practice failed', [ 'status' => $response->status(), 'body' => $response->body(), 'payload' => $payload, ]); } catch (\Throwable $e) { Log::error('Recommend practice exception', [ 'payload' => $payload, 'error' => $e->getMessage(), ]); } return ['data' => []]; } /** * 为学生仪表板提供快照数据 */ public function getPanelSnapshot(string $studentId, int $limit = 5): array { $list = $this->listMistakes([ 'student_id' => $studentId, 'per_page' => $limit, ]); $patterns = $this->getMistakePatterns($studentId); $summary = $this->summarize($studentId); return [ 'recent' => $list['data'] ?? [], 'weak_skills' => $patterns['top_skills'] ?? [], 'weak_kps' => $patterns['top_kps'] ?? [], 'error_types' => $patterns['error_types'] ?? [], 'recommend_path' => $patterns['recommend_path'] ?? [], 'stats' => [ 'total' => $summary['total'] ?? Arr::get($list, 'meta.total', 0), 'this_week' => $summary['this_week'] ?? null, 'pending_review' => $summary['pending_review'] ?? null, 'mastery_rate' => $summary['mastery_rate'] ?? null, ], ]; } private function implodeIfArray($value): ?string { if (is_array($value)) { return implode(',', array_filter($value)); } return $value; } /** * 获取题目的全体正确率(LearningAnalytics 聚合) */ public function getQuestionAccuracy(string $questionId): ?float { try { $response = Http::timeout($this->timeout) ->get($this->learningAnalyticsBase . '/api/analytics/question/' . $questionId . '/accuracy'); if ($response->successful()) { $body = $response->json(); return isset($body['accuracy']) ? floatval($body['accuracy']) : null; } } catch (\Throwable $e) { Log::warning('Get question accuracy failed', [ 'question_id' => $questionId, 'error' => $e->getMessage(), ]); } return null; } /** * 补充错题数据中的知识点名称、技能信息和难度 */ private function enrichMistakeData(array $result): array { Log::info('MistakeBookService::enrichMistakeData - Starting enrichment', [ 'data_count' => count($result['data'] ?? []), ]); if (!isset($result['data']) || !is_array($result['data'])) { Log::warning('MistakeBookService::enrichMistakeData - No data to enrich'); return $result; } // 获取所有知识点代码 $kpCodes = []; foreach ($result['data'] as $item) { if (!empty($item['question']['kp_code'])) { $kpCodes[] = $item['question']['kp_code']; } if (!empty($item['kp_ids']) && is_array($item['kp_ids'])) { $kpCodes = array_merge($kpCodes, $item['kp_ids']); } } $kpCodes = array_unique(array_filter($kpCodes)); Log::info('MistakeBookService::enrichMistakeData - Found kp_codes', [ 'kp_codes' => $kpCodes, ]); // 从KnowledgeService获取知识点详细信息 $kpDetailMap = []; foreach ($kpCodes as $kpCode) { $detail = $this->getKnowledgePointDetail($kpCode); if (!empty($detail)) { $kpDetailMap[$kpCode] = $detail; } } Log::info('MistakeBookService::enrichMistakeData - Retrieved kp details', [ 'kp_detail_map_keys' => array_keys($kpDetailMap), ]); // 补充数据 foreach ($result['data'] as &$item) { // 补充知识点名称和技能信息 if (isset($item['question']['kp_code'])) { $kpCode = $item['question']['kp_code']; if (isset($kpDetailMap[$kpCode])) { $detail = $kpDetailMap[$kpCode]; $item['question']['kp_name'] = $detail['cn_name'] ?? $detail['en_name'] ?? $kpCode; $item['question']['skills'] = array_column($detail['skills'] ?? [], 'skill_name'); Log::info('MistakeBookService::enrichMistakeData - Enriched item', [ 'kp_code' => $kpCode, 'kp_name' => $item['question']['kp_name'], 'skills' => $item['question']['skills'], ]); } else { // 如果没有找到详细信息,使用代码作为名称 $item['question']['kp_name'] = $kpCode; $item['question']['skills'] = []; Log::warning('MistakeBookService::enrichMistakeData - No detail found for kp', [ 'kp_code' => $kpCode, ]); } } // 确保kp_ids数组存在且不为空 if (!isset($item['kp_ids']) || !is_array($item['kp_ids'])) { $item['kp_ids'] = []; } // 如果kp_ids为空但有kp_code,则添加 if (empty($item['kp_ids']) && !empty($item['question']['kp_code'])) { $item['kp_ids'] = [$item['question']['kp_code']]; } // 确保skill_ids数组存在 if (!isset($item['skill_ids']) || !is_array($item['skill_ids'])) { $item['skill_ids'] = []; } // 如果skill_ids为空但有skills,则使用skills if (empty($item['skill_ids']) && !empty($item['question']['skills'])) { $item['skill_ids'] = $item['question']['skills']; } // 补充难度值(如果缺失) if (!isset($item['question']['difficulty']) && isset($item['question']['kp_code'])) { $kpCode = $item['question']['kp_code']; if (isset($kpDetailMap[$kpCode])) { $detail = $kpDetailMap[$kpCode]; // 使用importance作为难度(1-10) $item['question']['difficulty'] = ($detail['importance'] ?? 5) / 10.0; Log::info('MistakeBookService::enrichMistakeData - Set difficulty', [ 'kp_code' => $kpCode, 'difficulty' => $item['question']['difficulty'], 'importance' => $detail['importance'] ?? 5, ]); } } } Log::info('MistakeBookService::enrichMistakeData - Completed enrichment', [ 'processed_count' => count($result['data']), ]); return $result; } /** * 补充单条错题数据中的知识点名称、技能信息和难度 */ private function enrichSingleMistakeData(array $item): array { if (empty($item)) { return $item; } // 从KnowledgeService获取知识点详细信息 $kpDetail = null; if (isset($item['question']['kp_code'])) { $kpCode = $item['question']['kp_code']; $kpDetail = $this->getKnowledgePointDetail($kpCode); } // 补充知识点名称和技能信息 if (isset($item['question']['kp_code']) && $kpDetail) { $item['question']['kp_name'] = $kpDetail['cn_name'] ?? $kpDetail['en_name'] ?? $item['question']['kp_code']; $item['question']['skills'] = array_column($kpDetail['skills'] ?? [], 'skill_name'); } else { if (isset($item['question']['kp_code'])) { $item['question']['kp_name'] = $item['question']['kp_code']; } $item['question']['skills'] = []; } // 确保kp_ids数组存在且不为空 if (!isset($item['kp_ids']) || !is_array($item['kp_ids'])) { $item['kp_ids'] = []; } // 如果kp_ids为空但有kp_code,则添加 if (empty($item['kp_ids']) && !empty($item['question']['kp_code'])) { $item['kp_ids'] = [$item['question']['kp_code']]; } // 确保skill_ids数组存在 if (!isset($item['skill_ids']) || !is_array($item['skill_ids'])) { $item['skill_ids'] = []; } // 如果skill_ids为空但有skills,则使用skills if (empty($item['skill_ids']) && !empty($item['question']['skills'])) { $item['skill_ids'] = $item['question']['skills']; } // 补充难度值(如果缺失) if (!isset($item['question']['difficulty']) && $kpDetail) { // 使用importance作为难度(1-10) $item['question']['difficulty'] = ($kpDetail['importance'] ?? 5) / 10.0; } return $item; } /** * 从QuestionBank服务获取知识点详细信息 */ private function getKnowledgePointDetail(string $kpCode): ?array { Log::info('MistakeBookService::getKnowledgePointDetail - Fetching detail from QuestionBank', [ 'kp_code' => $kpCode, ]); try { // 获取QuestionBank中的题目来获取知识点信息 $response = Http::timeout($this->timeout) ->get($this->questionBankBase . '/api/questions', [ 'kp_code' => $kpCode, 'limit' => 1, ]); Log::info('MistakeBookService::getKnowledgePointDetail - Response from QuestionBank', [ 'status' => $response->status(), 'has_body' => !empty($response->body()), ]); if ($response->successful()) { $data = $response->json(); $questions = $data['data'] ?? []; if (!empty($questions)) { $question = $questions[0]; $detail = [ 'kp_code' => $kpCode, 'cn_name' => $question['kp_name'] ?? $kpCode, 'skills' => [], 'importance' => null, ]; // 提取技能信息 if (!empty($question['skills'])) { foreach ($question['skills'] as $skillCode) { $detail['skills'][] = [ 'skill_code' => $skillCode, 'skill_name' => $skillCode, ]; } } // 从difficulty计算importance(0-1转为1-10) if (isset($question['difficulty'])) { $detail['importance'] = intval($question['difficulty'] * 10); } Log::info('MistakeBookService::getKnowledgePointDetail - Success', [ 'kp_code' => $kpCode, 'kp_name' => $detail['cn_name'], 'skills_count' => count($detail['skills']), 'importance' => $detail['importance'], ]); return $detail; } } } catch (\Throwable $e) { Log::error('Get knowledge point detail from QuestionBank failed', [ 'kp_code' => $kpCode, 'error' => $e->getMessage(), ]); } return null; } }