package middleware import ( "bytes" "crypto/sha256" "encoding/hex" "errors" "io" "kra/internal/biz/system" httpx "kra/internal/server/httpx" "log/slog" "mime" "net/http" "net/url" "strconv" "strings" "time" "kra/internal/config" "kra/internal/routecatalog" "github.com/gin-gonic/gin" ) const defaultRequestBodyLimit int64 = 8 << 20 // AccessLog is the single global request/response capture point, matching // the reference middleware ordering and making every HTTP request observable. func AccessLog(runtime *config.Store, logger *slog.Logger, version string) gin.HandlerFunc { return func(c *gin.Context) { started := time.Now() var requestBody []byte multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") mediaUpload := routecatalog.BodyPolicyFor(c.Request.Method, c.Request.URL.Path) == routecatalog.BodyPolicyUpload var admin *config.Admin if runtime != nil { if snapshot := runtime.Snapshot(); snapshot != nil { admin = snapshot.Admin } } bodyLimit := defaultRequestBodyLimit if mediaUpload { mediaSettings := system.MediaSettings{} if admin != nil && admin.Media != nil { mediaSettings.MaxFileSize = admin.Media.MaxFileSize } bodyLimit = mediaSettings.EffectiveMaxFileSize() + (1 << 20) } bytesIn := c.Request.ContentLength logLimit := 1024 if admin != nil && admin.Zap != nil && admin.Zap.AccessLogMaxBytes > 0 { logLimit = admin.Zap.AccessLogMaxBytes } maxBytes := 1 << 20 if logLimit > maxBytes { maxBytes = logLimit } writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes} c.Writer = writer c.Header("X-Kra-Version", version) requestReadFailed := c.Request.ContentLength > bodyLimit if requestReadFailed { c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"code": httpx.CodeError, "msg": "请求体超过大小上限"}) } else if c.Request.Body != nil && !mediaUpload { limited := http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit) var err error requestBody, err = io.ReadAll(limited) if err != nil { requestReadFailed = true var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"code": httpx.CodeError, "msg": "请求体超过大小上限"}) } else { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": httpx.CodeError, "msg": "请求体读取失败"}) } } else { c.Request.Body = io.NopCloser(bytes.NewReader(requestBody)) bytesIn = int64(len(requestBody)) } } else if c.Request.Body != nil { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit) } if bytesIn < 0 { bytesIn = 0 } bodyPolicy := routecatalog.BodyPolicyFor(c.Request.Method, c.Request.URL.Path) paymentCallback := bodyPolicy == routecatalog.BodyPolicyPaymentCallback paymentConfigWrite := bodyPolicy == routecatalog.BodyPolicyPaymentConfig requestText := "" if paymentCallback { requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type")) } else if paymentConfigWrite { requestText = paymentConfigSummary(requestBody) } else if multipart { requestText = "[文件]" } else { requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit) } c.Set(ctxReqBodyKey, requestText) c.Set(ctxRespTruncatedKey, writer.Truncated()) if paymentCallback { // Callback acknowledgements and provider payloads must not flow into // the generic response/error audit pipeline. c.Set(ctxRespBufferKey, &bytes.Buffer{}) } else { c.Set(ctxRespBufferKey, &writer.body) } if !requestReadFailed { c.Next() } if logger == nil { return } responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit) if writer.Truncated() { responseText = "[超出记录长度]" } if paymentCallback { responseText = "[支付回调响应已省略]" } userID, authorityID := uint(0), uint(0) if claims := Claims(c); claims != nil { userID, authorityID = claims.ID, claims.AuthorityID } route := c.FullPath() if route == "" { route = "unmatched" } bytesOut := int64(c.Writer.Size()) if bytesOut < 0 { bytesOut = 0 } privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n") var attributes []any if paymentCallback { attributes = []any{ "mod", "payment-callback", "payment_provider", paymentCallbackProvider(c.Request.URL.Path), "http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(), "request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"), "bytes_in", bytesIn, "bytes_out", bytesOut, "error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "payment_callback", true, "payment_callback_summary", requestText, } } else { attributes = []any{ "mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route, "http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(), "request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"), "bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID, "error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(), "req_query", redactQuery(c.Request.URL.RawQuery), } } // A payment callback is logged through its own attribute set: neither the // configurable request/response capture nor the private error text may leak // provider payloads into the generic access log. if !paymentCallback { var zap *config.Zap if admin != nil { zap = admin.Zap } if zap != nil { if zap.AccessReqHeaders { attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header)) } if zap.AccessReqBody { attributes = append(attributes, "req_body", requestText) } if zap.AccessRespData { attributes = append(attributes, "resp_data", responseText) } } if privateErrors != "" { attributes = append(attributes, "error_msg", privateErrors) } } logger.InfoContext(c.Request.Context(), "请求完成", attributes...) } } func paymentCallbackProvider(path string) string { parts := strings.Split(strings.Trim(path, "/"), "/") for index := 0; index+2 < len(parts); index++ { if parts[index] == "payment" && parts[index+1] == "callback" { return parts[index+2] } } return "unknown" } func paymentCallbackSummary(body []byte, contentType string) string { mediaType, _, err := mime.ParseMediaType(contentType) if err != nil || mediaType == "" { mediaType = strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]) } if mediaType == "" { mediaType = "unknown" } digest := sha256.Sum256(body) return "[支付回调正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + " content_type=" + mediaType + "]" } func paymentConfigSummary(body []byte) string { digest := sha256.Sum256(body) return "[支付配置正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + "]" } func redactHeaders(headers map[string][]string) map[string]string { out := make(map[string]string, len(headers)) for key, values := range headers { if isSensitiveHeader(key) { out[key] = redactedValue } else { out[key] = strings.Join(values, ",") } } return out } func redactQuery(raw string) string { if strings.TrimSpace(raw) == "" { return "" } values, _ := url.ParseQuery(raw) for key := range values { if isSensitivePayloadKey(key) { values[key] = []string{redactedValue} } } return values.Encode() } func stringValueFromContext(c *gin.Context, key string) string { value, _ := c.Get(key) return stringValue(value) }