package middleware import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "kra/internal/config" "kra/internal/routecatalog" "kra/internal/service/dto" systemservice "kra/internal/service/system" "github.com/gin-gonic/gin" ) const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed" func OperationAudit(runtime *config.Store, recorder *systemservice.AuditRecorder) gin.HandlerFunc { return func(c *gin.Context) { if runtime == nil || recorder == nil { c.Next() return } routePath := c.FullPath() if routePath == "" { routePath = c.Request.URL.Path } path := c.Request.URL.Path if !routecatalog.ShouldAudit(c.Request.Method, routePath) { c.Next() return } var requestBody []byte maxBytes := 1024 if snapshot := runtime.Snapshot(); snapshot != nil && snapshot.Admin != nil && snapshot.Admin.Zap != nil && snapshot.Admin.Zap.AccessLogMaxBytes > 0 { maxBytes = snapshot.Admin.Zap.AccessLogMaxBytes } if c.Request.Method == http.MethodGet { requestBody = operationQueryBody(c.Request.URL.RawQuery) } else if _, ok := c.Get(ctxReqBodyKey); ok { } else if c.Request.Body != nil { // Fallback for tests or custom middleware chains that omit AccessLog. if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") { requestBody = []byte("[文件]") } else { limited := http.MaxBytesReader(c.Writer, c.Request.Body, defaultRequestBodyLimit) var err error requestBody, err = io.ReadAll(limited) if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { c.AbortWithStatus(http.StatusRequestEntityTooLarge) } else { c.AbortWithStatus(http.StatusBadRequest) } return } c.Request.Body = io.NopCloser(bytes.NewReader(requestBody)) } } started := time.Now() c.Next() userID := uint(0) if claims := Claims(c); claims != nil { userID = claims.ID } requestID, _ := c.Get("request_id") status := c.Writer.Status() responseBody := "" if value, ok := c.Get(ctxRespTextKey); ok { responseBody = stringValue(value) } else if value, ok := c.Get(ctxRespBufferKey); ok { if body, bok := value.(*bytes.Buffer); bok { responseBody = redactJSON(body.Bytes(), c.Writer.Header().Get("Content-Type"), maxBytes) } } if truncated, _ := c.Get(ctxRespTruncatedKey); truncated == true { responseBody = "[超出记录长度]" } errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String() operationBody := "" capturedBody := false if c.Request.Method != "GET" { if value, ok := c.Get(ctxReqBodyKey); ok { // AccessLog has already captured, redacted, summarized, and bounded the // request body. Treat that value as final so audit does not process it a // second time. operationBody = stringValue(value) capturedBody = true } } if !capturedBody { operationBody = operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes) } if !capturedBody && routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicyPaymentConfig { operationBody = paymentConfigSummary(requestBody) } if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil { // Preserve the business response, but expose audit persistence failures // to the global access/error logging pipeline. c.Set(ctxOperationAuditPersistFailedKey, true) _ = c.Error(fmt.Errorf("operation audit persist: %w", err)) } } } func operationQueryBody(raw string) []byte { parsed, _ := url.ParseQuery(raw) values := make(map[string]string, len(parsed)) for key, items := range parsed { if len(items) > 0 { value := items[len(items)-1] if isSensitivePayloadKey(key) { value = redactedValue } values[key] = value } } body, _ := json.Marshal(&values) return body } func operationRequestBody(raw []byte, contentType string, limit int) string { if strings.Contains(contentType, "multipart/form-data") { return "[文件]" } text := string(raw) if strings.Contains(strings.ToLower(contentType), "json") && text != "" { var value any if json.Unmarshal(raw, &value) == nil { maskOperationBody(value) if encoded, err := json.Marshal(value); err == nil { text = string(encoded) } } } if len(text) > limit { return "[超出记录长度]" } return text } func maskOperationBody(value any) { switch current := value.(type) { case map[string]any: for key, item := range current { if isSensitivePayloadKey(key) { current[key] = redactedValue continue } maskOperationBody(item) } case []any: for _, item := range current { maskOperationBody(item) } } }