Files
FlowStack/backend/internal/handler/resources.go
T
Logan.Li a3b4637e19 feat: 设置语言精简中英+补全时区;构建节点磁盘显示;浏览器 favicon
- 系统设置:界面语言仅保留 简体中文/English;时区按 亚/欧/美/大洋洲
  分组补全 22 个常用时区
- 构建节点:BuildNode 新增 diskInfo 字段(模型/CRUD/seed),卡片显示
  磁盘用量(未上报显示 —),添加/编辑弹窗可维护
- 新增 favicon.svg(主题渐变火箭图标),index.html 注入 icon 链接
2026-10-27 09:16:42 +08:00

756 lines
23 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 (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"os/exec"
"strings"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/model"
)
// dialTCP 真实连通性测试:TCP 拨号 5 秒超时
func dialTCP(addr string) error {
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return err
}
return conn.Close()
}
// ============================================================
// 主机 /api/hosts
// ============================================================
func ListHosts(c *gin.Context) {
var list []model.Host
q := database.DB.Order("updated_at DESC")
if tag := c.Query("tag"); tag != "" {
q = q.Where("tags LIKE ?", "%"+tag+"%")
}
q.Find(&list)
c.JSON(http.StatusOK, list)
}
func CreateHost(c *gin.Context) {
var h model.Host
if err := c.ShouldBindJSON(&h); err != nil || h.Name == "" || h.IP == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
return
}
h.ID = 0
if err := database.DB.Create(&h).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
c.JSON(http.StatusOK, h)
}
func UpdateHost(c *gin.Context) {
var h model.Host
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
return
}
var body map[string]any
_ = c.ShouldBindJSON(&body)
for _, k := range []string{"name", "ip", "port", "username", "tags", "os", "remark"} {
if v, ok := body[k]; ok {
database.DB.Model(&h).Update(k, v)
}
}
database.DB.First(&h, h.ID)
c.JSON(http.StatusOK, h)
}
func DeleteHost(c *gin.Context) {
database.DB.Delete(&model.Host{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestHost POST /api/hosts/:id/test —— 真实 TCP 拨号 SSH 端口
func TestHost(c *gin.Context) {
var h model.Host
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
return
}
start := time.Now()
err := dialTCP(fmt.Sprintf("%s:%d", h.IP, h.SSHPort))
latency := time.Since(start).Milliseconds()
status := "online"
if err != nil {
status = "offline"
}
database.DB.Model(&h).Update("status", status)
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "status": status, "latencyMs": latency,
"message": map[bool]string{true: fmt.Sprintf("SSH 端口可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
})
}
// ============================================================
// 集群 /api/clusters
// ============================================================
func ListClusters(c *gin.Context) {
var list []model.K8sCluster
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].KubeconfigSet = list[i].Kubeconfig != ""
list[i].Kubeconfig = ""
}
c.JSON(http.StatusOK, list)
}
func CreateCluster(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)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称不能为空"})
return
}
cl := model.K8sCluster{Name: name}
cl.Context, _ = raw["context"].(string)
cl.APIServer, _ = raw["apiServer"].(string)
cl.Version, _ = raw["version"].(string)
if v, ok := raw["nodes"].(float64); ok {
cl.NodeCount = int(v)
}
if v, ok := raw["namespaces"].(string); ok {
cl.Namespaces = v
} else if ns, ok := raw["namespaces"].([]any); ok {
if b, err := json.Marshal(ns); err == nil {
cl.Namespaces = string(b)
}
}
if kubeconfig, _ := raw["kubeconfig"].(string); kubeconfig != "" {
enc, err := crypto.EncryptField(kubeconfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
return
}
cl.Kubeconfig = enc
}
if err := database.DB.Create(&cl).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
cl.KubeconfigSet = cl.Kubeconfig != ""
cl.Kubeconfig = ""
c.JSON(http.StatusOK, cl)
}
func UpdateCluster(c *gin.Context) {
var cl model.K8sCluster
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]any{}
for _, k := range []string{"name", "context", "apiServer", "version", "nodes", "namespaces"} {
if v, ok := raw[k]; ok {
updates[k] = v
}
}
// kubeconfig 只写不读:非空才更新
if kubeconfig, ok := raw["kubeconfig"].(string); ok && kubeconfig != "" {
enc, err := crypto.EncryptField(kubeconfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
return
}
updates["kubeconfig"] = enc
}
if len(updates) > 0 {
database.DB.Model(&cl).Updates(updates)
}
database.DB.First(&cl, cl.ID)
cl.KubeconfigSet = cl.Kubeconfig != ""
cl.Kubeconfig = ""
c.JSON(http.StatusOK, cl)
}
func DeleteCluster(c *gin.Context) {
database.DB.Delete(&model.K8sCluster{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestCluster POST /api/clusters/:id/test —— 拨号 APIServer(或 kubeconfig 中的 server)
func TestCluster(c *gin.Context) {
var cl model.K8sCluster
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
target := cl.APIServer
if cl.Kubeconfig != "" && crypto.IsEncrypted(cl.Kubeconfig) {
if plain, err := crypto.DecryptField(cl.Kubeconfig); err == nil {
for _, line := range strings.Split(plain, "\n") {
if trimmed := strings.TrimSpace(line); strings.HasPrefix(trimmed, "server:") {
target = strings.TrimSpace(strings.TrimPrefix(trimmed, "server:"))
break
}
}
}
}
if target == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未配置 APIServer 或 kubeconfig"})
return
}
host := strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://")
err := dialTCP(host)
status := "connected"
if err != nil {
status = "disconnected"
}
now := time.Now()
database.DB.Model(&cl).Updates(map[string]any{"status": status, "last_heartbeat": &now})
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "status": status,
"message": map[bool]string{true: "APIServer 可达", false: "连接失败: " + err.Error()}[err == nil],
})
}
// ============================================================
// 凭证 /api/credentials(内容只写不读)
// ============================================================
func ListCredentials(c *gin.Context) {
var list []model.Credential
q := database.DB.Order("updated_at DESC")
if t := c.Query("type"); t != "" && t != "all" {
q = q.Where("type = ?", t)
}
q.Find(&list)
for i := range list {
list[i].ContentSet = list[i].Content != ""
list[i].Content = ""
}
c.JSON(http.StatusOK, list)
}
func CreateCredential(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)
typ, _ := raw["type"].(string)
content, _ := raw["content"].(string)
if name == "" || typ == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和类型不能为空"})
return
}
cred := model.Credential{Name: name, Type: typ}
if scope, ok := raw["scope"].(string); ok {
cred.Scope = scope
}
if content != "" {
enc, err := crypto.EncryptField(content)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
cred.Content = enc
cred.Summary = summarizeCredential(typ, content)
}
if err := database.DB.Create(&cred).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
cred.ContentSet = cred.Content != ""
cred.Content = ""
c.JSON(http.StatusOK, cred)
}
// summarizeCredential 从明文 JSON 中提取脱敏摘要(不落明文)
func summarizeCredential(typ, contentJSON string) string {
var m map[string]any
if err := json.Unmarshal([]byte(contentJSON), &m); err != nil {
return typ + " 凭证"
}
pick := func(keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return k + "=" + crypto.Mask(v)
}
}
return ""
}
switch typ {
case "ssh":
return typ + " " + pick("ssh_user", "username", "private_key")
case "docker":
return typ + " " + pick("server") + " " + pick("username")
case "git":
return typ + " " + pick("url", "username")
case "kubeconfig":
return typ + " (kubeconfig)"
case "aksk":
return typ + " " + pick("access_key")
default:
if len(m) > 0 {
return typ + " (" + fmt.Sprint(len(m)) + " 项)"
}
return typ + " 凭证"
}
}
func UpdateCredential(c *gin.Context) {
var cred model.Credential
if err := database.DB.First(&cred, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]any{}
if v, ok := raw["name"].(string); ok && v != "" {
updates["name"] = v
}
if v, ok := raw["scope"].(string); ok {
updates["scope"] = v
}
if content, ok := raw["content"].(string); ok && content != "" {
enc, err := crypto.EncryptField(content)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
updates["content"] = enc
typ, _ := raw["type"].(string)
if typ == "" {
typ = cred.Type
}
updates["summary"] = summarizeCredential(typ, content)
}
if len(updates) > 0 {
database.DB.Model(&cred).Updates(updates)
}
database.DB.First(&cred, cred.ID)
cred.ContentSet = cred.Content != ""
cred.Content = ""
c.JSON(http.StatusOK, cred)
}
func DeleteCredential(c *gin.Context) {
database.DB.Delete(&model.Credential{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ============================================================
// 构建节点 /api/build-nodes
// ============================================================
func ListBuildNodes(c *gin.Context) {
var list []model.BuildNode
database.DB.Order("updated_at DESC").Find(&list)
c.JSON(http.StatusOK, list)
}
func CreateBuildNode(c *gin.Context) {
var n model.BuildNode
if err := c.ShouldBindJSON(&n); err != nil || n.Name == "" || n.IP == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
return
}
n.ID = 0
if err := database.DB.Create(&n).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
c.JSON(http.StatusOK, n)
}
func UpdateBuildNode(c *gin.Context) {
var n model.BuildNode
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
for _, k := range []string{"name", "ip", "labels", "maxConcurrency", "workspacePath", "diskInfo", "enabled"} {
if v, ok := raw[k]; ok {
database.DB.Model(&n).Update(k, v)
}
}
database.DB.First(&n, n.ID)
c.JSON(http.StatusOK, n)
}
func DeleteBuildNode(c *gin.Context) {
database.DB.Delete(&model.BuildNode{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestBuildNode POST /api/build-nodes/:id/test —— 真实 TCP 拨号
func TestBuildNode(c *gin.Context) {
var n model.BuildNode
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
return
}
start := time.Now()
err := dialTCP(n.IP + ":22")
latency := time.Since(start).Milliseconds()
status := "online"
if err != nil {
status = "offline"
}
now := time.Now()
database.DB.Model(&n).Updates(map[string]any{"status": status, "last_heartbeat": &now})
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "status": status, "latencyMs": latency,
"message": map[bool]string{true: fmt.Sprintf("节点可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
})
}
// ============================================================
// 工具 /api/tools(真实检测 + 保守安装)
// ============================================================
func ListTools(c *gin.Context) {
var list []model.DeployTool
database.DB.Order("sort_order").Find(&list)
c.JSON(http.StatusOK, list)
}
func UpdateTool(c *gin.Context) {
var t model.DeployTool
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
for _, k := range []string{"installMode", "version", "path", "enabled"} {
if v, ok := raw[k]; ok {
database.DB.Model(&t).Update(k, v)
}
}
database.DB.First(&t, t.ID)
c.JSON(http.StatusOK, t)
}
// runToolCheck 真实执行 CheckCommand,返回输出中的版本号片段
func runToolCheck(checkCommand string) (version string, out string, err error) {
cmd := exec.Command("sh", "-c", checkCommand)
cmd.Stderr = nil
bytes, err := cmd.Output()
out = strings.TrimSpace(string(bytes))
if err != nil {
// java -version 等把版本打到 stderr
if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
out = strings.TrimSpace(string(exitErr.Stderr))
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "version") {
return extractVersion(line), out, nil
}
}
}
return "", out, err
}
return extractVersion(out), out, nil
}
// extractVersion 从命令输出中提取首个 x.y.z 形态的版本号
func extractVersion(s string) string {
fields := strings.Fields(s)
inVersion := false
for i, f := range fields {
if strings.Contains(f, "version") {
if i+1 < len(fields) {
return strings.Trim(fields[i+1], `"`)
}
inVersion = true
continue
}
if inVersion {
return strings.Trim(f, `"`)
}
}
// 兜底:找 x.y.z
for _, f := range fields {
if strings.Count(f, ".") >= 1 && f[0] >= '0' && f[0] <= '9' {
return strings.Trim(f, `"`)
}
}
return ""
}
// CheckTool POST /api/tools/:id/check —— 真实执行检测命令并回填版本
func CheckTool(c *gin.Context) {
var t model.DeployTool
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
return
}
version, out, err := runToolCheck(t.CheckCommand)
now := time.Now()
updates := map[string]any{"last_check": &now}
if err == nil && version != "" {
updates["status"] = "installed"
updates["installed_version"] = version
updates["error_msg"] = ""
} else {
updates["status"] = "not-installed"
updates["error_msg"] = "未检测到:" + firstLine(out, err)
}
database.DB.Model(&t).Updates(updates)
database.DB.First(&t, t.ID)
c.JSON(http.StatusOK, gin.H{"tool": t, "output": out, "detected": version})
}
// InstallTool POST /api/tools/:id/install —— 保守安装:真实检测,检出即装;未检出标记 failed 提示手动安装
func InstallTool(c *gin.Context) {
var t model.DeployTool
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
return
}
database.DB.Model(&t).Update("status", "installing")
version, out, err := runToolCheck(t.CheckCommand)
now := time.Now()
updates := map[string]any{"last_check": &now}
if err == nil && version != "" {
updates["status"] = "installed"
updates["installed_version"] = version
updates["error_msg"] = ""
c.JSON(http.StatusOK, gin.H{"success": true, "message": "检测到已安装 " + version, "version": version, "output": out})
} else {
updates["status"] = "failed"
updates["error_msg"] = "本机未检测到 " + t.DisplayName + ",保守安装策略不会自动下载,请手动安装(期望版本 " + t.Version + ")"
c.JSON(http.StatusOK, gin.H{"success": false, "message": updates["error_msg"], "output": firstLine(out, err)})
}
database.DB.Model(&t).Updates(updates)
}
func firstLine(out string, err error) string {
if err != nil {
return "命令执行失败: " + err.Error()
}
for _, l := range strings.Split(out, "\n") {
if l != "" {
return l
}
}
return out
}
// ============================================================
// Webhook /api/webhooks(HMAC-SHA256 签名)
// ============================================================
func ListWebhooks(c *gin.Context) {
var list []model.WebhookEndpoint
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].SecretSet = list[i].Secret != ""
list[i].Secret = ""
}
c.JSON(http.StatusOK, list)
}
func CreateWebhook(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)
url, _ := raw["url"].(string)
if name == "" || url == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 URL 不能为空"})
return
}
wh := model.WebhookEndpoint{Name: name, URL: url, Active: true}
if events, ok := raw["events"]; ok {
if b, err := json.Marshal(events); err == nil {
wh.Events = string(b)
}
}
if secret, ok := raw["secret"].(string); ok && secret != "" {
enc, err := crypto.EncryptField(secret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
wh.Secret = enc
}
if err := database.DB.Create(&wh).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
wh.SecretSet = wh.Secret != ""
wh.Secret = ""
c.JSON(http.StatusOK, wh)
}
func UpdateWebhook(c *gin.Context) {
var wh model.WebhookEndpoint
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
updates := map[string]any{}
if v, ok := raw["name"].(string); ok {
updates["name"] = v
}
if v, ok := raw["url"].(string); ok {
updates["url"] = v
}
if v, ok := raw["active"].(bool); ok {
updates["active"] = v
}
if events, ok := raw["events"]; ok {
if b, err := json.Marshal(events); err == nil {
updates["events"] = string(b)
}
}
if secret, ok := raw["secret"].(string); ok && secret != "" {
if enc, err := crypto.EncryptField(secret); err == nil {
updates["secret"] = enc
}
}
if len(updates) > 0 {
database.DB.Model(&wh).Updates(updates)
}
database.DB.First(&wh, wh.ID)
wh.SecretSet = wh.Secret != ""
wh.Secret = ""
c.JSON(http.StatusOK, wh)
}
func DeleteWebhook(c *gin.Context) {
database.DB.Delete(&model.WebhookEndpoint{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestWebhook POST /api/webhooks/:id/test —— 真实 HTTP POST + HMAC-SHA256 签名
func TestWebhook(c *gin.Context) {
var wh model.WebhookEndpoint
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
return
}
payload, _ := json.Marshal(map[string]any{
"event": "webhook.test", "timestamp": time.Now().Format(time.RFC3339),
"message": "FlowStack Webhook 测试消息",
})
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FlowStack-Event", "webhook.test")
if wh.Secret != "" {
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
now := time.Now()
if err != nil {
database.DB.Model(&wh).Updates(map[string]any{"last_status": "failed", "last_triggered_at": &now})
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败: " + err.Error()})
return
}
defer resp.Body.Close()
status := "success"
if resp.StatusCode >= 300 {
status = "failed"
}
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
c.JSON(http.StatusOK, gin.H{"success": resp.StatusCode < 300, "statusCode": resp.StatusCode, "message": fmt.Sprintf("响应 %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))})
}
// NotifyWebhookEvent 流水线事件发生后通知订阅了该事件的 Webhook(异步调用)
func NotifyWebhookEvent(event string, data map[string]any) {
go func() {
var endpoints []model.WebhookEndpoint
database.DB.Where("active = ?", true).Find(&endpoints)
payload, _ := json.Marshal(map[string]any{"event": event, "timestamp": time.Now().Format(time.RFC3339), "data": data})
client := &http.Client{Timeout: 10 * time.Second}
for _, wh := range endpoints {
if !strings.Contains(wh.Events, event) {
continue
}
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FlowStack-Event", event)
if wh.Secret != "" {
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
}
status := "success"
if resp, err := client.Do(req); err != nil || resp.StatusCode >= 300 {
status = "failed"
if resp != nil {
resp.Body.Close()
}
}
now := time.Now()
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
}
}()
}
// InboundPipelineHook POST /api/hooks/pipeline/:id?token= —— 入站触发(TriggerPanel 的 Webhook URL)
func InboundPipelineHook(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
var p model.Pipeline
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
token := c.Query("token")
if p.WebhookToken != "" && token != p.WebhookToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token 校验失败"})
return
}
if p.TriggerMode != "webhook" && p.TriggerMode != "git_push" {
c.JSON(http.StatusConflict, gin.H{"error": "流水线未启用 Webhook 触发"})
return
}
go triggerPipelineRun(hub, p, "", "", "webhook", "webhook")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已触发流水线 " + p.Name})
}
}