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", "redis>=5.1.0",
# 重试机制LLM 调用 + 下游 gRPC # 重试机制LLM 调用 + 下游 gRPC
"tenacity>=9.0.0", "tenacity>=9.0.0",
# GraphQL Federation 2 子图v2.1 M1Apollo Router 组合)
"strawberry-graphql[asgi]>=0.257.0",
] ]
[tool.uv.sources] [tool.uv.sources]

View File

@@ -57,6 +57,9 @@ class Settings(BaseSettings):
data_ana_grpc_endpoint: str = "localhost:50055" data_ana_grpc_endpoint: str = "localhost:50055"
iam_grpc_endpoint: str = "localhost:50052" 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_ttl_seconds: int = 86400 # 24h
workflow_max_retries: int = 3 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 opentelemetry.sdk.trace.export import BatchSpanProcessor
from prometheus_client import make_asgi_app from prometheus_client import make_asgi_app
from redis.asyncio import Redis from redis.asyncio import Redis
from strawberry.asgi import GraphQL
from .clients import ContentClientGrpc, DataAnaClientGrpc, IamClientGrpc from .clients import ContentClientGrpc, DataAnaClientGrpc, IamClientGrpc
from .config import settings 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 .grpc_server import create_grpc_server
from .middleware import ( from .middleware import (
PermissionGuard, PermissionGuard,
@@ -152,6 +156,8 @@ _state_store = WorkflowStateStore(
redis=None, redis=None,
ttl_seconds=settings.workflow_ttl_seconds, ttl_seconds=settings.workflow_ttl_seconds,
) )
# 注入状态存储到 GraphQL 子图(供 LessonPlanStatus 解析器使用)
set_state_store(_state_store)
_workflow_service = LessonPlanWorkflowService( _workflow_service = LessonPlanWorkflowService(
state_store=_state_store, state_store=_state_store,
failover_chain=_failover_chain, failover_chain=_failover_chain,
@@ -257,6 +263,11 @@ FastAPIInstrumentor.instrument_app(app)
app.mount("/metrics", make_asgi_app()) app.mount("/metrics", make_asgi_app())
register_error_handlers(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/* # 业务路由(/v1/ai 前缀Gateway 代理 /api/v1/ai/* → /v1/ai/*
router = APIRouter(prefix="/v1/ai") router = APIRouter(prefix="/v1/ai")