Files
team-api/internal/cmd/cmd.go
T
martin a6ed67d841 fix: 完成 P3 细节优化项
依据《代码分析结果.md》P3 章节,逐条修复 20 项细节问题:

正确性修复:
- P3-1 锁定剩余分钟改用 math.Ceil,避免「0.9 分钟」显示为「0 分钟」
- P3-12 移除 CheckAndUpgradeLevel 中恒真的 hasUpdate 标志
- P3-14 移除 CheckTrialExpiry 中恒真表达式(count > 0 || true)调试残留,改用 RowsAffected
- P3-2 成员不存在统一返回 NewNotFoundError(404),与其它方法语义一致

安全加固:
- P3-6 X-Request-Id 加格式校验 ^[A-Za-z0-9-]{1,64}$,防日志注入
- P3-7 新增公共 EscapeLikePattern,转义 admin LIKE 搜索的 %/_ 通配符
- P3-8 Encrypt 输出加 v1: 版本前缀,Decrypt 兼容无前缀历史密文
- P3-9 turnstile 响应体读取加 64KB 上限,防内存耗尽
- P3-11 backup codes 熵由 48-bit 提升到 64-bit
- P3-15 recordAudit 对 multipart/二进制体跳过 Unmarshal 与原文落库
- P3-17 错误日志按 Content-Type 跳过 multipart/二进制体读取
- P3-16 HandleRealtime 加 panic recover 与连接生命周期日志

可维护性/一致性:
- P3-3 oauth.go 时间格式统一为 time.RFC3339
- P3-5 MsgSuccess 统一为 ok,response.go 改用常量引用
- P3-4 错误码分段策略加注释,说明空洞与跳跃的成因
- P3-13 ModelMultiplier 占位语义加注释(计费公式设计位 + 快照列)
- P3-19 抽取 registerCronJobs,主启动流程清爽

重构:
- P3-18 删除死代码 BuildBillingSummary/SummaryParams(仅测试引用),保留共用 helper
- P3-20 抽取 relay.InvalidateMemberModelScopeCache 单一失效入口,
  消除三处 member_model 前缀字面量重复(原为有意跨包失效,原建议改名会破坏)

经核实保留 + 文档化:
- P3-10 TOTP 暂保留 SHA-1:totp.Validate 硬编码 SHA-1 且存储不保存算法元信息,
  直接切 SHA-256 会导致校验恒失败,需配合 schema 改动,超出 P3 范围
2026-07-26 12:16:33 +08:00

