Files
novalon-manage-system/docs/plans/2026-04-03-operation-log-optimization.md
T
张翔 588493f4c9 docs: add operation log optimization implementation plan
- Break down into 8 tasks across 2 phases
- Phase 1: Short-term optimization (1-2 weeks)
- Phase 2: Mid-term optimization (1-2 months)
- Include detailed steps, code examples, and verification methods
- Estimated total time: 22 hours (3-5 working days)
2026-04-03 21:10:01 +08:00

39 KiB
Raw Blame History

操作日志功能优化实施计划

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

目标: 完善操作日志功能,修复已知问题,增强功能特性,提升用户体验和系统可维护性。

架构: 在现有注解驱动AOP架构基础上,修复H2数据库兼容性问题,添加集成测试和E2E测试,实现查询导出、统计分析、定时清理等增强功能。

技术栈: Java 21, Spring Boot 3.5.13, Spring WebFlux, R2DBC, H2 Database, Jackson, Playwright


Phase 1: 短期优化(1-2周)

Task 1: 修复H2数据库初始化问题

问题分析:

  • H2测试环境启动时报错:bad SQL grammar [SELECT sys_user.username, sys_user.password, sys_user.email, sys_user.phone, sys_user.nickname, sys_user.role_id, sys_user.status...]
  • 原因:实体类字段映射与H2 schema不匹配

文件:

  • 检查: novalon-manage-api/manage-db/src/main/java/cn/novalon/manage/db/entity/SysUserEntity.java
  • 检查: novalon-manage-api/manage-app/src/main/resources/schema-h2.sql
  • 检查: novalon-manage-api/manage-app/src/main/resources/data-h2.sql

Step 1: 分析实体类字段映射

运行:

cd novalon-manage-api
grep -n "@Column" manage-db/src/main/java/cn/novalon/manage/db/entity/SysUserEntity.java

预期: 查看所有字段映射

Step 2: 对比H2 schema定义

运行:

cd novalon-manage-api/manage-app/src/main/resources
head -30 schema-h2.sql

预期: 查看H2表结构定义

Step 3: 检查data-h2.sql中的测试数据

运行:

cd novalon-manage-api/manage-app/src/main/resources
grep -A5 "INSERT INTO sys_user" data-h2.sql | head -20

预期: 查看测试数据插入语句

Step 4: 分析问题根源

根据错误信息和代码检查,确定以下可能的问题:

  1. 实体类字段名与数据库列名不匹配
  2. H2 schema中缺少某些字段
  3. R2DBC映射配置问题

Step 5: 修复方案选择

根据分析结果,选择合适的修复方案:

  • 方案A: 修改实体类的@Column注解,使其与H2 schema匹配
  • 方案B: 修改H2 schema,使其与实体类字段匹配
  • 方案C: 添加R2DBC自定义映射配置

Step 6: 实施修复

根据选择的方案,修改相应文件。

Step 7: 验证修复

运行:

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

等待服务启动,检查是否还有SQL错误。

Step 8: 提交修复

git add <修改的文件>
git commit -m "fix: resolve H2 database initialization issue

- Fix entity field mapping mismatch
- Update H2 schema to match entity definitions
- Ensure test environment works correctly"

Task 2: 添加操作日志集成测试

文件:

  • 创建: novalon-manage-api/manage-sys/src/test/java/cn/novalon/manage/sys/audit/OperationLogIntegrationTest.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 org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.time.Duration;

import static org.junit.jupiter.api.Assertions.*;

