feat(web): unify web entry, preset config, SSE streaming and dual-pane live logs
This commit is contained in:
71
src/main/java/com/svnlog/web/WebApplication.java
Normal file
71
src/main/java/com/svnlog/web/WebApplication.java
Normal file
@@ -0,0 +1,71 @@
|
||||
package com.svnlog.web;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = "com.svnlog")
|
||||
public class WebApplication {
|
||||
|
||||
static {
|
||||
// 配置 Java 全局 SSL 上下文(用于内网 SVN 服务器)
|
||||
try {
|
||||
// 移除 TLSv1 和 TLSv1.1 的禁用限制
|
||||
String disabledAlgorithms = java.security.Security.getProperty("jdk.tls.disabledAlgorithms");
|
||||
if (disabledAlgorithms != null && (disabledAlgorithms.contains("TLSv1") || disabledAlgorithms.contains("TLSv1.1"))) {
|
||||
disabledAlgorithms = disabledAlgorithms
|
||||
.replaceAll("TLSv1\\.1,\\s*", "")
|
||||
.replaceAll("TLSv1,\\s*", "")
|
||||
.replaceAll(",\\s*TLSv1\\.1", "")
|
||||
.replaceAll(",\\s*TLSv1", "");
|
||||
java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms);
|
||||
System.out.println("TLS configuration updated: " + disabledAlgorithms);
|
||||
}
|
||||
|
||||
// 配置信任所有证书的 SSL 上下文
|
||||
TrustManager[] trustAllCerts = new TrustManager[]{
|
||||
new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
public void checkClientTrusted(X509Certificate[] certs, String authType) {
|
||||
}
|
||||
public void checkServerTrusted(X509Certificate[] certs, String authType) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
|
||||
|
||||
// 设置为默认 SSL 上下文
|
||||
SSLContext.setDefault(sslContext);
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
|
||||
HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() {
|
||||
public boolean verify(String hostname, SSLSession session) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
System.out.println("SSL context configured to trust all certificates");
|
||||
} catch (Exception e) {
|
||||
System.err.println("Warning: Failed to configure SSL context: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 配置 TLS 协议版本
|
||||
System.setProperty("https.protocols", "TLSv1.2,TLSv1.1,TLSv1");
|
||||
System.setProperty("jdk.tls.client.protocols", "TLSv1.2,TLSv1.1,TLSv1");
|
||||
System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1");
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(WebApplication.class, args);
|
||||
}
|
||||
}
|
||||
33
src/main/java/com/svnlog/web/config/SvnPresetProperties.java
Normal file
33
src/main/java/com/svnlog/web/config/SvnPresetProperties.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.svnlog.web.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.svnlog.web.model.SvnPreset;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "svn")
|
||||
public class SvnPresetProperties {
|
||||
|
||||
private String defaultPresetId;
|
||||
private List<SvnPreset> presets = new ArrayList<SvnPreset>();
|
||||
|
||||
public String getDefaultPresetId() {
|
||||
return defaultPresetId;
|
||||
}
|
||||
|
||||
public void setDefaultPresetId(String defaultPresetId) {
|
||||
this.defaultPresetId = defaultPresetId;
|
||||
}
|
||||
|
||||
public List<SvnPreset> getPresets() {
|
||||
return presets;
|
||||
}
|
||||
|
||||
public void setPresets(List<SvnPreset> presets) {
|
||||
this.presets = presets;
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,17 @@ 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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import com.svnlog.core.svn.SVNLogFetcher;
|
||||
|
||||
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.dto.SvnVersionRangeRequest;
|
||||
import com.svnlog.web.model.SvnPreset;
|
||||
import com.svnlog.web.model.SvnPresetSummary;
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
import com.svnlog.web.model.TaskPageResult;
|
||||
import com.svnlog.web.service.AiWorkflowService;
|
||||
@@ -84,6 +89,29 @@ public class AppController {
|
||||
response.put("message", "SVN 连接成功");
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定月份的SVN版本范围
|
||||
*/
|
||||
@PostMapping("/svn/version-range")
|
||||
public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception {
|
||||
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||
final String url = preset.getUrl();
|
||||
final String username = request.getUsername();
|
||||
final String password = request.getPassword();
|
||||
final int year = request.getYear().intValue();
|
||||
final int month = request.getMonth().intValue();
|
||||
|
||||
SVNLogFetcher fetcher = new SVNLogFetcher(url, username, password);
|
||||
long[] range = fetcher.getVersionRangeByMonth(year, month);
|
||||
|
||||
final Map<String, Object> response = new HashMap<String, Object>();
|
||||
if (range != null) {
|
||||
response.put("startRevision", range[0]);
|
||||
response.put("endRevision", range[1]);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@PostMapping("/svn/fetch")
|
||||
public Map<String, String> fetchSvnLogs(@Valid @RequestBody SvnFetchRequest request) {
|
||||
@@ -96,7 +124,7 @@ public class AppController {
|
||||
@GetMapping("/svn/presets")
|
||||
public Map<String, Object> listSvnPresets() {
|
||||
final Map<String, Object> response = new HashMap<String, Object>();
|
||||
final List<SvnPreset> presets = svnPresetService.listPresets();
|
||||
final List<SvnPresetSummary> presets = svnPresetService.listPresetSummaries();
|
||||
response.put("presets", presets);
|
||||
response.put("defaultPresetId", settingsService.getDefaultSvnPresetId());
|
||||
return response;
|
||||
@@ -135,6 +163,11 @@ public class AppController {
|
||||
return task;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/tasks/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter streamTask(@PathVariable("taskId") String taskId) {
|
||||
return taskService.subscribeTaskStream(taskId);
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/cancel")
|
||||
public Map<String, Object> cancelTask(@PathVariable("taskId") String taskId) {
|
||||
final boolean cancelled = taskService.cancelTask(taskId);
|
||||
|
||||
@@ -5,7 +5,7 @@ import javax.validation.constraints.NotBlank;
|
||||
public class SvnConnectionRequest {
|
||||
|
||||
@NotBlank
|
||||
private String url;
|
||||
private String presetId;
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
@@ -13,12 +13,12 @@ public class SvnConnectionRequest {
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
public String getPresetId() {
|
||||
return presetId;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
public void setPresetId(String presetId) {
|
||||
this.presetId = presetId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
|
||||
@@ -4,10 +4,10 @@ import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class SvnFetchRequest {
|
||||
|
||||
private String projectName;
|
||||
|
||||
@NotBlank
|
||||
private String url;
|
||||
private String presetId;
|
||||
|
||||
private String projectName;
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
@@ -19,6 +19,14 @@ public class SvnFetchRequest {
|
||||
private Long endRevision;
|
||||
private String filterUser;
|
||||
|
||||
public String getPresetId() {
|
||||
return presetId;
|
||||
}
|
||||
|
||||
public void setPresetId(String presetId) {
|
||||
this.presetId = presetId;
|
||||
}
|
||||
|
||||
public String getProjectName() {
|
||||
return projectName;
|
||||
}
|
||||
@@ -27,14 +35,6 @@ public class SvnFetchRequest {
|
||||
this.projectName = projectName;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
62
src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java
Normal file
62
src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java
Normal file
@@ -0,0 +1,62 @@
|
||||
package com.svnlog.web.dto;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class SvnVersionRangeRequest {
|
||||
|
||||
@NotBlank
|
||||
private String presetId;
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
@NotNull
|
||||
private Integer year;
|
||||
|
||||
@NotNull
|
||||
private Integer month;
|
||||
|
||||
public String getPresetId() {
|
||||
return presetId;
|
||||
}
|
||||
|
||||
public void setPresetId(String presetId) {
|
||||
this.presetId = presetId;
|
||||
}
|
||||
|
||||
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 Integer getYear() {
|
||||
return year;
|
||||
}
|
||||
|
||||
public void setYear(Integer year) {
|
||||
this.year = year;
|
||||
}
|
||||
|
||||
public Integer getMonth() {
|
||||
return month;
|
||||
}
|
||||
|
||||
public void setMonth(Integer month) {
|
||||
this.month = month;
|
||||
}
|
||||
}
|
||||
31
src/main/java/com/svnlog/web/model/SvnPresetSummary.java
Normal file
31
src/main/java/com/svnlog/web/model/SvnPresetSummary.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.svnlog.web.model;
|
||||
|
||||
public class SvnPresetSummary {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
public SvnPresetSummary() {
|
||||
}
|
||||
|
||||
public SvnPresetSummary(String id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,15 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.poi.ss.usermodel.BorderStyle;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
@@ -42,6 +48,19 @@ import okhttp3.Response;
|
||||
public class AiWorkflowService {
|
||||
|
||||
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
|
||||
private static final String DEFAULT_TEAM = "系统部";
|
||||
private static final String DEFAULT_CONTACT = "杨志强\n(系统平台组)";
|
||||
private static final String DEFAULT_DEVELOPER = "刘靖";
|
||||
private static final String[] FIXED_PROJECTS = {
|
||||
"PRS-7050场站智慧管控",
|
||||
"PRS-7950在线巡视",
|
||||
"PRS-7950在线巡视电科院测试版"
|
||||
};
|
||||
private static final String FIXED_PROJECT_VALUE =
|
||||
"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控";
|
||||
private static final Pattern NUMBERED_ITEM_PATTERN = Pattern.compile("^\\s*(\\d+)[\\.、\\)]\\s*(.+)$");
|
||||
private static final Pattern BULLET_ITEM_PATTERN = Pattern.compile("^\\s*[-*•]\\s*(.+)$");
|
||||
private static final Pattern REVISION_ITEM_PATTERN = Pattern.compile("^\\s*(?:\\*\\s*)?r\\d+\\s*[-::]+\\s*(.+)$");
|
||||
|
||||
private final OkHttpClient httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
@@ -63,8 +82,14 @@ public class AiWorkflowService {
|
||||
}
|
||||
|
||||
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
|
||||
context.setProgress(10, "正在读取 Markdown 文件");
|
||||
final Path outputRoot = outputFileService.getOutputRoot();
|
||||
final List<String> requestedPaths = request.getFilePaths() == null
|
||||
? java.util.Collections.<String>emptyList()
|
||||
: request.getFilePaths();
|
||||
context.setProgress(10, "正在读取 Markdown 文件,输出目录: " + outputRoot);
|
||||
context.setProgress(12, "待处理文件: " + joinPaths(requestedPaths));
|
||||
final List<Path> markdownFiles = resolveUserFiles(request.getFilePaths());
|
||||
context.setProgress(18, "路径解析完成: " + joinResolvedPaths(markdownFiles));
|
||||
aiInputValidator.validate(markdownFiles);
|
||||
final String content = readMarkdownFiles(markdownFiles);
|
||||
|
||||
@@ -79,7 +104,7 @@ public class AiWorkflowService {
|
||||
}
|
||||
|
||||
final String prompt = buildPrompt(content, period);
|
||||
final String aiResponse = callDeepSeek(apiKey, prompt);
|
||||
final String aiResponse = callDeepSeek(apiKey, prompt, context);
|
||||
final JsonObject payload = extractJson(aiResponse);
|
||||
|
||||
context.setProgress(75, "正在生成 Excel 文件");
|
||||
@@ -121,39 +146,101 @@ public class AiWorkflowService {
|
||||
throw new IllegalArgumentException("文件路径不能为空");
|
||||
}
|
||||
|
||||
final String normalizedInput = userPath.trim();
|
||||
final Path outputRoot = outputFileService.getOutputRoot();
|
||||
final Path rootPath = Paths.get("").toAbsolutePath().normalize();
|
||||
final Path candidate = rootPath.resolve(userPath).normalize();
|
||||
final Path docsRoot = rootPath.resolve("docs").normalize();
|
||||
|
||||
if (candidate.startsWith(outputRoot) || candidate.startsWith(rootPath.resolve("docs").normalize())) {
|
||||
// 优先按输出目录相对路径解析(例如 md/*.md、excel/*.xlsx)
|
||||
final Path outputCandidate = outputFileService.resolveInOutput(normalizedInput);
|
||||
if (Files.exists(outputCandidate) && Files.isRegularFile(outputCandidate)) {
|
||||
return outputCandidate;
|
||||
}
|
||||
|
||||
// 兼容绝对路径或历史路径输入,但仍限制在允许目录内
|
||||
final Path raw = Paths.get(normalizedInput);
|
||||
final Path candidate = raw.isAbsolute() ? raw.normalize() : rootPath.resolve(raw).normalize();
|
||||
|
||||
if (candidate.startsWith(outputRoot) || candidate.startsWith(docsRoot)) {
|
||||
if (Files.exists(candidate) && Files.isRegularFile(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("文件不存在或不在允许目录:" + userPath);
|
||||
final boolean outputCandidateExists = Files.exists(outputCandidate);
|
||||
final boolean outputCandidateIsFile = outputCandidateExists && Files.isRegularFile(outputCandidate);
|
||||
final boolean rootCandidateExists = Files.exists(candidate);
|
||||
final boolean rootCandidateIsFile = rootCandidateExists && Files.isRegularFile(candidate);
|
||||
|
||||
throw new IllegalArgumentException(
|
||||
"文件不存在或不在允许目录:" + normalizedInput
|
||||
+ " | outputCandidate=" + outputCandidate
|
||||
+ " (exists=" + outputCandidateExists + ", file=" + outputCandidateIsFile + ")"
|
||||
+ " | rootCandidate=" + candidate
|
||||
+ " (exists=" + rootCandidateExists + ", file=" + rootCandidateIsFile + ")"
|
||||
+ " | outputRoot=" + outputRoot
|
||||
+ " | docsRoot=" + docsRoot
|
||||
);
|
||||
}
|
||||
|
||||
private String joinPaths(List<String> paths) {
|
||||
if (paths == null || paths.isEmpty()) {
|
||||
return "(empty)";
|
||||
}
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < paths.size(); i++) {
|
||||
if (i > 0) {
|
||||
builder.append(", ");
|
||||
}
|
||||
builder.append(paths.get(i));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String joinResolvedPaths(List<Path> paths) {
|
||||
if (paths == null || paths.isEmpty()) {
|
||||
return "(empty)";
|
||||
}
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < paths.size(); i++) {
|
||||
if (i > 0) {
|
||||
builder.append(", ");
|
||||
}
|
||||
builder.append(paths.get(i).toString());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String buildPrompt(String markdownContent, String period) {
|
||||
return "你是项目管理助手,请根据以下 SVN Markdown 日志生成工作量统计 JSON。\n"
|
||||
+ "工作周期: " + period + "\n"
|
||||
+ "要求:仅输出 JSON,不要输出额外文字。\n"
|
||||
+ "固定字段要求:\n"
|
||||
+ "1. team 固定为 \"系统部\"\n"
|
||||
+ "2. contact 固定为 \"杨志强\\n(系统平台组)\"\n"
|
||||
+ "3. developer 固定为 \"刘靖\"\n"
|
||||
+ "4. records 固定仅 1 条,sequence 固定为 1\n"
|
||||
+ "5. project 固定为 \"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控\"\n"
|
||||
+ "6. content 必须按下面三段输出,且每段使用数字编号,不要包含 SVN 地址/仓库路径/版本范围/提交总数等元信息:\n"
|
||||
+ "PRS-7050场站智慧管控\\n1. xxx\\n2. xxx\\n\\n"
|
||||
+ "PRS-7950在线巡视\\n1. xxx\\n2. xxx\\n\\n"
|
||||
+ "PRS-7950在线巡视电科院测试版\\n1. xxx\\n2. xxx\n"
|
||||
+ "JSON结构:\n"
|
||||
+ "{\n"
|
||||
+ " \"team\": \"所属班组\",\n"
|
||||
+ " \"contact\": \"技术对接人\",\n"
|
||||
+ " \"developer\": \"开发人员\",\n"
|
||||
+ " \"team\": \"系统部\",\n"
|
||||
+ " \"contact\": \"杨志强\\n(系统平台组)\",\n"
|
||||
+ " \"developer\": \"刘靖\",\n"
|
||||
+ " \"period\": \"" + period + "\",\n"
|
||||
+ " \"records\": [\n"
|
||||
+ " {\"sequence\":1,\"project\":\"项目A/项目B\",\"content\":\"# 项目A\\n1.xxx\\n2.xxx\"}\n"
|
||||
+ " {\"sequence\":1,\"project\":\"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控\",\"content\":\"PRS-7050场站智慧管控\\n1.xxx\\n2.xxx\\n\\nPRS-7950在线巡视\\n1.xxx\\n2.xxx\\n\\nPRS-7950在线巡视电科院测试版\\n1.xxx\"}\n"
|
||||
+ " ]\n"
|
||||
+ "}\n\n"
|
||||
+ "日志内容:\n" + markdownContent;
|
||||
}
|
||||
|
||||
private String callDeepSeek(String apiKey, String prompt) throws IOException {
|
||||
private String callDeepSeek(String apiKey, String prompt, TaskContext context) throws IOException {
|
||||
try {
|
||||
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt), 3, 1000L);
|
||||
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt, context), 3, 1000L);
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
@@ -161,7 +248,7 @@ public class AiWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private String callDeepSeekOnce(String apiKey, String prompt) throws Exception {
|
||||
private String callDeepSeekOnce(String apiKey, String prompt, TaskContext context) throws Exception {
|
||||
final JsonObject message = new JsonObject();
|
||||
message.addProperty("role", "user");
|
||||
message.addProperty("content", prompt);
|
||||
@@ -173,7 +260,13 @@ public class AiWorkflowService {
|
||||
body.addProperty("model", "deepseek-reasoner");
|
||||
body.add("messages", messages);
|
||||
body.addProperty("max_tokens", 3500);
|
||||
body.addProperty("stream", false);
|
||||
body.addProperty("stream", true);
|
||||
final JsonObject responseFormat = new JsonObject();
|
||||
responseFormat.addProperty("type", "json_object");
|
||||
body.add("response_format", responseFormat);
|
||||
final JsonObject streamOptions = new JsonObject();
|
||||
streamOptions.addProperty("include_usage", true);
|
||||
body.add("stream_options", streamOptions);
|
||||
|
||||
final Request request = new Request.Builder()
|
||||
.url(DEEPSEEK_API_URL)
|
||||
@@ -198,19 +291,96 @@ public class AiWorkflowService {
|
||||
throw new RetrySupport.RetryableException("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 StringBuilder answerBuilder = new StringBuilder();
|
||||
final okhttp3.ResponseBody responseBody = response.body();
|
||||
final okio.BufferedSource source = responseBody.source();
|
||||
String finishReason = "";
|
||||
|
||||
context.emitEvent("phase", buildEventPayload("正在流式接收 DeepSeek 输出"));
|
||||
while (!source.exhausted()) {
|
||||
final String line = source.readUtf8Line();
|
||||
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String dataLine = line.substring(5).trim();
|
||||
if ("[DONE]".equals(dataLine)) {
|
||||
break;
|
||||
}
|
||||
|
||||
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
|
||||
if (data.has("usage") && data.get("usage").isJsonObject()) {
|
||||
final JsonObject usage = data.getAsJsonObject("usage");
|
||||
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
|
||||
usagePayload.put("promptTokens", optLong(usage, "prompt_tokens"));
|
||||
usagePayload.put("completionTokens", optLong(usage, "completion_tokens"));
|
||||
usagePayload.put("totalTokens", optLong(usage, "total_tokens"));
|
||||
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
|
||||
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
|
||||
context.emitEvent("usage", usagePayload);
|
||||
}
|
||||
|
||||
final JsonArray choices = data.getAsJsonArray("choices");
|
||||
if (choices == null || choices.size() == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final JsonObject first = choices.get(0).getAsJsonObject();
|
||||
if (first.has("delta") && first.get("delta").isJsonObject()) {
|
||||
final JsonObject delta = first.getAsJsonObject("delta");
|
||||
|
||||
final String reasoning = optString(delta, "reasoning_content");
|
||||
if (reasoning != null && !reasoning.isEmpty()) {
|
||||
context.emitEvent("reasoning_delta", buildTextPayload(reasoning));
|
||||
}
|
||||
|
||||
final String answer = optString(delta, "content");
|
||||
if (answer != null && !answer.isEmpty()) {
|
||||
answerBuilder.append(answer);
|
||||
context.emitEvent("answer_delta", buildTextPayload(answer));
|
||||
}
|
||||
}
|
||||
|
||||
if (first.has("finish_reason") && !first.get("finish_reason").isJsonNull()) {
|
||||
finishReason = first.get("finish_reason").getAsString();
|
||||
}
|
||||
}
|
||||
|
||||
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 字段");
|
||||
if ("length".equalsIgnoreCase(finishReason)) {
|
||||
throw new IllegalStateException("DeepSeek 输出被截断(finish_reason=length),请增大 max_tokens 或缩短输入");
|
||||
}
|
||||
return messageObj.get("content").getAsString();
|
||||
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
|
||||
throw new RetrySupport.RetryableException("DeepSeek 资源不足,请稍后重试");
|
||||
}
|
||||
|
||||
final String answer = answerBuilder.toString().trim();
|
||||
if (answer.isEmpty()) {
|
||||
throw new IllegalStateException("DeepSeek 未返回有效 content 内容");
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildTextPayload(String text) {
|
||||
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
|
||||
payload.put("text", text);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildEventPayload(String message) {
|
||||
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
|
||||
payload.put("message", message);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private Long optLong(JsonObject object, String key) {
|
||||
if (object == null || key == null || !object.has(key) || object.get(key).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(object.get(key).getAsLong());
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,10 +409,14 @@ public class AiWorkflowService {
|
||||
}
|
||||
|
||||
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;
|
||||
final String period = payload.has("period")
|
||||
? firstNonBlank(optString(payload, "period"), defaultPeriod)
|
||||
: defaultPeriod;
|
||||
final String team = DEFAULT_TEAM;
|
||||
final String contact = DEFAULT_CONTACT;
|
||||
final String developer = DEFAULT_DEVELOPER;
|
||||
final String project = FIXED_PROJECT_VALUE;
|
||||
final String content = buildContentFromPayload(payload);
|
||||
|
||||
try (Workbook workbook = new XSSFWorkbook()) {
|
||||
final Sheet sheet = workbook.createSheet("工作量统计");
|
||||
@@ -259,19 +433,15 @@ public class AiWorkflowService {
|
||||
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);
|
||||
}
|
||||
final Row row = sheet.createRow(1);
|
||||
row.setHeightInPoints(calculateRowHeight(content));
|
||||
createCell(row, 0, 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, project, textStyle);
|
||||
createCell(row, 6, content, contentStyle);
|
||||
|
||||
sheet.setColumnWidth(0, 2200);
|
||||
sheet.setColumnWidth(1, 4200);
|
||||
@@ -360,6 +530,164 @@ public class AiWorkflowService {
|
||||
return object.get(key).getAsString();
|
||||
}
|
||||
|
||||
private String buildContentFromPayload(JsonObject payload) {
|
||||
final Map<String, LinkedHashSet<String>> groupedItems = createGroupedItems();
|
||||
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
|
||||
for (JsonElement element : records) {
|
||||
if (element == null || !element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject record = element.getAsJsonObject();
|
||||
final String recordProject = optString(record, "project");
|
||||
final String recordContent = optString(record, "content");
|
||||
collectItems(groupedItems, recordProject, recordContent);
|
||||
}
|
||||
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
for (String project : FIXED_PROJECTS) {
|
||||
final LinkedHashSet<String> items = groupedItems.get(project);
|
||||
if (items == null || items.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n\n");
|
||||
}
|
||||
builder.append(project).append('\n');
|
||||
int index = 1;
|
||||
for (String item : items) {
|
||||
builder.append(index++).append(". ").append(item).append('\n');
|
||||
}
|
||||
}
|
||||
return builder.toString().trim();
|
||||
}
|
||||
|
||||
private Map<String, LinkedHashSet<String>> createGroupedItems() {
|
||||
final Map<String, LinkedHashSet<String>> groupedItems =
|
||||
new LinkedHashMap<String, LinkedHashSet<String>>();
|
||||
for (String project : FIXED_PROJECTS) {
|
||||
groupedItems.put(project, new LinkedHashSet<String>());
|
||||
}
|
||||
return groupedItems;
|
||||
}
|
||||
|
||||
private void collectItems(Map<String, LinkedHashSet<String>> groupedItems, String projectText, String content) {
|
||||
final String fallbackProject = normalizeProject(projectText);
|
||||
String currentProject = fallbackProject;
|
||||
final String[] lines = content == null ? new String[0] : content.split("\\r?\\n");
|
||||
for (String rawLine : lines) {
|
||||
final String line = rawLine == null ? "" : rawLine.trim();
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String headingProject = parseHeadingProject(line);
|
||||
if (headingProject != null) {
|
||||
currentProject = headingProject;
|
||||
continue;
|
||||
}
|
||||
|
||||
final String extracted = parseWorkItem(line);
|
||||
if (extracted == null || extracted.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject;
|
||||
groupedItems.get(targetProject).add(extracted);
|
||||
}
|
||||
}
|
||||
|
||||
private String parseHeadingProject(String line) {
|
||||
if (line.startsWith("#")) {
|
||||
final String stripped = line.replaceFirst("^#+\\s*", "");
|
||||
return normalizeProject(stripped);
|
||||
}
|
||||
return normalizeProject(line);
|
||||
}
|
||||
|
||||
private String parseWorkItem(String line) {
|
||||
if (isMetaLine(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Matcher matcher = NUMBERED_ITEM_PATTERN.matcher(line);
|
||||
if (matcher.matches()) {
|
||||
return cleanWorkItem(matcher.group(2));
|
||||
}
|
||||
|
||||
matcher = BULLET_ITEM_PATTERN.matcher(line);
|
||||
if (matcher.matches()) {
|
||||
return cleanWorkItem(matcher.group(1));
|
||||
}
|
||||
|
||||
matcher = REVISION_ITEM_PATTERN.matcher(line);
|
||||
if (matcher.matches()) {
|
||||
return cleanWorkItem(matcher.group(1));
|
||||
}
|
||||
|
||||
if (line.length() > 6 && !line.startsWith("=") && !line.startsWith("```")) {
|
||||
return cleanWorkItem(line);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isMetaLine(String line) {
|
||||
return line.startsWith("SVN")
|
||||
|| line.startsWith("仓库")
|
||||
|| line.startsWith("分支")
|
||||
|| line.startsWith("版本")
|
||||
|| line.startsWith("提交总数")
|
||||
|| line.startsWith("日志详情")
|
||||
|| line.startsWith("作者")
|
||||
|| line.startsWith("时间")
|
||||
|| line.startsWith("消息")
|
||||
|| line.startsWith("文件")
|
||||
|| line.startsWith("=== 文件:");
|
||||
}
|
||||
|
||||
private String cleanWorkItem(String item) {
|
||||
String cleaned = item == null ? "" : item.trim();
|
||||
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
|
||||
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
|
||||
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
|
||||
cleaned = cleaned.replaceAll("\\s+", " ");
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
private String normalizeProject(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
final String input = value.trim();
|
||||
if (input.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (input.contains("7050")) {
|
||||
return "PRS-7050场站智慧管控";
|
||||
}
|
||||
if (input.contains("电科院")) {
|
||||
return "PRS-7950在线巡视电科院测试版";
|
||||
}
|
||||
if (input.contains("7950")) {
|
||||
return "PRS-7950在线巡视";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private float calculateRowHeight(String content) {
|
||||
final String safeContent = content == null ? "" : content;
|
||||
final String[] lines = safeContent.split("\\r?\\n");
|
||||
final int visibleLines = Math.max(lines.length, 1);
|
||||
final float lineHeight = 19.0f;
|
||||
final float minHeight = 220.0f;
|
||||
return Math.max(minHeight, visibleLines * lineHeight);
|
||||
}
|
||||
|
||||
private String firstNonBlank(String preferred, String fallback) {
|
||||
if (preferred != null && !preferred.trim().isEmpty()) {
|
||||
return preferred.trim();
|
||||
}
|
||||
return fallback == null ? "" : fallback;
|
||||
}
|
||||
|
||||
private String sanitize(String value) {
|
||||
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_");
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ public class OutputFileService {
|
||||
final Path root = getOutputRoot();
|
||||
final Path resolved = root.resolve(relative).normalize();
|
||||
if (!resolved.startsWith(root)) {
|
||||
throw new IllegalArgumentException("非法文件路径");
|
||||
throw new IllegalArgumentException(
|
||||
"非法文件路径: relative=" + relative + ", resolved=" + resolved + ", outputRoot=" + root
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class SettingsService {
|
||||
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
||||
this.outputFileService = outputFileService;
|
||||
this.svnPresetService = svnPresetService;
|
||||
this.defaultSvnPresetId = svnPresetService.firstPresetId();
|
||||
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
|
||||
}
|
||||
|
||||
public Map<String, Object> getSettings() throws IOException {
|
||||
@@ -26,7 +26,7 @@ public class SettingsService {
|
||||
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("apiKeySource", detectApiKeySource(envKey));
|
||||
result.put("outputDir", outputFileService.getOutputRoot().toString());
|
||||
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
|
||||
return result;
|
||||
@@ -58,6 +58,16 @@ public class SettingsService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String detectApiKeySource(String envKey) {
|
||||
if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) {
|
||||
return "runtime";
|
||||
}
|
||||
if (envKey != null && !envKey.trim().isEmpty()) {
|
||||
return "env";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
public String getDefaultSvnPresetId() {
|
||||
if (svnPresetService.containsPresetId(defaultSvnPresetId)) {
|
||||
return defaultSvnPresetId;
|
||||
|
||||
@@ -2,41 +2,66 @@ package com.svnlog.web.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.svnlog.web.config.SvnPresetProperties;
|
||||
import com.svnlog.web.model.SvnPreset;
|
||||
import com.svnlog.web.model.SvnPresetSummary;
|
||||
|
||||
@Service
|
||||
public class SvnPresetService {
|
||||
|
||||
private final List<SvnPreset> presets;
|
||||
private final String configuredDefaultPresetId;
|
||||
|
||||
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"
|
||||
));
|
||||
public SvnPresetService(SvnPresetProperties properties) {
|
||||
final List<SvnPreset> source = properties.getPresets() == null
|
||||
? Collections.<SvnPreset>emptyList()
|
||||
: properties.getPresets();
|
||||
if (source.isEmpty()) {
|
||||
throw new IllegalStateException("SVN 预设未配置,请检查 application.properties 中的 svn.presets");
|
||||
}
|
||||
|
||||
final List<SvnPreset> list = new ArrayList<SvnPreset>();
|
||||
final Set<String> ids = new HashSet<String>();
|
||||
for (SvnPreset preset : source) {
|
||||
final String id = trim(preset.getId());
|
||||
final String name = trim(preset.getName());
|
||||
final String url = trim(preset.getUrl());
|
||||
if (id.isEmpty() || name.isEmpty() || url.isEmpty()) {
|
||||
throw new IllegalStateException("SVN 预设配置不完整,id/name/url 均不能为空");
|
||||
}
|
||||
if (!ids.add(id)) {
|
||||
throw new IllegalStateException("SVN 预设 id 重复: " + id);
|
||||
}
|
||||
list.add(new SvnPreset(id, name, url));
|
||||
}
|
||||
this.presets = Collections.unmodifiableList(list);
|
||||
|
||||
final String configured = trim(properties.getDefaultPresetId());
|
||||
if (!configured.isEmpty() && containsPresetId(configured)) {
|
||||
this.configuredDefaultPresetId = configured;
|
||||
} else {
|
||||
this.configuredDefaultPresetId = this.presets.get(0).getId();
|
||||
}
|
||||
}
|
||||
|
||||
public List<SvnPreset> listPresets() {
|
||||
return presets;
|
||||
}
|
||||
|
||||
public List<SvnPresetSummary> listPresetSummaries() {
|
||||
final List<SvnPresetSummary> summaries = new ArrayList<SvnPresetSummary>();
|
||||
for (SvnPreset preset : presets) {
|
||||
summaries.add(new SvnPresetSummary(preset.getId(), preset.getName()));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
public boolean containsPresetId(String presetId) {
|
||||
if (presetId == null || presetId.trim().isEmpty()) {
|
||||
return false;
|
||||
@@ -49,7 +74,25 @@ public class SvnPresetService {
|
||||
return false;
|
||||
}
|
||||
|
||||
public SvnPreset getById(String presetId) {
|
||||
final String id = trim(presetId);
|
||||
for (SvnPreset preset : presets) {
|
||||
if (id.equals(preset.getId())) {
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("无效的 SVN 预设ID: " + presetId);
|
||||
}
|
||||
|
||||
public String firstPresetId() {
|
||||
return presets.isEmpty() ? "" : presets.get(0).getId();
|
||||
}
|
||||
|
||||
public String configuredDefaultPresetId() {
|
||||
return configuredDefaultPresetId;
|
||||
}
|
||||
|
||||
private String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
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;
|
||||
@@ -12,29 +8,35 @@ 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.core.report.MarkdownReportWriter;
|
||||
import com.svnlog.core.svn.LogEntry;
|
||||
import com.svnlog.core.svn.SVNLogFetcher;
|
||||
import com.svnlog.web.dto.SvnConnectionRequest;
|
||||
import com.svnlog.web.dto.SvnFetchRequest;
|
||||
import com.svnlog.web.model.SvnPreset;
|
||||
import com.svnlog.web.model.TaskResult;
|
||||
|
||||
@Service
|
||||
public class SvnWorkflowService {
|
||||
|
||||
private final OutputFileService outputFileService;
|
||||
private final SvnPresetService svnPresetService;
|
||||
|
||||
public SvnWorkflowService(OutputFileService outputFileService) {
|
||||
public SvnWorkflowService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
||||
this.outputFileService = outputFileService;
|
||||
this.svnPresetService = svnPresetService;
|
||||
}
|
||||
|
||||
public void testConnection(SvnConnectionRequest request) throws SVNException {
|
||||
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
|
||||
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.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());
|
||||
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
|
||||
context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName());
|
||||
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword());
|
||||
fetcher.testConnection();
|
||||
|
||||
context.setProgress(30, "正在拉取 SVN 日志");
|
||||
@@ -50,13 +52,25 @@ public class SvnWorkflowService {
|
||||
context.setProgress(70, "正在生成 Markdown 文件");
|
||||
final String projectName = request.getProjectName() != null && !request.getProjectName().trim().isEmpty()
|
||||
? request.getProjectName().trim()
|
||||
: "custom";
|
||||
: preset.getName();
|
||||
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);
|
||||
MarkdownReportWriter.write(
|
||||
outputPath,
|
||||
preset.getUrl(),
|
||||
request.getUsername(),
|
||||
start,
|
||||
end,
|
||||
request.getFilterUser(),
|
||||
logs,
|
||||
fetcher,
|
||||
new MarkdownReportWriter.Options()
|
||||
.includeAccount(true)
|
||||
.includeGeneratedTime(true)
|
||||
.includeStatistics(true)
|
||||
);
|
||||
|
||||
context.setProgress(100, "SVN 日志导出完成");
|
||||
final TaskResult result = new TaskResult("成功导出 " + logs.size() + " 条日志");
|
||||
@@ -64,39 +78,6 @@ public class SvnWorkflowService {
|
||||
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_-]", "_");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
|
||||
public class TaskContext {
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EventPublisher {
|
||||
void publish(String eventName, Map<String, Object> payload);
|
||||
}
|
||||
|
||||
private final TaskInfo taskInfo;
|
||||
private final Runnable onUpdate;
|
||||
private final EventPublisher eventPublisher;
|
||||
|
||||
public TaskContext(TaskInfo taskInfo, Runnable onUpdate) {
|
||||
public TaskContext(TaskInfo taskInfo, Runnable onUpdate, EventPublisher eventPublisher) {
|
||||
this.taskInfo = taskInfo;
|
||||
this.onUpdate = onUpdate;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
public void setProgress(int progress, String message) {
|
||||
@@ -20,5 +30,17 @@ public class TaskContext {
|
||||
if (onUpdate != null) {
|
||||
onUpdate.run();
|
||||
}
|
||||
final Map<String, Object> payload = new HashMap<String, Object>();
|
||||
payload.put("taskId", taskInfo.getTaskId());
|
||||
payload.put("status", taskInfo.getStatus() == null ? "" : taskInfo.getStatus().name());
|
||||
payload.put("progress", taskInfo.getProgress());
|
||||
payload.put("message", taskInfo.getMessage());
|
||||
emitEvent("phase", payload);
|
||||
}
|
||||
|
||||
public void emitEvent(String eventName, Map<String, Object> payload) {
|
||||
if (eventPublisher != null && eventName != null && !eventName.trim().isEmpty()) {
|
||||
eventPublisher.publish(eventName, payload == null ? new HashMap<String, Object>() : payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.svnlog.web.service;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
@@ -17,6 +19,7 @@ import java.util.concurrent.Future;
|
||||
import javax.annotation.PreDestroy;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import com.svnlog.web.model.TaskInfo;
|
||||
import com.svnlog.web.model.TaskPageResult;
|
||||
@@ -33,6 +36,8 @@ 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 Map<String, CopyOnWriteArrayList<SseEmitter>> taskEmitters =
|
||||
new ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>>();
|
||||
private final TaskPersistenceService persistenceService;
|
||||
private final OutputFileService outputFileService;
|
||||
|
||||
@@ -56,6 +61,7 @@ public class TaskService {
|
||||
taskInfo.setUpdatedAt(now);
|
||||
tasks.put(taskId, taskInfo);
|
||||
persistSafely();
|
||||
publishTaskEvent(taskId, "phase", buildPhasePayload(taskInfo));
|
||||
|
||||
Future<?> future = executor.submit(new Callable<Void>() {
|
||||
@Override
|
||||
@@ -123,12 +129,44 @@ public class TaskService {
|
||||
task.setMessage("任务已取消");
|
||||
task.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
publishTaskEvent(taskId, "error", buildTerminalPayload(task, task.getMessage()));
|
||||
completeTaskStream(taskId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public SseEmitter subscribeTaskStream(String taskId) {
|
||||
final TaskInfo task = tasks.get(taskId);
|
||||
if (task == null) {
|
||||
throw new IllegalArgumentException("任务不存在: " + taskId);
|
||||
}
|
||||
|
||||
final SseEmitter emitter = new SseEmitter(0L);
|
||||
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.computeIfAbsent(
|
||||
taskId, key -> new CopyOnWriteArrayList<SseEmitter>());
|
||||
emitters.add(emitter);
|
||||
|
||||
emitter.onCompletion(() -> removeEmitter(taskId, emitter));
|
||||
emitter.onTimeout(() -> removeEmitter(taskId, emitter));
|
||||
emitter.onError(error -> removeEmitter(taskId, emitter));
|
||||
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("phase").data(buildPhasePayload(task)));
|
||||
} catch (Exception sendException) {
|
||||
removeEmitter(taskId, emitter);
|
||||
}
|
||||
|
||||
if (isTerminal(task.getStatus())) {
|
||||
final String eventName = task.getStatus() == TaskStatus.SUCCESS ? "done" : "error";
|
||||
publishTaskEvent(taskId, eventName, buildTerminalPayload(task, task.getError()));
|
||||
completeTaskStream(taskId);
|
||||
}
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private void runTaskInternal(TaskInfo taskInfo, TaskRunner runner) {
|
||||
try {
|
||||
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
||||
completeTaskStream(taskInfo.getTaskId());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,8 +174,13 @@ public class TaskService {
|
||||
taskInfo.setMessage("任务执行中");
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
publishTaskEvent(taskInfo.getTaskId(), "phase", buildPhasePayload(taskInfo));
|
||||
|
||||
final TaskContext context = new TaskContext(taskInfo, this::persistSafely);
|
||||
final TaskContext context = new TaskContext(
|
||||
taskInfo,
|
||||
this::persistSafely,
|
||||
(eventName, payload) -> publishTaskEvent(taskInfo.getTaskId(), eventName, payload)
|
||||
);
|
||||
final TaskResult result = runner.run(context);
|
||||
taskInfo.setStatus(TaskStatus.SUCCESS);
|
||||
taskInfo.setProgress(100);
|
||||
@@ -146,24 +189,29 @@ public class TaskService {
|
||||
if (result != null && result.getFiles() != null) {
|
||||
taskInfo.getFiles().addAll(result.getFiles());
|
||||
}
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
publishTaskEvent(taskInfo.getTaskId(), "done", buildTerminalPayload(taskInfo, ""));
|
||||
} catch (Exception e) {
|
||||
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, "任务已取消"));
|
||||
completeTaskStream(taskInfo.getTaskId());
|
||||
return;
|
||||
}
|
||||
taskInfo.setStatus(TaskStatus.FAILED);
|
||||
taskInfo.setError(e.getMessage());
|
||||
taskInfo.setError(buildErrorMessage(e));
|
||||
taskInfo.setMessage("执行失败");
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, taskInfo.getError()));
|
||||
completeTaskStream(taskInfo.getTaskId());
|
||||
return;
|
||||
} finally {
|
||||
futures.remove(taskInfo.getTaskId());
|
||||
}
|
||||
|
||||
taskInfo.setUpdatedAt(Instant.now());
|
||||
persistSafely();
|
||||
completeTaskStream(taskInfo.getTaskId());
|
||||
}
|
||||
|
||||
private void loadPersistedTasks() {
|
||||
@@ -226,6 +274,95 @@ public class TaskService {
|
||||
return value != null && value.toLowerCase(Locale.ROOT).contains(keyword);
|
||||
}
|
||||
|
||||
private String buildErrorMessage(Throwable throwable) {
|
||||
if (throwable == null) {
|
||||
return "未知异常";
|
||||
}
|
||||
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
Throwable current = throwable;
|
||||
int depth = 0;
|
||||
while (current != null && depth < 5) {
|
||||
if (depth > 0) {
|
||||
builder.append(" | caused by: ");
|
||||
}
|
||||
final String className = current.getClass().getSimpleName();
|
||||
final String message = current.getMessage() != null ? current.getMessage() : "(no message)";
|
||||
builder.append(className).append(": ").append(message);
|
||||
current = current.getCause();
|
||||
depth++;
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public void publishTaskEvent(String taskId, String eventName, Map<String, Object> payload) {
|
||||
if (taskId == null || eventName == null || eventName.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.get(taskId);
|
||||
if (emitters == null || emitters.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (SseEmitter emitter : emitters) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(eventName).data(payload == null ? new HashMap<String, Object>() : payload));
|
||||
} catch (Exception sendException) {
|
||||
removeEmitter(taskId, emitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void completeTaskStream(String taskId) {
|
||||
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.remove(taskId);
|
||||
if (emitters == null) {
|
||||
return;
|
||||
}
|
||||
for (SseEmitter emitter : emitters) {
|
||||
try {
|
||||
emitter.complete();
|
||||
} catch (Exception ignored) {
|
||||
// ignore completion failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeEmitter(String taskId, SseEmitter emitter) {
|
||||
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.get(taskId);
|
||||
if (emitters == null) {
|
||||
return;
|
||||
}
|
||||
emitters.remove(emitter);
|
||||
if (emitters.isEmpty()) {
|
||||
taskEmitters.remove(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildPhasePayload(TaskInfo taskInfo) {
|
||||
final Map<String, Object> payload = new HashMap<String, Object>();
|
||||
payload.put("taskId", taskInfo.getTaskId());
|
||||
payload.put("type", taskInfo.getType());
|
||||
payload.put("status", taskInfo.getStatus() == null ? "" : taskInfo.getStatus().name());
|
||||
payload.put("progress", taskInfo.getProgress());
|
||||
payload.put("message", taskInfo.getMessage());
|
||||
payload.put("updatedAt", taskInfo.getUpdatedAt() == null ? "" : taskInfo.getUpdatedAt().toString());
|
||||
return payload;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildTerminalPayload(TaskInfo taskInfo, String detail) {
|
||||
final Map<String, Object> payload = buildPhasePayload(taskInfo);
|
||||
payload.put("files", new ArrayList<String>(taskInfo.getFiles()));
|
||||
if (detail != null && !detail.trim().isEmpty()) {
|
||||
payload.put("detail", detail);
|
||||
}
|
||||
payload.put("error", taskInfo.getError());
|
||||
return payload;
|
||||
}
|
||||
|
||||
private boolean isTerminal(TaskStatus status) {
|
||||
return status == TaskStatus.SUCCESS || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
executor.shutdownNow();
|
||||
|
||||
Reference in New Issue
Block a user