依据 coord-final-decisions §3.8 W1-W8 裁决与 president-final-rulings §2.15/§2.16/§2.19 完整实现网关硬化: - W1/W2: 错误码 GW_ 前缀 + ActionState 信封响应体 - W3: 全量替换为 log/slog 结构化日志 - W4: /readyz 并行 ping 9 下游 + 软失败规则 - W5: 7 个业务 Prometheus 指标 + /metrics 端点 - W6: tracer 资源属性补全(name/version/env/host) - W7: DevMode=true && ENV=production panic 防护 - W8: 保持共享 downstream 熔断 P2 RS256 升级:接入 shared-go/jwks.Fetcher(TTL 5min)。 P2.7+P3-P5 路由扩展:student/parent/messages/dashboard。 文档同步:README/01/02/known-issues,arch.db 已更新。 质量校验:go vet + build + test 均通过。
152 lines
5.3 KiB
Go
152 lines
5.3 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"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)
|
||
registerProxy(api, "teacher", cfg.TeacherBffURL)
|
||
// student-bff 路由(学生聚合层 GraphQL,P3)
|
||
registerProxy(api, "student", cfg.StudentBffURL)
|
||
// parent-bff 路由(家长聚合层 GraphQL,P4)
|
||
registerProxy(api, "parent", cfg.ParentBffURL)
|
||
// core-edu 域路由(考试/作业/成绩)
|
||
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)
|
||
// ai 服务路由(AI 聊天/生成/优化)
|
||
registerProxy(api, "ai", cfg.AiServiceURL)
|
||
// data-ana 域路由(学情诊断/错题本/仪表盘)
|
||
registerProxy(api, "analytics", cfg.DataAnaServiceURL)
|
||
registerProxy(api, "dashboard", cfg.DataAnaServiceURL)
|
||
}
|
||
|
||
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 不会自动跳转,故两条路由都要显式注册。
|
||
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)
|
||
}
|