/**
 * 操作日志集成测试
 * 
 * @author 张翔
 * @date 2026-04-03
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class OperationLogIntegrationTest {

    @Autowired
    private WebTestClient webTestClient;

    @Autowired
    private IOperationLogService logService;

    @Test
    @WithMockUser(username = "test_user", roles = {"admin"})
    void testCreateUserOperation_ShouldLogOperation() {
        long initialCount = logService.count().block(Duration.ofSeconds(5));

        String userJson = """
            {
                "username": "test_integration_user",
                "password": "Test123!@#",
                "email": "test@example.com",
                "phone": "13900139000",
                "nickname": "集成测试用户"
            }
            """;

        webTestClient.post()
                .uri("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(userJson)
                .exchange()
                .expectStatus().isCreated();

        // 等待异步日志保存
        StepVerifier.create(logService.count())
                .expectNext(initialCount + 1)
                .verify(Duration.ofSeconds(5));

        // 验证日志内容
        StepVerifier.create(logService.findAll().last())
                .assertNext(log -> {
                    assertEquals("test_user", log.getUsername());
                    assertTrue(log.getOperation().contains("创建用户"));
                    assertEquals("0", log.getStatus());
                    assertNotNull(log.getParams());
                    assertNotNull(log.getDuration());
                })
                .verify(Duration.ofSeconds(5));
    }

    @Test
    @WithMockUser(username = "test_user", roles = {"admin"})
    void testDeleteUserOperation_ShouldLogOperation() {
        // 先创建一个用户
        String userJson = """
            {
                "username": "test_delete_user",
                "password": "Test123!@#",
                "email": "delete@example.com",
                "phone": "13900139001",
                "nickname": "待删除用户"
            }
            """;

        Long userId = webTestClient.post()
                .uri("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(userJson)
                .exchange()
                .expectStatus().isCreated()
                .expectBody(Long.class)
                .returnResult()
                .getResponseBody();

        long initialCount = logService.count().block(Duration.ofSeconds(5));

        // 删除用户
        webTestClient.delete()
                .uri("/api/users/{id}", userId)
                .exchange()
                .expectStatus().isOk();

        // 验证日志记录
        StepVerifier.create(logService.count())
                .expectNext(initialCount + 1)
                .verify(Duration.ofSeconds(5));
    }

    @Test
    @WithMockUser(username = "test_user", roles = {"admin"})
    void testFailedOperation_ShouldLogError() {
        // 尝试创建重复用户名(应该失败)
        String userJson = """
            {
                "username": "admin",
                "password": "Test123!@#",
                "email": "duplicate@example.com",
                "phone": "13900139002",
                "nickname": "重复用户"
            }
            """;

        webTestClient.post()
                .uri("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(userJson)
                .exchange()
                .expectStatus().is4xxClientError();

        // 验证错误日志记录
        StepVerifier.create(logService.findAll().last())
                .assertNext(log -> {
                    assertEquals("1", log.getStatus());
                    assertNotNull(log.getErrorMsg());
                })
                .verify(Duration.ofSeconds(5));
    }
}

Step 2: 运行集成测试

运行:

cd novalon-manage-api
./mvnw test -Dtest=OperationLogIntegrationTest -pl manage-sys

预期: 所有测试通过

Step 3: 提交测试代码

git add novalon-manage-api/manage-sys/src/test/java/cn/novalon/manage/sys/audit/OperationLogIntegrationTest.java
git commit -m "test: add integration tests for operation log

- Test successful operation logging
- Test failed operation error logging
- Verify log content and status"

Task 3: 添加操作日志E2E测试

文件:

  • 创建: novalon-manage-web/e2e/operation-log.spec.ts

Step 1: 创建E2E测试文件

import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
import { UserManagementPage } from './pages/UserManagementPage';

test.describe('操作日志E2E测试', () => {
  let loginPage: LoginPage;
  let dashboardPage: DashboardPage;
  let userManagementPage: UserManagementPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    dashboardPage = new DashboardPage(page);
    userManagementPage = new UserManagementPage(page);

    // 清理localStorage
    await page.goto('/');
    await page.evaluate(() => localStorage.clear());

    // 登录
    await loginPage.goto();
    await loginPage.login('e2e_test_user', 'admin123');
  });

  test('创建用户后Dashboard操作日志数量应增加', async ({ page }) => {
    // 获取初始操作日志数量
    await dashboardPage.goto();
    const initialCount = await dashboardPage.getOperationLogCount();
    console.log('初始操作日志数量:', initialCount);

    // 创建用户
    await dashboardPage.navigateToUserManagement();
    await userManagementPage.clickCreateUser();
    
    const timestamp = Date.now();
    const userData = {
      username: `oplog_test_${timestamp}`,
      nickname: `操作日志测试${timestamp}`,
      email: `oplog_${timestamp}@example.com`,
      phone: '13800138000',
      password: 'Test123!@#',
      confirmPassword: 'Test123!@#',
    };
    
    await userManagementPage.fillUserForm(userData);
    await userManagementPage.submitForm();
    await expect(userManagementPage.successMessage).toBeVisible();

    // 返回Dashboard,验证操作日志数量
    await dashboardPage.goto();
    await page.waitForTimeout(2000); // 等待异步日志保存
    
    const newCount = await dashboardPage.getOperationLogCount();
    console.log('新操作日志数量:', newCount);
    
    expect(newCount).toBeGreaterThan(initialCount);
  });

  test('删除用户后Dashboard操作日志数量应增加', async ({ page }) => {
    // 先创建一个用户
    await dashboardPage.navigateToUserManagement();
    await userManagementPage.clickCreateUser();
    
    const timestamp = Date.now();
    const userData = {
      username: `delete_oplog_${timestamp}`,
      nickname: `待删除用户${timestamp}`,
      email: `delete_${timestamp}@example.com`,
      phone: '13800138001',
      password: 'Test123!@#',
      confirmPassword: 'Test123!@#',
    };
    
    await userManagementPage.fillUserForm(userData);
    await userManagementPage.submitForm();
    await expect(userManagementPage.successMessage).toBeVisible();

    // 获取初始操作日志数量
    await dashboardPage.goto();
    const initialCount = await dashboardPage.getOperationLogCount();

    // 删除用户
    await dashboardPage.navigateToUserManagement();
    await userManagementPage.search(userData.username);
    await page.waitForTimeout(1000);
    await userManagementPage.deleteUser(1);
    await userManagementPage.confirmDelete();
    await expect(userManagementPage.successMessage).toBeVisible();

    // 验证操作日志数量
    await dashboardPage.goto();
    await page.waitForTimeout(2000);
    
    const newCount = await dashboardPage.getOperationLogCount();
    expect(newCount).toBeGreaterThan(initialCount);
  });

  test('操作失败后应记录错误日志', async ({ page }) => {
    // 获取初始操作日志数量
    await dashboardPage.goto();
    const initialCount = await dashboardPage.getOperationLogCount();

    // 尝试创建重复用户(应该失败)
    await dashboardPage.navigateToUserManagement();
    await userManagementPage.clickCreateUser();
    
    const userData = {
      username: 'admin', // 已存在的用户名
      nickname: '重复用户',
      email: 'duplicate@example.com',
      phone: '13800138002',
      password: 'Test123!@#',
      confirmPassword: 'Test123!@#',
    };
    
    await userManagementPage.fillUserForm(userData);
    await userManagementPage.submitForm();
    
    // 应该看到错误消息
    await expect(userManagementPage.errorMessage).toBeVisible();

    // 验证操作日志数量(失败操作也应该记录)
    await dashboardPage.goto();
    await page.waitForTimeout(2000);
    
    const newCount = await dashboardPage.getOperationLogCount();
    expect(newCount).toBeGreaterThan(initialCount);
  });
});

Step 2: 更新DashboardPage添加getOperationLogCount方法

novalon-manage-web/e2e/pages/DashboardPage.ts 中添加:

async getOperationLogCount(): Promise<number> {
  const logCard = this.page.locator('.log-card .el-statistic__content');
  const text = await logCard.textContent();
  return parseInt(text || '0', 10);
}

Step 3: 运行E2E测试

运行:

cd novalon-manage-web
npx playwright test e2e/operation-log.spec.ts --project=chromium

预期: 所有测试通过

Step 4: 提交测试代码

git add novalon-manage-web/e2e/operation-log.spec.ts novalon-manage-web/e2e/pages/DashboardPage.ts
git commit -m "test: add E2E tests for operation log

- Verify operation log count increases after operations
- Test successful and failed operation logging
- Add getOperationLogCount method to DashboardPage"

Task 4: 验证Dashboard操作日志显示

文件:

  • 检查: novalon-manage-web/src/views/system/Dashboard.vue

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: 手动测试Dashboard显示

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

Step 3: 检查API响应

运行:

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 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" | jq '.[0]'

预期: 返回最新的操作日志记录

Step 5: 记录测试结果

创建测试报告文档,记录:

  • Dashboard显示是否正常
  • 操作日志数量是否正确
  • 操作日志内容是否完整
  • 发现的问题和解决方案

Step 6: 提交验证报告

git add test-suite/reports/dashboard_operation_log_verification.md
git commit -m "docs: add Dashboard operation log verification report

- Verify Dashboard displays operation log count correctly
- Confirm operation log content is complete
- Document test results and findings"

Phase 2: 中期优化(1-2个月)

Task 5: 添加操作日志查询功能

文件:

  • 修改: novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/log/OperationLogHandler.java
  • 创建: novalon-manage-web/src/views/system/OperationLog.vue

Step 1: 扩展后端查询接口

OperationLogHandler.java 中添加:

@Operation(summary = "根据条件查询操作日志", description = "支持按用户名、操作类型、时间范围等条件查询")
public Mono<ServerResponse> searchOperationLogs(ServerRequest request) {
    Optional<String> username = request.queryParam("username");
    Optional<String> operation = request.queryParam("operation");
    Optional<String> startTime = request.queryParam("startTime");
    Optional<String> endTime = request.queryParam("endTime");
    Optional<String> status = request.queryParam("status");
    
    // 构建查询条件
    OperationLogQuery query = new OperationLogQuery();
    username.ifPresent(query::setUsername);
    operation.ifPresent(query::setOperation);
    startTime.ifPresent(query::setStartTime);
    endTime.ifPresent(query::setEndTime);
    status.ifPresent(query::setStatus);
    
    return logService.search(query)
            .collectList()
            .flatMap(logs -> ServerResponse.ok().bodyValue(logs));
}

Step 2: 在SystemRouter中添加路由

SystemRouter.java 中添加:

.GET("/api/logs/operation/search", operationLogHandler::searchOperationLogs)

Step 3: 创建前端查询页面

创建 OperationLog.vue:

<template>
  <div class="operation-log">
    <el-card>
      <template #header>
        <div class="card-header">
          <span>操作日志查询</span>
        </div>
      </template>
      
      <el-form :model="searchForm" inline>
        <el-form-item label="用户名">
          <el-input v-model="searchForm.username" placeholder="请输入用户名" />
        </el-form-item>
        <el-form-item label="操作类型">
          <el-select v-model="searchForm.operation" placeholder="请选择操作类型">
            <el-option label="全部" value="" />
            <el-option label="创建用户" value="创建用户" />
            <el-option label="更新用户" value="更新用户" />
            <el-option label="删除用户" value="删除用户" />
          </el-select>
        </el-form-item>
        <el-form-item label="时间范围">
          <el-date-picker
            v-model="searchForm.timeRange"
            type="datetimerange"
            range-separator=""
            start-placeholder="开始时间"
            end-placeholder="结束时间"
          />
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="searchForm.status" placeholder="请选择状态">
            <el-option label="全部" value="" />
            <el-option label="成功" value="0" />
            <el-option label="失败" value="1" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">查询</el-button>
          <el-button @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
      
      <el-table :data="tableData" v-loading="loading">
        <el-table-column prop="username" label="用户名" width="120" />
        <el-table-column prop="operation" label="操作" width="180" />
        <el-table-column prop="method" label="方法" show-overflow-tooltip />
        <el-table-column prop="ip" label="IP地址" width="140" />
        <el-table-column prop="duration" label="耗时(ms)" width="100" />
        <el-table-column prop="status" label="状态" width="80">
          <template #default="{ row }">
            <el-tag :type="row.status === '0' ? 'success' : 'danger'">
              {{ row.status === '0' ? '成功' : '失败' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createdAt" label="操作时间" width="180" />
        <el-table-column label="操作" width="100">
          <template #default="{ row }">
            <el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
          </template>
        </el-table-column>
      </el-table>
      
      <el-pagination
        v-model:current-page="pagination.page"
        v-model:page-size="pagination.size"
        :total="pagination.total"
        :page-sizes="[10, 20, 50, 100]"
        layout="total, sizes, prev, pager, next, jumper"
        @size-change="handleSearch"
        @current-change="handleSearch"
      />
    </el-card>
    
    <el-dialog v-model="detailVisible" title="操作日志详情" width="60%">
      <el-descriptions :column="2" border>
        <el-descriptions-item label="用户名">{{ currentLog.username }}</el-descriptions-item>
        <el-descriptions-item label="操作">{{ currentLog.operation }}</el-descriptions-item>
        <el-descriptions-item label="方法">{{ currentLog.method }}</el-descriptions-item>
        <el-descriptions-item label="IP地址">{{ currentLog.ip }}</el-descriptions-item>
        <el-descriptions-item label="耗时">{{ currentLog.duration }}ms</el-descriptions-item>
        <el-descriptions-item label="状态">
          <el-tag :type="currentLog.status === '0' ? 'success' : 'danger'">
            {{ currentLog.status === '0' ? '成功' : '失败' }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="操作时间" :span="2">{{ currentLog.createdAt }}</el-descriptions-item>
        <el-descriptions-item label="参数" :span="2">
          <pre>{{ formatJson(currentLog.params) }}</pre>
        </el-descriptions-item>
        <el-descriptions-item label="结果" :span="2">
          <pre>{{ formatJson(currentLog.result) }}</pre>
        </el-descriptions-item>
        <el-descriptions-item v-if="currentLog.errorMsg" label="错误信息" :span="2">
          <el-alert type="error" :closable="false">{{ currentLog.errorMsg }}</el-alert>
        </el-descriptions-item>
      </el-descriptions>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import request from '@/utils/request'

const loading = ref(false)
const tableData = ref([])
const detailVisible = ref(false)
const currentLog = ref<any>({})

const searchForm = reactive({
  username: '',
  operation: '',
  timeRange: [],
  status: ''
})

const pagination = reactive({
  page: 1,
  size: 10,
  total: 0
})

const handleSearch = async () => {
  loading.value = true
  try {
    const params: any = {
      page: pagination.page,
      size: pagination.size,
      ...searchForm
    }
    
    if (searchForm.timeRange && searchForm.timeRange.length === 2) {
      params.startTime = searchForm.timeRange[0]
      params.endTime = searchForm.timeRange[1]
    }
    
    const res: any = await request.get('/logs/operation/search', { params })
    tableData.value = res.data || []
    pagination.total = res.total || 0
  } catch (error) {
    console.error('查询失败:', error)
  } finally {
    loading.value = false
  }
}

const handleReset = () => {
  Object.assign(searchForm, {
    username: '',
    operation: '',
    timeRange: [],
    status: ''
  })
  pagination.page = 1
  handleSearch()
}

const handleViewDetail = (row: any) => {
  currentLog.value = row
  detailVisible.value = true
}

const formatJson = (jsonStr: string) => {
  try {
    return JSON.stringify(JSON.parse(jsonStr), null, 2)
  } catch {
    return jsonStr
  }
}

onMounted(() => {
  handleSearch()
})
</script>

<style scoped lang="css">
.operation-log {
  padding: 20px;
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

pre {
  background: #f5f5f5;
  padding: 10px;
  border-radius: 4px;
  overflow-x: auto;
  max-height: 300px;
}
</style>

Step 4: 添加路由配置

router/index.ts 中添加:

{
  path: '/system/operation-log',
  name: 'OperationLog',
  component: () => import('@/views/system/OperationLog.vue'),
  meta: { title: '操作日志', icon: 'document' }
}

Step 5: 测试查询功能

运行:

# 启动服务
cd novalon-manage-api && ./mvnw spring-boot:run -pl manage-app -Dspring-boot.run.profiles=test
cd novalon-manage-web && pnpm dev

# 浏览器访问
http://localhost:3002/system/operation-log

Step 6: 提交代码

git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/log/OperationLogHandler.java
git add novalon-manage-api/manage-app/src/main/java/cn/novalon/manage/app/config/SystemRouter.java
git add novalon-manage-web/src/views/system/OperationLog.vue
git add novalon-manage-web/src/router/index.ts
git commit -m "feat: add operation log search functionality

- Add search API with multiple filter conditions
- Create operation log query page
- Support pagination and detail view"

Task 6: 添加操作日志导出功能

文件:

  • 修改: novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/log/OperationLogHandler.java
  • 修改: novalon-manage-web/src/views/system/OperationLog.vue

Step 1: 添加后端导出接口

OperationLogHandler.java 中添加:

@Operation(summary = "导出操作日志", description = "导出操作日志为Excel文件")
public Mono<ServerResponse> exportOperationLogs(ServerRequest request) {
    // 获取查询条件
    Optional<String> username = request.queryParam("username");
    Optional<String> operation = request.queryParam("operation");
    Optional<String> startTime = request.queryParam("startTime");
    Optional<String> endTime = request.queryParam("endTime");
    
    // 构建查询条件
    OperationLogQuery query = new OperationLogQuery();
    username.ifPresent(query::setUsername);
    operation.ifPresent(query::setOperation);
    startTime.ifPresent(query::setStartTime);
    endTime.ifPresent(query::setEndTime);
    
    return logService.search(query)
            .collectList()
            .flatMap(logs -> {
                // 生成Excel文件
                byte[] excelData = generateExcel(logs);
                
                return ServerResponse.ok()
                        .header("Content-Disposition", "attachment; filename=operation_logs.xlsx")
                        .contentType(MediaType.APPLICATION_OCTET_STREAM)
                        .bodyValue(excelData);
            });
}

private byte[] generateExcel(List<OperationLog> logs) {
    // 使用Apache POI或EasyExcel生成Excel
    // 实现细节省略
    return new byte[0];
}

Step 2: 在SystemRouter中添加路由

.GET("/api/logs/operation/export", operationLogHandler::exportOperationLogs)

Step 3: 在前端添加导出按钮

OperationLog.vue 中添加:

<el-form-item>
  <el-button type="primary" @click="handleSearch">查询</el-button>
  <el-button @click="handleReset">重置</el-button>
  <el-button type="success" @click="handleExport">导出</el-button>
</el-form-item>
const handleExport = async () => {
  try {
    const params: any = { ...searchForm }
    
    if (searchForm.timeRange && searchForm.timeRange.length === 2) {
      params.startTime = searchForm.timeRange[0]
      params.endTime = searchForm.timeRange[1]
    }
    
    const response = await request.get('/logs/operation/export', {
      params,
      responseType: 'blob'
    })
    
    const url = window.URL.createObjectURL(new Blob([response]))
    const link = document.createElement('a')
    link.href = url
    link.setAttribute('download', 'operation_logs.xlsx')
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
  } catch (error) {
    console.error('导出失败:', error)
  }
}

Step 4: 测试导出功能

在浏览器中点击"导出"按钮,验证Excel文件是否正确下载。

Step 5: 提交代码

git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/log/OperationLogHandler.java
git add novalon-manage-api/manage-app/src/main/java/cn/novalon/manage/app/config/SystemRouter.java
git add novalon-manage-web/src/views/system/OperationLog.vue
git commit -m "feat: add operation log export functionality

- Add export API endpoint
- Generate Excel file with operation logs
- Add export button in frontend"

Task 7: 实现操作日志统计分析

文件:

  • 创建: novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/stats/OperationLogStatsHandler.java
  • 创建: novalon-manage-web/src/views/system/OperationLogStats.vue

Step 1: 创建统计分析接口

package cn.novalon.manage.sys.handler.stats;

import cn.novalon.manage.sys.core.service.IOperationLogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@Component
@Tag(name = "操作日志统计", description = "操作日志统计分析")
public class OperationLogStatsHandler {

    private final IOperationLogService logService;

    public OperationLogStatsHandler(IOperationLogService logService) {
        this.logService = logService;
    }

    @Operation(summary = "获取操作日志统计概览", description = "获取操作日志的统计数据")
    public Mono<ServerResponse> getStatsOverview(ServerRequest request) {
        Mono<Long> totalCount = logService.count();
        Mono<Long> todayCount = logService.countToday();
        Mono<Long> successCount = logService.countByStatus("0");
        Mono<Long> failCount = logService.countByStatus("1");

        return Mono.zip(totalCount, todayCount, successCount, failCount)
                .flatMap(tuple -> {
                    Map<String, Object> stats = new HashMap<>();
                    stats.put("totalCount", tuple.getT1());
                    stats.put("todayCount", tuple.getT2());
                    stats.put("successCount", tuple.getT3());
                    stats.put("failCount", tuple.getT4());
                    stats.put("successRate", 
                            tuple.getT1() > 0 ? 
                            (double) tuple.getT3() / tuple.getT1() * 100 : 0);
                    return ServerResponse.ok().bodyValue(stats);
                });
    }

    @Operation(summary = "按操作类型统计", description = "统计各操作类型的数量")
    public Mono<ServerResponse> getStatsByOperation(ServerRequest request) {
        return logService.countByOperation()
                .collectList()
                .flatMap(stats -> ServerResponse.ok().bodyValue(stats));
    }

    @Operation(summary = "按用户统计", description = "统计各用户的操作数量")
    public Mono<ServerResponse> getStatsByUser(ServerRequest request) {
        return logService.countByUsername()
                .collectList()
                .flatMap(stats -> ServerResponse.ok().bodyValue(stats));
    }

    @Operation(summary = "按时间统计", description = "统计每日操作数量趋势")
    public Mono<ServerResponse> getStatsByTime(ServerRequest request) {
        Optional<String> days = request.queryParam("days");
        int dayCount = days.map(Integer::parseInt).orElse(7);
        
        LocalDateTime startTime = LocalDateTime.now().minusDays(dayCount);
        return logService.countByDate(startTime)
                .collectList()
                .flatMap(stats -> ServerResponse.ok().bodyValue(stats));
    }
}

Step 2: 创建前端统计页面

创建 OperationLogStats.vue:

<template>
  <div class="operation-log-stats">
    <el-row :gutter="16">
      <el-col :span="6">
        <el-card>
          <el-statistic title="总操作数" :value="stats.totalCount" />
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card>
          <el-statistic title="今日操作" :value="stats.todayCount" />
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card>
          <el-statistic title="成功率" :value="stats.successRate" suffix="%" />
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card>
          <el-statistic title="失败数" :value="stats.failCount" />
        </el-card>
      </el-col>
    </el-row>

    <el-row :gutter="16" style="margin-top: 16px">
      <el-col :span="12">
        <el-card>
          <template #header>
            <span>操作类型分布</span>
          </template>
          <div ref="operationChart" style="height: 300px"></div>
        </el-card>
      </el-col>
      <el-col :span="12">
        <el-card>
          <template #header>
            <span>用户操作排行</span>
          </template>
          <div ref="userChart" style="height: 300px"></div>
        </el-card>
      </el-col>
    </el-row>

    <el-card style="margin-top: 16px">
      <template #header>
        <span>操作趋势最近7天</span>
      </template>
      <div ref="trendChart" style="height: 300px"></div>
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
import request from '@/utils/request'

const stats = ref({
  totalCount: 0,
  todayCount: 0,
  successRate: 0,
  failCount: 0
})

const operationChart = ref<HTMLElement>()
const userChart = ref<HTMLElement>()
const trendChart = ref<HTMLElement>()

const loadStats = async () => {
  const res: any = await request.get('/logs/operation/stats/overview')
  stats.value = res
}

const loadOperationChart = async () => {
  const res: any = await request.get('/logs/operation/stats/by-operation')
  
  const chart = echarts.init(operationChart.value!)
  chart.setOption({
    tooltip: { trigger: 'item' },
    legend: { orient: 'vertical', left: 'left' },
    series: [{
      type: 'pie',
      radius: '50%',
      data: res.map((item: any) => ({
        name: item.operation,
        value: item.count
      }))
    }]
  })
}

const loadUserChart = async () => {
  const res: any = await request.get('/logs/operation/stats/by-user')
  
  const chart = echarts.init(userChart.value!)
  chart.setOption({
    tooltip: { trigger: 'axis' },
    xAxis: { type: 'category', data: res.map((item: any) => item.username) },
    yAxis: { type: 'value' },
    series: [{
      type: 'bar',
      data: res.map((item: any) => item.count)
    }]
  })
}

const loadTrendChart = async () => {
  const res: any = await request.get('/logs/operation/stats/by-time?days=7')
  
  const chart = echarts.init(trendChart.value!)
  chart.setOption({
    tooltip: { trigger: 'axis' },
    xAxis: { 
      type: 'category', 
      data: res.map((item: any) => item.date) 
    },
    yAxis: { type: 'value' },
    series: [{
      type: 'line',
      data: res.map((item: any) => item.count),
      smooth: true
    }]
  })
}

onMounted(async () => {
  await loadStats()
  await loadOperationChart()
  await loadUserChart()
  await loadTrendChart()
})
</script>

<style scoped lang="css">
.operation-log-stats {
  padding: 20px;
}
</style>

Step 3: 添加路由

SystemRouter.java 中添加:

.GET("/api/logs/operation/stats/overview", operationLogStatsHandler::getStatsOverview)
.GET("/api/logs/operation/stats/by-operation", operationLogStatsHandler::getStatsByOperation)
.GET("/api/logs/operation/stats/by-user", operationLogStatsHandler::getStatsByUser)
.GET("/api/logs/operation/stats/by-time", operationLogStatsHandler::getStatsByTime)

Step 4: 测试统计功能

访问 http://localhost:3002/system/operation-log-stats 查看统计图表。

Step 5: 提交代码

git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/handler/stats/OperationLogStatsHandler.java
git add novalon-manage-api/manage-app/src/main/java/cn/novalon/manage/app/config/SystemRouter.java
git add novalon-manage-web/src/views/system/OperationLogStats.vue
git commit -m "feat: add operation log statistics and analysis

- Add stats API endpoints
- Create statistics dashboard with charts
- Support operation type, user, and time analysis"

Task 8: 添加操作日志定时清理任务

文件:

  • 创建: novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/scheduler/OperationLogCleanupScheduler.java

Step 1: 创建定时清理任务

package cn.novalon.manage.sys.scheduler;

import cn.novalon.manage.sys.core.service.IOperationLogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
 * 操作日志定时清理任务
 * 
 * @author 张翔
 * @date 2026-04-03
 */
