From ccecc512db335fb9ef2c42083e269ba55995558c Mon Sep 17 00:00:00 2001 From: qian Date: Thu, 16 Jul 2026 21:45:59 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=8F=8C=E5=86=99=E5=AF=BC=E8=87=B4=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/gemini/adaptor.go | 9 +- relay/channel/gemini/response.go | 41 +++--- relay/channel/gemini/response_test.go | 41 ++++++ relay/channel/openai/adaptor.go | 12 +- relay/channel/openai/audio.go | 8 +- relay/channel/openai/claude_response.go | 8 +- relay/channel/openai/completion.go | 4 +- relay/channel/openai/gemini_response.go | 8 +- relay/channel/openai/rerank.go | 4 +- relay/constant/errors.go | 4 + relay/handler/chat_handler.go | 6 + relay/handler/claude_handler.go | 6 + relay/handler/error_writer_test.go | 67 ++++++++++ relay/handler/gemini_handler.go | 6 + test/webhook_receiver/main.go | 168 ++++++++++++++++++++++++ 15 files changed, 358 insertions(+), 34 deletions(-) create mode 100644 relay/channel/gemini/response_test.go create mode 100644 relay/handler/error_writer_test.go create mode 100644 test/webhook_receiver/main.go diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 8b02f1f..9b99f33 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -351,13 +351,16 @@ func (a *Adaptor) handleCodeAssistAggregatedStream(ctx context.Context, resp *ht body = unwrapCodeAssistData(body) clientFormat := info.GetOriginalClientFormat() if clientFormat == constant.RelayFormatGemini { + // Gemini 原生格式:保留完整上游错误响应,标记以防上层二次写入 writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(resp.StatusCode) _, _ = writer.Write(body) - } else { - writeGeminiErrorAsOpenAI(writer, body, resp.StatusCode) + err := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + err.ResponseWritten = true + return nil, err } - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + // OpenAI 等格式:不写响应,交上层错误写入器统一写入 + return nil, buildGeminiUpstreamError(body, resp.StatusCode) } // 读取所有 SSE chunks 并聚合 diff --git a/relay/channel/gemini/response.go b/relay/channel/gemini/response.go index 18c41de..43ad3bf 100644 --- a/relay/channel/gemini/response.go +++ b/relay/channel/gemini/response.go @@ -50,10 +50,13 @@ func (a *Adaptor) handleGeminiNativeNonStream(ctx context.Context, resp *http.Re } if resp.StatusCode != http.StatusOK { + // Gemini 原生格式透传:已写入完整上游错误响应,标记以防上层二次写入 writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(resp.StatusCode) _, _ = writer.Write(body) - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + err := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + err.ResponseWritten = true + return nil, err } writer.Header().Set("Content-Type", "application/json") @@ -73,10 +76,13 @@ func (a *Adaptor) handleGeminiNativeStream(ctx context.Context, resp *http.Respo if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) + // Gemini 原生格式透传:已写入完整上游错误响应,标记以防上层二次写入 writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(resp.StatusCode) _, _ = writer.Write(body) - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + err := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + err.ResponseWritten = true + return nil, err } helper.SetEventStreamHeaders(writer) @@ -163,8 +169,8 @@ func (a *Adaptor) handleNonStreamToOpenAI(ctx context.Context, resp *http.Respon } if resp.StatusCode != http.StatusOK { - writeGeminiErrorAsOpenAI(writer, body, resp.StatusCode) - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + // 不写响应:交上层 WriteRelayError 统一写入,避免双重写入与重试时的响应污染 + return nil, buildGeminiUpstreamError(body, resp.StatusCode) } var geminiResp dto.GeminiChatResponse @@ -199,8 +205,8 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response, if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - writeGeminiErrorAsOpenAI(writer, body, resp.StatusCode) - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + // 不写响应:交上层 WriteRelayError 统一写入,避免双重写入与重试时的响应污染 + return nil, buildGeminiUpstreamError(body, resp.StatusCode) } helper.SetEventStreamHeaders(writer) @@ -683,8 +689,10 @@ func geminiStatusToOpenAIType(status string) string { } } -// writeGeminiErrorAsOpenAI 将 Gemini 上游错误转换为 OpenAI 格式写入响应 -func writeGeminiErrorAsOpenAI(writer http.ResponseWriter, body []byte, defaultStatusCode int) { +// buildGeminiUpstreamError 解析 Gemini 上游错误,构造携带正确 type 的 RelayError。 +// 用于 OpenAI 出站路径:adaptor 不直接写响应,交上层错误写入器(WriteRelayError)统一写入一次, +// 既消除双重写入,又避免非流式可重试错误提前 WriteHeader 造成的重试响应污染。 +func buildGeminiUpstreamError(body []byte, defaultStatusCode int) *constant.RelayError { code, status, message := parseGeminiError(body) if code == 0 { code = defaultStatusCode @@ -692,16 +700,9 @@ func writeGeminiErrorAsOpenAI(writer http.ResponseWriter, body []byte, defaultSt if code < 100 || code > 599 { code = 500 } - - openaiErr, _ := json.Marshal(map[string]any{ - "error": map[string]any{ - "message": message, - "type": geminiStatusToOpenAIType(status), - "param": nil, - "code": nil, - }, - }) - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(code) - _, _ = writer.Write(openaiErr) + return &constant.RelayError{ + StatusCode: code, + Message: message, + Type: geminiStatusToOpenAIType(status), + } } diff --git a/relay/channel/gemini/response_test.go b/relay/channel/gemini/response_test.go new file mode 100644 index 0000000..04e4679 --- /dev/null +++ b/relay/channel/gemini/response_test.go @@ -0,0 +1,41 @@ +package gemini + +import ( + "strings" + "testing" +) + +// TestBuildGeminiUpstreamError_ResourceExhausted 验证真实 Gemini 429(区域配额耗尽) +// 被解析为携带正确 type 的 RelayError,且 message 为简短文案而非整个 body。 +// 回归保护:OpenAI 出站路径上游报错时,由上层写入器写出单条 rate_limit_error, +// 不再出现 adaptor 与上层各写一次的「双重写入」。 +func TestBuildGeminiUpstreamError_ResourceExhausted(t *testing.T) { + body := []byte(`{ + "error": { + "code": 429, + "message": "Quota exceeded for quota metric 'API requests' and limit 'Request limit per minute for a region' of service 'generativelanguage.googleapis.com' for consumer 'project_number:121235835710'.", + "status": "RESOURCE_EXHAUSTED" + } + }`) + + err := buildGeminiUpstreamError(body, 200) + + if err.StatusCode != 429 { + t.Errorf("StatusCode = %d, want 429", err.StatusCode) + } + if err.Type != "rate_limit_error" { + t.Errorf("Type = %q, want rate_limit_error", err.Type) + } + if !strings.HasPrefix(err.Message, "Quota exceeded") { + t.Errorf("Message = %q, want short upstream message (not the whole body)", err.Message) + } +} + +// TestBuildGeminiUpstreamError_FallbackStatusCode 验证 body 无法解析出 code 时回退到默认状态码 +func TestBuildGeminiUpstreamError_FallbackStatusCode(t *testing.T) { + // 非 JSON body:parseGeminiError 返回 code=0,应回退到 defaultStatusCode + err := buildGeminiUpstreamError([]byte("plain text error"), 503) + if err.StatusCode != 503 { + t.Errorf("StatusCode = %d, want 503 (fallback)", err.StatusCode) + } +} diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 22e201b..75afb9a 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -304,7 +304,9 @@ func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Re // 尝试解析上游错误格式,如果是标准格式则透传原始响应 if isUpstreamOpenAIError(body) { writeUpstreamErrorResponse(writer, resp.StatusCode, body) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) } @@ -341,7 +343,9 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo // 尝试透传上游错误格式 if isUpstreamOpenAIError(body) { writeUpstreamErrorResponse(writer, resp.StatusCode, body) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) } @@ -465,7 +469,9 @@ func (a *Adaptor) handleModerationResponse(_ context.Context, resp *http.Respons } if resp.StatusCode != http.StatusOK { writeUpstreamErrorResponse(writer, resp.StatusCode, body) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) diff --git a/relay/channel/openai/audio.go b/relay/channel/openai/audio.go index 2642643..728a1b4 100644 --- a/relay/channel/openai/audio.go +++ b/relay/channel/openai/audio.go @@ -21,7 +21,9 @@ func handleAudioSpeechResponse(ctx context.Context, resp *http.Response, info *c body, _ := io.ReadAll(resp.Body) if isUpstreamOpenAIError(body) { writeUpstreamErrorResponse(writer, resp.StatusCode, body) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) } @@ -67,7 +69,9 @@ func handleAudioTranscriptionResponse(ctx context.Context, resp *http.Response, if resp.StatusCode != http.StatusOK { if isUpstreamOpenAIError(body) { writeUpstreamErrorResponse(writer, resp.StatusCode, body) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) } diff --git a/relay/channel/openai/claude_response.go b/relay/channel/openai/claude_response.go index f60586c..56a0038 100644 --- a/relay/channel/openai/claude_response.go +++ b/relay/channel/openai/claude_response.go @@ -34,7 +34,9 @@ func handleClaudeInboundNonStream(ctx context.Context, resp *http.Response, info "error": map[string]any{"type": "api_error", "message": string(body)}, }) _, _ = writer.Write(claudeErr) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } var openaiResp dto.ChatCompletionResponse @@ -74,7 +76,9 @@ func handleClaudeInboundStream(ctx context.Context, resp *http.Response, info *c "error": map[string]any{"type": "api_error", "message": string(body)}, }) _, _ = writer.Write(claudeErr) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } helper.SetEventStreamHeaders(writer) diff --git a/relay/channel/openai/completion.go b/relay/channel/openai/completion.go index ab05b77..deff81c 100644 --- a/relay/channel/openai/completion.go +++ b/relay/channel/openai/completion.go @@ -24,7 +24,9 @@ func (a *Adaptor) handleCompletionNonStreamResponse(ctx context.Context, resp *h if resp.StatusCode != http.StatusOK { if isUpstreamOpenAIError(body) { writeUpstreamErrorResponse(writer, resp.StatusCode, body) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) } diff --git a/relay/channel/openai/gemini_response.go b/relay/channel/openai/gemini_response.go index 1bb7318..3f74ffe 100644 --- a/relay/channel/openai/gemini_response.go +++ b/relay/channel/openai/gemini_response.go @@ -27,7 +27,9 @@ func handleGeminiInboundNonStream(ctx context.Context, resp *http.Response, info if resp.StatusCode != http.StatusOK { writeOpenAIErrorAsGemini(writer, body, resp.StatusCode) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } var openaiResp dto.ChatCompletionResponse @@ -59,7 +61,9 @@ func handleGeminiInboundStream(ctx context.Context, resp *http.Response, info *c body, _ := io.ReadAll(resp.Body) resp.Body.Close() writeOpenAIErrorAsGemini(writer, body, resp.StatusCode) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } helper.SetEventStreamHeaders(writer) diff --git a/relay/channel/openai/rerank.go b/relay/channel/openai/rerank.go index 8beecdb..8cc64cc 100644 --- a/relay/channel/openai/rerank.go +++ b/relay/channel/openai/rerank.go @@ -26,7 +26,9 @@ func handleRerankResponse(ctx context.Context, resp *http.Response, info *common if resp.StatusCode != http.StatusOK { if isUpstreamOpenAIError(body) { writeUpstreamErrorResponse(writer, resp.StatusCode, body) - return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr } return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) } diff --git a/relay/constant/errors.go b/relay/constant/errors.go index 3efdbed..3cd2d91 100644 --- a/relay/constant/errors.go +++ b/relay/constant/errors.go @@ -15,6 +15,10 @@ type RelayError struct { Message string Type string // upstream_error / channel_error / auth_error / request_error Cause error + // ResponseWritten 表示 adaptor 已直接向客户端写入响应体, + // 上层错误写入器(WriteRelayError / WriteClaudeRelayError / WriteGeminiRelayError)应跳过二次写入。 + // 字段对重试 / 健康度 / 计费 / 状态码重映射等逻辑透明(仍按 StatusCode 判断)。 + ResponseWritten bool } func (e *RelayError) Error() string { diff --git a/relay/handler/chat_handler.go b/relay/handler/chat_handler.go index 25288a5..a2a041b 100644 --- a/relay/handler/chat_handler.go +++ b/relay/handler/chat_handler.go @@ -145,6 +145,12 @@ func WriteRelayError(w http.ResponseWriter, err error) { return } + // adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入 + var prewritten *constant.RelayError + if errors.As(err, &prewritten) && prewritten.ResponseWritten { + return + } + var relayErr *constant.RelayError var rateLimitErr *RelayErrorWithRateLimit statusCode := http.StatusInternalServerError diff --git a/relay/handler/claude_handler.go b/relay/handler/claude_handler.go index 9c6427a..46575c1 100644 --- a/relay/handler/claude_handler.go +++ b/relay/handler/claude_handler.go @@ -25,6 +25,12 @@ func WriteClaudeRelayError(w http.ResponseWriter, err error) { return } + // adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入 + var prewritten *constant.RelayError + if errors.As(err, &prewritten) && prewritten.ResponseWritten { + return + } + var relayErr *constant.RelayError var rateLimitErr *RelayErrorWithRateLimit statusCode := http.StatusInternalServerError diff --git a/relay/handler/error_writer_test.go b/relay/handler/error_writer_test.go new file mode 100644 index 0000000..5aab9b9 --- /dev/null +++ b/relay/handler/error_writer_test.go @@ -0,0 +1,67 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/qianfree/team-api/relay/constant" +) + +// TestErrorWriters_SkipResponseWritten 验证当 adaptor 已置 ResponseWritten=true 时, +// 三个错误写入器都跳过二次写入(不写 header、不写 body)。 +// 这是 Gemini/OpenAI「双重写入」修复的核心机制兜底。 +func TestErrorWriters_SkipResponseWritten(t *testing.T) { + writtenErr := constant.NewUpstreamError(http.StatusTooManyRequests, "upstream 429", nil) + writtenErr.ResponseWritten = true + + cases := []struct { + name string + fn func(http.ResponseWriter, error) + }{ + {"WriteRelayError", WriteRelayError}, + {"WriteClaudeRelayError", WriteClaudeRelayError}, + {"WriteGeminiRelayError", WriteGeminiRelayError}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c.fn(rec, writtenErr) + + if rec.Code != http.StatusOK { + t.Errorf("Code = %d, want 200 (no WriteHeader should happen)", rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("body = %q, want empty (no write should happen)", rec.Body.String()) + } + }) + } +} + +// TestErrorWriters_WriteNormalError 验证普通(未标记 ResponseWritten)的 RelayError +// 仍被三个写入器正常写入一次,确保短路逻辑不影响正常错误路径。 +func TestErrorWriters_WriteNormalError(t *testing.T) { + normalErr := constant.NewUpstreamError(http.StatusTooManyRequests, "upstream 429", nil) + + cases := []struct { + name string + fn func(http.ResponseWriter, error) + }{ + {"WriteRelayError", WriteRelayError}, + {"WriteClaudeRelayError", WriteClaudeRelayError}, + {"WriteGeminiRelayError", WriteGeminiRelayError}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c.fn(rec, normalErr) + + if rec.Code != http.StatusTooManyRequests { + t.Errorf("Code = %d, want 429", rec.Code) + } + if rec.Body.Len() == 0 { + t.Errorf("body empty, want non-empty error response") + } + }) + } +} diff --git a/relay/handler/gemini_handler.go b/relay/handler/gemini_handler.go index 87598ff..ad49c1e 100644 --- a/relay/handler/gemini_handler.go +++ b/relay/handler/gemini_handler.go @@ -61,6 +61,12 @@ func HandleGeminiModelDetail(ctx context.Context, tenantID int64, modelName stri // WriteGeminiRelayError 写入 Gemini 格式的错误响应 func WriteGeminiRelayError(w http.ResponseWriter, err error) { + // adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入 + var prewritten *constant.RelayError + if errors.As(err, &prewritten) && prewritten.ResponseWritten { + return + } + var relayErr *constant.RelayError var rateLimitErr *RelayErrorWithRateLimit statusCode := http.StatusInternalServerError diff --git a/test/webhook_receiver/main.go b/test/webhook_receiver/main.go new file mode 100644 index 0000000..089aa0b --- /dev/null +++ b/test/webhook_receiver/main.go @@ -0,0 +1,168 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +// WebhookEvent 接收到的 webhook 事件格式 +type WebhookEvent struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + TenantID int64 `json:"tenant_id"` + Timestamp string `json:"timestamp"` + Data map[string]interface{} `json:"data"` +} + +// 全局变量:webhook 密钥,用于验签 +var secretKey string + +func main() { + // 从命令行参数或环境变量获取密钥 + secretKey = os.Getenv("WEBHOOK_SECRET") + if len(os.Args) > 1 { + secretKey = os.Args[1] + } + + if secretKey == "" { + log.Println("⚠️ 未提供 webhook 密钥,签名验证将跳过") + log.Println(" 用法: go run main.go ") + log.Println(" 或设置环境变量: WEBHOOK_SECRET=whk_xxx go run main.go") + log.Println() + } else { + log.Printf("🔑 已加载密钥: %s...%s\n", secretKey[:8], secretKey[len(secretKey)-4:]) + } + + port := os.Getenv("PORT") + if port == "" { + port = "9090" + } + + http.HandleFunc("/webhook", handleWebhook) + http.HandleFunc("/health", handleHealth) + http.HandleFunc("/", handleNotFound) + + addr := ":" + port + log.Printf("🚀 Webhook 接收服务启动在 http://localhost%s/webhook\n", addr) + log.Printf(" 健康检查: http://localhost%s/health\n", addr) + log.Printf(" 等待接收 webhook 事件...\n") + log.Println(strings.Repeat("=", 70)) + + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("服务启动失败: %v", err) + } +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func handleNotFound(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "error": "not_found", + "message": "请使用 POST /webhook 接收事件", + }) +} + +func handleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method_not_allowed"}`, http.StatusMethodNotAllowed) + return + } + + now := time.Now() + receiveTime := now.Format("2006-01-02 15:04:05.000") + + // 读取请求体 + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("❌ [%s] 读取请求体失败: %v\n", receiveTime, err) + http.Error(w, `{"error":"read_body_failed"}`, http.StatusBadRequest) + return + } + defer r.Body.Close() + + // 提取关键 Header + signature := r.Header.Get("X-Webhook-Signature") + timestamp := r.Header.Get("X-Webhook-Timestamp") + eventType := r.Header.Get("X-Webhook-Event") + eventID := r.Header.Get("X-Webhook-ID") + + log.Printf("📩 [%s] 收到 Webhook 事件", receiveTime) + log.Println(strings.Repeat("-", 50)) + log.Printf(" Event ID: %s", eventID) + log.Printf(" Event Type: %s", eventType) + log.Printf(" Timestamp: %s", timestamp) + + // 签名验证 + if secretKey != "" { + expectedSig := computeSignature(secretKey, timestamp, body) + if hmac.Equal([]byte(signature), []byte(expectedSig)) { + log.Printf(" 签名验证: ✅ 通过") + } else { + log.Printf(" 签名验证: ❌ 失败!") + log.Printf(" 期望签名: %s", expectedSig) + log.Printf(" 实际签名: %s", signature) + } + } else { + log.Printf(" 签名验证: ⏭️ 跳过(未配置密钥)") + } + + // 解析 Payload + var event WebhookEvent + if err := json.Unmarshal(body, &event); err != nil { + log.Printf(" JSON 解析: ❌ 失败: %v", err) + log.Printf(" 原始 Body: %s", string(body)) + } else { + log.Printf(" Tenant ID: %d", event.TenantID) + log.Printf(" Event Time: %s", event.Timestamp) + + // 格式化输出 Data + if event.Data != nil { + dataJSON, _ := json.MarshalIndent(event.Data, " ", " ") + log.Printf(" Data:") + log.Printf(" %s", string(dataJSON)) + } + } + + // 原始 Body(美化的 JSON) + var prettyBody map[string]interface{} + if err := json.Unmarshal(body, &prettyBody); err == nil { + beautified, _ := json.MarshalIndent(prettyBody, " ", " ") + log.Printf(" 完整 Payload:") + log.Printf(" %s", string(beautified)) + } else { + log.Printf(" 原始 Body: %s", string(body)) + } + + log.Println(strings.Repeat("-", 50)) + log.Printf(" ✅ 已处理 (%d bytes)", len(body)) + log.Println(strings.Repeat("=", 70)) + + // 返回 200 OK + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "received", + "event_id": eventID, + }) +} + +// computeSignature 计算 HMAC-SHA256 签名,与系统实现一致 +// 签名内容: timestamp + "." + body +func computeSignature(secret, timestamp string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(timestamp + "." + string(body))) + return hex.EncodeToString(mac.Sum(nil)) +}