feat(api-gateway): 实现 W1-W8 网关硬化与 P2-P5 路由扩展
依据 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 均通过。
This commit is contained in:
@@ -1,20 +1,26 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config 持有 api-gateway 运行时配置
|
||||
// Config 持有 api-gateway 运行时配置。
|
||||
// P2 起 JWT 验签改 RS256(IAM 签发,Gateway 用 JWKS 公钥校验),
|
||||
// JWTSecret 仅在 DevMode 下作为 mock 密钥保留。
|
||||
type Config struct {
|
||||
Port string
|
||||
JWTSecret string
|
||||
JWKSURL string // RS256 公钥端点(IAM GET /.well-known/jwks.json)
|
||||
JWTSecret string // DevMode 下 mock 用(生产不再使用 HS256)
|
||||
JWTIssuer string
|
||||
JWTAudience string
|
||||
CORSOrigins string
|
||||
ClassesServiceURL string
|
||||
IamServiceURL string
|
||||
TeacherBffURL string
|
||||
StudentBffURL string
|
||||
ParentBffURL string
|
||||
CoreEduServiceURL string
|
||||
ContentServiceURL string
|
||||
DataAnaServiceURL string
|
||||
@@ -23,33 +29,57 @@ type Config struct {
|
||||
OTLPEndpoint string
|
||||
LogLevel string
|
||||
DevMode bool
|
||||
Env string // 部署环境标识:production / development(W7 防护用)
|
||||
}
|
||||
|
||||
// devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须配置 JWT_SECRET)
|
||||
// devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须关闭 DevMode)
|
||||
const devJWTSecret = "p1-dev-secret-change-in-production"
|
||||
|
||||
// Load 从环境变量加载配置。
|
||||
// 非 DevMode 下若 JWT_SECRET 未配置则 fatal 退出;DevMode 下使用默认密钥并打印 warning。
|
||||
//
|
||||
// W7 生产防护:DevMode=true 且 ENV=production 时 panic 拒绝启动,
|
||||
// 避免 dev-token 旁路鉴权被误开到生产环境。
|
||||
// P2 起 RS256:非 DevMode 下要求 IAM_JWKS_URL(JWKS 公钥端点),
|
||||
// JWT_SECRET 不再是生产必填(仅 DevMode mock 用)。
|
||||
func Load() *Config {
|
||||
devMode := getEnvBool("DEV_MODE", false)
|
||||
jwtSecret := getEnv("JWT_SECRET", "")
|
||||
if jwtSecret == "" {
|
||||
if devMode {
|
||||
log.Println("warning: JWT_SECRET not set, using dev default (DEV_MODE=true)")
|
||||
jwtSecret = devJWTSecret
|
||||
} else {
|
||||
log.Fatal("JWT_SECRET must be set in non-dev mode")
|
||||
}
|
||||
env := getEnv("ENV", "development")
|
||||
|
||||
// W7 生产防护:DevMode 旁路仅允许非生产环境
|
||||
if devMode && env == "production" {
|
||||
panic("DEV_MODE=true is not allowed in production (ENV=production)")
|
||||
}
|
||||
|
||||
jwtSecret := getEnv("JWT_SECRET", "")
|
||||
if jwtSecret == "" && devMode {
|
||||
slog.Warn("JWT_SECRET not set, using dev default (DEV_MODE=true)")
|
||||
jwtSecret = devJWTSecret
|
||||
}
|
||||
|
||||
// 非 DevMode 下要求 JWKS URL(RS256 验签)
|
||||
jwksURL := getEnv("IAM_JWKS_URL", "http://localhost:3002/.well-known/jwks.json")
|
||||
if !devMode && jwksURL == "" {
|
||||
panic("IAM_JWKS_URL must be set in non-dev mode (RS256 JWT verification)")
|
||||
}
|
||||
|
||||
slog.Info("config loaded",
|
||||
"env", env,
|
||||
"dev_mode", devMode,
|
||||
"jwks_url", jwksURL,
|
||||
)
|
||||
|
||||
return &Config{
|
||||
Port: getEnv("API_GATEWAY_PORT", "8080"),
|
||||
JWKSURL: jwksURL,
|
||||
JWTSecret: jwtSecret,
|
||||
JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"),
|
||||
JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"),
|
||||
CORSOrigins: getEnv("CORS_ORIGINS", ""),
|
||||
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
|
||||
IamServiceURL: getEnv("IAM_SERVICE_URL", "http://localhost:3002"),
|
||||
TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"),
|
||||
StudentBffURL: getEnv("STUDENT_BFF_URL", "http://localhost:3009"),
|
||||
ParentBffURL: getEnv("PARENT_BFF_URL", "http://localhost:3010"),
|
||||
CoreEduServiceURL: getEnv("CORE_EDU_SERVICE_URL", "http://localhost:3004"),
|
||||
ContentServiceURL: getEnv("CONTENT_SERVICE_URL", "http://localhost:3005"),
|
||||
DataAnaServiceURL: getEnv("DATA_ANA_SERVICE_URL", "http://localhost:3006"),
|
||||
@@ -58,10 +88,11 @@ func Load() *Config {
|
||||
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
DevMode: devMode,
|
||||
Env: env,
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv 读取环境变量,缺失时返回 fallback
|
||||
// getEnv 读取环境变量,缺失或空字符串时返回 fallback
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
|
||||
@@ -1,23 +1,112 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/edu-cloud/api-gateway/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Healthz 存活探针(liveness)。
|
||||
// GET /healthz:返回 200 {"status":"ok"} 表示进程存活。
|
||||
func Healthz(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// Readyz 就绪探针(readiness)。
|
||||
// GET /readyz:检查下游服务可达性。
|
||||
// TODO: P7 接入服务发现后,检查 classes / iam / teacher-bff 等下游服务健康状态,
|
||||
// 任一不可达则返回 503,当前简化为直接返回 200。
|
||||
func Readyz(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
// downstreamCheck 描述一个下游服务的健康检查配置。
|
||||
type downstreamCheck struct {
|
||||
name string // 服务名(用于响应体与日志)
|
||||
url string // /healthz 完整 URL
|
||||
required bool // true=必需依赖(失败返回 503),false=可选依赖(失败返回 200+degraded)
|
||||
}
|
||||
|
||||
// Readyz 就绪探针(readiness,W4 裁决)。
|
||||
// GET /readyz:并行 ping 下游服务 /healthz,超时 2s。
|
||||
//
|
||||
// 软失败规则(president-final-rulings.md §3.3):
|
||||
// - 必需依赖(iam / teacher-bff,P2 已就绪)失败 → 503
|
||||
// - 可选依赖(P3-P5 未就绪服务)失败 → 200 + degraded 列表
|
||||
// - 全部可达 → 200 {"status":"ok"}
|
||||
func Readyz(cfg *config.Config) gin.HandlerFunc {
|
||||
checks := []downstreamCheck{
|
||||
{name: "iam", url: cfg.IamServiceURL + "/healthz", required: true},
|
||||
{name: "teacher-bff", url: cfg.TeacherBffURL + "/healthz", required: true},
|
||||
{name: "core-edu", url: cfg.CoreEduServiceURL + "/healthz", required: false},
|
||||
{name: "content", url: cfg.ContentServiceURL + "/healthz", required: false},
|
||||
{name: "msg", url: cfg.MsgServiceURL + "/healthz", required: false},
|
||||
{name: "ai", url: cfg.AiServiceURL + "/healthz", required: false},
|
||||
{name: "data-ana", url: cfg.DataAnaServiceURL + "/healthz", required: false},
|
||||
{name: "student-bff", url: cfg.StudentBffURL + "/healthz", required: false},
|
||||
{name: "parent-bff", url: cfg.ParentBffURL + "/healthz", required: false},
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
unhealthy []string
|
||||
degraded []string
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, check := range checks {
|
||||
wg.Add(1)
|
||||
go func(dc downstreamCheck) {
|
||||
defer wg.Done()
|
||||
resp, err := client.Get(dc.url)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if dc.required {
|
||||
unhealthy = append(unhealthy, dc.name)
|
||||
} else {
|
||||
degraded = append(degraded, dc.name)
|
||||
}
|
||||
mu.Unlock()
|
||||
slog.Warn("downstream health check failed",
|
||||
"service", dc.name,
|
||||
"required", dc.required,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
mu.Lock()
|
||||
if dc.required {
|
||||
unhealthy = append(unhealthy, dc.name)
|
||||
} else {
|
||||
degraded = append(degraded, dc.name)
|
||||
}
|
||||
mu.Unlock()
|
||||
slog.Warn("downstream unhealthy",
|
||||
"service", dc.name,
|
||||
"required", dc.required,
|
||||
"status", resp.StatusCode,
|
||||
)
|
||||
}
|
||||
}(check)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(unhealthy) > 0 {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "error",
|
||||
"unhealthy": unhealthy,
|
||||
"degraded": degraded,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp := gin.H{"status": "ok"}
|
||||
if len(degraded) > 0 {
|
||||
resp["degraded"] = degraded
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/edu-cloud/api-gateway/internal/config"
|
||||
"github.com/edu-cloud/api-gateway/internal/observability"
|
||||
"github.com/edu-cloud/shared-go/jwks"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
|
||||
@@ -23,16 +25,19 @@ func isPublicPath(path string) bool {
|
||||
return publicPaths[stripped]
|
||||
}
|
||||
|
||||
// AuthMiddleware 验证 JWT 并注入用户信息到请求头
|
||||
// P1 用 HS256,P2 改 RS256(IAM 签发)
|
||||
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
// AuthMiddleware 验证 JWT(RS256,通过 IAM JWKS 公钥校验)并注入用户信息到请求头。
|
||||
//
|
||||
// P2 升级:HS256 → RS256(shared-go/jwks.Fetcher),JWT 由 IAM 签发、Gateway 公钥校验。
|
||||
// DevMode 旁路:接受 "dev-token",注入固定开发身份(仅非生产环境,W7 防护在 config.Load)。
|
||||
//
|
||||
// 注入下游头:
|
||||
// - x-user-id(来自 claims.UserID)
|
||||
// - x-user-roles(来自 claims.Role)
|
||||
// - x-data-scope(来自 claims.DataScope)
|
||||
//
|
||||
// fetcher 为 nil 时(DevMode)跳过真实验签,仅走 dev-token 旁路。
|
||||
func AuthMiddleware(cfg *config.Config, fetcher *jwks.Fetcher) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 健康检查跳过鉴权
|
||||
if c.Request.URL.Path == "/healthz" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 公开路径白名单(register/login/refresh 无需鉴权)
|
||||
if isPublicPath(c.Request.URL.Path) {
|
||||
c.Next()
|
||||
@@ -41,81 +46,89 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "missing authorization header",
|
||||
},
|
||||
})
|
||||
observability.IncAuthFailure("missing_auth_header")
|
||||
abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "missing authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenStr == authHeader {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "invalid authorization scheme, expected Bearer",
|
||||
},
|
||||
})
|
||||
observability.IncAuthFailure("invalid_scheme")
|
||||
abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "invalid authorization scheme, expected Bearer")
|
||||
return
|
||||
}
|
||||
|
||||
// 开发模式旁路:DEV_MODE=true 时接受 "dev-token",注入开发用户
|
||||
// 仅用于本地联调,生产环境必须关闭 DEV_MODE
|
||||
// 仅用于本地联调,生产环境必须关闭 DEV_MODE(W7 防护在 config.Load 拦截)
|
||||
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.Request.Header.Set("x-data-scope", "SCHOOL")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "token validation failed",
|
||||
},
|
||||
})
|
||||
// 非 DevMode 必须有 fetcher(RS256 验签)
|
||||
if fetcher == nil {
|
||||
observability.IncAuthFailure("fetcher_nil")
|
||||
slog.Error("jwks fetcher is nil in non-dev mode")
|
||||
abortGW(c, http.StatusInternalServerError, "GW_INTERNAL_ERROR", "auth not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "INVALID_CLAIMS",
|
||||
"message": "invalid token claims",
|
||||
},
|
||||
})
|
||||
// RS256 验签(shared-go/jwks.Fetcher 内部按 kid 取公钥 + 缓存)
|
||||
claims, err := fetcher.ValidateToken(tokenStr)
|
||||
if err != nil {
|
||||
observability.IncAuthFailure("invalid_token")
|
||||
slog.Warn("jwt validation failed", "error", err)
|
||||
abortGW(c, http.StatusUnauthorized, "GW_INVALID_TOKEN", "token validation failed")
|
||||
return
|
||||
}
|
||||
|
||||
// iss / aud 校验(Config 定义但之前未生效)
|
||||
if cfg.JWTIssuer != "" && claims.Issuer != cfg.JWTIssuer {
|
||||
observability.IncAuthFailure("invalid_issuer")
|
||||
abortGW(c, http.StatusUnauthorized, "GW_INVALID_CLAIMS", "invalid issuer")
|
||||
return
|
||||
}
|
||||
if cfg.JWTAudience != "" {
|
||||
audMatch := false
|
||||
for _, a := range claims.Audience {
|
||||
if a == cfg.JWTAudience {
|
||||
audMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !audMatch {
|
||||
observability.IncAuthFailure("invalid_audience")
|
||||
abortGW(c, http.StatusUnauthorized, "GW_INVALID_CLAIMS", "invalid audience")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 注入用户信息到下游请求头
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
c.Request.Header.Set("x-user-id", sub)
|
||||
if claims.UserID != "" {
|
||||
c.Request.Header.Set("x-user-id", claims.UserID)
|
||||
}
|
||||
if roles, ok := claims["roles"].([]any); ok {
|
||||
roleStrs := make([]string, 0, len(roles))
|
||||
for _, r := range roles {
|
||||
if s, ok := r.(string); ok {
|
||||
roleStrs = append(roleStrs, s)
|
||||
}
|
||||
}
|
||||
c.Request.Header.Set("x-user-roles", strings.Join(roleStrs, ","))
|
||||
if claims.Role != "" {
|
||||
c.Request.Header.Set("x-user-roles", claims.Role)
|
||||
}
|
||||
if claims.DataScope != "" {
|
||||
c.Request.Header.Set("x-data-scope", claims.DataScope)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// abortGW 统一返回 ActionState 信封格式的错误响应(W1/W2 裁决)。
|
||||
// 响应体:{"success":false,"error":{"code":"<code>","message":"<msg>"}}
|
||||
func abortGW(c *gin.Context, status int, code, message string) {
|
||||
c.AbortWithStatusJSON(status, gin.H{
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,15 +2,16 @@ package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/edu-cloud/api-gateway/internal/observability"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
)
|
||||
|
||||
// CircuitBreaker 返回针对指定服务的熔断中间件。
|
||||
// CircuitBreaker 返回针对指定服务的熔断中间件(W8 裁决:保持共享 downstream)。
|
||||
//
|
||||
// 基于 gobreaker v2 实现:
|
||||
// - Interval=5s:CLOSED 状态下的统计窗口
|
||||
@@ -19,7 +20,9 @@ import (
|
||||
// - MaxRequests=1:HALF_OPEN 仅允许 1 个探测请求
|
||||
//
|
||||
// 仅当下游返回 5xx 视为失败;4xx 与 2xx 不计入熔断。
|
||||
// 熔断打开或半开探测名额已满时返回 503 + JSON {"error":"circuit_open","retry_after":30}。
|
||||
// 熔断打开或半开探测名额已满时返回 503 + ActionState 信封(W1/W2 裁决):
|
||||
//
|
||||
// {"success":false,"error":{"code":"GW_CIRCUIT_OPEN","message":"..."},"retry_after":30}
|
||||
func CircuitBreaker(serviceName string) gin.HandlerFunc {
|
||||
cb := gobreaker.NewCircuitBreaker[struct{}](gobreaker.Settings{
|
||||
Name: serviceName,
|
||||
@@ -35,7 +38,13 @@ func CircuitBreaker(serviceName string) gin.HandlerFunc {
|
||||
return counts.TotalFailures*2 > counts.Requests
|
||||
},
|
||||
OnStateChange: func(name string, from, to gobreaker.State) {
|
||||
log.Printf("[circuit-breaker] service=%s state: %s -> %s", name, from, to)
|
||||
observability.SetCircuitBreakerState(name, from.String(), 0)
|
||||
observability.SetCircuitBreakerState(name, to.String(), 1)
|
||||
slog.Info("circuit breaker state changed",
|
||||
"service", name,
|
||||
"from", from.String(),
|
||||
"to", to.String(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -52,8 +61,13 @@ func CircuitBreaker(serviceName string) gin.HandlerFunc {
|
||||
if err != nil {
|
||||
// 熔断打开或半开探测名额已满:返回 503
|
||||
if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
|
||||
c.Header("Retry-After", "30")
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "circuit_open",
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "GW_CIRCUIT_OPEN",
|
||||
"message": "upstream circuit breaker open",
|
||||
},
|
||||
"retry_after": 30,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/edu-cloud/api-gateway/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -16,16 +16,16 @@ const corsMaxAge = 12 * 60 * 60
|
||||
// devCORSOrigins 是未配置 CORS_ORIGINS 时的开发环境默认白名单
|
||||
const devCORSOrigins = "http://localhost:3000,http://localhost:3001"
|
||||
|
||||
// CORS 返回跨域资源共享中间件。
|
||||
// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔);
|
||||
// CORS 返回跨域资源共享中间件(从 Config 读取白名单,W3 裁决:slog 结构化日志)。
|
||||
// 允许来源从 cfg.CORSOrigins 读取(逗号分隔);
|
||||
// 未配置时使用开发环境白名单(localhost:3000/3001)并打印 warning。
|
||||
// 允许方法:GET POST PUT DELETE OPTIONS PATCH
|
||||
// 允许头:Authorization Content-Type X-Request-Id X-Trace-Id
|
||||
// 暴露头:X-Request-Id X-Trace-Id
|
||||
func CORS() gin.HandlerFunc {
|
||||
allowed := parseCORSOrigins(os.Getenv("CORS_ORIGINS"))
|
||||
func CORS(cfg *config.Config) gin.HandlerFunc {
|
||||
allowed := parseCORSOrigins(cfg.CORSOrigins)
|
||||
if len(allowed) == 0 {
|
||||
log.Println("warning: CORS_ORIGINS not set, using dev default whitelist")
|
||||
slog.Warn("CORS_ORIGINS not set, using dev default whitelist")
|
||||
allowed = parseCORSOrigins(devCORSOrigins)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/edu-cloud/api-gateway/internal/observability"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -22,9 +23,12 @@ type rateLimiter struct {
|
||||
buckets sync.Map // map[string]*bucket
|
||||
}
|
||||
|
||||
// RateLimit 返回基于令牌桶的限流中间件(内存版,不依赖 Redis)。
|
||||
// RateLimit 返回基于令牌桶的限流中间件(内存版,P6 迁 Redis)。
|
||||
// 每个客户端 IP 独立桶,按 rps(每秒令牌数)补充,最大容量为 burst。
|
||||
// 超限返回 429 + JSON {"error":"rate_limited","retry_after":60}。
|
||||
// 超限返回 429 + ActionState 信封(W1/W2 裁决):
|
||||
//
|
||||
// {"success":false,"error":{"code":"GW_RATE_LIMITED","message":"..."},"retry_after":60}
|
||||
//
|
||||
// 内部启动 cleanup goroutine,每 5 分钟清理 10 分钟未访问的桶。
|
||||
func RateLimit(rps float64, burst int) gin.HandlerFunc {
|
||||
rl := &rateLimiter{rps: rps, burst: burst}
|
||||
@@ -47,9 +51,14 @@ func RateLimit(rps float64, burst int) gin.HandlerFunc {
|
||||
// 令牌不足:拒绝
|
||||
if b.tokens < 1 {
|
||||
b.mu.Unlock()
|
||||
observability.IncRateLimited(ip)
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate_limited",
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "GW_RATE_LIMITED",
|
||||
"message": "rate limit exceeded",
|
||||
},
|
||||
"retry_after": 60,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
@@ -9,20 +9,32 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Recovery 捕获 panic 并返回 500,记录堆栈日志。
|
||||
// 返回 JSON {"error":"internal_error","request_id":"<uuid>"}。
|
||||
// request_id 使用 uuid.New() 生成(Recovery 在 RequestID 之前注册,
|
||||
// panic 发生时上下文中可能尚无 request_id,故独立生成)。
|
||||
// Recovery 捕获 panic 并返回 500(ActionState 信封,W1/W2 裁决)。
|
||||
//
|
||||
// 响应体:{"success":false,"error":{"code":"GW_INTERNAL_ERROR","message":"..."},"request_id":"<uuid>"}。
|
||||
// request_id 从 context 取(RequestID 中间件注入),若 Recovery 在 RequestID 之前触发
|
||||
// 导致 context 无 request_id,则独立生成 uuid 保证可追溯。
|
||||
func Recovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stack := debug.Stack()
|
||||
requestID := uuid.New().String()
|
||||
log.Printf("[recovery] panic recovered, request_id=%s: %v\n%s", requestID, r, stack)
|
||||
// 若已写入部分响应,AbortWithStatusJSON 仍会设置状态并尝试写 JSON
|
||||
// 优先从 context 取 request_id(RequestID 中间件已注入)
|
||||
requestID, exists := c.Get(requestIDContextKey)
|
||||
if !exists {
|
||||
requestID = uuid.New().String()
|
||||
}
|
||||
slog.Error("panic recovered",
|
||||
"request_id", requestID,
|
||||
"error", r,
|
||||
"stack", string(stack),
|
||||
)
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "internal_error",
|
||||
"success": false,
|
||||
"error": gin.H{
|
||||
"code": "GW_INTERNAL_ERROR",
|
||||
"message": "internal server error",
|
||||
},
|
||||
"request_id": requestID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rid := c.GetHeader(requestIDHeader)
|
||||
if rid == "" {
|
||||
rid = uuid.New().String()
|
||||
// 02-architecture-design.md §4.6:req-<uuid-v4> 格式
|
||||
rid = "req-" + uuid.New().String()
|
||||
}
|
||||
c.Set(requestIDContextKey, rid)
|
||||
c.Writer.Header().Set(requestIDHeader, rid)
|
||||
|
||||
@@ -30,9 +30,19 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// RequestBodyLimit 限制请求体大小中间件。
|
||||
// 通过 http.MaxBytesReader 包装 Body,超限读取时返回 413。
|
||||
// 超限时返回 413 + ActionState 信封(W1/W2 裁决):
|
||||
//
|
||||
// {"success":false,"error":{"code":"GW_REQUEST_TOO_LARGE","message":"..."}}
|
||||
//
|
||||
// 通过 Content-Length 预检 + http.MaxBytesReader 双重保障:
|
||||
// - Content-Length 预检:已知大小的请求体立即拒绝(JSON 信封响应)
|
||||
// - MaxBytesReader:流式请求体(无 Content-Length)读取超限时由标准库返回 413
|
||||
func RequestBodyLimit(maxBytes int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.ContentLength > maxBytes {
|
||||
abortGW(c, http.StatusRequestEntityTooLarge, "GW_REQUEST_TOO_LARGE", "request body too large")
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
96
services/api-gateway/internal/observability/metrics.go
Normal file
96
services/api-gateway/internal/observability/metrics.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Package observability provides tracer, metrics, and structured logging
|
||||
// initialization for the api-gateway.
|
||||
package observability
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// 业务指标(W5 裁决:7 个业务 metrics)
|
||||
var (
|
||||
httpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "api_gateway_http_requests_total",
|
||||
Help: "Total number of HTTP requests processed by the gateway.",
|
||||
}, []string{"method", "endpoint", "status"})
|
||||
|
||||
httpRequestDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "api_gateway_http_request_duration_seconds",
|
||||
Help: "HTTP request processing duration in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"method", "endpoint"})
|
||||
|
||||
circuitBreakerState = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "api_gateway_circuit_breaker_state",
|
||||
Help: "Circuit breaker state by service: 1=active state, 0=inactive.",
|
||||
}, []string{"service", "state"})
|
||||
|
||||
rateLimitedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "api_gateway_rate_limited_total",
|
||||
Help: "Total number of rate-limited requests.",
|
||||
}, []string{"ip"})
|
||||
|
||||
proxyUpstreamDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "api_gateway_proxy_upstream_duration_seconds",
|
||||
Help: "Upstream proxy response duration in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"upstream"})
|
||||
|
||||
jwksRefreshTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "api_gateway_jwks_refresh_total",
|
||||
Help: "Total number of JWKS cache refresh attempts.",
|
||||
}, []string{"result"})
|
||||
|
||||
authFailuresTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "api_gateway_auth_failures_total",
|
||||
Help: "Total number of authentication failures.",
|
||||
}, []string{"reason"})
|
||||
)
|
||||
|
||||
// Metrics 返回 HTTP 请求统计中间件(W5 裁决)。
|
||||
// 记录请求总数(method/endpoint/status)与请求延迟(method/endpoint)。
|
||||
// endpoint 使用 gin 路由模式(c.FullPath())避免高基数。
|
||||
func Metrics() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
duration := time.Since(start).Seconds()
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
endpoint := c.FullPath()
|
||||
if endpoint == "" {
|
||||
endpoint = "unknown"
|
||||
}
|
||||
httpRequestsTotal.WithLabelValues(c.Request.Method, endpoint, status).Inc()
|
||||
httpRequestDurationSeconds.WithLabelValues(c.Request.Method, endpoint).Observe(duration)
|
||||
}
|
||||
}
|
||||
|
||||
// IncAuthFailure 递增鉴权失败计数器。
|
||||
func IncAuthFailure(reason string) {
|
||||
authFailuresTotal.WithLabelValues(reason).Inc()
|
||||
}
|
||||
|
||||
// IncRateLimited 递增限流计数器。
|
||||
func IncRateLimited(ip string) {
|
||||
rateLimitedTotal.WithLabelValues(ip).Inc()
|
||||
}
|
||||
|
||||
// IncJWKSRefresh 递增 JWKS 刷新计数器(result: success/failure)。
|
||||
func IncJWKSRefresh(result string) {
|
||||
jwksRefreshTotal.WithLabelValues(result).Inc()
|
||||
}
|
||||
|
||||
// SetCircuitBreakerState 更新熔断器状态 gauge。
|
||||
func SetCircuitBreakerState(service, state string, value float64) {
|
||||
circuitBreakerState.WithLabelValues(service, state).Set(value)
|
||||
}
|
||||
|
||||
// ObserveProxyUpstreamDuration 记录上游代理响应延迟。
|
||||
func ObserveProxyUpstreamDuration(upstream string, duration time.Duration) {
|
||||
proxyUpstreamDurationSeconds.WithLabelValues(upstream).Observe(duration.Seconds())
|
||||
}
|
||||
@@ -2,11 +2,12 @@ package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
@@ -14,21 +15,22 @@ import (
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
)
|
||||
|
||||
// InitTracer 初始化 OpenTelemetry tracer.
|
||||
// InitTracer 初始化 OpenTelemetry tracer(W6 裁决:资源属性补全)。
|
||||
//
|
||||
// 资源属性包含:service.name / service.version / deployment.environment / host.name。
|
||||
// endpoint 为 "http://host:port" 格式(如 "http://localhost:4318");
|
||||
// 为空时跳过初始化(tracing disabled)。
|
||||
//
|
||||
// 返回 shutdown 函数,应在服务退出时调用以 flush 待发送 span.
|
||||
func InitTracer(serviceName, endpoint string) func() {
|
||||
// 返回 shutdown 函数,应在服务退出时调用以 flush 待发送 span。
|
||||
func InitTracer(serviceName, endpoint, env, version, hostName string) func() {
|
||||
if endpoint == "" {
|
||||
log.Println("OTEL endpoint not set, tracing disabled")
|
||||
slog.Info("OTEL endpoint not set, tracing disabled")
|
||||
return func() {}
|
||||
}
|
||||
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Host == "" {
|
||||
log.Printf("invalid OTEL endpoint %q, tracing disabled", endpoint)
|
||||
slog.Warn("invalid OTEL endpoint, tracing disabled", "endpoint", endpoint)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
@@ -40,15 +42,21 @@ func InitTracer(serviceName, endpoint string) func() {
|
||||
otlptracehttp.WithInsecure(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("failed to create OTLP exporter: %v, tracing disabled", err)
|
||||
slog.Error("failed to create OTLP exporter, tracing disabled", "error", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// W6 裁决:资源属性补全(service.name + version + env + host)
|
||||
res, err := resource.New(ctx,
|
||||
resource.WithAttributes(semconv.ServiceName(serviceName)),
|
||||
resource.WithAttributes(
|
||||
semconv.ServiceName(serviceName),
|
||||
attribute.String("service.version", version),
|
||||
attribute.String("deployment.environment", env),
|
||||
attribute.String("host.name", hostName),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("failed to create resource: %v", err)
|
||||
slog.Error("failed to create resource", "error", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
@@ -62,12 +70,18 @@ func InitTracer(serviceName, endpoint string) func() {
|
||||
propagation.Baggage{},
|
||||
))
|
||||
|
||||
log.Printf("OpenTelemetry tracer initialized for %s (endpoint=%s)", serviceName, u.Host)
|
||||
slog.Info("OpenTelemetry tracer initialized",
|
||||
"service", serviceName,
|
||||
"endpoint", u.Host,
|
||||
"env", env,
|
||||
"version", version,
|
||||
"host", hostName,
|
||||
)
|
||||
return func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := tp.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("failed to shutdown tracer: %v", err)
|
||||
slog.Error("failed to shutdown tracer", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,8 @@ func NewProxy(targetURL string) (*httputil.ReverseProxy, error) {
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
// 去除 /api/v1 前缀
|
||||
// 去除 /api/v1 前缀(Gateway 不改路径,直接透传给下游服务根路径)
|
||||
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api/v1")
|
||||
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api")
|
||||
req.Host = target.Host
|
||||
}
|
||||
return proxy, nil
|
||||
|
||||
Reference in New Issue
Block a user