Para mantener un código limpio y adherido a los principios de diseño de software, como el Principio Abierto-Cerrado, es fundamental evitar la duplicación de lógica transversal. Registrar las operaciones de los usuarios en cada método de negocio no es escalable. Una solución elegante consiste en utilizar la Programación Orientada a Aspectos (AOP) de Spring combinada con anotaciones personalizadas.
Diseño de la Base de Datos
Se requiere una tabla para almacenar los registros de auditoría, capturando detalles como el usuario, la acción, los parámetros y el tiempo de ejecución.
CREATE TABLE system_audit_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
operator_id BIGINT NOT NULL,
action_desc VARCHAR(255) NOT NULL,
class_method VARCHAR(500) NOT NULL,
request_args TEXT,
client_ip VARCHAR(45),
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
execution_time_ms INT
);
Dependencias del Proyecto
En un entorno Spring Boot, es necesario incluir el starter de AOP. Además, se utilizará Spring Data JPA para la persistencia de los registros.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Entidad de Persistencia
Definición de la clase que mapea la tabla de auditoría utilizando JPA y Lombok para reducir el código boilerplate.
package com.example.audit.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "system_audit_log")
public class AuditRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "operator_id", nullable = false)
private Long operatorId;
@Column(name = "action_desc", nullable = false)
private String actionDescription;
@Column(name = "class_method", nullable = false)
private String classMethod;
@Column(name = "request_args", columnDefinition = "TEXT")
private String requestArguments;
@Column(name = "client_ip")
private String clientIp;
@Column(name = "executed_at")
private LocalDateTime executedAt;
@Column(name = "execution_time_ms")
private Integer executionTimeMs;
}
Anotación Personalizada
Creación de la anotación que marcará los métodos que deben ser interceptados y auditados.
package com.example.audit.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String description() default "";
}
Implementación del Aspecto
El componante que intercepta las llamadas a los métodos anotados. Se utiliza StopWatch para calcular el tiempo de ejecución y ObjectMapper para serializar los argumentos a formato JSON, mejorando la legibilidad de los datos almacenados.
package com.example.audit.aspect;
import com.example.audit.annotation.Auditable;
import com.example.audit.entity.AuditRecord;
import com.example.audit.repository.AuditRecordRepository;
import com.example.audit.util.SecurityContextUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
@Aspect
@Component
public class OperationAuditAspect {
private final AuditRecordRepository auditRepository;
private final ObjectMapper objectMapper;
public OperationAuditAspect(AuditRecordRepository auditRepository, ObjectMapper objectMapper) {
this.auditRepository = auditRepository;
this.objectMapper = objectMapper;
}
@Around("@annotation(auditable)")
public Object interceptOperation(ProceedingJoinPoint joinPoint, Auditable auditable) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
Object result = joinPoint.proceed();
stopWatch.stop();
long duration = stopWatch.getTotalTimeMillis();
persistAuditRecord(joinPoint, auditable, duration);
return result;
}
private void persistAuditRecord(ProceedingJoinPoint joinPoint, Auditable auditable, long duration) {
try {
AuditRecord record = new AuditRecord();
record.setActionDescription(auditable.description());
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
record.setClassMethod(joinPoint.getTarget().getClass().getName() + "." + method.getName());
Object[] args = joinPoint.getArgs();
record.setRequestArguments(objectMapper.writeValueAsString(args));
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
record.setClientIp(extractClientIp(request));
}
record.setOperatorId(SecurityContextUtil.getCurrentUserId());
record.setExecutedAt(LocalDateTime.now());
record.setExecutionTimeMs((int) duration);
auditRepository.save(record);
} catch (Exception e) {
System.err.println("Error al persistir el registro de auditoría: " + e.getMessage());
}
}
private String extractClientIp(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null) {
return request.getRemoteAddr();
}
return xfHeader.split(",")[0];
}
}
Integración en los Controladores
Ejemplo de cómo aplicar la enotación @Auditable en los endpoints de la API para habilitar el registro automático.
package com.example.audit.controller;
import com.example.audit.annotation.Auditable;
import com.example.audit.dto.ApiResponse;
import com.example.audit.entity.Organization;
import com.example.audit.service.OrganizationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/organizations")
public class OrganizationController {
private final OrganizationService organizationService;
public OrganizationController(OrganizationService organizationService) {
this.organizationService = organizationService;
}
@Auditable(description = "Fetch organizations by region")
@GetMapping("/by-region")
public ApiResponse<List<Organization>> getOrganizationsByRegion(@RequestParam("regionId") Long regionId) {
List<Organization> data = organizationService.findByRegion(regionId);
return ApiResponse.success(data);
}
@Auditable(description = "Fetch organizations by user role")
@GetMapping("/by-role")
public ApiResponse<List<Organization>> getOrganizationsByRole() {
List<Organization> data = organizationService.findByCurrentUserRole();
return ApiResponse.success(data);
}
}