feat(ai): python graphql federation subgraph with strawberry

- strawberry-graphql[asgi] dependency added

- GeneratedReport and LessonPlanStatus @key types with resolve_reference

- RouterAuthMiddleware validates Router-Authorization header on /graphql

- GraphQL endpoint mounted at /graphql in FastAPI app

- WorkflowStateStore injected for lesson plan status resolution
This commit is contained in:
SpecialX
2026-07-15 00:56:37 +08:00
parent 35aa56537d
commit 1dcdcf23fd
6 changed files with 210 additions and 0 deletions

View File

@@ -20,6 +20,8 @@ dependencies = [
"redis>=5.1.0",
# 重试机制LLM 调用 + 下游 gRPC
"tenacity>=9.0.0",
# GraphQL Federation 2 子图v2.1 M1Apollo Router 组合)
"strawberry-graphql[asgi]>=0.257.0",
]
[tool.uv.sources]

View File

@@ -57,6 +57,9 @@ class Settings(BaseSettings):
data_ana_grpc_endpoint: str = "localhost:50055"
iam_grpc_endpoint: str = "localhost:50052"
# GraphQL Federation 2 子图v2.1 ADR-036 Router-Authorization 信任凭证)
router_auth_secret: str = ""
# 备课工作流
workflow_ttl_seconds: int = 86400 # 24h
workflow_max_retries: int = 3

View File

View File

