feat(web): 增强任务治理与系统诊断能力

新增任务持久化、筛选分页、取消任务、健康检查与 AI 输入校验,并完善前端历史管理交互与容错重试机制。补充对应单元测试,提升系统稳定性和可运维性。
This commit is contained in:
2026-03-08 23:35:36 +08:00
parent e26fb9cebb
commit bdf6367404
21 changed files with 1049 additions and 34 deletions

View File

@@ -7,8 +7,9 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API支持
1. SVN 参数录入与连接测试
2. 异步抓取日志并导出 Markdown
3. 使用 DeepSeek 分析 Markdown 并生成 Excel
4. 查看任务历史(状态、进度、错误、产物)
4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
5. 下载输出文件、配置 API Key 与输出目录
6. 工作台展示系统健康状态输出目录可写性、API Key 配置、任务统计)
## 启动方式
@@ -27,11 +28,9 @@ http://localhost:8080
## 页面说明
- 工作台:最近任务统计与最近产物
- SVN 日志抓取SVN 地址、账号密码、版本区间、过滤用户
- SVN 日志抓取支持预置项目下拉3 个默认项目)与自定义地址
- SVN 日志抓取SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址)
- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
- 任务历史:异步任务状态与产物列表
- 系统设置DeepSeek API Key、输出目录
- 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务
- 系统设置DeepSeek API Key、输出目录、默认 SVN 预置项目
## 输出目录
@@ -39,6 +38,7 @@ http://localhost:8080
- 默认输出目录:`outputs/`
- Markdown 输出:`outputs/md/*.md`
- Excel 输出:`outputs/excel/*.xlsx`
- 任务持久化:`outputs/task-history.json`(重启后可恢复历史)
## API Key 读取优先级
@@ -55,7 +55,11 @@ http://localhost:8080
- `GET /api/svn/presets`
- `POST /api/ai/analyze`
- `GET /api/tasks`
- `GET /api/tasks/query?status=&type=&keyword=&page=1&size=10`
- `GET /api/tasks/{taskId}`
- `POST /api/tasks/{taskId}/cancel`
- `GET /api/health`
- `GET /api/health/details`
- `GET /api/files`
- `GET /api/files/download?path=...`
- `GET /api/settings`
@@ -75,3 +79,11 @@ mvn clean compile
2. 在「SVN 日志抓取」创建任务并生成 `.md`
3. 在「AI 工作量分析」选择 `.md` 并生成 `.xlsx`
4. 在「任务历史」中下载产物并核验内容
## AI 输入校验
为避免误操作和资源滥用AI 分析接口增加输入约束:
- 一次最多分析 20 个文件
- 仅允许 `.md` 文件
- 单文件大小不超过 2MB

View File

@@ -66,6 +66,13 @@
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -29,7 +29,9 @@ import com.svnlog.web.dto.SvnConnectionRequest;
import com.svnlog.web.dto.SvnFetchRequest;
import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskPageResult;
import com.svnlog.web.service.AiWorkflowService;
import com.svnlog.web.service.HealthService;
import com.svnlog.web.service.OutputFileService;
import com.svnlog.web.service.SettingsService;
import com.svnlog.web.service.SvnPresetService;
@@ -46,27 +48,32 @@ public class AppController {
private final OutputFileService outputFileService;
private final SettingsService settingsService;
private final SvnPresetService svnPresetService;
private final HealthService healthService;
public AppController(SvnWorkflowService svnWorkflowService,
AiWorkflowService aiWorkflowService,
TaskService taskService,
OutputFileService outputFileService,
SettingsService settingsService,
SvnPresetService svnPresetService) {
SvnPresetService svnPresetService,
HealthService healthService) {
this.svnWorkflowService = svnWorkflowService;
this.aiWorkflowService = aiWorkflowService;
this.taskService = taskService;
this.outputFileService = outputFileService;
this.settingsService = settingsService;
this.svnPresetService = svnPresetService;
this.healthService = healthService;
}
@GetMapping("/health")
public Map<String, Object> health() {
final Map<String, Object> response = new HashMap<String, Object>();
response.put("status", "ok");
response.put("timestamp", Instant.now().toString());
return response;
return healthService.basicHealth();
}
@GetMapping("/health/details")
public Map<String, Object> healthDetails() throws IOException {
return healthService.detailedHealth();
}
@PostMapping("/svn/test-connection")
@@ -108,6 +115,17 @@ public class AppController {
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}")
public TaskInfo getTask(@PathVariable("taskId") String taskId) {
final TaskInfo task = taskService.getTask(taskId);
@@ -117,6 +135,20 @@ public class AppController {
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")
public Map<String, Object> listFiles() throws IOException {
final Map<String, Object> response = new HashMap<String, Object>();

View 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;
}
}

View File

@@ -4,5 +4,6 @@ public enum TaskStatus {
PENDING,
RUNNING,
SUCCESS,
FAILED
FAILED,
CANCELLED
}

View 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());
}
}
}
}

