Files
FlowStack/backend/internal/handler/jenkins.go
T
Logan.Li d9d1dd5e99 feat: 发布成功/失败飞书&Lark 群卡片通知
- internal/lark:飞书自定义机器人交互卡片(wide_screen_mode + 彩色头部
  green/red/yellow + lark_md 字段区 + 查看构建详情按钮),支持签名加签
  (timestamp+sign HMAC-SHA256,密钥 enc:v1: 加密存储)
- 双引擎收口:原生模拟引擎与 Jenkins 真实引擎运行结束均推送卡片;
  应用/分支/构建(#N 或 runId)/状态/时间;Jenkins 构建附
  externalURL 按钮直达构建详情页
- Webhook 优先级:流水线「通知 Webhook」> 全局设置(需启用)
- 系统设置新增飞书通知配置区(启用开关/Webhook/签名密钥只写/
  发送测试卡片);POST /api/settings/test-lark
- E2E 已验证:本地模拟机器人收到完整卡片 JSON,测试卡片正常
2026-10-27 09:37:23 +08:00

249 lines
7.4 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 handler
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/jenkins"
"flowstack/internal/model"
)
func clientFromServer(s model.JenkinsServer) *jenkins.Client {
token := s.Token
if crypto.IsEncrypted(token) {
if plain, err := crypto.DecryptField(token); err == nil {
token = plain
}
}
return jenkins.NewClient(s.BaseURL, s.Username, token)
}
// ---- Jenkins 服务器 CRUD ----
func ListJenkinsServers(c *gin.Context) {
var list []model.JenkinsServer
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].TokenSet = list[i].Token != ""
list[i].Token = ""
}
c.JSON(http.StatusOK, list)
}
func CreateJenkinsServer(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
baseURL, _ := raw["baseUrl"].(string)
if name == "" || baseURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和地址不能为空"})
return
}
s := model.JenkinsServer{Name: name, BaseURL: baseURL, Enabled: true}
s.Username, _ = raw["username"].(string)
if token, ok := raw["token"].(string); ok && token != "" {
enc, err := crypto.EncryptField(token)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token 加密失败"})
return
}
s.Token = enc
}
if err := database.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
s.TokenSet = s.Token != ""
s.Token = ""
c.JSON(http.StatusOK, s)
}
func UpdateJenkinsServer(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
updates := map[string]any{}
for _, k := range []string{"name", "baseUrl", "username", "enabled", "remark"} {
if v, ok := raw[k]; ok {
key := k
if k == "baseUrl" {
key = "base_url"
}
updates[key] = v
}
}
if token, ok := raw["token"].(string); ok && token != "" {
if enc, err := crypto.EncryptField(token); err == nil {
updates["token"] = enc
}
}
if len(updates) > 0 {
database.DB.Model(&s).Updates(updates)
}
database.DB.First(&s, s.ID)
s.TokenSet = s.Token != ""
s.Token = ""
c.JSON(http.StatusOK, s)
}
func DeleteJenkinsServer(c *gin.Context) {
database.DB.Delete(&model.JenkinsServer{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestJenkinsServer POST /api/jenkins-servers/:id/test —— 真实连通测试
func TestJenkinsServer(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
start := time.Now()
err := clientFromServer(s).Test()
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "latencyMs": time.Since(start).Milliseconds(),
"message": func() string {
if err != nil {
return "连接失败: " + err.Error()
}
return "连接成功,认证通过"
}(),
})
}
// ListJenkinsJobs GET /api/jenkins-servers/:id/jobs —— 真实拉取 Job 树(folder 已压平)
func ListJenkinsJobs(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
jobs, err := clientFromServer(s).ListJobs()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取 Job 失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, jobs)
}
// ImportJenkinsJobs POST /api/jenkins-servers/:id/import —— 导入 Job 为 jenkins 类型流水线
func ImportJenkinsJobs(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
var body struct {
Jobs []string `json:"jobs"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的 Job"})
return
}
imported, skipped := 0, 0
for _, jobPath := range body.Jobs {
var exist model.Pipeline
if err := database.DB.Where("jenkins_job_name = ? AND jenkins_server_id = ?", jobPath, s.ID).First(&exist).Error; err == nil {
skipped++
continue
}
name := jobPath
if len(name) > 60 {
name = name[len(name)-60:]
}
p := model.Pipeline{
Name: name, Description: "从 Jenkins 导入 (" + s.Name + ")",
Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging",
JenkinsJobName: jobPath, JenkinsServerID: s.ID,
}
// 名称冲突时追加后缀保证唯一
base := name
for i := 0; ; i++ {
var cnt int64
database.DB.Model(&model.Pipeline{}).Where("name = ?", p.Name).Count(&cnt)
if cnt == 0 {
break
}
if i >= 5 {
p.Name = fmt.Sprintf("%s-%d", base, time.Now().UnixMilli())
break
}
p.Name = fmt.Sprintf("%s-%d", base, i+1)
}
database.DB.Create(&p)
imported++
}
c.JSON(http.StatusOK, gin.H{"success": true, "imported": imported, "skipped": skipped})
}
// jenkinsRunEngine jenkins 类型流水线的真实执行引擎:
// 触发 → 队列 → 增量控制台 → 结果映射,全程写 LogEntry + SSE 推送
func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, jobName string) {
start := time.Now()
emit := func(msg string) {
entry := model.LogEntry{RunID: run.ID, StageID: 0, Level: "info", Message: msg, Source: "jenkins", Timestamp: time.Now()}
database.DB.Create(&entry)
hub.Publish(run.RunID, "log", entry)
}
setRun := func(status string) {
now := time.Now()
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": &now,
})
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
if status == "success" || status == "failed" {
NotifyWebhookEvent("pipeline."+status, map[string]any{
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
})
}
// 发布结束后回查最新构建信息再推飞书卡片(含 buildNumber/externalURL)
var fresh model.PipelineRun
database.DB.First(&fresh, run.ID)
go notifyLarkRelease(fresh, status)
}
emit("$ jenkins build \"" + jobName + "\"")
queueURL, err := client.Trigger(jobName, nil)
if err != nil {
emit("触发失败: " + err.Error())
setRun("failed")
return
}
emit("已进入 Jenkins 构建队列…")
buildNumber, buildURL, err := client.WaitQueue(queueURL, 10*time.Minute, emit)
if err != nil {
emit("队列等待失败: " + err.Error())
setRun("failed")
return
}
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"build_number": buildNumber, "external_run_id": fmt.Sprintf("#%d", buildNumber), "external_url": buildURL,
})
emit(fmt.Sprintf("构建 #%d 已开始: %s", buildNumber, buildURL))
client.StreamConsole(buildURL, emit)
result, err := client.WaitResult(buildURL, 30*time.Minute)
if err != nil {
emit("获取构建结果超时: " + err.Error())
setRun("failed")
return
}
emit("Jenkins 结果: " + result)
setRun(jenkins.MapResult(result))
}