- 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,测试卡片正常
249 lines
7.4 KiB
Go
249 lines
7.4 KiB
Go
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))
|
||
}
|
||
|