feat(web): 增强任务治理与系统诊断能力
新增任务持久化、筛选分页、取消任务、健康检查与 AI 输入校验,并完善前端历史管理交互与容错重试机制。补充对应单元测试,提升系统稳定性和可运维性。
This commit is contained in:
@@ -7,8 +7,9 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持
|
|||||||
1. SVN 参数录入与连接测试
|
1. SVN 参数录入与连接测试
|
||||||
2. 异步抓取日志并导出 Markdown
|
2. 异步抓取日志并导出 Markdown
|
||||||
3. 使用 DeepSeek 分析 Markdown 并生成 Excel
|
3. 使用 DeepSeek 分析 Markdown 并生成 Excel
|
||||||
4. 查看任务历史(状态、进度、错误、产物)
|
4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
|
||||||
5. 下载输出文件、配置 API Key 与输出目录
|
5. 下载输出文件、配置 API Key 与输出目录
|
||||||
|
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
|
||||||
|
|
||||||
## 启动方式
|
## 启动方式
|
||||||
|
|
||||||
@@ -27,11 +28,9 @@ http://localhost:8080
|
|||||||
## 页面说明
|
## 页面说明
|
||||||
|
|
||||||
- 工作台:最近任务统计与最近产物
|
- 工作台:最近任务统计与最近产物
|
||||||
- SVN 日志抓取:SVN 地址、账号密码、版本区间、过滤用户
|
- SVN 日志抓取:SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址)
|
||||||
- SVN 日志抓取:支持预置项目下拉(3 个默认项目)与自定义地址
|
|
||||||
- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
|
- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
|
||||||
- 任务历史:异步任务状态与产物列表
|
- 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务
|
||||||
- 系统设置:DeepSeek API Key、输出目录
|
|
||||||
- 系统设置:DeepSeek API Key、输出目录、默认 SVN 预置项目
|
- 系统设置:DeepSeek API Key、输出目录、默认 SVN 预置项目
|
||||||
|
|
||||||
## 输出目录
|
## 输出目录
|
||||||
@@ -39,6 +38,7 @@ http://localhost:8080
|
|||||||
- 默认输出目录:`outputs/`
|
- 默认输出目录:`outputs/`
|
||||||
- Markdown 输出:`outputs/md/*.md`
|
- Markdown 输出:`outputs/md/*.md`
|
||||||
- Excel 输出:`outputs/excel/*.xlsx`
|
- Excel 输出:`outputs/excel/*.xlsx`
|
||||||
|
- 任务持久化:`outputs/task-history.json`(重启后可恢复历史)
|
||||||
|
|
||||||
## API Key 读取优先级
|
## API Key 读取优先级
|
||||||
|
|
||||||
@@ -55,7 +55,11 @@ http://localhost:8080
|
|||||||
- `GET /api/svn/presets`
|
- `GET /api/svn/presets`
|
||||||
- `POST /api/ai/analyze`
|
- `POST /api/ai/analyze`
|
||||||
- `GET /api/tasks`
|
- `GET /api/tasks`
|
||||||
|
- `GET /api/tasks/query?status=&type=&keyword=&page=1&size=10`
|
||||||
- `GET /api/tasks/{taskId}`
|
- `GET /api/tasks/{taskId}`
|
||||||
|
- `POST /api/tasks/{taskId}/cancel`
|
||||||
|
- `GET /api/health`
|
||||||
|
- `GET /api/health/details`
|
||||||
- `GET /api/files`
|
- `GET /api/files`
|
||||||
- `GET /api/files/download?path=...`
|
- `GET /api/files/download?path=...`
|
||||||
- `GET /api/settings`
|
- `GET /api/settings`
|
||||||
@@ -75,3 +79,11 @@ mvn clean compile
|
|||||||
2. 在「SVN 日志抓取」创建任务并生成 `.md`
|
2. 在「SVN 日志抓取」创建任务并生成 `.md`
|
||||||
3. 在「AI 工作量分析」选择 `.md` 并生成 `.xlsx`
|
3. 在「AI 工作量分析」选择 `.md` 并生成 `.xlsx`
|
||||||
4. 在「任务历史」中下载产物并核验内容
|
4. 在「任务历史」中下载产物并核验内容
|
||||||
|
|
||||||
|
## AI 输入校验
|
||||||
|
|
||||||
|
为避免误操作和资源滥用,AI 分析接口增加输入约束:
|
||||||
|
|
||||||
|
- 一次最多分析 20 个文件
|
||||||
|
- 仅允许 `.md` 文件
|
||||||
|
- 单文件大小不超过 2MB
|
||||||
|
|||||||
7
pom.xml
7
pom.xml
@@ -66,6 +66,13 @@
|
|||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
<version>${spring-boot.version}</version>
|
<version>${spring-boot.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import com.svnlog.web.dto.SvnConnectionRequest;
|
|||||||
import com.svnlog.web.dto.SvnFetchRequest;
|
import com.svnlog.web.dto.SvnFetchRequest;
|
||||||
import com.svnlog.web.model.SvnPreset;
|
import com.svnlog.web.model.SvnPreset;
|
||||||
import com.svnlog.web.model.TaskInfo;
|
import com.svnlog.web.model.TaskInfo;
|
||||||
|
import com.svnlog.web.model.TaskPageResult;
|
||||||
import com.svnlog.web.service.AiWorkflowService;
|
import com.svnlog.web.service.AiWorkflowService;
|
||||||
|
import com.svnlog.web.service.HealthService;
|
||||||
import com.svnlog.web.service.OutputFileService;
|
import com.svnlog.web.service.OutputFileService;
|
||||||
import com.svnlog.web.service.SettingsService;
|
import com.svnlog.web.service.SettingsService;
|
||||||
import com.svnlog.web.service.SvnPresetService;
|
import com.svnlog.web.service.SvnPresetService;
|
||||||
@@ -46,27 +48,32 @@ public class AppController {
|
|||||||
private final OutputFileService outputFileService;
|
private final OutputFileService outputFileService;
|
||||||
private final SettingsService settingsService;
|
private final SettingsService settingsService;
|
||||||
private final SvnPresetService svnPresetService;
|
private final SvnPresetService svnPresetService;
|
||||||
|
private final HealthService healthService;
|
||||||
|
|
||||||
public AppController(SvnWorkflowService svnWorkflowService,
|
public AppController(SvnWorkflowService svnWorkflowService,
|
||||||
AiWorkflowService aiWorkflowService,
|
AiWorkflowService aiWorkflowService,
|
||||||
TaskService taskService,
|
TaskService taskService,
|
||||||
OutputFileService outputFileService,
|
OutputFileService outputFileService,
|
||||||
SettingsService settingsService,
|
SettingsService settingsService,
|
||||||
SvnPresetService svnPresetService) {
|
SvnPresetService svnPresetService,
|
||||||
|
HealthService healthService) {
|
||||||
this.svnWorkflowService = svnWorkflowService;
|
this.svnWorkflowService = svnWorkflowService;
|
||||||
this.aiWorkflowService = aiWorkflowService;
|
this.aiWorkflowService = aiWorkflowService;
|
||||||
this.taskService = taskService;
|
this.taskService = taskService;
|
||||||
this.outputFileService = outputFileService;
|
this.outputFileService = outputFileService;
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.svnPresetService = svnPresetService;
|
this.svnPresetService = svnPresetService;
|
||||||
|
this.healthService = healthService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/health")
|
@GetMapping("/health")
|
||||||
public Map<String, Object> health() {
|
public Map<String, Object> health() {
|
||||||
final Map<String, Object> response = new HashMap<String, Object>();
|
return healthService.basicHealth();
|
||||||
response.put("status", "ok");
|
}
|
||||||
response.put("timestamp", Instant.now().toString());
|
|
||||||
return response;
|
@GetMapping("/health/details")
|
||||||
|
public Map<String, Object> healthDetails() throws IOException {
|
||||||
|
return healthService.detailedHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/svn/test-connection")
|
@PostMapping("/svn/test-connection")
|
||||||
@@ -108,6 +115,17 @@ public class AppController {
|
|||||||
return taskService.getTasks();
|
return taskService.getTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tasks/query")
|
||||||
|
public TaskPageResult queryTasks(
|
||||||
|
@RequestParam(value = "status", required = false) String status,
|
||||||
|
@RequestParam(value = "type", required = false) String type,
|
||||||
|
@RequestParam(value = "keyword", required = false) String keyword,
|
||||||
|
@RequestParam(value = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(value = "size", defaultValue = "10") int size
|
||||||
|
) {
|
||||||
|
return taskService.queryTasks(status, type, keyword, page, size);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/tasks/{taskId}")
|
@GetMapping("/tasks/{taskId}")
|
||||||
public TaskInfo getTask(@PathVariable("taskId") String taskId) {
|
public TaskInfo getTask(@PathVariable("taskId") String taskId) {
|
||||||
final TaskInfo task = taskService.getTask(taskId);
|
final TaskInfo task = taskService.getTask(taskId);
|
||||||
@@ -117,6 +135,20 @@ public class AppController {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tasks/{taskId}/cancel")
|
||||||
|
public Map<String, Object> cancelTask(@PathVariable("taskId") String taskId) {
|
||||||
|
final boolean cancelled = taskService.cancelTask(taskId);
|
||||||
|
final Map<String, Object> response = new HashMap<String, Object>();
|
||||||
|
response.put("taskId", taskId);
|
||||||
|
response.put("cancelled", cancelled);
|
||||||
|
if (!cancelled) {
|
||||||
|
response.put("message", "任务已结束或不存在,无法取消");
|
||||||
|
} else {
|
||||||
|
response.put("message", "任务取消成功");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/files")
|
@GetMapping("/files")
|
||||||
public Map<String, Object> listFiles() throws IOException {
|
public Map<String, Object> listFiles() throws IOException {
|
||||||
final Map<String, Object> response = new HashMap<String, Object>();
|
final Map<String, Object> response = new HashMap<String, Object>();
|
||||||
|
|||||||
44
src/main/java/com/svnlog/web/model/TaskPageResult.java
Normal file
44
src/main/java/com/svnlog/web/model/TaskPageResult.java
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package com.svnlog.web.model;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class TaskPageResult {
|
||||||
|
|
||||||
|
private int page;
|
||||||
|
private int size;
|
||||||
|
private long total;
|
||||||
|
private List<TaskInfo> items = new ArrayList<TaskInfo>();
|
||||||
|
|
||||||
|
public int getPage() {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPage(int page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSize() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSize(int size) {
|
||||||
|
this.size = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotal() {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotal(long total) {
|
||||||
|
this.total = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TaskInfo> getItems() {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setItems(List<TaskInfo> items) {
|
||||||
|
this.items = items;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ public enum TaskStatus {
|
|||||||
PENDING,
|
PENDING,
|
||||||
RUNNING,
|
RUNNING,
|
||||||
SUCCESS,
|
SUCCESS,
|
||||||
FAILED
|
FAILED,
|
||||||
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/main/java/com/svnlog/web/service/AiInputValidator.java
Normal file
35
src/main/java/com/svnlog/web/service/AiInputValidator.java
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AiInputValidator {
|
||||||
|
|
||||||
|
private static final int MAX_FILES = 20;
|
||||||
|
private static final long MAX_FILE_SIZE = 2L * 1024L * 1024L;
|
||||||
|
|
||||||
|
public void validate(List<Path> markdownPaths) throws IOException {
|
||||||
|
if (markdownPaths == null || markdownPaths.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("至少需要选择 1 个 Markdown 文件");
|
||||||
|
}
|
||||||
|
if (markdownPaths.size() > MAX_FILES) {
|
||||||
|
throw new IllegalArgumentException("一次最多分析 " + MAX_FILES + " 个文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Path path : markdownPaths) {
|
||||||
|
final String fileName = path.getFileName() == null ? "" : path.getFileName().toString().toLowerCase();
|
||||||
|
if (!fileName.endsWith(".md")) {
|
||||||
|
throw new IllegalArgumentException("仅支持 .md 文件: " + path.toString());
|
||||||
|
}
|
||||||
|
final long fileSize = Files.size(path);
|
||||||
|
if (fileSize > MAX_FILE_SIZE) {
|
||||||
|
throw new IllegalArgumentException("文件过大(>2MB): " + path.getFileName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,15 +51,22 @@ public class AiWorkflowService {
|
|||||||
|
|
||||||
private final OutputFileService outputFileService;
|
private final OutputFileService outputFileService;
|
||||||
private final SettingsService settingsService;
|
private final SettingsService settingsService;
|
||||||
|
private final AiInputValidator aiInputValidator;
|
||||||
|
private final RetrySupport retrySupport = new RetrySupport();
|
||||||
|
|
||||||
public AiWorkflowService(OutputFileService outputFileService, SettingsService settingsService) {
|
public AiWorkflowService(OutputFileService outputFileService,
|
||||||
|
SettingsService settingsService,
|
||||||
|
AiInputValidator aiInputValidator) {
|
||||||
this.outputFileService = outputFileService;
|
this.outputFileService = outputFileService;
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
|
this.aiInputValidator = aiInputValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
|
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
|
||||||
context.setProgress(10, "正在读取 Markdown 文件");
|
context.setProgress(10, "正在读取 Markdown 文件");
|
||||||
final String content = readMarkdownFiles(request.getFilePaths());
|
final List<Path> markdownFiles = resolveUserFiles(request.getFilePaths());
|
||||||
|
aiInputValidator.validate(markdownFiles);
|
||||||
|
final String content = readMarkdownFiles(markdownFiles);
|
||||||
|
|
||||||
context.setProgress(35, "正在请求 DeepSeek 分析");
|
context.setProgress(35, "正在请求 DeepSeek 分析");
|
||||||
final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
|
final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
|
||||||
@@ -88,10 +95,9 @@ public class AiWorkflowService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String readMarkdownFiles(List<String> filePaths) throws IOException {
|
private String readMarkdownFiles(List<Path> filePaths) throws IOException {
|
||||||
final StringBuilder builder = new StringBuilder();
|
final StringBuilder builder = new StringBuilder();
|
||||||
for (String filePath : filePaths) {
|
for (Path path : filePaths) {
|
||||||
final Path path = resolveUserFile(filePath);
|
|
||||||
final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
||||||
builder.append("\n\n=== 文件: ").append(path.getFileName().toString()).append(" ===\n");
|
builder.append("\n\n=== 文件: ").append(path.getFileName().toString()).append(" ===\n");
|
||||||
builder.append(content);
|
builder.append(content);
|
||||||
@@ -99,6 +105,17 @@ public class AiWorkflowService {
|
|||||||
return builder.toString();
|
return builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Path> resolveUserFiles(List<String> userPaths) throws IOException {
|
||||||
|
java.util.ArrayList<Path> files = new java.util.ArrayList<Path>();
|
||||||
|
if (userPaths == null) {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
for (String userPath : userPaths) {
|
||||||
|
files.add(resolveUserFile(userPath));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
private Path resolveUserFile(String userPath) throws IOException {
|
private Path resolveUserFile(String userPath) throws IOException {
|
||||||
if (userPath == null || userPath.trim().isEmpty()) {
|
if (userPath == null || userPath.trim().isEmpty()) {
|
||||||
throw new IllegalArgumentException("文件路径不能为空");
|
throw new IllegalArgumentException("文件路径不能为空");
|
||||||
@@ -135,6 +152,16 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String callDeepSeek(String apiKey, String prompt) throws IOException {
|
private String callDeepSeek(String apiKey, String prompt) throws IOException {
|
||||||
|
try {
|
||||||
|
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt), 3, 1000L);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IOException(e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String callDeepSeekOnce(String apiKey, String prompt) throws Exception {
|
||||||
final JsonObject message = new JsonObject();
|
final JsonObject message = new JsonObject();
|
||||||
message.addProperty("role", "user");
|
message.addProperty("role", "user");
|
||||||
message.addProperty("content", prompt);
|
message.addProperty("content", prompt);
|
||||||
@@ -161,10 +188,14 @@ public class AiWorkflowService {
|
|||||||
if (response.body() != null) {
|
if (response.body() != null) {
|
||||||
errorBody = response.body().string();
|
errorBody = response.body().string();
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("DeepSeek API 调用失败: " + response.code() + " " + errorBody);
|
String detail = "DeepSeek API 调用失败: " + response.code() + " " + errorBody;
|
||||||
|
if (response.code() == 429 || response.code() >= 500) {
|
||||||
|
throw new RetrySupport.RetryableException(detail);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(detail);
|
||||||
}
|
}
|
||||||
if (response.body() == null) {
|
if (response.body() == null) {
|
||||||
throw new IllegalStateException("DeepSeek API 返回空响应体");
|
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
|
||||||
}
|
}
|
||||||
|
|
||||||
final String raw = response.body().string();
|
final String raw = response.body().string();
|
||||||
|
|||||||
81
src/main/java/com/svnlog/web/service/HealthService.java
Normal file
81
src/main/java/com/svnlog/web/service/HealthService.java
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.svnlog.web.model.TaskInfo;
|
||||||
|
import com.svnlog.web.model.TaskStatus;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class HealthService {
|
||||||
|
|
||||||
|
private final OutputFileService outputFileService;
|
||||||
|
private final SettingsService settingsService;
|
||||||
|
private final TaskService taskService;
|
||||||
|
|
||||||
|
public HealthService(OutputFileService outputFileService,
|
||||||
|
SettingsService settingsService,
|
||||||
|
TaskService taskService) {
|
||||||
|
this.outputFileService = outputFileService;
|
||||||
|
this.settingsService = settingsService;
|
||||||
|
this.taskService = taskService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> basicHealth() {
|
||||||
|
final Map<String, Object> response = new HashMap<String, Object>();
|
||||||
|
response.put("status", "ok");
|
||||||
|
response.put("timestamp", Instant.now().toString());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> detailedHealth() throws IOException {
|
||||||
|
final Map<String, Object> result = new HashMap<String, Object>();
|
||||||
|
final Map<String, Object> settings = settingsService.getSettings();
|
||||||
|
final Path outputRoot = outputFileService.getOutputRoot();
|
||||||
|
|
||||||
|
final boolean outputDirWritable = ensureWritable(outputRoot);
|
||||||
|
int running = 0;
|
||||||
|
int failed = 0;
|
||||||
|
int cancelled = 0;
|
||||||
|
for (TaskInfo task : taskService.getTasks()) {
|
||||||
|
if (task.getStatus() == TaskStatus.RUNNING || task.getStatus() == TaskStatus.PENDING) {
|
||||||
|
running++;
|
||||||
|
}
|
||||||
|
if (task.getStatus() == TaskStatus.FAILED) {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
if (task.getStatus() == TaskStatus.CANCELLED) {
|
||||||
|
cancelled++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("status", "ok");
|
||||||
|
result.put("timestamp", Instant.now().toString());
|
||||||
|
result.put("outputDir", outputRoot.toString());
|
||||||
|
result.put("outputDirWritable", outputDirWritable);
|
||||||
|
result.put("apiKeyConfigured", Boolean.TRUE.equals(settings.get("apiKeyConfigured")));
|
||||||
|
result.put("taskTotal", taskService.getTasks().size());
|
||||||
|
result.put("taskRunning", running);
|
||||||
|
result.put("taskFailed", failed);
|
||||||
|
result.put("taskCancelled", cancelled);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean ensureWritable(Path outputRoot) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(outputRoot);
|
||||||
|
final Path probe = outputRoot.resolve(".health-probe");
|
||||||
|
Files.write(probe, "ok".getBytes("UTF-8"));
|
||||||
|
Files.deleteIfExists(probe);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/main/java/com/svnlog/web/service/RetrySupport.java
Normal file
53
src/main/java/com/svnlog/web/service/RetrySupport.java
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
public class RetrySupport {
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface RetryableSupplier<T> {
|
||||||
|
T get() throws Exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T execute(RetryableSupplier<T> supplier, int maxAttempts, long initialDelayMillis) throws Exception {
|
||||||
|
if (maxAttempts <= 0) {
|
||||||
|
throw new IllegalArgumentException("maxAttempts 必须大于 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
Exception lastException = null;
|
||||||
|
long delay = Math.max(0L, initialDelayMillis);
|
||||||
|
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return supplier.get();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
lastException = ex;
|
||||||
|
if (attempt == maxAttempts || !isRetryable(ex)) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delay > 0L) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(delay);
|
||||||
|
} catch (InterruptedException interruptedException) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
delay = delay * 2L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRetryable(Exception ex) {
|
||||||
|
if (ex instanceof RetryableException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ex instanceof java.io.IOException;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RetryableException extends Exception {
|
||||||
|
public RetryableException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ import com.svnlog.web.model.TaskInfo;
|
|||||||
public class TaskContext {
|
public class TaskContext {
|
||||||
|
|
||||||
private final TaskInfo taskInfo;
|
private final TaskInfo taskInfo;
|
||||||
|
private final Runnable onUpdate;
|
||||||
|
|
||||||
public TaskContext(TaskInfo taskInfo) {
|
public TaskContext(TaskInfo taskInfo, Runnable onUpdate) {
|
||||||
this.taskInfo = taskInfo;
|
this.taskInfo = taskInfo;
|
||||||
|
this.onUpdate = onUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setProgress(int progress, String message) {
|
public void setProgress(int progress, String message) {
|
||||||
@@ -15,5 +17,8 @@ public class TaskContext {
|
|||||||
taskInfo.setProgress(bounded);
|
taskInfo.setProgress(bounded);
|
||||||
taskInfo.setMessage(message);
|
taskInfo.setMessage(message);
|
||||||
taskInfo.setUpdatedAt(java.time.Instant.now());
|
taskInfo.setUpdatedAt(java.time.Instant.now());
|
||||||
|
if (onUpdate != null) {
|
||||||
|
onUpdate.run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
138
src/main/java/com/svnlog/web/service/TaskPersistenceService.java
Normal file
138
src/main/java/com/svnlog/web/service/TaskPersistenceService.java
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
import com.svnlog.web.model.TaskInfo;
|
||||||
|
import com.svnlog.web.model.TaskStatus;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TaskPersistenceService {
|
||||||
|
|
||||||
|
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
|
||||||
|
public List<TaskInfo> load(Path storePath) throws IOException {
|
||||||
|
if (storePath == null || !Files.exists(storePath) || !Files.isRegularFile(storePath)) {
|
||||||
|
return new ArrayList<TaskInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Reader reader = Files.newBufferedReader(storePath, StandardCharsets.UTF_8)) {
|
||||||
|
List<PersistedTaskInfo> persisted = gson.fromJson(
|
||||||
|
reader,
|
||||||
|
new TypeToken<List<PersistedTaskInfo>>() {
|
||||||
|
}.getType()
|
||||||
|
);
|
||||||
|
if (persisted == null) {
|
||||||
|
return new ArrayList<TaskInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TaskInfo> result = new ArrayList<TaskInfo>();
|
||||||
|
for (PersistedTaskInfo item : persisted) {
|
||||||
|
result.add(toTaskInfo(item));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(Path storePath, Collection<TaskInfo> tasks) throws IOException {
|
||||||
|
if (storePath == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storePath.getParent() != null) {
|
||||||
|
Files.createDirectories(storePath.getParent());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PersistedTaskInfo> persisted = new ArrayList<PersistedTaskInfo>();
|
||||||
|
if (tasks != null) {
|
||||||
|
for (TaskInfo task : tasks) {
|
||||||
|
persisted.add(fromTaskInfo(task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Writer writer = Files.newBufferedWriter(storePath, StandardCharsets.UTF_8)) {
|
||||||
|
gson.toJson(persisted, writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PersistedTaskInfo fromTaskInfo(TaskInfo task) {
|
||||||
|
PersistedTaskInfo info = new PersistedTaskInfo();
|
||||||
|
info.taskId = task.getTaskId();
|
||||||
|
info.type = task.getType();
|
||||||
|
info.status = task.getStatus() == null ? null : task.getStatus().name();
|
||||||
|
info.progress = task.getProgress();
|
||||||
|
info.message = task.getMessage();
|
||||||
|
info.error = task.getError();
|
||||||
|
info.createdAt = toString(task.getCreatedAt());
|
||||||
|
info.updatedAt = toString(task.getUpdatedAt());
|
||||||
|
info.files = new ArrayList<String>(task.getFiles());
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskInfo toTaskInfo(PersistedTaskInfo persisted) {
|
||||||
|
TaskInfo task = new TaskInfo();
|
||||||
|
task.setTaskId(persisted.taskId);
|
||||||
|
task.setType(persisted.type);
|
||||||
|
task.setStatus(parseStatus(persisted.status));
|
||||||
|
task.setProgress(persisted.progress);
|
||||||
|
task.setMessage(persisted.message);
|
||||||
|
task.setError(persisted.error);
|
||||||
|
task.setCreatedAt(parseInstant(persisted.createdAt));
|
||||||
|
task.setUpdatedAt(parseInstant(persisted.updatedAt));
|
||||||
|
if (persisted.files != null) {
|
||||||
|
task.getFiles().addAll(persisted.files);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant parseInstant(String value) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
return Instant.now();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Instant.parse(value);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toString(Instant value) {
|
||||||
|
return value == null ? Instant.now().toString() : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskStatus parseStatus(String value) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
return TaskStatus.FAILED;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return TaskStatus.valueOf(value);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return TaskStatus.FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class PersistedTaskInfo {
|
||||||
|
private String taskId;
|
||||||
|
private String type;
|
||||||
|
private String status;
|
||||||
|
private int progress;
|
||||||
|
private String message;
|
||||||
|
private String error;
|
||||||
|
private String createdAt;
|
||||||
|
private String updatedAt;
|
||||||
|
private List<String> files;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,24 @@ package com.svnlog.web.service;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
|
||||||
import javax.annotation.PreDestroy;
|
import javax.annotation.PreDestroy;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.svnlog.web.model.TaskInfo;
|
import com.svnlog.web.model.TaskInfo;
|
||||||
|
import com.svnlog.web.model.TaskPageResult;
|
||||||
import com.svnlog.web.model.TaskResult;
|
import com.svnlog.web.model.TaskResult;
|
||||||
import com.svnlog.web.model.TaskStatus;
|
import com.svnlog.web.model.TaskStatus;
|
||||||
|
|
||||||
@@ -27,6 +32,15 @@ public class TaskService {
|
|||||||
|
|
||||||
private final ExecutorService executor = Executors.newFixedThreadPool(4);
|
private final ExecutorService executor = Executors.newFixedThreadPool(4);
|
||||||
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
|
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
|
||||||
|
private final Map<String, Future<?>> futures = new ConcurrentHashMap<String, Future<?>>();
|
||||||
|
private final TaskPersistenceService persistenceService;
|
||||||
|
private final OutputFileService outputFileService;
|
||||||
|
|
||||||
|
public TaskService(TaskPersistenceService persistenceService, OutputFileService outputFileService) {
|
||||||
|
this.persistenceService = persistenceService;
|
||||||
|
this.outputFileService = outputFileService;
|
||||||
|
loadPersistedTasks();
|
||||||
|
}
|
||||||
|
|
||||||
public String submit(String type, TaskRunner runner) {
|
public String submit(String type, TaskRunner runner) {
|
||||||
final String taskId = UUID.randomUUID().toString();
|
final String taskId = UUID.randomUUID().toString();
|
||||||
@@ -41,14 +55,16 @@ public class TaskService {
|
|||||||
taskInfo.setCreatedAt(now);
|
taskInfo.setCreatedAt(now);
|
||||||
taskInfo.setUpdatedAt(now);
|
taskInfo.setUpdatedAt(now);
|
||||||
tasks.put(taskId, taskInfo);
|
tasks.put(taskId, taskInfo);
|
||||||
|
persistSafely();
|
||||||
|
|
||||||
executor.submit(new Callable<Void>() {
|
Future<?> future = executor.submit(new Callable<Void>() {
|
||||||
@Override
|
@Override
|
||||||
public Void call() {
|
public Void call() {
|
||||||
runTask(taskInfo, runner);
|
runTaskInternal(taskInfo, runner);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
futures.put(taskId, future);
|
||||||
|
|
||||||
return taskId;
|
return taskId;
|
||||||
}
|
}
|
||||||
@@ -58,16 +74,70 @@ public class TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<TaskInfo> getTasks() {
|
public List<TaskInfo> getTasks() {
|
||||||
return new ArrayList<TaskInfo>(tasks.values());
|
return new ArrayList<TaskInfo>(tasks.values()).stream()
|
||||||
|
.sorted(Comparator.comparing(TaskInfo::getCreatedAt).reversed())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public TaskPageResult queryTasks(String status, String type, String keyword, int page, int size) {
|
||||||
|
final int safePage = Math.max(page, 1);
|
||||||
|
final int safeSize = Math.max(1, Math.min(size, 200));
|
||||||
|
|
||||||
|
final List<TaskInfo> filtered = getTasks().stream()
|
||||||
|
.filter(task -> matchStatus(task, status))
|
||||||
|
.filter(task -> matchType(task, type))
|
||||||
|
.filter(task -> matchKeyword(task, keyword))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
int fromIndex = (safePage - 1) * safeSize;
|
||||||
|
if (fromIndex > filtered.size()) {
|
||||||
|
fromIndex = filtered.size();
|
||||||
|
}
|
||||||
|
final int toIndex = Math.min(fromIndex + safeSize, filtered.size());
|
||||||
|
|
||||||
|
TaskPageResult result = new TaskPageResult();
|
||||||
|
result.setPage(safePage);
|
||||||
|
result.setSize(safeSize);
|
||||||
|
result.setTotal(filtered.size());
|
||||||
|
result.setItems(new ArrayList<TaskInfo>(filtered.subList(fromIndex, toIndex)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean cancelTask(String taskId) {
|
||||||
|
final TaskInfo task = tasks.get(taskId);
|
||||||
|
if (task == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final TaskStatus status = task.getStatus();
|
||||||
|
if (status == TaskStatus.SUCCESS || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Future<?> future = futures.get(taskId);
|
||||||
|
if (future != null) {
|
||||||
|
future.cancel(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.setStatus(TaskStatus.CANCELLED);
|
||||||
|
task.setMessage("任务已取消");
|
||||||
|
task.setUpdatedAt(Instant.now());
|
||||||
|
persistSafely();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runTaskInternal(TaskInfo taskInfo, TaskRunner runner) {
|
||||||
|
try {
|
||||||
|
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void runTask(TaskInfo taskInfo, TaskRunner runner) {
|
|
||||||
taskInfo.setStatus(TaskStatus.RUNNING);
|
taskInfo.setStatus(TaskStatus.RUNNING);
|
||||||
taskInfo.setMessage("任务执行中");
|
taskInfo.setMessage("任务执行中");
|
||||||
taskInfo.setUpdatedAt(Instant.now());
|
taskInfo.setUpdatedAt(Instant.now());
|
||||||
|
persistSafely();
|
||||||
|
|
||||||
final TaskContext context = new TaskContext(taskInfo);
|
final TaskContext context = new TaskContext(taskInfo, this::persistSafely);
|
||||||
try {
|
|
||||||
final TaskResult result = runner.run(context);
|
final TaskResult result = runner.run(context);
|
||||||
taskInfo.setStatus(TaskStatus.SUCCESS);
|
taskInfo.setStatus(TaskStatus.SUCCESS);
|
||||||
taskInfo.setProgress(100);
|
taskInfo.setProgress(100);
|
||||||
@@ -77,11 +147,83 @@ public class TaskService {
|
|||||||
taskInfo.getFiles().addAll(result.getFiles());
|
taskInfo.getFiles().addAll(result.getFiles());
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
||||||
|
taskInfo.setUpdatedAt(Instant.now());
|
||||||
|
persistSafely();
|
||||||
|
return;
|
||||||
|
}
|
||||||
taskInfo.setStatus(TaskStatus.FAILED);
|
taskInfo.setStatus(TaskStatus.FAILED);
|
||||||
taskInfo.setError(e.getMessage());
|
taskInfo.setError(e.getMessage());
|
||||||
taskInfo.setMessage("执行失败");
|
taskInfo.setMessage("执行失败");
|
||||||
}
|
|
||||||
taskInfo.setUpdatedAt(Instant.now());
|
taskInfo.setUpdatedAt(Instant.now());
|
||||||
|
persistSafely();
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
futures.remove(taskInfo.getTaskId());
|
||||||
|
}
|
||||||
|
|
||||||
|
taskInfo.setUpdatedAt(Instant.now());
|
||||||
|
persistSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadPersistedTasks() {
|
||||||
|
try {
|
||||||
|
final List<TaskInfo> loaded = persistenceService.load(buildStorePath());
|
||||||
|
for (TaskInfo task : loaded) {
|
||||||
|
if (task.getStatus() == TaskStatus.RUNNING || task.getStatus() == TaskStatus.PENDING) {
|
||||||
|
task.setStatus(TaskStatus.FAILED);
|
||||||
|
task.setMessage("任务因服务重启中断");
|
||||||
|
task.setUpdatedAt(Instant.now());
|
||||||
|
}
|
||||||
|
tasks.put(task.getTaskId(), task);
|
||||||
|
}
|
||||||
|
if (!loaded.isEmpty()) {
|
||||||
|
persistSafely();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// ignore persistence loading failures to keep service available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void persistSafely() {
|
||||||
|
try {
|
||||||
|
persistenceService.save(buildStorePath(), tasks.values());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// ignore persistence saving failures to avoid interrupting running tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private java.nio.file.Path buildStorePath() throws java.io.IOException {
|
||||||
|
return outputFileService.getOutputRoot().resolve("task-history.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchStatus(TaskInfo task, String status) {
|
||||||
|
if (status == null || status.trim().isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return task.getStatus() != null && task.getStatus().name().equalsIgnoreCase(status.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchType(TaskInfo task, String type) {
|
||||||
|
if (type == null || type.trim().isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return task.getType() != null && task.getType().equalsIgnoreCase(type.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchKeyword(TaskInfo task, String keyword) {
|
||||||
|
if (keyword == null || keyword.trim().isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final String lowered = keyword.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return contains(task.getTaskId(), lowered)
|
||||||
|
|| contains(task.getMessage(), lowered)
|
||||||
|
|| contains(task.getError(), lowered)
|
||||||
|
|| contains(task.getType(), lowered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean contains(String value, String keyword) {
|
||||||
|
return value != null && value.toLowerCase(Locale.ROOT).contains(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
const state = {
|
const state = {
|
||||||
tasks: [],
|
tasks: [],
|
||||||
|
taskPage: { items: [], page: 1, size: 10, total: 0 },
|
||||||
|
taskQuery: { status: "", type: "", keyword: "", page: 1, size: 10 },
|
||||||
files: [],
|
files: [],
|
||||||
|
health: null,
|
||||||
presets: [],
|
presets: [],
|
||||||
defaultPresetId: "",
|
defaultPresetId: "",
|
||||||
activeView: "dashboard",
|
activeView: "dashboard",
|
||||||
@@ -51,6 +54,11 @@ function bindForms() {
|
|||||||
|
|
||||||
const svnPresetSelect = document.querySelector("#svn-preset-select");
|
const svnPresetSelect = document.querySelector("#svn-preset-select");
|
||||||
svnPresetSelect.addEventListener("change", onSvnPresetChange);
|
svnPresetSelect.addEventListener("change", onSvnPresetChange);
|
||||||
|
|
||||||
|
const taskFilterBtn = document.querySelector("#btn-task-filter");
|
||||||
|
if (taskFilterBtn) {
|
||||||
|
taskFilterBtn.addEventListener("click", onTaskFilterSubmit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchView(view) {
|
function switchView(view) {
|
||||||
@@ -65,7 +73,7 @@ function switchView(view) {
|
|||||||
document.querySelector("#view-desc").textContent = viewMeta[view].desc;
|
document.querySelector("#view-desc").textContent = viewMeta[view].desc;
|
||||||
|
|
||||||
if (view === "history") {
|
if (view === "history") {
|
||||||
renderTaskTable();
|
loadTaskPage();
|
||||||
renderFileTable();
|
renderFileTable();
|
||||||
}
|
}
|
||||||
if (view === "ai") {
|
if (view === "ai") {
|
||||||
@@ -88,15 +96,17 @@ async function apiFetch(url, options = {}) {
|
|||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
try {
|
try {
|
||||||
const [tasksResp, filesResp] = await Promise.all([
|
const [tasksResp, filesResp, healthResp] = await Promise.all([
|
||||||
apiFetch("/api/tasks"),
|
apiFetch("/api/tasks"),
|
||||||
apiFetch("/api/files"),
|
apiFetch("/api/files"),
|
||||||
|
apiFetch("/api/health/details"),
|
||||||
]);
|
]);
|
||||||
state.tasks = (tasksResp || []).slice().sort((a, b) => sortByTimeDesc(a.createdAt, b.createdAt));
|
state.tasks = (tasksResp || []).slice().sort((a, b) => sortByTimeDesc(a.createdAt, b.createdAt));
|
||||||
state.files = (filesResp.files || []).slice().sort((a, b) => sortByTimeDesc(a.modifiedAt, b.modifiedAt));
|
state.files = (filesResp.files || []).slice().sort((a, b) => sortByTimeDesc(a.modifiedAt, b.modifiedAt));
|
||||||
|
state.health = healthResp || null;
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
if (state.activeView === "history") {
|
if (state.activeView === "history") {
|
||||||
renderTaskTable();
|
loadTaskPage();
|
||||||
renderFileTable();
|
renderFileTable();
|
||||||
}
|
}
|
||||||
if (state.activeView === "ai") {
|
if (state.activeView === "ai") {
|
||||||
@@ -180,10 +190,19 @@ function renderDashboard() {
|
|||||||
const total = state.tasks.length;
|
const total = state.tasks.length;
|
||||||
const running = state.tasks.filter((t) => t.status === "RUNNING" || t.status === "PENDING").length;
|
const running = state.tasks.filter((t) => t.status === "RUNNING" || t.status === "PENDING").length;
|
||||||
const failed = state.tasks.filter((t) => t.status === "FAILED").length;
|
const failed = state.tasks.filter((t) => t.status === "FAILED").length;
|
||||||
|
const health = state.health;
|
||||||
|
|
||||||
document.querySelector("#stat-total").textContent = `${total}`;
|
document.querySelector("#stat-total").textContent = `${total}`;
|
||||||
document.querySelector("#stat-running").textContent = `${running}`;
|
document.querySelector("#stat-running").textContent = `${running}`;
|
||||||
document.querySelector("#stat-failed").textContent = `${failed}`;
|
document.querySelector("#stat-failed").textContent = `${failed}`;
|
||||||
|
document.querySelector("#stat-health").textContent = health && health.outputDirWritable ? "正常" : "异常";
|
||||||
|
|
||||||
|
const healthDetails = document.querySelector("#health-details");
|
||||||
|
if (health) {
|
||||||
|
healthDetails.textContent = `输出目录: ${health.outputDir} | 可写: ${health.outputDirWritable ? "是" : "否"} | API Key: ${health.apiKeyConfigured ? "已配置" : "未配置"}`;
|
||||||
|
} else {
|
||||||
|
healthDetails.textContent = "健康状态暂不可用";
|
||||||
|
}
|
||||||
|
|
||||||
const taskList = document.querySelector("#recent-tasks");
|
const taskList = document.querySelector("#recent-tasks");
|
||||||
taskList.innerHTML = "";
|
taskList.innerHTML = "";
|
||||||
@@ -328,13 +347,15 @@ async function onRunAi(event) {
|
|||||||
|
|
||||||
function renderTaskTable() {
|
function renderTaskTable() {
|
||||||
const container = document.querySelector("#task-table");
|
const container = document.querySelector("#task-table");
|
||||||
if (!state.tasks.length) {
|
if (!state.taskPage.items.length) {
|
||||||
container.innerHTML = "<p class='muted'>暂无任务记录</p>";
|
container.innerHTML = "<p class='muted'>暂无任务记录</p>";
|
||||||
|
renderTaskPager();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = state.tasks.map((task) => {
|
const rows = state.taskPage.items.map((task) => {
|
||||||
const files = (task.files || []).map((f) => `<a href="/api/files/download?path=${encodeURIComponent(f)}">${escapeHtml(f)}</a>`).join("<br>");
|
const files = (task.files || []).map((f) => `<a href="/api/files/download?path=${encodeURIComponent(f)}">${escapeHtml(f)}</a>`).join("<br>");
|
||||||
|
const canCancel = task.status === "RUNNING" || task.status === "PENDING";
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${escapeHtml(task.taskId.slice(0, 8))}</td>
|
<td>${escapeHtml(task.taskId.slice(0, 8))}</td>
|
||||||
<td>${escapeHtml(task.type)}</td>
|
<td>${escapeHtml(task.type)}</td>
|
||||||
@@ -342,13 +363,106 @@ function renderTaskTable() {
|
|||||||
<td>${task.progress || 0}%</td>
|
<td>${task.progress || 0}%</td>
|
||||||
<td>${escapeHtml(task.message || "")}${task.error ? `<br><span class='muted'>${escapeHtml(task.error)}</span>` : ""}</td>
|
<td>${escapeHtml(task.message || "")}${task.error ? `<br><span class='muted'>${escapeHtml(task.error)}</span>` : ""}</td>
|
||||||
<td>${files || "-"}</td>
|
<td>${files || "-"}</td>
|
||||||
|
<td>${canCancel ? `<button type="button" class="btn-cancel-task" data-task-id="${escapeHtml(task.taskId)}">取消</button>` : "-"}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
container.innerHTML = `<table>
|
container.innerHTML = `<table>
|
||||||
<thead><tr><th>任务ID</th><th>类型</th><th>状态</th><th>进度</th><th>说明</th><th>产物</th></tr></thead>
|
<thead><tr><th>任务ID</th><th>类型</th><th>状态</th><th>进度</th><th>说明</th><th>产物</th><th>操作</th></tr></thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
|
|
||||||
|
document.querySelectorAll(".btn-cancel-task").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const taskId = btn.dataset.taskId;
|
||||||
|
if (!taskId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(btn, true);
|
||||||
|
try {
|
||||||
|
const result = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: "POST" });
|
||||||
|
toast(result.message || "任务取消请求已处理");
|
||||||
|
await loadTaskPage();
|
||||||
|
await refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
} finally {
|
||||||
|
setLoading(btn, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderTaskPager();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTaskPage() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (state.taskQuery.status) {
|
||||||
|
params.set("status", state.taskQuery.status);
|
||||||
|
}
|
||||||
|
if (state.taskQuery.type) {
|
||||||
|
params.set("type", state.taskQuery.type);
|
||||||
|
}
|
||||||
|
if (state.taskQuery.keyword) {
|
||||||
|
params.set("keyword", state.taskQuery.keyword);
|
||||||
|
}
|
||||||
|
params.set("page", String(state.taskQuery.page));
|
||||||
|
params.set("size", String(state.taskQuery.size));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch(`/api/tasks/query?${params.toString()}`);
|
||||||
|
state.taskPage = {
|
||||||
|
items: data.items || [],
|
||||||
|
page: data.page || 1,
|
||||||
|
size: data.size || state.taskQuery.size,
|
||||||
|
total: data.total || 0,
|
||||||
|
};
|
||||||
|
renderTaskTable();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskFilterSubmit() {
|
||||||
|
state.taskQuery.status = document.querySelector("#task-filter-status").value || "";
|
||||||
|
state.taskQuery.type = document.querySelector("#task-filter-type").value || "";
|
||||||
|
state.taskQuery.keyword = (document.querySelector("#task-filter-keyword").value || "").trim();
|
||||||
|
state.taskQuery.page = 1;
|
||||||
|
loadTaskPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskPager() {
|
||||||
|
const pager = document.querySelector("#task-pager");
|
||||||
|
if (!pager) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const totalPages = Math.max(1, Math.ceil((state.taskPage.total || 0) / state.taskQuery.size));
|
||||||
|
const current = state.taskPage.page || 1;
|
||||||
|
pager.innerHTML = `
|
||||||
|
<span>共 ${state.taskPage.total || 0} 条,第 ${current}/${totalPages} 页</span>
|
||||||
|
<div class="pager-actions">
|
||||||
|
<button type="button" ${current <= 1 ? "disabled" : ""} id="btn-page-prev">上一页</button>
|
||||||
|
<button type="button" ${current >= totalPages ? "disabled" : ""} id="btn-page-next">下一页</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const prev = document.querySelector("#btn-page-prev");
|
||||||
|
const next = document.querySelector("#btn-page-next");
|
||||||
|
if (prev) {
|
||||||
|
prev.addEventListener("click", () => {
|
||||||
|
if (state.taskQuery.page > 1) {
|
||||||
|
state.taskQuery.page -= 1;
|
||||||
|
loadTaskPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (next) {
|
||||||
|
next.addEventListener("click", () => {
|
||||||
|
if (state.taskQuery.page < totalPages) {
|
||||||
|
state.taskQuery.page += 1;
|
||||||
|
loadTaskPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFileTable() {
|
function renderFileTable() {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="view active" id="view-dashboard" aria-live="polite">
|
<section class="view active" id="view-dashboard" aria-live="polite">
|
||||||
<div class="grid cols-3" id="stats-cards">
|
<div class="grid cols-4" id="stats-cards">
|
||||||
<article class="card stat">
|
<article class="card stat">
|
||||||
<h3>任务总数</h3>
|
<h3>任务总数</h3>
|
||||||
<p id="stat-total">0</p>
|
<p id="stat-total">0</p>
|
||||||
@@ -39,8 +39,17 @@
|
|||||||
<h3>失败任务</h3>
|
<h3>失败任务</h3>
|
||||||
<p id="stat-failed">0</p>
|
<p id="stat-failed">0</p>
|
||||||
</article>
|
</article>
|
||||||
|
<article class="card stat">
|
||||||
|
<h3>系统状态</h3>
|
||||||
|
<p id="stat-health">-</p>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<article class="card" id="health-card">
|
||||||
|
<h3>健康检查</h3>
|
||||||
|
<p class="muted" id="health-details">加载中...</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
<div class="grid cols-2">
|
<div class="grid cols-2">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<h3>最近任务</h3>
|
<h3>最近任务</h3>
|
||||||
@@ -94,7 +103,25 @@
|
|||||||
<section class="view" id="view-history">
|
<section class="view" id="view-history">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<h3>任务列表</h3>
|
<h3>任务列表</h3>
|
||||||
|
<div class="history-toolbar" id="history-toolbar">
|
||||||
|
<select id="task-filter-status" aria-label="状态筛选">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="PENDING">PENDING</option>
|
||||||
|
<option value="RUNNING">RUNNING</option>
|
||||||
|
<option value="SUCCESS">SUCCESS</option>
|
||||||
|
<option value="FAILED">FAILED</option>
|
||||||
|
<option value="CANCELLED">CANCELLED</option>
|
||||||
|
</select>
|
||||||
|
<select id="task-filter-type" aria-label="类型筛选">
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
<option value="SVN_FETCH">SVN_FETCH</option>
|
||||||
|
<option value="AI_ANALYZE">AI_ANALYZE</option>
|
||||||
|
</select>
|
||||||
|
<input id="task-filter-keyword" placeholder="搜索任务ID/信息" aria-label="关键词搜索">
|
||||||
|
<button id="btn-task-filter" type="button">查询</button>
|
||||||
|
</div>
|
||||||
<div id="task-table" class="table-wrap"></div>
|
<div id="task-table" class="table-wrap"></div>
|
||||||
|
<div class="pager" id="task-pager"></div>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<h3>输出文件</h3>
|
<h3>输出文件</h3>
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ body {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid.cols-4 {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.grid.cols-2 {
|
.grid.cols-2 {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -276,6 +281,27 @@ button:disabled {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 180px minmax(220px, 1fr) 120px;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager .pager-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -315,6 +341,11 @@ td {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag.CANCELLED {
|
||||||
|
background: #e4e7ec;
|
||||||
|
color: #344054;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -353,11 +384,16 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid.cols-3,
|
.grid.cols-3,
|
||||||
|
.grid.cols-4,
|
||||||
.grid.cols-2,
|
.grid.cols-2,
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.span-2 {
|
.span-2 {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
public class AiInputValidatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectEmptyListWhenValidatingInputFiles() {
|
||||||
|
AiInputValidator validator = new AiInputValidator();
|
||||||
|
Assertions.assertThrows(IllegalArgumentException.class, () -> validator.validate(Collections.<Path>emptyList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectNonMarkdownFileWhenValidatingInputFiles() throws Exception {
|
||||||
|
AiInputValidator validator = new AiInputValidator();
|
||||||
|
Path temp = Files.createTempFile("ai-input", ".txt");
|
||||||
|
Files.write(temp, "abc".getBytes(StandardCharsets.UTF_8));
|
||||||
|
Assertions.assertThrows(IllegalArgumentException.class, () -> validator.validate(Arrays.asList(temp)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldAcceptSmallMarkdownFilesWhenValidatingInputFiles() throws Exception {
|
||||||
|
AiInputValidator validator = new AiInputValidator();
|
||||||
|
Path temp = Files.createTempFile("ai-input", ".md");
|
||||||
|
Files.write(temp, "# title".getBytes(StandardCharsets.UTF_8));
|
||||||
|
validator.validate(Arrays.asList(temp));
|
||||||
|
Assertions.assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/test/java/com/svnlog/web/service/HealthServiceTest.java
Normal file
36
src/test/java/com/svnlog/web/service/HealthServiceTest.java
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
public class HealthServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldReturnDetailedHealthWhenDependenciesAvailable() throws Exception {
|
||||||
|
OutputFileService outputFileService = Mockito.mock(OutputFileService.class);
|
||||||
|
SettingsService settingsService = Mockito.mock(SettingsService.class);
|
||||||
|
TaskService taskService = Mockito.mock(TaskService.class);
|
||||||
|
|
||||||
|
Path outputDir = Files.createTempDirectory("health-service-test");
|
||||||
|
Mockito.when(outputFileService.getOutputRoot()).thenReturn(outputDir);
|
||||||
|
|
||||||
|
Map<String, Object> settings = new HashMap<String, Object>();
|
||||||
|
settings.put("apiKeyConfigured", true);
|
||||||
|
Mockito.when(settingsService.getSettings()).thenReturn(settings);
|
||||||
|
Mockito.when(taskService.getTasks()).thenReturn(new java.util.ArrayList<>());
|
||||||
|
|
||||||
|
HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
|
||||||
|
Map<String, Object> details = healthService.detailedHealth();
|
||||||
|
|
||||||
|
Assertions.assertEquals("ok", details.get("status"));
|
||||||
|
Assertions.assertEquals(true, details.get("outputDirWritable"));
|
||||||
|
Assertions.assertEquals(true, details.get("apiKeyConfigured"));
|
||||||
|
Assertions.assertEquals(0, details.get("taskTotal"));
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/test/java/com/svnlog/web/service/RetrySupportTest.java
Normal file
39
src/test/java/com/svnlog/web/service/RetrySupportTest.java
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
public class RetrySupportTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRetryAndSucceedWhenExceptionIsRetryable() throws Exception {
|
||||||
|
RetrySupport retrySupport = new RetrySupport();
|
||||||
|
AtomicInteger attempts = new AtomicInteger(0);
|
||||||
|
|
||||||
|
String result = retrySupport.execute(() -> {
|
||||||
|
if (attempts.incrementAndGet() < 3) {
|
||||||
|
throw new IOException("temporary error");
|
||||||
|
}
|
||||||
|
return "ok";
|
||||||
|
}, 3, 1L);
|
||||||
|
|
||||||
|
Assertions.assertEquals("ok", result);
|
||||||
|
Assertions.assertEquals(3, attempts.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldFailImmediatelyWhenExceptionIsNotRetryable() {
|
||||||
|
RetrySupport retrySupport = new RetrySupport();
|
||||||
|
AtomicInteger attempts = new AtomicInteger(0);
|
||||||
|
|
||||||
|
Assertions.assertThrows(IllegalArgumentException.class, () -> retrySupport.execute(() -> {
|
||||||
|
attempts.incrementAndGet();
|
||||||
|
throw new IllegalArgumentException("bad request");
|
||||||
|
}, 3, 1L));
|
||||||
|
|
||||||
|
Assertions.assertEquals(1, attempts.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import com.svnlog.web.model.TaskInfo;
|
||||||
|
import com.svnlog.web.model.TaskStatus;
|
||||||
|
|
||||||
|
public class TaskPersistenceServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldSaveAndLoadTaskHistoryWhenStoreFileExists() throws Exception {
|
||||||
|
TaskPersistenceService service = new TaskPersistenceService();
|
||||||
|
Path tempDir = Files.createTempDirectory("task-persistence-test");
|
||||||
|
Path storePath = tempDir.resolve("task-history.json");
|
||||||
|
|
||||||
|
TaskInfo task = new TaskInfo();
|
||||||
|
task.setTaskId("task-1");
|
||||||
|
task.setType("SVN_FETCH");
|
||||||
|
task.setStatus(TaskStatus.SUCCESS);
|
||||||
|
task.setProgress(100);
|
||||||
|
task.setMessage("ok");
|
||||||
|
task.setError("");
|
||||||
|
task.setCreatedAt(Instant.parse("2026-03-01T10:00:00Z"));
|
||||||
|
task.setUpdatedAt(Instant.parse("2026-03-01T10:05:00Z"));
|
||||||
|
task.getFiles().add("md/a.md");
|
||||||
|
|
||||||
|
service.save(storePath, Arrays.asList(task));
|
||||||
|
|
||||||
|
List<TaskInfo> loaded = service.load(storePath);
|
||||||
|
Assertions.assertEquals(1, loaded.size());
|
||||||
|
Assertions.assertEquals("task-1", loaded.get(0).getTaskId());
|
||||||
|
Assertions.assertEquals(TaskStatus.SUCCESS, loaded.get(0).getStatus());
|
||||||
|
Assertions.assertEquals(1, loaded.get(0).getFiles().size());
|
||||||
|
Assertions.assertEquals("md/a.md", loaded.get(0).getFiles().get(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
import com.svnlog.web.model.TaskInfo;
|
||||||
|
import com.svnlog.web.model.TaskStatus;
|
||||||
|
|
||||||
|
public class TaskServiceCancelTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldCancelRunningTaskWhenCancelEndpointInvoked() throws Exception {
|
||||||
|
TaskPersistenceService persistenceService = Mockito.mock(TaskPersistenceService.class);
|
||||||
|
OutputFileService outputFileService = Mockito.mock(OutputFileService.class);
|
||||||
|
|
||||||
|
Path tempDir = Files.createTempDirectory("task-cancel-test");
|
||||||
|
Mockito.when(outputFileService.getOutputRoot()).thenReturn(tempDir);
|
||||||
|
Mockito.when(persistenceService.load(tempDir.resolve("task-history.json"))).thenReturn(new ArrayList<TaskInfo>());
|
||||||
|
|
||||||
|
TaskService taskService = new TaskService(persistenceService, outputFileService);
|
||||||
|
String taskId = taskService.submit("SVN_FETCH", context -> {
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
|
throw new InterruptedException("cancelled");
|
||||||
|
}
|
||||||
|
Thread.sleep(20);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
boolean cancelled = taskService.cancelTask(taskId);
|
||||||
|
Assertions.assertTrue(cancelled);
|
||||||
|
|
||||||
|
TaskInfo task = taskService.getTask(taskId);
|
||||||
|
Assertions.assertNotNull(task);
|
||||||
|
Assertions.assertEquals(TaskStatus.CANCELLED, task.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
import com.svnlog.web.model.TaskInfo;
|
||||||
|
import com.svnlog.web.model.TaskPageResult;
|
||||||
|
import com.svnlog.web.model.TaskStatus;
|
||||||
|
|
||||||
|
public class TaskServiceQueryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldFilterAndPaginateTasksWhenQuerying() throws Exception {
|
||||||
|
TaskPersistenceService persistenceService = Mockito.mock(TaskPersistenceService.class);
|
||||||
|
OutputFileService outputFileService = Mockito.mock(OutputFileService.class);
|
||||||
|
|
||||||
|
Path tempDir = Files.createTempDirectory("task-query-test");
|
||||||
|
Mockito.when(outputFileService.getOutputRoot()).thenReturn(tempDir);
|
||||||
|
|
||||||
|
TaskInfo t1 = buildTask("1", "SVN_FETCH", TaskStatus.SUCCESS, "抓取完成", Instant.parse("2026-03-01T10:00:00Z"));
|
||||||
|
TaskInfo t2 = buildTask("2", "AI_ANALYZE", TaskStatus.FAILED, "AI失败", Instant.parse("2026-03-01T10:10:00Z"));
|
||||||
|
TaskInfo t3 = buildTask("3", "SVN_FETCH", TaskStatus.SUCCESS, "导出成功", Instant.parse("2026-03-01T10:20:00Z"));
|
||||||
|
|
||||||
|
Mockito.when(persistenceService.load(tempDir.resolve("task-history.json")))
|
||||||
|
.thenReturn(Arrays.asList(t1, t2, t3));
|
||||||
|
|
||||||
|
TaskService taskService = new TaskService(persistenceService, outputFileService);
|
||||||
|
|
||||||
|
TaskPageResult page1 = taskService.queryTasks("SUCCESS", "SVN_FETCH", "", 1, 1);
|
||||||
|
Assertions.assertEquals(2, page1.getTotal());
|
||||||
|
Assertions.assertEquals(1, page1.getItems().size());
|
||||||
|
Assertions.assertEquals("3", page1.getItems().get(0).getTaskId());
|
||||||
|
|
||||||
|
TaskPageResult page2 = taskService.queryTasks("SUCCESS", "SVN_FETCH", "", 2, 1);
|
||||||
|
Assertions.assertEquals(1, page2.getItems().size());
|
||||||
|
Assertions.assertEquals("1", page2.getItems().get(0).getTaskId());
|
||||||
|
|
||||||
|
TaskPageResult keyword = taskService.queryTasks("", "", "ai", 1, 10);
|
||||||
|
Assertions.assertEquals(1, keyword.getTotal());
|
||||||
|
Assertions.assertEquals("2", keyword.getItems().get(0).getTaskId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskInfo buildTask(String id, String type, TaskStatus status, String message, Instant createdAt) {
|
||||||
|
TaskInfo task = new TaskInfo();
|
||||||
|
task.setTaskId(id);
|
||||||
|
task.setType(type);
|
||||||
|
task.setStatus(status);
|
||||||
|
task.setMessage(message);
|
||||||
|
task.setProgress(status == TaskStatus.SUCCESS ? 100 : 0);
|
||||||
|
task.setCreatedAt(createdAt);
|
||||||
|
task.setUpdatedAt(createdAt);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user