feat(data-ana): python graphql federation subgraph with strawberry
- strawberry-graphql[asgi] dependency added - 13 Federation 2 types: ClassPerformance/StudentWeakness/Dashboards/Mastery/ErrorBook - 11 Query resolvers delegate to existing analytics/mastery services - RouterAuthMiddleware validates Router-Authorization header on /graphql - GraphQL endpoint mounted at /graphql alongside existing REST endpoints
This commit is contained in:
@@ -84,6 +84,12 @@ class Settings(BaseSettings):
|
||||
readyz_iam_grpc_timeout_s: float = 2.0
|
||||
readyz_cdc_lag_threshold: int = 1000 # lag 超过此值判定 not_ready
|
||||
|
||||
# GraphQL Federation 2 子图(v2.1 M1,ADR-036)
|
||||
# Apollo Router 请求子图时必须携带 Router-Authorization Header(共享密钥)
|
||||
# 与 TS 服务使用相同的 secret(由 Secret 注入)
|
||||
router_auth_secret: str = "" # 留空则 /graphql 返回 403(除非 dev_mode=True)
|
||||
graphql_path: str = "/graphql" # GraphQL 端点路径
|
||||
|
||||
# 向后兼容:旧代码引用 settings.port / settings.kafka_group_id
|
||||
@property
|
||||
def port(self) -> int:
|
||||
|
||||
0
services/data-ana/src/data_ana/graphql/__init__.py
Normal file
0
services/data-ana/src/data_ana/graphql/__init__.py
Normal file
102
services/data-ana/src/data_ana/graphql/router_auth.py
Normal file
102
services/data-ana/src/data_ana/graphql/router_auth.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""GraphQL 端点 Router-Authorization 校验(v2.1 M1, ADR-036).
|
||||
|
||||
对齐 TS 服务的 RouterAuthGuard(packages/shared-ts/src/federation/router-auth.guard.ts).
|
||||
|
||||
强制约束:
|
||||
- Apollo Router 请求子图时必须携带 ``Router-Authorization`` Header
|
||||
- 拒绝任何非 Router 发起的 GraphQL 请求
|
||||
- 健康检查端点豁免
|
||||
- dev_mode=True 时跳过校验(仅限本地)
|
||||
|
||||
部署模式:
|
||||
- 生产:``DATA_ANA_ROUTER_AUTH_SECRET`` 环境变量配置共享密钥(与 TS 服务相同)
|
||||
- 开发:``DATA_ANA_DEV_MODE=true`` 时跳过校验
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from ..config import settings
|
||||
|
||||
ROUTER_AUTH_HEADER = "router-authorization"
|
||||
|
||||
# 健康检查端点豁免前缀(这些路径不经过 GraphQL 校验)
|
||||
_EXEMPT_PATH_PREFIXES: tuple[str, ...] = (
|
||||
"/health",
|
||||
"/ready",
|
||||
"/healthz",
|
||||
"/readyz",
|
||||
"/metrics",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc",
|
||||
)
|
||||
|
||||
|
||||
def _clean_path(path: str) -> str:
|
||||
"""去除 query string 和 fragment."""
|
||||
return path.split("?", 1)[0].split("#", 1)[0]
|
||||
|
||||
|
||||
def is_graphql_request(path: str) -> bool:
|
||||
"""判断是否为 GraphQL 端点请求."""
|
||||
clean = _clean_path(path)
|
||||
graphql_path = settings.graphql_path
|
||||
return clean == graphql_path or clean.startswith(graphql_path + "/")
|
||||
|
||||
|
||||
def _auth_failure_response(status_code: int, detail: str) -> JSONResponse:
|
||||
"""构造认证失败响应(直接返回 JSONResponse,避免依赖异常传播)."""
|
||||
return JSONResponse(status_code=status_code, content={"detail": detail})
|
||||
|
||||
|
||||
class RouterAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""FastAPI 中间件:仅对 /graphql 端点校验 Router-Authorization Header.
|
||||
|
||||
对齐 NestJS 的 RouterAuthGuard(packages/shared-ts/src/federation/router-auth.guard.ts).
|
||||
|
||||
实现说明:
|
||||
- 在 ``dispatch`` 内直接返回 JSONResponse 而非抛 HTTPException,
|
||||
避免 BaseHTTPMiddleware 异常传播到 ServerErrorMiddleware 时被吞为 500.
|
||||
"""
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Any, # noqa: ANN401 - Starlette 接口约定为 callable
|
||||
) -> Response:
|
||||
# 1. dev_mode 跳过校验(仅限本地开发)
|
||||
if settings.dev_mode:
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
|
||||
# 2. 非 GraphQL 路径放行(REST 路由已有 PermissionGuard)
|
||||
if not is_graphql_request(path):
|
||||
return await call_next(request)
|
||||
|
||||
# 3. 健康检查端点豁免
|
||||
clean = _clean_path(path)
|
||||
if any(clean == p or clean.startswith(p + "/") for p in _EXEMPT_PATH_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# 4. secret 未配置 → 503(生产配置错误)
|
||||
expected = settings.router_auth_secret
|
||||
if not expected:
|
||||
return _auth_failure_response(
|
||||
status_code=503,
|
||||
detail="Router authorization not configured on server",
|
||||
)
|
||||
|
||||
# 5. Header 缺失或不匹配 → 403
|
||||
provided = request.headers.get(ROUTER_AUTH_HEADER, "")
|
||||
if not provided or provided != expected:
|
||||
return _auth_failure_response(
|
||||
status_code=403,
|
||||
detail="Direct GraphQL access denied; must go through Apollo Router",
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
614
services/data-ana/src/data_ana/graphql/schema.py
Normal file
614
services/data-ana/src/data_ana/graphql/schema.py
Normal file
@@ -0,0 +1,614 @@
|
||||
"""GraphQL Federation 2 子图 schema(v2.1 M1).
|
||||
|
||||
使用 strawberry-graphql 构建 Apollo Federation 2 子图,供 Apollo Router 组合.
|
||||
|
||||
设计要点:
|
||||
- 所有 Resolver 委托给 analytics_service / mastery_service / clickhouse_repository
|
||||
- 不重新实现业务逻辑,仅做数据形状映射(dict → strawberry type)
|
||||
- 异步 Resolver(async def),与现有服务一致
|
||||
- 降级响应:ClickHouse 不可达时 service 层已返回骨架数据(零值),
|
||||
Resolver 直接转换即可,不抛异常
|
||||
- UserContext 从请求头提取(x-user-id / x-user-roles),与 REST 端点一致
|
||||
- Router-Authorization 校验由 router_auth.RouterAuthMiddleware 在 main.py 中注册
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import strawberry
|
||||
import structlog
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from strawberry.asgi import GraphQL
|
||||
from strawberry.federation import Schema
|
||||
from strawberry.types import Info
|
||||
|
||||
from .. import analytics_service
|
||||
from ..shared.permissions import UserContext
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ===== 辅助函数 =====
|
||||
|
||||
|
||||
def _get_user_context(info: Info) -> UserContext:
|
||||
"""从 GraphQL context 提取 UserContext.
|
||||
|
||||
Apollo Router 从 JWT 提取用户信息后注入 x-user-id / x-user-roles 头,
|
||||
与 REST 端点使用相同的 header 约定.
|
||||
"""
|
||||
user = info.context.get("user")
|
||||
if isinstance(user, UserContext):
|
||||
return user
|
||||
# 降级:无 user context(如未配置 Gateway 注入头)
|
||||
return UserContext(user_id="", roles=[])
|
||||
|
||||
|
||||
def _to_float(value: Any, default: float = 0.0) -> float:
|
||||
"""安全转换为 float(None / 异常返回 default)."""
|
||||
try:
|
||||
return float(value) if value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _to_int(value: Any, default: int = 0) -> int:
|
||||
"""安全转换为 int(None / 异常返回 default)."""
|
||||
try:
|
||||
return int(value) if value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _first_str(*candidates: Any) -> str:
|
||||
"""从多个候选值中取第一个非空字符串."""
|
||||
for c in candidates:
|
||||
if c:
|
||||
return str(c)
|
||||
return ""
|
||||
|
||||
|
||||
# ===== 类型定义 =====
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class WeakPoint:
|
||||
"""学生薄弱知识点."""
|
||||
|
||||
knowledge_point_id: str
|
||||
title: str
|
||||
mastery: float
|
||||
error_count: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "WeakPoint":
|
||||
return cls(
|
||||
knowledge_point_id=_first_str(d.get("knowledgePointId"), d.get("knowledge_point_id")),
|
||||
title=_first_str(d.get("title"), d.get("knowledge_point_id")),
|
||||
mastery=_to_float(d.get("mastery")),
|
||||
error_count=_to_int(d.get("errorCount") or d.get("error_count")),
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class ClassPerformance:
|
||||
"""班级成绩分析."""
|
||||
|
||||
class_id: str
|
||||
average_score: float
|
||||
pass_rate: float
|
||||
total_students: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "ClassPerformance":
|
||||
return cls(
|
||||
class_id=_first_str(d.get("classId"), d.get("class_id")),
|
||||
average_score=_to_float(d.get("averageScore")),
|
||||
pass_rate=_to_float(d.get("passRate")),
|
||||
total_students=_to_int(d.get("totalStudents")),
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class StudentWeakness:
|
||||
"""学生薄弱知识点列表."""
|
||||
|
||||
student_id: str
|
||||
weak_points: list[WeakPoint]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "StudentWeakness":
|
||||
raw_points = d.get("weakPoints") or d.get("weak_points") or []
|
||||
return cls(
|
||||
student_id=_first_str(d.get("studentId"), d.get("student_id")),
|
||||
weak_points=[WeakPoint.from_dict(p) for p in raw_points],
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class TrendPoint:
|
||||
"""学习趋势数据点."""
|
||||
|
||||
date: int
|
||||
score: float
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "TrendPoint":
|
||||
return cls(
|
||||
date=_to_int(d.get("date")),
|
||||
score=_to_float(d.get("score")),
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class LearningTrend:
|
||||
"""学习趋势."""
|
||||
|
||||
student_id: str
|
||||
points: list[TrendPoint]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "LearningTrend":
|
||||
raw_points = d.get("points") or []
|
||||
return cls(
|
||||
student_id=_first_str(d.get("studentId"), d.get("student_id")),
|
||||
points=[TrendPoint.from_dict(p) for p in raw_points],
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class TeacherDashboard:
|
||||
"""教师仪表盘."""
|
||||
|
||||
user_id: str
|
||||
total_classes: int
|
||||
total_students: int
|
||||
class_avg_score: float
|
||||
pending_homework_count: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "TeacherDashboard":
|
||||
return cls(
|
||||
user_id=_first_str(d.get("userId"), d.get("user_id")),
|
||||
total_classes=_to_int(d.get("totalClasses")),
|
||||
total_students=_to_int(d.get("totalStudents")),
|
||||
class_avg_score=_to_float(d.get("classAvgScore") or d.get("averageScore")),
|
||||
pending_homework_count=_to_int(d.get("pendingHomeworkCount")),
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class StudentDashboard:
|
||||
"""学生仪表盘."""
|
||||
|
||||
user_id: str
|
||||
avg_score: float
|
||||
class_rank: int
|
||||
total_students: int
|
||||
pending_homework: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any], user_id: str = "") -> "StudentDashboard":
|
||||
"""从 student_dashboard service 结果构造.
|
||||
|
||||
service 返回的 records 含 score / rank_in_class 等字段,
|
||||
avg_score / class_rank 从 records 聚合.
|
||||
total_students / pending_homework 需额外聚合(当前返回 0,P6 演进).
|
||||
"""
|
||||
records = d.get("records") or []
|
||||
scores = [r.get("score") for r in records if r.get("score") is not None]
|
||||
avg_score = sum(scores) / len(scores) if scores else 0.0
|
||||
class_rank = _to_int(records[0].get("rank_in_class")) if records else 0
|
||||
return cls(
|
||||
user_id=user_id or _first_str(d.get("studentId"), d.get("userId")),
|
||||
avg_score=_to_float(avg_score),
|
||||
class_rank=class_rank,
|
||||
total_students=0, # service 未返回,需班级聚合查询(P6 演进)
|
||||
pending_homework=0, # service 未返回,需 homework_submissions 聚合(P6 演进)
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class ParentDashboard:
|
||||
"""家长仪表盘."""
|
||||
|
||||
user_id: str
|
||||
student_id: str
|
||||
child_avg_score: float
|
||||
child_class_rank: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any], user_id: str = "") -> "ParentDashboard":
|
||||
"""从 parent_dashboard service 结果构造(复用 student_dashboard 查询)."""
|
||||
records = d.get("records") or []
|
||||
scores = [r.get("score") for r in records if r.get("score") is not None]
|
||||
avg_score = sum(scores) / len(scores) if scores else 0.0
|
||||
class_rank = _to_int(records[0].get("rank_in_class")) if records else 0
|
||||
return cls(
|
||||
user_id=user_id,
|
||||
student_id=_first_str(d.get("childId"), d.get("studentId")),
|
||||
child_avg_score=_to_float(avg_score),
|
||||
child_class_rank=class_rank,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class AdminDashboard:
|
||||
"""管理员仪表盘."""
|
||||
|
||||
user_id: str
|
||||
total_teachers: int
|
||||
total_students: int
|
||||
total_classes: int
|
||||
school_avg_score: float
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "AdminDashboard":
|
||||
"""从 admin_dashboard service 结果构造.
|
||||
|
||||
service 返回 teacher_dashboard 聚合数据 + AI 用量统计,
|
||||
total_teachers 需 iam 聚合(当前返回 0,P6 演进).
|
||||
"""
|
||||
return cls(
|
||||
user_id=_first_str(d.get("userId"), d.get("user_id")),
|
||||
total_teachers=0, # service 未返回,需 iam 聚合(P6 演进)
|
||||
total_students=_to_int(d.get("totalStudents")),
|
||||
total_classes=_to_int(d.get("totalClasses")),
|
||||
school_avg_score=_to_float(d.get("classAvgScore") or d.get("averageScore")),
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class StudentMastery:
|
||||
"""学生知识点掌握度明细."""
|
||||
|
||||
student_id: str
|
||||
overall_mastery: float
|
||||
mastered_count: int
|
||||
progressing_count: int
|
||||
weak_count: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "StudentMastery":
|
||||
"""从 mastery_snapshot service 结果构造,三档分布从 knowledgePoints 聚合."""
|
||||
kps = d.get("knowledgePoints") or []
|
||||
mastered = sum(1 for kp in kps if kp.get("mastery_label") == "mastered")
|
||||
progressing = sum(1 for kp in kps if kp.get("mastery_label") == "progressing")
|
||||
weak = sum(1 for kp in kps if kp.get("mastery_label") == "weak")
|
||||
return cls(
|
||||
student_id=_first_str(d.get("studentId"), d.get("student_id")),
|
||||
overall_mastery=_to_float(d.get("overallMastery")),
|
||||
mastered_count=mastered,
|
||||
progressing_count=progressing,
|
||||
weak_count=weak,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class MasterySummary:
|
||||
"""学生掌握度汇总(轻量级)."""
|
||||
|
||||
student_id: str
|
||||
overall_mastery: float
|
||||
mastered_count: int
|
||||
progressing_count: int
|
||||
weak_count: int
|
||||
total_knowledge_points: int
|
||||
mastery_level: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "MasterySummary":
|
||||
return cls(
|
||||
student_id=_first_str(d.get("studentId"), d.get("student_id")),
|
||||
overall_mastery=_to_float(d.get("overallMastery")),
|
||||
mastered_count=_to_int(d.get("masteredCount")),
|
||||
progressing_count=_to_int(d.get("progressingCount")),
|
||||
weak_count=_to_int(d.get("weakCount")),
|
||||
total_knowledge_points=_to_int(d.get("totalKnowledgePoints")),
|
||||
mastery_level=_first_str(d.get("masteryLevel")) or "weak",
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class ErrorBookItem:
|
||||
"""错题本条目."""
|
||||
|
||||
question_id: str
|
||||
knowledge_point_id: str
|
||||
knowledge_point_title: str
|
||||
error_count: int
|
||||
last_error_time: int
|
||||
content: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "ErrorBookItem":
|
||||
return cls(
|
||||
question_id=_first_str(d.get("question_id"), d.get("questionId")),
|
||||
knowledge_point_id=_first_str(d.get("knowledge_point_id"), d.get("knowledgePointId")),
|
||||
knowledge_point_title=_first_str(
|
||||
d.get("knowledge_point_title"), d.get("knowledgePointTitle")
|
||||
),
|
||||
error_count=_to_int(d.get("error_count") or d.get("errorCount")),
|
||||
last_error_time=_to_int(d.get("last_error_time") or d.get("lastErrorTime")),
|
||||
content=d.get("content") or "",
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class ErrorBookStats:
|
||||
"""错题本统计."""
|
||||
|
||||
student_id: str
|
||||
total_error_questions: int
|
||||
total_error_count: int
|
||||
recent_7d_errors: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "ErrorBookStats":
|
||||
return cls(
|
||||
student_id=_first_str(d.get("studentId"), d.get("student_id")),
|
||||
total_error_questions=_to_int(d.get("totalErrorQuestions")),
|
||||
total_error_count=_to_int(d.get("totalErrorCount")),
|
||||
recent_7d_errors=_to_int(d.get("recent7dErrors") or d.get("recent_7d_errors")),
|
||||
)
|
||||
|
||||
|
||||
# ===== Query 根类型(Resolver 委托给现有 service) =====
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
"""GraphQL Query 根类型.
|
||||
|
||||
所有 Resolver 委托给 analytics_service / mastery_service / clickhouse_repository,
|
||||
不重新实现业务逻辑,仅做 dict → strawberry type 的形状映射.
|
||||
"""
|
||||
|
||||
@strawberry.field
|
||||
async def class_performance(
|
||||
self,
|
||||
info: Info,
|
||||
class_id: str,
|
||||
subject_id: str = "",
|
||||
start_date: int = 0,
|
||||
end_date: int = 0,
|
||||
) -> ClassPerformance:
|
||||
"""班级成绩分析."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_class_performance(
|
||||
user=user,
|
||||
class_id=class_id,
|
||||
subject_id=subject_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
return ClassPerformance.from_dict(result)
|
||||
|
||||
@strawberry.field
|
||||
async def student_weakness(
|
||||
self,
|
||||
info: Info,
|
||||
student_id: str,
|
||||
subject_id: str = "",
|
||||
) -> StudentWeakness:
|
||||
"""学生薄弱知识点."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_student_weakness(
|
||||
user=user,
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
return StudentWeakness.from_dict(result)
|
||||
|
||||
@strawberry.field
|
||||
async def learning_trend(
|
||||
self,
|
||||
info: Info,
|
||||
student_id: str,
|
||||
subject_id: str = "",
|
||||
start_date: int = 0,
|
||||
end_date: int = 0,
|
||||
) -> LearningTrend:
|
||||
"""学习趋势."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_learning_trend(
|
||||
user=user,
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
return LearningTrend.from_dict(result)
|
||||
|
||||
@strawberry.field
|
||||
async def teacher_dashboard(
|
||||
self,
|
||||
info: Info,
|
||||
user_id: str,
|
||||
class_id: str = "",
|
||||
) -> TeacherDashboard:
|
||||
"""教师仪表盘."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_teacher_dashboard(
|
||||
user=user,
|
||||
class_id=class_id,
|
||||
)
|
||||
return TeacherDashboard.from_dict(result)
|
||||
|
||||
@strawberry.field
|
||||
async def student_dashboard(
|
||||
self,
|
||||
info: Info,
|
||||
user_id: str,
|
||||
) -> StudentDashboard:
|
||||
"""学生仪表盘."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_student_dashboard(
|
||||
user=user,
|
||||
student_id=user_id,
|
||||
)
|
||||
return StudentDashboard.from_dict(result, user_id=user_id)
|
||||
|
||||
@strawberry.field
|
||||
async def parent_dashboard(
|
||||
self,
|
||||
info: Info,
|
||||
user_id: str,
|
||||
student_id: str,
|
||||
) -> ParentDashboard:
|
||||
"""家长仪表盘."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_parent_dashboard(
|
||||
user=user,
|
||||
child_id=student_id,
|
||||
)
|
||||
return ParentDashboard.from_dict(result, user_id=user_id)
|
||||
|
||||
@strawberry.field
|
||||
async def admin_dashboard(
|
||||
self,
|
||||
info: Info,
|
||||
user_id: str,
|
||||
scope: str = "",
|
||||
scope_id: str = "",
|
||||
) -> AdminDashboard:
|
||||
"""管理员仪表盘."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_admin_dashboard(
|
||||
user=user,
|
||||
school_id=scope_id,
|
||||
)
|
||||
return AdminDashboard.from_dict(result)
|
||||
|
||||
@strawberry.field
|
||||
async def student_mastery(
|
||||
self,
|
||||
info: Info,
|
||||
student_id: str,
|
||||
subject_id: str = "",
|
||||
) -> StudentMastery:
|
||||
"""学生知识点掌握度明细."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_student_mastery(
|
||||
user=user,
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
return StudentMastery.from_dict(result)
|
||||
|
||||
@strawberry.field
|
||||
async def mastery_summary(
|
||||
self,
|
||||
info: Info,
|
||||
student_id: str,
|
||||
subject_id: str = "",
|
||||
) -> MasterySummary:
|
||||
"""学生掌握度汇总."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_mastery_summary(
|
||||
user=user,
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
return MasterySummary.from_dict(result)
|
||||
|
||||
@strawberry.field
|
||||
async def error_book_items(
|
||||
self,
|
||||
info: Info,
|
||||
student_id: str,
|
||||
subject_id: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[ErrorBookItem]:
|
||||
"""错题本列表."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.list_error_book_items(
|
||||
user=user,
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
items = result.get("items") or []
|
||||
return [ErrorBookItem.from_dict(item) for item in items]
|
||||
|
||||
@strawberry.field
|
||||
async def error_book_stats(
|
||||
self,
|
||||
info: Info,
|
||||
student_id: str,
|
||||
subject_id: str = "",
|
||||
) -> ErrorBookStats:
|
||||
"""错题本统计."""
|
||||
user = _get_user_context(info)
|
||||
result = await analytics_service.get_error_book_stats(
|
||||
user=user,
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
return ErrorBookStats.from_dict(result)
|
||||
|
||||
|
||||
# ===== Federation 2 Schema =====
|
||||
|
||||
|
||||
schema = Schema(
|
||||
query=Query,
|
||||
# 显式声明所有类型(确保 Federation 2 子图完整暴露)
|
||||
types=[
|
||||
ClassPerformance,
|
||||
StudentWeakness,
|
||||
WeakPoint,
|
||||
LearningTrend,
|
||||
TrendPoint,
|
||||
TeacherDashboard,
|
||||
StudentDashboard,
|
||||
ParentDashboard,
|
||||
AdminDashboard,
|
||||
StudentMastery,
|
||||
MasterySummary,
|
||||
ErrorBookItem,
|
||||
ErrorBookStats,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ===== ASGI app + context getter =====
|
||||
|
||||
|
||||
async def _build_context(request: Request) -> dict[str, Any]:
|
||||
"""从请求头提取 UserContext(与 REST 端点使用相同的 header 约定).
|
||||
|
||||
Apollo Router 从 JWT 提取用户信息后注入 x-user-id / x-user-roles 头.
|
||||
"""
|
||||
user_id = request.headers.get("x-user-id", "")
|
||||
user_roles_header = request.headers.get("x-user-roles", "")
|
||||
roles = (
|
||||
[r.strip() for r in user_roles_header.split(",") if r.strip()] if user_roles_header else []
|
||||
)
|
||||
return {
|
||||
"request": request,
|
||||
"user": UserContext(user_id=user_id, roles=roles),
|
||||
}
|
||||
|
||||
|
||||
class _DataAnaGraphQLApp(GraphQL):
|
||||
"""data-ana GraphQL ASGI handler.
|
||||
|
||||
重写 get_context 以注入 UserContext(strawberry 0.257+ 不再支持
|
||||
context_getter 构造参数,需通过子类重写).
|
||||
"""
|
||||
|
||||
async def get_context(
|
||||
self,
|
||||
request: Request,
|
||||
response: Response, # noqa: ARG002 - strawberry 调用约定
|
||||
) -> dict[str, Any]:
|
||||
return await _build_context(request)
|
||||
|
||||
|
||||
# GraphQL ASGI app(在 main.py 中挂载到 /graphql)
|
||||
# 生产环境通过 Router-Authorization Header 校验(RouterAuthMiddleware 拦截)
|
||||
# 开发环境通过 dev_mode 控制中间件放行(schema 本身不输出堆栈,避免泄露)
|
||||
graphql_app: GraphQL = _DataAnaGraphQLApp(schema)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""数据分析服务入口(FastAPI HTTP :3006).
|
||||
|
||||
端点清单(3 基础 + 11 业务 = 14 个):
|
||||
端点清单(3 基础 + 11 业务 + 1 GraphQL = 15 个):
|
||||
基础:
|
||||
GET / 根信息
|
||||
GET /healthz 活性检查(liveness)
|
||||
@@ -20,12 +20,18 @@
|
||||
GET /analytics/class/{class_id}/mastery-distribution 班级掌握度分布
|
||||
GET /analytics/student/{student_id}/mastery 学生掌握度明细
|
||||
|
||||
GraphQL(v2.1 M1,Apollo Federation 2 子图):
|
||||
POST /graphql GraphQL 端点(Router-Authorization 校验)
|
||||
- Resolver 委托给 analytics_service / mastery_service
|
||||
- 由 Apollo Router 组合(不直接对外暴露,dev_mode 除外)
|
||||
|
||||
设计要点:
|
||||
- 所有业务端点返回 ActionState[T](coord-cross-review §5.3 P0 整改)
|
||||
- 降级标记在顶层 details.degraded(不放 error.details)
|
||||
- /readyz 检查 4 依赖:clickhouse / cdc_consumer / redis / iam_grpc
|
||||
- gRPC server :50055 在 lifespan 启动
|
||||
- CDC 消费者在 lifespan 启动
|
||||
- /graphql 由 strawberry-graphql 提供,RouterAuthMiddleware 拦截
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
@@ -44,6 +50,8 @@ from prometheus_client import make_asgi_app
|
||||
|
||||
from . import analytics_service, cdc_consumer, grpc_server, warning_service
|
||||
from .config import settings
|
||||
from .graphql.router_auth import RouterAuthMiddleware
|
||||
from .graphql.schema import graphql_app
|
||||
from .repository import (
|
||||
clickhouse_repository,
|
||||
iam_client,
|
||||
@@ -181,6 +189,13 @@ app = FastAPI(
|
||||
FastAPIInstrumentor.instrument_app(app)
|
||||
app.mount("/metrics", make_asgi_app())
|
||||
|
||||
# GraphQL Federation 2 子图(v2.1 M1)
|
||||
# RouterAuthMiddleware 仅对 /graphql 端点校验 Router-Authorization Header,
|
||||
# REST 路由放行(已有 PermissionGuard / DataScope 校验)
|
||||
app.add_middleware(RouterAuthMiddleware)
|
||||
# 挂载 strawberry ASGI handler 到 /graphql(Apollo Router 访问入口)
|
||||
app.mount(settings.graphql_path, graphql_app)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -200,6 +215,7 @@ async def root() -> dict[str, Any]:
|
||||
"healthz": "/healthz",
|
||||
"readyz": "/readyz",
|
||||
"business": "/analytics/*",
|
||||
"graphql": settings.graphql_path,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user