Files
Edu/services/api-gateway/main.go
SpecialX 83b6919185 feat(api-gateway): retire teacher/student/parent-bff, route GraphQL to apollo-router
v2.1 M9+M10: BFF layer and old portals retired.

- api-gateway: remove TeacherBffURL/StudentBffURL/ParentBffURL config

- api-gateway: add ApolloRouterURL config and /api/graphql route

- api-gateway: /api/admin/graphql now proxies to apollo-router

- api-gateway: health checks now ping apollo-router instead of BFF

- deploy.yml: replace teacher-bff service block with apollo-router

- deploy.yml: add config-service service block (M3 dependency)

- deploy.yml: remove teacher-portal and admin-portal service blocks

- source code in services/{teacher,student,parent}-bff/ and apps/*-portal/ retained for rollback
2026-07-15 08:14:24 +08:00

197 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/edu-cloud/api-gateway/internal/health"
"github.com/edu-cloud/api-gateway/internal/middleware"
"github.com/edu-cloud/api-gateway/internal/observability"
"github.com/edu-cloud/api-gateway/internal/proxy"
"github.com/edu-cloud/shared-go/jwks"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
// maxBodyBytes 请求体大小上限10MB
const maxBodyBytes int64 = 10 * 1024 * 1024
// version 编译时通过 -ldflags "-X main.version=..." 注入,默认 dev
var version = "dev"
func main() {
cfg := config.Load()
// 初始化 OpenTelemetry tracerW6 裁决:资源属性补全)
hostName, _ := os.Hostname()
tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint, cfg.Env, version, hostName)
defer tracerShutdown()
// 初始化 JWKS FetcherRS256 公钥缓存P2.2 裁决)
// DevMode 下也创建 fetcher使 DevMode 可验证真实 JWT非 dev-token 场景)
fetcher := jwks.NewFetcher(cfg.JWKSURL)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环
r.RedirectTrailingSlash = false
// 全局中间件(按顺序注册)
// 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic
r.Use(middleware.Recovery())
// 2. OpenTelemetry 自动埋点HTTP 请求/响应 span
r.Use(otelgin.Middleware("api-gateway"))
// 3. 请求 ID 注入
r.Use(middleware.RequestID())
// 4. 跨域
r.Use(middleware.CORS(cfg))
// 5. 安全响应头
r.Use(middleware.SecurityHeaders())
// 6. 请求体大小限制
r.Use(middleware.RequestBodyLimit(maxBodyBytes))
// 7. 限流(每 IP 100 rps突发 20
r.Use(middleware.RateLimit(100, 20))
// 健康检查路由(无需鉴权,在 Auth 之前)
r.GET("/healthz", health.Healthz)
r.GET("/readyz", health.Readyz(cfg))
// Prometheus 指标端点
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// API v1 组:熔断 + 鉴权 + 指标 + 反向代理
api := r.Group("/api/v1")
// 8. 熔断(仅作用于代理路由,下游 5xx 触发)
api.Use(middleware.CircuitBreaker("downstream"))
// 9. JWT 鉴权RS256通过 JWKS 公钥校验)
api.Use(middleware.AuthMiddleware(cfg, fetcher))
// 10. HTTP 请求指标(统计通过鉴权的请求)
api.Use(observability.Metrics())
{
// classes → core-eduC1 裁决classes 合并入 core-edu
registerProxy(api, "classes", cfg.CoreEduServiceURL)
// iam 服务路由(身份与访问管理)
registerProxy(api, "iam", cfg.IamServiceURL)
// v2.1 M9teacher-bff / student-bff / parent-bff 已下线,
// 前端 GraphQL 查询统一通过 apollo-router 聚邦层。
// /api/v1/teacher/* /api/v1/student/* /api/v1/parent/* 路由已移除。
// core-edu 域路由(考试/作业/成绩)—— 下游 controller 在 /v1/{domain}/*,仅需剥离 /api
registerProxy(api, "exams", cfg.CoreEduServiceURL)
registerProxy(api, "homework", cfg.CoreEduServiceURL)
registerProxy(api, "grades", cfg.CoreEduServiceURL)
// content 域路由(教材/章节/知识点/题库)
registerProxy(api, "textbooks", cfg.ContentServiceURL)
registerProxy(api, "chapters", cfg.ContentServiceURL)
registerProxy(api, "knowledge-points", cfg.ContentServiceURL)
registerProxy(api, "questions", cfg.ContentServiceURL)
// msg 域路由(通知/消息/公告)
registerProxy(api, "notifications", cfg.MsgServiceURL)
registerProxy(api, "messages", cfg.MsgServiceURL)
// announcements 路由msg 公告 REST APImsg nextstep.md §2.5 要求)
// /api/v1/announcements/* → msg:3007/v1/announcements/*
registerProxy(api, "announcements", cfg.MsgServiceURL)
// ai 服务路由AI 聊天/生成/优化)
registerProxy(api, "ai", cfg.AiServiceURL)
// data-ana 域路由(学情诊断/错题本/仪表盘)
registerProxy(api, "analytics", cfg.DataAnaServiceURL)
registerProxy(api, "dashboard", cfg.DataAnaServiceURL)
}
// v2.1 M9GraphQL 联邦入口(替代旧 BFF 聚合层)
// /api/graphql → apollo-router:3000/graphql统一前端 GraphQL 入口)
// /api/admin/graphql → apollo-router:3000/graphqladmin 命名空间)
// 中间件链:熔断 → JWT 鉴权 → 指标 → 反向代理admin 路由额外强制 admin 角色)
graphqlGroup := r.Group("/api/graphql")
graphqlGroup.Use(middleware.CircuitBreaker("apollo-router"))
graphqlGroup.Use(middleware.AuthMiddleware(cfg, fetcher))
graphqlGroup.Use(observability.Metrics())
{
// 路径重写:/api/graphql → /graphqlapollo-router supergraph.path
graphqlProxy, err := proxy.NewProxyRewrite(cfg.ApolloRouterURL, func(p string) string {
return strings.TrimPrefix(p, "/api/graphql")
})
if err != nil {
slog.Error("failed to create apollo-router graphql proxy", "target", cfg.ApolloRouterURL, "error", err)
panic(err)
}
graphqlGroup.Any("", proxy.ProxyHandler(graphqlProxy))
graphqlGroup.Any("/", proxy.ProxyHandler(graphqlProxy))
}
// admin GraphQL 入口admin-portal 仍走独立路径以强制 admin 角色)
admin := r.Group("/api/admin")
admin.Use(middleware.CircuitBreaker("apollo-router-admin"))
admin.Use(middleware.AuthMiddleware(cfg, fetcher))
admin.Use(middleware.AdminRoleMiddleware())
admin.Use(observability.Metrics())
{
// 路径重写:/api/admin/graphql → /graphqlapollo-router
adminGraphqlProxy, err := proxy.NewProxyRewrite(cfg.ApolloRouterURL, func(p string) string {
return strings.TrimPrefix(p, "/api/admin")
})
if err != nil {
slog.Error("failed to create admin graphql proxy", "target", cfg.ApolloRouterURL, "error", err)
panic(err)
}
admin.Any("/graphql", proxy.ProxyHandler(adminGraphqlProxy))
}
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
// 优雅关闭
go func() {
slog.Info("API Gateway listening",
"port", cfg.Port,
"env", cfg.Env,
"dev_mode", cfg.DevMode,
"version", version,
)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("listen failed", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("shutting down API Gateway")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("server forced to shutdown", "error", err)
}
slog.Info("API Gateway exited")
}
// registerProxy 创建反向代理并注册无尾斜杠与通配符两条路由。
// RedirectTrailingSlash=false 时 Gin 不会自动跳转,故两条路由都要显式注册。
// 适用于下游 controller 路径含 /v1 前缀的服务iam/core-edu/content/msg/ai/data-ana
// /api/v1/{prefix}/* → 剥离 /api → /v1/{prefix}/* 转发下游。
func registerProxy(api *gin.RouterGroup, prefix, targetURL string) {
p, err := proxy.NewProxy(targetURL)
if err != nil {
slog.Error("failed to create proxy", "prefix", prefix, "target", targetURL, "error", err)
panic(err)
}
handler := proxy.ProxyHandler(p)
api.Any("/"+prefix, handler)
api.Any("/"+prefix+"/*path", handler)
}
// registerBffProxy 已在 v2.1 M9 移除teacher-bff / student-bff / parent-bff 由 apollo-router 替代。
// 前端 GraphQL 查询统一通过 /api/graphql → apollo-router:3000/graphql。