fix:修复错误信息双写导致响应异常
This commit is contained in:
1 parent
88447ac558
commit
ccecc512db
15 files changed
+358
-34
No files matched your search
@@ -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 并聚合
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <secret_key>")
|
||||
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))
|
||||
}
|
||||
Reference in new issue
Block a user