feat(data-ana): 完整实现 data-ana 数据分析服务

包含 CDC consumer、analytics/mastery/warning service、grpc server、repository、ClickHouse DDL 等
This commit is contained in:
SpecialX
2026-07-10 19:09:27 +08:00
parent 033057a302
commit ca3780aa24
29 changed files with 5401 additions and 383 deletions

View File

@@ -0,0 +1,450 @@
"""分析服务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