diff --git a/backend/internal/handler/jenkins.go b/backend/internal/handler/jenkins.go index 142446b..35acb50 100644 --- a/backend/internal/handler/jenkins.go +++ b/backend/internal/handler/jenkins.go @@ -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 + "\"") diff --git a/backend/internal/handler/notify.go b/backend/internal/handler/notify.go new file mode 100644 index 0000000..fb7da5f --- /dev/null +++ b/backend/internal/handler/notify.go @@ -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": "测试卡片已发送到群"}) +} diff --git a/backend/internal/handler/pipelines.go b/backend/internal/handler/pipelines.go index 1f7dfac..ca36d33 100644 --- a/backend/internal/handler/pipelines.go +++ b/backend/internal/handler/pipelines.go @@ -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) } diff --git a/backend/internal/handler/runs.go b/backend/internal/handler/runs.go index 7091193..6db71c4 100644 --- a/backend/internal/handler/runs.go +++ b/backend/internal/handler/runs.go @@ -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) diff --git a/backend/internal/lark/lark.go b/backend/internal/lark/lark.go new file mode 100644 index 0000000..dd510e6 --- /dev/null +++ b/backend/internal/lark/lark.go @@ -0,0 +1,125 @@ +// Package lark 飞书/Lark 群自定义机器人卡片通知。 +package lark + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// ReleaseCardParams 发布结果卡片字段 +type ReleaseCardParams struct { + AppName string // 应用名 + Branch string // 分支 + Build string // 构建号(如 #172 或 pr-xxx) + Status string // success | failed | 其他 + Time string // 展示时间 + BuildURL string // 「查看构建详情」按钮地址(空则不放按钮) +} + +func statusMeta(status string) (template, text string) { + switch status { + case "success": + return "green", "✅ 成功" + case "failed": + return "red", "❌ 失败" + case "cancelled": + return "yellow", "⚠️ 已取消" + default: + return "yellow", "⚠️ " + status + } +} + +// BuildReleaseCard 组装发布结果卡片(结构对齐现有 Jenkins 通知脚本) +func BuildReleaseCard(p ReleaseCardParams) map[string]any { + _, statusText := statusMeta(p.Status) + template, _ := statusMeta(p.Status) + + elements := []any{ + map[string]any{ + "tag": "div", + "text": map[string]any{ + "tag": "lark_md", + "content": fmt.Sprintf( + "**应用:** %s\n**分支:** %s\n**构建:** %s\n**状态:** %s\n**时间:** %s", + p.AppName, p.Branch, p.Build, statusText, p.Time, + ), + }, + }, + } + if p.BuildURL != "" { + elements = append(elements, map[string]any{ + "tag": "action", + "actions": []any{map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "查看构建详情"}, + "url": p.BuildURL, + "type": "default", + }}, + }) + } + + _, statusOnly := statusMeta(p.Status) + title := "发布结果通知 - " + statusOnly + return map[string]any{ + "msg_type": "interactive", + "card": map[string]any{ + "config": map[string]any{"wide_screen_mode": true}, + "header": map[string]any{ + "template": template, + "title": map[string]any{"tag": "plain_text", "content": title}, + }, + "elements": elements, + }, + } +} + +// Send 发送卡片;secret 非空时按飞书自定义机器人规范加签(timestamp+sign) +func Send(webhook, secret string, card map[string]any) error { + if webhook == "" { + return fmt.Errorf("webhook 未配置") + } + if secret != "" { + ts := strconv.FormatInt(time.Now().Unix(), 10) + mac := hmac.New(sha256.New, []byte(ts+"\n"+secret)) + mac.Write(nil) + card["timestamp"] = ts + card["sign"] = base64.StdEncoding.EncodeToString(mac.Sum(nil)) + } + body, err := json.Marshal(card) + if err != nil { + return err + } + resp, err := (&http.Client{Timeout: 10 * time.Second}).Post(webhook, "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + } + // 飞书成功返回 {"code":0,...} / {"StatusCode":0} + var out struct { + Code int `json:"code"` + StatusCode int `json:"StatusCode"` + Msg string `json:"msg"` + ErrorMessage string `json:"ErrorMessage"` + } + _ = json.Unmarshal(data, &out) + if out.Code != 0 || out.StatusCode != 0 { + msg := out.Msg + if msg == "" { + msg = out.ErrorMessage + } + return fmt.Errorf("飞书返回错误: %s", msg) + } + return nil +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 53b9991..4e6b333 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -274,6 +274,10 @@ type SystemSetting struct { LlmApiKey string `gorm:"type:text" json:"-"` // enc:v1: 加密,只写不读 LlmApiKeySet bool `gorm:"-" json:"llmApiKeySet"` LlmMaxTokens int `gorm:"default:1200" json:"llmMaxTokens"` + LarkEnabled bool `gorm:"default:false" json:"larkEnabled"` + LarkWebhook string `json:"larkWebhook"` // 飞书群自定义机器人 Webhook + LarkSecret string `gorm:"type:text" json:"-"` // 加签密钥(可选),enc:v1: 加密 + LarkSecretSet bool `gorm:"-" json:"larkSecretSet"` Extra string `gorm:"type:text" json:"extra"` UpdatedAt time.Time `json:"updatedAt"` } diff --git a/backend/main.go b/backend/main.go index 5e96a28..461db8f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -55,6 +55,7 @@ func main() { 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/release", handler.AiRelease(hub)) diff --git a/backend/static/index.html b/backend/static/index.html index 2b7519f..30e2799 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -1,10 +1,14 @@ - + -FlowStack - -
-