505 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"context"
"fmt"
"io/fs"
"net/http"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcmd"
"github.com/qianfree/team-api/internal/consts"
adminController "github.com/qianfree/team-api/internal/controller/admin"
captchaController "github.com/qianfree/team-api/internal/controller/captcha"
docsController "github.com/qianfree/team-api/internal/controller/docs"
openController "github.com/qianfree/team-api/internal/controller/open"
settingsController "github.com/qianfree/team-api/internal/controller/settings"
tenantController "github.com/qianfree/team-api/internal/controller/tenant"
"github.com/qianfree/team-api/internal/logic/admin"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/monitor"
relayLogic "github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/internal/logic/task"
"github.com/qianfree/team-api/internal/logic/tenant"
"github.com/qianfree/team-api/internal/logic/update"
"github.com/qianfree/team-api/internal/middleware"
"github.com/qianfree/team-api/internal/response"
"github.com/qianfree/team-api/internal/utility/crypto"
adminHandler "github.com/qianfree/team-api/internal/handler/admin"
"github.com/qianfree/team-api/internal/handler/public"
"github.com/qianfree/team-api/internal/handler/relay"
setupHandler "github.com/qianfree/team-api/internal/handler/setup"
"github.com/qianfree/team-api/internal/plugin"
"github.com/qianfree/team-api/web"
)
var (
Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start http server of team-api",
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
s := g.Server()
printBanner()
// Auto-migrate database schema
if err := runAutoMigrate(ctx); err != nil {
return err
}
// Health check: Redis
if err := checkRedis(ctx); err != nil {
g.Log().Fatalf(ctx, "Redis 连接检查失败,程序退出: %v", err)
}
g.Log().Info(ctx, "Redis 连接正常")
// Initialize JWT secret
common.InitJWTSecret(ctx)
// 启动阶段校验加密密钥,避免运行时请求路径 panic(#3)
if hexKey := g.Cfg().MustGet(ctx, "crypto.encryptionKey").String(); hexKey == "" {
g.Log().Fatal(ctx, "crypto.encryptionKey 未配置,程序退出(运行 openssl rand -hex 32 生成)")
} else if _, keyErr := crypto.GetEncryptionKey(hexKey); keyErr != nil {
g.Log().Fatalf(ctx, "crypto.encryptionKey 配置无效,程序退出: %v", keyErr)
}
g.Log().Info(ctx, "加密密钥校验通过")
// System initialization: env auto-init or detect setup mode
autoInit, initErr := admin.AutoInitAdmin(ctx)
if initErr != nil {
g.Log().Errorf(ctx, "auto init admin: %v", initErr)
}
if !autoInit {
exists, checkErr := admin.AdminExists(ctx)
if checkErr != nil {
g.Log().Errorf(ctx, "check admin existence: %v", checkErr)
} else if !exists {
setupHandler.SetSetupMode(true)
g.Log().Info(ctx, "系统未初始化,进入设置模式 — 请访问 /setup 完成初始化")
}
}
// Initialize config service
common.Config().Warmup(ctx)
common.Config().StartSubscriber(ctx)
// C1: 通用 Cache 的跨实例 L1 失效订阅(cache:invalidate)
common.StartCacheInvalidationSubscriber(ctx)
// Initialize content filter engine
common.InitContentFilter(ctx)
// Initialize monitoring collector
monitor.InitCollector(ctx)
monitor.InitRequestTracker()
// Ensure partitioned tables have current+future partitions
if partitionErr := common.EnsurePartitions(ctx); partitionErr != nil {
g.Log().Errorf(ctx, "ensure partitions: %v", partitionErr)
}
// Initialize async usage log writer
common.InitUsageLogWriter()
// Initialize async channel error writer
common.InitChannelErrorWriter()
// Initialize async error log writer
response.InitErrorLogWriter()
// Register cron jobs
common.InitCronScheduler()
cs := common.GetCronScheduler()
registerCronJobs(cs)
cs.StartBackground(ctx)
// Initialize update manager
update.InitManager(ctx)
update.CheckPendingVerification(ctx)
// Initialize plugin system (must be after CronScheduler init)
pluginApp := &plugin.App{
Server: s,
DB: g.DB(),
Redis: g.Redis(),
Hook: plugin.GlobalEmitter(),
}
if pluginErr := plugin.Bootstrap(ctx, pluginApp); pluginErr != nil {
g.Log().Errorf(ctx, "plugin bootstrap: %v", pluginErr)
}
// Global middleware
s.Use(middleware.Recovery)
s.Use(middleware.RequestId)
// Setup mode guard: block all requests until initialization is complete
s.Use(func(r *ghttp.Request) {
if !setupHandler.IsSetupMode() {
r.Middleware.Next()
return
}
// Double-check DB in case setup was completed by another request/instance
exists, _ := admin.AdminExists(r.Context())
if exists {
setupHandler.SetSetupComplete()
r.Middleware.Next()
return
}
// Allow setup and health endpoints
if r.URL.Path == "/setup" || r.URL.Path == "/api/setup/initialize" || r.URL.Path == "/api/setup/status" || r.URL.Path == "/api/health" {
r.Middleware.Next()
return
}
// Block everything else
accept := r.Header.Get("Accept")
if len(accept) >= 4 && accept[:4] == "text" {
r.Response.RedirectTo("/setup")
} else {
r.Response.WriteJson(g.Map{
"code": consts.CodeSetupNotInitialized,
"message": consts.MsgSetupNotInitialized,
"data": nil,
"request_id": r.GetCtxVar("RequestId"),
})
}
r.Exit()
})
// Setup routes (always registered, guard decides if accessible)
s.Group("/", func(group *ghttp.RouterGroup) {
group.GET("/setup", setupHandler.HandleSetupPage)
})
s.Group("/api/setup", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ErrorHandler)
group.GET("/status", setupHandler.HandleSetupStatus)
group.POST("/initialize", setupHandler.HandleSetupInitialize)
})
// Health check endpoint (always accessible, used by Docker/K8s probes)
s.Group("/api", func(group *ghttp.RouterGroup) {
group.GET("/health", func(r *ghttp.Request) {
r.Response.WriteJson(g.Map{
"status": "ok",
"version": consts.Version,
})
})
})
// Register route groups
s.Group("/api", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.MiddlewareHandlerResponse)
// Admin — public endpoints use g.Meta middleware:"-" to skip auth
group.Group("/admin", func(g *ghttp.RouterGroup) {
g.Middleware(middleware.DemoMode, middleware.AdminAuth, middleware.AdminPermissionGuard, middleware.OperationLog)
g.Bind(adminController.NewV1())
})
// Tenant — public endpoints use g.Meta middleware:"-" to skip auth
group.Group("/tenant", func(g *ghttp.RouterGroup) {
g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency)
g.Bind(tenantController.NewV1())
})
// Payment callbacks — manual registration (raw string response, not JSON)
group.Middleware(middleware.ErrorHandler)
registerPaymentCallbacks(group)
// Captcha — public, no auth required (shared by admin + tenant)
group.Group("/captcha", func(g *ghttp.RouterGroup) {
g.Bind(captchaController.NewV1())
})
// Settings — public settings, no auth required
group.Group("/settings", func(g *ghttp.RouterGroup) {
g.Bind(settingsController.NewV1())
})
// Docs — public OpenAPI spec
group.Group("/docs", func(g *ghttp.RouterGroup) {
g.Bind(docsController.NewV1())
})
})
// File streaming serve — admin auth required, bypasses JSON response wrapper.
// 流式返回二进制(local 存储模式 + 未来应用层代理下载),不能走统一 JSON 响应格式,
// 故独立注册路由组(不套 MiddlewareHandlerResponse),只套鉴权与审计中间件。
s.Group("/api/admin/files", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.AdminAuth, middleware.AdminPermissionGuard, middleware.OperationLog)
group.GET("/{id}/serve", adminHandler.HandleFileServe)
})
// Open Platform API — HMAC-SHA256 authentication
s.Group("/api/open", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.MiddlewareHandlerResponse)
group.Middleware(middleware.OpenPlatformAuth)
group.Bind(openController.NewV1())
})
// AI proxy endpoints (OpenAI compatible, /v1/xxx)
registerRelayRoutes(s)
// Register plugin routes
plugin.RegisterAllRoutes(ctx, s)
// Embedded frontend SPA serving
registerFrontendRoutes(s)
// Migrate encryption key (legacy default → configured key)
relayLogic.MigrateEncryptionKey(ctx)
// Initialize active task count and start polling
task.InitActiveCount(ctx)
task.StartAsyncPolling(ctx)
// Start sync-image async worker pool (wraps synchronous image providers as async tasks)
task.StartSyncImageWorkers(ctx)
// Start webhook dispatcher (event-driven delivery)
tenant.InitWebhookDispatcher(ctx)
// 注入 webhook 发布函数到 billing 包(解耦循环依赖)
billing.SetPublishWebhookEventFn(tenant.PublishWebhookEvent)
// Graceful shutdown order (defers run LIFO after s.Run returns):
// 1) 先排空任务池(StopSyncImageWorkers / StopAsyncPolling)——它们收尾时会写
// 用量日志 / 审计 / 结算;此时异步 Writer 必须仍存活。
// 2) 再 flush 关闭异步 Writer。
// 3) 最后关 webhook / plugin。
// 因此把两个 task.Stop* 注册在最后(最先执行),Writer 关闭注册在前(后执行)。
defer plugin.Shutdown(ctx)
defer tenant.ShutdownWebhookDispatcher()
defer common.CloseChannelErrorWriter()
defer common.CloseUsageLogWriter()
defer response.CloseErrorLogWriter()
defer task.StopAsyncPolling()
defer task.StopSyncImageWorkers()
s.Run()
return nil
},
}
)
// printBanner prints the startup banner with copyright information.
func printBanner() {
cyan := "\x1b[36;1m"
green := "\x1b[32m"
dim := "\x1b[2m"
reset := "\x1b[0m"
fmt.Println()
fmt.Printf(" %s████████╗███████╗ █████╗ ███╗ ███╗ █████╗ ██████╗ ██╗%s\n", cyan, reset)
fmt.Printf(" %s╚══██╔══╝██╔════╝██╔══██╗████╗ ████║ ██╔══██╗██╔══██╗██║%s\n", cyan, reset)
fmt.Printf(" %s ██║ █████╗ ███████║██╔████╔██║█████╗███████║██████╔╝██║%s\n", cyan, reset)
fmt.Printf(" %s ██║ ██╔══╝ ██╔══██║██║╚██╔╝██║╚════╝██╔══██║██╔═══╝ ██║%s\n", cyan, reset)
fmt.Printf(" %s ██║ ███████╗██║ ██║██║ ╚═╝ ██║ ██║ ██║██║ ██║%s\n", cyan, reset)
fmt.Printf(" %s ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝%s\n", cyan, reset)
fmt.Printf(" %sTeam-API%s %s%s%s %s| %s%s企业级大模型 API 网关系统%s\n", cyan, reset, green, consts.Version, reset, dim, reset, dim, reset)
fmt.Printf(" %shttps://github.com/qianfree/team-api%s\n", dim, reset)
fmt.Println()
fmt.Printf(" %sAGPL v3.0 开源协议 | Copyright © 2025-2026 Team-API Contributors%s\n", dim, reset)
fmt.Println()
}
// registerCronJobs 集中注册所有定时任务,避免散落在主启动流程中。
// 新增 cron 任务时在此追加一项即可;任务名(用于分布式锁 key)需保持唯一且稳定。
func registerCronJobs(cs *common.CronScheduler) {
cs.Register("ops_system_collector", "* * * * *", func(ctx context.Context) error {
return monitor.CollectSystemMetrics(ctx)
})
cs.Register("ops_alert_detector", "* * * * *", func(ctx context.Context) error {
return monitor.RunAlertDetection(ctx)
})
cs.Register("ops_metrics_cleanup", "0 3 * * *", func(ctx context.Context) error {
return monitor.CleanupOldMetrics(ctx)
})
cs.Register("partition_ensure", "0 2 * * *", func(ctx context.Context) error {
return common.EnsurePartitions(ctx)
})
cs.Register("health_snapshot", "*/5 * * * *", func(ctx context.Context) error {
return task.SnapshotHealthScores(ctx)
})
cs.Register("channel_auto_test", "*/5 * * * *", func(ctx context.Context) error {
if common.Config().GetBool(ctx, "channel_auto_test_enabled") {
task.AutoTestChannels(ctx)
}
return nil
})
cs.Register("model_sunset_check", "0 0 * * *", func(ctx context.Context) error {
return task.CheckModelSunset(ctx)
})
cs.Register("data_cleanup", "0 3 * * *", func(ctx context.Context) error {
return admin.CleanupExpiredData(ctx)
})
cs.Register("export_file_cleanup", "0 4 * * *", func(ctx context.Context) error {
return admin.CleanupExpiredExportFiles(ctx)
})
cs.Register("file_retention_check", "0 5 * * *", func(ctx context.Context) error {
return admin.CheckFileRetention(ctx)
})
cs.Register("task_timeout_check", "*/10 * * * *", func(ctx context.Context) error {
return admin.MarkStuckTasksFailed(ctx)
})
cs.Register("task_executor", "*/1 * * * *", func(ctx context.Context) error {
task.RunPendingTasks(ctx)
return nil
})
cs.Register("project_budget_check", "*/5 * * * *", func(ctx context.Context) error {
return tenant.CheckBudgetExhausted(ctx)
})
cs.Register("usage_log_cleanup", "0 3 * * *", func(ctx context.Context) error {
retentionDays := common.Config().GetInt(ctx, "usage_log_retention_days")
if retentionDays == 0 {
retentionDays = 90
}
return task.ScheduleAutoCleanup(ctx, retentionDays)
})
cs.Register("oauth_token_refresh", "*/10 * * * *", func(ctx context.Context) error {
return task.RefreshExpiringOAuthTokens(ctx)
})
cs.Register("prededuct_orphan_cleanup", "*/2 * * * *", func(ctx context.Context) error {
billing.CleanExpiredPreDeducts(ctx)
return nil
})
cs.Register("prededuct_tracks_cleanup", "0 4 * * *", func(ctx context.Context) error {
billing.CleanSettledPreDeductTracks(ctx)
return nil
})
cs.Register("update_check", "0 */6 * * *", func(ctx context.Context) error {
if common.Config().GetBool(ctx, "update_auto_check_enabled") {
return update.BackgroundCheck(ctx)
}
return nil
})
cs.Register("order_expiration", "*/5 * * * *", func(ctx context.Context) error {
return task.ExpirePendingOrders(ctx)
})
}
// registerPaymentCallbacks registers payment callback routes.
func registerPaymentCallbacks(group *ghttp.RouterGroup) {
group.Group("/payment", func(g *ghttp.RouterGroup) {
g.POST("/callback/{channel}", public.HandlePaymentCallback)
g.GET("/callback/{channel}", public.HandlePaymentCallback)
g.GET("/epay/return", public.HandlePaymentEpayReturn)
g.POST("/epay/return", public.HandlePaymentEpayReturn)
})
}
// registerRelayRoutes registers AI proxy routes (/v1/xxx).
func registerRelayRoutes(server *ghttp.Server) {
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.POST("/chat/completions", relay.HandleChatCompletions)
group.GET("/models", relay.HandleModels)
group.GET("/models/{model_id}", relay.HandleModelDetail)
group.POST("/embeddings", relay.HandleEmbeddings)
group.POST("/images/generations", relay.HandleImagesGenerations)
group.POST("/completions", relay.HandleCompletions)
group.POST("/responses", relay.HandleResponses)
group.POST("/responses/compact", relay.HandleResponses)
group.POST("/messages", relay.HandleMessages)
group.POST("/audio/speech", relay.HandleAudioSpeech)
group.POST("/audio/transcriptions", relay.HandleAudioTranscription)
group.POST("/audio/translations", relay.HandleAudioTranslation)
group.POST("/rerank", relay.HandleRerank)
group.POST("/moderations", relay.HandleModerations)
group.POST("/images/edits", relay.HandleImagesEdits)
group.GET("/realtime", relay.HandleRealtime)
})
// Gemini 兼容路由(/v1beta/models/{model}:generateContent)
server.Group("/v1beta", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.GET("/models", relay.HandleGeminiModels)
group.GET("/models/{model}", relay.HandleGeminiModelDetail)
group.POST("/models/{model}", relay.HandleGeminiGenerateContent)
})
// 异步任务端点(视频/音乐生成)
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.POST("/video/generations", relay.HandleTaskSubmit)
group.GET("/video/generations/{task_id}", relay.HandleTaskFetch)
})
// 异步图片生成端点(阿里云 DashScope 等)
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.POST("/images/generations/async", relay.HandleAliImageSubmit)
group.GET("/images/generations/async/{task_id}", relay.HandleTaskFetch)
})
// Suno 端点
server.Group("/suno", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.POST("/submit/{action}", relay.HandleTaskSubmit)
group.POST("/fetch", relay.HandleSunoFetchBatch)
group.GET("/fetch/{task_id}", relay.HandleTaskFetch)
})
}
// registerFrontendRoutes serves embedded frontend SPA assets.
// Admin console at /admin, tenant console at / (catch-all).
// Existing API routes take priority over these wildcard routes.
// When built without the "embedweb" tag, this is a no-op.
func registerFrontendRoutes(s *ghttp.Server) {
if !web.Enabled {
return
}
adminSub, _ := fs.Sub(web.AdminFS, "admin/dist")
tenantSub, _ := fs.Sub(web.TenantFS, "tenant/dist")
// Admin SPA: /admin/* → web/admin/dist/
s.Group("/admin", func(group *ghttp.RouterGroup) {
group.ALL("/*any", ghttp.WrapF(spaHandler(adminSub, "/admin")))
})
// Tenant SPA: /* → web/tenant/dist/ (lowest priority catch-all)
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/*any", ghttp.WrapF(spaHandler(tenantSub, "")))
})
}
// spaHandler returns an http.HandlerFunc that serves static files from the
// given filesystem, falling back to index.html for SPA client-side routing.
func spaHandler(root fs.FS, prefix string) http.HandlerFunc {
fileServer := http.FileServer(http.FS(root))
if prefix != "" {
fileServer = http.StripPrefix(prefix, fileServer)
}
return func(w http.ResponseWriter, r *http.Request) {
// Resolve the file path within the embedded FS
path := strings.TrimPrefix(r.URL.Path, prefix)
path = strings.TrimPrefix(path, "/")
if path == "" {
path = "index.html"
}
// Try to open the file; if it exists, serve it directly
if f, err := root.Open(path); err == nil {
f.Close()
fileServer.ServeHTTP(w, r)
return
}
// File not found — serve index.html (SPA fallback)
indexBytes, err := fs.ReadFile(root, "index.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexBytes)
}
}