package middleware import ( "bytes" "crypto/sha256" "encoding/hex" "errors" "io" "kra/internal/biz/system" "log/slog" "mime" "net/http" "net/url" "strconv" "strings" "time" "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()) var config *conf.AdminBackend if runtime != nil { config = runtime.Admin() } bodyLimit := defaultRequestBodyLimit if mediaUpload { bodyLimit = system.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) } paymentCallback := isPaymentCallbackPath(c.Request.URL.Path) paymentConfigWrite := isPaymentIntegrationConfigWrite(c.Request.Method, c.Request.URL.Path) 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) 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 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), } } if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqHeaders { attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header)) } if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqBody { attributes = append(attributes, "req_body", requestText) } if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessRespData { attributes = append(attributes, "resp_data", responseText) } if !paymentCallback && 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 isPaymentCallbackPath(path string) bool { parts := strings.Split(strings.Trim(path, "/"), "/") for index := 0; index+1 < len(parts); index++ { if parts[index] == "payment" && parts[index+1] == "callback" { return true } } return false } 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 isPaymentIntegrationConfigWrite(method, path string) bool { if method != http.MethodPut { return false } parts := strings.Split(strings.Trim(path, "/"), "/") for index := 0; index+3 < len(parts); index++ { if parts[index] == "integration" && parts[index+1] == "configs" && parts[index+2] == "payment" && parts[index+3] != "" { return true } } return false } 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 { 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 redactQuery(raw string) string { if strings.TrimSpace(raw) == "" { return "" } values, _ := url.ParseQuery(raw) for key := range values { if sensitiveQueryKey(key) { values[key] = []string{"***"} } } return values.Encode() } func sensitiveQueryKey(key string) bool { normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", "")) switch normalized { case "token", "accesstoken", "refreshtoken", "authorization", "apikey", "secret": return true default: return strings.HasSuffix(normalized, "token") } } func stringValueFromContext(c *gin.Context, key string) string { value, _ := c.Get(key) return stringValue(value) }