"""分析服务(4 端 Dashboard 聚合 + DataScope 注入 + 降级兜底). 对齐 02-architecture-design.md §7 AnalyticsService: - GetTeacherDashboard:班级聚合(教师视角) - GetStudentDashboard:学生学情(学生视角) - GetParentDashboard:孩子学情(家长视角) - GetAdminDashboard:学校聚合 + AI 用量(管理员视角) DataScope 注入: - 教师只能查自己班级数据(CLASS 级 scope_ids 过滤) - 学生只能查自己数据(SELF 级 user_id 过滤) - 管理员可查全校数据(ALL 级无过滤) 降级策略: - ClickHouse 不可达:返回骨架数据 + degraded=true - iam gRPC 不可达:使用 role-based fallback + degraded=true """ from typing import Any import structlog from .repository import clickhouse_repository, iam_client from .shared.permissions import DataScopeLevel, UserContext logger = structlog.get_logger(__name__) def _skeleton_dashboard(dashboard_type: str, user_id: str, reason: str) -> dict[str, Any]: """降级骨架数据(ClickHouse 不可达时返回).""" return { "userId": user_id, "dashboardType": dashboard_type, "degraded": True, "degraded_reason": reason, "totalClasses": 0, "totalStudents": 0, "averageScore": 0.0, "passRate": 0.0, "weakStudents": [], "recentWarnings": [], "trend": [], } async def get_teacher_dashboard( user: UserContext, class_id: str = "", subject_id: str = "", ) -> dict[str, Any]: """教师仪表盘(聚合班级成绩 + 薄弱学生 + 预警). DataScope: - CLASS 级:仅返回 scope_ids 内的班级数据 - ALL 级:返回所有班级数据 """ # 1. 解析 DataScope scope = await iam_client.get_effective_datascope(user) degraded = scope.degraded # 2. 若指定 class_id,验证是否在 scope 内 effective_class_ids: list[str] = [] if class_id: if scope.level == DataScopeLevel.CLASS and class_id not in scope.scope_ids: return { **_skeleton_dashboard("teacher", user.user_id, "datascope_violation"), "error": "class_id_out_of_scope", } effective_class_ids = [class_id] elif scope.level == DataScopeLevel.CLASS: effective_class_ids = scope.scope_ids # 3. 查询 ClickHouse if not effective_class_ids and scope.level != DataScopeLevel.ALL: # CLASS 级无 scope_ids,返回空骨架 return _skeleton_dashboard("teacher", user.user_id, "no_class_scope") dashboard = await clickhouse_repository.query_teacher_dashboard( user_id=user.user_id, class_id=effective_class_ids[0] if effective_class_ids else "", ) if dashboard is None: return _skeleton_dashboard("teacher", user.user_id, "clickhouse_unavailable") # 4. 补充薄弱学生列表 weak_students: list[dict[str, Any]] = [] if effective_class_ids: for cid in effective_class_ids[:5]: # 限制 5 个班级 class_perf = await clickhouse_repository.query_class_performance( class_id=cid, subject_id=subject_id, ) if class_perf is not None: weak_students.append( { "class_id": cid, "total_students": class_perf.get("totalStudents", 0), "average_score": class_perf.get("averageScore", 0.0), "pass_rate": class_perf.get("passRate", 0.0), } ) # 5. 补充降级标记 if degraded: dashboard["degraded"] = True dashboard["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" dashboard["classes"] = weak_students dashboard["dashboardType"] = "teacher" return dashboard async def get_student_dashboard( user: UserContext, student_id: str = "", subject_id: str = "", ) -> dict[str, Any]: """学生仪表盘(学情宽表 + 薄弱知识点 + 学习趋势). DataScope: - SELF 级:student_id 必须为当前 user_id - CLASS/GRADE/SCHOOL/ALL 级:可查指定 student_id """ # 1. 解析 DataScope scope = await iam_client.get_effective_datascope(user) degraded = scope.degraded # 2. SELF 级强制限制 student_id effective_student_id = student_id or user.user_id if scope.level == DataScopeLevel.SELF: effective_student_id = user.user_id # SELF 级只能查自己 # 3. 查询 ClickHouse dashboard = await clickhouse_repository.query_student_dashboard(effective_student_id) if dashboard is None: return _skeleton_dashboard("student", effective_student_id, "clickhouse_unavailable") # 4. 补充薄弱知识点 weakness = await clickhouse_repository.query_student_weakness( student_id=effective_student_id, subject_id=subject_id, ) dashboard["weakPoints"] = weakness.get("weakPoints", []) if weakness else [] # 5. 补充学习趋势 trend = await clickhouse_repository.query_learning_trend( student_id=effective_student_id, subject_id=subject_id, ) dashboard["trend"] = trend.get("points", []) if trend else [] # 6. 补充掌握度快照 mastery = await clickhouse_repository.query_mastery_snapshot( student_id=effective_student_id, subject_id=subject_id, ) dashboard["mastery"] = mastery if mastery else {"knowledgePoints": [], "overallMastery": 0.0} # 7. 降级标记 if degraded: dashboard["degraded"] = True dashboard["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" dashboard["dashboardType"] = "student" return dashboard async def get_parent_dashboard( user: UserContext, child_id: str = "", ) -> dict[str, Any]: """家长仪表盘(孩子学情聚合). DataScope: - SELF 级:child_id 必须为家长关联的孩子(iam.GetChildrenByParent 校验) - 降级时使用 child_id 直接查询(依赖 Gateway 层鉴权) """ # 1. 解析 DataScope scope = await iam_client.get_effective_datascope(user) degraded = scope.degraded # 2. 学生 ID(家长视角:使用 child_id 或 user_id) effective_student_id = child_id or user.user_id # 3. 查询 ClickHouse(复用学生仪表盘查询) dashboard = await clickhouse_repository.query_student_dashboard(effective_student_id) if dashboard is None: return _skeleton_dashboard("parent", effective_student_id, "clickhouse_unavailable") # 4. 补充考勤 attendance = await clickhouse_repository.query_attendance(effective_student_id) dashboard["attendance"] = ( attendance if attendance else { "absentCount": 0, "lateCount": 0, "presentCount": 0, } ) # 5. 补充学习趋势 trend = await clickhouse_repository.query_learning_trend(effective_student_id) dashboard["trend"] = trend.get("points", []) if trend else [] # 6. 降级标记 if degraded: dashboard["degraded"] = True dashboard["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" dashboard["dashboardType"] = "parent" dashboard["childId"] = effective_student_id return dashboard async def get_admin_dashboard( user: UserContext, school_id: str = "", ) -> dict[str, Any]: """管理员仪表盘(学校聚合 + AI 用量统计). DataScope: - SCHOOL 级:仅返回指定 school_id 数据 - ALL 级:返回所有数据 """ # 1. 解析 DataScope scope = await iam_client.get_effective_datascope(user) degraded = scope.degraded # 2. 学校级聚合查询(复用 teacher_dashboard,不传 class_id) dashboard = await clickhouse_repository.query_teacher_dashboard( user_id=user.user_id, class_id="", ) if dashboard is None: return _skeleton_dashboard("admin", user.user_id, "clickhouse_unavailable") # 3. 补充 AI 用量统计(P5 启用,对齐 02 §7.4) ai_usage = await clickhouse_repository.query_ai_usage_summary() dashboard["aiUsage"] = ( ai_usage if ai_usage else { "totalRequests": 0, "totalTokens": 0, "totalCostCents": 0, "byProvider": [], } ) # 4. 降级标记 if degraded: dashboard["degraded"] = True dashboard["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" dashboard["dashboardType"] = "admin" dashboard["schoolId"] = school_id or scope.school_id return dashboard async def get_class_performance( user: UserContext, class_id: str, subject_id: str = "", start_date: int = 0, end_date: int = 0, ) -> dict[str, Any]: """查询班级成绩分析(GetClassPerformance RPC 实现). DataScope:CLASS 级需验证 class_id 在 scope_ids 内. """ scope = await iam_client.get_effective_datascope(user) degraded = scope.degraded # DataScope 校验 if scope.level == DataScopeLevel.CLASS and class_id not in scope.scope_ids: return { "classId": class_id, "degraded": True, "degraded_reason": "datascope_violation", "averageScore": 0.0, "passRate": 0.0, "totalStudents": 0, } result = await clickhouse_repository.query_class_performance( class_id=class_id, subject_id=subject_id, start_date=start_date, end_date=end_date, ) if result is None: return { "classId": class_id, "degraded": True, "degraded_reason": "clickhouse_unavailable", "averageScore": 0.0, "passRate": 0.0, "totalStudents": 0, } if degraded: result["degraded"] = True result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" return result async def get_mastery_distribution( user: UserContext, class_id: str, subject_id: str = "", knowledge_point_id: str = "", ) -> dict[str, Any]: """查询班级掌握度分布(GetMasteryDistribution RPC 实现).""" scope = await iam_client.get_effective_datascope(user) degraded = scope.degraded # DataScope 校验 if scope.level == DataScopeLevel.CLASS and class_id not in scope.scope_ids: return { "classId": class_id, "degraded": True, "degraded_reason": "datascope_violation", "masteredCount": 0, "progressingCount": 0, "weakCount": 0, "totalStudents": 0, } result = await clickhouse_repository.query_mastery_distribution( class_id=class_id, subject_id=subject_id, knowledge_point_id=knowledge_point_id, ) if result is None: return { "classId": class_id, "degraded": True, "degraded_reason": "clickhouse_unavailable", "masteredCount": 0, "progressingCount": 0, "weakCount": 0, "totalStudents": 0, } if degraded: result["degraded"] = True result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" return result async def get_student_mastery( user: UserContext, student_id: str, subject_id: str = "", ) -> dict[str, Any]: """查询学生知识点掌握度明细(GetStudentMastery RPC 实现).""" scope = await iam_client.get_effective_datascope(user) degraded = scope.degraded # SELF 级强制限制 effective_student_id = student_id if scope.level == DataScopeLevel.SELF: effective_student_id = user.user_id result = await clickhouse_repository.query_mastery_snapshot( student_id=effective_student_id, subject_id=subject_id, ) if result is None: return { "studentId": effective_student_id, "degraded": True, "degraded_reason": "clickhouse_unavailable", "knowledgePoints": [], "overallMastery": 0.0, } if degraded: result["degraded"] = True result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" return result async def get_student_weakness( user: UserContext, student_id: str, subject_id: str = "", ) -> dict[str, Any]: """查询学生薄弱知识点(GetStudentWeakness RPC 实现).""" scope = await iam_client.get_effective_datascope(user) degraded = scope.degraded # SELF 级强制限制 effective_student_id = student_id if scope.level == DataScopeLevel.SELF: effective_student_id = user.user_id result = await clickhouse_repository.query_student_weakness( student_id=effective_student_id, subject_id=subject_id, ) if result is None: return { "studentId": effective_student_id, "degraded": True, "degraded_reason": "clickhouse_unavailable", "weakPoints": [], } if degraded: result["degraded"] = True result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" return result async def get_learning_trend( user: UserContext, student_id: str, subject_id: str = "", start_date: int = 0, end_date: int = 0, ) -> dict[str, Any]: """查询学习趋势(GetLearningTrend RPC 实现).""" scope = await iam_client.get_effective_datascope(user) degraded = scope.degraded # SELF 级强制限制 effective_student_id = student_id if scope.level == DataScopeLevel.SELF: effective_student_id = user.user_id result = await clickhouse_repository.query_learning_trend( student_id=effective_student_id, subject_id=subject_id, start_date=start_date, end_date=end_date, ) if result is None: return { "studentId": effective_student_id, "degraded": True, "degraded_reason": "clickhouse_unavailable", "points": [], } if degraded: result["degraded"] = True result["degraded_reason"] = scope.degraded_reason or "iam_grpc_unavailable" return result