Files
FlowStack/backend/internal/lark/lark.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

126 lines
3.3 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 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
}