package httpx import ( "net/http" "github.com/go-chi/chi/v5" "log/slog" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" ) // RouterDeps bundles the dependencies needed to assemble the router. // MVP exposes only liveness/readiness probe hooks; later phases (auth // resolver, module handlers, etc.) will add fields here rather than // growing NewRouter's signature. type RouterDeps struct { // HealthCheck is invoked from GET /healthz. Returning a non-nil // error renders 503 with the error message as the body. If nil, // /healthz unconditionally returns 200 "ok". HealthCheck func(*http.Request) error // ReadyCheck is invoked from GET /readyz with the same contract. ReadyCheck func(*http.Request) error // SPAHandler 为 nil 时禁用前端 fallback;非 nil 时挂到 chi NotFound, // 把未匹配的请求交给 SPA handler 处理(静态文件 + index.html 回退)。 SPAHandler http.Handler // Logger 用于 Recover 和 Logger 中间件。为 nil 时跳过日志相关中间件。 Logger *slog.Logger // Security 是安全响应头中间件配置(cfg-gated;Enabled=false 时透传)。 Security middleware.SecurityHeadersConfig // CORS 是跨域中间件配置(cfg-gated;Enabled=false 时透传)。默认 off, // 避免误配破坏 SPA + WS 握手;需要时显式开启并配置 origin 白名单。 CORS middleware.CORSConfig // MetricsHandler 为 nil 时不挂 /metrics;非 nil 时挂到 GET /metrics。 // 暴露成本/会话数,必须经 MetricsAuthToken 门禁(见 NewRouter)。 MetricsHandler http.Handler // MetricsAuthToken 非空时,/metrics 要求 Authorization: Bearer // 或 ?token= 匹配,否则 401。为空时 /metrics 不做令牌校验(仅 // 适用于绑定内网 / 由反代鉴权的部署)。 MetricsAuthToken string } // NewRouter builds the application's chi.Router with the standard // liveness/readiness endpoints attached. The return type is the // chi.Router interface (not *chi.Mux) so call sites depend only on // behaviour shared with future routing layers. func NewRouter(deps RouterDeps) chi.Router { r := chi.NewRouter() // 所有中间件必须在注册路由之前添加(chi 的强制要求)。 r.Use(middleware.RequestID) // 安全头与 CORS 紧随 RequestID、在路由挂载之前;二者均 cfg-gated, // Enabled=false 时为透传,默认行为与历史一致。 r.Use(middleware.SecurityHeaders(deps.Security)) r.Use(middleware.CORS(deps.CORS)) if deps.Logger != nil { r.Use(middleware.Recover(deps.Logger)) r.Use(middleware.Logger(deps.Logger)) } r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { if deps.HealthCheck != nil { if err := deps.HealthCheck(req); err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } } _, _ = w.Write([]byte("ok")) }) r.Get("/readyz", func(w http.ResponseWriter, req *http.Request) { if deps.ReadyCheck != nil { if err := deps.ReadyCheck(req); err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } } _, _ = w.Write([]byte("ok")) }) // /metrics(Prometheus):admin-gated via static bearer token. 挂在 SPA // NotFound 之前,确保未匹配请求不会落到 SPA fallback。 if deps.MetricsHandler != nil { token := deps.MetricsAuthToken mh := deps.MetricsHandler r.Get("/metrics", func(w http.ResponseWriter, req *http.Request) { if token != "" && !metricsTokenOK(req, token) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } mh.ServeHTTP(w, req) }) } if deps.SPAHandler != nil { r.NotFound(deps.SPAHandler.ServeHTTP) } return r } // metricsTokenOK 校验 /metrics 请求是否携带匹配的令牌(Authorization: Bearer // 或 ?token=)。 func metricsTokenOK(req *http.Request, token string) bool { const prefix = "Bearer " if h := req.Header.Get("Authorization"); len(h) > len(prefix) && h[:len(prefix)] == prefix { if h[len(prefix):] == token { return true } } return req.URL.Query().Get("token") == token }