package middleware import ( "bytes" "errors" "io" "log/slog" "net/http" "strings" "time" "kra/internal/biz" "kra/internal/conf" "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 *conf.Runtime, 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 := multipart && isMediaUploadRoute(c.FullPath()) config := runtime.Admin() bodyLimit := defaultRequestBodyLimit if mediaUpload { bodyLimit = biz.DefaultMaxMediaFileSize + (1 << 20) if config != nil && config.Media != nil && config.Media.MaxFileSize > 0 { bodyLimit = config.Media.MaxFileSize + (1 << 20) } } bytesIn := c.Request.ContentLength maxBytes := 1 << 20 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": 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": CodeError, "msg": "请求体超过大小上限"}) } else { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": 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 } logLimit := 1024 if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 { logLimit = int(config.Zap.AccessLogMaxBytes) } requestText := "" if multipart { requestText = "[文件]" } else { requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit) } c.Set(ctxReqBodyKey, requestText) 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) 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") 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", c.Request.URL.RawQuery} if config != nil && config.Zap != nil && config.Zap.AccessReqHeaders { attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header)) } if config != nil && config.Zap != nil && config.Zap.AccessReqBody { attributes = append(attributes, "req_body", requestText) } if config != nil && config.Zap != nil && config.Zap.AccessRespData { attributes = append(attributes, "resp_data", responseText) } if privateErrors != "" { attributes = append(attributes, "error_msg", privateErrors) } logger.InfoContext(c.Request.Context(), "请求完成", attributes...) } } func isMediaUploadRoute(route string) bool { return strings.HasSuffix(route, "/fileUploadAndDownload/upload") || strings.HasSuffix(route, "/mediaUpload/chunk") } func redactHeaders(headers map[string][]string) map[string]string { out := make(map[string]string, len(headers)) for key, values := range headers { lower := strings.ToLower(key) if lower == "authorization" || lower == "cookie" || lower == "set-cookie" || lower == "x-token" { out[key] = "***" } else { out[key] = strings.Join(values, ",") } } return out } func stringValueFromContext(c *gin.Context, key string) string { value, _ := c.Get(key) return stringValue(value) }