package handler import ( "context" "net/http" "time" "github.com/gin-gonic/gin" "flowstack/internal/crypto" "flowstack/internal/database" "flowstack/internal/llm" "flowstack/internal/model" ) // ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息 func ListPipelines(c *gin.Context) { var list []model.Pipeline database.DB.Order("updated_at DESC").Find(&list) // 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需) var runs []model.PipelineRun database.DB.Order("started_at DESC").Limit(500).Find(&runs) lastByPipeline := make(map[uint]model.PipelineRun) for _, r := range runs { if _, ok := lastByPipeline[r.PipelineID]; !ok { lastByPipeline[r.PipelineID] = r } } type pipelineWithLastRun struct { model.Pipeline LastRun *model.PipelineRun `json:"lastRun"` } result := make([]pipelineWithLastRun, 0, len(list)) for _, p := range list { item := pipelineWithLastRun{Pipeline: p} if r, ok := lastByPipeline[p.ID]; ok { item.LastRun = &r } result = append(result, item) } c.JSON(http.StatusOK, result) } // GetPipelineDetail GET /api/pipelines/:id —— 完整详情(编辑器各 Tab 数据源) func GetPipelineDetail(c *gin.Context) { var p model.Pipeline if err := database.DB.First(&p, c.Param("id")).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"}) return } var nodes []model.PipelineNode database.DB.Where("pipeline_id = ?", p.ID).Order("position_x").Find(&nodes) var edges []model.PipelineEdge database.DB.Where("pipeline_id = ?", p.ID).Find(&edges) var lastRun model.PipelineRun hasRun := database.DB.Where("pipeline_id = ?", p.ID).Order("started_at DESC").First(&lastRun).Error == nil c.JSON(http.StatusOK, gin.H{ "pipeline": p, "nodes": nodes, "edges": edges, "lastRun": func() *model.PipelineRun { if hasRun { return &lastRun } return nil }(), }) } // GetSummary GET /api/summary —— 侧边栏徽标与首页统计 func GetSummary(c *gin.Context) { var buildNodes, plugins, running, pipelines, hosts, clusters int64 database.DB.Model(&model.BuildNode{}).Count(&buildNodes) database.DB.Model(&model.Plugin{}).Count(&plugins) database.DB.Model(&model.PipelineRun{}).Where("status IN ('pending','queued','running')").Count(&running) database.DB.Model(&model.Pipeline{}).Count(&pipelines) database.DB.Model(&model.Host{}).Count(&hosts) database.DB.Model(&model.K8sCluster{}).Count(&clusters) c.JSON(http.StatusOK, gin.H{ "buildNodes": buildNodes, "plugins": plugins, "running": running, "pipelines": pipelines, "hosts": hosts, "clusters": clusters, }) } // GetSettings GET /api/settings —— 单行设置 func GetSettings(c *gin.Context) { var s model.SystemSetting if err := database.DB.First(&s, 1).Error; err != nil { s = model.SystemSetting{ID: 1, LlmEndpoint: "https://api.deepseek.com/v1", LlmModel: "deepseek-chat", LlmMaxTokens: 1200} database.DB.Create(&s) } s.LlmApiKeySet = s.LlmApiKey != "" c.JSON(http.StatusOK, s) } // UpdateSettings PUT /api/settings func UpdateSettings(c *gin.Context) { var s model.SystemSetting if err := database.DB.First(&s, 1).Error; err != nil { s = model.SystemSetting{ID: 1} database.DB.Create(&s) } var body map[string]any if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } updates := map[string]any{"updated_at": time.Now()} if v, ok := body["theme"].(string); ok { updates["theme"] = v } if v, ok := body["notification"].(string); ok { updates["notification"] = v } if v, ok := body["toolMode"].(string); ok { updates["tool_mode"] = v } if v, ok := body["extra"].(string); ok { updates["extra"] = v } if v, ok := body["llmEnabled"].(bool); ok { updates["llm_enabled"] = v } if v, ok := body["llmEndpoint"].(string); ok { updates["llm_endpoint"] = v } if v, ok := body["llmModel"].(string); ok { updates["llm_model"] = v } if v, ok := body["llmMaxTokens"].(float64); ok { updates["llm_max_tokens"] = int(v) } // API Key 只写不读:非空才更新 if v, ok := body["llmApiKey"].(string); ok && v != "" { enc, err := crypto.EncryptField(v) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "API Key 加密失败"}) return } updates["llm_api_key"] = enc } database.DB.Model(&s).Updates(updates) database.DB.First(&s, 1) s.LlmApiKeySet = s.LlmApiKey != "" c.JSON(http.StatusOK, s) } // TestLLM POST /api/settings/test-llm —— 用已保存配置做 ping 对话 func TestLLM(c *gin.Context) { var s model.SystemSetting if err := database.DB.First(&s, 1).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请先保存设置"}) return } if !s.LlmEnabled || s.LlmApiKey == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "AI 未启用或未配置 API Key"}) return } apiKey := s.LlmApiKey if crypto.IsEncrypted(apiKey) { if plain, err := crypto.DecryptField(apiKey); err == nil { apiKey = plain } } client := llm.NewClient(s.LlmEndpoint, apiKey, s.LlmModel, 128) ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() reply, err := client.Chat(ctx, "你是连通性检查助手,只回复:连接成功。", "ping") if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试失败: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "message": reply}) } // CreatePipeline POST /api/pipelines func CreatePipeline(c *gin.Context) { var input struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Type string `json:"type"` SourceRepo string `json:"sourceRepo"` SourceBranch string `json:"sourceBranch"` DeployType string `json:"deployType"` Env string `json:"env"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "name 不能为空"}) return } setDefault(&input.Type, "native") setDefault(&input.SourceBranch, "main") setDefault(&input.DeployType, "host") setDefault(&input.Env, "staging") p := model.Pipeline{ Name: input.Name, Description: input.Description, Type: input.Type, SourceRepo: input.SourceRepo, SourceBranch: input.SourceBranch, DeployType: input.DeployType, Env: input.Env, } if err := database.DB.Create(&p).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, p) } // UpdatePipeline PUT /api/pipelines/:id func UpdatePipeline(c *gin.Context) { var p model.Pipeline if err := database.DB.First(&p, c.Param("id")).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"}) return } var input map[string]any if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } allowed := map[string]bool{ "name": true, "description": true, "sourceRepo": true, "sourceBranch": true, "gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true, "triggerMode": true, "cronExpr": true, "webhookToken": true, "watchBranches": true, "variables": true, "cachePaths": true, "notifyWebhookURL": true, "jenkinsJobName": true, } updates := map[string]any{} for k, v := range input { if allowed[k] { updates[k] = v } } if len(updates) > 0 { updates["updated_at"] = time.Now() database.DB.Model(&p).Updates(updates) } c.JSON(http.StatusOK, p) } // DeletePipeline DELETE /api/pipelines/:id —— 级联删除节点与连线 func DeletePipeline(c *gin.Context) { id := c.Param("id") database.DB.Where("pipeline_id = ?", id).Delete(&model.PipelineEdge{}) database.DB.Where("pipeline_id = ?", id).Delete(&model.PipelineNode{}) database.DB.Delete(&model.Pipeline{}, id) c.JSON(http.StatusOK, gin.H{"success": true}) } // SaveNodes PUT /api/pipelines/:id/nodes —— 全量覆盖保存 func SaveNodes(c *gin.Context) { pipelineID := c.Param("id") var p model.Pipeline if err := database.DB.First(&p, pipelineID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"}) return } var body struct { Nodes []model.PipelineNode `json:"nodes"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } database.DB.Where("pipeline_id = ?", p.ID).Delete(&model.PipelineNode{}) for i := range body.Nodes { body.Nodes[i].ID = 0 body.Nodes[i].PipelineID = p.ID database.DB.Create(&body.Nodes[i]) } database.DB.Model(&p).Update("updated_at", time.Now()) c.JSON(http.StatusOK, gin.H{"success": true}) } // SaveEdges PUT /api/pipelines/:id/edges —— 全量覆盖保存 func SaveEdges(c *gin.Context) { pipelineID := c.Param("id") var p model.Pipeline if err := database.DB.First(&p, pipelineID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"}) return } var body struct { Edges []model.PipelineEdge `json:"edges"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } database.DB.Where("pipeline_id = ?", p.ID).Delete(&model.PipelineEdge{}) for i := range body.Edges { body.Edges[i].ID = 0 body.Edges[i].PipelineID = p.ID database.DB.Create(&body.Edges[i]) } database.DB.Model(&p).Update("updated_at", time.Now()) c.JSON(http.StatusOK, gin.H{"success": true}) } func setDefault(v *string, def string) { if *v == "" { *v = def } }