- 系统设置:界面语言仅保留 简体中文/English;时区按 亚/欧/美/大洋洲 分组补全 22 个常用时区 - 构建节点:BuildNode 新增 diskInfo 字段(模型/CRUD/seed),卡片显示 磁盘用量(未上报显示 —),添加/编辑弹窗可维护 - 新增 favicon.svg(主题渐变火箭图标),index.html 注入 icon 链接
756 lines
23 KiB
Go
756 lines
23 KiB
Go
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})
|
||
}
|
||
}
|