"""gRPC Server(AnalyticsService 12 RPC + HealthService). 对齐 02-architecture-design.md §4.2: - AnalyticsService 12 RPC(含 1 个 server-streaming SubscribeMasteryUpdate) - HealthService(grpc.health.v1,供 K8s 探针) - 端口 50055 - 所有 RPC 返回结构化 message(HTTP 层包装 ActionState 信封) SubscribeMasteryUpdate(P5+): - 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-streaming,P5+).""" # 确定订阅 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 :50055(lifespan 调用). 降级策略: - 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 server(lifespan 退出时调用).""" 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