diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 05fd00f1..7a3135b8 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -159,8 +159,8 @@ func (h *UsageHandler) List(c *gin.Context) { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return } - // Set end time to end of day - t = t.Add(24*time.Hour - time.Nanosecond) + // Use half-open range [start, end), move to next calendar day start (DST-safe). + t = t.AddDate(0, 0, 1) endTime = &t } @@ -285,7 +285,8 @@ func (h *UsageHandler) Stats(c *gin.Context) { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return } - endTime = endTime.Add(24*time.Hour - time.Nanosecond) + // 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。 + endTime = endTime.AddDate(0, 0, 1) } else { period := c.DefaultQuery("period", "today") switch period { diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go new file mode 100644 index 00000000..b1200988 --- /dev/null +++ b/backend/internal/handler/endpoint.go @@ -0,0 +1,174 @@ +package handler + +import ( + "strings" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// ────────────────────────────────────────────────────────── +// Canonical inbound / upstream endpoint paths. +// All normalization and derivation reference this single set +// of constants — add new paths HERE when a new API surface +// is introduced. +// ────────────────────────────────────────────────────────── + +const ( + EndpointMessages = "/v1/messages" + EndpointChatCompletions = "/v1/chat/completions" + EndpointResponses = "/v1/responses" + EndpointGeminiModels = "/v1beta/models" +) + +// gin.Context keys used by the middleware and helpers below. +const ( + ctxKeyInboundEndpoint = "_gateway_inbound_endpoint" +) + +// ────────────────────────────────────────────────────────── +// Normalization functions +// ────────────────────────────────────────────────────────── + +// NormalizeInboundEndpoint maps a raw request path (which may carry +// prefixes like /antigravity, /openai, /sora) to its canonical form. +// +// "/antigravity/v1/messages" → "/v1/messages" +// "/v1/chat/completions" → "/v1/chat/completions" +// "/openai/v1/responses/foo" → "/v1/responses" +// "/v1beta/models/gemini:gen" → "/v1beta/models" +func NormalizeInboundEndpoint(path string) string { + path = strings.TrimSpace(path) + switch { + case strings.Contains(path, EndpointChatCompletions): + return EndpointChatCompletions + case strings.Contains(path, EndpointMessages): + return EndpointMessages + case strings.Contains(path, EndpointResponses): + return EndpointResponses + case strings.Contains(path, EndpointGeminiModels): + return EndpointGeminiModels + default: + return path + } +} + +// DeriveUpstreamEndpoint determines the upstream endpoint from the +// account platform and the normalized inbound endpoint. +// +// Platform-specific rules: +// - OpenAI always forwards to /v1/responses (with optional subpath +// such as /v1/responses/compact preserved from the raw URL). +// - Anthropic → /v1/messages +// - Gemini → /v1beta/models +// - Sora → /v1/chat/completions +// - Antigravity routes may target either Claude or Gemini, so the +// inbound endpoint is used to distinguish. +func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string { + inbound = strings.TrimSpace(inbound) + + switch platform { + case service.PlatformOpenAI: + // OpenAI forwards everything to the Responses API. + // Preserve subresource suffix (e.g. /v1/responses/compact). + if suffix := responsesSubpathSuffix(rawRequestPath); suffix != "" { + return EndpointResponses + suffix + } + return EndpointResponses + + case service.PlatformAnthropic: + return EndpointMessages + + case service.PlatformGemini: + return EndpointGeminiModels + + case service.PlatformSora: + return EndpointChatCompletions + + case service.PlatformAntigravity: + // Antigravity accounts serve both Claude and Gemini. + if inbound == EndpointGeminiModels { + return EndpointGeminiModels + } + return EndpointMessages + } + + // Unknown platform — fall back to inbound. + return inbound +} + +// responsesSubpathSuffix extracts the part after "/responses" in a raw +// request path, e.g. "/openai/v1/responses/compact" → "/compact". +// Returns "" when there is no meaningful suffix. +func responsesSubpathSuffix(rawPath string) string { + trimmed := strings.TrimRight(strings.TrimSpace(rawPath), "/") + idx := strings.LastIndex(trimmed, "/responses") + if idx < 0 { + return "" + } + suffix := trimmed[idx+len("/responses"):] + if suffix == "" || suffix == "/" { + return "" + } + if !strings.HasPrefix(suffix, "/") { + return "" + } + return suffix +} + +// ────────────────────────────────────────────────────────── +// Middleware +// ────────────────────────────────────────────────────────── + +// InboundEndpointMiddleware normalizes the request path and stores the +// canonical inbound endpoint in gin.Context so that every handler in +// the chain can read it via GetInboundEndpoint. +// +// Apply this middleware to all gateway route groups. +func InboundEndpointMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + path := c.FullPath() + if path == "" && c.Request != nil && c.Request.URL != nil { + path = c.Request.URL.Path + } + c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(path)) + c.Next() + } +} + +// ────────────────────────────────────────────────────────── +// Context helpers — used by handlers before building +// RecordUsageInput / RecordUsageLongContextInput. +// ────────────────────────────────────────────────────────── + +// GetInboundEndpoint returns the canonical inbound endpoint stored by +// InboundEndpointMiddleware. If the middleware did not run (e.g. in +// tests), it falls back to normalizing c.FullPath() on the fly. +func GetInboundEndpoint(c *gin.Context) string { + if v, ok := c.Get(ctxKeyInboundEndpoint); ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + // Fallback: normalize on the fly. + path := "" + if c != nil { + path = c.FullPath() + if path == "" && c.Request != nil && c.Request.URL != nil { + path = c.Request.URL.Path + } + } + return NormalizeInboundEndpoint(path) +} + +// GetUpstreamEndpoint derives the upstream endpoint from the context +// and the account platform. Handlers call this after scheduling an +// account, passing account.Platform. +func GetUpstreamEndpoint(c *gin.Context, platform string) string { + inbound := GetInboundEndpoint(c) + rawPath := "" + if c != nil && c.Request != nil && c.Request.URL != nil { + rawPath = c.Request.URL.Path + } + return DeriveUpstreamEndpoint(inbound, rawPath, platform) +} diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go new file mode 100644 index 00000000..a3767ac4 --- /dev/null +++ b/backend/internal/handler/endpoint_test.go @@ -0,0 +1,159 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func init() { gin.SetMode(gin.TestMode) } + +// ────────────────────────────────────────────────────────── +// NormalizeInboundEndpoint +// ────────────────────────────────────────────────────────── + +func TestNormalizeInboundEndpoint(t *testing.T) { + tests := []struct { + path string + want string + }{ + // Direct canonical paths. + {"/v1/messages", EndpointMessages}, + {"/v1/chat/completions", EndpointChatCompletions}, + {"/v1/responses", EndpointResponses}, + {"/v1beta/models", EndpointGeminiModels}, + + // Prefixed paths (antigravity, openai, sora). + {"/antigravity/v1/messages", EndpointMessages}, + {"/openai/v1/responses", EndpointResponses}, + {"/openai/v1/responses/compact", EndpointResponses}, + {"/sora/v1/chat/completions", EndpointChatCompletions}, + {"/antigravity/v1beta/models/gemini:generateContent", EndpointGeminiModels}, + + // Gin route patterns with wildcards. + {"/v1beta/models/*modelAction", EndpointGeminiModels}, + {"/v1/responses/*subpath", EndpointResponses}, + + // Unknown path is returned as-is. + {"/v1/embeddings", "/v1/embeddings"}, + {"", ""}, + {" /v1/messages ", EndpointMessages}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + require.Equal(t, tt.want, NormalizeInboundEndpoint(tt.path)) + }) + } +} + +// ────────────────────────────────────────────────────────── +// DeriveUpstreamEndpoint +// ────────────────────────────────────────────────────────── + +func TestDeriveUpstreamEndpoint(t *testing.T) { + tests := []struct { + name string + inbound string + rawPath string + platform string + want string + }{ + // Anthropic. + {"anthropic messages", EndpointMessages, "/v1/messages", service.PlatformAnthropic, EndpointMessages}, + + // Gemini. + {"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels}, + + // Sora. + {"sora completions", EndpointChatCompletions, "/sora/v1/chat/completions", service.PlatformSora, EndpointChatCompletions}, + + // OpenAI — always /v1/responses. + {"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses}, + {"openai responses compact", EndpointResponses, "/openai/v1/responses/compact", service.PlatformOpenAI, "/v1/responses/compact"}, + {"openai responses nested", EndpointResponses, "/openai/v1/responses/compact/detail", service.PlatformOpenAI, "/v1/responses/compact/detail"}, + {"openai from messages", EndpointMessages, "/v1/messages", service.PlatformOpenAI, EndpointResponses}, + {"openai from completions", EndpointChatCompletions, "/v1/chat/completions", service.PlatformOpenAI, EndpointResponses}, + + // Antigravity — uses inbound to pick Claude vs Gemini upstream. + {"antigravity claude", EndpointMessages, "/antigravity/v1/messages", service.PlatformAntigravity, EndpointMessages}, + {"antigravity gemini", EndpointGeminiModels, "/antigravity/v1beta/models", service.PlatformAntigravity, EndpointGeminiModels}, + + // Unknown platform — passthrough. + {"unknown platform", "/v1/embeddings", "/v1/embeddings", "unknown", "/v1/embeddings"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, DeriveUpstreamEndpoint(tt.inbound, tt.rawPath, tt.platform)) + }) + } +} + +// ────────────────────────────────────────────────────────── +// responsesSubpathSuffix +// ────────────────────────────────────────────────────────── + +func TestResponsesSubpathSuffix(t *testing.T) { + tests := []struct { + raw string + want string + }{ + {"/v1/responses", ""}, + {"/v1/responses/", ""}, + {"/v1/responses/compact", "/compact"}, + {"/openai/v1/responses/compact/detail", "/compact/detail"}, + {"/v1/messages", ""}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + require.Equal(t, tt.want, responsesSubpathSuffix(tt.raw)) + }) + } +} + +// ────────────────────────────────────────────────────────── +// InboundEndpointMiddleware + context helpers +// ────────────────────────────────────────────────────────── + +func TestInboundEndpointMiddleware(t *testing.T) { + router := gin.New() + router.Use(InboundEndpointMiddleware()) + + var captured string + router.POST("/v1/messages", func(c *gin.Context) { + captured = GetInboundEndpoint(c) + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, EndpointMessages, captured) +} + +func TestGetInboundEndpoint_FallbackWithoutMiddleware(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1/messages", nil) + + // Middleware did not run — fallback to normalizing c.Request.URL.Path. + got := GetInboundEndpoint(c) + require.Equal(t, EndpointMessages, got) +} + +func TestGetUpstreamEndpoint_FullFlow(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses/compact", nil) + + // Simulate middleware. + c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(c.Request.URL.Path)) + + got := GetUpstreamEndpoint(c, service.PlatformOpenAI) + require.Equal(t, "/v1/responses/compact", got) +} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 09652ada..831029c4 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -442,6 +442,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) if result.ReasoningEffort == nil { result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort) @@ -455,6 +457,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, @@ -757,6 +761,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) if result.ReasoningEffort == nil { result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort) @@ -770,6 +776,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { User: currentAPIKey.User, Account: account, Subscription: currentSubscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, @@ -935,7 +943,7 @@ func (h *GatewayHandler) parseUsageDateRange(c *gin.Context) (time.Time, time.Ti } if s := c.Query("end_date"); s != "" { if t, err := timezone.ParseInLocation("2006-01-02", s); err == nil { - endTime = t.Add(24*time.Hour - time.Second) // end of day + endTime = t.AddDate(0, 0, 1) // half-open range upper bound } } return startTime, endTime diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 9a16ff3a..cfe80911 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -504,6 +504,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { // 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。 requestPayloadHash := service.HashUsageRequestPayload(body) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) h.submitUsageRecordTask(func(ctx context.Context) { if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{ Result: result, @@ -511,6 +513,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 82b11c10..4db5cadd 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -261,8 +261,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, - InboundEndpoint: normalizedOpenAIInboundEndpoint(c, openAIInboundEndpointChatCompletions), - UpstreamEndpoint: normalizedOpenAIUpstreamEndpoint(c, openAIUpstreamEndpointResponses), + InboundEndpoint: GetInboundEndpoint(c), + UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform), UserAgent: userAgent, IPAddress: clientIP, APIKeyService: h.apiKeyService, diff --git a/backend/internal/handler/openai_gateway_endpoint_normalization_test.go b/backend/internal/handler/openai_gateway_endpoint_normalization_test.go index 6a055272..0dacd74d 100644 --- a/backend/internal/handler/openai_gateway_endpoint_normalization_test.go +++ b/backend/internal/handler/openai_gateway_endpoint_normalization_test.go @@ -5,42 +5,41 @@ import ( "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) -func TestNormalizedOpenAIUpstreamEndpoint(t *testing.T) { +// TestOpenAIUpstreamEndpoint_ViaGetUpstreamEndpoint verifies that the +// unified GetUpstreamEndpoint helper produces the same results as the +// former normalizedOpenAIUpstreamEndpoint for OpenAI platform requests. +func TestOpenAIUpstreamEndpoint_ViaGetUpstreamEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { - name string - path string - fallback string - want string + name string + path string + want string }{ { - name: "responses root maps to responses upstream", - path: "/v1/responses", - fallback: openAIUpstreamEndpointResponses, - want: "/v1/responses", + name: "responses root maps to responses upstream", + path: "/v1/responses", + want: EndpointResponses, }, { - name: "responses compact keeps compact suffix", - path: "/openai/v1/responses/compact", - fallback: openAIUpstreamEndpointResponses, - want: "/v1/responses/compact", + name: "responses compact keeps compact suffix", + path: "/openai/v1/responses/compact", + want: "/v1/responses/compact", }, { - name: "responses nested suffix preserved", - path: "/openai/v1/responses/compact/detail", - fallback: openAIUpstreamEndpointResponses, - want: "/v1/responses/compact/detail", + name: "responses nested suffix preserved", + path: "/openai/v1/responses/compact/detail", + want: "/v1/responses/compact/detail", }, { - name: "non responses path uses fallback", - path: "/v1/messages", - fallback: openAIUpstreamEndpointResponses, - want: "/v1/responses", + name: "non responses path uses platform fallback", + path: "/v1/messages", + want: EndpointResponses, }, } @@ -50,7 +49,7 @@ func TestNormalizedOpenAIUpstreamEndpoint(t *testing.T) { c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, tt.path, nil) - got := normalizedOpenAIUpstreamEndpoint(c, tt.fallback) + got := GetUpstreamEndpoint(c, service.PlatformOpenAI) require.Equal(t, tt.want, got) }) } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index b2aa5c50..c681e61d 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -37,13 +37,6 @@ type OpenAIGatewayHandler struct { cfg *config.Config } -const ( - openAIInboundEndpointResponses = "/v1/responses" - openAIInboundEndpointMessages = "/v1/messages" - openAIInboundEndpointChatCompletions = "/v1/chat/completions" - openAIUpstreamEndpointResponses = "/v1/responses" -) - // NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler func NewOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, @@ -369,8 +362,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, - InboundEndpoint: normalizedOpenAIInboundEndpoint(c, openAIInboundEndpointResponses), - UpstreamEndpoint: normalizedOpenAIUpstreamEndpoint(c, openAIUpstreamEndpointResponses), + InboundEndpoint: GetInboundEndpoint(c), + UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform), UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, @@ -747,8 +740,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, - InboundEndpoint: normalizedOpenAIInboundEndpoint(c, openAIInboundEndpointMessages), - UpstreamEndpoint: normalizedOpenAIUpstreamEndpoint(c, openAIUpstreamEndpointResponses), + InboundEndpoint: GetInboundEndpoint(c), + UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform), UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, @@ -1246,8 +1239,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, - InboundEndpoint: normalizedOpenAIInboundEndpoint(c, openAIInboundEndpointResponses), - UpstreamEndpoint: normalizedOpenAIUpstreamEndpoint(c, openAIUpstreamEndpointResponses), + InboundEndpoint: GetInboundEndpoint(c), + UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform), UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: service.HashUsageRequestPayload(firstMessage), @@ -1543,62 +1536,6 @@ func openAIWSIngressFallbackSessionSeed(userID, apiKeyID int64, groupID *int64) return fmt.Sprintf("openai_ws_ingress:%d:%d:%d", gid, userID, apiKeyID) } -func normalizedOpenAIInboundEndpoint(c *gin.Context, fallback string) string { - path := strings.TrimSpace(fallback) - if c != nil { - if fullPath := strings.TrimSpace(c.FullPath()); fullPath != "" { - path = fullPath - } else if c.Request != nil && c.Request.URL != nil { - if requestPath := strings.TrimSpace(c.Request.URL.Path); requestPath != "" { - path = requestPath - } - } - } - - switch { - case strings.Contains(path, openAIInboundEndpointChatCompletions): - return openAIInboundEndpointChatCompletions - case strings.Contains(path, openAIInboundEndpointMessages): - return openAIInboundEndpointMessages - case strings.Contains(path, openAIInboundEndpointResponses): - return openAIInboundEndpointResponses - default: - return path - } -} - -func normalizedOpenAIUpstreamEndpoint(c *gin.Context, fallback string) string { - base := strings.TrimSpace(fallback) - if base == "" { - base = openAIUpstreamEndpointResponses - } - base = strings.TrimRight(base, "/") - - if c == nil || c.Request == nil || c.Request.URL == nil { - return base - } - - path := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/") - if path == "" { - return base - } - - idx := strings.LastIndex(path, "/responses") - if idx < 0 { - return base - } - - suffix := strings.TrimSpace(path[idx+len("/responses"):]) - if suffix == "" || suffix == "/" { - return base - } - if !strings.HasPrefix(suffix, "/") { - return base - } - - return base + suffix -} - func isOpenAIWSUpgradeRequest(r *http.Request) bool { if r == nil { return false diff --git a/backend/internal/handler/sora_gateway_handler.go b/backend/internal/handler/sora_gateway_handler.go index 06abdf60..dc301ce1 100644 --- a/backend/internal/handler/sora_gateway_handler.go +++ b/backend/internal/handler/sora_gateway_handler.go @@ -400,6 +400,8 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) // 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。 h.submitUsageRecordTask(func(ctx context.Context) { @@ -409,6 +411,8 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 2bd0e0d7..483f5105 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -114,8 +114,8 @@ func (h *UsageHandler) List(c *gin.Context) { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return } - // Set end time to end of day - t = t.Add(24*time.Hour - time.Nanosecond) + // Use half-open range [start, end), move to next calendar day start (DST-safe). + t = t.AddDate(0, 0, 1) endTime = &t } @@ -227,8 +227,8 @@ func (h *UsageHandler) Stats(c *gin.Context) { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return } - // 设置结束时间为当天结束 - endTime = endTime.Add(24*time.Hour - time.Nanosecond) + // 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。 + endTime = endTime.AddDate(0, 0, 1) } else { // 使用 period 参数 period := c.DefaultQuery("period", "today") diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index cc949db2..a1fab45b 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -3004,7 +3004,7 @@ func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT COALESCE(SUM(actual_cost), 0) as total_actual_cost, COALESCE(AVG(duration_ms), 0) as avg_duration_ms FROM usage_logs - WHERE created_at >= $1 AND created_at <= $2 + WHERE created_at >= $1 AND created_at < $2 ` stats := &UsageStats{} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index ea40f2f1..fe820830 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -30,6 +30,7 @@ func RegisterGatewayRoutes( soraBodyLimit := middleware.RequestBodyLimit(soraMaxBodySize) clientRequestID := middleware.ClientRequestID() opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService) + endpointNorm := handler.InboundEndpointMiddleware() // 未分组 Key 拦截中间件(按协议格式区分错误响应) requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter) @@ -40,6 +41,7 @@ func RegisterGatewayRoutes( gateway.Use(bodyLimit) gateway.Use(clientRequestID) gateway.Use(opsErrorLogger) + gateway.Use(endpointNorm) gateway.Use(gin.HandlerFunc(apiKeyAuth)) gateway.Use(requireGroupAnthropic) { @@ -80,6 +82,7 @@ func RegisterGatewayRoutes( gemini.Use(bodyLimit) gemini.Use(clientRequestID) gemini.Use(opsErrorLogger) + gemini.Use(endpointNorm) gemini.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) gemini.Use(requireGroupGoogle) { @@ -90,11 +93,11 @@ func RegisterGatewayRoutes( } // OpenAI Responses API(不带v1前缀的别名) - r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.Responses) - r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.Responses) - r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ResponsesWebSocket) + r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.Responses) + r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.Responses) + r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ResponsesWebSocket) // OpenAI Chat Completions API(不带v1前缀的别名) - r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ChatCompletions) + r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ChatCompletions) // Antigravity 模型列表 r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels) @@ -104,6 +107,7 @@ func RegisterGatewayRoutes( antigravityV1.Use(bodyLimit) antigravityV1.Use(clientRequestID) antigravityV1.Use(opsErrorLogger) + antigravityV1.Use(endpointNorm) antigravityV1.Use(middleware.ForcePlatform(service.PlatformAntigravity)) antigravityV1.Use(gin.HandlerFunc(apiKeyAuth)) antigravityV1.Use(requireGroupAnthropic) @@ -118,6 +122,7 @@ func RegisterGatewayRoutes( antigravityV1Beta.Use(bodyLimit) antigravityV1Beta.Use(clientRequestID) antigravityV1Beta.Use(opsErrorLogger) + antigravityV1Beta.Use(endpointNorm) antigravityV1Beta.Use(middleware.ForcePlatform(service.PlatformAntigravity)) antigravityV1Beta.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) antigravityV1Beta.Use(requireGroupGoogle) @@ -132,6 +137,7 @@ func RegisterGatewayRoutes( soraV1.Use(soraBodyLimit) soraV1.Use(clientRequestID) soraV1.Use(opsErrorLogger) + soraV1.Use(endpointNorm) soraV1.Use(middleware.ForcePlatform(service.PlatformSora)) soraV1.Use(gin.HandlerFunc(apiKeyAuth)) soraV1.Use(requireGroupAnthropic) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index cff9e9bb..0b50162a 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -7130,6 +7130,8 @@ type RecordUsageInput struct { User *User Account *Account Subscription *UserSubscription // 可选:订阅信息 + InboundEndpoint string // 入站端点(客户端请求路径) + UpstreamEndpoint string // 上游端点(标准化后的上游路径) UserAgent string // 请求的 User-Agent IPAddress string // 请求的客户端 IP 地址 RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 @@ -7528,6 +7530,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu RequestID: requestID, Model: result.Model, ReasoningEffort: result.ReasoningEffort, + InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), + UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens, CacheCreationTokens: result.Usage.CacheCreationInputTokens, @@ -7608,6 +7612,8 @@ type RecordUsageLongContextInput struct { User *User Account *Account Subscription *UserSubscription // 可选:订阅信息 + InboundEndpoint string // 入站端点(客户端请求路径) + UpstreamEndpoint string // 上游端点(标准化后的上游路径) UserAgent string // 请求的 User-Agent IPAddress string // 请求的客户端 IP 地址 RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 @@ -7705,6 +7711,8 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input * RequestID: requestID, Model: result.Model, ReasoningEffort: result.ReasoningEffort, + InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), + UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens, CacheCreationTokens: result.Usage.CacheCreationInputTokens,