@@ -0,0 +1,80 @@
"""Router-Authorization 信任凭证校验中间件v2.1 ADR-036.
Apollo Router 请求子图时必须携带 Router-Authorization Header
本中间件拦截 /graphql 端点并校验,拒绝任何非 Router 发起的 GraphQL 请求。
与 TypeScript 服务的 RouterAuthGuard 保持一致的共享密钥模式:
- 生产ROUTER_AUTH_SECRET 环境变量配置共享密钥
- 开发dev_mode=true 时跳过校验(仅限本地)
健康检查端点(/healthz、/readyz不在 /graphql 路径下,自动豁免。
"""
from collections.abc import Awaitable, Callable
import structlog
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from ..config import settings
logger = structlog.get_logger()
ROUTER_AUTH_HEADER = "router-authorization"
GRAPHQL_PATH_PREFIX = "/graphql"
class RouterAuthMiddleware(BaseHTTPMiddleware):
"""校验 /graphql 端点的 Router-Authorization Header.
仅作用于 /graphql 路径REST 路由(/v1/ai/*)和健康检查放行。
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
# 仅 /graphql 端点需要校验REST 路由与健康检查放行
if not path.startswith(GRAPHQL_PATH_PREFIX):
return await call_next(request)
# 开发模式跳过校验
if settings.is_dev:
return await call_next(request)
# 校验共享密钥是否已配置
expected = settings.router_auth_secret
if not expected:
logger.error("router_auth_secret_not_configured")
return JSONResponse(
status_code=403,
content={
"errors": [
{
"message": "Router authorization not configured on server",
},
],
},
)
# 校验 Router-Authorization Header
provided = request.headers.get(ROUTER_AUTH_HEADER, "")
if provided != expected:
logger.warning("direct_graphql_access_denied", path=path)
return JSONResponse(
status_code=403,
content={
"errors": [
{
"message": "Direct GraphQL access denied; "
"must go through Apollo Router",
},
],
},
)
return await call_next(request)

View File

@@ -0,0 +1,114 @@
"""GraphQL Federation 2 子图 schemav2.1 M1.
使用 strawberry-graphql 构建 Apollo Federation 2 子图,暴露:
- GeneratedReport学情报告实体@key(id)
- LessonPlanStatus备课工作流状态实体@key(workflow_id)
供 Apollo Router 组合,替代原 teacher-bff 的聚合职责。
强制约束:
- Router-Authorization Header 校验ADR-036见 router_auth.py
- @key 解析器走 WorkflowStateStore备课工作流状态
- GeneratedReport 为占位实体(报告按需生成,不持久化)
- 所有 resolver 为 async
"""
import strawberry
import structlog
from ..errors import AIWorkflowNotFoundError
from ..workflow.state_store import WorkflowState, WorkflowStateStore
logger = structlog.get_logger()
# 模块级状态存储引用(由 main.py 通过 set_state_store 注入)
_state_store: WorkflowStateStore | None = None
def set_state_store(store: WorkflowStateStore) -> None:
"""注入 WorkflowStateStore 实例(由 main.py 调用)."""
global _state_store
_state_store = store
def _state_to_lesson_plan_status(state: WorkflowState) -> "LessonPlanStatus":
"""将 WorkflowState 转换为 LessonPlanStatus GraphQL 类型."""
return LessonPlanStatus(
workflow_id=state.workflow_id,
status=state.status,
error=state.error,
degraded=state.status == "failed",
degraded_reason=state.error or "",
)
@strawberry.federation.type(keys=["id"])
class GeneratedReport:
"""学情报告实体Federation 2 @key(id).
报告按需生成不持久化resolve_reference 返回 None。
此类型存在以支持其他子图通过 id 引用报告实体。
"""
id: str
content: str
summary: str
recommendations: list[str]
degraded: bool
degraded_reason: str
@classmethod
async def resolve_reference(cls, id: str) -> "GeneratedReport | None":
"""Federation 引用解析器(占位:报告不持久化,无法解析)."""
logger.debug("generated_report_resolve_reference", report_id=id)
return None
@strawberry.federation.type(keys=["workflow_id"])
class LessonPlanStatus:
"""备课工作流状态实体Federation 2 @key(workflow_id)."""
workflow_id: str
status: str
error: str | None
degraded: bool
degraded_reason: str
@classmethod
async def resolve_reference(cls, workflow_id: str) -> "LessonPlanStatus | None":
"""Federation 引用解析器:从 WorkflowStateStore 查询状态."""
if _state_store is None:
return None
try:
state = await _state_store.get(workflow_id)
except AIWorkflowNotFoundError:
return None
return _state_to_lesson_plan_status(state)
@strawberry.type
class Query:
"""GraphQL Query 根类型."""
@strawberry.field
async def lesson_plan_status(
self,
workflow_id: str,
) -> LessonPlanStatus | None:
"""查询备课工作流状态(委托 WorkflowStateStore."""
if _state_store is None:
return None
try:
state = await _state_store.get(workflow_id)
except AIWorkflowNotFoundError:
return None
return _state_to_lesson_plan_status(state)
@strawberry.field
async def generated_report(self, id: str) -> GeneratedReport | None:
"""查询学情报告(占位:报告按需生成,不持久化)."""
logger.debug("generated_report_query", report_id=id)
return None
schema = strawberry.federation.Schema(query=Query)

View File

@@ -30,9 +30,13 @@ from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from prometheus_client import make_asgi_app
from redis.asyncio import Redis
from strawberry.asgi import GraphQL
from .clients import ContentClientGrpc, DataAnaClientGrpc, IamClientGrpc
from .config import settings
from .graphql.router_auth import RouterAuthMiddleware
from .graphql.schema import schema as graphql_schema
from .graphql.schema import set_state_store
from .grpc_server import create_grpc_server
from .middleware import (
PermissionGuard,
@@ -152,6 +156,8 @@ _state_store = WorkflowStateStore(
redis=None,
ttl_seconds=settings.workflow_ttl_seconds,
)
# 注入状态存储到 GraphQL 子图(供 LessonPlanStatus 解析器使用)
set_state_store(_state_store)
_workflow_service = LessonPlanWorkflowService(
state_store=_state_store,
failover_chain=_failover_chain,
@@ -257,6 +263,11 @@ FastAPIInstrumentor.instrument_app(app)
app.mount("/metrics", make_asgi_app())
register_error_handlers(app)
# GraphQL Federation 2 子图v2.1 M1/graphql 端点供 Apollo Router 组合)
# Router-Authorization 校验仅作用于 /graphql 路径REST 路由与健康检查放行
app.add_middleware(RouterAuthMiddleware)
app.mount("/graphql", GraphQL(graphql_schema))
# 业务路由(/v1/ai 前缀Gateway 代理 /api/v1/ai/* → /v1/ai/*
router = APIRouter(prefix="/v1/ai")