- 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)
39 KiB
操作日志功能优化实施计划
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: 分析问题根源
根据错误信息和代码检查,确定以下可能的问题:
- 实体类字段名与数据库列名不匹配
- H2 schema中缺少某些字段
- 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显示
- 打开浏览器访问 http://localhost:3002
- 登录系统(用户名: e2e_test_user, 密码: admin123)
- 查看Dashboard操作日志数量(初始值)
- 执行用户管理操作(创建、更新、删除用户)
- 返回Dashboard,查看操作日志数量是否增加
- 检查操作日志显示是否正确
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个工作日)