Files
Edu/services/data-ana/src/data_ana/grpc_server.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

571 lines
19 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.
"""gRPC ServerAnalyticsService 12 RPC + HealthService.
对齐 02-architecture-design.md §4.2
- AnalyticsService 12 RPC含 1 个 server-streaming SubscribeMasteryUpdate
- HealthServicegrpc.health.v1供 K8s 探针)
- 端口 50055
- 所有 RPC 返回结构化 messageHTTP 层包装 ActionState 信封)
SubscribeMasteryUpdateP5+
- server-streaming RPC
- 客户端订阅 student_id / class_id
- 掌握度计算完成时推送 MasteryUpdateEvent
- 基于 asyncio.Queue 实现事件分发
降级策略:
- grpcio 未安装gRPC server 不启动(仅 HTTP 服务)
- 下游服务不可达返回降级响应degraded 字段标记)
"""
import asyncio
from typing import Any
import structlog
from . import analytics_service, warning_service
from .config import settings
from .shared.permissions import UserContext
logger = structlog.get_logger(__name__)
# SubscribeMasteryUpdate 订阅管理P5+
_subscribers: dict[str, asyncio.Queue] = {}
_subscribers_lock = asyncio.Lock()
async def _add_subscriber(key: str) -> asyncio.Queue:
"""添加订阅者."""
async with _subscribers_lock:
if key not in _subscribers:
_subscribers[key] = asyncio.Queue(maxsize=100)
return _subscribers[key]
async def _remove_subscriber(key: str) -> None:
"""移除订阅者."""
async with _subscribers_lock:
_subscribers.pop(key, None)
async def notify_mastery_update(
student_id: str,
knowledge_point_id: str,
mastery_level: float,
previous_level: float,
) -> None:
"""通知所有订阅者掌握度更新(由 mastery_service 调用).
订阅匹配规则:
- 订阅 student_id 的客户端收到通知
- 订阅 class_id 的客户端收到该班级所有学生的通知(需要 ExamCache 查 class_id
"""
import time
import uuid
event_id = str(uuid.uuid4())
calculated_at = int(time.time())
# 通知 student_id 订阅者
queue = _subscribers.get(f"student:{student_id}")
if queue is not None:
try:
queue.put_nowait(
{
"event_id": event_id,
"student_id": student_id,
"knowledge_point_id": knowledge_point_id,
"mastery_level": mastery_level,
"previous_level": previous_level,
"calculated_at": calculated_at,
}
)
except asyncio.QueueFull:
logger.warning("mastery_update_queue_full_dropped", student_id=student_id)
# ===== AnalyticsService 实现 =====
class AnalyticsServiceServicer:
"""AnalyticsService gRPC 实现12 RPC."""
async def GetClassPerformance(self, request, context):
"""班级成绩分析."""
user = _extract_user(context)
result = await analytics_service.get_class_performance(
user=user,
class_id=request.class_id,
subject_id=request.subject_id,
start_date=request.start_date,
end_date=request.end_date,
)
return _build_class_performance_response(result)
async def GetStudentWeakness(self, request, context):
"""学生薄弱知识点."""
user = _extract_user(context)
result = await analytics_service.get_student_weakness(
user=user,
student_id=request.student_id,
subject_id=request.subject_id,
)
return _build_student_weakness_response(result)
async def GetLearningTrend(self, request, context):
"""学习趋势."""
user = _extract_user(context)
result = await analytics_service.get_learning_trend(
user=user,
student_id=request.student_id,
subject_id=request.subject_id,
start_date=request.start_date,
end_date=request.end_date,
)
return _build_learning_trend_response(result)
async def GetTeacherDashboard(self, request, context):
"""教师仪表盘."""
user = _extract_user(context)
result = await analytics_service.get_teacher_dashboard(
user=user,
class_id=request.class_id,
)
return _build_teacher_dashboard_response(result)
async def GetStudentDashboard(self, request, context):
"""学生仪表盘."""
user = _extract_user(context)
result = await analytics_service.get_student_dashboard(
user=user,
)
return _build_student_dashboard_response(result)
async def GetParentDashboard(self, request, context):
"""家长仪表盘."""
user = _extract_user(context)
result = await analytics_service.get_parent_dashboard(
user=user,
child_id=request.student_id,
)
return _build_parent_dashboard_response(result)
async def GetAdminDashboard(self, request, context):
"""管理员仪表盘."""
user = _extract_user(context)
result = await analytics_service.get_admin_dashboard(
user=user,
school_id=request.scope_id,
)
return _build_admin_dashboard_response(result)
async def GetWarnings(self, request, context):
"""预警列表查询."""
# 简化:按 class_id 查询P6 改为多条件过滤)
warnings = await warning_service.get_warnings(
student_id="", # gRPC 层按 class_id 查询P6 扩展
warning_type="",
)
return _build_warning_list_response(warnings)
async def TriggerWarning(self, request, context):
"""手动触发预警."""
result = await warning_service.trigger_warning_manual(
target_id=request.target_id,
warning_type=request.warning_type,
threshold=0.0,
current_value=0.0,
severity=request.severity,
)
return _build_trigger_warning_response(result)
async def GetMasteryDistribution(self, request, context):
"""班级掌握度分布."""
user = _extract_user(context)
result = await analytics_service.get_mastery_distribution(
user=user,
class_id=request.class_id,
subject_id=request.subject_id,
knowledge_point_id=request.knowledge_point_id,
)
return _build_mastery_distribution_response(result)
async def GetStudentMastery(self, request, context):
"""学生知识点掌握度明细."""
user = _extract_user(context)
result = await analytics_service.get_student_mastery(
user=user,
student_id=request.student_id,
subject_id=request.subject_id,
)
return _build_student_mastery_response(result)
async def SubscribeMasteryUpdate(self, request, context):
"""订阅掌握度更新server-streamingP5+."""
# 确定订阅 key
if request.student_id:
sub_key = f"student:{request.student_id}"
elif request.class_id:
sub_key = f"class:{request.class_id}"
else:
sub_key = "global"
queue = await _add_subscriber(sub_key)
logger.info(
"mastery_subscription_added",
sub_key=sub_key,
peer=context.peer() if hasattr(context, "peer") else "unknown",
)
try:
while context.is_active():
try:
event = await asyncio.wait_for(queue.get(), timeout=30.0)
yield _build_mastery_update_event(event)
except TimeoutError:
# 心跳30s 无事件时发一个空 keepalive客户端应容忍
continue
except asyncio.CancelledError:
logger.info("mastery_subscription_cancelled", sub_key=sub_key)
raise
finally:
await _remove_subscriber(sub_key)
logger.info("mastery_subscription_removed", sub_key=sub_key)
# ===== 辅助函数 =====
def _extract_user(context: Any) -> UserContext:
"""从 gRPC metadata 提取用户上下文."""
user_id = ""
roles: list[str] = []
try:
metadata = (
dict(context.invocation_metadata()) if hasattr(context, "invocation_metadata") else {}
)
user_id = metadata.get("x-user-id", "")
roles_str = metadata.get("x-user-roles", "")
roles = [r.strip() for r in roles_str.split(",") if r.strip()] if roles_str else []
except Exception: # noqa: BLE001
pass
return UserContext(user_id=user_id, roles=roles)
def _build_class_performance_response(data: dict) -> Any:
"""构建 ClassPerformance proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
return analytics_pb2.ClassPerformance(
class_id=data.get("classId", ""),
average_score=data.get("averageScore", 0.0),
pass_rate=data.get("passRate", 0.0),
total_students=data.get("totalStudents", 0),
)
def _build_student_weakness_response(data: dict) -> Any:
"""构建 StudentWeakness proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
weak_points = [
analytics_pb2.WeakPoint(
knowledge_point_id=wp.get("knowledgePointId", ""),
title=wp.get("title", ""),
mastery=wp.get("mastery", 0.0),
error_count=wp.get("errorCount", 0),
)
for wp in data.get("weakPoints", [])
]
return analytics_pb2.StudentWeakness(
student_id=data.get("studentId", ""),
weak_points=weak_points,
)
def _build_learning_trend_response(data: dict) -> Any:
"""构建 LearningTrend proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
points = [
analytics_pb2.TrendPoint(
date=p.get("date", 0),
score=p.get("score", 0.0),
)
for p in data.get("points", [])
]
return analytics_pb2.LearningTrend(
student_id=data.get("studentId", ""),
points=points,
)
def _build_teacher_dashboard_response(data: dict) -> Any:
"""构建 TeacherDashboard proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
classes = [
analytics_pb2.ClassSummary(
class_id=c.get("class_id", ""),
class_name=c.get("class_name", ""),
student_count=c.get("total_students", 0),
average_score=c.get("average_score", 0.0),
)
for c in data.get("classes", [])
]
return analytics_pb2.TeacherDashboard(
user_id=data.get("userId", ""),
total_classes=data.get("totalClasses", 0),
total_students=data.get("totalStudents", 0),
class_avg_score=data.get("classAvgScore", 0.0),
pending_homework_count=data.get("pendingHomeworkCount", 0),
classes=classes,
)
def _build_student_dashboard_response(data: dict) -> Any:
"""构建 StudentDashboard proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
weak_points = [
analytics_pb2.WeakPoint(
knowledge_point_id=wp.get("knowledgePointId", ""),
title=wp.get("title", ""),
mastery=wp.get("mastery", 0.0),
error_count=wp.get("errorCount", 0),
)
for wp in data.get("weakPoints", [])
]
trends = [
analytics_pb2.TrendPoint(date=p.get("date", 0), score=p.get("score", 0.0))
for p in data.get("trend", [])
]
return analytics_pb2.StudentDashboard(
user_id=data.get("studentId", data.get("userId", "")),
avg_score=data.get("averageScore", 0.0),
class_rank=0,
total_students=0,
weak_points=weak_points,
recent_trends=trends,
)
def _build_parent_dashboard_response(data: dict) -> Any:
"""构建 ParentDashboard proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
weak_points = [
analytics_pb2.WeakPoint(
knowledge_point_id=wp.get("knowledgePointId", ""),
title=wp.get("title", ""),
mastery=wp.get("mastery", 0.0),
error_count=wp.get("errorCount", 0),
)
for wp in data.get("weakPoints", [])
]
return analytics_pb2.ParentDashboard(
user_id=data.get("userId", ""),
student_id=data.get("childId", ""),
child_avg_score=data.get("averageScore", 0.0),
child_class_rank=0,
total_class_students=0,
child_weak_points=weak_points,
)
def _build_admin_dashboard_response(data: dict) -> Any:
"""构建 AdminDashboard proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
ai_usage_data = data.get("aiUsage", {})
by_provider = [
analytics_pb2.AIUsageByProvider(
provider=p.get("provider", ""),
request_count=p.get("requestCount", 0),
total_tokens=p.get("totalTokens", 0),
cost_cents=p.get("costCents", 0),
)
for p in ai_usage_data.get("byProvider", [])
]
ai_usage = analytics_pb2.AIUsageSummary(
total_requests=ai_usage_data.get("totalRequests", 0),
total_tokens=ai_usage_data.get("totalTokens", 0),
total_cost_cents=ai_usage_data.get("totalCostCents", 0),
by_provider=by_provider,
)
return analytics_pb2.AdminDashboard(
user_id=data.get("userId", ""),
total_teachers=0,
total_students=data.get("totalStudents", 0),
total_classes=data.get("totalClasses", 0),
school_avg_score=data.get("classAvgScore", 0.0),
ai_usage=ai_usage,
)
def _build_warning_list_response(warnings: list[dict]) -> Any:
"""构建 WarningList proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
warning_infos = [
analytics_pb2.WarningInfo(
warning_id=w.get("warning_id", ""),
warning_type=w.get("warning_type", ""),
target_id=w.get("target_id", ""),
target_name=w.get("target_name", ""),
threshold=w.get("threshold", 0.0),
current_value=w.get("current_value", 0.0),
severity=w.get("severity", "WARN"),
occurred_at=int(w.get("occurred_at", 0)),
)
for w in warnings
]
return analytics_pb2.WarningList(
warnings=warning_infos,
total=len(warning_infos),
)
def _build_trigger_warning_response(data: dict) -> Any:
"""构建 TriggerWarningResponse proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
return analytics_pb2.TriggerWarningResponse(
warning_id=data.get("warning_id", ""),
triggered=data.get("triggered", False),
)
def _build_mastery_distribution_response(data: dict) -> Any:
"""构建 MasteryDistribution proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
return analytics_pb2.MasteryDistribution(
class_id=data.get("classId", ""),
mastered_count=data.get("masteredCount", 0),
progressing_count=data.get("progressingCount", 0),
weak_count=data.get("weakCount", 0),
total_students=data.get("totalStudents", 0),
)
def _build_student_mastery_response(data: dict) -> Any:
"""构建 StudentMastery proto 响应."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
knowledge_points = [
analytics_pb2.KnowledgePointMastery(
knowledge_point_id=kp.get("knowledge_point_id", ""),
title=kp.get("title", ""),
subject_id=kp.get("subject_id", ""),
mastery_level=kp.get("mastery_level", 0.0),
mastery_label=kp.get("mastery_label", "weak"),
calculated_at=kp.get("calculated_at", 0),
)
for kp in data.get("knowledgePoints", [])
]
return analytics_pb2.StudentMastery(
student_id=data.get("studentId", ""),
knowledge_points=knowledge_points,
overall_mastery=data.get("overallMastery", 0.0),
)
def _build_mastery_update_event(event: dict) -> Any:
"""构建 MasteryUpdateEvent proto 响应streaming."""
from generated_proto import analytics_pb2 # type: ignore[import-not-found]
return analytics_pb2.MasteryUpdateEvent(
event_id=event.get("event_id", ""),
student_id=event.get("student_id", ""),
knowledge_point_id=event.get("knowledge_point_id", ""),
mastery_level=event.get("mastery_level", 0.0),
previous_level=event.get("previous_level", 0.0),
calculated_at=event.get("calculated_at", 0),
)
# ===== gRPC Server 管理 =====
_server: Any | None = None
async def start_grpc_server() -> Any | None:
"""启动 gRPC server :50055lifespan 调用).
降级策略:
- grpcio 未安装:返回 None仅 HTTP 服务
- 启动失败:仅记录错误,不阻塞 FastAPI 主流程
"""
global _server
try:
import grpc # type: ignore[import-not-found]
from generated_proto import analytics_pb2_grpc # type: ignore[import-not-found]
except ImportError as exc:
logger.warning("grpc_dependencies_not_installed_degraded", error=str(exc))
return None
_server = grpc.aio.server()
# 注册 AnalyticsService
servicer = AnalyticsServiceServicer()
analytics_pb2_grpc.add_AnalyticsServiceServicer_to_server(servicer, _server)
# 注册 HealthService
try:
from grpc_health.v1 import ( # type: ignore[import-not-found]
health,
health_pb2,
health_pb2_grpc,
)
health_servicer = health.aio.HealthServicer()
await health_servicer.set("analytics", health_pb2.HealthCheckResponse.SERVING)
await health_servicer.set("", health_pb2.HealthCheckResponse.SERVING) # overall
health_pb2_grpc.add_HealthServicer_to_server(health_servicer, _server)
except ImportError:
logger.warning("grpc_health_not_installed_skip_health_service")
# 绑定端口
bind_address = f"[::]:{settings.grpc_port}"
_server.add_insecure_port(bind_address)
try:
await _server.start()
logger.info(
"grpc_server_started",
port=settings.grpc_port,
rpc_count=12,
)
return _server
except Exception as exc: # noqa: BLE001
logger.error("grpc_server_start_failed", error=str(exc), port=settings.grpc_port)
_server = None
return None
async def stop_grpc_server() -> None:
"""停止 gRPC serverlifespan 退出时调用)."""
global _server
if _server is not None:
try:
await _server.stop(grace=5)
logger.info("grpc_server_stopped")
except Exception as exc: # noqa: BLE001
logger.warning("grpc_server_stop_failed", error=str(exc))
finally:
_server = None
def is_running() -> bool:
"""gRPC server 是否运行中."""
return _server is not None