diff --git a/docs/README_Web.md b/docs/README_Web.md index 74bd284..73eea84 100644 --- a/docs/README_Web.md +++ b/docs/README_Web.md @@ -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 diff --git a/pom.xml b/pom.xml index b536006..bccdada 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,13 @@ spring-boot-starter-validation ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-test + ${spring-boot.version} + test + diff --git a/src/main/java/com/svnlog/web/controller/AppController.java b/src/main/java/com/svnlog/web/controller/AppController.java index 11db98d..0ab32b5 100644 --- a/src/main/java/com/svnlog/web/controller/AppController.java +++ b/src/main/java/com/svnlog/web/controller/AppController.java @@ -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 health() { - final Map response = new HashMap(); - response.put("status", "ok"); - response.put("timestamp", Instant.now().toString()); - return response; + return healthService.basicHealth(); + } + + @GetMapping("/health/details") + public Map 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 cancelTask(@PathVariable("taskId") String taskId) { + final boolean cancelled = taskService.cancelTask(taskId); + final Map response = new HashMap(); + response.put("taskId", taskId); + response.put("cancelled", cancelled); + if (!cancelled) { + response.put("message", "任务已结束或不存在,无法取消"); + } else { + response.put("message", "任务取消成功"); + } + return response; + } + @GetMapping("/files") public Map listFiles() throws IOException { final Map response = new HashMap(); diff --git a/src/main/java/com/svnlog/web/model/TaskPageResult.java b/src/main/java/com/svnlog/web/model/TaskPageResult.java new file mode 100644 index 0000000..666fc3b --- /dev/null +++ b/src/main/java/com/svnlog/web/model/TaskPageResult.java @@ -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 items = new ArrayList(); + + 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 getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } +} diff --git a/src/main/java/com/svnlog/web/model/TaskStatus.java b/src/main/java/com/svnlog/web/model/TaskStatus.java index c470c2b..54b62fc 100644 --- a/src/main/java/com/svnlog/web/model/TaskStatus.java +++ b/src/main/java/com/svnlog/web/model/TaskStatus.java @@ -4,5 +4,6 @@ public enum TaskStatus { PENDING, RUNNING, SUCCESS, - FAILED + FAILED, + CANCELLED } diff --git a/src/main/java/com/svnlog/web/service/AiInputValidator.java b/src/main/java/com/svnlog/web/service/AiInputValidator.java new file mode 100644 index 0000000..082b3bd --- /dev/null +++ b/src/main/java/com/svnlog/web/service/AiInputValidator.java @@ -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 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()); + } + } + } +} diff --git a/src/main/java/com/svnlog/web/service/AiWorkflowService.java b/src/main/java/com/svnlog/web/service/AiWorkflowService.java index e7e9b6d..3d12c58 100644 --- a/src/main/java/com/svnlog/web/service/AiWorkflowService.java +++ b/src/main/java/com/svnlog/web/service/AiWorkflowService.java @@ -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 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 filePaths) throws IOException { + private String readMarkdownFiles(List 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 resolveUserFiles(List userPaths) throws IOException { + java.util.ArrayList files = new java.util.ArrayList(); + 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(); diff --git a/src/main/java/com/svnlog/web/service/HealthService.java b/src/main/java/com/svnlog/web/service/HealthService.java new file mode 100644 index 0000000..e3dd6db --- /dev/null +++ b/src/main/java/com/svnlog/web/service/HealthService.java @@ -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 basicHealth() { + final Map response = new HashMap(); + response.put("status", "ok"); + response.put("timestamp", Instant.now().toString()); + return response; + } + + public Map detailedHealth() throws IOException { + final Map result = new HashMap(); + final Map 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; + } + } +} diff --git a/src/main/java/com/svnlog/web/service/RetrySupport.java b/src/main/java/com/svnlog/web/service/RetrySupport.java new file mode 100644 index 0000000..267f663 --- /dev/null +++ b/src/main/java/com/svnlog/web/service/RetrySupport.java @@ -0,0 +1,53 @@ +package com.svnlog.web.service; + +public class RetrySupport { + + @FunctionalInterface + public interface RetryableSupplier { + T get() throws Exception; + } + + public T execute(RetryableSupplier 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); + } + } +} diff --git a/src/main/java/com/svnlog/web/service/TaskContext.java b/src/main/java/com/svnlog/web/service/TaskContext.java index 296be4b..8e17257 100644 --- a/src/main/java/com/svnlog/web/service/TaskContext.java +++ b/src/main/java/com/svnlog/web/service/TaskContext.java @@ -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(); + } } } diff --git a/src/main/java/com/svnlog/web/service/TaskPersistenceService.java b/src/main/java/com/svnlog/web/service/TaskPersistenceService.java new file mode 100644 index 0000000..4ebcd93 --- /dev/null +++ b/src/main/java/com/svnlog/web/service/TaskPersistenceService.java @@ -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 load(Path storePath) throws IOException { + if (storePath == null || !Files.exists(storePath) || !Files.isRegularFile(storePath)) { + return new ArrayList(); + } + + try (Reader reader = Files.newBufferedReader(storePath, StandardCharsets.UTF_8)) { + List persisted = gson.fromJson( + reader, + new TypeToken>() { + }.getType() + ); + if (persisted == null) { + return new ArrayList(); + } + + List result = new ArrayList(); + for (PersistedTaskInfo item : persisted) { + result.add(toTaskInfo(item)); + } + return result; + } + } + + public void save(Path storePath, Collection tasks) throws IOException { + if (storePath == null) { + return; + } + + if (storePath.getParent() != null) { + Files.createDirectories(storePath.getParent()); + } + + List persisted = new ArrayList(); + 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(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 files; + } +} diff --git a/src/main/java/com/svnlog/web/service/TaskService.java b/src/main/java/com/svnlog/web/service/TaskService.java index 9e1a3f1..cc29031 100644 --- a/src/main/java/com/svnlog/web/service/TaskService.java +++ b/src/main/java/com/svnlog/web/service/TaskService.java @@ -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 tasks = new ConcurrentHashMap(); + private final Map> futures = new ConcurrentHashMap>(); + 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() { + Future future = executor.submit(new Callable() { @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 getTasks() { - return new ArrayList(tasks.values()); + return new ArrayList(tasks.values()).stream() + .sorted(Comparator.comparing(TaskInfo::getCreatedAt).reversed()) + .collect(Collectors.toList()); } - private void runTask(TaskInfo taskInfo, TaskRunner runner) { - taskInfo.setStatus(TaskStatus.RUNNING); - taskInfo.setMessage("任务执行中"); - taskInfo.setUpdatedAt(Instant.now()); + 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 TaskContext context = new TaskContext(taskInfo); + final List 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(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; + } + + taskInfo.setStatus(TaskStatus.RUNNING); + taskInfo.setMessage("任务执行中"); + taskInfo.setUpdatedAt(Instant.now()); + persistSafely(); + + 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 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 diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index cbd86e3..dfb1635 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -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 = "

暂无任务记录

"; + renderTaskPager(); return; } - const rows = state.tasks.map((task) => { + const rows = state.taskPage.items.map((task) => { const files = (task.files || []).map((f) => `${escapeHtml(f)}`).join("
"); + const canCancel = task.status === "RUNNING" || task.status === "PENDING"; return ` ${escapeHtml(task.taskId.slice(0, 8))} ${escapeHtml(task.type)} @@ -342,13 +363,106 @@ function renderTaskTable() { ${task.progress || 0}% ${escapeHtml(task.message || "")}${task.error ? `
${escapeHtml(task.error)}` : ""} ${files || "-"} + ${canCancel ? `` : "-"} `; }).join(""); container.innerHTML = ` - + ${rows}
任务ID类型状态进度说明产物
任务ID类型状态进度说明产物操作
`; + + 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 = ` + 共 ${state.taskPage.total || 0} 条,第 ${current}/${totalPages} 页 +
+ + +
+ `; + 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() { diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index a80f3f5..f031a42 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -26,7 +26,7 @@
-
+

任务总数

0

@@ -39,8 +39,17 @@

失败任务

0

+
+

系统状态

+

-

+
+
+

健康检查

+

加载中...

+
+

最近任务

@@ -94,7 +103,25 @@

任务列表

+
+ + + + +
+

输出文件

diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css index cc646d8..17bf889 100644 --- a/src/main/resources/static/styles.css +++ b/src/main/resources/static/styles.css @@ -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; } diff --git a/src/test/java/com/svnlog/web/service/AiInputValidatorTest.java b/src/test/java/com/svnlog/web/service/AiInputValidatorTest.java new file mode 100644 index 0000000..4448373 --- /dev/null +++ b/src/test/java/com/svnlog/web/service/AiInputValidatorTest.java @@ -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.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); + } +} diff --git a/src/test/java/com/svnlog/web/service/HealthServiceTest.java b/src/test/java/com/svnlog/web/service/HealthServiceTest.java new file mode 100644 index 0000000..f7e7f25 --- /dev/null +++ b/src/test/java/com/svnlog/web/service/HealthServiceTest.java @@ -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 settings = new HashMap(); + 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 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")); + } +} diff --git a/src/test/java/com/svnlog/web/service/RetrySupportTest.java b/src/test/java/com/svnlog/web/service/RetrySupportTest.java new file mode 100644 index 0000000..f40bb6f --- /dev/null +++ b/src/test/java/com/svnlog/web/service/RetrySupportTest.java @@ -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()); + } +} diff --git a/src/test/java/com/svnlog/web/service/TaskPersistenceServiceTest.java b/src/test/java/com/svnlog/web/service/TaskPersistenceServiceTest.java new file mode 100644 index 0000000..8e7c13e --- /dev/null +++ b/src/test/java/com/svnlog/web/service/TaskPersistenceServiceTest.java @@ -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 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)); + } +} diff --git a/src/test/java/com/svnlog/web/service/TaskServiceCancelTest.java b/src/test/java/com/svnlog/web/service/TaskServiceCancelTest.java new file mode 100644 index 0000000..385d2c9 --- /dev/null +++ b/src/test/java/com/svnlog/web/service/TaskServiceCancelTest.java @@ -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()); + + 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()); + } +} diff --git a/src/test/java/com/svnlog/web/service/TaskServiceQueryTest.java b/src/test/java/com/svnlog/web/service/TaskServiceQueryTest.java new file mode 100644 index 0000000..5d4102a --- /dev/null +++ b/src/test/java/com/svnlog/web/service/TaskServiceQueryTest.java @@ -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; + } +}