152 lines
5.0 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 routes
import (
"bytes"
"context"
"io"
"net/http"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
const (
anthropicEventLoggingURL = "https://api.anthropic.com/api/event_logging/batch"
eventLoggingForwardTimeout = 8 * time.Second
claudeCodeGrowthBookDateUpdated = "1970-01-01T00:00:00Z"
)
// readinessHandlerTimeout 限定 readiness 端点对外的最大返回耗时。
// HealthService 内部对每个组件再有独立超时,所以这里给宽一点即可。
const readinessHandlerTimeout = 3 * time.Second
// RegisterCommonRoutes 注册通用路由(健康检查、状态等)。
//
// 健康端点的语义分层:
// - /healthz : liveness 探针。零依赖、永远 200。容器/进程探活专用。
// - /ready : readiness 探针。检查 DB+Redis任一失败返回 503。
// - /health : 历史端点,等价于 /healthz保留向后兼容。
//
// dashboard 用的"业务健康分"由 ops_health_score 单独提供,与本路由无关。
func RegisterCommonRoutes(r *gin.Engine, healthService *service.HealthService) {
// Liveness仅证明进程在响应。
livenessHandler := func(c *gin.Context) {
_ = healthService.Liveness()
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
r.GET("/healthz", livenessHandler)
r.GET("/health", livenessHandler) // 向后兼容旧的 docker-compose healthcheck
// Readiness检查关键依赖。失败时返回 503 但仍带详情,便于排障。
r.GET("/ready", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), readinessHandlerTimeout)
defer cancel()
report := healthService.Readiness(ctx)
status := http.StatusOK
if !report.OK {
status = http.StatusServiceUnavailable
}
c.JSON(status, report)
})
// Claude Code 遥测日志:清理敏感字段后转发给 Anthropic。
// 删除 baseUrl/gateway 字段防止网关地址暴露(见 FINGERPRINT_SECURITY_REPORT.md §GAP-1/2
// 转发而非丢弃,避免"高流量零遥测"异常被检测。
r.POST("/api/event_logging/batch", func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil || len(body) == 0 {
c.Status(http.StatusOK)
return
}
sanitized := sanitizeEventBatch(body)
ctx, cancel := context.WithTimeout(c.Request.Context(), eventLoggingForwardTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, anthropicEventLoggingURL, bytes.NewReader(sanitized))
if err != nil {
c.Status(http.StatusOK)
return
}
req.Header.Set("Content-Type", "application/json")
// 透传客户端的 Authorization headerOAuth Bearer token
if auth := c.GetHeader("Authorization"); auth != "" {
req.Header.Set("Authorization", auth)
}
resp, err := http.DefaultClient.Do(req)
if err == nil {
resp.Body.Close()
}
c.Status(http.StatusOK)
})
// Claude Code 启动预检:本地 CLI 会在启动早期请求该端点。
r.GET("/api/claude_cli/bootstrap", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"client_data": nil,
"additional_model_options": []any{},
"additional_model_costs": gin.H{},
})
})
// Claude Code 组织级策略限制:源码 schema 为 { restrictions: { key: { allowed: boolean } } }。
// 空对象表示当前没有下发任何限制。
r.GET("/api/claude_code/policy_limits", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"restrictions": gin.H{},
})
})
// GrowthBook 特性拉取:真实 Claude Code 远端评估会命中 /api/eval/:clientKey
// SDK 也支持 /api/features/:clientKey并通过 x-sse-support 探测是否可订阅 SSE。
r.GET("/api/features/:clientKey", func(c *gin.Context) {
c.Header("x-sse-support", "enabled")
c.JSON(http.StatusOK, gin.H{
"features": gin.H{},
"dateUpdated": claudeCodeGrowthBookDateUpdated,
})
})
r.POST("/api/eval/:clientKey", func(c *gin.Context) {
c.Header("x-sse-support", "enabled")
c.JSON(http.StatusOK, gin.H{
"features": gin.H{},
"dateUpdated": claudeCodeGrowthBookDateUpdated,
})
})
writeGrowthBookSSE := func(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
c.SSEvent("features", gin.H{
"features": gin.H{},
"dateUpdated": claudeCodeGrowthBookDateUpdated,
})
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
}
}
// 真实 Claude Code SDK 使用 /sub/:clientKey 订阅特性更新。
r.GET("/sub/:clientKey", writeGrowthBookSSE)
// 兼容当前内部 bootstrap 预热器仍在使用的旧路径,避免本地联调时 404。
r.GET("/sub/features/:clientKey", writeGrowthBookSSE)
// Setup status endpoint (always returns needs_setup: false in normal mode)
// This is used by the frontend to detect when the service has restarted after setup
r.GET("/setup/status", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": gin.H{
"needs_setup": false,
"step": "completed",
},
})
})
}