Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s

This commit is contained in:
李建琦
2026-08-21 18:30:13 +08:00
commit 6d655c9903
3584 changed files with 1270640 additions and 0 deletions
+859
View File
@@ -0,0 +1,859 @@
// Package routes provides HTTP route registration and handlers.
package routes
import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// RegisterAdminRoutes 注册管理员路由
func RegisterAdminRoutes(
v1 *gin.RouterGroup,
h *handler.Handlers,
adminAuth middleware.AdminAuthMiddleware,
auditLog middleware.AuditLogMiddleware,
stepUpAuth middleware.StepUpAuthMiddleware,
settingService *service.SettingService,
panelRateLimiter *middleware.PanelRateLimiter,
) {
admin := v1.Group("/admin")
admin.Use(gin.HandlerFunc(adminAuth))
// 面板全局按用户限流(默认管理员豁免,可在系统设置中关闭豁免)
admin.Use(panelRateLimiter.Global())
// 审计中间件挂在认证之后:所有管理面变更类操作 + 敏感读取入审计日志
admin.Use(gin.HandlerFunc(auditLog))
admin.Use(middleware.AdminComplianceGuard(settingService))
{
// 部署与运营合规确认
registerAdminComplianceRoutes(admin, h)
// 仪表盘
registerDashboardRoutes(admin, h)
// 用户管理
registerUserManagementRoutes(admin, h)
// 分组管理
registerGroupRoutes(admin, h)
// 账号管理
registerAccountRoutes(admin, h, stepUpAuth)
// 公告管理
registerAnnouncementRoutes(admin, h)
// OpenAI OAuth
registerOpenAIOAuthRoutes(admin, h)
// Gemini OAuth
registerGeminiOAuthRoutes(admin, h)
// Antigravity OAuth
registerAntigravityOAuthRoutes(admin, h)
// Grok OAuth
registerGrokOAuthRoutes(admin, h)
// 国产供应商(kimi/zhipu/deepseek)额度与余额
registerCNProviderRoutes(admin, h)
// 代理管理
registerProxyRoutes(admin, h, stepUpAuth)
// 卡密管理
registerRedeemCodeRoutes(admin, h)
// 优惠码管理
registerPromoCodeRoutes(admin, h)
// 系统设置
registerSettingsRoutes(admin, h)
// 数据管理
registerDataManagementRoutes(admin, h, stepUpAuth)
// 数据库备份恢复
registerBackupRoutes(admin, h, stepUpAuth)
// 运维监控(Ops
registerOpsRoutes(admin, h)
// 系统管理
registerSystemRoutes(admin, h)
// 订阅管理
registerSubscriptionRoutes(admin, h)
// 使用记录管理
registerUsageRoutes(admin, h)
// 用户属性管理
registerUserAttributeRoutes(admin, h)
// 错误透传规则管理
registerErrorPassthroughRoutes(admin, h)
// TLS 指纹模板管理
registerTLSFingerprintProfileRoutes(admin, h)
// API Key 管理
registerAdminAPIKeyRoutes(admin, h)
// 定时测试计划
registerScheduledTestRoutes(admin, h)
// 渠道管理
registerChannelRoutes(admin, h)
// 渠道监控
registerChannelMonitorRoutes(admin, h, settingService)
registerChannelMonitorV2Routes(admin, h, settingService)
// 风控中心
registerContentModerationRoutes(admin, h)
// 独立提示词输入审计
registerPromptAuditRoutes(admin, h)
// 邀请返利(专属用户管理)
registerAffiliateRoutes(admin, h)
// 操作审计日志
registerAuditLogRoutes(admin, h, stepUpAuth)
}
}
func registerPromptAuditRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
promptAudit := admin.Group("/prompt-audit")
{
promptAudit.GET("/config", h.Admin.PromptAudit.GetConfig)
promptAudit.PUT("/config", h.Admin.PromptAudit.UpdateConfig)
promptAudit.POST("/endpoints/probe", h.Admin.PromptAudit.ProbeEndpoint)
promptAudit.GET("/runtime", h.Admin.PromptAudit.GetRuntime)
promptAudit.GET("/events", h.Admin.PromptAudit.ListEvents)
promptAudit.GET("/events/:id", h.Admin.PromptAudit.GetEvent)
promptAudit.DELETE("/events/:id", h.Admin.PromptAudit.DeleteEvent)
promptAudit.POST("/events/batch-delete", h.Admin.PromptAudit.BatchDelete)
promptAudit.POST("/events/delete-preview", h.Admin.PromptAudit.DeletePreview)
promptAudit.POST("/events/delete-by-filter", h.Admin.PromptAudit.DeleteByFilter)
}
}
func registerAuditLogRoutes(admin *gin.RouterGroup, h *handler.Handlers, _ middleware.StepUpAuthMiddleware) {
auditLogs := admin.Group("/audit-logs")
{
auditLogs.GET("", h.Admin.AuditLog.List)
auditLogs.GET("/:id", h.Admin.AuditLog.Get)
// 清空需现场 TOTP 校验(在 handler 内强制),不复用 step-up sudo 窗口
auditLogs.POST("/clear", h.Admin.AuditLog.Clear)
}
}
func registerAdminComplianceRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
compliance := admin.Group("/compliance")
{
compliance.GET("", h.Admin.Compliance.GetStatus)
compliance.POST("/accept", h.Admin.Compliance.Accept)
}
}
func registerContentModerationRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
risk := admin.Group("/risk-control")
{
risk.GET("/config", h.Admin.ContentModeration.GetConfig)
risk.PUT("/config", h.Admin.ContentModeration.UpdateConfig)
risk.POST("/api-keys/test", h.Admin.ContentModeration.TestAPIKeys)
risk.GET("/status", h.Admin.ContentModeration.GetStatus)
risk.GET("/logs", h.Admin.ContentModeration.ListLogs)
risk.POST("/users/:user_id/unban", h.Admin.ContentModeration.UnbanUser)
risk.DELETE("/hashes", h.Admin.ContentModeration.DeleteFlaggedHash)
risk.DELETE("/hashes/all", h.Admin.ContentModeration.ClearFlaggedHashes)
}
}
func registerAdminAPIKeyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
apiKeys := admin.Group("/api-keys")
{
apiKeys.PUT("/:id", h.Admin.APIKey.UpdateGroup)
}
}
func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
ops := admin.Group("/ops")
{
// Realtime ops signals
ops.GET("/concurrency", h.Admin.Ops.GetConcurrencyStats)
ops.GET("/user-concurrency", h.Admin.Ops.GetUserConcurrencyStats)
ops.GET("/account-availability", h.Admin.Ops.GetAccountAvailability)
ops.GET("/realtime-traffic", h.Admin.Ops.GetRealtimeTrafficSummary)
// Alerts (rules + events)
ops.GET("/alert-rules", h.Admin.Ops.ListAlertRules)
ops.POST("/alert-rules", h.Admin.Ops.CreateAlertRule)
ops.PUT("/alert-rules/:id", h.Admin.Ops.UpdateAlertRule)
ops.DELETE("/alert-rules/:id", h.Admin.Ops.DeleteAlertRule)
ops.GET("/alert-events", h.Admin.Ops.ListAlertEvents)
ops.GET("/alert-events/:id", h.Admin.Ops.GetAlertEvent)
ops.PUT("/alert-events/:id/status", h.Admin.Ops.UpdateAlertEventStatus)
ops.POST("/alert-silences", h.Admin.Ops.CreateAlertSilence)
// Email notification config (DB-backed)
ops.GET("/email-notification/config", h.Admin.Ops.GetEmailNotificationConfig)
ops.PUT("/email-notification/config", h.Admin.Ops.UpdateEmailNotificationConfig)
// Runtime settings (DB-backed)
runtime := ops.Group("/runtime")
{
runtime.GET("/alert", h.Admin.Ops.GetAlertRuntimeSettings)
runtime.PUT("/alert", h.Admin.Ops.UpdateAlertRuntimeSettings)
runtime.GET("/logging", h.Admin.Ops.GetRuntimeLogConfig)
runtime.PUT("/logging", h.Admin.Ops.UpdateRuntimeLogConfig)
runtime.POST("/logging/reset", h.Admin.Ops.ResetRuntimeLogConfig)
}
// Advanced settings (DB-backed)
ops.GET("/advanced-settings", h.Admin.Ops.GetAdvancedSettings)
ops.PUT("/advanced-settings", h.Admin.Ops.UpdateAdvancedSettings)
// Settings group (DB-backed)
settings := ops.Group("/settings")
{
settings.GET("/metric-thresholds", h.Admin.Ops.GetMetricThresholds)
settings.PUT("/metric-thresholds", h.Admin.Ops.UpdateMetricThresholds)
}
// WebSocket realtime (QPS/TPS)
ws := ops.Group("/ws")
{
ws.GET("/qps", h.Admin.Ops.QPSWSHandler)
}
// Error logs (legacy)
ops.GET("/errors", h.Admin.Ops.GetErrorLogs)
ops.GET("/errors/:id", h.Admin.Ops.GetErrorLogByID)
ops.PUT("/errors/:id/resolve", h.Admin.Ops.UpdateErrorResolution)
// Request errors (client-visible failures)
ops.GET("/request-errors", h.Admin.Ops.ListRequestErrors)
ops.GET("/request-errors/:id", h.Admin.Ops.GetRequestError)
ops.GET("/request-errors/:id/upstream-errors", h.Admin.Ops.ListRequestErrorUpstreamErrors)
ops.PUT("/request-errors/:id/resolve", h.Admin.Ops.ResolveRequestError)
// Bounded ingress-admission rejection aggregates.
ops.GET("/ingress-rejections", h.Admin.Ops.ListIngressRejects)
ops.GET("/ingress-rejections/health", h.Admin.Ops.GetIngressRejectHealth)
ops.GET("/auth-cache-invalidation/health", h.Admin.Ops.GetAuthCacheInvalidationHealth)
// Upstream errors (independent upstream failures)
ops.GET("/upstream-errors", h.Admin.Ops.ListUpstreamErrors)
ops.GET("/upstream-errors/:id", h.Admin.Ops.GetUpstreamError)
ops.PUT("/upstream-errors/:id/resolve", h.Admin.Ops.ResolveUpstreamError)
// Request drilldown (success + error)
ops.GET("/requests", h.Admin.Ops.ListRequestDetails)
// Indexed system logs
ops.GET("/system-logs", h.Admin.Ops.ListSystemLogs)
ops.POST("/system-logs/cleanup", h.Admin.Ops.CleanupSystemLogs)
ops.GET("/system-logs/health", h.Admin.Ops.GetSystemLogIngestionHealth)
// Dashboard (vNext - raw path for MVP)
ops.GET("/dashboard/snapshot-v2", h.Admin.Ops.GetDashboardSnapshotV2)
ops.GET("/dashboard/overview", h.Admin.Ops.GetDashboardOverview)
ops.GET("/dashboard/throughput-trend", h.Admin.Ops.GetDashboardThroughputTrend)
ops.GET("/dashboard/latency-histogram", h.Admin.Ops.GetDashboardLatencyHistogram)
ops.GET("/dashboard/error-trend", h.Admin.Ops.GetDashboardErrorTrend)
ops.GET("/dashboard/error-distribution", h.Admin.Ops.GetDashboardErrorDistribution)
ops.GET("/dashboard/openai-token-stats", h.Admin.Ops.GetDashboardOpenAITokenStats)
}
}
func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
dashboard := admin.Group("/dashboard")
{
dashboard.GET("/snapshot-v2", h.Admin.Dashboard.GetSnapshotV2)
dashboard.GET("/stats", h.Admin.Dashboard.GetStats)
dashboard.GET("/realtime", h.Admin.Dashboard.GetRealtimeMetrics)
dashboard.GET("/trend", h.Admin.Dashboard.GetUsageTrend)
dashboard.GET("/models", h.Admin.Dashboard.GetModelStats)
dashboard.GET("/groups", h.Admin.Dashboard.GetGroupStats)
dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetAPIKeyUsageTrend)
dashboard.GET("/users-trend", h.Admin.Dashboard.GetUserUsageTrend)
dashboard.GET("/users-ranking", h.Admin.Dashboard.GetUserSpendingRanking)
dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage)
dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchAPIKeysUsage)
dashboard.GET("/user-breakdown", h.Admin.Dashboard.GetUserBreakdown)
dashboard.POST("/aggregation/backfill", h.Admin.Dashboard.BackfillAggregation)
}
}
func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users := admin.Group("/users")
{
users.GET("", h.Admin.User.List)
users.GET("/:id", h.Admin.User.GetByID)
users.POST("/:id/auth-identities", h.Admin.User.BindAuthIdentity)
users.POST("", h.Admin.User.Create)
users.PUT("/:id", h.Admin.User.Update)
users.DELETE("/:id", h.Admin.User.Delete)
users.POST("/:id/balance", h.Admin.User.UpdateBalance)
users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys)
users.GET("/:id/usage", h.Admin.User.GetUserUsage)
users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory)
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus)
users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency)
users.POST("/batch-limits", h.Admin.User.BatchUpdateLimits)
users.GET("/:id/platform-quotas", h.Admin.User.GetUserPlatformQuotas)
users.PUT("/:id/platform-quotas", h.Admin.User.UpdateUserPlatformQuotas)
users.POST("/:id/platform-quotas/reset", h.Admin.User.ResetUserPlatformQuotaWindow)
// User attribute values
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)
users.PUT("/:id/attributes", h.Admin.UserAttribute.UpdateUserAttributes)
}
}
func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
groups := admin.Group("/groups")
{
groups.GET("", h.Admin.Group.List)
groups.GET("/all", h.Admin.Group.GetAll)
groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary)
groups.GET("/capacity-summary", h.Admin.Group.GetCapacitySummary)
groups.GET("/live-capability", h.Admin.Group.GetLiveCapability)
groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder)
groups.GET("/:id/models-list-candidates", h.Admin.Group.GetModelsListCandidates)
groups.GET("/:id/composite-routes", h.Admin.Group.ListCompositeRoutes)
groups.POST("/:id/composite-routes", h.Admin.Group.CreateCompositeRoute)
groups.POST("/:id/composite-routes/preview", h.Admin.Group.PreviewCompositeRoute)
groups.PUT("/:id/composite-routes/:route_id", h.Admin.Group.UpdateCompositeRoute)
groups.DELETE("/:id/composite-routes/:route_id", h.Admin.Group.DeleteCompositeRoute)
groups.GET("/:id", h.Admin.Group.GetByID)
groups.POST("", h.Admin.Group.Create)
groups.POST("/:id/duplicate", h.Admin.Group.Duplicate)
groups.PUT("/:id", h.Admin.Group.Update)
groups.DELETE("/:id", h.Admin.Group.Delete)
groups.GET("/:id/stats", h.Admin.Group.GetStats)
groups.GET("/:id/rate-multipliers", h.Admin.Group.GetGroupRateMultipliers)
groups.PUT("/:id/rate-multipliers", h.Admin.Group.BatchSetGroupRateMultipliers)
groups.DELETE("/:id/rate-multipliers", h.Admin.Group.ClearGroupRateMultipliers)
groups.PUT("/:id/rpm-overrides", h.Admin.Group.BatchSetGroupRPMOverrides)
groups.DELETE("/:id/rpm-overrides", h.Admin.Group.ClearGroupRPMOverrides)
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
}
}
func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) {
accounts := admin.Group("/accounts")
{
accounts.GET("", h.Admin.Account.List)
accounts.GET("/upstream-billing-probe/settings", h.Admin.Account.GetUpstreamBillingProbeSettings)
accounts.PUT("/upstream-billing-probe/settings", h.Admin.Account.UpdateUpstreamBillingProbeSettings)
accounts.POST("/upstream-billing-probe/batch", h.Admin.Account.ProbeUpstreamBillingBatch)
accounts.GET("/ollama-cloud-usage/settings", h.Admin.Account.GetOllamaCloudUsageSettings)
accounts.PUT("/ollama-cloud-usage/settings", h.Admin.Account.UpdateOllamaCloudUsageSettings)
accounts.GET("/:id", h.Admin.Account.GetByID)
accounts.POST("", h.Admin.Account.Create)
accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate)
accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel)
accounts.POST("/import/codex-session", h.Admin.Account.ImportCodexSession)
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
accounts.PUT("/:id", h.Admin.Account.Update)
accounts.PUT("/:id/upstream-billing-probe", h.Admin.Account.SetUpstreamBillingProbeEnabled)
accounts.POST("/:id/upstream-billing-probe", h.Admin.Account.ProbeUpstreamBilling)
accounts.GET("/:id/ollama-cloud-usage", h.Admin.Account.GetOllamaCloudUsage)
accounts.PUT("/:id/ollama-cloud-usage/session", h.Admin.Account.SaveOllamaCloudUsageSession)
accounts.DELETE("/:id/ollama-cloud-usage/session", h.Admin.Account.DeleteOllamaCloudUsageSession)
accounts.PUT("/:id/ollama-cloud-usage/auto-refresh", h.Admin.Account.SetOllamaCloudUsageAutoRefresh)
accounts.POST("/:id/ollama-cloud-usage/refresh", h.Admin.Account.RefreshOllamaCloudUsage)
accounts.DELETE("/:id", h.Admin.Account.Delete)
accounts.POST("/:id/test", h.Admin.Account.Test)
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
accounts.POST("/:id/apply-oauth-credentials", h.Admin.Account.ApplyOAuthCredentials)
accounts.POST("/:id/set-privacy", h.Admin.Account.SetPrivacy)
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
accounts.GET("/:id/stats", h.Admin.Account.GetStats)
accounts.POST("/:id/clear-error", h.Admin.Account.ClearError)
accounts.POST("/:id/revert-proxy-fallback", h.Admin.Account.RevertProxyFallback)
accounts.GET("/:id/usage", h.Admin.Account.GetUsage)
accounts.GET("/:id/today-stats", h.Admin.Account.GetTodayStats)
accounts.POST("/usage/batch", h.Admin.Account.GetBatchUsage)
accounts.POST("/today-stats/batch", h.Admin.Account.GetBatchTodayStats)
accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit)
accounts.POST("/:id/reset-quota", h.Admin.Account.ResetQuota)
accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable)
accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable)
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
accounts.POST("/models/sync-upstream-preview", h.Admin.Account.SyncUpstreamModelsPreview)
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
accounts.POST("/:id/models/sync-upstream", h.Admin.Account.SyncUpstreamModels)
accounts.POST("/batch", h.Admin.Account.BatchCreate)
// 账号导出泄露上游凭证原文——要求 step-up 2FA
accounts.GET("/data", gin.HandlerFunc(stepUpAuth), h.Admin.Account.ExportData)
accounts.POST("/data", h.Admin.Account.ImportData)
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
accounts.POST("/batch-delete", h.Admin.Account.BatchDelete)
accounts.POST("/batch-clear-error", h.Admin.Account.BatchClearError)
accounts.POST("/batch-refresh", h.Admin.Account.BatchRefresh)
// Antigravity 默认模型映射
accounts.GET("/antigravity/default-model-mapping", h.Admin.Account.GetAntigravityDefaultModelMapping)
// Spark 影子账号
accounts.POST("/:id/shadow", h.Admin.OpenAIOAuth.CreateShadow)
// Claude OAuth routes
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
accounts.POST("/generate-setup-token-url", h.Admin.OAuth.GenerateSetupTokenURL)
accounts.POST("/exchange-code", h.Admin.OAuth.ExchangeCode)
accounts.POST("/exchange-setup-token-code", h.Admin.OAuth.ExchangeSetupTokenCode)
accounts.POST("/cookie-auth", h.Admin.OAuth.CookieAuth)
accounts.POST("/setup-token-cookie-auth", h.Admin.OAuth.SetupTokenCookieAuth)
}
}
func registerAnnouncementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
announcements := admin.Group("/announcements")
{
announcements.GET("", h.Admin.Announcement.List)
announcements.POST("", h.Admin.Announcement.Create)
announcements.GET("/:id", h.Admin.Announcement.GetByID)
announcements.PUT("/:id", h.Admin.Announcement.Update)
announcements.DELETE("/:id", h.Admin.Announcement.Delete)
announcements.GET("/:id/read-status", h.Admin.Announcement.ListReadStatus)
}
}
func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
openai := admin.Group("/openai")
{
openai.POST("/generate-auth-url", h.Admin.OpenAIOAuth.GenerateAuthURL)
openai.POST("/exchange-code", h.Admin.OpenAIOAuth.ExchangeCode)
openai.POST("/refresh-token", h.Admin.OpenAIOAuth.RefreshToken)
openai.POST("/accounts/:id/refresh", h.Admin.OpenAIOAuth.RefreshAccountToken)
openai.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth)
openai.POST("/create-from-codex-pat", h.Admin.OpenAIOAuth.CreateAccountFromCodexPAT)
openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota)
openai.POST("/accounts/:id/quota/refresh", h.Admin.OpenAIOAuth.RefreshQuota)
openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota)
}
}
func registerGeminiOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
gemini := admin.Group("/gemini")
{
gemini.POST("/oauth/auth-url", h.Admin.GeminiOAuth.GenerateAuthURL)
gemini.POST("/oauth/exchange-code", h.Admin.GeminiOAuth.ExchangeCode)
gemini.GET("/oauth/capabilities", h.Admin.GeminiOAuth.GetCapabilities)
}
}
func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
antigravity := admin.Group("/antigravity")
{
antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL)
antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode)
antigravity.POST("/oauth/refresh-token", h.Admin.AntigravityOAuth.RefreshToken)
}
}
func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
grok := admin.Group("/grok")
{
grok.GET("/oauth/capabilities", h.Admin.GrokOAuth.GetCapabilities)
grok.POST("/oauth/auth-url", h.Admin.GrokOAuth.GenerateAuthURL)
grok.POST("/oauth/exchange-code", h.Admin.GrokOAuth.ExchangeCode)
grok.POST("/oauth/refresh-token", h.Admin.GrokOAuth.RefreshToken)
grok.POST("/oauth/sso-token", h.Admin.GrokOAuth.ValidateSSOToken)
grok.POST("/oauth/password", h.Admin.GrokOAuth.AuthorizePassword)
grok.POST("/oauth/create-from-oauth", h.Admin.GrokOAuth.CreateAccountFromOAuth)
grok.POST("/sso-to-oauth", h.Admin.GrokOAuth.CreateAccountsFromSSO)
grok.POST("/oauth/reconcile", h.Admin.GrokOAuth.ReconcileOAuthAccounts)
grok.POST("/accounts/:id/refresh", h.Admin.GrokOAuth.RefreshAccountToken)
grok.GET("/accounts/:id/quota", h.Admin.GrokOAuth.QueryQuota)
grok.POST("/accounts/:id/reset-quota", h.Admin.GrokOAuth.ResetQuota)
grok.GET("/runtime-sanity", h.Admin.GrokOAuth.RuntimeSanity)
}
}
// registerCNProviderRoutes 注册国产供应商(kimi/zhipu/deepseek)的额度与余额查询端点。
func registerCNProviderRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
cn := admin.Group("/cn-providers")
{
// Coding Plan 滚动窗口用量(kimi/zhipu coding 账号)。
cn.GET("/accounts/:id/quota", h.Admin.CNProvider.QueryQuota)
// payg 账号余额(kimi/deepseekzhipu 无余额端点)。
cn.GET("/accounts/:id/balance", h.Admin.CNProvider.QueryBalance)
}
}
func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) {
proxies := admin.Group("/proxies")
{
proxies.GET("", h.Admin.Proxy.List)
proxies.GET("/all", h.Admin.Proxy.GetAll)
// 代理导出泄露账号密码原文——要求 step-up 2FA
proxies.GET("/data", gin.HandlerFunc(stepUpAuth), h.Admin.Proxy.ExportData)
proxies.POST("/data", h.Admin.Proxy.ImportData)
proxies.GET("/:id", h.Admin.Proxy.GetByID)
proxies.POST("", h.Admin.Proxy.Create)
proxies.PUT("/:id", h.Admin.Proxy.Update)
proxies.DELETE("/:id", h.Admin.Proxy.Delete)
proxies.POST("/:id/test", h.Admin.Proxy.Test)
proxies.POST("/:id/quality-check", h.Admin.Proxy.CheckQuality)
proxies.GET("/:id/stats", h.Admin.Proxy.GetStats)
proxies.GET("/:id/accounts", h.Admin.Proxy.GetProxyAccounts)
proxies.POST("/batch-delete", h.Admin.Proxy.BatchDelete)
proxies.POST("/batch", h.Admin.Proxy.BatchCreate)
}
}
func registerRedeemCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
codes := admin.Group("/redeem-codes")
{
codes.GET("", h.Admin.Redeem.List)
codes.GET("/stats", h.Admin.Redeem.GetStats)
codes.GET("/export", h.Admin.Redeem.Export)
codes.GET("/:id", h.Admin.Redeem.GetByID)
codes.POST("/create-and-redeem", h.Admin.Redeem.CreateAndRedeem)
codes.POST("/generate", h.Admin.Redeem.Generate)
codes.DELETE("/:id", h.Admin.Redeem.Delete)
codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete)
codes.POST("/batch-update", h.Admin.Redeem.BatchUpdate)
codes.POST("/:id/expire", h.Admin.Redeem.Expire)
}
}
func registerPromoCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
promoCodes := admin.Group("/promo-codes")
{
promoCodes.GET("", h.Admin.Promo.List)
promoCodes.GET("/:id", h.Admin.Promo.GetByID)
promoCodes.POST("", h.Admin.Promo.Create)
promoCodes.PUT("/:id", h.Admin.Promo.Update)
promoCodes.DELETE("/:id", h.Admin.Promo.Delete)
promoCodes.GET("/:id/usages", h.Admin.Promo.GetUsages)
}
}
func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
adminSettings := admin.Group("/settings")
{
adminSettings.GET("", h.Admin.Setting.GetSettings)
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection)
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
adminSettings.GET("/email-templates", h.Admin.Setting.ListEmailTemplates)
adminSettings.POST("/email-template-preview", h.Admin.Setting.PreviewEmailTemplate)
adminSettings.GET("/email-templates/:event/:locale", h.Admin.Setting.GetEmailTemplate)
adminSettings.PUT("/email-templates/:event/:locale", h.Admin.Setting.UpdateEmailTemplate)
adminSettings.POST("/email-templates/:event/:locale/restore-official", h.Admin.Setting.RestoreOfficialEmailTemplate)
// Admin API Key 管理
adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey)
adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey)
adminSettings.DELETE("/admin-api-key", h.Admin.Setting.DeleteAdminAPIKey)
// 529过载冷却配置
adminSettings.GET("/overload-cooldown", h.Admin.Setting.GetOverloadCooldownSettings)
adminSettings.PUT("/overload-cooldown", h.Admin.Setting.UpdateOverloadCooldownSettings)
// 429默认回避配置
adminSettings.GET("/rate-limit-429-cooldown", h.Admin.Setting.GetRateLimit429CooldownSettings)
adminSettings.PUT("/rate-limit-429-cooldown", h.Admin.Setting.UpdateRateLimit429CooldownSettings)
// 面板 API 限流配置
adminSettings.GET("/panel-rate-limit", h.Admin.Setting.GetPanelRateLimitSettings)
adminSettings.PUT("/panel-rate-limit", h.Admin.Setting.UpdatePanelRateLimitSettings)
// 流超时处理配置
adminSettings.GET("/stream-timeout", h.Admin.Setting.GetStreamTimeoutSettings)
adminSettings.PUT("/stream-timeout", h.Admin.Setting.UpdateStreamTimeoutSettings)
// 请求整流器配置
adminSettings.GET("/rectifier", h.Admin.Setting.GetRectifierSettings)
adminSettings.PUT("/rectifier", h.Admin.Setting.UpdateRectifierSettings)
// Beta 策略配置
adminSettings.GET("/beta-policy", h.Admin.Setting.GetBetaPolicySettings)
adminSettings.PUT("/beta-policy", h.Admin.Setting.UpdateBetaPolicySettings)
// Web Search 模拟配置
adminSettings.GET("/web-search-emulation", h.Admin.Setting.GetWebSearchEmulationConfig)
adminSettings.PUT("/web-search-emulation", h.Admin.Setting.UpdateWebSearchEmulationConfig)
adminSettings.POST("/web-search-emulation/test", h.Admin.Setting.TestWebSearchEmulation)
adminSettings.POST("/web-search-emulation/reset-usage", h.Admin.Setting.ResetWebSearchUsage)
}
}
func registerDataManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) {
dataManagement := admin.Group("/data-management")
{
dataManagement.GET("/agent/health", h.Admin.DataManagement.GetAgentHealth)
dataManagement.GET("/config", h.Admin.DataManagement.GetConfig)
dataManagement.PUT("/config", h.Admin.DataManagement.UpdateConfig)
dataManagement.GET("/sources/:source_type/profiles", h.Admin.DataManagement.ListSourceProfiles)
dataManagement.POST("/sources/:source_type/profiles", h.Admin.DataManagement.CreateSourceProfile)
dataManagement.PUT("/sources/:source_type/profiles/:profile_id", h.Admin.DataManagement.UpdateSourceProfile)
dataManagement.DELETE("/sources/:source_type/profiles/:profile_id", h.Admin.DataManagement.DeleteSourceProfile)
dataManagement.POST("/sources/:source_type/profiles/:profile_id/activate", h.Admin.DataManagement.SetActiveSourceProfile)
dataManagement.POST("/s3/test", h.Admin.DataManagement.TestS3)
dataManagement.GET("/s3/profiles", h.Admin.DataManagement.ListS3Profiles)
// 修改 S3 目标可将数据备份外泄——要求 step-up 2FA
dataManagement.POST("/s3/profiles", gin.HandlerFunc(stepUpAuth), h.Admin.DataManagement.CreateS3Profile)
dataManagement.PUT("/s3/profiles/:profile_id", gin.HandlerFunc(stepUpAuth), h.Admin.DataManagement.UpdateS3Profile)
dataManagement.DELETE("/s3/profiles/:profile_id", h.Admin.DataManagement.DeleteS3Profile)
dataManagement.POST("/s3/profiles/:profile_id/activate", gin.HandlerFunc(stepUpAuth), h.Admin.DataManagement.SetActiveS3Profile)
dataManagement.POST("/backups", gin.HandlerFunc(stepUpAuth), h.Admin.DataManagement.CreateBackupJob)
dataManagement.GET("/backups", h.Admin.DataManagement.ListBackupJobs)
dataManagement.GET("/backups/:job_id", h.Admin.DataManagement.GetBackupJob)
}
}
func registerBackupRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) {
backup := admin.Group("/backups")
{
// S3 存储配置
backup.GET("/s3-config", h.Admin.Backup.GetS3Config)
// 修改 S3 目标可将数据库备份外泄——要求 step-up 2FA
backup.PUT("/s3-config", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.UpdateS3Config)
backup.POST("/s3-config/test", h.Admin.Backup.TestS3Connection)
// 异步生图对象存储配置(与备份共用 S3 客户端,可直接复用备份凭证)
backup.GET("/image-storage", h.Admin.Backup.GetImageStorageConfig)
// 同 S3 配置:改写对象存储目标可将生成内容导向外部账号——要求 step-up 2FA
backup.PUT("/image-storage", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.UpdateImageStorageConfig)
backup.POST("/image-storage/test", h.Admin.Backup.TestImageStorageConnection)
// 定时备份配置
backup.GET("/schedule", h.Admin.Backup.GetSchedule)
backup.PUT("/schedule", h.Admin.Backup.UpdateSchedule)
// 备份操作
backup.POST("", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.CreateBackup)
backup.GET("", h.Admin.Backup.ListBackups)
backup.GET("/:id", h.Admin.Backup.GetBackup)
backup.DELETE("/:id", h.Admin.Backup.DeleteBackup)
// 备份下载链接可直接取走整库数据——要求 step-up 2FA
backup.GET("/:id/download-url", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.GetDownloadURL)
// 恢复操作:整库覆盖可回滚安全设置(含 step-up 开关本身)——要求 step-up 2FA
backup.POST("/:id/restore", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.RestoreBackup)
}
}
func registerSystemRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
system := admin.Group("/system")
{
system.GET("/version", h.Admin.System.GetVersion)
system.GET("/check-updates", h.Admin.System.CheckUpdates)
system.GET("/rollback-versions", h.Admin.System.GetRollbackVersions)
system.POST("/update", h.Admin.System.PerformUpdate)
system.POST("/rollback", h.Admin.System.Rollback)
system.POST("/restart", h.Admin.System.RestartService)
}
}
func registerSubscriptionRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
subscriptions := admin.Group("/subscriptions")
{
subscriptions.GET("", h.Admin.Subscription.List)
subscriptions.GET("/:id", h.Admin.Subscription.GetByID)
subscriptions.GET("/:id/progress", h.Admin.Subscription.GetProgress)
subscriptions.POST("/assign", h.Admin.Subscription.Assign)
subscriptions.POST("/bulk-assign", h.Admin.Subscription.BulkAssign)
subscriptions.POST("/:id/extend", h.Admin.Subscription.Extend)
subscriptions.POST("/:id/reset-quota", h.Admin.Subscription.ResetQuota)
subscriptions.POST("/:id/revoke", h.Admin.Subscription.Revoke)
subscriptions.POST("/:id/restore", h.Admin.Subscription.Restore)
subscriptions.DELETE("/:id", h.Admin.Subscription.Revoke)
}
// 分组下的订阅列表
admin.GET("/groups/:id/subscriptions", h.Admin.Subscription.ListByGroup)
// 用户下的订阅列表
admin.GET("/users/:id/subscriptions", h.Admin.Subscription.ListByUser)
}
func registerUsageRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
usage := admin.Group("/usage")
{
usage.GET("", h.Admin.Usage.List)
usage.GET("/stats", h.Admin.Usage.Stats)
usage.GET("/search-users", h.Admin.Usage.SearchUsers)
usage.GET("/search-api-keys", h.Admin.Usage.SearchAPIKeys)
usage.GET("/cleanup-tasks", h.Admin.Usage.ListCleanupTasks)
usage.POST("/cleanup-tasks", h.Admin.Usage.CreateCleanupTask)
usage.POST("/cleanup-tasks/:id/cancel", h.Admin.Usage.CancelCleanupTask)
}
}
func registerUserAttributeRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
attrs := admin.Group("/user-attributes")
{
attrs.GET("", h.Admin.UserAttribute.ListDefinitions)
attrs.POST("", h.Admin.UserAttribute.CreateDefinition)
attrs.POST("/batch", h.Admin.UserAttribute.GetBatchUserAttributes)
attrs.PUT("/reorder", h.Admin.UserAttribute.ReorderDefinitions)
attrs.PUT("/:id", h.Admin.UserAttribute.UpdateDefinition)
attrs.DELETE("/:id", h.Admin.UserAttribute.DeleteDefinition)
}
}
func registerScheduledTestRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
plans := admin.Group("/scheduled-test-plans")
{
plans.POST("", h.Admin.ScheduledTest.Create)
plans.PUT("/:id", h.Admin.ScheduledTest.Update)
plans.DELETE("/:id", h.Admin.ScheduledTest.Delete)
plans.GET("/:id/results", h.Admin.ScheduledTest.ListResults)
}
// Nested under accounts
admin.GET("/accounts/:id/scheduled-test-plans", h.Admin.ScheduledTest.ListByAccount)
}
func registerErrorPassthroughRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
rules := admin.Group("/error-passthrough-rules")
{
rules.GET("", h.Admin.ErrorPassthrough.List)
rules.GET("/:id", h.Admin.ErrorPassthrough.GetByID)
rules.POST("", h.Admin.ErrorPassthrough.Create)
rules.PUT("/:id", h.Admin.ErrorPassthrough.Update)
rules.DELETE("/:id", h.Admin.ErrorPassthrough.Delete)
}
}
func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
profiles := admin.Group("/tls-fingerprint-profiles")
{
profiles.GET("", h.Admin.TLSFingerprintProfile.List)
profiles.GET("/:id", h.Admin.TLSFingerprintProfile.GetByID)
profiles.POST("", h.Admin.TLSFingerprintProfile.Create)
profiles.PUT("/:id", h.Admin.TLSFingerprintProfile.Update)
profiles.DELETE("/:id", h.Admin.TLSFingerprintProfile.Delete)
}
}
func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
channels := admin.Group("/channels")
{
channels.GET("", h.Admin.Channel.List)
channels.GET("/model-pricing", h.Admin.Channel.GetModelDefaultPricing)
channels.GET("/pricing/sync-models", h.Admin.Channel.SyncPricingModels)
channels.GET("/:id", h.Admin.Channel.GetByID)
channels.POST("", h.Admin.Channel.Create)
channels.PUT("/:id", h.Admin.Channel.Update)
channels.DELETE("/:id", h.Admin.Channel.Delete)
}
}
func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers, settingService *service.SettingService) {
guard := channelMonitorAdminFeatureGuard(settingService)
monitors := admin.Group("/channel-monitors")
monitors.Use(guard)
{
monitors.GET("", h.Admin.ChannelMonitor.List)
monitors.POST("", h.Admin.ChannelMonitor.Create)
monitors.GET("/:id", h.Admin.ChannelMonitor.Get)
monitors.POST("/:id/duplicate", h.Admin.ChannelMonitor.Duplicate)
monitors.PUT("/:id", h.Admin.ChannelMonitor.Update)
monitors.DELETE("/:id", h.Admin.ChannelMonitor.Delete)
monitors.POST("/:id/run", h.Admin.ChannelMonitor.Run)
monitors.GET("/:id/history", h.Admin.ChannelMonitor.History)
}
templates := admin.Group("/channel-monitor-templates")
templates.Use(guard)
{
templates.GET("", h.Admin.ChannelMonitorTemplate.List)
templates.POST("", h.Admin.ChannelMonitorTemplate.Create)
templates.GET("/:id", h.Admin.ChannelMonitorTemplate.Get)
templates.PUT("/:id", h.Admin.ChannelMonitorTemplate.Update)
templates.DELETE("/:id", h.Admin.ChannelMonitorTemplate.Delete)
templates.GET("/:id/monitors", h.Admin.ChannelMonitorTemplate.AssociatedMonitors)
templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply)
}
}
// registerAffiliateRoutes 注册邀请返利的管理端路由(专属用户配置)
func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
affiliates := admin.Group("/affiliates")
{
affiliates.GET("/invites", h.Admin.Affiliate.ListInviteRecords)
affiliates.GET("/rebates", h.Admin.Affiliate.ListRebateRecords)
affiliates.GET("/transfers", h.Admin.Affiliate.ListTransferRecords)
users := affiliates.Group("/users")
{
users.GET("", h.Admin.Affiliate.ListUsers)
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview)
users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
}
}
}
func registerChannelMonitorV2Routes(admin *gin.RouterGroup, h *handler.Handlers, settingService *service.SettingService) {
// Config GET/PUT: feature enabled only (operators can prepare V2 before flipping mode).
// Read/matrix endpoints: require mode=v2 so V1 deployments do not serve passive data.
featureGuard := channelMonitorAdminFeatureGuard(settingService)
modeV2Guard := channelMonitorModeV2Guard(settingService)
monitor := admin.Group("/channel-monitor-v2")
{
config := monitor.Group("")
config.Use(featureGuard)
{
config.GET("/config", h.ChannelMonitorV2.GetConfig)
config.PUT("/config", h.ChannelMonitorV2.UpdateConfig)
}
reads := monitor.Group("")
reads.Use(modeV2Guard)
{
reads.GET("/dimensions", h.ChannelMonitorV2.Dimensions)
reads.GET("/snapshot", h.ChannelMonitorV2.AdminSnapshot)
reads.GET("/models", h.ChannelMonitorV2.AdminModels)
reads.GET("/matrix", h.ChannelMonitorV2.AdminMatrix)
reads.GET("/errors", h.ChannelMonitorV2.Errors)
reads.GET("/users", h.ChannelMonitorV2.AdminUsers)
}
}
}
func channelMonitorAdminFeatureGuard(settingService *service.SettingService) gin.HandlerFunc {
return func(c *gin.Context) {
if settingService != nil && settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled {
c.Next()
return
}
response.ErrorFrom(c, service.ErrChannelMonitorDisabled)
c.Abort()
}
}
// channelMonitorModeV2Guard requires feature enabled and channel_monitor_mode=v2.
func channelMonitorModeV2Guard(settingService *service.SettingService) gin.HandlerFunc {
return func(c *gin.Context) {
if settingService == nil {
response.ErrorFrom(c, service.ErrChannelMonitorDisabled)
c.Abort()
return
}
rt := settingService.GetChannelMonitorRuntime(c.Request.Context())
if !rt.Enabled {
response.ErrorFrom(c, service.ErrChannelMonitorDisabled)
c.Abort()
return
}
if !rt.PassiveAggregationAllowed() {
response.ErrorFrom(c, service.ErrChannelMonitorModeMismatch)
c.Abort()
return
}
c.Next()
}
}
+262
View File
@@ -0,0 +1,262 @@
package routes
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/middleware"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// RegisterAuthRoutes 注册认证相关路由
func RegisterAuthRoutes(
v1 *gin.RouterGroup,
h *handler.Handlers,
jwtAuth servermiddleware.JWTAuthMiddleware,
auditLog servermiddleware.AuditLogMiddleware,
redisClient *redis.Client,
settingService *service.SettingService,
panelRateLimiter *servermiddleware.PanelRateLimiter,
) {
// 创建速率限制器
rateLimiter := middleware.NewRateLimiter(redisClient)
// 公开接口
auth := v1.Group("/auth")
auth.Use(servermiddleware.BackendModeAuthGuard(settingService))
// 认证事件(登录/注册/2FA/token 刷新失败)入审计
auth.Use(gin.HandlerFunc(auditLog))
{
// 注册/登录/2FA/验证码发送均属于高风险入口,增加服务端兜底限流(Redis 故障时 fail-close
auth.POST("/register", rateLimiter.LimitWithOptions("auth-register", 5, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.Register)
auth.POST("/login", rateLimiter.LimitWithOptions("auth-login", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.Login)
auth.POST("/login/2fa", rateLimiter.LimitWithOptions("auth-login-2fa", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.Login2FA)
auth.POST("/passkey/login/begin", rateLimiter.LimitWithOptions("passkey-login-begin", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Passkey.BeginLogin)
auth.POST("/passkey/login/finish", rateLimiter.LimitWithOptions("passkey-login-finish", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Passkey.FinishLogin)
auth.POST("/send-verify-code", rateLimiter.LimitWithOptions("auth-send-verify-code", 5, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.SendVerifyCode)
// Token刷新接口添加速率限制:每分钟最多 30 次(Redis 故障时 fail-close
auth.POST("/refresh", rateLimiter.LimitWithOptions("refresh-token", 30, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.RefreshToken)
// 登出接口(公开,允许未认证用户调用以撤销Refresh Token
auth.POST("/logout", h.Auth.Logout)
// 优惠码验证接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close
auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.ValidatePromoCode)
// 邀请码验证接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close
auth.POST("/validate-invitation-code", rateLimiter.LimitWithOptions("validate-invitation", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.ValidateInvitationCode)
// 忘记密码接口添加速率限制:每分钟最多 5 次(Redis 故障时 fail-close
auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.ForgotPassword)
// 重置密码接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close
auth.POST("/reset-password", rateLimiter.LimitWithOptions("reset-password", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.ResetPassword)
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
auth.POST("/oauth/linuxdo/start", rateLimiter.LimitWithOptions("oauth-linuxdo-start", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.LinuxDoOAuthStart)
auth.GET("/oauth/github/start", h.Auth.GitHubOAuthStart)
auth.POST("/oauth/github/start", rateLimiter.LimitWithOptions("oauth-github-start", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.GitHubOAuthStart)
auth.GET("/oauth/github/callback", h.Auth.GitHubOAuthCallback)
auth.POST("/oauth/github/complete-registration",
rateLimiter.LimitWithOptions("oauth-github-complete", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CompleteGitHubOAuthRegistration,
)
auth.GET("/oauth/google/start", h.Auth.GoogleOAuthStart)
auth.POST("/oauth/google/start", rateLimiter.LimitWithOptions("oauth-google-start", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.GoogleOAuthStart)
auth.GET("/oauth/google/callback", h.Auth.GoogleOAuthCallback)
auth.POST("/oauth/google/complete-registration",
rateLimiter.LimitWithOptions("oauth-google-complete", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CompleteGoogleOAuthRegistration,
)
auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) {
query := c.Request.URL.Query()
query.Set("intent", "bind_current_user")
c.Request.URL.RawQuery = query.Encode()
h.Auth.LinuxDoOAuthStart(c)
})
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
auth.POST("/oauth/wechat/start", rateLimiter.LimitWithOptions("oauth-wechat-start", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.WeChatOAuthStart)
auth.GET("/oauth/wechat/bind/start", func(c *gin.Context) {
query := c.Request.URL.Query()
query.Set("intent", "bind_current_user")
c.Request.URL.RawQuery = query.Encode()
h.Auth.WeChatOAuthStart(c)
})
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
auth.GET("/oauth/wechat/payment/start", h.Auth.WeChatPaymentOAuthStart)
auth.GET("/oauth/wechat/payment/callback", h.Auth.WeChatPaymentOAuthCallback)
auth.POST("/oauth/pending/exchange",
rateLimiter.LimitWithOptions("oauth-pending-exchange", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.ExchangePendingOAuthCompletion,
)
auth.POST("/oauth/pending/send-verify-code",
rateLimiter.LimitWithOptions("oauth-pending-send-verify-code", 5, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.SendPendingOAuthVerifyCode,
)
auth.POST("/oauth/pending/create-account",
rateLimiter.LimitWithOptions("oauth-pending-create-account", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CreatePendingOAuthAccount,
)
auth.POST("/oauth/pending/bind-login",
rateLimiter.LimitWithOptions("oauth-pending-bind-login", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.BindPendingOAuthLogin,
)
auth.POST("/oauth/linuxdo/complete-registration",
rateLimiter.LimitWithOptions("oauth-linuxdo-complete", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CompleteLinuxDoOAuthRegistration,
)
auth.POST("/oauth/linuxdo/bind-login",
rateLimiter.LimitWithOptions("oauth-linuxdo-bind-login", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.BindLinuxDoOAuthLogin,
)
auth.POST("/oauth/linuxdo/create-account",
rateLimiter.LimitWithOptions("oauth-linuxdo-create-account", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CreateLinuxDoOAuthAccount,
)
auth.POST("/oauth/wechat/complete-registration",
rateLimiter.LimitWithOptions("oauth-wechat-complete", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CompleteWeChatOAuthRegistration,
)
auth.POST("/oauth/wechat/bind-login",
rateLimiter.LimitWithOptions("oauth-wechat-bind-login", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.BindWeChatOAuthLogin,
)
auth.POST("/oauth/wechat/create-account",
rateLimiter.LimitWithOptions("oauth-wechat-create-account", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CreateWeChatOAuthAccount,
)
auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart)
auth.POST("/oauth/oidc/start", rateLimiter.LimitWithOptions("oauth-oidc-start", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.OIDCOAuthStart)
auth.GET("/oauth/oidc/bind/start", func(c *gin.Context) {
query := c.Request.URL.Query()
query.Set("intent", "bind_current_user")
c.Request.URL.RawQuery = query.Encode()
h.Auth.OIDCOAuthStart(c)
})
auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback)
auth.POST("/oauth/oidc/complete-registration",
rateLimiter.LimitWithOptions("oauth-oidc-complete", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CompleteOIDCOAuthRegistration,
)
auth.POST("/oauth/oidc/bind-login",
rateLimiter.LimitWithOptions("oauth-oidc-bind-login", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.BindOIDCOAuthLogin,
)
auth.POST("/oauth/oidc/create-account",
rateLimiter.LimitWithOptions("oauth-oidc-create-account", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CreateOIDCOAuthAccount,
)
auth.GET("/oauth/dingtalk/start", h.Auth.DingTalkOAuthStart)
auth.POST("/oauth/dingtalk/start", rateLimiter.LimitWithOptions("oauth-dingtalk-start", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.DingTalkOAuthStart)
auth.GET("/oauth/dingtalk/bind/start", func(c *gin.Context) {
query := c.Request.URL.Query()
query.Set("intent", "bind_current_user")
c.Request.URL.RawQuery = query.Encode()
h.Auth.DingTalkOAuthStart(c)
})
auth.GET("/oauth/dingtalk/callback", h.Auth.DingTalkOAuthCallback)
auth.POST("/oauth/dingtalk/complete-registration",
rateLimiter.LimitWithOptions("oauth-dingtalk-complete", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CompleteDingTalkOAuthRegistration,
)
auth.POST("/oauth/dingtalk/bind-login",
rateLimiter.LimitWithOptions("oauth-dingtalk-bind-login", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.BindDingTalkOAuthLogin,
)
auth.POST("/oauth/dingtalk/create-account",
rateLimiter.LimitWithOptions("oauth-dingtalk-create-account", 10, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}),
h.Auth.CreateDingTalkOAuthAccount,
)
}
// 公开设置(无需认证):每次请求都会查询 DB,按客户端 IP 兜底限流,
// 防止匿名高频刷接口打爆数据库(反代内部地址会被自动跳过,不会误伤)。
settings := v1.Group("/settings")
settings.Use(panelRateLimiter.PublicIP())
{
settings.GET("/public", h.Setting.GetPublicSettings)
settings.GET("/email-unsubscribe", h.Setting.UnsubscribeNotificationEmail)
}
// 需要认证的当前用户信息
authenticated := v1.Group("")
authenticated.Use(gin.HandlerFunc(jwtAuth))
authenticated.Use(servermiddleware.BackendModeUserGuard(settingService))
// 面板全局按用户限流
authenticated.Use(panelRateLimiter.Global())
{
authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
// 撤销所有会话(需要认证)
authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions)
authenticated.POST("/auth/oauth/bind-token", h.Auth.PrepareOAuthBindAccessTokenCookie)
}
}
@@ -0,0 +1,111 @@
//go:build integration
package routes
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
)
const authRouteRedisImageTag = "redis:8.4-alpine"
func TestAuthRegisterRateLimitThresholdHitReturns429(t *testing.T) {
ctx := context.Background()
rdb := startAuthRouteRedis(t, ctx)
router := newAuthRoutesTestRouter(rdb)
const path = "/api/v1/auth/register"
for i := 1; i <= 6; i++ {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "198.51.100.10:23456"
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if i <= 5 {
require.Equal(t, http.StatusBadRequest, w.Code, "第 %d 次请求应先进入业务校验", i)
continue
}
require.Equal(t, http.StatusTooManyRequests, w.Code, "第 6 次请求应命中限流")
require.Contains(t, w.Body.String(), "rate limit exceeded")
}
}
func startAuthRouteRedis(t *testing.T, ctx context.Context) *redis.Client {
t.Helper()
ensureAuthRouteDockerAvailable(t)
redisContainer, err := tcredis.Run(ctx, authRouteRedisImageTag)
require.NoError(t, err)
t.Cleanup(func() {
_ = redisContainer.Terminate(ctx)
})
redisHost, err := redisContainer.Host(ctx)
require.NoError(t, err)
redisPort, err := redisContainer.MappedPort(ctx, "6379/tcp")
require.NoError(t, err)
rdb := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", redisHost, redisPort.Int()),
DB: 0,
})
require.NoError(t, rdb.Ping(ctx).Err())
t.Cleanup(func() {
_ = rdb.Close()
})
return rdb
}
func ensureAuthRouteDockerAvailable(t *testing.T) {
t.Helper()
if authRouteDockerAvailable() {
return
}
t.Skip("Docker 未启用,跳过认证限流集成测试")
}
func authRouteDockerAvailable() bool {
if os.Getenv("DOCKER_HOST") != "" {
return true
}
socketCandidates := []string{
"/var/run/docker.sock",
filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), "docker.sock"),
filepath.Join(authRouteUserHomeDir(), ".docker", "run", "docker.sock"),
filepath.Join(authRouteUserHomeDir(), ".docker", "desktop", "docker.sock"),
filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "docker.sock"),
}
for _, socket := range socketCandidates {
if socket == "" {
continue
}
if _, err := os.Stat(socket); err == nil {
return true
}
}
return false
}
func authRouteUserHomeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return home
}
@@ -0,0 +1,73 @@
package routes
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func newAuthRoutesTestRouter(redisClient *redis.Client) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
v1 := router.Group("/api/v1")
RegisterAuthRoutes(
v1,
&handler.Handlers{
Auth: &handler.AuthHandler{},
Setting: &handler.SettingHandler{},
},
servermiddleware.JWTAuthMiddleware(func(c *gin.Context) {
c.Next()
}),
servermiddleware.AuditLogMiddleware(func(c *gin.Context) {
c.Next()
}),
redisClient,
nil,
nil,
)
return router
}
func TestAuthRoutesRateLimitFailCloseWhenRedisUnavailable(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:1",
DialTimeout: 50 * time.Millisecond,
ReadTimeout: 50 * time.Millisecond,
WriteTimeout: 50 * time.Millisecond,
})
t.Cleanup(func() {
_ = rdb.Close()
})
router := newAuthRoutesTestRouter(rdb)
paths := []string{
"/api/v1/auth/register",
"/api/v1/auth/login",
"/api/v1/auth/login/2fa",
"/api/v1/auth/send-verify-code",
"/api/v1/auth/oauth/pending/send-verify-code",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "203.0.113.10:12345"
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusTooManyRequests, w.Code, "path=%s", path)
require.Contains(t, w.Body.String(), "rate limit exceeded", "path=%s", path)
}
}
@@ -0,0 +1,173 @@
package routes
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// channelMonitorRouteSettingRepoStub is a minimal SettingRepository for route guards.
type channelMonitorRouteSettingRepoStub struct {
values map[string]string
}
func (s *channelMonitorRouteSettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
panic("unexpected Get call")
}
func (s *channelMonitorRouteSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
return s.values[key], nil
}
func (s *channelMonitorRouteSettingRepoStub) Set(context.Context, string, string) error {
panic("unexpected Set call")
}
func (s *channelMonitorRouteSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := s.values[key]; ok {
out[key] = value
}
}
return out, nil
}
func (s *channelMonitorRouteSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
panic("unexpected SetMultiple call")
}
func (s *channelMonitorRouteSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
panic("unexpected GetAll call")
}
func (s *channelMonitorRouteSettingRepoStub) Delete(context.Context, string) error {
panic("unexpected Delete call")
}
func newChannelMonitorRouteSettings(enabled bool) *service.SettingService {
value := "false"
if enabled {
value = "true"
}
return service.NewSettingService(&channelMonitorRouteSettingRepoStub{
values: map[string]string{
service.SettingKeyChannelMonitorEnabled: value,
},
}, &config.Config{})
}
func newChannelMonitorModeSettings(enabled bool, mode string) *service.SettingService {
enabledVal := "false"
if enabled {
enabledVal = "true"
}
return service.NewSettingService(&channelMonitorRouteSettingRepoStub{
values: map[string]string{
service.SettingKeyChannelMonitorEnabled: enabledVal,
service.SettingKeyChannelMonitorMode: mode,
},
}, &config.Config{})
}
func TestChannelMonitorAdminFeatureGuard(t *testing.T) {
tests := []struct {
name string
svc *service.SettingService
wantStatus int
}{
{
name: "nil setting service blocks",
svc: nil,
wantStatus: http.StatusForbidden,
},
{
name: "disabled blocks",
svc: newChannelMonitorRouteSettings(false),
wantStatus: http.StatusForbidden,
},
{
name: "enabled allows",
svc: newChannelMonitorRouteSettings(true),
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(channelMonitorAdminFeatureGuard(tt.svc))
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil)
router.ServeHTTP(rec, req)
require.Equal(t, tt.wantStatus, rec.Code)
if tt.wantStatus == http.StatusForbidden {
require.Contains(t, rec.Body.String(), "CHANNEL_MONITOR_DISABLED")
}
})
}
}
func TestChannelMonitorModeV2Guard(t *testing.T) {
tests := []struct {
name string
svc *service.SettingService
wantStatus int
wantCode string
}{
{
name: "nil blocks as disabled",
svc: nil,
wantStatus: http.StatusForbidden,
wantCode: "CHANNEL_MONITOR_DISABLED",
},
{
name: "feature off blocks",
svc: newChannelMonitorModeSettings(false, service.ChannelMonitorModeV2),
wantStatus: http.StatusForbidden,
wantCode: "CHANNEL_MONITOR_DISABLED",
},
{
name: "mode v1 blocks with mode mismatch",
svc: newChannelMonitorModeSettings(true, service.ChannelMonitorModeV1),
wantStatus: http.StatusForbidden,
wantCode: "CHANNEL_MONITOR_MODE_MISMATCH",
},
{
name: "mode v2 allows",
svc: newChannelMonitorModeSettings(true, service.ChannelMonitorModeV2),
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(channelMonitorModeV2Guard(tt.svc))
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil)
router.ServeHTTP(rec, req)
require.Equal(t, tt.wantStatus, rec.Code)
if tt.wantCode != "" {
require.Contains(t, rec.Body.String(), tt.wantCode)
}
})
}
}
+32
View File
@@ -0,0 +1,32 @@
package routes
import (
"net/http"
"github.com/gin-gonic/gin"
)
// RegisterCommonRoutes 注册通用路由(健康检查、状态等)
func RegisterCommonRoutes(r *gin.Engine) {
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// Claude Code 遥测日志(忽略,直接返回200)
r.POST("/api/event_logging/batch", func(c *gin.Context) {
c.Status(http.StatusOK)
})
// 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",
},
})
})
}
@@ -0,0 +1,314 @@
package routes
import (
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type compositeRouteRepoStub struct {
routes []service.CompositeModelRoute
}
func (s compositeRouteRepoStub) ListByGroup(ctx context.Context, groupID int64, includeDisabled bool) ([]service.CompositeModelRoute, error) {
routes := make([]service.CompositeModelRoute, 0, len(s.routes))
for _, route := range s.routes {
if route.GroupID != groupID {
continue
}
if !includeDisabled && !route.Enabled {
continue
}
routes = append(routes, route)
}
return routes, nil
}
func (s compositeRouteRepoStub) Create(ctx context.Context, route *service.CompositeModelRoute) error {
return nil
}
func (s compositeRouteRepoStub) Update(ctx context.Context, route *service.CompositeModelRoute) error {
return nil
}
func (s compositeRouteRepoStub) Delete(ctx context.Context, id int64) error {
return nil
}
func (s compositeRouteRepoStub) DeleteByGroup(ctx context.Context, groupID int64) error {
return nil
}
func TestCompositeTargetPlatformMiddlewareResolvesModelAndRestoresBody(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
groupID := int64(1)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{Platform: service.PlatformComposite},
})
c.Next()
})))
router.Use(compositeTargetPlatformMiddleware(nil))
router.POST("/", func(c *gin.Context) {
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, service.PlatformOpenAI, platform)
body, err := io.ReadAll(c.Request.Body)
require.NoError(t, err)
require.JSONEq(t, `{"model":"gpt-5"}`, string(body))
c.Status(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"model":"gpt-5"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
}
func TestCompositeTargetPlatformMiddlewareUsesExplicitRouteAndRewritesBody(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
resolver := service.NewCompositeRouteResolver(compositeRouteRepoStub{
routes: []service.CompositeModelRoute{
{
ID: 1,
GroupID: 1,
PublicModel: "openrouter/gpt-5",
MatchType: service.CompositeRouteMatchExact,
TargetPlatform: service.PlatformOpenAI,
UpstreamModel: "gpt-5",
Endpoint: service.CompositeRouteEndpointAny,
Priority: 100,
Enabled: true,
},
},
})
router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
groupID := int64(1)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
c.Next()
})))
router.Use(compositeTargetPlatformMiddleware(resolver))
router.POST("/v1/chat/completions", func(c *gin.Context) {
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, service.PlatformOpenAI, platform)
upstreamModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, "gpt-5", upstreamModel)
body, err := io.ReadAll(c.Request.Body)
require.NoError(t, err)
require.JSONEq(t, `{"model":"gpt-5","messages":[]}`, string(body))
c.Status(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"openrouter/gpt-5","messages":[]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
}
func TestCompositeTargetPlatformMiddlewareRewritesNestedLiveModel(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
resolver := service.NewCompositeRouteResolver(compositeRouteRepoStub{
routes: []service.CompositeModelRoute{
{
ID: 1,
GroupID: 1,
PublicModel: "live-alias",
MatchType: service.CompositeRouteMatchExact,
TargetPlatform: service.PlatformOpenAI,
UpstreamModel: "gpt-live",
Endpoint: service.CompositeRouteEndpointAny,
Priority: 100,
Enabled: true,
},
},
})
router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
groupID := int64(1)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
c.Next()
})))
router.Use(compositeTargetPlatformMiddleware(resolver))
router.POST("/backend-api/codex/realtime/calls", func(c *gin.Context) {
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, service.PlatformOpenAI, platform)
body, err := io.ReadAll(c.Request.Body)
require.NoError(t, err)
require.JSONEq(t, `{"session":{"model":"gpt-live"},"sdp":"v=0"}`, string(body))
c.Status(http.StatusNoContent)
})
req := httptest.NewRequest(
http.MethodPost,
"/backend-api/codex/realtime/calls",
strings.NewReader(`{"session":{"model":"live-alias"},"sdp":"v=0"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
}
func TestCompositeRequestModelFromMultipartLiveSession(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("sdp", "v=0"))
require.NoError(t, writer.WriteField("session", `{"model":"live-alias"}`))
require.NoError(t, writer.Close())
require.Equal(t, "live-alias", compositeRequestModelFromBody(writer.FormDataContentType(), body.Bytes()))
}
func TestCompositeCodexControlPathsUseResponsesRoutes(t *testing.T) {
for _, path := range []string{
"/v1/alpha/search",
"/backend-api/codex/alpha/search",
"/v1/live",
"/backend-api/codex/realtime/calls",
} {
require.Equal(t, service.CompositeRouteEndpointResponses, compositeRouteEndpointForPath(path), "path=%s", path)
}
}
func TestCompositeTargetPlatformMiddlewareUsesExplicitRouteForMultipartImages(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
resolver := service.NewCompositeRouteResolver(compositeRouteRepoStub{
routes: []service.CompositeModelRoute{
{
ID: 1,
GroupID: 1,
PublicModel: "image-alias",
MatchType: service.CompositeRouteMatchExact,
TargetPlatform: service.PlatformOpenAI,
UpstreamModel: "gpt-image-1",
Endpoint: service.CompositeRouteEndpointImages,
Priority: 100,
Enabled: true,
},
},
})
router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
groupID := int64(1)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
c.Next()
})))
router.Use(compositeTargetPlatformMiddleware(resolver))
router.POST("/v1/images/edits", func(c *gin.Context) {
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, service.PlatformOpenAI, platform)
upstreamModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, "gpt-image-1", upstreamModel)
publicModel, ok := service.RequestedPublicModelFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, "image-alias", publicModel)
body, err := io.ReadAll(c.Request.Body)
require.NoError(t, err)
require.Contains(t, string(body), "image-alias")
c.Status(http.StatusNoContent)
})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "image-alias"))
require.NoError(t, writer.WriteField("prompt", "draw"))
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
}
func TestCompositeGeminiTargetPlatformMiddlewareUsesPathRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
resolver := service.NewCompositeRouteResolver(compositeRouteRepoStub{
routes: []service.CompositeModelRoute{
{
ID: 1,
GroupID: 1,
PublicModel: "openrouter/gemini-pro",
MatchType: service.CompositeRouteMatchExact,
TargetPlatform: service.PlatformGemini,
UpstreamModel: "gemini-2.5-pro",
Endpoint: service.CompositeRouteEndpointGemini,
Priority: 100,
Enabled: true,
},
},
})
router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
groupID := int64(1)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
c.Next()
})))
router.Use(compositeGeminiTargetPlatformMiddleware(resolver))
router.POST("/v1beta/models/*modelAction", func(c *gin.Context) {
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, service.PlatformGemini, platform)
upstreamModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, "gemini-2.5-pro", upstreamModel)
c.Status(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/openrouter/gemini-pro:generateContent", strings.NewReader(`{"contents":[]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
}
+716
View File
@@ -0,0 +1,716 @@
package routes
import (
"bytes"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// RegisterGatewayRoutes 注册 API 网关路由(Claude/OpenAI/Gemini 兼容)
func RegisterGatewayRoutes(
r *gin.Engine,
h *handler.Handlers,
apiKeyAuth middleware.APIKeyAuthMiddleware,
apiKeyService *service.APIKeyService,
subscriptionService *service.SubscriptionService,
opsService *service.OpsService,
settingService *service.SettingService,
compositeResolver *service.CompositeRouteResolver,
cfg *config.Config,
) {
bodyLimit := middleware.RequestBodyLimit(cfg.Gateway.MaxBodySize)
textBodyLimit := middleware.RequestBodyLimit(cfg.Gateway.TextMaxBodySize)
clientRequestID := middleware.ClientRequestID()
opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService)
endpointNorm := handler.InboundEndpointMiddleware()
compositeTarget := compositeTargetPlatformMiddleware(compositeResolver)
compositeGeminiTarget := compositeGeminiTargetPlatformMiddleware(compositeResolver)
// 未分组 Key 拦截中间件(按协议格式区分错误响应)
requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter)
requireGroupGoogle := middleware.RequireGroupAssignment(settingService, middleware.GoogleErrorWriter)
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 openai/grok 一样经 OpenAI 网关转发。
return true
default:
return false
}
}
countTokensHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
h.OpenAIGateway.CountTokens(c)
case service.PlatformGrok:
h.OpenAIGateway.GrokCountTokens(c)
default:
h.Gateway.CountTokens(c)
}
}
modelsHandler := func(c *gin.Context) {
if c.Query("client_version") != "" {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformComposite:
h.OpenAIGateway.CodexModels(c)
return
}
}
h.Gateway.Models(c)
}
isOpenAIOnlyEndpointGatewayPlatform := func(c *gin.Context) bool {
return getGroupPlatform(c) == service.PlatformOpenAI
}
imagesHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI:
h.OpenAIGateway.Images(c)
case service.PlatformGrok:
h.OpenAIGateway.GrokImages(c)
default:
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"type": "not_found_error",
"message": "Images API is not supported for this platform",
},
})
}
}
videoGenerationHandler := func(c *gin.Context) {
if getGroupPlatform(c) == service.PlatformGrok {
h.OpenAIGateway.GrokVideoGeneration(c)
return
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"type": "not_found_error",
"message": "Videos API is not supported for this platform",
},
})
}
videoStatusHandler := func(c *gin.Context) {
// Video status requests do not carry a model, so composite groups cannot
// be resolved by compositeTargetPlatformMiddleware. Route them through
// the Grok handler and let scheduler/account selection enforce capacity.
if getGroupPlatform(c) == service.PlatformGrok || getGroupPlatform(c) == service.PlatformComposite {
h.OpenAIGateway.GrokVideoStatus(c)
return
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"type": "not_found_error",
"message": "Videos API is not supported for this platform",
},
})
}
videoContentHandler := func(c *gin.Context) {
// Video content requests do not carry a model, so composite groups cannot
// be resolved by compositeTargetPlatformMiddleware. Route them through
// the Grok handler just like video status lookups.
if getGroupPlatform(c) == service.PlatformGrok || getGroupPlatform(c) == service.PlatformComposite {
h.OpenAIGateway.GrokVideoContent(c)
return
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"type": "not_found_error",
"message": "Videos API is not supported for this platform",
},
})
}
videoEditHandler := func(c *gin.Context) {
if getGroupPlatform(c) == service.PlatformGrok {
h.OpenAIGateway.GrokVideoEdit(c)
return
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Videos API is not supported for this platform"}})
}
videoExtensionHandler := func(c *gin.Context) {
if getGroupPlatform(c) == service.PlatformGrok {
h.OpenAIGateway.GrokVideoExtension(c)
return
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Videos API is not supported for this platform"}})
}
// /responses/*subpath 的子路径会被转发到上游同名端点之后,因此在入口就拒掉
// 不可转发的子路径,不让它进入调度与转发流程。可转发的判定见
// service.IsForwardableOpenAIResponsesRequestPath 及 upstream_path_guard.go。
guardResponsesSubpath := func(next gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
if !service.IsForwardableOpenAIResponsesRequestPath(c) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
"error": gin.H{
"type": "not_found_error",
"message": "Unsupported responses subpath",
},
})
return
}
if service.IsOpenAIResponsesInputTokensRequestPath(c) && isOpenAIResponsesCompatibleGatewayPlatform(c) {
h.OpenAIGateway.ResponsesInputTokens(c)
return
}
next(c)
}
}
// API网关(Claude API兼容)
gateway := r.Group("/v1")
gateway.Use(bodyLimit)
gateway.Use(clientRequestID)
gateway.Use(opsErrorLogger)
gateway.Use(endpointNorm)
gateway.Use(gin.HandlerFunc(apiKeyAuth))
gateway.GET("/sub2api/billing", h.Gateway.KeyBillingInfo)
gateway.Use(compositeTarget)
gateway.Use(requireGroupAnthropic)
{
// /v1/messages: auto-route based on group platform
gateway.POST("/messages", func(c *gin.Context) {
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
h.OpenAIGateway.Messages(c)
return
}
h.Gateway.Messages(c)
})
// /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates
// locally, and Anthropic-compatible platforms retain their existing path.
gateway.POST("/messages/count_tokens", countTokensHandler)
// Codex CLI / Codex app refresh their model picker from the provider's
// /models endpoint with a client_version query and expect the ChatGPT
// Codex manifest format; other clients keep the OpenAI-style list.
gateway.GET("/models", modelsHandler)
gateway.GET("/usage", h.Gateway.Usage)
gateway.POST("/live", h.OpenAIGateway.Live)
gateway.GET("/live/:call_id", h.OpenAIGateway.LiveSideband)
// OpenAI Responses API: auto-route based on group platform
gateway.POST("/responses", func(c *gin.Context) {
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
h.OpenAIGateway.Responses(c)
return
}
h.Gateway.Responses(c)
})
gateway.POST("/responses/*subpath", guardResponsesSubpath(func(c *gin.Context) {
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
h.OpenAIGateway.Responses(c)
return
}
h.Gateway.Responses(c)
}))
gateway.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
gateway.GET("/responses", func(c *gin.Context) {
h.OpenAIGateway.ResponsesWebSocket(c)
})
// OpenAI Chat Completions API: auto-route based on group platform
gateway.POST("/chat/completions", func(c *gin.Context) {
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
h.OpenAIGateway.ChatCompletions(c)
return
}
h.Gateway.ChatCompletions(c)
})
gateway.POST("/embeddings", textBodyLimit, func(c *gin.Context) {
if !isOpenAIOnlyEndpointGatewayPlatform(c) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"type": "not_found_error",
"message": "Embeddings API is not supported for this platform",
},
})
return
}
h.OpenAIGateway.Embeddings(c)
})
gateway.POST("/images/generations", imagesHandler)
gateway.POST("/images/edits", imagesHandler)
gateway.POST("/images/generations/async", h.AsyncImage.Submit)
gateway.POST("/images/edits/async", h.AsyncImage.Submit)
gateway.GET("/images/tasks/:task_id", h.AsyncImage.Get)
gateway.POST("/images/batches", h.BatchImage.Submit)
gateway.GET("/images/batches", h.BatchImage.List)
gateway.GET("/images/batches/models", h.BatchImage.Models)
gateway.GET("/images/batches/:id", h.BatchImage.Get)
gateway.GET("/images/batches/:id/items", h.BatchImage.Items)
gateway.GET("/images/batches/:id/items/:custom_id/content", h.BatchImage.ItemContent)
gateway.GET("/images/batches/:id/download", h.BatchImage.Download)
gateway.POST("/images/batches/:id/cancel", h.BatchImage.Cancel)
gateway.DELETE("/images/batches/:id", h.BatchImage.DeleteRecord)
gateway.DELETE("/images/batches/:id/outputs", h.BatchImage.DeleteOutputs)
// OpenAI-compatible clients may create through /videos; xAI receives the
// canonical /videos/generations route inside the Grok media forwarder.
gateway.POST("/videos", videoGenerationHandler)
gateway.POST("/videos/generations", videoGenerationHandler)
gateway.POST("/videos/edits", videoEditHandler)
gateway.POST("/videos/extensions", videoExtensionHandler)
gateway.GET("/videos/generations/:request_id/content", videoContentHandler)
gateway.GET("/videos/edits/:request_id/content", videoContentHandler)
gateway.GET("/videos/extensions/:request_id/content", videoContentHandler)
gateway.GET("/videos/generations/:request_id", videoStatusHandler)
gateway.GET("/videos/edits/:request_id", videoStatusHandler)
gateway.GET("/videos/extensions/:request_id", videoStatusHandler)
gateway.GET("/videos/:request_id", videoStatusHandler)
gateway.GET("/videos/:request_id/content", videoContentHandler)
// xAI Voice APIs (Grok platform only): HTTP TTS/STT + Realtime WS.
// Not part of the creation-center product surface — gateway relay only.
voiceHandler := func(endpoint string) gin.HandlerFunc {
return func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}})
return
}
h.OpenAIGateway.GrokVoice(c, endpoint)
}
}
gateway.POST("/tts", voiceHandler("tts"))
gateway.POST("/stt", voiceHandler("stt"))
gateway.POST("/custom-voices", voiceHandler("custom-voices"))
customVoicePathHandler := func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}})
return
}
h.OpenAIGateway.GrokVoice(c, grokCustomVoiceEndpoint(c))
}
gateway.GET("/custom-voices", voiceHandler("custom-voices"))
gateway.GET("/custom-voices/:voice_id/audio", customVoicePathHandler)
gateway.GET("/custom-voices/:voice_id", customVoicePathHandler)
gateway.PATCH("/custom-voices/:voice_id", customVoicePathHandler)
gateway.DELETE("/custom-voices/:voice_id", customVoicePathHandler)
gateway.GET("/realtime", func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Realtime API is not supported for this platform"}})
return
}
h.OpenAIGateway.GrokRealtime(c)
})
gateway.POST("/web_search", func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Web Search API is not supported for this platform"}})
return
}
h.Gateway.WebSearch(c)
})
gateway.POST("/x_search", func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "X Search API is not supported for this platform"}})
return
}
h.Gateway.XSearch(c)
})
}
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
gemini := r.Group("/v1beta")
gemini.Use(bodyLimit)
gemini.Use(clientRequestID)
gemini.Use(opsErrorLogger)
gemini.Use(endpointNorm)
gemini.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
gemini.Use(compositeGeminiTarget)
gemini.Use(requireGroupGoogle)
{
gemini.GET("/models", h.Gateway.GeminiV1BetaListModels)
gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
// Gin treats ":" as a param marker, but Gemini uses "{model}:{action}" in the same segment.
gemini.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels)
}
// OpenAI Responses API(不带v1前缀的别名)— auto-route based on group platform
responsesHandler := func(c *gin.Context) {
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
h.OpenAIGateway.Responses(c)
return
}
h.Gateway.Responses(c)
}
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler)
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, guardResponsesSubpath(responsesHandler))
r.POST("/alpha/search", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.OpenAIGateway.AlphaSearch)
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
h.OpenAIGateway.ResponsesWebSocket(c)
})
r.GET("/models", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, modelsHandler)
r.POST("/messages/count_tokens", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, countTokensHandler)
codexDirect := r.Group("/backend-api/codex")
codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic)
{
codexDirect.POST("/realtime/calls", h.OpenAIGateway.Live)
codexDirect.GET("/:call_id", h.OpenAIGateway.LiveSideband)
codexDirect.POST("/responses", responsesHandler)
codexDirect.POST("/responses/*subpath", guardResponsesSubpath(responsesHandler))
codexDirect.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
codexDirect.GET("/responses", func(c *gin.Context) {
h.OpenAIGateway.ResponsesWebSocket(c)
})
codexDirect.GET("/models", h.OpenAIGateway.CodexModels)
}
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
h.OpenAIGateway.ChatCompletions(c)
return
}
h.Gateway.ChatCompletions(c)
})
r.POST("/embeddings", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
if !isOpenAIOnlyEndpointGatewayPlatform(c) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"type": "not_found_error",
"message": "Embeddings API is not supported for this platform",
},
})
return
}
h.OpenAIGateway.Embeddings(c)
})
r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, imagesHandler)
r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, imagesHandler)
r.POST("/images/generations/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit)
r.POST("/images/edits/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit)
r.GET("/images/tasks/:task_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Get)
r.POST("/videos", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler)
r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler)
r.POST("/videos/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoEditHandler)
r.POST("/videos/extensions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoExtensionHandler)
r.GET("/videos/generations/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler)
r.GET("/videos/edits/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler)
r.GET("/videos/extensions/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler)
r.GET("/videos/generations/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler)
r.GET("/videos/edits/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler)
r.GET("/videos/extensions/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler)
r.GET("/videos/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler)
r.GET("/videos/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler)
rootVoiceHandler := func(endpoint string) gin.HandlerFunc {
return func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}})
return
}
h.OpenAIGateway.GrokVoice(c, endpoint)
}
}
r.POST("/tts", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("tts"))
r.POST("/stt", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("stt"))
r.POST("/custom-voices", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("custom-voices"))
rootCustomVoicePathHandler := func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}})
return
}
h.OpenAIGateway.GrokVoice(c, grokCustomVoiceEndpoint(c))
}
r.GET("/custom-voices", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("custom-voices"))
r.GET("/custom-voices/:voice_id/audio", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler)
r.GET("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler)
r.PATCH("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler)
r.DELETE("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler)
r.GET("/realtime", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Realtime API is not supported for this platform"}})
return
}
h.OpenAIGateway.GrokRealtime(c)
})
r.POST("/web_search", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Web Search API is not supported for this platform"}})
return
}
h.Gateway.WebSearch(c)
})
r.POST("/x_search", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
if getGroupPlatform(c) != service.PlatformGrok {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "X Search API is not supported for this platform"}})
return
}
h.Gateway.XSearch(c)
})
// Antigravity 模型列表
r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels)
// Antigravity 专用路由(仅使用 antigravity 账户,不混合调度)
antigravityV1 := r.Group("/antigravity/v1")
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)
{
antigravityV1.POST("/messages", h.Gateway.Messages)
antigravityV1.POST("/messages/count_tokens", h.Gateway.CountTokens)
antigravityV1.GET("/models", h.Gateway.AntigravityModels)
antigravityV1.GET("/usage", h.Gateway.Usage)
}
antigravityV1Beta := r.Group("/antigravity/v1beta")
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)
{
antigravityV1Beta.GET("/models", h.Gateway.GeminiV1BetaListModels)
antigravityV1Beta.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
antigravityV1Beta.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels)
}
}
// getGroupPlatform extracts the group platform from the API Key stored in context.
func getGroupPlatform(c *gin.Context) string {
apiKey, ok := middleware.GetAPIKeyFromContext(c)
if !ok || apiKey.Group == nil {
return ""
}
if apiKey.Group.Platform == service.PlatformComposite {
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
return platform
}
}
return apiKey.Group.Platform
}
func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) gin.HandlerFunc {
if resolver == nil {
resolver = service.NewCompositeRouteResolver(nil)
}
return func(c *gin.Context) {
apiKey, ok := middleware.GetAPIKeyFromContext(c)
if !ok || apiKey == nil || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformComposite {
c.Next()
return
}
if c.Request == nil || c.Request.Method == http.MethodGet {
c.Next()
return
}
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
if err != nil {
status := http.StatusBadRequest
message := "Failed to read request body"
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
status = http.StatusRequestEntityTooLarge
message = "Request body is too large"
}
c.JSON(status, gin.H{"error": gin.H{"type": "invalid_request_error", "message": message}})
c.Abort()
return
}
model := compositeRequestModelFromBody(c.GetHeader("Content-Type"), body)
if model != "" {
decision, err := resolver.Resolve(c.Request.Context(), apiKey.Group.ID, model, compositeRouteEndpointForPath(c.Request.URL.Path))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"type": "server_error", "message": "Failed to resolve composite model route"}})
c.Abort()
return
}
if decision.Matched {
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
if upstreamModel := strings.TrimSpace(decision.UpstreamModel); upstreamModel != "" && upstreamModel != model && gjson.ValidBytes(body) {
if _, modelPath := compositeJSONRequestModel(body); modelPath != "" {
if rewritten, rewriteErr := sjson.SetBytes(body, modelPath, upstreamModel); rewriteErr == nil {
body = rewritten
}
}
}
}
}
resetRequestBody(c, body)
c.Next()
}
}
func compositeRequestModelFromBody(contentType string, body []byte) string {
if model, _ := compositeJSONRequestModel(body); model != "" {
return model
}
return compositeMultipartModelFromBody(contentType, body)
}
func compositeJSONRequestModel(body []byte) (string, string) {
for _, path := range []string{"model", "session.model"} {
model := gjson.GetBytes(body, path)
if model.Type != gjson.String {
continue
}
if value := strings.TrimSpace(model.String()); value != "" {
return value, path
}
}
return "", ""
}
func compositeMultipartModelFromBody(contentType string, body []byte) string {
mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(contentType))
if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") {
return ""
}
boundary := strings.TrimSpace(params["boundary"])
if boundary == "" {
return ""
}
reader := multipart.NewReader(bytes.NewReader(body), boundary)
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
return ""
}
if err != nil {
return ""
}
fieldName := part.FormName()
if part.FileName() != "" || (fieldName != "model" && fieldName != "session") {
continue
}
data, err := io.ReadAll(part)
if err != nil {
return ""
}
switch fieldName {
case "model":
return strings.TrimSpace(string(data))
case "session":
if model, _ := compositeJSONRequestModel(data); model != "" {
return model
}
}
}
}
func compositeGeminiTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) gin.HandlerFunc {
if resolver == nil {
resolver = service.NewCompositeRouteResolver(nil)
}
return func(c *gin.Context) {
apiKey, ok := middleware.GetAPIKeyFromContext(c)
if ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
model := compositeGeminiModelFromParams(c)
if model != "" {
decision, err := resolver.Resolve(c.Request.Context(), apiKey.Group.ID, model, service.CompositeRouteEndpointGemini)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"type": "server_error", "message": "Failed to resolve composite model route"}})
c.Abort()
return
}
if decision.Matched {
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
}
}
if _, resolved := service.ResolvedTargetPlatformFromContext(c.Request.Context()); !resolved {
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformGemini))
}
}
c.Next()
}
}
// grokCustomVoiceEndpoint derives the upstream Voice endpoint for the
// /custom-voices/:voice_id[/audio] routes.
//
// The /audio suffix must be decided from the matched route template, not from
// the raw URL path: a voice literally named "audio" makes GET
// /custom-voices/audio match /custom-voices/:voice_id, and a raw-path suffix
// check would rewrite it to custom-voices/audio/audio — turning a profile
// lookup into an audio download.
func grokCustomVoiceEndpoint(c *gin.Context) string {
endpoint := "custom-voices/" + c.Param("voice_id")
if strings.HasSuffix(c.FullPath(), "/:voice_id/audio") {
endpoint += "/audio"
}
return endpoint
}
func compositeGeminiModelFromParams(c *gin.Context) string {
if c == nil {
return ""
}
if model := strings.TrimSpace(c.Param("model")); model != "" {
return model
}
modelAction := strings.TrimPrefix(strings.TrimSpace(c.Param("modelAction")), "/")
if modelAction == "" {
return ""
}
if idx := strings.LastIndex(modelAction, ":"); idx >= 0 {
return strings.TrimSpace(modelAction[:idx])
}
return modelAction
}
func resetRequestBody(c *gin.Context, body []byte) {
c.Request.Body = io.NopCloser(bytes.NewReader(body))
c.Request.ContentLength = int64(len(body))
c.Request.Header.Set("Content-Length", strconv.Itoa(len(body)))
}
func compositeRouteEndpointForPath(path string) string {
switch {
case strings.Contains(path, "/messages/count_tokens"):
return service.CompositeRouteEndpointCountTokens
case strings.Contains(path, "/messages"):
return service.CompositeRouteEndpointMessages
case strings.Contains(path, "/responses"),
strings.Contains(path, "/alpha/search"),
strings.Contains(path, "/realtime/calls"),
strings.HasSuffix(strings.TrimRight(path, "/"), "/live"):
return service.CompositeRouteEndpointResponses
case strings.Contains(path, "/chat/completions"):
return service.CompositeRouteEndpointChatCompletions
case strings.Contains(path, "/embeddings"):
return service.CompositeRouteEndpointEmbeddings
case strings.Contains(path, "/images/"):
return service.CompositeRouteEndpointImages
case strings.Contains(path, "/v1beta/"):
return service.CompositeRouteEndpointGemini
default:
return service.CompositeRouteEndpointAny
}
}
@@ -0,0 +1,24 @@
package routes
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter()
registered := make(map[string]string)
for _, route := range router.Routes() {
if route.Method == http.MethodGet {
registered[route.Path] = route.Handler
}
}
require.NotEmpty(t, registered["/backend-api/codex/models"], "GET /backend-api/codex/models should be registered")
require.NotEmpty(t, registered["/v1/models"], "GET /v1/models should be registered")
require.NotEmpty(t, registered["/models"], "GET /models should be registered")
require.Equal(t, registered["/v1/models"], registered["/models"], "root alias should use the same platform-aware handler")
}
@@ -0,0 +1,173 @@
package routes
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/web"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type keyBillingRouteAPIKeyRepo struct {
service.APIKeyRepository
apiKey *service.APIKey
}
func (r *keyBillingRouteAPIKeyRepo) GetByKeyForAuth(_ context.Context, key string) (*service.APIKey, error) {
if r.apiKey == nil || key != r.apiKey.Key {
return nil, service.ErrAPIKeyNotFound
}
clone := *r.apiKey
return &clone, nil
}
type keyBillingRouteRateRepo struct {
service.UserGroupRateRepository
lookupCalls int
}
func (r *keyBillingRouteRateRepo) GetByUserAndGroup(context.Context, int64, int64) (*float64, error) {
r.lookupCalls++
return nil, nil
}
func (r *keyBillingRouteRateRepo) GetRPMOverrideByUserAndGroup(context.Context, int64, int64) (*int, error) {
return nil, nil
}
func newKeyBillingRouteTestRouter(runMode string) (*gin.Engine, *keyBillingRouteRateRepo, string) {
gin.SetMode(gin.TestMode)
group := &service.Group{
ID: 42,
Status: service.StatusActive,
Hydrated: true,
Platform: service.PlatformOpenAI,
SubscriptionType: service.SubscriptionTypeStandard,
RateMultiplier: 0.75,
}
user := &service.User{ID: 7, Role: service.RoleUser, Status: service.StatusActive, Balance: 10}
var groupID *int64
var apiKeyGroup *service.Group
if runMode != config.RunModeSimple {
groupID = &group.ID
apiKeyGroup = group
}
apiKey := &service.APIKey{
ID: 100,
UserID: user.ID,
Key: "billing-route-test-key",
Status: service.StatusActive,
User: user,
GroupID: groupID,
Group: apiKeyGroup,
}
cfg := &config.Config{RunMode: runMode}
rateRepo := &keyBillingRouteRateRepo{}
apiKeyService := service.NewAPIKeyService(
&keyBillingRouteAPIKeyRepo{apiKey: apiKey}, nil, nil, nil, rateRepo, nil, cfg,
)
gatewayService := service.NewGatewayService(
nil, nil, nil, nil, nil, nil, rateRepo, nil, cfg, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
)
openAIGatewayService := service.NewOpenAIGatewayService(
nil, nil, nil, nil, nil, rateRepo, nil, cfg, nil, nil, nil,
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
)
gatewayHandler := handler.NewGatewayHandler(
gatewayService, openAIGatewayService, nil, nil, nil, nil, nil, nil,
apiKeyService, nil, nil, nil, nil, cfg, nil,
)
router := gin.New()
if web.HasEmbeddedFrontend() {
router.Use(web.ServeEmbeddedFrontend())
}
RegisterGatewayRoutes(
router,
&handler.Handlers{Gateway: gatewayHandler, OpenAIGateway: &handler.OpenAIGatewayHandler{}},
servermiddleware.NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg),
apiKeyService,
nil,
nil,
nil,
nil,
cfg,
)
return router, rateRepo, apiKey.Key
}
func TestGatewayRoutesKeyBillingInfoPathIsRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter()
for _, route := range router.Routes() {
if route.Method == http.MethodGet && route.Path == "/v1/sub2api/billing" {
return
}
}
t.Fatal("GET /v1/sub2api/billing should be registered")
}
func TestGatewayRoutesKeyBillingInfoEndToEnd(t *testing.T) {
t.Run("missing credentials", func(t *testing.T) {
router, rateRepo, _ := newKeyBillingRouteTestRouter(config.RunModeStandard)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil))
require.Equal(t, http.StatusUnauthorized, w.Code)
require.Contains(t, w.Header().Get("Content-Type"), "application/json")
require.NotContains(t, strings.ToLower(w.Body.String()), "<!doctype html>")
require.Zero(t, rateRepo.lookupCalls)
})
t.Run("standard mode", func(t *testing.T) {
router, rateRepo, key := newKeyBillingRouteTestRouter(config.RunModeStandard)
req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)
req.Header.Set("Authorization", "Bearer "+key)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Header().Get("Content-Type"), "application/json")
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
require.NotContains(t, strings.ToLower(w.Body.String()), "<!doctype html>")
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "sub2api.key_billing", body["object"])
require.Equal(t, 0.75, body["effective_rate_multiplier"])
require.Equal(t, 1, rateRepo.lookupCalls)
})
t.Run("simple mode", func(t *testing.T) {
router, rateRepo, key := newKeyBillingRouteTestRouter(config.RunModeSimple)
req := httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)
req.Header.Set("x-api-key", key)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
require.Contains(t, w.Header().Get("Content-Type"), "application/json")
require.NotContains(t, strings.ToLower(w.Body.String()), "<!doctype html>")
require.JSONEq(t, `{
"type": "error",
"error": {
"type": "not_found_error",
"message": "Billing information is not supported in simple mode"
}
}`, w.Body.String())
require.Zero(t, rateRepo.lookupCalls)
})
}
@@ -0,0 +1,456 @@
package routes
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func newGatewayRoutesTestRouter(platform ...string) *gin.Engine {
return newGatewayRoutesTestRouterWithConfig(&config.Config{
Gateway: config.GatewayConfig{
MaxBodySize: 1024 * 1024,
TextMaxBodySize: 1024 * 1024,
},
}, platform...)
}
func newGatewayRoutesTestRouterWithConfig(cfg *config.Config, platform ...string) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
groupPlatform := service.PlatformOpenAI
if len(platform) > 0 && platform[0] != "" {
groupPlatform = platform[0]
}
RegisterGatewayRoutes(
router,
&handler.Handlers{
Gateway: &handler.GatewayHandler{},
OpenAIGateway: &handler.OpenAIGatewayHandler{},
AsyncImage: handler.NewAsyncImageHandler(nil, nil),
},
servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
groupID := int64(1)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{Platform: groupPlatform},
})
c.Next()
}),
nil,
nil,
nil,
nil,
nil,
cfg,
)
return router
}
func TestGatewayRoutesOpenAIResponsesCompactPathIsRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter()
for _, path := range []string{
"/v1/responses/compact",
"/responses/compact",
"/backend-api/codex/responses",
"/backend-api/codex/responses/compact",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit OpenAI responses handler", path)
}
}
func TestGatewayRoutesOpenAIAlphaSearchPathsAreRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter()
registered := make(map[string]bool)
for _, route := range router.Routes() {
if route.Method == http.MethodPost {
registered[route.Path] = true
}
}
for _, path := range []string{
"/v1/alpha/search",
"/alpha/search",
"/backend-api/codex/alpha/search",
} {
require.True(t, registered[path], "POST %s should be registered", path)
}
}
func TestGatewayRoutesAlphaSearchRejectsUnsupportedGroup(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformGrok)
req := httptest.NewRequest(http.MethodPost, "/v1/alpha/search", strings.NewReader(`{"model":"gpt-5.6-sol"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
require.Contains(t, w.Body.String(), "only available for OpenAI and Composite groups")
}
func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter()
for _, path := range []string{
"/v1/images/generations",
"/v1/images/edits",
"/images/generations",
"/images/edits",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-image-2","prompt":"draw a cat"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit OpenAI images handler", path)
}
}
func TestGatewayRoutesAsyncImagesPathsAreRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter()
registered := make(map[string]bool)
for _, route := range router.Routes() {
registered[route.Method+" "+route.Path] = true
}
for _, route := range []string{
"POST /v1/images/generations/async",
"POST /v1/images/edits/async",
"GET /v1/images/tasks/:task_id",
"POST /images/generations/async",
"POST /images/edits/async",
"GET /images/tasks/:task_id",
} {
require.True(t, registered[route], "%s should be registered", route)
}
}
func TestGatewayRoutesGrokImagesAndVideosPathsAreRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformGrok)
for _, path := range []string{
"/v1/images/generations",
"/v1/images/edits",
"/images/generations",
"/images/edits",
"/v1/videos/generations",
"/v1/videos",
"/videos",
"/videos/generations",
"/v1/videos/edits",
"/videos/edits",
"/v1/videos/extensions",
"/videos/extensions",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"grok-imagine","prompt":"draw a cat"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit Grok media handler", path)
require.NotContains(t, w.Body.String(), "not supported for this platform")
}
for _, path := range []string{
"/v1/videos/request-123",
"/videos/request-123",
"/v1/videos/generations/request-123",
"/videos/generations/request-123",
"/v1/videos/edits/request-123",
"/videos/edits/request-123",
"/v1/videos/extensions/request-123",
"/videos/extensions/request-123",
"/v1/videos/request-123/content",
"/videos/request-123/content",
"/v1/videos/generations/request-123/content",
"/videos/generations/request-123/content",
"/v1/videos/edits/request-123/content",
"/videos/edits/request-123/content",
"/v1/videos/extensions/request-123/content",
"/videos/extensions/request-123/content",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit Grok video handler", path)
require.NotContains(t, w.Body.String(), "not supported for this platform")
}
}
func TestGatewayRoutesGrokCustomVoiceCRUDPathsAreRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformGrok)
registered := make(map[string]bool)
for _, route := range router.Routes() {
registered[route.Method+" "+route.Path] = true
}
for _, route := range []string{
"POST /v1/custom-voices",
"GET /v1/custom-voices",
"GET /v1/custom-voices/:voice_id",
"PATCH /v1/custom-voices/:voice_id",
"DELETE /v1/custom-voices/:voice_id",
"GET /v1/custom-voices/:voice_id/audio",
"POST /custom-voices",
"GET /custom-voices",
"GET /custom-voices/:voice_id",
"PATCH /custom-voices/:voice_id",
"DELETE /custom-voices/:voice_id",
"GET /custom-voices/:voice_id/audio",
} {
require.True(t, registered[route], "%s should be registered", route)
}
}
func TestGrokCustomVoiceEndpointUsesRouteTemplateNotRawPath(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
var got string
capture := func(c *gin.Context) {
got = grokCustomVoiceEndpoint(c)
c.Status(http.StatusOK)
}
router.GET("/v1/custom-voices/:voice_id/audio", capture)
router.GET("/v1/custom-voices/:voice_id", capture)
for _, tc := range []struct {
path string
want string
}{
{path: "/v1/custom-voices/voice-123", want: "custom-voices/voice-123"},
{path: "/v1/custom-voices/voice-123/audio", want: "custom-voices/voice-123/audio"},
// A voice literally named "audio" matches /:voice_id, not /:voice_id/audio.
// A raw-path suffix check would turn this profile lookup into an audio download.
{path: "/v1/custom-voices/audio", want: "custom-voices/audio"},
{path: "/v1/custom-voices/audio/audio", want: "custom-voices/audio/audio"},
} {
got = ""
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "path=%s", tc.path)
require.Equal(t, tc.want, got, "path=%s", tc.path)
}
}
func TestGatewayRoutesCompositeVideoLookupsUseGrokHandler(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformComposite)
for _, path := range []string{
"/v1/videos/request-123",
"/videos/request-123",
"/v1/videos/request-123/content",
"/videos/request-123/content",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit Grok video lookup handler", path)
require.NotContains(t, w.Body.String(), "not supported for this platform")
}
}
func TestGatewayRoutesCompositeMessagesWithGrokModelUsesOpenAIGateway(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformComposite)
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"grok-4.3","messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code)
require.NotContains(t, w.Body.String(), "not supported")
require.NotContains(t, w.Body.String(), "OpenAI-compatible endpoint")
require.NotContains(t, w.Body.String(), "composite groups")
}
func TestGatewayRoutesCompositeChatCompletionsWithGrokModelUsesOpenAIGateway(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformComposite)
for _, path := range []string{"/v1/chat/completions", "/chat/completions"} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"grok-4.3","messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s", path)
require.NotContains(t, w.Body.String(), "not supported")
require.NotContains(t, w.Body.String(), "OpenAI-compatible endpoint")
require.NotContains(t, w.Body.String(), "composite groups")
}
}
func TestGatewayRoutesNonGrokVideosAreRejectedAtPlatformGate(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformOpenAI)
for _, tc := range []struct {
method string
path string
body string
}{
{http.MethodPost, "/v1/videos/generations", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`},
{http.MethodPost, "/v1/videos", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`},
{http.MethodPost, "/videos", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`},
{http.MethodPost, "/videos/generations", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`},
{http.MethodPost, "/v1/videos/edits", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`},
{http.MethodPost, "/videos/edits", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`},
{http.MethodPost, "/v1/videos/extensions", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`},
{http.MethodPost, "/videos/extensions", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`},
{http.MethodGet, "/v1/videos/request-123", ""},
{http.MethodGet, "/videos/request-123", ""},
{http.MethodGet, "/v1/videos/generations/request-123", ""},
{http.MethodGet, "/videos/generations/request-123", ""},
{http.MethodGet, "/v1/videos/edits/request-123", ""},
{http.MethodGet, "/videos/edits/request-123", ""},
{http.MethodGet, "/v1/videos/extensions/request-123", ""},
{http.MethodGet, "/videos/extensions/request-123", ""},
{http.MethodGet, "/v1/videos/request-123/content", ""},
{http.MethodGet, "/videos/request-123/content", ""},
{http.MethodGet, "/v1/videos/generations/request-123/content", ""},
{http.MethodGet, "/videos/generations/request-123/content", ""},
{http.MethodGet, "/v1/videos/edits/request-123/content", ""},
{http.MethodGet, "/videos/edits/request-123/content", ""},
{http.MethodGet, "/v1/videos/extensions/request-123/content", ""},
{http.MethodGet, "/videos/extensions/request-123/content", ""},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code, "method=%s path=%s", tc.method, tc.path)
require.Contains(t, w.Body.String(), "Videos API is not supported for this platform")
}
}
func TestGatewayRoutesCompositeOpenAIOnlyEndpointsRequireOpenAITarget(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformComposite)
req := httptest.NewRequest(http.MethodPost, "/v1/embeddings", strings.NewReader(`{"model":"gemini-2.5-pro","input":"hello"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
req = httptest.NewRequest(http.MethodPost, "/v1/embeddings", strings.NewReader(`{"model":"text-embedding-3-small","input":"hello"}`))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code)
}
func TestGatewayRoutesGrokAllowsCLICompatibilityEntrypoints(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformGrok)
for _, tc := range []struct {
method string
path string
}{
{http.MethodPost, "/v1/messages"},
{http.MethodPost, "/v1/chat/completions"},
{http.MethodPost, "/chat/completions"},
{http.MethodGet, "/v1/responses"},
{http.MethodGet, "/responses"},
{http.MethodGet, "/backend-api/codex/responses"},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(`{"model":"grok"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code, "method=%s path=%s", tc.method, tc.path)
require.NotContains(t, w.Body.String(), "not supported for Grok groups")
}
countTokensRouter := newGatewayRoutesTestRouterWithConfig(&config.Config{
Gateway: config.GatewayConfig{MaxBodySize: 1024 * 1024},
}, service.PlatformGrok)
for _, path := range []string{"/v1/messages/count_tokens", "/messages/count_tokens"} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"grok","messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
countTokensRouter.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "path=%s", path)
var response struct {
InputTokens int `json:"input_tokens"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response), "path=%s", path)
require.Positive(t, response.InputTokens, "path=%s", path)
}
for _, path := range []string{
"/v1/responses",
"/responses",
"/backend-api/codex/responses",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"grok","input":"hi"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should still reach Responses handler", path)
}
}
// TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths 端到端锁定不变式:
// /responses/*subpath 的子路径会被转发到上游同名端点之后,因此不合规的子路径必须
// 在入口就被拒绝,不得进入调度与转发流程。
func TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths(t *testing.T) {
router := newGatewayRoutesTestRouter()
for _, path := range []string{
"/v1/responses/../../x/y",
"/v1/responses/..%2f..%2fx/y",
"/v1/responses/%2e%2e/%2e%2e/x",
"/responses/%2e%2e%2fx",
"/backend-api/codex/responses/..%2f..%2fx",
`/v1/responses/..\..\x`,
"/v1/responses/%3fa=b",
"/v1/responses/x%23frag",
"/v1/responses/compact%2f..",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code, "path=%s must be rejected at the edge", path)
require.Contains(t, w.Body.String(), "Unsupported responses subpath", "path=%s", path)
}
}
func TestGatewayRoutesOpenAICountTokensPathIsRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformOpenAI)
req := httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", strings.NewReader(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusNotFound, w.Code)
}
@@ -0,0 +1,30 @@
package routes
import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// RegisterModelPlazaRoutes 注册模型广场路由。
//
// 挂 OptionalJWT:匿名可访问(开关与 require_auth 由 handler fail-closed 判定),
// 带 token 则识别用户以展示专属分组与个人倍率。
// BackendModeUserGuard 保证 backend 模式下广场不对非管理员开放(匿名无 role → 403)。
func RegisterModelPlazaRoutes(
v1 *gin.RouterGroup,
h *handler.Handlers,
optionalJWT middleware.OptionalJWTAuthMiddleware,
settingService *service.SettingService,
panelRateLimiter *middleware.PanelRateLimiter,
) {
plaza := v1.Group("/model-plaza")
plaza.Use(panelRateLimiter.PublicIP())
plaza.Use(gin.HandlerFunc(optionalJWT))
plaza.Use(middleware.BackendModeUserGuard(settingService))
{
plaza.GET("", h.ModelPlaza.Get)
}
}
@@ -0,0 +1,53 @@
package routes
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/handler"
adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestIngressRejectAdminRoutesRequireAdminAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
handlers := &handler.Handlers{Admin: &handler.AdminHandlers{Ops: adminhandler.NewOpsHandler(nil)}}
adminAuth := servermiddleware.AdminAuthMiddleware(func(c *gin.Context) {
if c.GetHeader("Authorization") == "" {
servermiddleware.AbortWithError(c, http.StatusUnauthorized, "UNAUTHORIZED", "Authorization required")
return
}
servermiddleware.AbortWithError(c, http.StatusForbidden, "FORBIDDEN", "Admin access required")
})
auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() })
stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() })
RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil, nil)
for _, path := range []string{
"/api/v1/admin/ops/ingress-rejections",
"/api/v1/admin/ops/ingress-rejections/health",
} {
for _, tc := range []struct {
name string
auth string
wantStatus int
}{
{name: "unauthenticated", wantStatus: http.StatusUnauthorized},
{name: "non-admin", auth: "Bearer user-token", wantStatus: http.StatusForbidden},
} {
t.Run(path+"/"+tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, path, nil)
if tc.auth != "" {
request.Header.Set("Authorization", tc.auth)
}
router.ServeHTTP(recorder, request)
require.Equal(t, tc.wantStatus, recorder.Code)
})
}
}
}
+113
View File
@@ -0,0 +1,113 @@
package routes
import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// RegisterPaymentRoutes registers all payment-related routes:
// user-facing endpoints, webhook endpoints, and admin endpoints.
func RegisterPaymentRoutes(
v1 *gin.RouterGroup,
paymentHandler *handler.PaymentHandler,
webhookHandler *handler.PaymentWebhookHandler,
adminPaymentHandler *admin.PaymentHandler,
jwtAuth middleware.JWTAuthMiddleware,
adminAuth middleware.AdminAuthMiddleware,
auditLog middleware.AuditLogMiddleware,
settingService *service.SettingService,
panelRateLimiter *middleware.PanelRateLimiter,
) {
// --- User-facing payment endpoints (authenticated) ---
authenticated := v1.Group("/payment")
authenticated.Use(gin.HandlerFunc(jwtAuth))
authenticated.Use(middleware.BackendModeUserGuard(settingService))
// 面板全局按用户限流
authenticated.Use(panelRateLimiter.Global())
{
authenticated.GET("/config", paymentHandler.GetPaymentConfig)
authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo)
authenticated.GET("/plans", paymentHandler.GetPlans)
authenticated.GET("/limits", paymentHandler.GetLimits)
orders := authenticated.Group("/orders")
{
orders.POST("", paymentHandler.CreateOrder)
orders.POST("/verify", paymentHandler.VerifyOrder)
orders.GET("/my", paymentHandler.GetMyOrders)
orders.GET("/:id", paymentHandler.GetOrder)
orders.POST("/:id/cancel", paymentHandler.CancelOrder)
orders.POST("/:id/refund-request", paymentHandler.RequestRefund)
orders.GET("/refund-eligible-providers", paymentHandler.GetRefundEligibleProviders)
}
}
// --- Public payment endpoints (no auth) ---
// Signed resume-token recovery is the preferred public lookup path.
// The legacy anonymous out_trade_no verify endpoint remains available as a
// persisted-state compatibility path for staggered upgrades.
public := v1.Group("/payment/public")
{
public.POST("/orders/verify", paymentHandler.VerifyOrderPublic)
public.POST("/orders/resolve", paymentHandler.ResolveOrderPublicByResumeToken)
}
// --- Webhook endpoints (no auth) ---
webhook := v1.Group("/payment/webhook")
{
// EasyPay sends GET callbacks with query params
webhook.GET("/easypay", webhookHandler.EasyPayNotify)
webhook.POST("/easypay", webhookHandler.EasyPayNotify)
webhook.POST("/alipay", webhookHandler.AlipayNotify)
webhook.POST("/wxpay", webhookHandler.WxpayNotify)
webhook.POST("/stripe", webhookHandler.StripeWebhook)
webhook.POST("/airwallex", webhookHandler.AirwallexWebhook)
}
// --- Admin payment endpoints (admin auth) ---
adminGroup := v1.Group("/admin/payment")
adminGroup.Use(gin.HandlerFunc(adminAuth))
adminGroup.Use(gin.HandlerFunc(auditLog))
adminGroup.Use(middleware.AdminComplianceGuard(settingService))
{
// Dashboard
adminGroup.GET("/dashboard", adminPaymentHandler.GetDashboard)
// Config
adminGroup.GET("/config", adminPaymentHandler.GetConfig)
adminGroup.PUT("/config", adminPaymentHandler.UpdateConfig)
// Orders
adminOrders := adminGroup.Group("/orders")
{
adminOrders.GET("", adminPaymentHandler.ListOrders)
adminOrders.GET("/:id", adminPaymentHandler.GetOrderDetail)
adminOrders.POST("/:id/cancel", adminPaymentHandler.CancelOrder)
adminOrders.POST("/:id/retry", adminPaymentHandler.RetryFulfillment)
adminOrders.POST("/:id/refund", adminPaymentHandler.ProcessRefund)
adminOrders.POST("/:id/refund/query", adminPaymentHandler.QueryAndFinalizeRefund)
}
// Subscription Plans
plans := adminGroup.Group("/plans")
{
plans.GET("", adminPaymentHandler.ListPlans)
plans.POST("", adminPaymentHandler.CreatePlan)
plans.PUT("/:id", adminPaymentHandler.UpdatePlan)
plans.DELETE("/:id", adminPaymentHandler.DeletePlan)
}
// Provider Instances
providers := adminGroup.Group("/providers")
{
providers.GET("", adminPaymentHandler.ListProviders)
providers.POST("", adminPaymentHandler.CreateProvider)
providers.PUT("/:id", adminPaymentHandler.UpdateProvider)
providers.DELETE("/:id", adminPaymentHandler.DeleteProvider)
}
}
}
@@ -0,0 +1,144 @@
package routes
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestEveryGatewayPOSTRouteIsClassifiedForPromptAuditCoverage(t *testing.T) {
routeSource, err := os.ReadFile("gateway.go")
require.NoError(t, err)
pattern := regexp.MustCompile(`(?:gateway|gemini|r|codexDirect|antigravityV1|antigravityV1Beta)\.POST\("([^"]+)"`)
matches := pattern.FindAllStringSubmatch(string(routeSource), -1)
actual := map[string]struct{}{}
for _, match := range matches {
actual[match[1]] = struct{}{}
}
audited := map[string][]string{
"/messages": {"gateway_handler.go", "openai_gateway_handler.go"},
"/responses": {"gateway_handler_responses.go", "openai_gateway_handler.go"},
"/responses/*subpath": {"gateway_handler_responses.go", "openai_gateway_handler.go"},
"/chat/completions": {"gateway_handler_chat_completions.go", "openai_chat_completions.go"},
"/embeddings": {"openai_embeddings.go"},
"/alpha/search": {"openai_alpha_search.go"},
"/live": {"openai_live.go"},
"/realtime/calls": {"openai_live.go"},
"/images/generations": {"openai_images.go", "grok_media.go"},
"/images/edits": {"openai_images.go", "grok_media.go"},
"/images/generations/async": {"image_task_handler.go"},
"/images/edits/async": {"image_task_handler.go"},
"/images/batches": {"batch_image_handler.go"},
"/videos": {"grok_media.go"},
"/videos/generations": {"grok_media.go"},
"/videos/edits": {"grok_media.go"},
"/videos/extensions": {"grok_media.go"},
"/models/*modelAction": {"gemini_v1beta_handler.go"},
"/tts": {"grok_audio.go"},
"/web_search": {"gateway_web_search.go"},
"/x_search": {"gateway_web_search.go"},
}
excluded := map[string]string{
"/messages/count_tokens": "tokenization only; it does not execute a model request",
"/images/batches/:id/cancel": "control-plane cancellation with no user prompt",
"/stt": "speech transcription is not a text-generation prompt",
"/custom-voices": "voice profile management has no model prompt",
}
unclassified := make([]string, 0)
for route := range actual {
if _, ok := audited[route]; ok {
continue
}
if _, ok := excluded[route]; ok {
continue
}
unclassified = append(unclassified, route)
}
sort.Strings(unclassified)
require.Empty(t, unclassified, "new gateway POST routes must be audited or explicitly classified with a no-prompt reason")
for route, files := range audited {
_, exists := actual[route]
require.Truef(t, exists, "stale prompt-audit route manifest entry %s", route)
for _, filename := range files {
source, readErr := os.ReadFile(filepath.Join("..", "..", "handler", filename))
require.NoError(t, readErr)
require.Containsf(t, string(source), "checkSecurityAudit", "%s route handler %s bypasses Coordinator", route, filename)
}
}
for route, reason := range excluded {
require.NotEmpty(t, strings.TrimSpace(reason))
_, exists := actual[route]
require.Truef(t, exists, "stale excluded route %s", route)
}
}
func TestResponsesWebSocketHasFirstAndSubsequentTurnPromptGates(t *testing.T) {
routeSource, err := os.ReadFile("gateway.go")
require.NoError(t, err)
require.GreaterOrEqual(t, strings.Count(string(routeSource), `.GET("/responses"`), 2)
handlerSource, err := os.ReadFile(filepath.Join("..", "..", "handler", "openai_gateway_handler.go"))
require.NoError(t, err)
require.Contains(t, string(handlerSource), `checkSecurityAuditStage`)
require.Contains(t, string(handlerSource), `"first_turn"`)
require.Contains(t, string(handlerSource), `"subsequent_turn"`)
wsStart := strings.Index(string(handlerSource), `func (h *OpenAIGatewayHandler) ResponsesWebSocket`)
require.NotEqual(t, -1, wsStart)
wsSource := string(handlerSource)[wsStart:]
require.Less(t,
strings.Index(wsSource, `"first_turn"`),
strings.Index(wsSource, `TryAcquireUserSlotForAPIKey`),
"the first response.create gate must precede per-request user/account slots",
)
}
func TestPromptAuditAdminRoutesRejectUnauthenticatedAndNonAdminRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
handlers := &handler.Handlers{Admin: &handler.AdminHandlers{
PromptAudit: securityaudit.NewPromptAdminHandler(nil),
}}
adminAuth := servermiddleware.AdminAuthMiddleware(func(c *gin.Context) {
if c.GetHeader("Authorization") == "" {
servermiddleware.AbortWithError(c, http.StatusUnauthorized, "UNAUTHORIZED", "Authorization required")
return
}
servermiddleware.AbortWithError(c, http.StatusForbidden, "FORBIDDEN", "Admin access required")
})
auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() })
stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() })
RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil, nil)
for _, tc := range []struct {
name string
auth string
wantStatus int
}{
{name: "unauthenticated", wantStatus: http.StatusUnauthorized},
{name: "non-admin", auth: "Bearer user-token", wantStatus: http.StatusForbidden},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/prompt-audit/config", nil)
if tc.auth != "" {
request.Header.Set("Authorization", tc.auth)
}
router.ServeHTTP(recorder, request)
require.Equal(t, tc.wantStatus, recorder.Code)
})
}
}
+158
View File
@@ -0,0 +1,158 @@
package routes
import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// RegisterUserRoutes 注册用户相关路由(需要认证)
func RegisterUserRoutes(
v1 *gin.RouterGroup,
h *handler.Handlers,
jwtAuth middleware.JWTAuthMiddleware,
auditLog middleware.AuditLogMiddleware,
settingService *service.SettingService,
panelRateLimiter *middleware.PanelRateLimiter,
) {
authenticated := v1.Group("")
authenticated.Use(gin.HandlerFunc(jwtAuth))
authenticated.Use(middleware.BackendModeUserGuard(settingService))
// 面板全局按用户限流:防止单个账号高频刷接口打爆数据库
authenticated.Use(panelRateLimiter.Global())
// 用户管理面变更类操作入审计(含 TOTP 启用/禁用、step-up 验证、密码修改等安全事件)
authenticated.Use(gin.HandlerFunc(auditLog))
{
// 用户接口
user := authenticated.Group("/user")
{
user.GET("/profile", h.User.GetProfile)
user.PUT("/password", h.User.ChangePassword)
user.PUT("", h.User.UpdateProfile)
user.GET("/aff", h.User.GetAffiliate)
user.POST("/aff/transfer", h.User.TransferAffiliateQuota)
user.POST("/account-bindings/email/send-code", h.User.SendEmailBindingCode)
user.POST("/account-bindings/email", h.User.BindEmailIdentity)
user.DELETE("/account-bindings/:provider", h.User.UnbindIdentity)
user.POST("/auth-identities/bind/start", h.User.StartIdentityBinding)
user.GET("/api-keys/:id/usage/daily", panelRateLimiter.Heavy(), h.Usage.GetMyAPIKeyDailyUsage)
user.GET("/platform-quotas", h.User.GetMyPlatformQuotas)
// 通知邮箱管理
notifyEmail := user.Group("/notify-email")
{
notifyEmail.POST("/send-code", h.User.SendNotifyEmailCode)
notifyEmail.POST("/verify", h.User.VerifyNotifyEmail)
notifyEmail.PUT("/toggle", h.User.ToggleNotifyEmail)
notifyEmail.DELETE("", h.User.RemoveNotifyEmail)
}
// TOTP 双因素认证
totp := user.Group("/totp")
{
totp.GET("/status", h.Totp.GetStatus)
totp.GET("/verification-method", h.Totp.GetVerificationMethod)
totp.POST("/send-code", h.Totp.SendVerifyCode)
totp.POST("/setup", h.Totp.InitiateSetup)
totp.POST("/enable", h.Totp.Enable)
totp.POST("/disable", h.Totp.Disable)
// 敏感操作二次验证:授予当前会话一段时间的 step-up 权限
totp.POST("/step-up", h.Totp.StepUp)
}
passkeys := user.Group("/passkeys")
{
passkeys.GET("", h.Passkey.List)
passkeys.POST("/register/begin", h.Passkey.BeginRegistration)
passkeys.POST("/register/finish", h.Passkey.FinishRegistration)
passkeys.PATCH("/:id", h.Passkey.Rename)
passkeys.DELETE("/:id", h.Passkey.Delete)
}
}
// API Key管理
keys := authenticated.Group("/keys")
{
keys.GET("", h.APIKey.List)
keys.GET("/:id", h.APIKey.GetByID)
keys.POST("", h.APIKey.Create)
keys.PUT("/:id", h.APIKey.Update)
keys.DELETE("/:id", h.APIKey.Delete)
}
// 用户可用分组(非管理员接口)
groups := authenticated.Group("/groups")
{
groups.GET("/available", h.APIKey.GetAvailableGroups)
groups.GET("/rates", h.APIKey.GetUserGroupRates)
}
// 用户可用渠道(非管理员接口)
channels := authenticated.Group("/channels")
{
channels.GET("/available", h.AvailableChannel.List)
}
// 使用记录(聚合统计属重查询,叠加更严格的按用户限流)
usage := authenticated.Group("/usage")
usage.Use(panelRateLimiter.Heavy())
{
usage.GET("", h.Usage.List)
usage.GET("/errors", h.Usage.ListErrors)
usage.GET("/errors/:id", h.Usage.GetErrorDetail)
usage.GET("/:id", h.Usage.GetByID)
usage.GET("/stats", h.Usage.Stats)
// User dashboard endpoints
usage.GET("/dashboard/stats", h.Usage.DashboardStats)
usage.GET("/dashboard/trend", h.Usage.DashboardTrend)
usage.GET("/dashboard/models", h.Usage.DashboardModels)
usage.GET("/dashboard/snapshot-v2", h.Usage.DashboardSnapshotV2)
usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardAPIKeysUsage)
}
// 公告(用户可见)
announcements := authenticated.Group("/announcements")
{
announcements.GET("", h.Announcement.List)
announcements.POST("/:id/read", h.Announcement.MarkRead)
}
// 卡密兑换
redeem := authenticated.Group("/redeem")
{
redeem.POST("", h.Redeem.Redeem)
redeem.GET("/history", h.Redeem.GetHistory)
}
// 用户订阅
subscriptions := authenticated.Group("/subscriptions")
{
subscriptions.GET("", h.Subscription.List)
subscriptions.GET("/active", h.Subscription.GetActive)
subscriptions.GET("/progress", h.Subscription.GetProgress)
subscriptions.GET("/summary", h.Subscription.GetSummary)
}
// 渠道监控(用户只读)
monitors := authenticated.Group("/channel-monitors")
{
monitors.GET("", h.ChannelMonitor.List)
monitors.GET("/:id/status", h.ChannelMonitor.GetStatus)
}
// V2 passive views require feature on + mode=v2.
monitorV2 := authenticated.Group("/channel-monitor-v2")
monitorV2.Use(panelRateLimiter.Heavy())
monitorV2.Use(channelMonitorModeV2Guard(settingService))
{
monitorV2.GET("/dimensions", h.ChannelMonitorV2.Dimensions)
monitorV2.GET("/snapshot", h.ChannelMonitorV2.Snapshot)
monitorV2.GET("/models", h.ChannelMonitorV2.Models)
monitorV2.GET("/matrix", h.ChannelMonitorV2.Matrix)
monitorV2.GET("/errors", h.ChannelMonitorV2.Errors)
monitorV2.GET("/users", h.ChannelMonitorV2.Users)
}
}
}