diff --git a/internal/dto/response.go b/internal/dto/response.go new file mode 100644 index 0000000..8577c67 --- /dev/null +++ b/internal/dto/response.go @@ -0,0 +1,60 @@ +package dto + +// ResponseEnvelope is the standard API response wrapper. +// All JSON API responses are wrapped in this envelope format. +// @Description Standard API response envelope +type ResponseEnvelope struct { + // Business code: 0 for success, non-zero for errors + // 0 = success + // 1xxx = common errors (invalid param, unauthorized, forbidden, rate limited) + // 4xxx = client errors (resource not found, conflict, invalid state) + // 5xxx = server errors (internal error, service unavailable, timeout) + Code int `json:"code" example:"0"` + + // Human-readable message: "success" for success, error description for errors + Message string `json:"message" example:"success"` + + // Response data: business payload for success, null for errors + Data any `json:"data"` + + // Request trace ID for debugging and log correlation + TraceID string `json:"trace_id" example:"a1b2c3d4e5f6g7h8"` + + // Optional error details (e.g., field validation errors) + Details any `json:"details,omitempty"` +} + +// ErrorResponse is returned when an error occurs. +// @Description Error response format +type ErrorResponse struct { + // Business error code (non-zero) + Code int `json:"code" example:"4001"` + + // Error message + Message string `json:"message" example:"resource not found"` + + // Always null for errors + Data any `json:"data" example:"null"` + + // Request trace ID + TraceID string `json:"trace_id" example:"a1b2c3d4e5f6g7h8"` + + // Optional structured error details + Details any `json:"details,omitempty"` +} + +// SuccessResponse is returned for successful operations. +// @Description Success response format +type SuccessResponse struct { + // Always 0 for success + Code int `json:"code" example:"0"` + + // Always "success" + Message string `json:"message" example:"success"` + + // Response payload + Data any `json:"data"` + + // Request trace ID + TraceID string `json:"trace_id" example:"a1b2c3d4e5f6g7h8"` +} diff --git a/internal/middleware/response_envelope.go b/internal/middleware/response_envelope.go index f8f4a01..fdaf0c5 100644 --- a/internal/middleware/response_envelope.go +++ b/internal/middleware/response_envelope.go @@ -2,6 +2,8 @@ package middleware import ( "bytes" + "crypto/rand" + "encoding/hex" "encoding/json" "net/http" "strings" @@ -31,11 +33,18 @@ const ( ) const ( - businessCodeKey = "response_business_code" - errorDetailsKey = "response_error_details" - errorMessageKey = "response_error_message" + businessCodeKey = "response_business_code" + errorDetailsKey = "response_error_details" + errorMessageKey = "response_error_message" ) +// Paths that should not be wrapped +var excludedPaths = map[string]bool{ + "/health": true, + "/debug/vars": true, + "/internal/metrics": true, +} + type responseEnvelope struct { Code int `json:"code"` Message string `json:"message"` @@ -122,6 +131,12 @@ func SetErrorMessage(c *gin.Context, message string) { func ResponseEnvelope() gin.HandlerFunc { return func(c *gin.Context) { + // Skip excluded paths + if isExcludedPath(c.Request.URL.Path) { + c.Next() + return + } + originalWriter := c.Writer writer := &envelopeWriter{ResponseWriter: originalWriter} c.Writer = writer @@ -174,6 +189,17 @@ func ResponseEnvelope() gin.HandlerFunc { } } + // Get details: from context first, then from original response + details := errorDetailsFromContext(c) + if details == nil && isError && objOK { + if raw, ok := obj["details"]; ok { + var d any + if err := json.Unmarshal(raw, &d); err == nil { + details = d + } + } + } + var data json.RawMessage if isError { data = json.RawMessage("null") @@ -186,7 +212,7 @@ func ResponseEnvelope() gin.HandlerFunc { Message: message, Data: data, TraceID: traceID, - Details: errorDetailsFromContext(c), + Details: details, } payload, err := json.Marshal(envelope) if err != nil { @@ -200,6 +226,17 @@ func ResponseEnvelope() gin.HandlerFunc { } } +func isExcludedPath(path string) bool { + if excludedPaths[path] { + return true + } + // Also exclude swagger paths + if strings.HasPrefix(path, "/swagger") { + return true + } + return false +} + func businessCodeFromContext(c *gin.Context) int { if c == nil { return 0 @@ -243,7 +280,7 @@ func errorMessageFromContext(c *gin.Context) string { func getTraceID(c *gin.Context) string { if c == nil { - return "" + return generateTraceID() } // Try to get from context first (set by RequestID middleware) if id, ok := c.Get("request_id"); ok { @@ -252,7 +289,17 @@ func getTraceID(c *gin.Context) string { } } // Fallback to header - return c.GetHeader("X-Request-ID") + if id := c.GetHeader("X-Request-ID"); id != "" { + return id + } + // Generate UUID as fallback + return generateTraceID() +} + +func generateTraceID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) } func defaultBusinessCode(status int) int { @@ -273,6 +320,10 @@ func defaultBusinessCode(status int) int { return CodeRateLimited case status >= http.StatusBadRequest && status < http.StatusInternalServerError: return CodeInvalidParam + case status == http.StatusServiceUnavailable: + return CodeServiceUnavailable + case status == http.StatusGatewayTimeout: + return CodeTimeout case status >= http.StatusInternalServerError: return CodeInternalError default: @@ -311,33 +362,23 @@ func isEnvelopeObject(obj map[string]json.RawMessage) bool { if obj == nil { return false } - // Check for new envelope format (code is number, has trace_id) + // Only recognize new envelope format: must have trace_id and code must be number codeRaw, hasCode := obj["code"] _, hasData := obj["data"] _, hasMessage := obj["message"] _, hasTraceID := obj["trace_id"] - if !hasCode || !hasData || !hasMessage { + if !hasCode || !hasData || !hasMessage || !hasTraceID { return false } - // If has trace_id, it's definitely our envelope - if hasTraceID { - return true - } - - // Check if code is a number (new format) or string (old format) - // Both should be treated as envelope to avoid double-wrapping + // Code must be a number (int) var codeNum int - if json.Unmarshal(codeRaw, &codeNum) == nil { - return true - } - var codeStr string - if json.Unmarshal(codeRaw, &codeStr) == nil { - return true + if json.Unmarshal(codeRaw, &codeNum) != nil { + return false } - return false + return true } func writeStatusOnly(w gin.ResponseWriter, status int) { diff --git a/internal/middleware/response_envelope_test.go b/internal/middleware/response_envelope_test.go index 00175fc..d814af5 100644 --- a/internal/middleware/response_envelope_test.go +++ b/internal/middleware/response_envelope_test.go @@ -163,6 +163,38 @@ func TestResponseEnvelope_WithDetails(t *testing.T) { } } +func TestResponseEnvelope_AutoExtractDetails(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + r.Use(RequestID()) + r.Use(ResponseEnvelope()) + r.POST("/validate", func(c *gin.Context) { + // Handler returns details in response, middleware should extract it + c.JSON(http.StatusBadRequest, gin.H{ + "error": "参数校验失败", + "details": map[string]string{"email": "格式错误"}, + }) + }) + + req := httptest.NewRequest(http.MethodPost, "/validate", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + var env struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` + Details map[string]any `json:"details"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + if env.Details["email"] != "格式错误" { + t.Fatalf("expected details.email='格式错误', got %v", env.Details) + } +} + func TestResponseEnvelope_Idempotent(t *testing.T) { gin.SetMode(gin.TestMode) @@ -201,13 +233,14 @@ func TestResponseEnvelope_Idempotent(t *testing.T) { } } -func TestResponseEnvelope_IdempotentOldFormat(t *testing.T) { +func TestResponseEnvelope_OldFormatGetsWrapped(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() + r.Use(RequestID()) r.Use(ResponseEnvelope()) r.GET("/old-wrapped", func(c *gin.Context) { - // Old format with string code + // Old format with string code - should be wrapped now c.JSON(http.StatusOK, gin.H{ "code": "ok", "data": gin.H{"id": 1}, @@ -219,17 +252,25 @@ func TestResponseEnvelope_IdempotentOldFormat(t *testing.T) { rr := httptest.NewRecorder() r.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + var env struct { + Code int `json:"code"` + Message string `json:"message"` + Data map[string]any `json:"data"` + TraceID string `json:"trace_id"` } - - var env map[string]any if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil { t.Fatalf("unmarshal envelope: %v", err) } - // Should pass through unchanged - if env["code"] != "ok" { - t.Fatalf("expected code='ok', got %v", env["code"]) + // Old format should be wrapped, so code should be 0 (success) + if env.Code != 0 { + t.Fatalf("expected code=0, got %d", env.Code) + } + // Data should contain the old response + if env.Data["code"] != "ok" { + t.Fatalf("expected data.code='ok', got %v", env.Data["code"]) + } + if env.TraceID == "" { + t.Fatal("expected trace_id to be set") } } @@ -259,6 +300,34 @@ func TestResponseEnvelope_TraceIDFromHeader(t *testing.T) { } } +func TestResponseEnvelope_TraceIDFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + // No RequestID middleware + r.Use(ResponseEnvelope()) + r.GET("/trace", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/trace", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + var env struct { + TraceID string `json:"trace_id"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + if env.TraceID == "" { + t.Fatal("expected trace_id to be generated") + } + if len(env.TraceID) != 32 { + t.Fatalf("expected 32-char hex trace_id, got %q", env.TraceID) + } +} + func TestResponseEnvelope_NonJSON(t *testing.T) { gin.SetMode(gin.TestMode) @@ -280,6 +349,41 @@ func TestResponseEnvelope_NonJSON(t *testing.T) { } } +func TestResponseEnvelope_ExcludedPaths(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + r.Use(ResponseEnvelope()) + r.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + r.GET("/swagger/doc.json", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"openapi": "3.0.0"}) + }) + + tests := []struct { + path string + }{ + {"/health"}, + {"/swagger/doc.json"}, + } + + for _, tt := range tests { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response for %s: %v", tt.path, err) + } + // Should not have envelope fields + if _, hasCode := resp["code"]; hasCode { + t.Fatalf("expected %s to not be wrapped, but found 'code' field", tt.path) + } + } +} + func TestResponseEnvelope_DefaultCodes(t *testing.T) { testCases := []struct { status int @@ -294,7 +398,8 @@ func TestResponseEnvelope_DefaultCodes(t *testing.T) { {http.StatusConflict, CodeResourceConflict}, {http.StatusTooManyRequests, CodeRateLimited}, {http.StatusInternalServerError, CodeInternalError}, - {http.StatusServiceUnavailable, CodeInternalError}, + {http.StatusServiceUnavailable, CodeServiceUnavailable}, + {http.StatusGatewayTimeout, CodeTimeout}, } for _, tc := range testCases {