@Component
public class OperationLogCleanupScheduler {

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

    private final IOperationLogService logService;

    public OperationLogCleanupScheduler(IOperationLogService logService) {
        this.logService = logService;
    }

    /**
     * 每天凌晨2点清理3个月前的操作日志
     */
    @Scheduled(cron = "0 0 2 * * ?")
    public void cleanupOldLogs() {
        logger.info("开始清理操作日志...");
        
        LocalDateTime cutoffDate = LocalDateTime.now().minusMonths(3);
        
        logService.deleteByCreatedAtBefore(cutoffDate)
                .doOnSuccess(count -> logger.info("操作日志清理完成,删除 {} 条记录", count))
                .doOnError(error -> logger.error("操作日志清理失败: {}", error.getMessage(), error))
                .subscribe();
    }
}

Step 2: 在IOperationLogService中添加删除方法

Mono<Long> deleteByCreatedAtBefore(LocalDateTime cutoffDate);

Step 3: 在OperationLogService中实现删除方法

@Override
public Mono<Long> deleteByCreatedAtBefore(LocalDateTime cutoffDate) {
    return logRepository.deleteByCreatedAtBefore(cutoffDate);
}

Step 4: 在IOperationLogRepository中添加删除方法

Mono<Long> deleteByCreatedAtBefore(LocalDateTime cutoffDate);

