fix(api-gateway): 修复尾斜杠重定向循环与 DEV_MODE 旁路

- main.go: 禁用 RedirectTrailingSlash,为 classes/iam/teacher 双注册无尾斜杠与通配符路由

- auth.go: DEV_MODE=true 时接受 Bearer dev-token 注入开发用户

- config.go: 新增 DevMode 配置项与 getEnvBool 工具

- page.tsx: 开发模式请求携带 Authorization: Bearer dev-token

- .env.example: 添加 DEV_MODE=false 默认值与生产警告
This commit is contained in:
SpecialX
2026-07-08 15:11:47 +08:00
parent a4ec5b72c5
commit e5902ca2b3
7 changed files with 171 additions and 55 deletions

View File

@@ -71,14 +71,20 @@ docker build -t edu/api-gateway .
通过环境变量配置(见 `internal/config/config.go`
| 变量 | 默认值 | 说明 |
| --------------------- | --------------------- | -------------------- |
| `PORT` | 8080 | 监听端口 |
| `JWT_SECRET` | (必填) | HS256 签名密钥P1 |
| `JWT_PUBLIC_KEY` | P2 | RS256 公钥 |
| `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址 |
| `RATE_LIMIT_RPS` | 10 | 每秒令牌数 |
| `RATE_LIMIT_BURST` | 20 | 突发容量 |
| 变量 | 默认值 | 说明 |
| ----------------------------- | --------------------- | ------------------------------------------------------ |
| `API_GATEWAY_PORT` | 8080 | 监听端口 |
| `JWT_SECRET` | (必填) | HS256 签名密钥P1 |
| `JWT_ISSUER` | next-edu-cloud | JWT 签发者 |
| `JWT_AUDIENCE` | next-edu-cloud | JWT 受众 |
| `DEV_MODE` | false | 开发模式旁路true 时接受 `Bearer dev-token`(仅本地) |
| `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址 |
| `IAM_SERVICE_URL` | http://localhost:3002 | iam 服务地址 |
| `TEACHER_BFF_URL` | http://localhost:3003 | teacher-bff 服务地址 |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | http://localhost:4318 | OpenTelemetry OTLP 端点 |
| `LOG_LEVEL` | info | 日志级别 |
> **生产环境警告**`DEV_MODE` 必须为 `false` 或不设。设为 `true` 会允许 `dev-token` 旁路鉴权并注入固定 admin 身份。
## 关联文档

View File

@@ -15,6 +15,7 @@ type Config struct {
TeacherBffURL string
OTLPEndpoint string
LogLevel string
DevMode bool
}
func Load() *Config {
@@ -28,6 +29,7 @@ func Load() *Config {
TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"),
DevMode: getEnvBool("DEV_MODE", false),
}
}
@@ -46,3 +48,12 @@ func getEnvInt(key string, fallback int) int {
}
return fallback
}
func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}

View File

@@ -1,4 +1,4 @@
package middleware
package middleware
import (
"net/http"
@@ -44,6 +44,15 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return
}
// 开发模式旁路DEV_MODE=true 时接受 "dev-token",注入开发用户
// 仅用于本地联调,生产环境必须关闭 DEV_MODE
if cfg.DevMode && tokenStr == "dev-token" {
c.Request.Header.Set("x-user-id", "dev-user")
c.Request.Header.Set("x-user-roles", "teacher,admin")
c.Next()
return
}
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid

View File

@@ -23,6 +23,8 @@ 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
@@ -50,25 +52,33 @@ func main() {
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)
}
api.Any("/classes/*path", proxy.ProxyHandler(classesProxy))
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)
}
api.Any("/iam/*path", proxy.ProxyHandler(iamProxy))
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)
}
api.Any("/teacher/*path", proxy.ProxyHandler(bffProxy))
bffHandler := proxy.ProxyHandler(bffProxy)
api.Any("/teacher", bffHandler)
api.Any("/teacher/*path", bffHandler)
}
srv := &http.Server{