Files
novalon-manage-system/docs/plans/2026-04-03-operation-log-implementation.md
张翔 2bdf5a6c90 docs: add operation log implementation plan
- Break down into 9 bite-sized tasks
- Follow TDD approach with tests first
- Include exact file paths and complete code
- Provide verification steps for each task
2026-04-03 19:28:48 +08:00

19 KiB
Raw Permalink Blame History

操作日志记录功能实施计划

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

目标: 实现基于注解的操作日志记录功能,自动记录关键业务操作到数据库,解决Dashboard操作日志显示0的问题。

架构: 采用AOP切面 + 注解驱动的架构,使用Spring AOP拦截带@OperationLog注解的方法,异步记录操作日志到数据库,不影响主业务流程性能。

技术栈: Java 21, Spring Boot 3.5.13, Spring AOP, Project Reactor, Jackson


Task 1: 创建 @OperationLog 注解

文件:

  • 创建: novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/audit/OperationLog.java

Step 1: 创建注解文件

package cn.novalon.manage.sys.audit;

import java.lang.annotation.*;

/**
 * 操作日志注解
 * 标记需要记录操作日志的方法
 * 
 * @author 张翔
 * @date 2026-04-03
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperationLog {
    
    /**
     * 操作名称
     * 例如:"创建用户"、"删除角色"
     */
    String operation();
    
    /**
     * 模块名称
     * 例如:"用户管理"、"角色管理"
     */
    String module();
}

Step 2: 验证注解创建成功

运行: ls -la novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/audit/OperationLog.java

预期: 文件存在且内容正确

Step 3: 提交

git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/audit/OperationLog.java
git commit -m "feat: add @OperationLog annotation for operation logging"

Task 2: 创建 OperationLogAspect 切面(基础结构)

文件:

  • 创建: novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/audit/OperationLogAspect.java

Step 1: 创建切面基础结构

package cn.novalon.manage.sys.audit;

import cn.novalon.manage.sys.core.domain.OperationLog;
import cn.novalon.manage.sys.core.service.IOperationLogService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

/**
 * 操作日志切面
 * 
 * 文件定义:使用AOP自动拦截带@OperationLog注解的方法,记录操作日志
 * 涉及业务:自动记录用户操作,包括操作人、操作时间、参数、结果、耗时等
 * 算法:使用异步方式记录日志,不阻塞主流程
 * 
 * @author 张翔
 * @date 2026-04-03
 */
@Aspect
@Component
public class OperationLogAspect {

    private static final Logger logger = LoggerFactory.getLogger(OperationLogAspect.class);

    private final IOperationLogService logService;
    private final ObjectMapper objectMapper;

    public OperationLogAspect(IOperationLogService logService, ObjectMapper objectMapper) {
        this.logService = logService;
        this.objectMapper = objectMapper;
    }