Step 5: 启用定时任务

ManageApplication.java 中添加:

@EnableScheduling
@SpringBootApplication
public class ManageApplication {
    public static void main(String[] args) {
        SpringApplication.run(ManageApplication.class, args);
    }
}

Step 6: 测试定时任务

可以手动触发测试:

@Test
void testCleanupScheduler() {
    cleanupScheduler.cleanupOldLogs();
    Thread.sleep(5000); // 等待异步操作完成
}

Step 7: 提交代码

git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/scheduler/OperationLogCleanupScheduler.java
git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/core/service/IOperationLogService.java
git add novalon-manage-api/manage-sys/src/main/java/cn/novalon/manage/sys/core/service/impl/OperationLogService.java
git add novalon-manage-api/manage-db/src/main/java/cn/novalon/manage/db/repository/IOperationLogRepository.java
git add novalon-manage-api/manage-app/src/main/java/cn/novalon/manage/app/ManageApplication.java
git commit -m "feat: add operation log cleanup scheduler

- Add scheduled task to clean up old logs
- Keep logs for last 3 months
- Run cleanup at 2 AM daily"

完成标准

Phase 1 完成标准

  • H2数据库初始化问题已修复
  • 集成测试全部通过
  • E2E测试全部通过
  • Dashboard操作日志显示正常
  • 所有代码已提交到Git

Phase 2 完成标准

  • 操作日志查询功能可用
  • 操作日志导出功能可用
  • 操作日志统计分析功能可用
  • 定时清理任务正常运行
  • 所有测试通过
  • 文档更新完成
  • 所有代码已提交到Git

预估时间

Phase 1: 短期优化

  • Task 1: 修复H2数据库问题 - 2小时
  • Task 2: 添加集成测试 - 3小时
  • Task 3: 添加E2E测试 - 2小时
  • Task 4: 验证Dashboard显示 - 1小时
  • 总计: 约8小时(1-2个工作日)

Phase 2: 中期优化

  • Task 5: 添加查询功能 - 4小时
  • Task 6: 添加导出功能 - 3小时
  • Task 7: 实现统计分析 - 5小时
  • Task 8: 添加定时清理 - 2小时
  • 总计: 约14小时(2-3个工作日)

总预估时间: 约22小时(3-5个工作日)