package middleware import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "kra/internal/conf" "kra/internal/service" "kra/internal/service/dto" "github.com/gin-gonic/gin" ) const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed" func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path if !recordsOperation(c.Request.Method, path) { c.Next() return } var requestBody []byte maxBytes := 1024 if config := runtime.Admin(); config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 { maxBytes = int(config.Zap.AccessLogMaxBytes) } if c.Request.Method == http.MethodGet { requestBody = operationQueryBody(c.Request.URL.RawQuery) } else if value, ok := c.Get(ctxReqBodyKey); ok { requestBody = []byte(stringValue(value)) } 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)) } } // The reference middleware captures up to the global 1 MiB response // safety limit and only applies the configured operation-log limit when a // download response is recorded. Using maxBytes here would silently // truncate ordinary JSON responses before that decision is possible. started := time.Now() c.Next() userID := uint(0) if claims := Claims(c); claims != nil { userID = claims.ID } else if value, err := strconv.ParseUint(c.GetHeader("x-user-id"), 10, 64); err == nil { userID = uint(value) } requestID, _ := c.Get("request_id") status := c.Writer.Status() responseBody := "" if value, ok := c.Get(ctxRespBufferKey); ok { if body, bok := value.(*bytes.Buffer); bok { responseBody = body.String() } } if isDownloadResponse(c) && len(responseBody) > maxBytes { responseBody = "[超出记录长度]" } errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String() operationBody := operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes) if isPaymentIntegrationConfigWrite(c.Request.Method, path) { 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 { query, _ := url.QueryUnescape(raw) values := make(map[string]string) for _, item := range strings.Split(query, "&") { parts := strings.Split(item, "=") if len(parts) == 2 { values[parts[0]] = parts[1] } } 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 { normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", "")) if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" || normalized == "appkey" || normalized == "mchkey" || normalized == "apiv3key" || normalized == "clientcert" || normalized == "clientkey" || normalized == "platformcert" || normalized == "platformserialno" || normalized == "credentialcode" || normalized == "certfile" || normalized == "keyfile" || normalized == "publickey" || normalized == "rootcert" || normalized == "appcert" || normalized == "webhookid" { current[key] = "***" continue } maskOperationBody(item) } case []any: for _, item := range current { maskOperationBody(item) } } } func isDownloadResponse(c *gin.Context) bool { header := c.Writer.Header() return strings.Contains(header.Get("Pragma"), "public") || strings.Contains(header.Get("Expires"), "0") || strings.Contains(header.Get("Cache-Control"), "must-revalidate, post-check=0, pre-check=0") || strings.Contains(header.Get("Content-Type"), "application/force-download") || strings.Contains(header.Get("Content-Type"), "application/octet-stream") || strings.Contains(header.Get("Content-Type"), "application/vnd.ms-excel") || strings.Contains(header.Get("Content-Type"), "application/download") || strings.Contains(header.Get("Content-Disposition"), "attachment") || strings.Contains(header.Get("Content-Transfer-Encoding"), "binary") } // recordsOperation mirrors the routes on which operation records are enabled. // Matching by suffix keeps the behavior stable when router-prefix is configured. func recordsOperation(method, path string) bool { for route := range operationRoutes { parts := strings.SplitN(route, " ", 2) if len(parts) != 2 || parts[0] != method || !operationPathMatches(parts[1], path) { continue } return true } return false } func operationPathMatches(pattern, path string) bool { patternParts := strings.Split(strings.Trim(pattern, "/"), "/") pathParts := strings.Split(strings.Trim(path, "/"), "/") if len(pathParts) < len(patternParts) { return false } pathParts = pathParts[len(pathParts)-len(patternParts):] for index, patternPart := range patternParts { if strings.HasPrefix(patternPart, "*") { return index <= len(pathParts) } if index >= len(pathParts) || (strings.HasPrefix(patternPart, ":") == false && patternPart != pathParts[index]) { return false } } return len(patternParts) == len(pathParts) } func routeSuffix(path string) string { bestIndex := -1 bestPath := path for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/", "/integration/", "/payment/"} { if index := strings.Index(path, marker); index >= 0 { // Router prefixes may themselves contain a registered route marker // (for example /api/integration/...). Keep the deepest match so the // policy and audit route remain the actual application endpoint. if index > bestIndex { bestIndex = index bestPath = path[index:] } } } return bestPath } var operationRoutes = func() map[string]struct{} { values := []string{ "POST /user/admin_register", "POST /user/changePassword", "POST /user/setUserAuthority", "DELETE /user/deleteUser", "PUT /user/setUserInfo", "PUT /user/setSelfInfo", "POST /user/setUserAuthorities", "POST /user/setUserDepartments", "POST /user/setUserPositions", "POST /user/resetPassword", "PUT /user/setSelfSetting", "GET /api/getApiGroups", "GET /api/syncApi", "POST /api/ignoreApi", "POST /api/enterSyncApi", "POST /api/createApi", "POST /api/deleteApi", "POST /api/getApiById", "POST /api/updateApi", "DELETE /api/deleteApisByIds", "POST /api/setApiRoles", "POST /casbin/updateCasbin", "POST /authority/createAuthority", "POST /authority/deleteAuthority", "PUT /authority/updateAuthority", "POST /authority/copyAuthority", "POST /authority/setDataScope", "POST /authority/setRoleUsers", "POST /menu/addBaseMenu", "POST /menu/addMenuAuthority", "POST /menu/deleteBaseMenu", "POST /menu/updateBaseMenu", "POST /menu/setMenuRoles", "POST /department/createDepartment", "PUT /department/updateDepartment", "DELETE /department/deleteDepartment", "POST /department/setDepartmentUsers", "POST /position/createPosition", "PUT /position/updatePosition", "DELETE /position/deletePosition", "POST /position/setPositionUsers", "POST /sysDictionary/createSysDictionary", "DELETE /sysDictionary/deleteSysDictionary", "PUT /sysDictionary/updateSysDictionary", "POST /sysDictionary/importSysDictionary", "GET /sysDictionary/exportSysDictionary", "POST /sysDictionaryDetail/createSysDictionaryDetail", "DELETE /sysDictionaryDetail/deleteSysDictionaryDetail", "PUT /sysDictionaryDetail/updateSysDictionaryDetail", "POST /sysParams/createSysParams", "DELETE /sysParams/deleteSysParams", "DELETE /sysParams/deleteSysParamsByIds", "PUT /sysParams/updateSysParams", "POST /securityConfig/setSecurityConfig", "POST /system/setSystemConfig", "POST /system/reloadSystem", "POST /sysApiToken/createApiToken", "POST /sysApiToken/getApiTokenList", "POST /sysApiToken/deleteApiToken", "DELETE /sysVersion/deleteSysVersion", "DELETE /sysVersion/deleteSysVersionByIds", "POST /sysVersion/exportVersion", "POST /sysVersion/importVersion", "POST /sysExportTemplate/createSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplateByIds", "PUT /sysExportTemplate/updateSysExportTemplate", "POST /sysExportTemplate/importExcel", "DELETE /sysError/deleteSysError", "DELETE /sysError/deleteSysErrorByIds", "PUT /sysError/updateSysError", "DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds", "POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask", "POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail", "PUT /integration/configs/:kind/:provider", "POST /integration/configs/:kind/:provider/test", "DELETE /integration/configs/:kind/:provider", "POST /payment/create", "POST /payment/query", "POST /payment/refund", "POST /payment/orders/:provider/:tradeNo/refund", "POST /payment/fulfill", "POST /payment/orders/:provider/:tradeNo/fulfill", "POST /payment/providers/:provider/test", } out := make(map[string]struct{}, len(values)) for _, value := range values { out[value] = struct{}{} } return out }()