FlowStack 发布系统

-

请在项目根目录执行 ./run.sh build 生成前端产物后重新编译。

-
- + + + + + FlowStack · 发布系统 + + + + +
+ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a598c2d..e4c4947 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -153,4 +153,5 @@ export const apiClient = { batchParse: (b: { text: string; refType?: string; fixedRef?: string }) => api('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }), testLLM: () => api('/api/settings/test-llm', { method: 'POST' }), + testLark: () => api('/api/settings/test-lark', { method: 'POST' }), } diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx index 6f2a46d..ecc1bb2 100644 --- a/frontend/src/pages/SystemSettings.tsx +++ b/frontend/src/pages/SystemSettings.tsx @@ -67,6 +67,7 @@ export default function SystemSettings() { const [theme, setTheme] = useState(defaultTheme); const [notifications, setNotifications] = useState(defaultNotifications); const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' }); + const [lark, setLark] = useState({ enabled: false, webhook: '', secret: '' }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings }); @@ -82,8 +83,37 @@ export default function SystemSettings() { model: settings.llmModel || '', apiKey: '', // 只写:回显永远留空 })); + setLark((prev) => ({ + ...prev, + enabled: !!settings.larkEnabled, + webhook: settings.larkWebhook || '', + secret: '', + })); }, [settings]); + const saveLarkMutation = useMutation({ + mutationFn: () => { + const body: any = { larkEnabled: lark.enabled, larkWebhook: lark.webhook }; + if (lark.secret) body.larkSecret = lark.secret; + return apiClient.updateSettings(body); + }, + onSuccess: () => { + toast.success('飞书通知配置已保存'); + setLark((p) => ({ ...p, secret: '' })); + queryClient.invalidateQueries({ queryKey: ['settings'] }); + }, + onError: (err: Error) => toast.error('保存失败', { description: err.message }), + }); + + const testLarkMutation = useMutation({ + mutationFn: () => apiClient.testLark(), + onSuccess: (res: any) => { + if (res.success) toast.success('测试卡片已发送', { description: '请到飞书群查看' }); + else toast.error('发送失败', { description: res.message }); + }, + onError: (err: Error) => toast.error('测试失败', { description: err.message }), + }); + const saveLlmMutation = useMutation({ mutationFn: () => { const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model }; @@ -384,6 +414,44 @@ export default function SystemSettings() { + + {/* 飞书 / Lark 通知 */} +
+
+
+ 飞书 / Lark 群通知 + + {settings?.larkEnabled && settings?.larkWebhook ? '已启用' : '未启用'} + +
+
+
+

+ 发布成功/失败后向群推送结果卡片(应用 / 分支 / 构建 / 状态 / 时间 + 「查看构建详情」按钮跳转 Jenkins 构建页)。 + 流水线单独配置的「通知 Webhook」优先生效。 +

+ +
+ + setLark({ ...lark, webhook: e.target.value })} placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxx" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /> +
+
+ + setLark({ ...lark, secret: e.target.value })} placeholder={settings?.larkSecretSet ? '已配置,留空不修改' : '无需签名可留空'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /> +
+
+ + +
+
+
);