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,测试卡片正常
This commit is contained in:
Logan.Li committed 2026-10-27 09:37:23 +08:00
1 parent 6e97b55015
commit d9d1dd5e99
10 files changed
+329 -9

No files matched your search

+4
View File
@@ -209,6 +209,10 @@ func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, j
"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 + "\"")
+96
View File
@@ -0,0 +1,96 @@
package handler
import (
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/lark"
"flowstack/internal/model"
)
// notifyLarkRelease 运行结束后推送飞书发布结果卡片:
// Webhook 优先取流水线自带 notifyWebhookURL,否则用全局设置(需启用)。
func notifyLarkRelease(run model.PipelineRun, status string) {
if status != "success" && status != "failed" && status != "cancelled" {
return
}
var p model.Pipeline
database.DB.First(&p, run.PipelineID)
var s model.SystemSetting
database.DB.First(&s, 1)
webhook := p.NotifyWebhook
if webhook == "" {
if !s.LarkEnabled || s.LarkWebhook == "" {
return
}
webhook = s.LarkWebhook
}
secret := ""
if s.LarkSecret != "" && crypto.IsEncrypted(s.LarkSecret) {
if plain, err := crypto.DecryptField(s.LarkSecret); err == nil {
secret = plain
}
}
build := run.RunID
if run.BuildNumber > 0 {
build = "#" + strconv.Itoa(run.BuildNumber)
}
card := lark.BuildReleaseCard(lark.ReleaseCardParams{
AppName: run.PipelineName,
Branch: run.Branch,
Build: build,
Status: status,
Time: time.Now().Format("2006-01-02 15:04:05"),
BuildURL: run.ExternalURL,
})
if err := lark.Send(webhook, secret, card); err != nil {
log.Printf("[lark] 发布通知发送失败 (%s): %v", run.RunID, err)
} else {
log.Printf("[lark] 发布通知已发送: %s %s → %s", run.PipelineName, status, webhook)
}
}
// TestLark POST /api/settings/test-lark —— 用已保存配置发测试卡片
func TestLark(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
}
webhook := c.Query("webhook")
if webhook == "" {
webhook = s.LarkWebhook
}
if webhook == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未配置飞书 Webhook"})
return
}
secret := ""
if s.LarkSecret != "" && crypto.IsEncrypted(s.LarkSecret) {
if plain, err := crypto.DecryptField(s.LarkSecret); err == nil {
secret = plain
}
}
card := lark.BuildReleaseCard(lark.ReleaseCardParams{
AppName: "flowstack-test",
Branch: "test",
Build: "#0",
Status: "success",
Time: time.Now().Format("2006-01-02 15:04:05"),
BuildURL: "",
})
if err := lark.Send(webhook, secret, card); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试卡片已发送到群"})
}
+16
View File
@@ -90,6 +90,7 @@ func GetSettings(c *gin.Context) {
database.DB.Create(&s)
}
s.LlmApiKeySet = s.LlmApiKey != ""
s.LarkSecretSet = s.LarkSecret != ""
c.JSON(http.StatusOK, s)
}
@@ -139,9 +140,24 @@ func UpdateSettings(c *gin.Context) {
}
updates["llm_api_key"] = enc
}
if v, ok := body["larkEnabled"].(bool); ok {
updates["lark_enabled"] = v
}
if v, ok := body["larkWebhook"].(string); ok {
updates["lark_webhook"] = v
}
if v, ok := body["larkSecret"].(string); ok && v != "" {
enc, err := crypto.EncryptField(v)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "签名密钥加密失败"})
return
}
updates["lark_secret"] = enc
}
database.DB.Model(&s).Updates(updates)
database.DB.First(&s, 1)
s.LlmApiKeySet = s.LlmApiKey != ""
s.LarkSecretSet = s.LarkSecret != ""
c.JSON(http.StatusOK, s)
}
+2 -1
View File
@@ -127,13 +127,14 @@ func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook + 飞书发布卡片
if status == "success" || status == "failed" {
NotifyWebhookEvent("pipeline."+status, map[string]any{
"runId": run.RunID, "pipeline": run.PipelineName,
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
})
}
go notifyLarkRelease(run, status)
}
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)