Initial commit: TaskPool React panel

- React frontend with route-level code splitting
- Backend rebranded from Baihu to TaskPool
- DB brand migration script and local compatibility
This commit is contained in:
2026-07-26 08:43:52 +08:00
commit e6956aa001
397 changed files with 73621 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
package router
import (
"github.com/engigu/taskpool/internal/middleware"
"github.com/gin-gonic/gin"
)
func initPublicAPIRoutes(api *gin.RouterGroup, c *Controllers) {
// Health check (无需认证)
api.GET("/ping", func(ctx *gin.Context) {
ctx.JSON(200, gin.H{"message": "pong"})
})
// Install routes (无需认证,仅在未安装时可用)
install := api.Group("/install")
{
install.GET("/status", c.Install.GetInstallStatus)
install.POST("", c.Install.Install)
}
// api.GET("/debug/goroutines", func(ctx *gin.Context) {
// buf := make([]byte, 1024*1024)
// n := runtime.Stack(buf, true)
// ctx.Data(200, "text/plain; charset=utf-8", buf[:n])
// })
// Authentication routes (无需认证)
auth := api.Group("/auth")
{
auth.POST("/login", c.Auth.Login)
auth.POST("/logout", c.Auth.Logout)
// auth.POST("/register", c.Auth.Register)
}
// 公开的站点设置(无需认证)
api.GET("/settings/public", c.Settings.GetPublicSiteSettings)
// 隧道模式 (被控端反向连入,使用独立 Token 做 WebSocket 鉴权)
api.GET("/interconnect/tunnel", c.Interconnect.HandleTunnel)
// 子节点主动上报监控数据 (无中间件鉴权,内部鉴权)
api.POST("/interconnect/report", c.Interconnect.ReportMonitorData)
// 内部使用的 API(仅限本地调用,无需 Bearer 认证)
internalAPI := api.Group("/internal")
internalAPI.Use(middleware.LocalhostOnly())
{
internalAPI.POST("/tasks/sync-repo-status", c.Task.SyncRepoTasks)
internalAPI.POST("/tasks/execute/:id", c.Executor.ExecuteTask)
internalAPI.POST("/tasks/toggle/:id", c.Task.ToggleTask)
}
}
func initAuthorizedAPIRoutes(api *gin.RouterGroup, c *Controllers) {
authorized := api.Group("")
authorized.Use(middleware.AuthRequired())
{
// 获取当前用户 (普通用户即可访问)
authorized.GET("/auth/me", c.Auth.GetCurrentUser)
// 以下管理接口需要管理员权限
adminOnly := authorized.Group("")
adminOnly.Use(middleware.AdminRequired())
{
registerDashboardRoutes(adminOnly, c)
registerTaskRoutes(adminOnly, c)
registerEnvRoutes(adminOnly, c)
registerScriptRoutes(adminOnly, c)
registerFileRoutes(adminOnly, c)
registerLogRoutes(adminOnly, c)
registerTerminalRoutes(adminOnly, c)
registerSettingsRoutes(adminOnly, c)
registerDependencyRoutes(adminOnly, c)
registerAgentRoutes(adminOnly, c)
registerMiseRoutes(adminOnly, c)
registerNotificationRoutes(adminOnly, c)
registerAppLogRoutes(adminOnly, c)
registerSystemWSRoutes(adminOnly, c)
registerWebUIRoutes(adminOnly, c)
registerMonitorRoutes(adminOnly, c)
registerInterconnectRoutes(adminOnly, c)
registerSystemRoutes(adminOnly, c)
}
}
// 通知发送 API(使用通知 Token 认证,供脚本调用)
notifyAPI := api.Group("/notify")
notifyAPI.Use(middleware.NotifyTokenAuth())
{
notifyAPI.POST("/send", c.Notification.SendNotification)
}
}
func registerDashboardRoutes(g *gin.RouterGroup, c *Controllers) {
g.GET("/stats", c.Dashboard.GetStats)
g.GET("/sentence", c.Dashboard.GetSentence)
g.GET("/sendstats", c.Dashboard.GetSendStats)
g.GET("/taskstats", c.Dashboard.GetTaskStats)
}
func registerTaskRoutes(g *gin.RouterGroup, c *Controllers) {
tasks := g.Group("/tasks")
{
tasks.POST("", c.Task.CreateTask)
tasks.GET("", c.Task.GetTasks)
tasks.GET("/:id", c.Task.GetTask)
tasks.POST("/bulk_save", c.Task.BulkSaveTask)
tasks.PUT("/:id", c.Task.UpdateTask)
tasks.DELETE("/:id", c.Task.DeleteTask)
tasks.POST("/batch-delete", c.Task.BatchDeleteTasks)
tasks.DELETE("/batch-by-query", c.Task.BatchDeleteByQuery)
tasks.POST("/stop/:logID", c.Task.StopTask)
tasks.GET("/tags", c.Task.GetTags)
}
execution := g.Group("/execute")
{
execution.POST("/task/:id", c.Executor.ExecuteTask)
execution.POST("/command", c.Executor.ExecuteCommand)
execution.GET("/results", c.Executor.GetLastResults)
}
}
func registerEnvRoutes(g *gin.RouterGroup, c *Controllers) {
env := g.Group("/env")
{
env.GET("/secret-status", c.Env.GetSecretStatus)
env.GET("/tags", c.Env.GetTags)
env.POST("", c.Env.CreateEnvVar)
env.POST("/bulk_save", c.Env.BulkSaveEnv)
env.GET("", c.Env.GetEnvVars)
env.GET("/all", c.Env.GetAllEnvVars)
env.GET("/:id", c.Env.GetEnvVar)
env.GET("/:id/tasks", c.Env.GetAssociatedTasks)
env.PUT("/:id", c.Env.UpdateEnvVar)
env.DELETE("/:id", c.Env.DeleteEnvVar)
}
}
func registerScriptRoutes(g *gin.RouterGroup, c *Controllers) {
scripts := g.Group("/scripts")
{
scripts.POST("", c.Script.CreateScript)
scripts.GET("", c.Script.GetScripts)
scripts.GET("/:id", c.Script.GetScript)
scripts.PUT("/:id", c.Script.UpdateScript)
scripts.DELETE("/:id", c.Script.DeleteScript)
}
}
func registerFileRoutes(g *gin.RouterGroup, c *Controllers) {
files := g.Group("/files")
{
files.GET("/tree", c.File.GetFileTree)
files.GET("/content", c.File.GetFileContent)
files.GET("/download", c.File.DownloadFile)
files.GET("/download-zip", c.File.DownloadZip)
files.POST("/content", c.File.SaveFileContent)
files.POST("/create", c.File.CreateFile)
files.POST("/delete", c.File.DeleteFile)
files.POST("/rename", c.File.RenameFile)
files.POST("/move", c.File.MoveFile)
files.POST("/copy", c.File.CopyFile)
files.POST("/upload", c.File.UploadArchive)
files.POST("/uploadfiles", c.File.UploadFiles)
}
}
func registerLogRoutes(g *gin.RouterGroup, c *Controllers) {
logs := g.Group("/logs")
{
logs.GET("", c.Log.GetLogs)
logs.POST("/clear", c.Log.ClearLogs)
logs.GET("/sse", c.LogSSE.StreamLog)
logs.GET("/:id", c.Log.GetLogDetail)
logs.DELETE("/:id", c.Log.DeleteLog)
}
}
func registerTerminalRoutes(g *gin.RouterGroup, c *Controllers) {
g.GET("/terminal/ws", c.Terminal.HandleWebSocket)
// g.POST("/terminal/exec", c.Terminal.ExecuteShellCommand) // 暂未使用,已注释
g.GET("/terminal/cmds", c.Terminal.GetCommands)
}
func registerSettingsRoutes(g *gin.RouterGroup, c *Controllers) {
settings := g.Group("/settings")
{
settings.POST("/password", c.Settings.ChangePassword)
settings.GET("/site", c.Settings.GetSiteSettings)
settings.PUT("/site", c.Settings.UpdateSiteSettings)
settings.POST("/site/openapi-token/generate", c.Settings.GenerateOpenapiToken)
settings.GET("/paths", c.Settings.GetPaths)
settings.GET("/scheduler", c.Settings.GetSchedulerSettings)
settings.PUT("/scheduler", c.Settings.UpdateSchedulerSettings)
settings.GET("/about", c.Settings.GetAbout)
settings.GET("/changelog", c.Settings.GetChangelog)
settings.GET("/loginlogs", c.Settings.GetLoginLogs)
settings.POST("/backup", c.Settings.CreateBackup)
settings.GET("/backup/status", c.Settings.GetBackupStatus)
settings.GET("/backup/download", c.Settings.DownloadBackup)
settings.POST("/restore", c.Settings.RestoreBackup)
// 通用设置接口
settings.GET("/:section", c.Settings.GetSectionSettings)
settings.PUT("/:section", c.Settings.UpdateSectionSettings)
settings.GET("/:section/:key", c.Settings.GetSetting)
settings.POST("/:section/:key/generate", c.Settings.GenerateSettingToken)
}
}
func registerDependencyRoutes(g *gin.RouterGroup, c *Controllers) {
deps := g.Group("/deps")
{
deps.GET("", c.Dependency.List)
deps.POST("", c.Dependency.Create)
deps.DELETE("/:id", c.Dependency.Delete)
deps.POST("/install", c.Dependency.Install)
deps.POST("/install-cmd", c.Dependency.GetInstallCommand)
deps.POST("/uninstall/:id", c.Dependency.Uninstall)
deps.POST("/reinstall/:id", c.Dependency.Reinstall)
deps.POST("/reinstall-all", c.Dependency.ReinstallAll)
deps.POST("/reinstall-all-cmd", c.Dependency.GetReinstallAllCommand)
deps.POST("/batch-install-cmd", c.Dependency.GetBatchInstallCommand)
deps.POST("/import", c.Dependency.ParseAndImport)
deps.GET("/installed", c.Dependency.GetInstalled)
deps.GET("/install-suggest-cmd", c.Dependency.GetDepInstallCommand)
}
}
func registerAgentRoutes(g *gin.RouterGroup, c *Controllers) {
agents := g.Group("/agents")
{
agents.GET("", c.Agent.List)
agents.GET("/version", c.Agent.GetVersion)
agents.PUT("/:id", c.Agent.Update)
agents.DELETE("/:id", c.Agent.Delete)
agents.POST("/:id/token", c.Agent.RegenerateToken)
agents.POST("/:id/update", c.Agent.ForceUpdate)
// 令牌管理
agents.GET("/tokens", c.Agent.ListTokens)
agents.POST("/tokens", c.Agent.CreateToken)
agents.DELETE("/tokens/:id", c.Agent.DeleteToken)
}
// Agent API(供前端调用,保持在 v1 下)
agentAPIv1 := g.Group("/agent")
{
agentAPIv1.GET("/download", c.Agent.Download)
}
}
func registerMiseRoutes(g *gin.RouterGroup, c *Controllers) {
mise := g.Group("/mise")
{
mise.GET("/ls", c.Mise.List)
mise.POST("/sync", c.Mise.Sync)
mise.GET("/plugins", c.Mise.Plugins)
mise.GET("/versions", c.Mise.Versions)
mise.GET("/verify-cmd", c.Mise.VerifyCommand)
mise.POST("/use-global", c.Mise.UseGlobal)
mise.POST("/unset-global", c.Mise.UnsetGlobal)
mise.GET("/envs", c.Mise.Envs)
mise.POST("/envs", c.Mise.SetEnv)
mise.DELETE("/envs", c.Mise.UnsetEnv)
}
}
func registerNotificationRoutes(g *gin.RouterGroup, c *Controllers) {
notify := g.Group("/notify")
{
notify.GET("/types", c.Notification.GetChannelTypes)
notify.GET("/channels", c.Notification.GetChannels)
notify.POST("/channels", c.Notification.SaveChannel)
notify.DELETE("/channels/:id", c.Notification.DeleteChannel)
notify.POST("/channels/test", c.Notification.TestChannel)
notify.GET("/bindings", c.Notification.GetBindings)
notify.POST("/bindings", c.Notification.SaveBinding)
notify.POST("/bindings/batch", c.Notification.BatchSaveBindings)
notify.DELETE("/bindings/:id", c.Notification.DeleteBinding)
}
}
func registerAppLogRoutes(g *gin.RouterGroup, c *Controllers) {
appLogs := g.Group("/app-logs")
{
appLogs.GET("", c.AppLog.GetLogs)
appLogs.POST("/read", c.AppLog.MarkAsRead)
appLogs.POST("/clear", c.AppLog.ClearLogs)
}
}
func registerSystemWSRoutes(g *gin.RouterGroup, c *Controllers) {
g.GET("/ws/events", c.SystemWS.HandleEvents)
}
func registerMonitorRoutes(g *gin.RouterGroup, c *Controllers) {
monitor := g.Group("/monitor")
{
monitor.GET("", c.Monitor.GetSystemMonitor)
monitor.GET("/sse", c.Monitor.MonitorSSE)
}
}
func initAgentAPIRoutes(root *gin.RouterGroup, c *Controllers) {
// Agent API(供远程 Agent 调用,不使用 /v1 版本号)
agentAPI := root.Group("/api/agent")
{
agentAPI.POST("/heartbeat", c.Agent.Heartbeat)
agentAPI.GET("/tasks", c.Agent.GetTasks)
agentAPI.POST("/report", c.Agent.ReportResult)
agentAPI.GET("/download", c.Agent.Download) // 也在这里注册,兼容 Agent 调用
agentAPI.GET("/ws", c.Agent.WSConnect) // WebSocket 连接
}
}
func registerWebUIRoutes(g *gin.RouterGroup, c *Controllers) {
webuiGroup := g.Group("/webui")
{
webuiGroup.GET("", c.WebUI.GetWebUIs)
webuiGroup.POST("/upload", c.WebUI.UploadWebUI)
webuiGroup.PUT("/active", c.WebUI.SetActiveWebUI)
webuiGroup.DELETE("/:name", c.WebUI.DeleteWebUI)
}
}
func registerInterconnectRoutes(g *gin.RouterGroup, c *Controllers) {
interconnect := g.Group("/interconnect")
{
interconnect.GET("/nodes", c.Interconnect.GetNodes)
interconnect.POST("/nodes", c.Interconnect.CreateNode)
interconnect.PUT("/nodes/:id", c.Interconnect.UpdateNode)
interconnect.DELETE("/nodes/:id", c.Interconnect.DeleteNode)
interconnect.GET("/nodes/:id/status", c.Interconnect.GetNodeStatus)
interconnect.POST("/sync/script", c.Interconnect.SyncScript)
interconnect.POST("/sync/env", c.Interconnect.SyncEnv)
interconnect.POST("/sync/task", c.Interconnect.SyncTask)
interconnect.GET("/child/status", c.Interconnect.GetChildStatus)
// 代理模式 (面板穿越)
interconnect.Any("/proxy/:node_id/*path", c.Interconnect.ProxyRequest)
}
}
func registerSystemRoutes(g *gin.RouterGroup, c *Controllers) {
systemAPI := g.Group("/system")
{
systemAPI.POST("/export", c.Data.ExportBusinessData)
systemAPI.POST("/import", c.Data.ImportBusinessData)
}
}
+28
View File
@@ -0,0 +1,28 @@
package router
import (
// "fmt"
// "github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/eventbus"
// "github.com/engigu/taskpool/internal/logger"
// "github.com/engigu/taskpool/internal/models"
"github.com/engigu/taskpool/internal/services"
"github.com/engigu/taskpool/internal/executor"
)
func setupEventHandlers(subscribers ...eventbus.Subscriber) {
bus := eventbus.DefaultBus
// 遍历并统一初始化所有订阅者的事件链路
for _, s := range subscribers {
s.SubscribeEvents(bus)
}
}
func startAppLogCleanup(appLogSvc *services.AppLogService) {
// 注册到内部系统定时器(并立即执行第一次)
executor.GetSysCron().AddJobWithRun("@every 1h", func() {
appLogSvc.CleanUp()
})
}
+84
View File
@@ -0,0 +1,84 @@
package router
import (
"github.com/engigu/taskpool/internal/middleware"
"github.com/gin-gonic/gin"
)
// initOpenAPIV1Routes 初始化 OpenAPI v1 路由
// 只注册有 @Tags OpenAPI 注释的接口
func initOpenAPIV1Routes(root *gin.RouterGroup, c *Controllers) {
// OpenAPI v1 路由组 (使用 Bearer Token)
open := root.Group("/open2api/v1")
open.Use(middleware.OpenapiRequired())
{
// 任务相关接口
registerOpenAPITaskRoutes(open, c)
// 环境变量相关接口
registerOpenAPIEnvRoutes(open, c)
// 脚本相关接口
registerOpenAPIScriptRoutes(open, c)
// 日志相关接口
registerOpenAPILogRoutes(open, c)
// 任务执行相关接口
registerOpenAPIExecutorRoutes(open, c)
}
}
// registerOpenAPITaskRoutes 注册 OpenAPI 任务路由(只包含有 @Tags OpenAPI 注释的接口)
func registerOpenAPITaskRoutes(g *gin.RouterGroup, c *Controllers) {
tasks := g.Group("/tasks")
{
tasks.POST("", c.Task.CreateTask)
tasks.GET("", c.Task.GetTasks)
tasks.GET("/:id", c.Task.GetTask)
tasks.PUT("/:id", c.Task.UpdateTask)
tasks.DELETE("/:id", c.Task.DeleteTask)
tasks.POST("/stop/:logID", c.Task.StopTask)
tasks.GET("/tags", c.Task.GetTags)
}
}
// registerOpenAPIEnvRoutes 注册 OpenAPI 环境变量路由(只包含有 @Tags OpenAPI 注释的接口)
func registerOpenAPIEnvRoutes(g *gin.RouterGroup, c *Controllers) {
env := g.Group("/env")
{
env.POST("", c.Env.CreateEnvVar)
env.GET("", c.Env.GetEnvVars)
env.GET("/all", c.Env.GetAllEnvVars)
env.GET("/:id", c.Env.GetEnvVar)
env.GET("/:id/tasks", c.Env.GetAssociatedTasks)
env.PUT("/:id", c.Env.UpdateEnvVar)
env.DELETE("/:id", c.Env.DeleteEnvVar)
}
}
// registerOpenAPILogRoutes 注册 OpenAPI 日志路由(只包含有 @Tags OpenAPI 注释的接口)
func registerOpenAPILogRoutes(g *gin.RouterGroup, c *Controllers) {
logs := g.Group("/logs")
{
logs.GET("", c.Log.GetLogs)
logs.GET("/:id", c.Log.GetLogDetail)
}
}
// registerOpenAPIExecutorRoutes 注册 OpenAPI 任务执行路由(只包含有 @Tags OpenAPI 注释的接口)
func registerOpenAPIExecutorRoutes(g *gin.RouterGroup, c *Controllers) {
execution := g.Group("/execute")
{
execution.POST("/task/:id", c.Executor.ExecuteTask)
execution.GET("/results", c.Executor.GetLastResults)
}
}
// registerOpenAPIScriptRoutes 注册 OpenAPI 脚本路由
func registerOpenAPIScriptRoutes(g *gin.RouterGroup, c *Controllers) {
scripts := g.Group("/scripts")
{
scripts.POST("", c.Script.CreateScript)
scripts.GET("", c.Script.GetScripts)
scripts.GET("/:id", c.Script.GetScript)
scripts.PUT("/:id", c.Script.UpdateScript)
scripts.DELETE("/:id", c.Script.DeleteScript)
}
}
+83
View File
@@ -0,0 +1,83 @@
package router
import (
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/controllers"
"github.com/engigu/taskpool/internal/services"
"github.com/engigu/taskpool/internal/services/tasks"
)
var executorService *tasks.ExecutorService
func RegisterControllers() *Controllers {
// 初始化服务
settingsService := services.NewSettingsService()
loginLogService := services.NewLoginLogService()
// 执行系统初始化(返回 userService
initService := services.NewInitService(settingsService)
userService := initService.Initialize()
taskService := tasks.NewTaskService()
envService := services.NewEnvService()
scriptService := services.NewScriptService()
sendStatsService := services.NewSendStatsService()
agentWSManager := services.GetAgentWSManager()
systemWSManager := services.GetSystemWSManager()
taskLogService := tasks.NewTaskLogService(sendStatsService)
// 创建任务执行服务(需要依赖注入)
notifyService := services.NewNotificationService()
appLogService := services.NewAppLogService()
interconnectService := services.NewInterconnectService()
// 清理 task 运行状态的任务可以直接由 executorService 承担或在此处通过 Database 直接清理
// 简单期间,我们使用一个新方法 tasks.CleanupRunningTasks() 或者让 executorService 启动时清理
executorService = tasks.NewExecutorService(taskService, taskLogService, agentWSManager, settingsService, envService)
// 启动时清理残留的运行状态
_ = executorService.CleanupRunningTasks()
// 启动计划任务
executorService.StartCron()
// 初始化所有关注系统总线的服务
setupEventHandlers(appLogService, notifyService, loginLogService, systemWSManager)
startAppLogCleanup(appLogService)
taskController := controllers.NewTaskController(taskService, executorService)
envController := controllers.NewEnvController(envService)
// 初始化并返回控制器
return &Controllers{
Task: taskController,
Auth: controllers.NewAuthController(userService, settingsService, loginLogService),
Env: envController,
Script: controllers.NewScriptController(scriptService),
Executor: controllers.NewExecutorController(executorService),
File: controllers.NewFileController(constant.ScriptsWorkDir),
Dashboard: controllers.NewDashboardController(executorService),
Log: controllers.NewLogController(),
LogSSE: controllers.NewLogSSEController(),
Terminal: controllers.NewTerminalController(envService),
Settings: controllers.NewSettingsController(userService, loginLogService, executorService),
Dependency: controllers.NewDependencyController(),
Agent: controllers.NewAgentController(settingsService),
Mise: controllers.NewMiseController(services.NewMiseService()),
Notification: controllers.NewNotificationController(),
AppLog: controllers.NewAppLogController(),
SystemWS: controllers.NewSystemWSController(),
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
Monitor: controllers.NewMonitorController(executorService),
Interconnect: controllers.NewInterconnectController(interconnectService),
Data: controllers.NewDataController(taskController, envController),
Install: controllers.NewInstallController(),
}
}
// StopCron 停止计划任务服务
func StopCron() {
if executorService != nil {
executorService.Stop()
}
}
+120
View File
@@ -0,0 +1,120 @@
package router
import (
"os"
"strings"
"github.com/engigu/taskpool/internal/controllers"
"github.com/engigu/taskpool/internal/middleware"
"github.com/engigu/taskpool/internal/services"
"github.com/gin-contrib/pprof"
"github.com/gin-gonic/gin"
)
type Controllers struct {
Task *controllers.TaskController
Auth *controllers.AuthController
Env *controllers.EnvController
Script *controllers.ScriptController
Executor *controllers.ExecutorController
File *controllers.FileController
Dashboard *controllers.DashboardController
Log *controllers.LogController
LogSSE *controllers.LogSSEController
Terminal *controllers.TerminalController
Settings *controllers.SettingsController
Dependency *controllers.DependencyController
Agent *controllers.AgentController
Mise *controllers.MiseController
Notification *controllers.NotificationController
AppLog *controllers.AppLogController
SystemWS *controllers.SystemWSController
WebUI *controllers.WebUIController
Monitor *controllers.MonitorController
Interconnect *controllers.InterconnectController
Data *controllers.DataController
Install *controllers.InstallController
}
func Setup(c *Controllers) *gin.Engine {
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(middleware.GinLogger(), middleware.GinRecovery())
router.Use(middleware.TravelProxyMiddleware())
// 获取 URL 前缀
cfg := services.GetConfig()
urlPrefix := strings.TrimSuffix(cfg.Server.URLPrefix, "/")
// 创建一个路由组,如果有前缀则使用前缀,否则使用根路径
var root *gin.RouterGroup
if urlPrefix != "" {
root = router.Group(urlPrefix)
} else {
root = router.Group("")
}
// 按需绑定 Pprof 调试路由 (注册在 root 下以支持 URLPrefix)
if cfg.Server.PprofEnabled {
// pprof.RouteRegister 会在传入的路由组下注册 /debug/pprof 等路由
pprof.RouteRegister(root)
}
// =========================================================================
// 路由分类组装 (对应 Nginx 的 location 块分发)
// =========================================================================
// 1. [ location /assets ] 静态资源路由
initStaticRoutes(root)
// 3. [ location /api ] 内部 API 路由组
apiV1 := root.Group("/api/v1")
initPublicAPIRoutes(apiV1, c) // 公开接口 (无需认证)
initAuthorizedAPIRoutes(apiV1, c) // 授权接口 (需 JWT)
// 4. [ location /api/agent ] Agent 相关 API 路由组
initAgentAPIRoutes(root, c)
initOpenAPIV1Routes(root, c)
// =========================================================================
// [ location / ] 全局 404 兜底与 SPA 渲染
// 对应 Nginx: try_files $uri $uri/ /index.html;
// =========================================================================
router.NoRoute(func(ctx *gin.Context) {
path := ctx.Request.URL.Path
// 如果配置了前缀,只处理带前缀的路径
if urlPrefix != "" && !strings.HasPrefix(path, urlPrefix) {
ctx.Status(404)
return
}
// 解析实际的相对路径
relPath := strings.TrimPrefix(path, urlPrefix)
if !strings.HasPrefix(relPath, "/") {
relPath = "/" + relPath
}
// 拦截器:不该返回 index.html 的情况
// 如果该请求被识别为 API 请求、静态资源请求,或者是带有明确文件后缀(如 .js / .css / .png)的物理文件请求
// 都不应该返回 SPA 页面(会报前端 MIME 类型错误),而是直接掐断返回 404
hasAnyExt := false
if idx := strings.LastIndex(relPath, "."); idx > 0 && len(relPath)-idx < 6 {
// 简单判断是否有后缀(如 .js, .css)
hasAnyExt = true
}
if strings.HasPrefix(relPath, "/api/") || strings.HasPrefix(relPath, "/assets/") || strings.HasPrefix(relPath, "/debug/") || hasAnyExt {
ctx.String(404, "404 Not Found")
return
}
// 其他所有有效的前端页面路径(如 /tasks, /settings),都返回 index.html 交给 vue-router 处理
serveSPA(ctx, urlPrefix, 200)
})
return router
}
+268
View File
@@ -0,0 +1,268 @@
package router
import (
"compress/gzip"
"encoding/json"
"io"
"io/fs"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/services"
"github.com/engigu/taskpool/internal/static"
"github.com/gin-gonic/gin"
)
// cacheControl 返回设置 Cache-Control header 的中间件
func cacheControl(value string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", value)
c.Next()
}
}
func openFileWithWebui(filename string) (fs.File, error) {
webuiSvc := services.NewWebUIService(services.NewSettingsService())
if customFS := webuiSvc.GetActiveWebUIFS(); customFS != nil {
// 如果启用了定义的前端包,去取定义的路径
return customFS.Open(filename)
}
// 如果是默认的,取默认路径
defaultFS := static.GetFS()
if defaultFS == nil {
return nil, fs.ErrNotExist
}
return defaultFS.Open(filename)
}
func readFileWithWebui(filename string) ([]byte, error) {
webuiSvc := services.NewWebUIService(services.NewSettingsService())
if customFS := webuiSvc.GetActiveWebUIFS(); customFS != nil {
// 如果启用了定义的前端包,去取定义的路径
return fs.ReadFile(customFS, filename)
}
// 如果是默认的,取默认路径
defaultFS := static.GetFS()
if defaultFS == nil {
return nil, fs.ErrNotExist
}
return fs.ReadFile(defaultFS, filename)
}
func initStaticRoutes(root *gin.RouterGroup) {
// 专门处理 /assets 目录下的资源
root.GET("/assets/*filepath", cacheControl("public, max-age=31536000, immutable"), func(ctx *gin.Context) {
fullPath := "assets" + ctx.Param("filepath")
fullPath = strings.TrimPrefix(fullPath, "/")
isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip")
gzPath := fullPath + ".gz"
// 确定 MIME 类型
ext := filepath.Ext(fullPath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
switch ext {
case ".js":
contentType = "application/javascript"
case ".css":
contentType = "text/css"
case ".svg":
contentType = "image/svg+xml"
default:
contentType = "application/octet-stream"
}
}
// 优先尝试读取 .gz 文件
if gzFile, err := openFileWithWebui(gzPath); err == nil {
defer gzFile.Close()
ctx.Header("Content-Type", contentType)
if isGzipSupported {
// 极致性能:流式透传压缩包 (RSS 占用极低)
ctx.Header("Content-Encoding", "gzip")
ctx.Status(http.StatusOK)
io.Copy(ctx.Writer, gzFile)
} else {
// 兼容处理:流式解压发送
gr, _ := gzip.NewReader(gzFile)
defer gr.Close()
ctx.Status(http.StatusOK)
io.Copy(ctx.Writer, gr)
}
return
}
// 如果没有 .gz,流式读取原文件
if file, err := openFileWithWebui(fullPath); err == nil {
defer file.Close()
ctx.Header("Content-Type", contentType)
ctx.Status(http.StatusOK)
io.Copy(ctx.Writer, file)
return
}
ctx.Status(404)
})
// logo.svg 等单文件处理
root.GET("/logo.svg", func(ctx *gin.Context) {
settings := services.NewSettingsService()
icon := settings.Get(constant.SectionSite, constant.KeyIcon)
if icon != "" {
ctx.Header("Cache-Control", "public, max-age=86400")
ctx.Data(http.StatusOK, "image/svg+xml", []byte(icon))
return
}
serveSingleFile(ctx, "logo.svg", "image/svg+xml", "public, max-age=86400")
})
// PWA 相关路由处理
initPWARoutes(root)
}
func initPWARoutes(root *gin.RouterGroup) {
// PWA 相关文件处理
pwaRootFiles := map[string]string{
"/sw.js": "application/javascript",
"/registerSW.js": "application/javascript",
"/favicon.ico": "image/x-icon",
"/pwa-icon-192.png": "image/png",
"/pwa-icon-512.png": "image/png",
}
for path, contentType := range pwaRootFiles {
pPath := path
pType := contentType
root.GET(pPath, func(ctx *gin.Context) {
file := strings.TrimPrefix(pPath, "/")
serveSingleFile(ctx, file, pType, "public, no-cache")
})
}
// 动态 manifest 处理 (支持由 Go 后端控制标题和图标)
root.GET("/manifest.webmanifest", handleManifest)
// 动态匹配 workbox-*.js (Vite PWA 生成的库文件)
root.GET("/workbox-:hash.js", func(ctx *gin.Context) {
file := "workbox-" + ctx.Param("hash") + ".js"
serveSingleFile(ctx, file, "application/javascript", "public, max-age=31536000, immutable")
})
}
func handleManifest(ctx *gin.Context) {
// 读取原始 manifest
data, err := readFileWithWebui("manifest.webmanifest")
if err != nil {
ctx.Status(404)
return
}
var manifest map[string]interface{}
if err := json.Unmarshal(data, &manifest); err != nil {
// 如果解析失败,回退到原始文件
ctx.Data(200, "application/manifest+json", data)
return
}
// 注入后端配置的标题
settings := services.NewSettingsService()
title := settings.Get(constant.SectionSite, constant.KeyTitle)
if title != "" {
manifest["name"] = title
manifest["short_name"] = title
}
// 注入后端配置的图标 (首选 logo.svg)
manifest["icons"] = []map[string]interface{}{
{
"src": "/logo.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable",
},
}
res, _ := json.Marshal(manifest)
ctx.Header("Cache-Control", "public, no-cache")
ctx.Data(200, "application/manifest+json", res)
}
func serveSingleFile(ctx *gin.Context, filename string, contentType string, cache string) {
if cache != "" {
ctx.Header("Cache-Control", cache)
}
ctx.Header("Content-Type", contentType)
isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip")
// 尝试流式发送压缩版
if gzFile, err := openFileWithWebui(filename + ".gz"); err == nil {
defer gzFile.Close()
if isGzipSupported {
ctx.Header("Content-Encoding", "gzip")
ctx.Status(200)
io.Copy(ctx.Writer, gzFile)
} else {
gr, _ := gzip.NewReader(gzFile)
defer gr.Close()
ctx.Status(200)
io.Copy(ctx.Writer, gr)
}
return
}
// 尝试流式发送原版
if file, err := openFileWithWebui(filename); err == nil {
defer file.Close()
ctx.Status(200)
io.Copy(ctx.Writer, file)
return
}
ctx.Status(404)
}
// serveSPA 注入配置并返回 index.html 给前端渲染
func serveSPA(ctx *gin.Context, urlPrefix string, status int) {
var data []byte
// index.html 较小且需要修改字符串,可以一次性读入内存
if gzFile, err := openFileWithWebui("index.html.gz"); err == nil {
defer gzFile.Close()
gr, _ := gzip.NewReader(gzFile)
data, _ = io.ReadAll(gr)
gr.Close()
} else if file, err := openFileWithWebui("index.html"); err == nil {
defer file.Close()
data, _ = io.ReadAll(file)
}
if data == nil {
ctx.String(status, "index.html not found.")
return
}
html := string(data)
baseHref := urlPrefix + "/"
if urlPrefix == "" {
baseHref = "/"
}
html = strings.Replace(html, "<head>", "<head>\n <base href=\""+baseHref+"\">", 1)
configScript := `<script>window.__BASE_URL__ = "` + urlPrefix + `"; window.__API_VERSION__ = "/api/v1";</script>`
html = strings.Replace(html, "</head>", configScript+"</head>", 1)
ctx.Header("Content-Type", "text/html; charset=utf-8")
ctx.Header("Cache-Control", "no-cache, no-store, must-revalidate")
ctx.Data(status, "text/html; charset=utf-8", []byte(html))
}