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 tracer(W6 裁决:资源属性补全) hostName, _ := os.Hostname() tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint, cfg.Env, version, hostName) defer tracerShutdown() // 初始化 JWKS Fetcher(RS256 公钥缓存,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-edu(C1 裁决:classes 合并入 core-edu) registerProxy(api, "classes", cfg.CoreEduServiceURL) // iam 服务路由(身份与访问管理) registerProxy(api, "iam", cfg.IamServiceURL) // v2.1 M9:teacher-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 API(msg 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 M9:GraphQL 联邦入口(替代旧 BFF 聚合层) // /api/graphql → apollo-router:3000/graphql(统一前端 GraphQL 入口) // /api/admin/graphql → apollo-router:3000/graphql(admin 命名空间) // 中间件链:熔断 → 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 → /graphql(apollo-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 → /graphql(apollo-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。