feat(api): standardize response envelope behavior

Add shared response DTOs and enhance the response envelope middleware with
excluded paths, trace ID generation fallback, and automatic extraction of
error details from handler responses. Update default business code mapping
for 503 and 504, and adjust idempotency detection to only treat the new
envelope format as already-wrapped.

BREAKING CHANGE: responses using the old envelope format (e.g., string
`code`) are now wrapped into the new standard envelope.
This commit is contained in:
zenfun
2026-01-10 00:59:45 +08:00
parent 6af938448e
commit 26733be020
3 changed files with 238 additions and 32 deletions

View File

@@ -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) {