    @Around("@annotation(operationLog)")
    public Object around(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable {
        long startTime = System.currentTimeMillis();
        
        // 获取基本信息
        String username = getCurrentUsername();
        String ip = "unknown";
        String method = point.getSignature().toShortString();
        String params = serializeParams(point.getArgs());
        
        // 执行业务方法
        Object result = null;
        String status = "0";
        String errorMsg = null;
        
        try {
            result = point.proceed();
            
            // 处理响应式结果
            if (result instanceof Mono) {
                return ((Mono<?>) result)
                    .doOnSuccess(res -> {
                        long duration = System.currentTimeMillis() - startTime;
                        saveLogAsync(operationLog, username, ip, method, 
                                   params, res, duration, "0", null);
                    })
                    .doOnError(error -> {
                        long duration = System.currentTimeMillis() - startTime;
                        saveLogAsync(operationLog, username, ip, method, 
                                   params, null, duration, "1", error.getMessage());
                    });
            }
            
            return result;
        } catch (Throwable error) {
            status = "1";
            errorMsg = error.getMessage();
            throw error;
        } finally {
            if (!(result instanceof Mono)) {
                long duration = System.currentTimeMillis() - startTime;
                saveLogAsync(operationLog, username, ip, method, 
                           params, result, duration, status, errorMsg);
            }
        }
    }
    
    private String getCurrentUsername() {
        try {
            return ReactiveSecurityContextHolder.getContext()
                    .map(ctx -> ctx.getAuthentication().getPrincipal())
                    .cast(String.class)
                    .blockOptional()
                    .orElse("system");
        } catch (Exception e) {
            logger.warn("获取当前用户名失败: {}", e.getMessage());
            return "system";
        }
    }
    
    private String serializeParams(Object[] args) {
        try {
            if (args == null || args.length == 0) {
                return null;
            }
            return objectMapper.writeValueAsString(args);
        } catch (Exception e) {
            logger.warn("序列化参数失败: {}", e.getMessage());
            return null;
        }
    }
    
    private String serializeResult(Object result) {
        try {
            if (result == null) {
                return null;
            }
            return objectMapper.writeValueAsString(result);
        } catch (Exception e) {
            logger.warn("序列化结果失败: {}", e.getMessage());
            return null;
        }
    }
    
    private void saveLogAsync(OperationLog annotation, String username, 
                             String ip, String method, String params, 
                             Object result, long duration, String status, 
                             String errorMsg) {
        Mono.fromRunnable(() -> {
            OperationLog log = new OperationLog();
            log.setUsername(username);
            log.setOperation(annotation.module() + " - " + annotation.operation());
            log.setMethod(method);
            log.setParams(params);
            log.setResult(serializeResult(result));
            log.setIp(ip);
            log.setDuration(duration);
            log.setStatus(status);
            log.setErrorMsg(errorMsg);
            
            logService.save(log)
                    .doOnSuccess(saved -> logger.debug("操作日志保存成功: {} - {}", 
                            annotation.module(), annotation.operation()))
                    .doOnError(error -> logger.error("操作日志保存失败: {}", 
                            error.getMessage()))
                    .subscribe();
        })
        .subscribeOn(Schedulers.boundedElastic())
        .subscribe();
    }
}

Step 2: 验证切面创建成功

运行: ls -la novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/audit/OperationLogAspect.java

预期: 文件存在且内容正确

Step 3: 提交

git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/audit/OperationLogAspect.java
git commit -m "feat: implement OperationLogAspect for automatic operation logging"

Task 3: 编写单元测试

文件:

  • 创建: novalon-manage-api/manage-sys/src/test/java/cn/novalon/manage/sys/audit/OperationLogAspectTest.java

Step 1: 创建测试文件

package cn.novalon.manage.sys.audit;

import cn.novalon.manage.sys.core.domain.OperationLog;
import cn.novalon.manage.sys.core.service.IOperationLogService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

/**
 * OperationLogAspect 单元测试
 * 
 * @author 张翔
 * @date 2026-04-03
 */
@ExtendWith(MockitoExtension.class)
class OperationLogAspectTest {

    @Mock
    private IOperationLogService logService;

    @Mock
    private ProceedingJoinPoint joinPoint;

    @Mock
    private Signature signature;

    private OperationLogAspect aspect;
    private ObjectMapper objectMapper;

    @BeforeEach
    void setUp() {
        objectMapper = new ObjectMapper();
        aspect = new OperationLogAspect(logService, objectMapper);
    }

    @Test
    void testAround_WithMonoResult_ShouldSaveLog() throws Throwable {
        OperationLog annotation = new OperationLog() {
            @Override
            public String operation() {
                return "创建用户";
            }

            @Override
            public String module() {
                return "用户管理";
            }

            @Override
            public Class<? extends java.lang.annotation.Annotation> annotationType() {
                return OperationLog.class;
            }
        };

        when(joinPoint.getSignature()).thenReturn(signature);
        when(signature.toShortString()).thenReturn("SysUserHandler.createUser");
        when(joinPoint.getArgs()).thenReturn(new Object[]{"test"});
        when(joinPoint.proceed()).thenReturn(Mono.just("success"));
        when(logService.save(any(OperationLog.class))).thenReturn(Mono.just(new OperationLog()));

        Object result = aspect.around(joinPoint, annotation);

        StepVerifier.create((Mono<?>) result)
                .expectNext("success")
                .verifyComplete();

        verify(logService, timeout(1000)).save(any(OperationLog.class));
    }

