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) // teacher-bff 路由(教师聚合层 GraphQL) // BFF 在 /graphql 提供服务(@Controller("graphql")),需剥离 /api/v1/teacher 前缀 // 契约 student-portal_contract.md §2.3:/api/v1/{bff}/* → {bff}:port/* registerBffProxy(api, "teacher", cfg.TeacherBffURL) // student-bff 路由(学生聚合层 GraphQL,P3) registerBffProxy(api, "student", cfg.StudentBffURL) // parent-bff 路由(家长聚合层 GraphQL,P4) // parent-bff 在 /v1/graphql 提供服务(ARB-022 §24.4 ISSUE-003 方案 A) registerBffProxy(api, "parent", cfg.ParentBffURL) // 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) } // admin 路由组:admin-portal 入口(契约 admin-portal_contract.md §2.3) // POST /api/admin/graphql → teacher-bff:3003/graphql(admin 命名空间) // 中间件链:熔断 → JWT 鉴权 → admin 角色强制 → 指标 → 反向代理 admin := r.Group("/api/admin") admin.Use(middleware.CircuitBreaker("teacher-bff-admin")) admin.Use(middleware.AuthMiddleware(cfg, fetcher)) admin.Use(middleware.AdminRoleMiddleware()) admin.Use(observability.Metrics()) { // /api/admin/graphql 是唯一入口(契约 §2.3 要求), // 路径重写:/api/admin/graphql → /graphql(teacher-bff @Controller("graphql")) graphqlProxy, err := proxy.NewProxyRewrite(cfg.TeacherBffURL, func(p string) string { return strings.TrimPrefix(p, "/api/admin") }) if err != nil { slog.Error("failed to create admin graphql proxy", "target", cfg.TeacherBffURL, "error", err) panic(err) } admin.Any("/graphql", proxy.ProxyHandler(graphqlProxy)) } 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 创建带路径重写的反向代理,用于 BFF 路由(teacher/student/parent)。 // BFF 在 /graphql 或 /v1/graphql 提供服务(@Controller),与下游 controller 在 /v1/{domain}/* 的 // 非 BFF 服务不同,需剥离 /api/v1/{prefix} 前缀,仅转发剩余路径到下游。 // 例:/api/v1/teacher/graphql → /graphql(teacher-bff:3003/graphql)。 // 例:/api/v1/parent/v1/graphql → /v1/graphql(parent-bff:3010/v1/graphql,ARB-022 §24.4 ISSUE-003 方案 A)。 // 契约依据:student-portal_contract.md §2.3 /api/v1/{bff}/* → {bff}:port/* func registerBffProxy(api *gin.RouterGroup, prefix, targetURL string) { stripPrefix := "/api/v1/" + prefix p, err := proxy.NewProxyRewrite(targetURL, func(p string) string { return strings.TrimPrefix(p, stripPrefix) }) if err != nil { slog.Error("failed to create bff proxy", "prefix", prefix, "target", targetURL, "error", err) panic(err) } handler := proxy.ProxyHandler(p) api.Any("/"+prefix, handler) api.Any("/"+prefix+"/*path", handler) }