/** * GraphQL 同域代理(P0-3,ARCHITECTURE.md §3.4 V3-A2 / §4 / §5.2) * * 浏览器 Apollo Client 一律走同域 `/api/graphql`: * Browser → /api/graphql (本 Route Handler) → apollo-router :3000 * * 职责: * 1. 从 httpOnly cookie `edu_session` 取 JWT,注入 `Authorization: Bearer` * 2. 透传 body(APQ hash 或 query)与 Apollo 相关头 * 3. 响应 status / JSON 原样回传,不缓存 * 4. 错误归一化:网络错误 → `{ errors: [{ message: "UPSTREAM_UNAVAILABLE" }] }` * * 安全收益(§4.2): * - JWT 全程不出 httpOnly cookie,消除 XSS 窃取凭证面 * - 修复"浏览器绕过 api-gateway"问题(代理在服务端调 router) * * 验收命令: * DevTools Network 面板无 `localhost:3000` 直连;所有 GraphQL 请求走 `/api/graphql` */ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; const SESSION_COOKIE = "edu_session"; const UPSTREAM_URL = process.env.APOLLO_ROUTER_URL || process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL || "http://localhost:3000/graphql"; /** * 从 Cookie 头解析指定 cookie 值。 */ function readCookie(cookieHeader: string | null, name: string): string | null { if (!cookieHeader) return null; const match = cookieHeader .split(";") .map((p) => p.trim()) .find((p) => p.startsWith(`${name}=`)); if (!match) return null; return decodeURIComponent(match.slice(name.length + 1)); } export async function POST(req: NextRequest): Promise { const cookieHeader = req.headers.get("cookie"); const token = readCookie(cookieHeader, SESSION_COOKIE); // 透传 body(APQ hash 请求或完整 query),不解析不修改 const body = await req.text(); const upstreamHeaders: Record = { "Content-Type": req.headers.get("content-type") ?? "application/json", Accept: req.headers.get("accept") ?? "application/json", // Apollo Persisted Query 协议头透传 "X-APQ": req.headers.get("x-apq") ?? "1", // 服务端追踪:透传客户端 X-Request-Id(若有) ...(req.headers.get("x-request-id") ? { "X-Request-Id": req.headers.get("x-request-id") as string } : {}), }; // 注入 Authorization(若 cookie 中有 JWT) if (token) { upstreamHeaders.Authorization = `Bearer ${token}`; } try { const upstream = await fetch(UPSTREAM_URL, { method: "POST", headers: upstreamHeaders, body, cache: "no-store", }); const responseText = await upstream.text(); return new NextResponse(responseText, { status: upstream.status, headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/json", // 不缓存:GraphQL 响应可能因身份/变量而异 "Cache-Control": "no-store, no-cache, must-revalidate", }, }); } catch (err) { const message = err instanceof Error ? err.message : "Unknown upstream error"; console.error( `[portal-shell] /api/graphql upstream error: ${message} (url=${UPSTREAM_URL})`, ); return NextResponse.json( { errors: [ { message: "UPSTREAM_UNAVAILABLE", extensions: { code: "UPSTREAM_UNAVAILABLE", reason: message, }, }, ], }, { status: 502 }, ); } } /** * GET /api/graphql → 简单健康标识,便于排查路由是否挂载。 * Apollo Router 自身的 health 在 :8088/health。 */ export async function GET(): Promise { return NextResponse.json( { ok: true, proxy: "/api/graphql", upstream: UPSTREAM_URL, }, { status: 200 }, ); }