Files
Edu/services/data-ana/src/data_ana/analytics_service.py
SpecialX ca3780aa24 feat(data-ana): 完整实现 data-ana 数据分析服务
包含 CDC consumer、analytics/mastery/warning service、grpc server、repository、ClickHouse DDL 等
2026-07-10 19:09:27 +08:00

451 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分析服务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 实现).
DataScopeCLASS 级需验证 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