202 lines
8.0 KiB
Go
202 lines
8.0 KiB
Go
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)
|
||
}
|