View File

@@ -51,15 +51,22 @@ public class AiWorkflowService {
private final OutputFileService outputFileService;
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.settingsService = settingsService;
this.aiInputValidator = aiInputValidator;
}
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
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 分析");
final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
@@ -88,10 +95,9 @@ public class AiWorkflowService {
return result;
}
private String readMarkdownFiles(List<String> filePaths) throws IOException {
private String readMarkdownFiles(List<Path> filePaths) throws IOException {
final StringBuilder builder = new StringBuilder();
for (String filePath : filePaths) {
final Path path = resolveUserFile(filePath);
for (Path path : filePaths) {
final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
builder.append("\n\n=== 文件: ").append(path.getFileName().toString()).append(" ===\n");
builder.append(content);
@@ -99,6 +105,17 @@ public class AiWorkflowService {
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 {
if (userPath == null || userPath.trim().isEmpty()) {
throw new IllegalArgumentException("文件路径不能为空");
@@ -135,6 +152,16 @@ public class AiWorkflowService {
}
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();
message.addProperty("role", "user");
message.addProperty("content", prompt);
@@ -161,10 +188,14 @@ public class AiWorkflowService {
if (response.body() != null) {
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) {
throw new IllegalStateException("DeepSeek API 返回空响应体");
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
}
final String raw = response.body().string();

View 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;
}
}
}

View 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);
}
}
}

View File

@@ -5,9 +5,11 @@ import com.svnlog.web.model.TaskInfo;
public class TaskContext {
private final TaskInfo taskInfo;
private final Runnable onUpdate;
public TaskContext(TaskInfo taskInfo) {
public TaskContext(TaskInfo taskInfo, Runnable onUpdate) {
this.taskInfo = taskInfo;
this.onUpdate = onUpdate;
}
public void setProgress(int progress, String message) {
@@ -15,5 +17,8 @@ public class TaskContext {
taskInfo.setProgress(bounded);
taskInfo.setMessage(message);
taskInfo.setUpdatedAt(java.time.Instant.now());
if (onUpdate != null) {
onUpdate.run();
}
}
}

View 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;
}
}

View File

@@ -2,19 +2,24 @@ package com.svnlog.web.service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import javax.annotation.PreDestroy;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskPageResult;
import com.svnlog.web.model.TaskResult;
import com.svnlog.web.model.TaskStatus;
@@ -27,6 +32,15 @@ public class TaskService {
private final ExecutorService executor = Executors.newFixedThreadPool(4);
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) {
final String taskId = UUID.randomUUID().toString();
@@ -41,14 +55,16 @@ public class TaskService {
taskInfo.setCreatedAt(now);
taskInfo.setUpdatedAt(now);
tasks.put(taskId, taskInfo);
persistSafely();
executor.submit(new Callable<Void>() {
Future<?> future = executor.submit(new Callable<Void>() {
@Override
public Void call() {
runTask(taskInfo, runner);
runTaskInternal(taskInfo, runner);
return null;
}
});
futures.put(taskId, future);
return taskId;
}
@@ -58,16 +74,70 @@ public class TaskService {
}
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.setMessage("任务执行中");
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
final TaskContext context = new TaskContext(taskInfo);
try {
final TaskContext context = new TaskContext(taskInfo, this::persistSafely);
final TaskResult result = runner.run(context);
taskInfo.setStatus(TaskStatus.SUCCESS);
taskInfo.setProgress(100);
@@ -77,11 +147,83 @@ public class TaskService {
taskInfo.getFiles().addAll(result.getFiles());
}
} catch (Exception e) {
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
return;
}
taskInfo.setStatus(TaskStatus.FAILED);
taskInfo.setError(e.getMessage());
taskInfo.setMessage("执行失败");
}
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

View File

@@ -1,6 +1,9 @@
const state = {
tasks: [],
taskPage: { items: [], page: 1, size: 10, total: 0 },
taskQuery: { status: "", type: "", keyword: "", page: 1, size: 10 },
files: [],
health: null,
presets: [],
defaultPresetId: "",
activeView: "dashboard",
@@ -51,6 +54,11 @@ function bindForms() {
const svnPresetSelect = document.querySelector("#svn-preset-select");
svnPresetSelect.addEventListener("change", onSvnPresetChange);
const taskFilterBtn = document.querySelector("#btn-task-filter");
if (taskFilterBtn) {
taskFilterBtn.addEventListener("click", onTaskFilterSubmit);
}
}
function switchView(view) {
@@ -65,7 +73,7 @@ function switchView(view) {
document.querySelector("#view-desc").textContent = viewMeta[view].desc;
if (view === "history") {
renderTaskTable();
loadTaskPage();
renderFileTable();
}
if (view === "ai") {
@@ -88,15 +96,17 @@ async function apiFetch(url, options = {}) {
async function refreshAll() {
try {
const [tasksResp, filesResp] = await Promise.all([
const [tasksResp, filesResp, healthResp] = await Promise.all([
apiFetch("/api/tasks"),
apiFetch("/api/files"),
apiFetch("/api/health/details"),
]);
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.health = healthResp || null;
renderDashboard();
if (state.activeView === "history") {
renderTaskTable();
loadTaskPage();
renderFileTable();
}
if (state.activeView === "ai") {
@@ -180,10 +190,19 @@ function renderDashboard() {
const total = state.tasks.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 health = state.health;
document.querySelector("#stat-total").textContent = `${total}`;
document.querySelector("#stat-running").textContent = `${running}`;
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");
taskList.innerHTML = "";
@@ -328,13 +347,15 @@ async function onRunAi(event) {
function renderTaskTable() {
const container = document.querySelector("#task-table");
if (!state.tasks.length) {
if (!state.taskPage.items.length) {
container.innerHTML = "<p class='muted'>暂无任务记录</p>";
renderTaskPager();
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 canCancel = task.status === "RUNNING" || task.status === "PENDING";
return `<tr>
<td>${escapeHtml(task.taskId.slice(0, 8))}</td>
<td>${escapeHtml(task.type)}</td>
@@ -342,13 +363,106 @@ function renderTaskTable() {
<td>${task.progress || 0}%</td>
<td>${escapeHtml(task.message || "")}${task.error ? `<br><span class='muted'>${escapeHtml(task.error)}</span>` : ""}</td>
<td>${files || "-"}</td>
<td>${canCancel ? `<button type="button" class="btn-cancel-task" data-task-id="${escapeHtml(task.taskId)}">取消</button>` : "-"}</td>
</tr>`;
}).join("");
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>
</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() {

View File

@@ -26,7 +26,7 @@
</header>
<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">
<h3>任务总数</h3>
<p id="stat-total">0</p>
@@ -39,8 +39,17 @@
<h3>失败任务</h3>
<p id="stat-failed">0</p>
</article>
<article class="card stat">
<h3>系统状态</h3>
<p id="stat-health">-</p>
</article>
</div>
<article class="card" id="health-card">
<h3>健康检查</h3>
<p class="muted" id="health-details">加载中...</p>
</article>
<div class="grid cols-2">
<article class="card">
<h3>最近任务</h3>
@@ -94,7 +103,25 @@
<section class="view" id="view-history">
<article class="card">
<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 class="pager" id="task-pager"></div>
</article>
<article class="card">
<h3>输出文件</h3>

View File

@@ -115,6 +115,11 @@ body {
margin-bottom: 16px;
}
.grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 16px;
}
.grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -276,6 +281,27 @@ button:disabled {
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 {
width: 100%;
border-collapse: collapse;
@@ -315,6 +341,11 @@ td {
color: var(--danger);
}
.tag.CANCELLED {
background: #e4e7ec;
color: #344054;
}
.muted {
color: var(--muted);
}
@@ -353,11 +384,16 @@ td {
}
.grid.cols-3,
.grid.cols-4,
.grid.cols-2,
.form-grid {
grid-template-columns: 1fr;
}
.history-toolbar {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: span 1;
}

View File

@@ -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);
}
}

View 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"));
}
}

View 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());
}
}

View File

@@ -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));
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}
}