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