    @Test
    void testAround_WithException_ShouldSaveErrorLog() throws Throwable {
        OperationLog annotation = new OperationLog() {
            @Override
            public String operation() {
                return "删除用户";
            }

            @Override
            public String module() {
                return "用户管理";
            }

            @Override
            public Class<? extends java.lang.annotation.Annotation> annotationType() {
                return OperationLog.class;
            }
        };

        when(joinPoint.getSignature()).thenReturn(signature);
        when(signature.toShortString()).thenReturn("SysUserHandler.deleteUser");
        when(joinPoint.getArgs()).thenReturn(new Object[]{1L});
        when(joinPoint.proceed()).thenThrow(new RuntimeException("删除失败"));

        try {
            aspect.around(joinPoint, annotation);
        } catch (RuntimeException e) {
            assert e.getMessage().equals("删除失败");
        }

        verify(logService, timeout(1000)).save(any(OperationLog.class));
    }
}

Step 2: 运行测试验证

运行: cd novalon-manage-api && ./mvnw test -Dtest=OperationLogAspectTest -pl manage-sys

预期: 测试通过

Step 3: 提交

git add novalon-manage-api/manage-sys/src/test/java/cn/novalon/manage/sys/audit/OperationLogAspectTest.java
git commit -m "test: add unit tests for OperationLogAspect"

Task 4: 在用户管理Handler上添加注解

文件:

  • 修改: novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/user/SysUserHandler.java

Step 1: 在createUser方法上添加注解

找到 createUser 方法,在方法上添加:

@OperationLog(operation = "创建用户", module = "用户管理")
public Mono<ServerResponse> createUser(ServerRequest request) {
    // 现有代码保持不变
}

Step 2: 在updateUser方法上添加注解

找到 updateUser 方法,在方法上添加:

@OperationLog(operation = "更新用户", module = "用户管理")
public Mono<ServerResponse> updateUser(ServerRequest request) {
    // 现有代码保持不变
}

Step 3: 在deleteUser方法上添加注解

找到 deleteUser 方法,在方法上添加:

@OperationLog(operation = "删除用户", module = "用户管理")
public Mono<ServerResponse> deleteUser(ServerRequest request) {
    // 现有代码保持不变
}

Step 4: 在changePassword方法上添加注解

找到 changePassword 方法,在方法上添加:

@OperationLog(operation = "修改密码", module = "用户管理")
public Mono<ServerResponse> changePassword(ServerRequest request) {
    // 现有代码保持不变
}

Step 5: 在assignRoles方法上添加注解

找到 assignRoles 方法,在方法上添加:

@OperationLog(operation = "分配角色", module = "用户管理")
public Mono<ServerResponse> assignRoles(ServerRequest request) {
    // 现有代码保持不变
}

Step 6: 验证修改

运行: cd novalon-manage-api && ./mvnw compile -pl manage-sys

预期: 编译成功

Step 7: 提交

git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/user/SysUserHandler.java
git commit -m "feat: add @OperationLog annotations to user management operations"

Task 5: 在角色管理Handler上添加注解

文件:

  • 修改: novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/role/SysRoleHandler.java

Step 1: 在createRole方法上添加注解

@OperationLog(operation = "创建角色", module = "角色管理")
public Mono<ServerResponse> createRole(ServerRequest request) {
    // 现有代码保持不变
}

Step 2: 在updateRole方法上添加注解

@OperationLog(operation = "更新角色", module = "角色管理")
public Mono<ServerResponse> updateRole(ServerRequest request) {
    // 现有代码保持不变
}

Step 3: 在deleteRole方法上添加注解

@OperationLog(operation = "删除角色", module = "角色管理")
public Mono<ServerResponse> deleteRole(ServerRequest request) {
    // 现有代码保持不变
}

Step 4: 验证修改

运行: cd novalon-manage-api && ./mvnw compile -pl manage-sys

预期: 编译成功

Step 5: 提交

git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/role/SysRoleHandler.java
git commit -m "feat: add @OperationLog annotations to role management operations"

Task 6: 在菜单管理Handler上添加注解

文件:

