Files
Edu/services/api-gateway/main.go
SpecialX 421edd8a41 feat(data-ana): 完善学情诊断服务并添加ClickHouse降级模式
config.py ClickHouse连接改可选+加DEV_MODE/kafka_brokers

clickhouse_client.py 降级模式: host为空时返回None

main.py 端点先查ClickHouse降级返回骨架数据+新增errorbook

新增clickhouse-init.sql创建宽表和错题表

Gateway添加/analytics路由
2026-07-09 08:58:39 +08:00

149 lines
4.7 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)
// content 服务路由(教材/章节/知识点/题库)
contentProxy, err := proxy.NewProxy(cfg.ContentServiceURL)
if err != nil {
log.Fatalf("failed to create content proxy: %v", err)
}
contentHandler := proxy.ProxyHandler(contentProxy)
api.Any("/textbooks", contentHandler)
api.Any("/textbooks/*path", contentHandler)
api.Any("/chapters", contentHandler)
api.Any("/chapters/*path", contentHandler)
api.Any("/knowledge-points", contentHandler)
api.Any("/knowledge-points/*path", contentHandler)
api.Any("/questions", contentHandler)
api.Any("/questions/*path", contentHandler)
// data-ana 服务路由(学情诊断/错题本)
dataAnaProxy, err := proxy.NewProxy(cfg.DataAnaServiceURL)
if err != nil {
log.Fatalf("failed to create data-ana proxy: %v", err)
}
dataAnaHandler := proxy.ProxyHandler(dataAnaProxy)
api.Any("/analytics", dataAnaHandler)
api.Any("/analytics/*path", dataAnaHandler)
}
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")
}