Files
FlowStack/backend/main.go
T
Logan.Li 97193eb090 feat: v0.4.0 主题切换 + LLM 全面优化 + 登录会话 + 用户管理/LDAP
主题:
- 深色/浅色/跟随系统(useTheme:本地缓存先行+远端同步+matchMedia),
  侧边栏快捷切换,设置页三档即选即生效

LLM(参考 opsxdb/aiops):
- llm.Client.ChatStream 流式(SSE 增量解析,透传 reasoning_content,
  ctx 控制超时);POST /api/ai/chat/stream(平台实时数据注入降幻觉,
  未配 LLM 降级规则 JSON,指令解析 action 不变)
- test-llm 支持草稿直测(保存前验证,apiKey 空/掩码回退已存)
- 发布失败自动 AI 分析日志尾部(结论/根因/建议),发布记录与发布
  Tab 展示分析卡片
- 设置页 Provider 预设(DeepSeek/GLM/GLM编码/Qwen/Kimi/Ollama/自定义)
  自动填 endpoint+模型+获取 Key 直达链接

登录与会话:
- HMAC 签名 token(7 天,密钥派生自 encryption.key);
  authMiddleware 保护 /api/*(豁免登录/健康/SSE日志流/入站webhook)
- 登录页 /login + 未登录跳转 + 登出;401 自动清理跳登录

用户管理与 LDAP(借鉴 LokiStack):
- /users 页面:本地用户 CRUD、一次性密码、角色切换、来源徽章
  (本地·蓝 | LDAP导入·紫);LDAP 导入按钮仅在启用且配置齐备时显示
- LDAP 配置卡(未启用仅提示语)、测试连接草稿直测+可选测试账号
- go-ldap:管理 Bind/用户搜索(防注入)/用户 DN 直连验证;
  批量导入冲突策略(已LDAP跳过/本地同名转LDAP/新建);
  登录分流本地 argon2id 或 LDAP 目录验证 + 首登 JIT 建号
2026-10-27 12:58:21 +08:00

181 lines
6.1 KiB
Go
Raw Permalink 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 main
import (
"embed"
"io/fs"
"net/http"
"os"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"flowstack/internal/database"
"flowstack/internal/handler"
)
// 前端产物在构建期拷贝到 static/ 并嵌入二进制(run.sh build)
//
//go:embed all:static
var staticFS embed.FS
func main() {
if err := database.Init(); err != nil {
panic("数据库初始化失败: " + err.Error())
}
hub := handler.NewHub()
r := gin.Default()
// 会话鉴权:所有 /api 需登录(豁免登录/健康检查/入站 Webhook/SSE 日志流)
r.Use(handler.AuthMiddleware())
// ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存
// (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS)
r.Use(func(c *gin.Context) {
if c.Query("_clear") != "" {
c.Header("Clear-Site-Data", "\"cache\"")
}
c.Next()
})
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"*"},
AllowCredentials: false,
}))
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "app": "flowstack"})
})
api := r.Group("/api")
{
api.POST("/auth/login", handler.Login)
api.POST("/auth/logout", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) })
// 用户管理(管理员)
users := api.Group("/users", handler.AdminOnly())
{
users.GET("", handler.ListUsers)
users.POST("", handler.CreateUser)
users.PUT("/:id", handler.UpdateUser)
users.DELETE("/:id", handler.DeleteUser)
users.POST("/:id/reset-password", handler.ResetPassword)
users.POST("/ldap-import", handler.LdapImport)
}
api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers)
api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP)
api.GET("/summary", handler.GetSummary)
api.GET("/settings", handler.GetSettings)
api.PUT("/settings", handler.UpdateSettings)
api.POST("/settings/test-llm", handler.TestLLM)
api.POST("/settings/test-lark", handler.TestLark)
api.POST("/ai/chat", handler.AiChat)
api.POST("/ai/chat/stream", handler.AiChatStream)
api.POST("/ai/release", handler.AiRelease(hub))
api.POST("/batch/parse", handler.BatchParse)
api.GET("/pipelines", handler.ListPipelines)
api.POST("/pipelines", handler.CreatePipeline)
api.GET("/pipelines/:id", handler.GetPipelineDetail)
api.PUT("/pipelines/:id", handler.UpdatePipeline)
api.DELETE("/pipelines/:id", handler.DeletePipeline)
api.PUT("/pipelines/:id/nodes", handler.SaveNodes)
api.PUT("/pipelines/:id/edges", handler.SaveEdges)
api.POST("/pipelines/:id/runs", handler.CreateRun(hub))
api.POST("/hooks/pipeline/:id", handler.InboundPipelineHook(hub))
api.GET("/runs", handler.ListRuns)
api.GET("/runs/:runId", handler.GetRun)
api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub))
api.POST("/runs/:runId/retry", handler.RetryRun(hub))
api.GET("/gitops/apps", handler.ListGitOpsApps)
api.GET("/gitops/argo/apps", handler.ArgoList)
api.POST("/gitops/argo/sync", handler.ArgoSync)
api.GET("/gitops/argo/:appName/tree", handler.ArgoResourceTree)
api.GET("/gitops/argo/:appName/diff", handler.ArgoDiff)
api.GET("/plugins", handler.ListPlugins)
api.GET("/plugins/available", handler.AvailablePlugins)
api.POST("/plugins/install", handler.InstallPlugin)
api.POST("/plugins/:id/toggle", handler.TogglePlugin)
api.DELETE("/plugins/:id", handler.UninstallPlugin)
api.GET("/hosts", handler.ListHosts)
api.POST("/hosts", handler.CreateHost)
api.PUT("/hosts/:id", handler.UpdateHost)
api.DELETE("/hosts/:id", handler.DeleteHost)
api.POST("/hosts/:id/test", handler.TestHost)
api.GET("/clusters", handler.ListClusters)
api.POST("/clusters", handler.CreateCluster)
api.PUT("/clusters/:id", handler.UpdateCluster)
api.DELETE("/clusters/:id", handler.DeleteCluster)
api.POST("/clusters/:id/test", handler.TestCluster)
api.GET("/credentials", handler.ListCredentials)
api.POST("/credentials", handler.CreateCredential)
api.PUT("/credentials/:id", handler.UpdateCredential)
api.DELETE("/credentials/:id", handler.DeleteCredential)
api.GET("/build-nodes", handler.ListBuildNodes)
api.POST("/build-nodes", handler.CreateBuildNode)
api.PUT("/build-nodes/:id", handler.UpdateBuildNode)
api.DELETE("/build-nodes/:id", handler.DeleteBuildNode)
api.POST("/build-nodes/:id/test", handler.TestBuildNode)
api.POST("/build-nodes/:id/disk-test", handler.TestBuildNodeDisk)
api.GET("/tools", handler.ListTools)
api.PUT("/tools/:id", handler.UpdateTool)
api.POST("/tools/:id/check", handler.CheckTool)
api.POST("/tools/:id/install", handler.InstallTool)
api.GET("/webhooks", handler.ListWebhooks)
api.POST("/webhooks", handler.CreateWebhook)
api.PUT("/webhooks/:id", handler.UpdateWebhook)
api.DELETE("/webhooks/:id", handler.DeleteWebhook)
api.POST("/webhooks/:id/test", handler.TestWebhook)
api.GET("/jenkins-servers", handler.ListJenkinsServers)
api.POST("/jenkins-servers", handler.CreateJenkinsServer)
api.PUT("/jenkins-servers/:id", handler.UpdateJenkinsServer)
api.DELETE("/jenkins-servers/:id", handler.DeleteJenkinsServer)
api.POST("/jenkins-servers/:id/test", handler.TestJenkinsServer)
api.GET("/jenkins-servers/:id/jobs", handler.ListJenkinsJobs)
api.POST("/jenkins-servers/:id/import", handler.ImportJenkinsJobs)
}
distFS, _ := fs.Sub(staticFS, "static")
r.NoRoute(func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path != "" {
if f, err := distFS.Open(path); err == nil {
f.Close()
c.FileFromFS("/"+path, http.FS(distFS))
return
}
}
// SPA 路由回退:直接返回 index.html 内容
// (不能用 FileFromFS,http.FileServer 会把 /index.html 301 到 ./ 造成死循环)
index, err := fs.ReadFile(distFS, "index.html")
if err != nil {
c.String(http.StatusNotFound, "前端资源未构建")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
})
port := os.Getenv("PORT")
if port == "" {
port = "9527"
}
if err := r.Run(":" + port); err != nil {
panic("服务启动失败: " + err.Error())
}
}