package main import ( "fmt" "log" "net/http" "os" "sync" "time" "github.com/gin-gonic/gin" ) // 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"` } // debounceEntry 单个 key 的冷却去重状态。 // lastExec:上次实际执行时刻;冷却窗口内到达的 push 直接丢弃,无补跑。 type debounceEntry struct { mu sync.Mutex lastExec time.Time } 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. 冷却去重:窗口外 push 立即执行并刷新 lastExec,窗口内 push 直接丢弃 lockKey := fmt.Sprintf("%s:%s", repoName, ref) entryVal, _ := lastExecMap.LoadOrStore(lockKey, &debounceEntry{}) entry := entryVal.(*debounceEntry) entry.mu.Lock() now := time.Now() if !entry.lastExec.IsZero() && now.Sub(entry.lastExec) < debounceDuration { remainingSeconds := max(int((debounceDuration - now.Sub(entry.lastExec)).Seconds()), 0) entry.mu.Unlock() msg := fmt.Sprintf("3 分钟内频繁提交被拦截, 剩余冷却时间: %d 秒", remainingSeconds) log.Printf("[DEBOUNCE] 跳过执行 [%s] %s\n", lockKey, msg) c.JSON(http.StatusOK, gin.H{ "status": "debounced", "message": msg, "remaining_seconds": remainingSeconds, }) return } entry.lastExec = now entry.mu.Unlock() log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s, commit: %s)\n", scriptPath, repoName, ref, shortCommit(commitID)) go runScript(scriptPath, repoName, ref, commitID) c.JSON(http.StatusOK, gin.H{ "status": "triggered", "repository": repoName, "ref": ref, "script": scriptPath, }) }