feat(web): 增强任务治理与系统诊断能力
新增任务持久化、筛选分页、取消任务、健康检查与 AI 输入校验,并完善前端历史管理交互与容错重试机制。补充对应单元测试,提升系统稳定性和可运维性。
This commit is contained in:
@@ -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>();
|
||||
|
||||
44
src/main/java/com/svnlog/web/model/TaskPageResult.java
Normal file
44
src/main/java/com/svnlog/web/model/TaskPageResult.java
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.svnlog.web.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class TaskPageResult {
|
||||
|
||||
private int page;
|
||||
private int size;
|
||||
private long total;
|
||||
private List<TaskInfo> items = new ArrayList<TaskInfo>();
|
||||
|
||||
public int getPage() {
|
||||
return page;
|
||||
}
|
||||
|
||||
public void setPage(int page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
public int getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(int size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public long getTotal() {
|
||||
return total;
|
||||
}
|
||||
|
||||
public void setTotal(long total) {
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
public List<TaskInfo> getItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
public void setItems(List<TaskInfo> items) {
|
||||
this.items = items;
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,6 @@ public enum TaskStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
SUCCESS,
|
||||
FAILED
|
||||
FAILED,
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
35
src/main/java/com/svnlog/web/service/AiInputValidator.java
Normal file
35
src/main/java/com/svnlog/web/service/AiInputValidator.java
Normal file
@@ -0,0 +1,35 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AiInputValidator {
|
||||
|
||||
private static final int MAX_FILES = 20;
|
||||
private static final long MAX_FILE_SIZE = 2L * 1024L * 1024L;
|
||||
|
||||
public void validate(List<Path> markdownPaths) throws IOException {
|
||||
if (markdownPaths == null || markdownPaths.isEmpty()) {
|
||||
throw new IllegalArgumentException("至少需要选择 1 个 Markdown 文件");
|
||||
}
|
||||
if (markdownPaths.size() > MAX_FILES) {
|
||||
throw new IllegalArgumentException("一次最多分析 " + MAX_FILES + " 个文件");
|
||||
}
|
||||
|
||||
for (Path path : markdownPaths) {
|
||||
final String fileName = path.getFileName() == null ? "" : path.getFileName().toString().toLowerCase();
|
||||
if (!fileName.endsWith(".md")) {
|
||||
throw new IllegalArgumentException("仅支持 .md 文件: " + path.toString());
|
||||
}
|
||||
final long fileSize = Files.size(path);
|
||||
if (fileSize > MAX_FILE_SIZE) {
|
||||
throw new IllegalArgumentException("文件过大(>2MB): " + path.getFileName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,15 +51,22 @@ public class AiWorkflowService {
|
||||
|
||||
private final OutputFileService outputFileService;
|
||||
private final 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();
|
||||
|
||||
81
src/main/java/com/svnlog/web/service/HealthService.java
Normal file
81
src/main/java/com/svnlog/web/service/HealthService.java
Normal file
@@ -0,0 +1,81 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
import com.svnlog.web.model.TaskStatus;
|
||||
|
||||
@Service
|
||||
public class HealthService {
|
||||
|
||||
private final OutputFileService outputFileService;
|
||||
private final SettingsService settingsService;
|
||||
private final TaskService taskService;
|
||||
|
||||
public HealthService(OutputFileService outputFileService,
|
||||
SettingsService settingsService,
|
||||
TaskService taskService) {
|
||||
this.outputFileService = outputFileService;
|
||||
this.settingsService = settingsService;
|
||||
this.taskService = taskService;
|
||||
}
|
||||
|
||||
public Map<String, Object> basicHealth() {
|
||||
final Map<String, Object> response = new HashMap<String, Object>();
|
||||
response.put("status", "ok");
|
||||
response.put("timestamp", Instant.now().toString());
|
||||
return response;
|
||||
}
|
||||
|
||||
public Map<String, Object> detailedHealth() throws IOException {
|
||||
final Map<String, Object> result = new HashMap<String, Object>();
|
||||
final Map<String, Object> settings = settingsService.getSettings();
|
||||
final Path outputRoot = outputFileService.getOutputRoot();
|
||||
|
||||
final boolean outputDirWritable = ensureWritable(outputRoot);
|
||||
int running = 0;
|
||||
int failed = 0;
|
||||
int cancelled = 0;
|
||||
for (TaskInfo task : taskService.getTasks()) {
|
||||
if (task.getStatus() == TaskStatus.RUNNING || task.getStatus() == TaskStatus.PENDING) {
|
||||
running++;
|
||||
}
|
||||
if (task.getStatus() == TaskStatus.FAILED) {
|
||||
failed++;
|
||||
}
|
||||
if (task.getStatus() == TaskStatus.CANCELLED) {
|
||||
cancelled++;
|
||||
}
|
||||
}
|
||||
|
||||
result.put("status", "ok");
|
||||
result.put("timestamp", Instant.now().toString());
|
||||
result.put("outputDir", outputRoot.toString());
|
||||
result.put("outputDirWritable", outputDirWritable);
|
||||
result.put("apiKeyConfigured", Boolean.TRUE.equals(settings.get("apiKeyConfigured")));
|
||||
result.put("taskTotal", taskService.getTasks().size());
|
||||
result.put("taskRunning", running);
|
||||
result.put("taskFailed", failed);
|
||||
result.put("taskCancelled", cancelled);
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean ensureWritable(Path outputRoot) {
|
||||
try {
|
||||
Files.createDirectories(outputRoot);
|
||||
final Path probe = outputRoot.resolve(".health-probe");
|
||||
Files.write(probe, "ok".getBytes("UTF-8"));
|
||||
Files.deleteIfExists(probe);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/main/java/com/svnlog/web/service/RetrySupport.java
Normal file
53
src/main/java/com/svnlog/web/service/RetrySupport.java
Normal file
@@ -0,0 +1,53 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
public class RetrySupport {
|
||||
|
||||
@FunctionalInterface
|
||||
public interface RetryableSupplier<T> {
|
||||
T get() throws Exception;
|
||||
}
|
||||
|
||||
public <T> T execute(RetryableSupplier<T> supplier, int maxAttempts, long initialDelayMillis) throws Exception {
|
||||
if (maxAttempts <= 0) {
|
||||
throw new IllegalArgumentException("maxAttempts 必须大于 0");
|
||||
}
|
||||
|
||||
Exception lastException = null;
|
||||
long delay = Math.max(0L, initialDelayMillis);
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return supplier.get();
|
||||
} catch (Exception ex) {
|
||||
lastException = ex;
|
||||
if (attempt == maxAttempts || !isRetryable(ex)) {
|
||||
throw ex;
|
||||
}
|
||||
|
||||
if (delay > 0L) {
|
||||
try {
|
||||
Thread.sleep(delay);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw ex;
|
||||
}
|
||||
delay = delay * 2L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException;
|
||||
}
|
||||
|
||||
private boolean isRetryable(Exception ex) {
|
||||
if (ex instanceof RetryableException) {
|
||||
return true;
|
||||
}
|
||||
return ex instanceof java.io.IOException;
|
||||
}
|
||||
|
||||
public static class RetryableException extends Exception {
|
||||
public RetryableException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import com.svnlog.web.model.TaskInfo;
|
||||
public class TaskContext {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
138
src/main/java/com/svnlog/web/service/TaskPersistenceService.java
Normal file
138
src/main/java/com/svnlog/web/service/TaskPersistenceService.java
Normal file
@@ -0,0 +1,138 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
import com.svnlog.web.model.TaskStatus;
|
||||
|
||||
@Service
|
||||
public class TaskPersistenceService {
|
||||
|
||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
public List<TaskInfo> load(Path storePath) throws IOException {
|
||||
if (storePath == null || !Files.exists(storePath) || !Files.isRegularFile(storePath)) {
|
||||
return new ArrayList<TaskInfo>();
|
||||
}
|
||||
|
||||
try (Reader reader = Files.newBufferedReader(storePath, StandardCharsets.UTF_8)) {
|
||||
List<PersistedTaskInfo> persisted = gson.fromJson(
|
||||
reader,
|
||||
new TypeToken<List<PersistedTaskInfo>>() {
|
||||
}.getType()
|
||||
);
|
||||
if (persisted == null) {
|
||||
return new ArrayList<TaskInfo>();
|
||||
}
|
||||
|
||||
List<TaskInfo> result = new ArrayList<TaskInfo>();
|
||||
for (PersistedTaskInfo item : persisted) {
|
||||
result.add(toTaskInfo(item));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public void save(Path storePath, Collection<TaskInfo> tasks) throws IOException {
|
||||
if (storePath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (storePath.getParent() != null) {
|
||||
Files.createDirectories(storePath.getParent());
|
||||
}
|
||||
|
||||
List<PersistedTaskInfo> persisted = new ArrayList<PersistedTaskInfo>();
|
||||
if (tasks != null) {
|
||||
for (TaskInfo task : tasks) {
|
||||
persisted.add(fromTaskInfo(task));
|
||||
}
|
||||
}
|
||||
|
||||
try (Writer writer = Files.newBufferedWriter(storePath, StandardCharsets.UTF_8)) {
|
||||
gson.toJson(persisted, writer);
|
||||
}
|
||||
}
|
||||
|
||||
private PersistedTaskInfo fromTaskInfo(TaskInfo task) {
|
||||
PersistedTaskInfo info = new PersistedTaskInfo();
|
||||
info.taskId = task.getTaskId();
|
||||
info.type = task.getType();
|
||||
info.status = task.getStatus() == null ? null : task.getStatus().name();
|
||||
info.progress = task.getProgress();
|
||||
info.message = task.getMessage();
|
||||
info.error = task.getError();
|
||||
info.createdAt = toString(task.getCreatedAt());
|
||||
info.updatedAt = toString(task.getUpdatedAt());
|
||||
info.files = new ArrayList<String>(task.getFiles());
|
||||
return info;
|
||||
}
|
||||
|
||||
private TaskInfo toTaskInfo(PersistedTaskInfo persisted) {
|
||||
TaskInfo task = new TaskInfo();
|
||||
task.setTaskId(persisted.taskId);
|
||||
task.setType(persisted.type);
|
||||
task.setStatus(parseStatus(persisted.status));
|
||||
task.setProgress(persisted.progress);
|
||||
task.setMessage(persisted.message);
|
||||
task.setError(persisted.error);
|
||||
task.setCreatedAt(parseInstant(persisted.createdAt));
|
||||
task.setUpdatedAt(parseInstant(persisted.updatedAt));
|
||||
if (persisted.files != null) {
|
||||
task.getFiles().addAll(persisted.files);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private Instant parseInstant(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return Instant.now();
|
||||
}
|
||||
try {
|
||||
return Instant.parse(value);
|
||||
} catch (Exception e) {
|
||||
return Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
private String toString(Instant value) {
|
||||
return value == null ? Instant.now().toString() : value.toString();
|
||||
}
|
||||
|
||||
private TaskStatus parseStatus(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return TaskStatus.FAILED;
|
||||
}
|
||||
try {
|
||||
return TaskStatus.valueOf(value);
|
||||
} catch (Exception e) {
|
||||
return TaskStatus.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
private static class PersistedTaskInfo {
|
||||
private String taskId;
|
||||
private String type;
|
||||
private String status;
|
||||
private int progress;
|
||||
private String message;
|
||||
private String error;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
private List<String> files;
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,24 @@ package com.svnlog.web.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.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());
|
||||
}
|
||||
|
||||
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<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;
|
||||
}
|
||||
|
||||
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<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
|
||||
|
||||
Reference in New Issue
Block a user