feat(data-ana): 完整实现 data-ana 数据分析服务
包含 CDC consumer、analytics/mastery/warning service、grpc server、repository、ClickHouse DDL 等
This commit is contained in:
450
services/data-ana/src/data_ana/analytics_service.py
Normal file
450
services/data-ana/src/data_ana/analytics_service.py
Normal 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 实现).
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user