包含 CDC consumer、analytics/mastery/warning service、grpc server、repository、ClickHouse DDL 等
451 lines
14 KiB
Python
451 lines
14 KiB
Python
"""分析服务(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
|