feat(web): 新增可视化工作台并支持预置SVN项目
新增 Spring Boot Web 后端与前端页面,打通 SVN 抓取、AI 分析、任务管理、文件下载与系统设置全流程。增加 3 个默认 SVN 预置项目下拉与默认项配置,提升日常使用效率与可维护性。
This commit is contained in:
12
src/main/java/com/svnlog/WebApplication.java
Normal file
12
src/main/java/com/svnlog/WebApplication.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.svnlog;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class WebApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(WebApplication.class, args);
|
||||
}
|
||||
}
|
||||
159
src/main/java/com/svnlog/web/controller/AppController.java
Normal file
159
src/main/java/com/svnlog/web/controller/AppController.java
Normal file
@@ -0,0 +1,159 @@
|
||||
package com.svnlog.web.controller;
|
||||
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.svnlog.web.dto.AiAnalyzeRequest;
|
||||
import com.svnlog.web.dto.SettingsUpdateRequest;
|
||||
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.service.AiWorkflowService;
|
||||
import com.svnlog.web.service.OutputFileService;
|
||||
import com.svnlog.web.service.SettingsService;
|
||||
import com.svnlog.web.service.SvnPresetService;
|
||||
import com.svnlog.web.service.SvnWorkflowService;
|
||||
import com.svnlog.web.service.TaskService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class AppController {
|
||||
|
||||
private final SvnWorkflowService svnWorkflowService;
|
||||
private final AiWorkflowService aiWorkflowService;
|
||||
private final TaskService taskService;
|
||||
private final OutputFileService outputFileService;
|
||||
private final SettingsService settingsService;
|
||||
private final SvnPresetService svnPresetService;
|
||||
|
||||
public AppController(SvnWorkflowService svnWorkflowService,
|
||||
AiWorkflowService aiWorkflowService,
|
||||
TaskService taskService,
|
||||
OutputFileService outputFileService,
|
||||
SettingsService settingsService,
|
||||
SvnPresetService svnPresetService) {
|
||||
this.svnWorkflowService = svnWorkflowService;
|
||||
this.aiWorkflowService = aiWorkflowService;
|
||||
this.taskService = taskService;
|
||||
this.outputFileService = outputFileService;
|
||||
this.settingsService = settingsService;
|
||||
this.svnPresetService = svnPresetService;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@PostMapping("/svn/test-connection")
|
||||
public Map<String, Object> testSvnConnection(@Valid @RequestBody SvnConnectionRequest request) throws Exception {
|
||||
svnWorkflowService.testConnection(request);
|
||||
final Map<String, Object> response = new HashMap<String, Object>();
|
||||
response.put("success", true);
|
||||
response.put("message", "SVN 连接成功");
|
||||
return response;
|
||||
}
|
||||
|
||||
@PostMapping("/svn/fetch")
|
||||
public Map<String, String> fetchSvnLogs(@Valid @RequestBody SvnFetchRequest request) {
|
||||
final String taskId = taskService.submit("SVN_FETCH", context -> svnWorkflowService.fetchToMarkdown(request, context));
|
||||
final Map<String, String> response = new HashMap<String, String>();
|
||||
response.put("taskId", taskId);
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/svn/presets")
|
||||
public Map<String, Object> listSvnPresets() {
|
||||
final Map<String, Object> response = new HashMap<String, Object>();
|
||||
final List<SvnPreset> presets = svnPresetService.listPresets();
|
||||
response.put("presets", presets);
|
||||
response.put("defaultPresetId", settingsService.getDefaultSvnPresetId());
|
||||
return response;
|
||||
}
|
||||
|
||||
@PostMapping("/ai/analyze")
|
||||
public Map<String, String> analyzeLogs(@Valid @RequestBody AiAnalyzeRequest request) {
|
||||
final String taskId = taskService.submit("AI_ANALYZE", context -> aiWorkflowService.analyzeAndExport(request, context));
|
||||
final Map<String, String> response = new HashMap<String, String>();
|
||||
response.put("taskId", taskId);
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/tasks")
|
||||
public List<TaskInfo> listTasks() {
|
||||
return taskService.getTasks();
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}")
|
||||
public TaskInfo getTask(@PathVariable("taskId") String taskId) {
|
||||
final TaskInfo task = taskService.getTask(taskId);
|
||||
if (task == null) {
|
||||
throw new IllegalArgumentException("任务不存在: " + taskId);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
@GetMapping("/files")
|
||||
public Map<String, Object> listFiles() throws IOException {
|
||||
final Map<String, Object> response = new HashMap<String, Object>();
|
||||
response.put("files", outputFileService.listOutputFiles());
|
||||
response.put("outputDir", outputFileService.getOutputRoot().toString());
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/files/download")
|
||||
public ResponseEntity<InputStreamResource> downloadFile(@RequestParam("path") String relativePath) throws IOException {
|
||||
final Path file = outputFileService.resolveInOutput(relativePath);
|
||||
if (!Files.exists(file) || !Files.isRegularFile(file)) {
|
||||
throw new IllegalArgumentException("文件不存在: " + relativePath);
|
||||
}
|
||||
|
||||
final HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentDispositionFormData("attachment", file.getFileName().toString());
|
||||
|
||||
final MediaType mediaType = relativePath.toLowerCase().endsWith(".md")
|
||||
? MediaType.parseMediaType("text/markdown")
|
||||
: MediaType.APPLICATION_OCTET_STREAM;
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.contentLength(Files.size(file))
|
||||
.contentType(mediaType)
|
||||
.body(new InputStreamResource(Files.newInputStream(file)));
|
||||
}
|
||||
|
||||
@GetMapping("/settings")
|
||||
public Map<String, Object> getSettings() throws IOException {
|
||||
return settingsService.getSettings();
|
||||
}
|
||||
|
||||
@PutMapping("/settings")
|
||||
public Map<String, Object> updateSettings(@RequestBody SettingsUpdateRequest request) throws IOException {
|
||||
settingsService.updateSettings(request.getApiKey(), request.getOutputDir(), request.getDefaultSvnPresetId());
|
||||
return settingsService.getSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.svnlog.web.controller;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return build(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
return build(HttpStatus.BAD_REQUEST, "请求参数校验失败");
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleAny(Exception ex) {
|
||||
return build(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage() == null ? "系统异常" : ex.getMessage());
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> build(HttpStatus status, String message) {
|
||||
final Map<String, Object> response = new HashMap<String, Object>();
|
||||
response.put("status", status.value());
|
||||
response.put("error", message);
|
||||
response.put("timestamp", Instant.now().toString());
|
||||
return ResponseEntity.status(status).body(response);
|
||||
}
|
||||
}
|
||||
47
src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java
Normal file
47
src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.svnlog.web.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
public class AiAnalyzeRequest {
|
||||
|
||||
@NotEmpty
|
||||
private List<String> filePaths;
|
||||
|
||||
private String period;
|
||||
private String apiKey;
|
||||
private String outputFileName;
|
||||
|
||||
public List<String> getFilePaths() {
|
||||
return filePaths;
|
||||
}
|
||||
|
||||
public void setFilePaths(List<String> filePaths) {
|
||||
this.filePaths = filePaths;
|
||||
}
|
||||
|
||||
public String getPeriod() {
|
||||
return period;
|
||||
}
|
||||
|
||||
public void setPeriod(String period) {
|
||||
this.period = period;
|
||||
}
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public String getOutputFileName() {
|
||||
return outputFileName;
|
||||
}
|
||||
|
||||
public void setOutputFileName(String outputFileName) {
|
||||
this.outputFileName = outputFileName;
|
||||
}
|
||||
}
|
||||
32
src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java
Normal file
32
src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.svnlog.web.dto;
|
||||
|
||||
public class SettingsUpdateRequest {
|
||||
|
||||
private String apiKey;
|
||||
private String outputDir;
|
||||
private String defaultSvnPresetId;
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public String getOutputDir() {
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
public void setOutputDir(String outputDir) {
|
||||
this.outputDir = outputDir;
|
||||
}
|
||||
|
||||
public String getDefaultSvnPresetId() {
|
||||
return defaultSvnPresetId;
|
||||
}
|
||||
|
||||
public void setDefaultSvnPresetId(String defaultSvnPresetId) {
|
||||
this.defaultSvnPresetId = defaultSvnPresetId;
|
||||
}
|
||||
}
|
||||
39
src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java
Normal file
39
src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.svnlog.web.dto;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class SvnConnectionRequest {
|
||||
|
||||
@NotBlank
|
||||
private String url;
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
77
src/main/java/com/svnlog/web/dto/SvnFetchRequest.java
Normal file
77
src/main/java/com/svnlog/web/dto/SvnFetchRequest.java
Normal file
@@ -0,0 +1,77 @@
|
||||
package com.svnlog.web.dto;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class SvnFetchRequest {
|
||||
|
||||
private String projectName;
|
||||
|
||||
@NotBlank
|
||||
private String url;
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
private Long startRevision;
|
||||
private Long endRevision;
|
||||
private String filterUser;
|
||||
|
||||
public String getProjectName() {
|
||||
return projectName;
|
||||
}
|
||||
|
||||
public void setProjectName(String projectName) {
|
||||
this.projectName = projectName;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public Long getStartRevision() {
|
||||
return startRevision;
|
||||
}
|
||||
|
||||
public void setStartRevision(Long startRevision) {
|
||||
this.startRevision = startRevision;
|
||||
}
|
||||
|
||||
public Long getEndRevision() {
|
||||
return endRevision;
|
||||
}
|
||||
|
||||
public void setEndRevision(Long endRevision) {
|
||||
this.endRevision = endRevision;
|
||||
}
|
||||
|
||||
public String getFilterUser() {
|
||||
return filterUser;
|
||||
}
|
||||
|
||||
public void setFilterUser(String filterUser) {
|
||||
this.filterUser = filterUser;
|
||||
}
|
||||
}
|
||||
34
src/main/java/com/svnlog/web/model/OutputFileInfo.java
Normal file
34
src/main/java/com/svnlog/web/model/OutputFileInfo.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.svnlog.web.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public class OutputFileInfo {
|
||||
|
||||
private String path;
|
||||
private long size;
|
||||
private Instant modifiedAt;
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(long size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public Instant getModifiedAt() {
|
||||
return modifiedAt;
|
||||
}
|
||||
|
||||
public void setModifiedAt(Instant modifiedAt) {
|
||||
this.modifiedAt = modifiedAt;
|
||||
}
|
||||
}
|
||||
41
src/main/java/com/svnlog/web/model/SvnPreset.java
Normal file
41
src/main/java/com/svnlog/web/model/SvnPreset.java
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.svnlog.web.model;
|
||||
|
||||
public class SvnPreset {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String url;
|
||||
|
||||
public SvnPreset() {
|
||||
}
|
||||
|
||||
public SvnPreset(String id, String name, String url) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
86
src/main/java/com/svnlog/web/model/TaskInfo.java
Normal file
86
src/main/java/com/svnlog/web/model/TaskInfo.java
Normal file
@@ -0,0 +1,86 @@
|
||||
package com.svnlog.web.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class TaskInfo {
|
||||
|
||||
private String taskId;
|
||||
private String type;
|
||||
private TaskStatus status;
|
||||
private int progress;
|
||||
private String message;
|
||||
private String error;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
private final List<String> files = new ArrayList<String>();
|
||||
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public TaskStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(TaskStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int getProgress() {
|
||||
return progress;
|
||||
}
|
||||
|
||||
public void setProgress(int progress) {
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
public void setError(String error) {
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public List<String> getFiles() {
|
||||
return files;
|
||||
}
|
||||
}
|
||||
33
src/main/java/com/svnlog/web/model/TaskResult.java
Normal file
33
src/main/java/com/svnlog/web/model/TaskResult.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.svnlog.web.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class TaskResult {
|
||||
|
||||
private String message;
|
||||
private final List<String> files = new ArrayList<String>();
|
||||
|
||||
public TaskResult() {
|
||||
}
|
||||
|
||||
public TaskResult(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public List<String> getFiles() {
|
||||
return files;
|
||||
}
|
||||
|
||||
public void addFile(String file) {
|
||||
this.files.add(file);
|
||||
}
|
||||
}
|
||||
8
src/main/java/com/svnlog/web/model/TaskStatus.java
Normal file
8
src/main/java/com/svnlog/web/model/TaskStatus.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package com.svnlog.web.model;
|
||||
|
||||
public enum TaskStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
335
src/main/java/com/svnlog/web/service/AiWorkflowService.java
Normal file
335
src/main/java/com/svnlog/web/service/AiWorkflowService.java
Normal file
@@ -0,0 +1,335 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.apache.poi.ss.usermodel.BorderStyle;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||
import org.apache.poi.ss.usermodel.Font;
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment;
|
||||
import org.apache.poi.ss.usermodel.IndexedColors;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.svnlog.web.dto.AiAnalyzeRequest;
|
||||
import com.svnlog.web.model.TaskResult;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
@Service
|
||||
public class AiWorkflowService {
|
||||
|
||||
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
|
||||
|
||||
private final OkHttpClient httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.readTimeout(180, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
private final OutputFileService outputFileService;
|
||||
private final SettingsService settingsService;
|
||||
|
||||
public AiWorkflowService(OutputFileService outputFileService, SettingsService settingsService) {
|
||||
this.outputFileService = outputFileService;
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
|
||||
context.setProgress(10, "正在读取 Markdown 文件");
|
||||
final String content = readMarkdownFiles(request.getFilePaths());
|
||||
|
||||
context.setProgress(35, "正在请求 DeepSeek 分析");
|
||||
final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
|
||||
? request.getPeriod().trim()
|
||||
: new SimpleDateFormat("yyyy年MM月").format(new Date());
|
||||
|
||||
final String apiKey = settingsService.pickActiveKey(request.getApiKey());
|
||||
if (apiKey == null || apiKey.trim().isEmpty()) {
|
||||
throw new IllegalStateException("未配置 DeepSeek API Key(可在设置页配置或请求中传入)");
|
||||
}
|
||||
|
||||
final String prompt = buildPrompt(content, period);
|
||||
final String aiResponse = callDeepSeek(apiKey, prompt);
|
||||
final JsonObject payload = extractJson(aiResponse);
|
||||
|
||||
context.setProgress(75, "正在生成 Excel 文件");
|
||||
final String filename = buildOutputFilename(request.getOutputFileName());
|
||||
final String relative = "excel/" + filename;
|
||||
final Path outputFile = outputFileService.resolveInOutput(relative);
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
writeExcel(outputFile, payload, period);
|
||||
|
||||
context.setProgress(100, "AI 分析已完成");
|
||||
final TaskResult result = new TaskResult("工作量统计已生成");
|
||||
result.addFile(relative);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String readMarkdownFiles(List<String> filePaths) throws IOException {
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
for (String filePath : filePaths) {
|
||||
final Path path = resolveUserFile(filePath);
|
||||
final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
||||
builder.append("\n\n=== 文件: ").append(path.getFileName().toString()).append(" ===\n");
|
||||
builder.append(content);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private Path resolveUserFile(String userPath) throws IOException {
|
||||
if (userPath == null || userPath.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("文件路径不能为空");
|
||||
}
|
||||
|
||||
final Path outputRoot = outputFileService.getOutputRoot();
|
||||
final Path rootPath = Paths.get("").toAbsolutePath().normalize();
|
||||
final Path candidate = rootPath.resolve(userPath).normalize();
|
||||
|
||||
if (candidate.startsWith(outputRoot) || candidate.startsWith(rootPath.resolve("docs").normalize())) {
|
||||
if (Files.exists(candidate) && Files.isRegularFile(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("文件不存在或不在允许目录:" + userPath);
|
||||
}
|
||||
|
||||
private String buildPrompt(String markdownContent, String period) {
|
||||
return "你是项目管理助手,请根据以下 SVN Markdown 日志生成工作量统计 JSON。\n"
|
||||
+ "工作周期: " + period + "\n"
|
||||
+ "要求:仅输出 JSON,不要输出额外文字。\n"
|
||||
+ "JSON结构:\n"
|
||||
+ "{\n"
|
||||
+ " \"team\": \"所属班组\",\n"
|
||||
+ " \"contact\": \"技术对接人\",\n"
|
||||
+ " \"developer\": \"开发人员\",\n"
|
||||
+ " \"period\": \"" + period + "\",\n"
|
||||
+ " \"records\": [\n"
|
||||
+ " {\"sequence\":1,\"project\":\"项目A/项目B\",\"content\":\"# 项目A\\n1.xxx\\n2.xxx\"}\n"
|
||||
+ " ]\n"
|
||||
+ "}\n\n"
|
||||
+ "日志内容:\n" + markdownContent;
|
||||
}
|
||||
|
||||
private String callDeepSeek(String apiKey, String prompt) throws IOException {
|
||||
final JsonObject message = new JsonObject();
|
||||
message.addProperty("role", "user");
|
||||
message.addProperty("content", prompt);
|
||||
|
||||
final JsonArray messages = new JsonArray();
|
||||
messages.add(message);
|
||||
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("model", "deepseek-reasoner");
|
||||
body.add("messages", messages);
|
||||
body.addProperty("max_tokens", 3500);
|
||||
body.addProperty("stream", false);
|
||||
|
||||
final Request request = new Request.Builder()
|
||||
.url(DEEPSEEK_API_URL)
|
||||
.addHeader("Authorization", "Bearer " + apiKey)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
|
||||
.build();
|
||||
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
String errorBody = "";
|
||||
if (response.body() != null) {
|
||||
errorBody = response.body().string();
|
||||
}
|
||||
throw new IllegalStateException("DeepSeek API 调用失败: " + response.code() + " " + errorBody);
|
||||
}
|
||||
if (response.body() == null) {
|
||||
throw new IllegalStateException("DeepSeek API 返回空响应体");
|
||||
}
|
||||
|
||||
final String raw = response.body().string();
|
||||
final JsonObject data = JsonParser.parseString(raw).getAsJsonObject();
|
||||
final JsonArray choices = data.getAsJsonArray("choices");
|
||||
if (choices == null || choices.size() == 0) {
|
||||
throw new IllegalStateException("DeepSeek API 未返回可用结果");
|
||||
}
|
||||
|
||||
final JsonObject first = choices.get(0).getAsJsonObject();
|
||||
final JsonObject messageObj = first.getAsJsonObject("message");
|
||||
if (messageObj == null || !messageObj.has("content")) {
|
||||
throw new IllegalStateException("DeepSeek API 响应缺少 content 字段");
|
||||
}
|
||||
return messageObj.get("content").getAsString();
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject extractJson(String rawResponse) {
|
||||
String trimmed = rawResponse == null ? "" : rawResponse.trim();
|
||||
if (trimmed.startsWith("```json")) {
|
||||
trimmed = trimmed.substring(7).trim();
|
||||
} else if (trimmed.startsWith("```")) {
|
||||
trimmed = trimmed.substring(3).trim();
|
||||
}
|
||||
if (trimmed.endsWith("```")) {
|
||||
trimmed = trimmed.substring(0, trimmed.length() - 3).trim();
|
||||
}
|
||||
return JsonParser.parseString(trimmed).getAsJsonObject();
|
||||
}
|
||||
|
||||
private String buildOutputFilename(String outputFileName) {
|
||||
if (outputFileName != null && !outputFileName.trim().isEmpty()) {
|
||||
String name = outputFileName.trim();
|
||||
if (!name.toLowerCase().endsWith(".xlsx")) {
|
||||
name = name + ".xlsx";
|
||||
}
|
||||
return sanitize(name);
|
||||
}
|
||||
return new SimpleDateFormat("yyyyMM").format(new Date()) + "工作量统计.xlsx";
|
||||
}
|
||||
|
||||
private void writeExcel(Path outputFile, JsonObject payload, String defaultPeriod) throws IOException {
|
||||
final String team = optString(payload, "team");
|
||||
final String contact = optString(payload, "contact");
|
||||
final String developer = optString(payload, "developer");
|
||||
final String period = payload.has("period") ? optString(payload, "period") : defaultPeriod;
|
||||
|
||||
try (Workbook workbook = new XSSFWorkbook()) {
|
||||
final Sheet sheet = workbook.createSheet("工作量统计");
|
||||
|
||||
final CellStyle headerStyle = createHeaderStyle(workbook);
|
||||
final CellStyle textStyle = createTextStyle(workbook);
|
||||
final CellStyle contentStyle = createContentStyle(workbook);
|
||||
|
||||
final String[] headers = {"序号", "所属班组", "技术对接", "开发人员", "工作周期", "开发项目名称", "具体工作内容"};
|
||||
final Row header = sheet.createRow(0);
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
final Cell cell = header.createCell(i);
|
||||
cell.setCellValue(headers[i]);
|
||||
cell.setCellStyle(headerStyle);
|
||||
}
|
||||
|
||||
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
final JsonObject record = records.get(i).getAsJsonObject();
|
||||
final Row row = sheet.createRow(i + 1);
|
||||
|
||||
createCell(row, 0, getAsInt(record.get("sequence"), i + 1), textStyle);
|
||||
createCell(row, 1, team, textStyle);
|
||||
createCell(row, 2, contact, textStyle);
|
||||
createCell(row, 3, developer, textStyle);
|
||||
createCell(row, 4, period, textStyle);
|
||||
createCell(row, 5, optString(record, "project"), textStyle);
|
||||
createCell(row, 6, optString(record, "content"), contentStyle);
|
||||
}
|
||||
|
||||
sheet.setColumnWidth(0, 2200);
|
||||
sheet.setColumnWidth(1, 4200);
|
||||
sheet.setColumnWidth(2, 5200);
|
||||
sheet.setColumnWidth(3, 4200);
|
||||
sheet.setColumnWidth(4, 4600);
|
||||
sheet.setColumnWidth(5, 12000);
|
||||
sheet.setColumnWidth(6, 26000);
|
||||
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
try (OutputStream out = Files.newOutputStream(outputFile)) {
|
||||
workbook.write(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CellStyle createHeaderStyle(Workbook workbook) {
|
||||
final CellStyle style = workbook.createCellStyle();
|
||||
final Font font = workbook.createFont();
|
||||
font.setBold(true);
|
||||
font.setFontName("SimSun");
|
||||
font.setColor(IndexedColors.BLACK.getIndex());
|
||||
style.setFont(font);
|
||||
style.setAlignment(HorizontalAlignment.CENTER);
|
||||
style.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
style.setBorderTop(BorderStyle.THIN);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setBorderLeft(BorderStyle.THIN);
|
||||
style.setBorderRight(BorderStyle.THIN);
|
||||
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
|
||||
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
return style;
|
||||
}
|
||||
|
||||
private CellStyle createTextStyle(Workbook workbook) {
|
||||
final CellStyle style = workbook.createCellStyle();
|
||||
final Font font = workbook.createFont();
|
||||
font.setFontName("SimSun");
|
||||
style.setFont(font);
|
||||
style.setAlignment(HorizontalAlignment.LEFT);
|
||||
style.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setWrapText(false);
|
||||
return style;
|
||||
}
|
||||
|
||||
private CellStyle createContentStyle(Workbook workbook) {
|
||||
final CellStyle style = workbook.createCellStyle();
|
||||
final Font font = workbook.createFont();
|
||||
font.setFontName("SimSun");
|
||||
style.setFont(font);
|
||||
style.setAlignment(HorizontalAlignment.LEFT);
|
||||
style.setVerticalAlignment(VerticalAlignment.TOP);
|
||||
style.setWrapText(true);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
return style;
|
||||
}
|
||||
|
||||
private void createCell(Row row, int idx, String value, CellStyle style) {
|
||||
final Cell cell = row.createCell(idx);
|
||||
cell.setCellValue(value == null ? "" : value);
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
|
||||
private void createCell(Row row, int idx, int value, CellStyle style) {
|
||||
final Cell cell = row.createCell(idx);
|
||||
cell.setCellValue(value);
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
|
||||
private int getAsInt(JsonElement element, int defaultValue) {
|
||||
if (element == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return element.getAsInt();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private String optString(JsonObject object, String key) {
|
||||
if (object == null || !object.has(key) || object.get(key).isJsonNull()) {
|
||||
return "";
|
||||
}
|
||||
return object.get(key).getAsString();
|
||||
}
|
||||
|
||||
private String sanitize(String value) {
|
||||
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_");
|
||||
}
|
||||
}
|
||||
80
src/main/java/com/svnlog/web/service/OutputFileService.java
Normal file
80
src/main/java/com/svnlog/web/service/OutputFileService.java
Normal file
@@ -0,0 +1,80 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.svnlog.web.model.OutputFileInfo;
|
||||
|
||||
@Service
|
||||
public class OutputFileService {
|
||||
|
||||
private Path outputRoot = Paths.get("outputs").toAbsolutePath().normalize();
|
||||
|
||||
public synchronized void setOutputRoot(String outputDir) {
|
||||
if (outputDir != null && !outputDir.trim().isEmpty()) {
|
||||
this.outputRoot = Paths.get(outputDir.trim()).toAbsolutePath().normalize();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Path getOutputRoot() throws IOException {
|
||||
Files.createDirectories(outputRoot);
|
||||
return outputRoot;
|
||||
}
|
||||
|
||||
public Path resolveInOutput(String relative) throws IOException {
|
||||
final Path root = getOutputRoot();
|
||||
final Path resolved = root.resolve(relative).normalize();
|
||||
if (!resolved.startsWith(root)) {
|
||||
throw new IllegalArgumentException("非法文件路径");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public List<OutputFileInfo> listOutputFiles() throws IOException {
|
||||
final Path root = getOutputRoot();
|
||||
if (!Files.exists(root)) {
|
||||
return new ArrayList<OutputFileInfo>();
|
||||
}
|
||||
|
||||
final List<Path> filePaths = new ArrayList<Path>();
|
||||
Files.walk(root)
|
||||
.filter(Files::isRegularFile)
|
||||
.forEach(filePaths::add);
|
||||
|
||||
filePaths.sort(Comparator.comparingLong(this::lastModified).reversed());
|
||||
|
||||
final List<OutputFileInfo> result = new ArrayList<OutputFileInfo>();
|
||||
for (Path path : filePaths) {
|
||||
final OutputFileInfo info = new OutputFileInfo();
|
||||
info.setPath(root.relativize(path).toString().replace(File.separatorChar, '/'));
|
||||
info.setSize(Files.size(path));
|
||||
info.setModifiedAt(Instant.ofEpochMilli(lastModified(path)));
|
||||
result.add(info);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public Path copyIntoOutput(Path source, String outputName) throws IOException {
|
||||
final Path target = resolveInOutput(outputName);
|
||||
Files.createDirectories(target.getParent());
|
||||
return Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
private long lastModified(Path path) {
|
||||
try {
|
||||
return Files.getLastModifiedTime(path).toMillis();
|
||||
} catch (IOException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/main/java/com/svnlog/web/service/SettingsService.java
Normal file
67
src/main/java/com/svnlog/web/service/SettingsService.java
Normal file
@@ -0,0 +1,67 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SettingsService {
|
||||
|
||||
private final OutputFileService outputFileService;
|
||||
private final SvnPresetService svnPresetService;
|
||||
private volatile String runtimeApiKey;
|
||||
private volatile String defaultSvnPresetId;
|
||||
|
||||
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
||||
this.outputFileService = outputFileService;
|
||||
this.svnPresetService = svnPresetService;
|
||||
this.defaultSvnPresetId = svnPresetService.firstPresetId();
|
||||
}
|
||||
|
||||
public Map<String, Object> getSettings() throws IOException {
|
||||
final Map<String, Object> result = new HashMap<String, Object>();
|
||||
final String envKey = System.getenv("DEEPSEEK_API_KEY");
|
||||
final String activeKey = pickActiveKey(null);
|
||||
|
||||
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
|
||||
result.put("apiKeySource", runtimeApiKey != null ? "runtime" : (envKey != null ? "env" : "none"));
|
||||
result.put("outputDir", outputFileService.getOutputRoot().toString());
|
||||
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
|
||||
return result;
|
||||
}
|
||||
|
||||
public void updateSettings(String apiKey, String outputDir, String newDefaultSvnPresetId) {
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
this.runtimeApiKey = apiKey.trim();
|
||||
}
|
||||
if (outputDir != null && !outputDir.trim().isEmpty()) {
|
||||
outputFileService.setOutputRoot(outputDir);
|
||||
}
|
||||
if (svnPresetService.containsPresetId(newDefaultSvnPresetId)) {
|
||||
this.defaultSvnPresetId = newDefaultSvnPresetId;
|
||||
}
|
||||
}
|
||||
|
||||
public String pickActiveKey(String requestKey) {
|
||||
if (requestKey != null && !requestKey.trim().isEmpty()) {
|
||||
return requestKey.trim();
|
||||
}
|
||||
if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) {
|
||||
return runtimeApiKey;
|
||||
}
|
||||
final String envKey = System.getenv("DEEPSEEK_API_KEY");
|
||||
if (envKey != null && !envKey.trim().isEmpty()) {
|
||||
return envKey.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getDefaultSvnPresetId() {
|
||||
if (svnPresetService.containsPresetId(defaultSvnPresetId)) {
|
||||
return defaultSvnPresetId;
|
||||
}
|
||||
return svnPresetService.firstPresetId();
|
||||
}
|
||||
}
|
||||
55
src/main/java/com/svnlog/web/service/SvnPresetService.java
Normal file
55
src/main/java/com/svnlog/web/service/SvnPresetService.java
Normal file
@@ -0,0 +1,55 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.svnlog.web.model.SvnPreset;
|
||||
|
||||
@Service
|
||||
public class SvnPresetService {
|
||||
|
||||
private final List<SvnPreset> presets;
|
||||
|
||||
public SvnPresetService() {
|
||||
List<SvnPreset> list = new ArrayList<SvnPreset>();
|
||||
list.add(new SvnPreset(
|
||||
"preset-1",
|
||||
"PRS-7050场站智慧管控",
|
||||
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00/src_java"
|
||||
));
|
||||
list.add(new SvnPreset(
|
||||
"preset-2",
|
||||
"PRS-7950在线巡视",
|
||||
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00/src_java"
|
||||
));
|
||||
list.add(new SvnPreset(
|
||||
"preset-3",
|
||||
"PRS-7950在线巡视电科院测试版",
|
||||
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024/src_java"
|
||||
));
|
||||
this.presets = Collections.unmodifiableList(list);
|
||||
}
|
||||
|
||||
public List<SvnPreset> listPresets() {
|
||||
return presets;
|
||||
}
|
||||
|
||||
public boolean containsPresetId(String presetId) {
|
||||
if (presetId == null || presetId.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (SvnPreset preset : presets) {
|
||||
if (presetId.equals(preset.getId())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public String firstPresetId() {
|
||||
return presets.isEmpty() ? "" : presets.get(0).getId();
|
||||
}
|
||||
}
|
||||
107
src/main/java/com/svnlog/web/service/SvnWorkflowService.java
Normal file
107
src/main/java/com/svnlog/web/service/SvnWorkflowService.java
Normal file
@@ -0,0 +1,107 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.tmatesoft.svn.core.SVNException;
|
||||
|
||||
import com.svnlog.LogEntry;
|
||||
import com.svnlog.SVNLogFetcher;
|
||||
import com.svnlog.web.dto.SvnConnectionRequest;
|
||||
import com.svnlog.web.dto.SvnFetchRequest;
|
||||
import com.svnlog.web.model.TaskResult;
|
||||
|
||||
@Service
|
||||
public class SvnWorkflowService {
|
||||
|
||||
private final OutputFileService outputFileService;
|
||||
|
||||
public SvnWorkflowService(OutputFileService outputFileService) {
|
||||
this.outputFileService = outputFileService;
|
||||
}
|
||||
|
||||
public void testConnection(SvnConnectionRequest request) throws SVNException {
|
||||
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
|
||||
fetcher.testConnection();
|
||||
}
|
||||
|
||||
public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception {
|
||||
context.setProgress(10, "正在连接 SVN 仓库");
|
||||
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
|
||||
fetcher.testConnection();
|
||||
|
||||
context.setProgress(30, "正在拉取 SVN 日志");
|
||||
final long latest = fetcher.getLatestRevision();
|
||||
final long start = request.getStartRevision() != null ? request.getStartRevision().longValue() : latest;
|
||||
final long end = request.getEndRevision() != null ? request.getEndRevision().longValue() : latest;
|
||||
|
||||
final List<LogEntry> logs = fetcher.fetchLogs(start, end, safe(request.getFilterUser()));
|
||||
if (logs.isEmpty()) {
|
||||
throw new IllegalStateException("未查询到符合条件的日志");
|
||||
}
|
||||
|
||||
context.setProgress(70, "正在生成 Markdown 文件");
|
||||
final String projectName = request.getProjectName() != null && !request.getProjectName().trim().isEmpty()
|
||||
? request.getProjectName().trim()
|
||||
: "custom";
|
||||
final String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
|
||||
final String fileName = "md/svn_log_" + sanitize(projectName) + "_" + timestamp + ".md";
|
||||
final Path outputPath = outputFileService.resolveInOutput(fileName);
|
||||
|
||||
Files.createDirectories(outputPath.getParent());
|
||||
writeMarkdown(outputPath, request, start, end, logs, fetcher);
|
||||
|
||||
context.setProgress(100, "SVN 日志导出完成");
|
||||
final TaskResult result = new TaskResult("成功导出 " + logs.size() + " 条日志");
|
||||
result.addFile(fileName);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void writeMarkdown(Path path, SvnFetchRequest request, long startRevision, long endRevision,
|
||||
List<LogEntry> logs, SVNLogFetcher fetcher) throws IOException {
|
||||
final StringBuilder markdown = new StringBuilder();
|
||||
|
||||
markdown.append("# SVN 日志报告\n\n");
|
||||
markdown.append("## 查询条件\n\n");
|
||||
markdown.append("- **SVN地址**: `").append(request.getUrl()).append("`\n");
|
||||
markdown.append("- **账号**: `").append(request.getUsername()).append("`\n");
|
||||
markdown.append("- **版本范围**: r").append(startRevision).append(" - r").append(endRevision).append("\n");
|
||||
if (!safe(request.getFilterUser()).isEmpty()) {
|
||||
markdown.append("- **过滤用户**: `").append(request.getFilterUser()).append("`\n");
|
||||
}
|
||||
markdown.append("- **生成时间**: ").append(fetcher.formatDate(new Date())).append("\n\n");
|
||||
|
||||
markdown.append("## 统计信息\n\n");
|
||||
markdown.append("- **总记录数**: ").append(logs.size()).append(" 条\n\n");
|
||||
markdown.append("## 日志详情\n\n");
|
||||
|
||||
for (LogEntry entry : logs) {
|
||||
markdown.append("### r").append(entry.getRevision()).append("\n\n");
|
||||
markdown.append("**作者**: `").append(entry.getAuthor()).append("` \n");
|
||||
markdown.append("**时间**: ").append(fetcher.formatDate(entry.getDate())).append(" \n");
|
||||
markdown.append("**版本**: r").append(entry.getRevision()).append("\n\n");
|
||||
markdown.append("**提交信息**:\n\n");
|
||||
markdown.append("```\n").append(safe(entry.getMessage())).append("\n```\n\n");
|
||||
markdown.append("---\n\n");
|
||||
}
|
||||
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
|
||||
writer.write(markdown.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitize(String value) {
|
||||
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_");
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
19
src/main/java/com/svnlog/web/service/TaskContext.java
Normal file
19
src/main/java/com/svnlog/web/service/TaskContext.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
|
||||
public class TaskContext {
|
||||
|
||||
private final TaskInfo taskInfo;
|
||||
|
||||
public TaskContext(TaskInfo taskInfo) {
|
||||
this.taskInfo = taskInfo;
|
||||
}
|
||||
|
||||
public void setProgress(int progress, String message) {
|
||||
final int bounded = Math.max(0, Math.min(100, progress));
|
||||
taskInfo.setProgress(bounded);
|
||||
taskInfo.setMessage(message);
|
||||
taskInfo.setUpdatedAt(java.time.Instant.now());
|
||||
}
|
||||
}
|
||||
91
src/main/java/com/svnlog/web/service/TaskService.java
Normal file
91
src/main/java/com/svnlog/web/service/TaskService.java
Normal file
@@ -0,0 +1,91 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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 javax.annotation.PreDestroy;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
import com.svnlog.web.model.TaskResult;
|
||||
import com.svnlog.web.model.TaskStatus;
|
||||
|
||||
@Service
|
||||
public class TaskService {
|
||||
|
||||
public interface TaskRunner {
|
||||
TaskResult run(TaskContext context) throws Exception;
|
||||
}
|
||||
|
||||
private final ExecutorService executor = Executors.newFixedThreadPool(4);
|
||||
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
|
||||
|
||||
public String submit(String type, TaskRunner runner) {
|
||||
final String taskId = UUID.randomUUID().toString();
|
||||
final TaskInfo taskInfo = new TaskInfo();
|
||||
final Instant now = Instant.now();
|
||||
|
||||
taskInfo.setTaskId(taskId);
|
||||
taskInfo.setType(type);
|
||||
taskInfo.setStatus(TaskStatus.PENDING);
|
||||
taskInfo.setProgress(0);
|
||||
taskInfo.setMessage("任务已创建");
|
||||
taskInfo.setCreatedAt(now);
|
||||
taskInfo.setUpdatedAt(now);
|
||||
tasks.put(taskId, taskInfo);
|
||||
|
||||
executor.submit(new Callable<Void>() {
|
||||
@Override
|
||||
public Void call() {
|
||||
runTask(taskInfo, runner);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public TaskInfo getTask(String taskId) {
|
||||
return tasks.get(taskId);
|
||||
}
|
||||
|
||||
public List<TaskInfo> getTasks() {
|
||||
return new ArrayList<TaskInfo>(tasks.values());
|
||||
}
|
||||
|
||||
private void runTask(TaskInfo taskInfo, TaskRunner runner) {
|
||||
taskInfo.setStatus(TaskStatus.RUNNING);
|
||||
taskInfo.setMessage("任务执行中");
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
|
||||
final TaskContext context = new TaskContext(taskInfo);
|
||||
try {
|
||||
final TaskResult result = runner.run(context);
|
||||
taskInfo.setStatus(TaskStatus.SUCCESS);
|
||||
taskInfo.setProgress(100);
|
||||
taskInfo.setMessage(result != null ? result.getMessage() : "执行完成");
|
||||
taskInfo.getFiles().clear();
|
||||
if (result != null && result.getFiles() != null) {
|
||||
taskInfo.getFiles().addAll(result.getFiles());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
taskInfo.setStatus(TaskStatus.FAILED);
|
||||
taskInfo.setError(e.getMessage());
|
||||
taskInfo.setMessage("执行失败");
|
||||
}
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user