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
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:
@@ -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/deepseek;zhipu 无余额端点)。
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user