Files
novalon-manage-system/docs/plans/2026-04-03-operation-log-optimization.md
张翔 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

1319 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 操作日志功能优化实施计划
> **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: 分析实体类字段映射**
运行:
```bash
cd novalon-manage-api
grep -n "@Column" manage-db/src/main/java/cn/novalon/manage/db/entity/SysUserEntity.java
```
预期: 查看所有字段映射
**Step 2: 对比H2 schema定义**
运行:
```bash
cd novalon-manage-api/manage-app/src/main/resources
head -30 schema-h2.sql
```
预期: 查看H2表结构定义
**Step 3: 检查data-h2.sql中的测试数据**
运行:
```bash
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: 验证修复**
运行:
```bash
cd novalon-manage-api
./mvnw spring-boot:run -pl manage-app -Dspring-boot.run.profiles=test
```
等待服务启动,检查是否还有SQL错误。
**Step 8: 提交修复**
```bash
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: 创建集成测试类**
```java
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: 运行集成测试**
运行:
```bash
cd novalon-manage-api
./mvnw test -Dtest=OperationLogIntegrationTest -pl manage-sys
```
预期: 所有测试通过
**Step 3: 提交测试代码**
```bash
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测试文件**
```typescript
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` 中添加:
```typescript
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测试**
运行:
```bash
cd novalon-manage-web
npx playwright test e2e/operation-log.spec.ts --project=chromium
```
预期: 所有测试通过
**Step 4: 提交测试代码**
```bash
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: 启动完整系统**
运行:
```bash
# 终端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响应**
运行:
```bash
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: 检查操作日志列表**
运行:
```bash
curl -X GET "http://localhost:8084/api/logs/operation" -H "Authorization: Bearer $TOKEN" | jq '.[0]'
```
预期: 返回最新的操作日志记录
**Step 5: 记录测试结果**
创建测试报告文档,记录:
- Dashboard显示是否正常
- 操作日志数量是否正确
- 操作日志内容是否完整
- 发现的问题和解决方案
**Step 6: 提交验证报告**
```bash
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` 中添加:
```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` 中添加:
```java
.GET("/api/logs/operation/search", operationLogHandler::searchOperationLogs)
```
**Step 3: 创建前端查询页面**
创建 `OperationLog.vue`:
```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` 中添加:
```typescript
{
path: '/system/operation-log',
name: 'OperationLog',
component: () => import('@/views/system/OperationLog.vue'),
meta: { title: '操作日志', icon: 'document' }
}
```
**Step 5: 测试查询功能**
运行:
```bash
# 启动服务
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: 提交代码**
```bash
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` 中添加:
```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中添加路由**
```java
.GET("/api/logs/operation/export", operationLogHandler::exportOperationLogs)
```
**Step 3: 在前端添加导出按钮**
`OperationLog.vue` 中添加:
```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>
```
```typescript
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: 提交代码**
```bash
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: 创建统计分析接口**
```java
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`:
```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` 中添加:
```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: 提交代码**
```bash
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: 创建定时清理任务**
```java
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中添加删除方法**
```java
Mono<Long> deleteByCreatedAtBefore(LocalDateTime cutoffDate);
```
**Step 3: 在OperationLogService中实现删除方法**
```java
@Override
public Mono<Long> deleteByCreatedAtBefore(LocalDateTime cutoffDate) {
return logRepository.deleteByCreatedAtBefore(cutoffDate);
}
```
**Step 4: 在IOperationLogRepository中添加删除方法**
```java
Mono<Long> deleteByCreatedAtBefore(LocalDateTime cutoffDate);
```
**Step 5: 启用定时任务**
`ManageApplication.java` 中添加:
```java
@EnableScheduling
@SpringBootApplication
public class ManageApplication {
public static void main(String[] args) {
SpringApplication.run(ManageApplication.class, args);
}
}
```
**Step 6: 测试定时任务**
可以手动触发测试:
```java
@Test
void testCleanupScheduler() {
cleanupScheduler.cleanupOldLogs();
Thread.sleep(5000); // 等待异步操作完成
}
```
**Step 7: 提交代码**
```bash
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个工作日)