任务列表
+
+
+
+
+
+
+
输出文件
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;
+ }
+}