  • 修改: novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/menu/MenuHandler.java

Step 1: 在createMenu方法上添加注解

@OperationLog(operation = "创建菜单", module = "菜单管理")
public Mono<ServerResponse> createMenu(ServerRequest request) {
    // 现有代码保持不变
}

Step 2: 在updateMenu方法上添加注解

@OperationLog(operation = "更新菜单", module = "菜单管理")
public Mono<ServerResponse> updateMenu(ServerRequest request) {
    // 现有代码保持不变
}

Step 3: 在deleteMenu方法上添加注解

@OperationLog(operation = "删除菜单", module = "菜单管理")
public Mono<ServerResponse> deleteMenu(ServerRequest request) {
    // 现有代码保持不变
}

Step 4: 验证修改

运行: cd novalon-manage-api && ./mvnw compile -pl manage-sys

预期: 编译成功

Step 5: 提交

git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/menu/MenuHandler.java
git commit -m "feat: add @OperationLog annotations to menu management operations"

Task 7: 运行集成测试验证

Step 1: 启动后端服务

运行: cd novalon-manage-api && ./mvnw spring-boot:run -pl manage-app -Dspring-boot.run.profiles=test

等待服务启动完成(约30秒)

Step 2: 执行用户创建操作

运行:

TOKEN=$(curl -s -X POST http://localhost:8084/api/auth/login -H "Content-Type: application/json" -d '{"username":"e2e_test_user","password":"admin123"}' | grep -o '"token":"[^"]*' | cut -d'"' -f4)

curl -X POST http://localhost:8084/api/users -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"username":"test_op_log","password":"Test123!@#","email":"test@example.com","phone":"13900139001","nickname":"测试操作日志"}'

预期: 用户创建成功

Step 3: 验证操作日志已记录

运行:

curl -X GET "http://localhost:8084/api/logs/operation/count" -H "Authorization: Bearer $TOKEN"

预期: 返回值大于0

Step 4: 查看操作日志详情

运行:

curl -X GET "http://localhost:8084/api/logs/operation" -H "Authorization: Bearer $TOKEN"

预期: 返回包含"创建用户"操作的日志记录

Step 5: 停止后端服务

按 Ctrl+C 停止服务


Task 8: 运行E2E测试验证

Step 1: 启动前端和后端服务

运行:

# 终端1: 启动后端
cd novalon-manage-api && ./mvnw spring-boot:run -pl manage-app -Dspring-boot.run.profiles=test

# 终端2: 启动前端
cd novalon-manage-web && pnpm dev

等待服务启动完成

Step 2: 运行E2E测试

运行: cd novalon-manage-web && npx playwright test e2e/user-management.spec.ts --project=chromium

预期: 测试通过

Step 3: 手动验证Dashboard

  1. 打开浏览器访问 http://localhost:3002
  2. 登录系统(用户名: e2e_test_user, 密码: admin123
  3. 执行用户管理操作(创建、更新、删除用户)
  4. 查看Dashboard操作日志数量是否增加

预期: 操作日志数量随操作增加

Step 4: 停止服务

按 Ctrl+C 停止所有服务


Task 9: 最终验证和提交

Step 1: 运行所有后端测试

运行: cd novalon-manage-api && ./mvnw test

预期: 所有测试通过

Step 2: 检查代码质量

运行: cd novalon-manage-api && ./mvnw checkstyle:check

预期: 检查通过

Step 3: 更新README文档

README.md 中添加操作日志功能说明:

## 操作日志功能

系统自动记录关键业务操作,包括:
- 用户管理:创建、更新、删除用户、修改密码、分配角色
- 角色管理:创建、更新、删除角色
- 菜单管理:创建、更新、删除菜单

操作日志可在Dashboard中查看,用于系统审计和问题追踪。

Step 4: 最终提交

git add README.md
git commit -m "docs: update README with operation log feature description"

完成标准

  • 所有单元测试通过
  • 所有集成测试通过
  • E2E测试通过
  • Dashboard操作日志数量正常显示
  • 代码质量检查通过
  • 文档更新完成
  • 所有代码已提交到Git