package main import ( "bytes" "encoding/json" "flag" "fmt" "io" "log" "net/http" "os" "os/exec" "sync" "time" "github.com/gin-gonic/gin" ) // Config 对应 config.json 结构 type Config struct { SecretToken string `json:"secret_token"` Projects map[string]map[string]string `json:"projects"` DeviceKeys []string `json:"device_keys"` } // GitLabPayload 简化版的 GitLab Push Webhook 数据结构 type GitLabPayload struct { Ref string `json:"ref"` After string `json:"after"` Project struct { PathWithNamespace string `json:"path_with_namespace"` } `json:"project"` } // BarkPushPayload 定义 Bark 推送的请求 JSON 结构(单 device_key) type BarkPushPayload struct { Title string `json:"title"` Body string `json:"body"` Group string `json:"group,omitempty"` DeviceKey string `json:"device_key"` IsArchive string `json:"isArchive,omitempty"` TTL int `json:"ttl,omitempty"` } // SendNotification 依次对每个 device_key 发送 Bark 通知,遇错即返回 func SendNotification(pushURL string, deviceKeys []string, title, body string) error { for _, key := range deviceKeys { if err := sendOne(pushURL, key, title, body); err != nil { return err } } return nil } // sendOne 向单个 device_key 发送推送 func sendOne(pushURL, deviceKey, title, body string) error { payload := BarkPushPayload{ Title: title, Body: body, Group: "Model Development Platform", DeviceKey: deviceKey, IsArchive: "1", TTL: 3600, } jsonBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("JSON 序列化失败: %w", err) } req, err := http.NewRequest("POST", pushURL, bytes.NewBuffer(jsonBytes)) if err != nil { return fmt.Errorf("创建请求失败: %w", err) } req.Header.Set("Content-Type", "application/json; charset=utf-8") client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("发送推送请求失败: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) bodyText := string(bytes.TrimSpace(bodyBytes)) if bodyText != "" { return fmt.Errorf("推送服务器返回异常状态码: %d, 响应: %s", resp.StatusCode, bodyText) } return fmt.Errorf("推送服务器返回异常状态码: %d", resp.StatusCode) } return nil } var ( globalConfig Config // lastExecutionMap 用于记录某个仓库+分支的上一次触发时间 key: "repoName:ref", value: time.Time lastExecutionMap sync.Map // 防抖冷却时间限制:5 分钟 debounceDuration = 5 * time.Minute // bark 推送消息 pushURL = "https://bark.maimaicuizhiji.top/push" ) func loadConfig(path string) error { file, err := os.ReadFile(path) if err != nil { return err } return json.Unmarshal(file, &globalConfig) } // 异步执行 Shell 脚本 func runScript(scriptPath, repoName, ref, commitID string) { log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s)\n", scriptPath, repoName, ref) cmd := exec.Command("bash", scriptPath) // 传入环境变量,脚本内可读取 cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_REPO_NAME=%s", repoName), fmt.Sprintf("GIT_REF=%s", ref), fmt.Sprintf("GIT_COMMIT_ID=%s", commitID), ) // 捕获输出 output, err := cmd.CombinedOutput() if err != nil { log.Printf("[ERROR] 脚本执行失败 [%s]: %v\n输出:\n%s\n", scriptPath, err, string(output)) return } log.Printf("[SUCCESS] 脚本执行成功 [%s]\n输出:\n%s\n", scriptPath, string(output)) title := fmt.Sprintf("%s更新成功", repoName) body := fmt.Sprintf("分支: %s\nCommit: %s\n服务器已接收到 Push,开始更新容器...", ref, commitID[:7]) if err := SendNotification(pushURL, globalConfig.DeviceKeys, title, body); err != nil { log.Printf("[WARNING] 推送通知失败: %v\n", err) } else { log.Println("[INFO] 推送通知发送成功") } } func handleWebhook(c *gin.Context) { // 1. 校验 GitLab Secret Token Header clientToken := c.GetHeader("X-Gitlab-Token") if globalConfig.SecretToken != "" && clientToken != globalConfig.SecretToken { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Secret Token"}) return } // 2. 校验 Event 类型 eventType := c.GetHeader("X-Gitlab-Event") if eventType != "Push Hook" { c.JSON(http.StatusOK, gin.H{"status": "ignored", "reason": "Only Push Hook is handled"}) return } // 3. 解析 Body JSON var payload GitLabPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON Payload"}) return } repoName := payload.Project.PathWithNamespace ref := payload.Ref commitID := payload.After if repoName == "" || ref == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing repo or ref in payload"}) return } // 4. 匹配脚本路径 repoConfig, exists := globalConfig.Projects[repoName] if !exists { c.JSON(http.StatusOK, gin.H{"status": "ignored", "message": "No script config for repository " + repoName}) return } scriptPath, exists := repoConfig[ref] if !exists { c.JSON(http.StatusOK, gin.H{"status": "ignored", "message": fmt.Sprintf("No script config for %s on %s", repoName, ref)}) return } // 5. 校验脚本文件是否存在 if _, err := os.Stat(scriptPath); os.IsNotExist(err) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Script file not found: " + scriptPath}) return } // 6. 核心逻辑:5 分钟防抖检查 lockKey := fmt.Sprintf("%s:%s", repoName, ref) now := time.Now() if lastTimeVal, loaded := lastExecutionMap.Load(lockKey); loaded { lastTime := lastTimeVal.(time.Time) elapsed := now.Sub(lastTime) if elapsed < debounceDuration { remainingSeconds := int((debounceDuration - elapsed).Seconds()) msg := fmt.Sprintf("触发太频繁,防抖拦截中(5分钟限制),剩余等待时间: %d 秒", remainingSeconds) log.Printf("[DEBOUNCE] 跳过执行 [%s] -> %s\n", lockKey, msg) c.JSON(http.StatusOK, gin.H{ "status": "debounced", "message": msg, "remaining_seconds": remainingSeconds, }) return } } // 更新上一次触发时间 lastExecutionMap.Store(lockKey, now) // 7. 开启 goroutine 异步执行脚本 go runScript(scriptPath, repoName, ref, commitID) // 8. 立即回应 200 给 GitLab c.JSON(http.StatusOK, gin.H{ "status": "triggered", "repository": repoName, "ref": ref, "script": scriptPath, }) } func main() { portFlag := flag.Int("port", 8000, "服务监听端口") flag.Parse() if err := loadConfig("config.json"); err != nil { log.Fatalf("加载配置文件失败: %v", err) } //gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.Use(func(c *gin.Context) { t := time.Now() c.Next() log.Printf("[%s] %s %d %v", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(t)) }) r.POST("/webhook", handleWebhook) listenAddr := fmt.Sprintf(":%d", *portFlag) log.Printf("GitLab Webhook 服务已启动 (带 5 分钟防抖),监听端口 %d ...\n", *portFlag) if err := r.Run(listenAddr); err != nil { log.Fatalf("服务启动失败: %v", err) } }