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:
@@ -20,6 +20,8 @@ dependencies = [
|
||||
"redis>=5.1.0",
|
||||
# 重试机制(LLM 调用 + 下游 gRPC)
|
||||
"tenacity>=9.0.0",
|
||||
# GraphQL Federation 2 子图(v2.1 M1,Apollo Router 组合)
|
||||
"strawberry-graphql[asgi]>=0.257.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
|
||||
@@ -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
|
||||
|
||||
0
services/ai/src/ai/graphql/__init__.py
Normal file
0
services/ai/src/ai/graphql/__init__.py
Normal file
80
services/ai/src/ai/graphql/router_auth.py
Normal file
80
services/ai/src/ai/graphql/router_auth.py
Normal 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)
|
||||
114
services/ai/src/ai/graphql/schema.py
Normal file
114
services/ai/src/ai/graphql/schema.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""GraphQL Federation 2 子图 schema(v2.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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user