588493f4c9
- 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)
1319 lines
39 KiB
Markdown
1319 lines
39 KiB
Markdown
# 操作日志功能优化实施计划
|
||
|
||
> **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个工作日)
|