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:
@@ -14,6 +14,8 @@ dependencies = [
|
||||
"grpcio-health-checking>=1.66.0",
|
||||
# protobuf 运行时(buf generate 生成的 stub 依赖)
|
||||
"protobuf>=5.28.0",
|
||||
# GraphQL Federation 2 子图(v2.1 M1,Apollo Router 组合)
|
||||
"strawberry-graphql[asgi]>=0.257.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
77
uv.lock
generated
77
uv.lock
generated
@@ -27,6 +27,7 @@ dependencies = [
|
||||
{ name = "protobuf" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "redis" },
|
||||
{ name = "strawberry-graphql", extra = ["asgi"] },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
|
||||
@@ -54,6 +55,7 @@ requires-dist = [
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "redis", specifier = ">=5.1.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.0" },
|
||||
{ name = "strawberry-graphql", extras = ["asgi"], specifier = ">=0.257.0" },
|
||||
{ name = "tenacity", specifier = ">=9.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
@@ -349,6 +351,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cross-web"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/a0/bdb8370215987cc5bde497cb8b976b17ab14841566ecef2aab6ae3e6c7b0/cross_web-0.7.0.tar.gz", hash = "sha256:15fbc8b9a824a055db8127fd6e43e0773074f620fdecb6b2b587d3d0a2bdd459", size = 332407, upload-time = "2026-05-19T14:18:48.849Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/4a/78b52ec2edcbd9b123638f4e0421fd8699ebe653ebf63ac5f82abd2665bc/cross_web-0.7.0-py3-none-any.whl", hash = "sha256:ddea9be3c68b48eaf16561847a5831a559786949c544b3701432e00a4e8d19d9", size = 25207, upload-time = "2026-05-19T14:18:47.614Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-ana-service"
|
||||
version = "1.0.0"
|
||||
@@ -359,6 +373,7 @@ dependencies = [
|
||||
{ name = "edu-shared-py" },
|
||||
{ name = "grpcio-health-checking" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "strawberry-graphql", extra = ["asgi"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -368,6 +383,7 @@ requires-dist = [
|
||||
{ name = "edu-shared-py", editable = "packages/shared-py" },
|
||||
{ name = "grpcio-health-checking", specifier = ">=1.66.0" },
|
||||
{ name = "protobuf", specifier = ">=5.28.0" },
|
||||
{ name = "strawberry-graphql", extras = ["asgi"], specifier = ">=0.257.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -437,6 +453,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "graphql-core"
|
||||
version = "3.2.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/90/f2aff026ab4aebd80eb71905106a0885f4cfde85dcf965543f45bed0d9ee/graphql_core-3.2.11.tar.gz", hash = "sha256:e7e156d10beb127cab5c89ff0da71416fc73d27c484a4757d3b2d35633774802", size = 528407, upload-time = "2026-06-05T13:45:22.915Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl", hash = "sha256:0b3e35ff41e9adba53021ab0cef475eb18f57c7f53f0f2ca55567fbf3c537ea0", size = 214879, upload-time = "2026-06-05T13:45:21.245Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.82.1"
|
||||
@@ -1121,6 +1146,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
@@ -1130,6 +1167,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -1234,6 +1280,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.3.1"
|
||||
@@ -1247,6 +1302,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strawberry-graphql"
|
||||
version = "0.321.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cross-web" },
|
||||
{ name = "graphql-core" },
|
||||
{ name = "packaging" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/b0/e2e13fadbf291fd7ee1c89a7f3d6872424ad299018a3adfd6ac8459ba21c/strawberry_graphql-0.321.0.tar.gz", hash = "sha256:54e916c83f21219bbe3067b40ca4d10d550a487ea6690336e3be9e5536409214", size = 233479, upload-time = "2026-07-13T20:56:02.808Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b7/9d4d280ccb675fb4273a9e7f1d67b67fbd4ce7baaa15dba646bc20759915/strawberry_graphql-0.321.0-py3-none-any.whl", hash = "sha256:bd85d8a3eda351cc4a952d3d6216096b112c94ee9149e18c4cda754bb967c0d9", size = 336994, upload-time = "2026-07-13T20:56:01.075Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
asgi = [
|
||||
{ name = "python-multipart" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "structlog"
|
||||
version = "26.1.0"
|
||||
|
||||
Reference in New Issue
Block a user