diff --git a/services/ai/pyproject.toml b/services/ai/pyproject.toml index 3185bc5..f541d24 100644 --- a/services/ai/pyproject.toml +++ b/services/ai/pyproject.toml @@ -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] diff --git a/services/ai/src/ai/config.py b/services/ai/src/ai/config.py index 05a569e..b581cac 100644 --- a/services/ai/src/ai/config.py +++ b/services/ai/src/ai/config.py @@ -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 diff --git a/services/ai/src/ai/graphql/__init__.py b/services/ai/src/ai/graphql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/src/ai/graphql/router_auth.py b/services/ai/src/ai/graphql/router_auth.py new file mode 100644 index 0000000..341164a --- /dev/null +++ b/services/ai/src/ai/graphql/router_auth.py @@ -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) diff --git a/services/ai/src/ai/graphql/schema.py b/services/ai/src/ai/graphql/schema.py new file mode 100644 index 0000000..e40e21a --- /dev/null +++ b/services/ai/src/ai/graphql/schema.py @@ -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) diff --git a/services/ai/src/ai/main.py b/services/ai/src/ai/main.py index 96daa56..2115045 100644 --- a/services/ai/src/ai/main.py +++ b/services/ai/src/ai/main.py @@ -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")