Files
Edu/services/api-gateway/main.go
SpecialX 4f539b50dd feat(api-gateway): 添加 core-edu 服务代理路由
main.go 新增 exams/homework/grades 三组路由(无尾斜杠+通配符)
config.go 新增 CoreEduServiceURL 字段默认 localhost:3004
删除死代码 internal/routing/routing.go(从未被 main 引用)

验证: GET /exams/class/:id 200, POST /exams 201, 链路全通
2026-07-09 08:02:39 +08:00

125 lines
3.8 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"
"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/proxy"
"github.com/gin-gonic/gin"
)
// maxBodyBytes 请求体大小上限10MB
const maxBodyBytes int64 = 10 * 1024 * 1024
func main() {
cfg := config.Load()
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. 请求 ID 注入
r.Use(middleware.RequestID())
// 3. 跨域
r.Use(middleware.CORS())
// 4. 安全响应头
r.Use(middleware.SecurityHeaders())
// 5. 请求体大小限制
r.Use(middleware.RequestBodyLimit(maxBodyBytes))
// 6. 限流(每 IP 100 rps突发 20
r.Use(middleware.RateLimit(100, 20))
// 健康检查路由(无需鉴权,在 Auth 之前)
r.GET("/healthz", health.Healthz)
r.GET("/readyz", health.Readyz)
// API v1 组:熔断 + 鉴权 + 反向代理
api := r.Group("/api/v1")
// 7. 熔断(仅作用于代理路由,下游 5xx 触发)
api.Use(middleware.CircuitBreaker("downstream"))
// 8. JWT 鉴权
api.Use(middleware.AuthMiddleware(cfg))
{
// classes 服务路由
// 注同时注册无尾斜杠与通配符两条路由。RedirectTrailingSlash=false 时,
// Gin 不会自动把 /classes 跳到 /classes/,所以两条都要显式注册。
classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL)
if err != nil {
log.Fatalf("failed to create classes proxy: %v", err)
}
classesHandler := proxy.ProxyHandler(classesProxy)
api.Any("/classes", classesHandler)
api.Any("/classes/*path", classesHandler)
// IAM 服务路由(身份与访问管理)
iamProxy, err := proxy.NewProxy(cfg.IamServiceURL)
if err != nil {
log.Fatalf("failed to create iam proxy: %v", err)
}
iamHandler := proxy.ProxyHandler(iamProxy)
api.Any("/iam", iamHandler)
api.Any("/iam/*path", iamHandler)
// Teacher BFF 路由(教师聚合层)
bffProxy, err := proxy.NewProxy(cfg.TeacherBffURL)
if err != nil {
log.Fatalf("failed to create teacher-bff proxy: %v", err)
}
bffHandler := proxy.ProxyHandler(bffProxy)
api.Any("/teacher", bffHandler)
api.Any("/teacher/*path", bffHandler)
// core-edu 服务路由(考试/作业/成绩)
// 注:同时注册无尾斜杠与通配符两条路由,与 classes/iam/teacher 一致。
coreEduProxy, err := proxy.NewProxy(cfg.CoreEduServiceURL)
if err != nil {
log.Fatalf("failed to create core-edu proxy: %v", err)
}
coreEduHandler := proxy.ProxyHandler(coreEduProxy)
api.Any("/exams", coreEduHandler)
api.Any("/exams/*path", coreEduHandler)
api.Any("/homework", coreEduHandler)
api.Any("/homework/*path", coreEduHandler)
api.Any("/grades", coreEduHandler)
api.Any("/grades/*path", coreEduHandler)
}
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
// 优雅关闭
go func() {
log.Printf("API Gateway listening on :%s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down API Gateway...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("API Gateway exited")
}