diff --git a/src/main/java/com/label/common/aop/AuditAspect.java b/src/main/java/com/label/common/aop/AuditAspect.java new file mode 100644 index 0000000..a2f0b61 --- /dev/null +++ b/src/main/java/com/label/common/aop/AuditAspect.java @@ -0,0 +1,75 @@ +package com.label.common.aop; + +import com.label.common.context.CompanyContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * AOP aspect for audit logging. + * + * KEY DESIGN DECISIONS: + * 1. Uses JdbcTemplate directly (not MyBatis Mapper) to bypass TenantLineInnerInterceptor + * — operation logs need to capture company_id explicitly, not via thread-local injection + * 2. Written in finally block — audit log is written regardless of business method success/failure + * 3. Audit failures are logged as ERROR but NEVER rethrown — business transactions must not be + * affected by audit failures + * 4. Captures result of business method to log SUCCESS or FAILURE + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class AuditAspect { + + private final JdbcTemplate jdbcTemplate; + + @Around("@annotation(operationLog)") + public Object audit(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { + Long companyId = CompanyContext.get(); + // operator_id can be obtained from SecurityContext or ThreadLocal in the future + // For now, use null as a safe default when not available + Long operatorId = null; + + String result = "SUCCESS"; + String errorMessage = null; + Object returnValue = null; + + try { + returnValue = joinPoint.proceed(); + } catch (Throwable e) { + result = "FAILURE"; + errorMessage = e.getMessage(); + throw e; // Always rethrow business exceptions + } finally { + // Write audit log in finally block — runs regardless of success or failure + // CRITICAL: Never throw from here — would swallow the original exception + try { + writeAuditLog(companyId, operatorId, operationLog.type(), + operationLog.targetType(), result, errorMessage); + } catch (Exception auditEx) { + // Audit failure must NOT affect business transaction + log.error("审计日志写入失败: type={}, error={}", + operationLog.type(), auditEx.getMessage(), auditEx); + } + } + + return returnValue; + } + + private void writeAuditLog(Long companyId, Long operatorId, String operationType, + String targetType, String result, String errorMessage) { + String sql = """ + INSERT INTO sys_operation_log + (company_id, operator_id, operation_type, target_type, result, error_message, operated_at) + VALUES (?, ?, ?, ?, ?, ?, NOW()) + """; + jdbcTemplate.update(sql, companyId, operatorId, operationType, + targetType.isEmpty() ? null : targetType, + result, errorMessage); + } +} diff --git a/src/main/java/com/label/common/aop/OperationLog.java b/src/main/java/com/label/common/aop/OperationLog.java new file mode 100644 index 0000000..8c96a4a --- /dev/null +++ b/src/main/java/com/label/common/aop/OperationLog.java @@ -0,0 +1,18 @@ +package com.label.common.aop; + +import java.lang.annotation.*; + +/** + * Marks a method for audit logging. + * The AuditAspect intercepts this annotation and writes to sys_operation_log. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface OperationLog { + /** Operation type, e.g., "EXTRACTION_APPROVE", "USER_LOGIN", "TASK_CLAIM" */ + String type(); + + /** Target entity type, e.g., "annotation_task", "sys_user" */ + String targetType() default ""; +}