From 2150dfe24ea2cb454e8259716f2eead4f7f59df9 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Fri, 3 Apr 2026 15:40:31 +0800 Subject: [PATCH] feat(web): unify web entry, preset config, SSE streaming and dual-pane live logs --- .gitignore | 5 + .idea/.gitignore | 8 - .idea/compiler.xml | 13 - .idea/encodings.xml | 9 - .idea/jarRepositories.xml | 25 - .idea/misc.xml | 27 - .idea/vcs.xml | 6 - .idea/日志.iml | 9 - AGENTS.md | 21 +- README.md | 28 + docs/202601工作量统计_刘靖.xlsx | Bin 14029 -> 0 bytes docs/README_DeepSeek.md | 131 +--- docs/README_Migration.md | 16 + docs/README_Web.md | 11 +- pom.xml | 4 +- .../java/com/svnlog/DeepSeekLogProcessor.java | 605 ------------------ src/main/java/com/svnlog/ExcelAnalyzer.java | 84 --- src/main/java/com/svnlog/Main.java | 215 ------- src/main/java/com/svnlog/SVNLogFetcher.java | 98 --- src/main/java/com/svnlog/WebApplication.java | 12 - .../core/report/MarkdownReportWriter.java | 101 +++ .../com/svnlog/{ => core/svn}/LogEntry.java | 2 +- .../com/svnlog/core/svn/SVNLogFetcher.java | 245 +++++++ .../svnlog/core/svn/TrustAllSSLContext.java | 39 ++ .../java/com/svnlog/web/WebApplication.java | 71 ++ .../web/config/SvnPresetProperties.java | 33 + .../svnlog/web/controller/AppController.java | 35 +- .../svnlog/web/dto/SvnConnectionRequest.java | 10 +- .../com/svnlog/web/dto/SvnFetchRequest.java | 22 +- .../web/dto/SvnVersionRangeRequest.java | 62 ++ .../svnlog/web/model/SvnPresetSummary.java | 31 + .../svnlog/web/service/AiWorkflowService.java | 408 ++++++++++-- .../svnlog/web/service/OutputFileService.java | 4 +- .../svnlog/web/service/SettingsService.java | 14 +- .../svnlog/web/service/SvnPresetService.java | 77 ++- .../web/service/SvnWorkflowService.java | 73 +-- .../com/svnlog/web/service/TaskContext.java | 24 +- .../com/svnlog/web/service/TaskService.java | 147 ++++- src/main/resources/application.properties | 23 + src/main/resources/static/app.js | 552 ++++++++++++---- src/main/resources/static/index.html | 105 ++- src/main/resources/static/styles.css | 45 ++ 42 files changed, 1917 insertions(+), 1533 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/encodings.xml delete mode 100644 .idea/jarRepositories.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/日志.iml create mode 100644 README.md delete mode 100644 docs/202601工作量统计_刘靖.xlsx create mode 100644 docs/README_Migration.md delete mode 100644 src/main/java/com/svnlog/DeepSeekLogProcessor.java delete mode 100644 src/main/java/com/svnlog/ExcelAnalyzer.java delete mode 100644 src/main/java/com/svnlog/Main.java delete mode 100644 src/main/java/com/svnlog/SVNLogFetcher.java delete mode 100644 src/main/java/com/svnlog/WebApplication.java create mode 100644 src/main/java/com/svnlog/core/report/MarkdownReportWriter.java rename src/main/java/com/svnlog/{ => core/svn}/LogEntry.java (98%) create mode 100644 src/main/java/com/svnlog/core/svn/SVNLogFetcher.java create mode 100644 src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java create mode 100644 src/main/java/com/svnlog/web/WebApplication.java create mode 100644 src/main/java/com/svnlog/web/config/SvnPresetProperties.java create mode 100644 src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java create mode 100644 src/main/java/com/svnlog/web/model/SvnPresetSummary.java create mode 100644 src/main/resources/application.properties diff --git a/.gitignore b/.gitignore index c7c411a..c06c52a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ buildNumber.properties *.iml *.ipr +# Agent / local assistant artifacts +.claude/ +.codex + # Compiled class files *.class @@ -34,6 +38,7 @@ buildNumber.properties # Generated files md/ *.xlsx +outputs/ # OS generated files .DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 35410ca..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 默认忽略的文件 -/shelf/ -/workspace.xml -# 基于编辑器的 HTTP 客户端请求 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 9120440..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index c27c291..0000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index 357a9f3..0000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 17e4782..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - 用户定义 - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/日志.iml b/.idea/日志.iml deleted file mode 100644 index d6ebd48..0000000 --- a/.idea/日志.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 02918ba..21d23d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,11 @@ ## 1. 项目概览 - 语言与构建:Java 8 + Maven(`pom.xml`)。 - 打包产物:可执行 fat jar(`jar-with-dependencies`)。 -- 主入口:`com.svnlog.Main`(CLI)。 -- Web 入口:`com.svnlog.WebApplication`(前后端一体,静态页面 + REST API)。 -- 其他入口:`com.svnlog.DeepSeekLogProcessor`、`com.svnlog.ExcelAnalyzer`。 +- 统一入口:`com.svnlog.web.WebApplication`(前后端一体,静态页面 + REST API)。 - 核心目录: - `src/main/java/com/svnlog/` - `docs/` + - SVN 预设地址:`src/main/resources/application.properties`(`svn.presets[*]`) ## 2. 常用命令(Build / Lint / Test / Run) 以下命令默认在仓库根目录执行。 @@ -36,29 +35,19 @@ - 说明:当前 `src/test/java` 为空;新增测试时采用 Surefire 默认约定。 ### 2.4 Run -- 运行主程序(SVN 日志抓取): - - `java -jar target/svn-log-tool-1.0.0-jar-with-dependencies.jar` - 运行 Web 工作台(推荐): - - `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.WebApplication` + - `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication` - 启动后访问:`http://localhost:8080` -- 运行 DeepSeek 处理器: - - `java -cp target/svn-log-tool-1.0.0-jar-with-dependencies.jar com.svnlog.DeepSeekLogProcessor` -- Maven 方式运行 DeepSeek: - - `mvn exec:java -Dexec.mainClass="com.svnlog.DeepSeekLogProcessor"` ## 3. 代码结构与职责边界 -- `Main.java`:CLI 交互、读取输入、调用 `SVNLogFetcher`、输出 Markdown。 - `SVNLogFetcher.java`:SVN 连接、版本区间处理、日志抓取、用户过滤。 - `LogEntry.java`:日志数据模型(POJO)。 -- `DeepSeekLogProcessor.java`:读取 Markdown、调用 DeepSeek API、生成 Excel。 -- `ExcelAnalyzer.java`:本地临时分析工具,偏实验性质。 - `web/controller/*`:REST API(SVN、AI、任务、文件、设置)。 - `web/service/*`:异步任务与业务编排(SVN 抓取、AI 分析、输出目录管理)。 - `src/main/resources/static/*`:Web 前端页面与交互脚本。 - 变更原则: - 抓取逻辑改在 `SVNLogFetcher`。 - - 交互逻辑改在 `Main`。 - - AI/Excel 逻辑改在 `DeepSeekLogProcessor`。 + - AI/Excel 逻辑改在 `web/service/AiWorkflowService`。 - 不把多种职责混入同一方法。 ## 4. 代码风格规范(必读) @@ -112,7 +101,7 @@ ## 5. 安全与敏感信息 - 严禁提交真实密钥、口令、Token、内网敏感地址。 -- `DeepSeekLogProcessor` 存在硬编码 API Key 风险;新增改动时应: +- Web 端 AI 分析涉及 API Key;新增改动时应: - 优先从环境变量读取(如 `DEEPSEEK_API_KEY`)。 - 回退到交互输入。 - 不把真实值写入源码或日志。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..79c511b --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# svn-log-tool + +SVN 日志抓取与 AI 工作量分析工具,统一使用 Web 工作台入口。 + +## 入口 + +- `com.svnlog.web.WebApplication` + +## 常用命令 + +```bash +# 编译 +mvn clean compile + +# 打包 +mvn clean package -DskipTests + +# 启动 Web +mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication +``` + +## 代码结构 + +- `com.svnlog.web`:Web 入口、控制器、DTO、服务 +- `com.svnlog.core.svn`:SVN 连接、日志抓取模型 +- `com.svnlog.core.report`:Markdown 报告输出能力 + +更多运行和功能说明见 `docs/`。 diff --git a/docs/202601工作量统计_刘靖.xlsx b/docs/202601工作量统计_刘靖.xlsx deleted file mode 100644 index 662d28bfbb119ba4e70211037b4cb7a30d826da6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14029 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 z zEs<|>L|zqE(81?^5j((I_o0tI@#vfi z>;Q7eA7-a5f9VtqSpG7!+onA=EjxmvJju<+d3#{!&n~8c>4iTm#&Vd9)O5|}4SS!R zIO#V{pgj}}^&hkI-efINYbyt1D+k?It~SQ@+V>_ms(c6aVARGldU9RGdO4*nWWaaHOgV9aM)uu2^i&nA-pY zWRclLDtf^}Cr^^q=`K4JhS)^URl6#hOD0rRtuW{%bkfcT+bdi_eha|X+8behgGZiB zs4KP2d`hpbZ3QohMIUlikG7BPB$@(9s%YmgHTu!Mw;`jehhpP=2mGlbf;ADR{wwHF%-QbD?% zTjk2~Hg$wNsMPp6c{SRebf*qWww;s5&qXu@0F`A=3p}cy)_)o9qzoAB>}@U_iIL*2$NtBTCh6%h5v6SEdM^s zpSL9U&!L`14f(?b{Qara-)H!9x4yr1c^aJz|8^tu_rO2*i~G&|Y3wro+3Fs*_P>Y! z*){Jsm8X#fI)D1RQ$B7ne~ `com.svnlog.core.svn.SVNLogFetcher` +- `com.svnlog.LogEntry` -> `com.svnlog.core.svn.LogEntry` +- `com.svnlog.TrustAllSSLContext` -> `com.svnlog.core.svn.TrustAllSSLContext` + +## 公共能力 + +- `com.svnlog.core.report.MarkdownReportWriter`:统一 Markdown 输出逻辑。 diff --git a/docs/README_Web.md b/docs/README_Web.md index 73eea84..3f8857f 100644 --- a/docs/README_Web.md +++ b/docs/README_Web.md @@ -11,12 +11,14 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持 5. 下载输出文件、配置 API Key 与输出目录 6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计) +批量抓取策略:多个项目按顺序执行(前一个项目完成后才开始下一个)。 + ## 启动方式 在仓库根目录执行: ```bash -mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.WebApplication +mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication ``` 启动后访问: @@ -48,6 +50,12 @@ http://localhost:8080 建议在生产环境优先使用环境变量,避免敏感信息暴露。 +## SVN 预设来源与调用方式 + +- SVN 地址统一维护在 `application.properties` 的 `svn.presets[*]` 中。 +- 前端不再传 SVN URL,业务接口统一传 `presetId`,后端按 `presetId` 解析地址。 +- `GET /api/svn/presets` 仅返回 `id` 与 `name`(不返回 `url`)。 + ## 主要 API - `POST /api/svn/test-connection` @@ -57,6 +65,7 @@ http://localhost:8080 - `GET /api/tasks` - `GET /api/tasks/query?status=&type=&keyword=&page=1&size=10` - `GET /api/tasks/{taskId}` +- `GET /api/tasks/{taskId}/stream`(SSE 实时输出) - `POST /api/tasks/{taskId}/cancel` - `GET /api/health` - `GET /api/health/details` diff --git a/pom.xml b/pom.xml index bccdada..ed808dd 100644 --- a/pom.xml +++ b/pom.xml @@ -95,7 +95,7 @@ - com.svnlog.Main + com.svnlog.web.WebApplication @@ -111,7 +111,7 @@ - com.svnlog.Main + com.svnlog.web.WebApplication diff --git a/src/main/java/com/svnlog/DeepSeekLogProcessor.java b/src/main/java/com/svnlog/DeepSeekLogProcessor.java deleted file mode 100644 index 866bf27..0000000 --- a/src/main/java/com/svnlog/DeepSeekLogProcessor.java +++ /dev/null @@ -1,605 +0,0 @@ -package com.svnlog; - -import okhttp3.*; -import org.apache.poi.ss.usermodel.*; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; - -import java.io.*; -import java.nio.file.Files; -import java.text.SimpleDateFormat; -import java.util.*; - -/** - * 使用DeepSeek API处理SVN日志并生成工作量统计Excel - */ -public class DeepSeekLogProcessor { - private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions"; - private static final String API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7"; // 用户需要替换为实际的API Key - private static final OkHttpClient client = new OkHttpClient.Builder() - .connectTimeout(60, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(300, java.util.concurrent.TimeUnit.SECONDS) // 5分钟读取超时 - .writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS) - .build(); - - public static void main(String[] args) { - try { - Scanner scanner = new Scanner(System.in); - System.out.println("==========================================="); - System.out.println(" SVN日志工作量统计工具(DeepSeek版)"); - System.out.println(" 支持多项目汇总分析"); - System.out.println("==========================================="); - System.out.println(); - - // 读取markdown日志文件目录 - System.out.print("请输入markdown日志文件所在目录路径 (回车使用当前目录): "); - String dirPath = scanner.nextLine().trim(); - - File dir; - if (dirPath.isEmpty()) { - dir = new File("."); - } else { - dir = new File(dirPath); - } - - if (!dir.exists() || !dir.isDirectory()) { - System.err.println("错误: 目录不存在或不是有效目录!"); - return; - } - - // 扫描目录中的所有 .md 文件 - File[] mdFiles = dir.listFiles((d, name) -> name.endsWith(".md")); - if (mdFiles == null || mdFiles.length == 0) { - System.err.println("错误: 目录中未找到任何 .md 文件!"); - return; - } - - System.out.println("找到 " + mdFiles.length + " 个日志文件:"); - for (File file : mdFiles) { - System.out.println(" - " + file.getName()); - } - System.out.println(); - - // 输入工作周期 - SimpleDateFormat periodSdf = new SimpleDateFormat("yyyy年MM月"); - String defaultPeriod = periodSdf.format(new Date()); - System.out.print("请输入工作周期 (例如: 2025年12月,回车使用默认: " + defaultPeriod + "): "); - String period = scanner.nextLine().trim(); - - if (period.isEmpty()) { - period = defaultPeriod; - System.out.println("使用默认工作周期: " + period); - } - - // 读取并合并所有markdown文件 - String combinedContent = readAndCombineMarkdownFiles(mdFiles); - System.out.println("成功读取并合并 " + mdFiles.length + " 个日志文件,总长度: " + combinedContent.length() + " 字符"); - - // 提示API Key - System.out.print("请输入DeepSeek API Key (留空使用代码中预设的): "); - String inputApiKey = scanner.nextLine().trim(); - String apiKey = inputApiKey.isEmpty() ? API_KEY : inputApiKey; - - if (apiKey.equals("YOUR_DEEPSEEK_API_KEY")) { - System.err.println("错误: 请提供有效的DeepSeek API Key!"); - return; - } - - // 询问输出文件名 - SimpleDateFormat sdf = new SimpleDateFormat("yyyyMM"); - String defaultOutput = sdf.format(new Date()) + "工作量统计.xlsx"; - System.out.print("请输入输出Excel文件名 (回车使用默认: " + defaultOutput + "): "); - String outputPath = scanner.nextLine().trim(); - if (outputPath.isEmpty()) { - outputPath = defaultOutput; - } - - System.out.println(); - System.out.println("正在调用DeepSeek API分析日志..."); - System.out.println("(使用 deepseek-reasoner 模型,推理阶段可能需要数十秒,请耐心等待)"); - System.out.println("--- 推理过程 ---"); - - // 调用DeepSeek API处理日志 - String prompt = buildPrompt(combinedContent, period); - String aiResponse = callDeepSeekAPI(apiKey, prompt); - - if (aiResponse == null) { - System.err.println("DeepSeek API调用失败!请检查网络连接和API Key。"); - return; - } - - if (aiResponse.isEmpty()) { - System.err.println("DeepSeek API返回空响应!请重试或联系技术支持。"); - return; - } - - System.out.println("DeepSeek分析完成,正在生成Excel..."); - - // 生成Excel - generateExcel(outputPath, aiResponse); - - System.out.println(); - System.out.println("Excel文件生成成功: " + outputPath); - System.out.println(); - - } catch (Exception e) { - System.err.println("发生错误: " + e.getMessage()); - e.printStackTrace(); - } - } - - /** - * 读取文件内容 - */ - private static String readFile(String path) throws IOException { - return new String(Files.readAllBytes(new File(path).toPath()), "UTF-8"); - } - - /** - * 读取并合并多个markdown文件的内容 - */ - private static String readAndCombineMarkdownFiles(File[] mdFiles) throws IOException { - StringBuilder combinedContent = new StringBuilder(); - - for (File file : mdFiles) { - String projectName = extractProjectName(file.getName()); - String content = readFile(file.getAbsolutePath()); - - combinedContent.append("\n\n"); - combinedContent.append("=== 项目: ").append(projectName).append(" ===\n"); - combinedContent.append(content); - } - - return combinedContent.toString(); - } - - /** - * 从文件名中提取项目名称 - * 例如: svn_log_PRS-7050场站智慧管控_20260130_093348.md -> PRS-7050场站智慧管控 - */ - private static String extractProjectName(String fileName) { - // 去掉 svn_log_ 前缀 - if (fileName.startsWith("svn_log_")) { - fileName = fileName.substring(8); - } - - // 去掉 .md 后缀 - if (fileName.endsWith(".md")) { - fileName = fileName.substring(0, fileName.length() - 3); - } - - // 去掉时间戳部分 (格式: _YYYYMMDD_HHMMSS) - int lastUnderscore = fileName.lastIndexOf('_'); - if (lastUnderscore > 0) { - // 检查是否是时间戳格式 - String timestampPart = fileName.substring(lastUnderscore + 1); - if (timestampPart.matches("\\d{8}_\\d{6}")) { - fileName = fileName.substring(0, lastUnderscore); - } - } - - return fileName; - } - - /** - * 构建发送给DeepSeek的提示词 - */ - private static String buildPrompt(String markdownContent, String period) { - return "你是一个专业的项目管理助手。请分析以下多个项目的SVN日志,并生成工作量统计数据。\n\n" + - "日志内容包含多个项目,每个项目之间用 === 项目: xxx === 标识。\n" + - "工作周期: " + period + "\n\n" + - "SVN日志内容:\n" + markdownContent + "\n\n" + - "请按照以下JSON格式返回工作量统计数据:\n" + - "{\n" + - " \"team\": \"所属班组\",\n" + - " \"contact\": \"技术对接人\",\n" + - " \"developer\": \"开发人员\",\n" + - " \"period\": \"" + period + "\",\n" + - " \"records\": [\n" + - " {\n" + - " \"sequence\": 1,\n" + - " \"project\": \"项目1/项目2/项目3\",\n" + - " \"content\": \"# 项目1\\n1.工作内容1\\n2.工作内容2\\n\\n# 项目2\\n1.工作内容1\\n2.工作内容2\\n\\n# 项目3\\n1.工作内容1\\n2.工作内容2\"\n" + - " }\n" + - " ]\n" + - "}\n\n" + - "重要要求:\n" + - "1. 根据日志作者确定开发人员\n" + - "2. 将所有项目的工作内容合并到一条记录中\n" + - "3. 项目名称字段(project):使用 / 分隔多个项目,例如:\"PRS7050场站系统/PRS7950智能巡视现场问题/PRS7950电科院测试\"\n" + - "4. 具体工作内容字段(content):使用 # 作为项目分类标识,格式为:\"# 项目名称\\n1.工作内容\\n2.工作内容\\n\\n# 下一个项目\\n1.工作内容\"\n" + - "5. 不同项目之间用空行分隔\n" + - "6. 只返回JSON,不要有其他文字\n" + - "7. 提取具体工作内容,要详细和有条理\n" + - "8. 项目名称要简洁明确,去掉多余的前缀和后缀"; - } - - /** - * 调用DeepSeek API(流式输出) - */ - private static String callDeepSeekAPI(String apiKey, String prompt) throws IOException { - JSONObject requestBody = new JSONObject(); - requestBody.put("model", "deepseek-reasoner"); - - // 创建消息对象,包含 role 和 content 字段 - JSONObject messageObj = new JSONObject(); - messageObj.put("role", "user"); - messageObj.put("content", prompt); - - // 创建消息数组 - com.google.gson.JsonArray messagesArray = new com.google.gson.JsonArray(); - messagesArray.add(messageObj.jsonObject); - requestBody.put("messages", messagesArray); - - requestBody.put("max_tokens", 4000); - requestBody.put("stream", true); // 启用流式输出 - - Request request = new Request.Builder() - .url(DEEPSEEK_API_URL) - .addHeader("Authorization", "Bearer " + apiKey) - .addHeader("Content-Type", "application/json") - .post(RequestBody.create(requestBody.toString(), MediaType.parse("application/json"))) - .build(); - - StringBuilder fullResponse = new StringBuilder(); - int chunkCount = 0; - - try (Response response = client.newCall(request).execute()) { - if (!response.isSuccessful()) { - System.err.println("API调用失败: " + response.code() + " " + response.message()); - String errorResponse = response.body().string(); - System.err.println("响应: " + errorResponse); - return null; - } - - // 读取流式响应 - try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream()))) { - String line; - while ((line = reader.readLine()) != null) { - if (line.startsWith("data: ")) { - String data = line.substring(6); - if (data.equals("[DONE]")) { - break; - } - - try { - JSONObject chunk = new JSONObject(data); - if (chunk.has("choices") && chunk.getJSONArray("choices").size() > 0) { - JSONObject choice = chunk.getJSONArray("choices").get(0); - if (choice.has("delta")) { - JSONObject delta = choice.getJSONObject("delta"); - // 打印思维链推理过程(不计入最终结果) - if (delta.has("reasoning_content")) { - String reasoning = delta.optString("reasoning_content", ""); - if (!reasoning.isEmpty()) { - System.out.print(reasoning); - System.out.flush(); - } - } - // 收集最终回答内容 - if (delta.has("content")) { - String content = delta.optString("content", ""); - if (!content.isEmpty()) { - if (chunkCount == 0) { - System.out.println("\n--- 最终结果 ---"); - } - fullResponse.append(content); - chunkCount++; - System.out.print(content); - System.out.flush(); - } - } - } - } - } catch (Exception e) { - // 忽略解析错误,继续处理下一行 - } - } - } - } - } catch (Exception e) { - System.err.println("API调用过程中发生异常: " + e.getMessage()); - e.printStackTrace(); - return null; - } - - System.out.println(); // 换行 - System.out.println("收到 " + chunkCount + " 个数据块"); - - if (fullResponse.length() == 0) { - System.err.println("警告: 未收到任何响应内容"); - } - - return fullResponse.toString(); - } - - /** - * 从响应中提取纯 JSON 内容 - */ - private static String extractJson(String response) { - String trimmed = response.trim(); - - // 去除 ```json 标记 - if (trimmed.startsWith("```json")) { - trimmed = trimmed.substring(7); - } else if (trimmed.startsWith("```")) { - trimmed = trimmed.substring(3); - } - - // 去除 ``` 结束标记 - if (trimmed.endsWith("```")) { - trimmed = trimmed.substring(0, trimmed.length() - 3); - } - - return trimmed.trim(); - } - -/** - * 生成Excel文件 - */ - private static void generateExcel(String outputPath, String jsonResponse) throws IOException { - // 提取纯 JSON 内容(去除 ```json 和 ``` 标记) - String cleanJson = extractJson(jsonResponse); - - // 解析JSON响应 - JSONObject data = new JSONObject(cleanJson); - - // 创建工作簿 - Workbook workbook = new XSSFWorkbook(); - Sheet sheet = workbook.createSheet("工作表1"); - - // 创建样式 - CellStyle headerStyle = createHeaderStyle(workbook); - CellStyle contentStyle = createContentStyle(workbook); - CellStyle workContentStyle = createWorkContentStyle(workbook); - - // 创建表头(7列,与参考文件一致) - Row headerRow = sheet.createRow(0); - headerRow.setHeightInPoints(14.25f); // 表头行高 - String[] headers = {"序号", "所属班组", "技术对接", "开发人员", "工作周期", "开发项目名称", "具体工作内容"}; - for (int i = 0; i < headers.length; i++) { - Cell cell = headerRow.createCell(i); - cell.setCellValue(headers[i]); - cell.setCellStyle(headerStyle); - } - - // 设置固定列宽(与参考文件一致) - sheet.setColumnWidth(0, 2048); // 序号:8.00字符 - sheet.setColumnWidth(1, 3328); // 所属班组:13.00字符 - sheet.setColumnWidth(2, 4608); // 技术对接:18.00字符 - sheet.setColumnWidth(3, 3840); // 开发人员:15.00字符 - sheet.setColumnWidth(4, 5888); // 工作周期:23.00字符 - sheet.setColumnWidth(5, 14080); // 开发项目名称:55.00字符 - sheet.setColumnWidth(6, 43991); // 具体工作内容:171.84字符 - - // 获取记录 - String team = data.optString("team", ""); - String contact = data.optString("contact", ""); - String developer = data.optString("developer", ""); - String period = data.optString("period", ""); - - if (data.has("records")) { - JSONArray recordsArray = data.getJSONArray("records"); - int rowNum = 1; - - for (int i = 0; i < recordsArray.size(); i++) { - JSONObject record = recordsArray.get(i); - Row row = sheet.createRow(rowNum++); - row.setHeightInPoints(16.50f); // 内容行高 - - // 序号 - Cell cell0 = row.createCell(0); - cell0.setCellValue(record.optDouble("sequence", i + 1)); - cell0.setCellStyle(contentStyle); - - // 所属班组 - Cell cell1 = row.createCell(1); - cell1.setCellValue(team); - cell1.setCellStyle(contentStyle); - - // 技术对接 - Cell cell2 = row.createCell(2); - cell2.setCellValue(contact); - cell2.setCellStyle(contentStyle); - - // 开发人员 - Cell cell3 = row.createCell(3); - cell3.setCellValue(developer); - cell3.setCellStyle(contentStyle); - - // 工作周期 - Cell cell4 = row.createCell(4); - cell4.setCellValue(period); - cell4.setCellStyle(contentStyle); - - // 项目名称(多个项目用 / 分隔) - Cell cell5 = row.createCell(5); - cell5.setCellValue(record.optString("project", "")); - cell5.setCellStyle(contentStyle); - - // 工作内容(支持换行,用 # 标识不同项目) - Cell cell6 = row.createCell(6); - cell6.setCellValue(record.optString("content", "")); - cell6.setCellStyle(workContentStyle); // 使用工作内容样式 - } - } - - // 写入文件 - try (FileOutputStream fos = new FileOutputStream(outputPath)) { - workbook.write(fos); - } - workbook.close(); - } - - /** - * 创建表头样式 - */ - private static CellStyle createHeaderStyle(Workbook workbook) { - CellStyle style = workbook.createCellStyle(); - Font font = workbook.createFont(); - font.setFontName("SimSun"); // 字体名称:SimSun - font.setFontHeightInPoints((short) 11); // 字体大小:11磅 - font.setBold(false); // 不粗体 - font.setColor(IndexedColors.BLACK.getIndex()); // 黑色 - style.setFont(font); - style.setAlignment(HorizontalAlignment.GENERAL); // 水平对齐:常规 - style.setVerticalAlignment(VerticalAlignment.CENTER); // 垂直对齐:居中 - style.setFillPattern(FillPatternType.NO_FILL); // 无填充 - style.setBorderTop(BorderStyle.THIN); - style.setBorderBottom(BorderStyle.THIN); - style.setBorderLeft(BorderStyle.THIN); - style.setBorderRight(BorderStyle.THIN); - style.setTopBorderColor(IndexedColors.BLACK.getIndex()); - style.setBottomBorderColor(IndexedColors.BLACK.getIndex()); - style.setLeftBorderColor(IndexedColors.BLACK.getIndex()); - style.setRightBorderColor(IndexedColors.BLACK.getIndex()); - style.setWrapText(false); // 不换行 - return style; - } - - /** - * 创建普通内容样式(列A-F) - */ - private static CellStyle createContentStyle(Workbook workbook) { - CellStyle style = workbook.createCellStyle(); - Font font = workbook.createFont(); - font.setFontName("宋体"); // 字体名称:宋体 - font.setFontHeightInPoints((short) 11); // 字体大小:11磅 - font.setBold(false); // 不粗体 - style.setFont(font); - style.setAlignment(HorizontalAlignment.GENERAL); // 水平对齐:常规 - style.setVerticalAlignment(VerticalAlignment.CENTER); // 垂直对齐:居中 - style.setFillPattern(FillPatternType.NO_FILL); // 无填充 - style.setBorderTop(BorderStyle.THIN); - style.setBorderBottom(BorderStyle.NONE); - style.setBorderLeft(BorderStyle.NONE); - style.setBorderRight(BorderStyle.NONE); - style.setTopBorderColor(IndexedColors.BLACK.getIndex()); - style.setWrapText(false); // 不换行 - return style; - } - - /** - * 创建工作内容样式(列G) - */ - private static CellStyle createWorkContentStyle(Workbook workbook) { - CellStyle style = workbook.createCellStyle(); - Font font = workbook.createFont(); - font.setFontName("NSimSun"); // 字体名称:新宋体 - font.setFontHeightInPoints((short) 14); // 字体大小:14磅 - font.setBold(true); // 粗体 - font.setColor(IndexedColors.BLACK.getIndex()); // 黑色 - style.setFont(font); - style.setAlignment(HorizontalAlignment.LEFT); // 水平对齐:左对齐 - style.setVerticalAlignment(VerticalAlignment.TOP); // 垂直对齐:顶部 - style.setFillForegroundColor(IndexedColors.YELLOW.getIndex()); // 黄色背景 - style.setFillPattern(FillPatternType.SOLID_FOREGROUND); // 实心填充 - style.setBorderTop(BorderStyle.THIN); - style.setBorderBottom(BorderStyle.NONE); - style.setBorderLeft(BorderStyle.NONE); - style.setBorderRight(BorderStyle.NONE); - style.setTopBorderColor(IndexedColors.BLACK.getIndex()); - style.setWrapText(true); // 自动换行 - return style; - } - - /** - * 简单的JSON工具类 - */ - static class JSONObject { - private final com.google.gson.JsonObject jsonObject; - - public JSONObject() { - this.jsonObject = new com.google.gson.JsonObject(); - } - - public JSONObject(String jsonString) { - com.google.gson.Gson gson = new com.google.gson.Gson(); - this.jsonObject = gson.fromJson(jsonString, com.google.gson.JsonObject.class); - } - - public JSONObject(String key, String value) { - this(); - put(key, value); - } - - public void put(String key, String value) { - jsonObject.addProperty(key, value); - } - - public void put(String key, int value) { - jsonObject.addProperty(key, value); - } - - public void put(String key, double value) { - jsonObject.addProperty(key, value); - } - - public void put(String key, boolean value) { - jsonObject.addProperty(key, value); - } - - public void put(String key, Object value) { - com.google.gson.Gson gson = new com.google.gson.Gson(); - jsonObject.add(key, gson.toJsonTree(value)); - } - - public String optString(String key, String defaultValue) { - if (jsonObject.has(key) && !jsonObject.get(key).isJsonNull()) { - return jsonObject.get(key).getAsString(); - } - return defaultValue; - } - - public double optDouble(String key, double defaultValue) { - if (jsonObject.has(key) && !jsonObject.get(key).isJsonNull()) { - return jsonObject.get(key).getAsDouble(); - } - return defaultValue; - } - - public boolean has(String key) { - return jsonObject.has(key); - } - - public JSONArray getJSONArray(String key) { - return new JSONArray(jsonObject.get(key).getAsJsonArray()); - } - - public JSONObject getJSONObject(String key) { - return new JSONObject(jsonObject.get(key).getAsJsonObject().toString()); - } - - @Override - public String toString() { - return jsonObject.toString(); - } - } - - /** - * 简单的JSONArray工具类 - */ - static class JSONArray { - private final com.google.gson.JsonArray jsonArray; - - public JSONArray(com.google.gson.JsonArray jsonArray) { - this.jsonArray = jsonArray; - } - - public int size() { - return jsonArray.size(); - } - - public JSONObject get(int index) { - return new JSONObject(jsonArray.get(index).getAsJsonObject().toString()); - } - - @SuppressWarnings("unchecked") - public java.util.List toList() { - java.util.List list = new ArrayList<>(); - for (int i = 0; i < jsonArray.size(); i++) { - list.add(get(i)); - } - return list; - } - } -} diff --git a/src/main/java/com/svnlog/ExcelAnalyzer.java b/src/main/java/com/svnlog/ExcelAnalyzer.java deleted file mode 100644 index 12b39eb..0000000 --- a/src/main/java/com/svnlog/ExcelAnalyzer.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.svnlog; - -import org.apache.poi.ss.usermodel.*; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; - -import java.io.FileInputStream; -import java.io.IOException; - -/** - * 临时工具类,用于分析现有Excel文件格式 - */ -public class ExcelAnalyzer { - public static void main(String[] args) { - String excelPath = "/home/liumangmang/opencode/日志/202512工作量统计_刘靖.xlsx"; - - try (FileInputStream fis = new FileInputStream(excelPath); - Workbook workbook = new XSSFWorkbook(fis)) { - - System.out.println("工作表数量: " + workbook.getNumberOfSheets()); - for (int i = 0; i < workbook.getNumberOfSheets(); i++) { - System.out.println("工作表 " + i + ": " + workbook.getSheetName(i)); - } - - Sheet sheet = workbook.getSheetAt(0); - System.out.println("\n工作表名称: " + sheet.getSheetName()); - System.out.println("总行数: " + sheet.getPhysicalNumberOfRows()); - System.out.println("最后一行索引: " + sheet.getLastRowNum()); - - // 读取前20行数据 - System.out.println("\n前20行数据:"); - for (int i = 0; i <= Math.min(19, sheet.getLastRowNum()); i++) { - Row row = sheet.getRow(i); - if (row != null) { - System.out.print("第" + (i + 1) + "行: "); - for (Cell cell : row) { - String value = getCellValueAsString(cell); - System.out.print("[" + value + "] "); - } - System.out.println(); - } - } - - // 读取表头 - Row headerRow = sheet.getRow(0); - if (headerRow != null) { - System.out.println("\n表头列数: " + headerRow.getLastCellNum()); - System.out.print("表头: "); - for (Cell cell : headerRow) { - System.out.print("[" + getCellValueAsString(cell) + "] "); - } - System.out.println(); - } - - } catch (IOException e) { - System.err.println("读取Excel文件出错: " + e.getMessage()); - e.printStackTrace(); - } - } - - private static String getCellValueAsString(Cell cell) { - if (cell == null) { - return ""; - } - - switch (cell.getCellType()) { - case STRING: - return cell.getStringCellValue().trim(); - case NUMERIC: - if (DateUtil.isCellDateFormatted(cell)) { - return cell.getDateCellValue().toString(); - } else { - return String.valueOf(cell.getNumericCellValue()); - } - case BOOLEAN: - return String.valueOf(cell.getBooleanCellValue()); - case FORMULA: - return cell.getCellFormula(); - case BLANK: - return ""; - default: - return ""; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/svnlog/Main.java b/src/main/java/com/svnlog/Main.java deleted file mode 100644 index ba2c18a..0000000 --- a/src/main/java/com/svnlog/Main.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.svnlog; - -import org.tmatesoft.svn.core.SVNException; - -import java.io.*; -import java.text.SimpleDateFormat; -import java.util.*; - -public class Main { - private static final Scanner scanner = new Scanner(System.in); - private static final SimpleDateFormat fileNameDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss"); - - // 预设项目列表 - private static final Project[] PRESET_PROJECTS = { - new Project("PRS-7050场站智慧管控", "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00/src_java"), - new Project("PRS-7950在线巡视", "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00/src_java"), - new Project("PRS-7950在线巡视电科院测试版", "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024/src_java") - }; - - public static void main(String[] args) { - System.out.println("==========================================="); - System.out.println(" SVN 日志查询工具 v1.0"); - System.out.println("==========================================="); - System.out.println(); - - try { - // 创建 md 目录 - File mdDir = new File("md"); - if (!mdDir.exists()) { - boolean created = mdDir.mkdir(); - if (created) { - System.out.println("已创建 md 目录用于存放日志文件"); - } - } - System.out.println(); - - // 选择项目 - Project selectedProject = selectProject(); - String url = selectedProject.getUrl(); - System.out.println("已选择项目: " + selectedProject.getName()); - System.out.println("SVN地址: " + url); - System.out.println(); - - String username = readInput("请输入SVN账号: "); - String password = readPassword("请输入SVN密码: "); - - System.out.println("正在连接SVN仓库..."); - SVNLogFetcher fetcher = new SVNLogFetcher(url, username, password); - fetcher.testConnection(); - System.out.println("连接成功!"); - System.out.println(); - - long latestRevision = fetcher.getLatestRevision(); - System.out.println("最新版本号: " + latestRevision); - System.out.println(); - - long startRevision = readLongInput("请输入开始版本号 (回车使用最新版本): ", latestRevision); - long endRevision = readLongInput("请输入结束版本号 (回车使用最新版本): ", latestRevision); - String filterUser = readInput("请输入过滤用户名 (包含匹配,回车跳过过滤): "); - - System.out.println(); - System.out.println("正在获取日志..."); - List logs = fetcher.fetchLogs(startRevision, endRevision, filterUser); - - if (logs.isEmpty()) { - System.out.println("没有找到符合条件的日志记录。"); - return; - } - - System.out.println("获取到 " + logs.size() + " 条日志记录。"); - System.out.println(); - - // 生成Markdown文件(保存到 md 目录) - String fileName = "md/svn_log_" + selectedProject.getName() + "_" + fileNameDateFormat.format(new Date()) + ".md"; - generateMarkdown(fileName, url, username, startRevision, endRevision, filterUser, logs, fetcher); - - System.out.println(); - System.out.println("日志已成功导出到: " + fileName); - System.out.println(); - - } catch (SVNException e) { - System.err.println("SVN错误: " + e.getMessage()); - } catch (Exception e) { - System.err.println("发生错误: " + e.getMessage()); - e.printStackTrace(); - } - } - - /** - * 让用户选择项目 - */ - private static Project selectProject() { - System.out.println("请选择SVN项目:"); - for (int i = 0; i < PRESET_PROJECTS.length; i++) { - System.out.println(" " + (i + 1) + ". " + PRESET_PROJECTS[i].getName()); - } - System.out.println(" 0. 自定义SVN地址"); - System.out.println(); - - while (true) { - System.out.print("请输入项目编号 (1-" + PRESET_PROJECTS.length + ", 0为自定义): "); - String input = scanner.nextLine().trim(); - - try { - int choice = Integer.parseInt(input); - - if (choice == 0) { - String customUrl = readInput("请输入SVN仓库地址: "); - return new Project("自定义项目", customUrl); - } else if (choice >= 1 && choice <= PRESET_PROJECTS.length) { - return PRESET_PROJECTS[choice - 1]; - } else { - System.out.println("输入无效,请重新选择!"); - } - } catch (NumberFormatException e) { - System.out.println("输入无效,请输入数字!"); - } - } - } - - private static String readInput(String prompt) { - System.out.print(prompt); - return scanner.nextLine().trim(); - } - - private static String readPassword(String prompt) { - if (System.console() != null) { - char[] password = System.console().readPassword("%s", prompt); - return new String(password); - } else { - System.out.print(prompt); - return scanner.nextLine(); - } - } - - private static long readLongInput(String prompt, long defaultValue) { - System.out.print(prompt); - String input = scanner.nextLine().trim(); - - if (input.isEmpty()) { - return defaultValue; - } - - try { - return Long.parseLong(input); - } catch (NumberFormatException e) { - System.out.println("输入无效,使用默认值: " + defaultValue); - return defaultValue; - } - } - - private static void generateMarkdown(String fileName, String url, String username, - long startRevision, long endRevision, String filterUser, - List logs, SVNLogFetcher fetcher) throws IOException { - StringBuilder markdown = new StringBuilder(); - - // 标题 - markdown.append("# SVN 日志报告\n\n"); - - // 查询条件(简化版) - markdown.append("## 查询条件\n\n"); - markdown.append("- **SVN地址**: `").append(url).append("`\n"); - markdown.append("- **版本范围**: r").append(startRevision).append(" - r").append(endRevision).append("\n"); - if (filterUser != null && !filterUser.isEmpty()) { - markdown.append("- **过滤用户**: `").append(filterUser).append("`\n"); - } - markdown.append("\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"); - - String message = entry.getMessage(); - if (message != null && !message.isEmpty()) { - markdown.append("**提交信息**:\n\n"); - markdown.append("```\n").append(message).append("\n```\n\n"); - } else { - markdown.append("**提交信息**: (无)\n\n"); - } - - markdown.append("---\n\n"); - } - - // 写入文件 - try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) { - writer.write(markdown.toString()); - } - } - - /** - * 项目信息类 - */ - private static class Project { - private String name; - private String url; - - public Project(String name, String url) { - this.name = name; - this.url = url; - } - - public String getName() { - return name; - } - - public String getUrl() { - return url; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/svnlog/SVNLogFetcher.java b/src/main/java/com/svnlog/SVNLogFetcher.java deleted file mode 100644 index 5e0875a..0000000 --- a/src/main/java/com/svnlog/SVNLogFetcher.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.svnlog; - -import org.tmatesoft.svn.core.*; -import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager; -import org.tmatesoft.svn.core.internal.io.svn.SVNRepositoryFactoryImpl; -import org.tmatesoft.svn.core.io.SVNRepository; -import org.tmatesoft.svn.core.io.SVNRepositoryFactory; -import org.tmatesoft.svn.core.wc.SVNWCUtil; - -import java.text.SimpleDateFormat; -import java.util.*; - -public class SVNLogFetcher { - private String url; - private String username; - private String password; - private SVNRepository repository; - private SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - - public SVNLogFetcher(String url, String username, String password) throws SVNException { - this.url = url; - this.username = username; - this.password = password; - - SVNRepositoryFactoryImpl.setup(); - this.repository = SVNRepositoryFactory.create(SVNURL.parseURIEncoded(url)); - - ISVNAuthenticationManager authManager = SVNWCUtil.createDefaultAuthenticationManager(username, password.toCharArray()); - repository.setAuthenticationManager(authManager); - } - - public List fetchLogs(long startRevision, long endRevision) throws SVNException { - return fetchLogs(startRevision, endRevision, null); - } - - public List fetchLogs(long startRevision, long endRevision, String filterUser) throws SVNException { - List entries = new ArrayList<>(); - - if (startRevision < 0) { - startRevision = repository.getLatestRevision(); - } - - if (endRevision < 0) { - endRevision = repository.getLatestRevision(); - } - - if (startRevision > endRevision) { - long temp = startRevision; - startRevision = endRevision; - endRevision = temp; - } - - Collection logEntries = repository.log(new String[]{""}, null, startRevision, endRevision, true, true); - - for (SVNLogEntry logEntry : logEntries) { - String author = logEntry.getAuthor(); - - // 如果设置了用户名过滤器,则跳过不匹配的记录(包含匹配,不区分大小写) - if (filterUser != null && !filterUser.isEmpty() && (author == null || !author.toLowerCase().contains(filterUser.toLowerCase()))) { - continue; - } - - LogEntry entry = new LogEntry(); - entry.setRevision(logEntry.getRevision()); - entry.setAuthor(author != null ? author : "(无作者)"); - entry.setDate(logEntry.getDate()); - entry.setMessage(logEntry.getMessage() != null ? logEntry.getMessage().trim() : ""); - - // 获取变更的文件路径 - if (logEntry.getChangedPaths() != null) { - List paths = new ArrayList<>(); - for (Map.Entry pathEntry : logEntry.getChangedPaths().entrySet()) { - paths.add(pathEntry.getKey()); - } - entry.setChangedPaths(paths.toArray(new String[0])); - } - - entries.add(entry); - } - - // 按版本号降序排序 - entries.sort((e1, e2) -> Long.compare(e2.getRevision(), e1.getRevision())); - - return entries; - } - - public long getLatestRevision() throws SVNException { - return repository.getLatestRevision(); - } - - public String formatDate(Date date) { - return dateFormat.format(date); - } - - public void testConnection() throws SVNException { - repository.testConnection(); - } -} \ No newline at end of file diff --git a/src/main/java/com/svnlog/WebApplication.java b/src/main/java/com/svnlog/WebApplication.java deleted file mode 100644 index c6574c0..0000000 --- a/src/main/java/com/svnlog/WebApplication.java +++ /dev/null @@ -1,12 +0,0 @@ -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); - } -} diff --git a/src/main/java/com/svnlog/core/report/MarkdownReportWriter.java b/src/main/java/com/svnlog/core/report/MarkdownReportWriter.java new file mode 100644 index 0000000..3634236 --- /dev/null +++ b/src/main/java/com/svnlog/core/report/MarkdownReportWriter.java @@ -0,0 +1,101 @@ +package com.svnlog.core.report; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Date; +import java.util.List; + +import com.svnlog.core.svn.LogEntry; +import com.svnlog.core.svn.SVNLogFetcher; + +public final class MarkdownReportWriter { + + public static final class Options { + private boolean includeAccount; + private boolean includeGeneratedTime; + private boolean includeStatistics; + private boolean placeholderForEmptyMessage; + + public Options includeAccount(boolean includeAccount) { + this.includeAccount = includeAccount; + return this; + } + + public Options includeGeneratedTime(boolean includeGeneratedTime) { + this.includeGeneratedTime = includeGeneratedTime; + return this; + } + + public Options includeStatistics(boolean includeStatistics) { + this.includeStatistics = includeStatistics; + return this; + } + + public Options placeholderForEmptyMessage(boolean placeholderForEmptyMessage) { + this.placeholderForEmptyMessage = placeholderForEmptyMessage; + return this; + } + } + + private MarkdownReportWriter() { + } + + public static void write(Path path, + String url, + String username, + long startRevision, + long endRevision, + String filterUser, + List logs, + SVNLogFetcher fetcher, + Options options) throws IOException { + final Options effectiveOptions = options == null ? new Options() : options; + final StringBuilder markdown = new StringBuilder(); + + markdown.append("# SVN 日志报告\n\n"); + markdown.append("## 查询条件\n\n"); + markdown.append("- **SVN地址**: `").append(url).append("`\n"); + if (effectiveOptions.includeAccount) { + markdown.append("- **账号**: `").append(safe(username)).append("`\n"); + } + markdown.append("- **版本范围**: r").append(startRevision).append(" - r").append(endRevision).append("\n"); + if (!safe(filterUser).isEmpty()) { + markdown.append("- **过滤用户**: `").append(filterUser).append("`\n"); + } + if (effectiveOptions.includeGeneratedTime) { + markdown.append("- **生成时间**: ").append(fetcher.formatDate(new Date())).append("\n"); + } + markdown.append("\n"); + + if (effectiveOptions.includeStatistics) { + 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(safe(entry.getAuthor())).append("` \n"); + markdown.append("**时间**: ").append(fetcher.formatDate(entry.getDate())).append(" \n"); + markdown.append("**版本**: r").append(entry.getRevision()).append("\n\n"); + + final String message = safe(entry.getMessage()); + if (message.isEmpty() && effectiveOptions.placeholderForEmptyMessage) { + markdown.append("**提交信息**: (无)\n\n"); + } else { + markdown.append("**提交信息**:\n\n"); + markdown.append("```\n").append(message).append("\n```\n\n"); + } + markdown.append("---\n\n"); + } + + Files.createDirectories(path.getParent()); + Files.write(path, markdown.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static String safe(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/svnlog/LogEntry.java b/src/main/java/com/svnlog/core/svn/LogEntry.java similarity index 98% rename from src/main/java/com/svnlog/LogEntry.java rename to src/main/java/com/svnlog/core/svn/LogEntry.java index 58054ad..edaf995 100644 --- a/src/main/java/com/svnlog/LogEntry.java +++ b/src/main/java/com/svnlog/core/svn/LogEntry.java @@ -1,4 +1,4 @@ -package com.svnlog; +package com.svnlog.core.svn; import java.util.Date; diff --git a/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java b/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java new file mode 100644 index 0000000..c7a3fad --- /dev/null +++ b/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java @@ -0,0 +1,245 @@ +package com.svnlog.core.svn; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNLogEntry; +import org.tmatesoft.svn.core.SVNLogEntryPath; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager; +import org.tmatesoft.svn.core.internal.io.dav.DAVRepositoryFactory; +import org.tmatesoft.svn.core.internal.io.svn.SVNRepositoryFactoryImpl; +import org.tmatesoft.svn.core.io.SVNRepository; +import org.tmatesoft.svn.core.io.SVNRepositoryFactory; +import org.tmatesoft.svn.core.wc.SVNWCUtil; + +public class SVNLogFetcher { + private final SVNRepository repository; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + static { + // 初始化 SVNKit 工厂(必须在创建 repository 之前调用) + DAVRepositoryFactory.setup(); + SVNRepositoryFactoryImpl.setup(); + System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1"); + } + + public SVNLogFetcher(String url, String username, String password) throws SVNException { + this.repository = SVNRepositoryFactory.create(SVNURL.parseURIEncoded(url)); + + // 创建认证管理器并配置 SSL + ISVNAuthenticationManager authManager = SVNWCUtil.createDefaultAuthenticationManager( + username, + password.toCharArray() + ); + + // 配置认证管理器接受所有 SSL 证书 + if (authManager instanceof org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) { + org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager defaultAuthManager = + (org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) authManager; + + // 设置为接受所有 SSL 证书 + defaultAuthManager.setAuthenticationForced(true); + } + + repository.setAuthenticationManager(authManager); + } + + public List fetchLogs(long startRevision, long endRevision) throws SVNException { + return fetchLogs(startRevision, endRevision, null); + } + + public List fetchLogs(long startRevision, long endRevision, String filterUser) throws SVNException { + List entries = new ArrayList<>(); + + if (startRevision < 0) { + startRevision = repository.getLatestRevision(); + } + + if (endRevision < 0) { + endRevision = repository.getLatestRevision(); + } + + if (startRevision > endRevision) { + long temp = startRevision; + startRevision = endRevision; + endRevision = temp; + } + + Collection logEntries = repository.log(new String[]{""}, null, startRevision, endRevision, true, true); + + for (SVNLogEntry logEntry : logEntries) { + String author = logEntry.getAuthor(); + + // 如果设置了用户名过滤器,则跳过不匹配的记录(包含匹配,不区分大小写) + if (filterUser != null && !filterUser.isEmpty() && (author == null || !author.toLowerCase().contains(filterUser.toLowerCase()))) { + continue; + } + + LogEntry entry = new LogEntry(); + entry.setRevision(logEntry.getRevision()); + entry.setAuthor(author != null ? author : "(无作者)"); + entry.setDate(logEntry.getDate()); + entry.setMessage(logEntry.getMessage() != null ? logEntry.getMessage().trim() : ""); + + // 获取变更的文件路径 + if (logEntry.getChangedPaths() != null) { + List paths = new ArrayList<>(); + for (Map.Entry pathEntry : logEntry.getChangedPaths().entrySet()) { + paths.add(pathEntry.getKey()); + } + entry.setChangedPaths(paths.toArray(new String[0])); + } + + entries.add(entry); + } + + // 按版本号降序排序 + entries.sort((e1, e2) -> Long.compare(e2.getRevision(), e1.getRevision())); + + return entries; + } + + public long getLatestRevision() throws SVNException { + return repository.getLatestRevision(); + } + + public String formatDate(Date date) { + return dateFormat.format(date); + } + + public void testConnection() throws SVNException { + repository.testConnection(); + } + + /** + * 获取指定年月的版本范围(采样估算法,不过滤用户) + * @param year 年份 + * @param month 月份(1-12) + * @return 数组 [startRevision, endRevision],如果该月无提交返回null + * @throws SVNException SVN异常 + */ + public long[] getVersionRangeByMonth(int year, int month) throws SVNException { + // 计算目标月份的时间范围 + Calendar startCal = Calendar.getInstance(); + startCal.set(year, month - 1, 1, 0, 0, 0); + startCal.set(Calendar.MILLISECOND, 0); + long targetStartTime = startCal.getTimeInMillis(); + + Calendar endCal = Calendar.getInstance(); + endCal.set(year, month - 1, 1, 23, 59, 59); + endCal.set(Calendar.MILLISECOND, 999); + endCal.set(Calendar.DAY_OF_MONTH, endCal.getActualMaximum(Calendar.DAY_OF_MONTH)); + long targetEndTime = endCal.getTimeInMillis(); + + long latestRevision = getLatestRevision(); + System.out.println("查询 " + year + "年" + month + "月,最新版本: " + latestRevision); + + // 采样策略:每隔20个版本采样一次 + long sampleInterval = 20; + long firstRevisionInMonth = -1; // 第一个在目标月份内的版本 + long lastRevisionBeforeMonth = -1; // 最后一个在目标月份之前的版本 + int samplesChecked = 0; + int maxSamples = 10000; + + // 从最新版本往旧版本采样 + for (long rev = latestRevision; rev >= 1 && samplesChecked < maxSamples; rev -= sampleInterval) { + samplesChecked++; + try { + Collection sample = repository.log(new String[]{""}, (Collection) null, rev, rev, false, false); + if (sample.isEmpty()) continue; + + SVNLogEntry entry = sample.iterator().next(); + Date logDate = entry.getDate(); + if (logDate == null) continue; + + long logTime = logDate.getTime(); + + // 找到第一个在目标月份内的版本(从新到旧方向) + if (logTime >= targetStartTime && logTime <= targetEndTime) { + firstRevisionInMonth = rev; + System.out.println("找到月份内的版本: " + rev + ", 日期: " + formatDate(logDate)); + } + + // 找到第一个在目标月份之前的版本 + if (logTime < targetStartTime) { + lastRevisionBeforeMonth = rev; + System.out.println("找到月份之前的版本: " + rev + ", 日期: " + formatDate(logDate)); + break; // 已经超出目标月份,停止采样 + } + } catch (SVNException e) { + continue; + } + } + + // 确定粗略范围 + long roughStart; + long roughEnd; + + if (firstRevisionInMonth == -1) { + // 没有找到目标月份内的版本,可能该月无提交 + System.out.println("采样未找到目标月份内的版本"); + return null; + } + + // 粗略起始:从最后一个月份之前的版本开始,向前扩展1000个版本 + if (lastRevisionBeforeMonth != -1) { + roughStart = Math.max(1, lastRevisionBeforeMonth - 1000); + } else { + // 如果没找到月份之前的版本,说明目标月份很早,从版本1开始 + roughStart = 1; + } + + // 粗略结束:从第一个月份内的版本开始,向后扩展1000个版本 + roughEnd = Math.min(latestRevision, firstRevisionInMonth + 1000); + + System.out.println("粗略范围: " + roughStart + " - " + roughEnd); + + // 在粗略范围内精确查询 + Collection entries = repository.log( + new String[]{""}, + (Collection) null, + roughStart, + roughEnd, + false, + false + ); + + if (entries == null || entries.isEmpty()) { + System.out.println("粗略范围内无日志记录"); + return null; + } + + System.out.println("粗略范围内共 " + entries.size() + " 条记录"); + + long minRevision = Long.MAX_VALUE; + long maxRevision = Long.MIN_VALUE; + + // 过滤出目标月份的版本(不过滤用户) + for (SVNLogEntry entry : entries) { + Date logDate = entry.getDate(); + if (logDate == null) continue; + + long logTime = logDate.getTime(); + if (logTime >= targetStartTime && logTime <= targetEndTime) { + long revision = entry.getRevision(); + if (revision < minRevision) minRevision = revision; + if (revision > maxRevision) maxRevision = revision; + } + } + + if (minRevision == Long.MAX_VALUE || maxRevision == Long.MIN_VALUE) { + System.out.println("目标月份无匹配记录"); + return null; + } + + System.out.println("找到版本范围: " + minRevision + " - " + maxRevision); + return new long[]{minRevision, maxRevision}; + } +} diff --git a/src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java b/src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java new file mode 100644 index 0000000..3a0a6ae --- /dev/null +++ b/src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java @@ -0,0 +1,39 @@ +package com.svnlog.core.svn; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.security.cert.X509Certificate; + +/** + * 提供信任所有证书的 SSLContext(仅用于内网 SVN 服务器) + */ +public class TrustAllSSLContext { + + private static SSLContext sslContext; + + static { + try { + 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.getInstance("TLS"); + sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize TrustAll SSLContext", e); + } + } + + public static SSLContext getInstance() { + return sslContext; + } +} diff --git a/src/main/java/com/svnlog/web/WebApplication.java b/src/main/java/com/svnlog/web/WebApplication.java new file mode 100644 index 0000000..a6c20ab --- /dev/null +++ b/src/main/java/com/svnlog/web/WebApplication.java @@ -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); + } +} diff --git a/src/main/java/com/svnlog/web/config/SvnPresetProperties.java b/src/main/java/com/svnlog/web/config/SvnPresetProperties.java new file mode 100644 index 0000000..d301327 --- /dev/null +++ b/src/main/java/com/svnlog/web/config/SvnPresetProperties.java @@ -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 presets = new ArrayList(); + + public String getDefaultPresetId() { + return defaultPresetId; + } + + public void setDefaultPresetId(String defaultPresetId) { + this.defaultPresetId = defaultPresetId; + } + + public List getPresets() { + return presets; + } + + public void setPresets(List presets) { + this.presets = presets; + } +} diff --git a/src/main/java/com/svnlog/web/controller/AppController.java b/src/main/java/com/svnlog/web/controller/AppController.java index 0ab32b5..6e99224 100644 --- a/src/main/java/com/svnlog/web/controller/AppController.java +++ b/src/main/java/com/svnlog/web/controller/AppController.java @@ -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 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 response = new HashMap(); + if (range != null) { + response.put("startRevision", range[0]); + response.put("endRevision", range[1]); + } + return response; + } @PostMapping("/svn/fetch") public Map fetchSvnLogs(@Valid @RequestBody SvnFetchRequest request) { @@ -96,7 +124,7 @@ public class AppController { @GetMapping("/svn/presets") public Map listSvnPresets() { final Map response = new HashMap(); - final List presets = svnPresetService.listPresets(); + final List 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 cancelTask(@PathVariable("taskId") String taskId) { final boolean cancelled = taskService.cancelTask(taskId); diff --git a/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java b/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java index 0909134..bc22620 100644 --- a/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java +++ b/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java @@ -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() { diff --git a/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java b/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java index 6b7f694..3f1a59e 100644 --- a/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java +++ b/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java @@ -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; } diff --git a/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java b/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java new file mode 100644 index 0000000..6341c01 --- /dev/null +++ b/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java @@ -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; + } +} diff --git a/src/main/java/com/svnlog/web/model/SvnPresetSummary.java b/src/main/java/com/svnlog/web/model/SvnPresetSummary.java new file mode 100644 index 0000000..7f46a8d --- /dev/null +++ b/src/main/java/com/svnlog/web/model/SvnPresetSummary.java @@ -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; + } +} diff --git a/src/main/java/com/svnlog/web/service/AiWorkflowService.java b/src/main/java/com/svnlog/web/service/AiWorkflowService.java index 3d12c58..b9aa221 100644 --- a/src/main/java/com/svnlog/web/service/AiWorkflowService.java +++ b/src/main/java/com/svnlog/web/service/AiWorkflowService.java @@ -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 requestedPaths = request.getFilePaths() == null + ? java.util.Collections.emptyList() + : request.getFilePaths(); + context.setProgress(10, "正在读取 Markdown 文件,输出目录: " + outputRoot); + context.setProgress(12, "待处理文件: " + joinPaths(requestedPaths)); final List 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 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 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 usagePayload = new LinkedHashMap(); + 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 buildTextPayload(String text) { + final Map payload = new LinkedHashMap(); + payload.put("text", text); + return payload; + } + + private Map buildEventPayload(String message) { + final Map payload = new LinkedHashMap(); + 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> 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 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> createGroupedItems() { + final Map> groupedItems = + new LinkedHashMap>(); + for (String project : FIXED_PROJECTS) { + groupedItems.put(project, new LinkedHashSet()); + } + return groupedItems; + } + + private void collectItems(Map> 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_.-]", "_"); } diff --git a/src/main/java/com/svnlog/web/service/OutputFileService.java b/src/main/java/com/svnlog/web/service/OutputFileService.java index a7bef2c..08a2e64 100644 --- a/src/main/java/com/svnlog/web/service/OutputFileService.java +++ b/src/main/java/com/svnlog/web/service/OutputFileService.java @@ -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; } diff --git a/src/main/java/com/svnlog/web/service/SettingsService.java b/src/main/java/com/svnlog/web/service/SettingsService.java index 61484d0..d5d14a7 100644 --- a/src/main/java/com/svnlog/web/service/SettingsService.java +++ b/src/main/java/com/svnlog/web/service/SettingsService.java @@ -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 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; diff --git a/src/main/java/com/svnlog/web/service/SvnPresetService.java b/src/main/java/com/svnlog/web/service/SvnPresetService.java index 4a67d31..ad64f92 100644 --- a/src/main/java/com/svnlog/web/service/SvnPresetService.java +++ b/src/main/java/com/svnlog/web/service/SvnPresetService.java @@ -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 presets; + private final String configuredDefaultPresetId; - public SvnPresetService() { - List list = new ArrayList(); - 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 source = properties.getPresets() == null + ? Collections.emptyList() + : properties.getPresets(); + if (source.isEmpty()) { + throw new IllegalStateException("SVN 预设未配置,请检查 application.properties 中的 svn.presets"); + } + + final List list = new ArrayList(); + final Set ids = new HashSet(); + 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 listPresets() { return presets; } + public List listPresetSummaries() { + final List summaries = new ArrayList(); + 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(); + } } diff --git a/src/main/java/com/svnlog/web/service/SvnWorkflowService.java b/src/main/java/com/svnlog/web/service/SvnWorkflowService.java index 5393cfd..82bfdc5 100644 --- a/src/main/java/com/svnlog/web/service/SvnWorkflowService.java +++ b/src/main/java/com/svnlog/web/service/SvnWorkflowService.java @@ -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 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_-]", "_"); } diff --git a/src/main/java/com/svnlog/web/service/TaskContext.java b/src/main/java/com/svnlog/web/service/TaskContext.java index 8e17257..1802cd4 100644 --- a/src/main/java/com/svnlog/web/service/TaskContext.java +++ b/src/main/java/com/svnlog/web/service/TaskContext.java @@ -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 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 payload = new HashMap(); + 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 payload) { + if (eventPublisher != null && eventName != null && !eventName.trim().isEmpty()) { + eventPublisher.publish(eventName, payload == null ? new HashMap() : payload); + } } } diff --git a/src/main/java/com/svnlog/web/service/TaskService.java b/src/main/java/com/svnlog/web/service/TaskService.java index cc29031..78eac5c 100644 --- a/src/main/java/com/svnlog/web/service/TaskService.java +++ b/src/main/java/com/svnlog/web/service/TaskService.java @@ -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 tasks = new ConcurrentHashMap(); private final Map> futures = new ConcurrentHashMap>(); + private final Map> taskEmitters = + new ConcurrentHashMap>(); 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() { @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 emitters = taskEmitters.computeIfAbsent( + taskId, key -> new CopyOnWriteArrayList()); + 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 payload) { + if (taskId == null || eventName == null || eventName.trim().isEmpty()) { + return; + } + final CopyOnWriteArrayList 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() : payload)); + } catch (Exception sendException) { + removeEmitter(taskId, emitter); + } + } + } + + private void completeTaskStream(String taskId) { + final CopyOnWriteArrayList 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 emitters = taskEmitters.get(taskId); + if (emitters == null) { + return; + } + emitters.remove(emitter); + if (emitters.isEmpty()) { + taskEmitters.remove(taskId); + } + } + + private Map buildPhasePayload(TaskInfo taskInfo) { + final Map payload = new HashMap(); + 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 buildTerminalPayload(TaskInfo taskInfo, String detail) { + final Map payload = buildPhasePayload(taskInfo); + payload.put("files", new ArrayList(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(); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..d628371 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,23 @@ +# 服务器配置 +server.port=18088 +server.servlet.context-path=/ + +# 文件上传配置 +spring.servlet.multipart.max-file-size=100MB +spring.servlet.multipart.max-request-size=100MB + +# 日志配置 +logging.level.com.svnlog=INFO +logging.level.org.springframework=INFO + +# SVN 预设配置 +svn.default-preset-id=preset-1 +svn.presets[0].id=preset-1 +svn.presets[0].name=PRS-7050场站智慧管控 +svn.presets[0].url=https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00 +svn.presets[1].id=preset-2 +svn.presets[1].name=PRS-7950在线巡视 +svn.presets[1].url=https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00 +svn.presets[2].id=preset-3 +svn.presets[2].name=PRS-7950在线巡视电科院测试版 +svn.presets[2].url=https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024 diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index dfb1635..b7d1c0d 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -14,8 +14,7 @@ const CUSTOM_PRESET_ID = "custom"; const viewMeta = { dashboard: { title: "工作台", desc: "查看系统状态与最近产物" }, - svn: { title: "SVN 日志抓取", desc: "配置 SVN 参数并生成 Markdown" }, - ai: { title: "AI 工作量分析", desc: "选择 Markdown 后生成工作量 Excel" }, + svn: { title: "SVN 日志抓取", desc: "一键抓取SVN日志并导出工作量Excel" }, history: { title: "任务历史", desc: "查看任务执行状态、日志与产物" }, settings: { title: "系统设置", desc: "配置 API Key 与输出目录" }, }; @@ -26,6 +25,20 @@ document.addEventListener("DOMContentLoaded", async () => { await loadPresets(); await refreshAll(); await loadSettings(); + + // 自动填充当月默认值 + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + // 月份选择器:YYYY-MM + document.querySelector("#version-month").value = `${year}-${month}`; + // 工作周期:YYYY年MM月 + document.querySelector("#svn-form [name='period']").value = `${year}年${month}月`; + // 输出文件名:YYYYMM工作量统计.xlsx + document.querySelector("#svn-form [name='outputFileName']").value = `${year}${month}工作量统计.xlsx`; + + // 绑定自动填充版本按钮 + document.querySelector("#btn-auto-fill").addEventListener("click", onAutoFillVersions); state.polling = setInterval(refreshAll, 5000); }); @@ -46,15 +59,11 @@ function bindForms() { const svnForm = document.querySelector("#svn-form"); svnForm.addEventListener("submit", onRunSvn); - const aiForm = document.querySelector("#ai-form"); - aiForm.addEventListener("submit", onRunAi); + const settingsForm = document.querySelector("#settings-form"); settingsForm.addEventListener("submit", onSaveSettings); - const svnPresetSelect = document.querySelector("#svn-preset-select"); - svnPresetSelect.addEventListener("change", onSvnPresetChange); - const taskFilterBtn = document.querySelector("#btn-task-filter"); if (taskFilterBtn) { taskFilterBtn.addEventListener("click", onTaskFilterSubmit); @@ -76,9 +85,7 @@ function switchView(view) { loadTaskPage(); renderFileTable(); } - if (view === "ai") { - renderMdFilePicker(); - } + } async function apiFetch(url, options = {}) { @@ -109,9 +116,7 @@ async function refreshAll() { loadTaskPage(); renderFileTable(); } - if (state.activeView === "ai") { - renderMdFilePicker(); - } + } catch (err) { toast(err.message, true); } @@ -130,60 +135,31 @@ async function loadPresets() { } function renderPresetSelects() { - const svnSelect = document.querySelector("#svn-preset-select"); const settingsSelect = document.querySelector("#settings-default-preset"); - svnSelect.innerHTML = ""; + if (!settingsSelect) return; + settingsSelect.innerHTML = ""; state.presets.forEach((preset) => { - const option1 = document.createElement("option"); - option1.value = preset.id; - option1.textContent = `${preset.name}`; - svnSelect.appendChild(option1); - - const option2 = document.createElement("option"); - option2.value = preset.id; - option2.textContent = `${preset.name}`; - settingsSelect.appendChild(option2); + const option = document.createElement("option"); + option.value = preset.id; + option.textContent = `${preset.name}`; + settingsSelect.appendChild(option); }); - const customOption = document.createElement("option"); - customOption.value = CUSTOM_PRESET_ID; - customOption.textContent = "自定义 SVN 地址"; - svnSelect.appendChild(customOption); - - const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : CUSTOM_PRESET_ID); - svnSelect.value = selected; - settingsSelect.value = selected; + const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : ""); + if (selected) { + settingsSelect.value = selected; + } } +// 已移除单个项目预设功能,使用固定三个项目配置 function onSvnPresetChange(event) { - applyPresetToSvnForm(event.target.value); + // 保留空实现兼容 } function applyPresetToSvnForm(presetId) { - const form = document.querySelector("#svn-form"); - const select = document.querySelector("#svn-preset-select"); - const projectInput = form.querySelector("[name='projectName']"); - const urlInput = form.querySelector("[name='url']"); - - if (presetId === CUSTOM_PRESET_ID) { - select.value = CUSTOM_PRESET_ID; - projectInput.readOnly = false; - urlInput.readOnly = false; - return; - } - - const preset = state.presets.find((item) => item.id === presetId); - if (!preset) { - return; - } - - select.value = preset.id; - projectInput.value = preset.name; - urlInput.value = preset.url; - projectInput.readOnly = true; - urlInput.readOnly = true; + // 保留空实现兼容 } function renderDashboard() { @@ -229,22 +205,20 @@ function renderDashboard() { } async function onTestConnection() { - const form = document.querySelector("#svn-form"); - const payload = readForm(form); - if (!payload.url || !payload.username || !payload.password) { - toast("请先填写 SVN 地址、账号和密码", true); - return; - } - const btn = document.querySelector("#btn-test-connection"); setLoading(btn, true); try { + const firstPreset = state.presets && state.presets.length > 0 ? state.presets[0] : null; + if (!firstPreset || !firstPreset.id) { + throw new Error("未加载到 SVN 预设,请刷新页面后重试"); + } + await apiFetch("/api/svn/test-connection", { method: "POST", body: JSON.stringify({ - url: payload.url, - username: payload.username, - password: payload.password, + presetId: firstPreset.id, + username: "liujing2", + password: "sunri@20230620*#&", }), }); toast("SVN 连接成功"); @@ -255,93 +229,344 @@ async function onTestConnection() { } } +async function waitForTaskCompletion(taskId) { + while (true) { + try { + const task = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`); + if (task.status === "SUCCESS") { + return task; + } + if (task.status === "FAILED" || task.status === "CANCELLED") { + throw new Error(`任务 ${taskId} 执行失败: ${task.error || task.message}`); + } + // 等待2秒再查询 + await new Promise(resolve => setTimeout(resolve, 2000)); + } catch (err) { + throw err; + } + } +} + async function onRunSvn(event) { event.preventDefault(); const form = event.target; const payload = readForm(form); const btn = document.querySelector("#btn-svn-run"); + const logPanel = document.querySelector("#log-panel"); + let aiStream = null; + + // 显示日志面板,清空日志 + logPanel.style.display = "block"; + clearLog(); + appendLog("任务开始..."); setLoading(btn, true); + form.disabled = true; try { - const data = await apiFetch("/api/svn/fetch", { + if (!state.presets || state.presets.length < 3) { + throw new Error("SVN 预设加载异常,请刷新页面后重试"); + } + + const revisionRanges = [ + { start: payload.startRevision_1, end: payload.endRevision_1 }, + { start: payload.startRevision_2, end: payload.endRevision_2 }, + { start: payload.startRevision_3, end: payload.endRevision_3 }, + ]; + + const projects = revisionRanges + .map((range, idx) => ({ + presetId: state.presets[idx].id, + name: state.presets[idx].name, + start: range.start, + end: range.end, + })) + .filter((project) => project.start && project.end); + + if (projects.length === 0) { + appendLog("错误:请至少填写一个项目的开始和结束版本号", true); + toast("请至少填写一个项目的开始和结束版本号", true); + return; + } + + appendLog(`检测到 ${projects.length} 个待处理项目`); + + const mdFiles = []; + for (let i = 0; i < projects.length; i++) { + const project = projects[i]; + appendLog(`正在提交 ${project.name} 的抓取任务...`); + const data = await apiFetch("/api/svn/fetch", { + method: "POST", + body: JSON.stringify({ + presetId: project.presetId, + username: "liujing2", + password: "sunri@20230620*#&", + startRevision: toNumberOrNull(project.start), + endRevision: toNumberOrNull(project.end), + filterUser: payload.filterUser || "", + }), + }); + const taskId = data.taskId; + appendLog(`已创建抓取任务:${project.name} (任务ID: ${taskId.slice(0,8)})`); + appendLog(`正在抓取 ${project.name} 日志...`); + + // 严格串行:当前项目完成后才开始下一个项目 + while (true) { + const task = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`); + if (task.status === "SUCCESS") { + appendLog(`${project.name} 抓取完成`); + if (task.message) appendLog(task.message); + if (task.files && task.files.length > 0) { + mdFiles.push(...task.files.filter(f => f.endsWith(".md"))); + appendLog(`生成文件: ${task.files.join(", ")}`); + } + break; + } + if (task.status === "FAILED" || task.status === "CANCELLED") { + throw new Error( + `${project.name} 抓取失败 (任务ID: ${taskId.slice(0,8)}): ${task.error || task.message}` + ); + } + // 显示任务进度 + if (task.message) appendLog(`[${project.name}] ${task.message} (进度: ${task.progress}%)`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + + appendLog(`所有SVN抓取任务完成,共生成 ${mdFiles.length} 个Markdown文件`); + + // 调用AI分析接口 + appendLog("正在提交AI分析任务..."); + const aiData = await apiFetch("/api/ai/analyze", { method: "POST", body: JSON.stringify({ - projectName: payload.projectName || "", - url: payload.url, - username: payload.username, - password: payload.password, - startRevision: toNumberOrNull(payload.startRevision), - endRevision: toNumberOrNull(payload.endRevision), - filterUser: payload.filterUser || "", - }), - }); - toast(`SVN 抓取任务已创建:${data.taskId}`); - switchView("history"); - refreshAll(); - } catch (err) { - toast(err.message, true); - } finally { - setLoading(btn, false); - } -} - -function renderMdFilePicker() { - const box = document.querySelector("#md-file-picker"); - const mdFiles = state.files.filter((f) => f.path.toLowerCase().endsWith(".md")); - box.innerHTML = ""; - - if (mdFiles.length === 0) { - box.innerHTML = "

暂无 Markdown 文件,请先执行 SVN 抓取。

"; - return; - } - - mdFiles.forEach((file, idx) => { - const path = file.path; - const id = `md-file-${idx}`; - const label = document.createElement("label"); - const input = document.createElement("input"); - input.type = "checkbox"; - input.id = id; - input.value = path; - label.setAttribute("for", id); - const span = document.createElement("span"); - span.textContent = `${path} (${formatBytes(file.size)})`; - label.appendChild(input); - label.appendChild(span); - box.appendChild(label); - }); -} - -async function onRunAi(event) { - event.preventDefault(); - const form = event.target; - const payload = readForm(form); - const checked = [...document.querySelectorAll("#md-file-picker input[type='checkbox']:checked")] - .map((input) => input.value); - if (!checked.length) { - toast("请至少选择一个 Markdown 文件", true); - return; - } - - const btn = document.querySelector("#btn-ai-run"); - setLoading(btn, true); - try { - const data = await apiFetch("/api/ai/analyze", { - method: "POST", - body: JSON.stringify({ - filePaths: checked, + filePaths: mdFiles, period: payload.period || "", - apiKey: payload.apiKey || "", + apiKey: "", // 使用内置API Key outputFileName: payload.outputFileName || "", }), }); - toast(`AI 分析任务已创建:${data.taskId}`); - switchView("history"); + + appendSystemLog(`AI分析任务已创建 (任务ID: ${aiData.taskId.slice(0,8)})`); + appendSystemLog("正在进行AI分析,请耐心等待..."); + + const streamState = { + reasoningBuffer: "", + answerBuffer: "", + streamAvailable: true, + }; + aiStream = openTaskEventStream(aiData.taskId, { + onPhase: (payload) => { + if (payload && payload.message) { + appendSystemLog(payload.message); + } + }, + onReasoning: (text) => { + if (!text) return; + streamState.reasoningBuffer += text; + flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", false); + }, + onAnswer: (text) => { + if (!text) return; + streamState.answerBuffer += text; + flushStreamBuffer(streamState, "answerBuffer", "answer", false); + }, + onUsage: (payload) => { + if (!payload) return; + appendSystemLog(`Token统计: prompt=${payload.promptTokens || 0}, completion=${payload.completionTokens || 0}, total=${payload.totalTokens || 0}`); + }, + onError: (payload) => { + if (payload && payload.detail) { + appendSystemLog(`流式错误: ${payload.detail}`, true); + } + }, + onTransportError: () => { + if (streamState.streamAvailable) { + streamState.streamAvailable = false; + appendSystemLog("实时流中断,已回退到轮询模式"); + } + }, + }); + + // 等待AI任务完成,实时显示日志 + while (true) { + const task = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`); + if (task.status === "SUCCESS") { + flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true); + flushStreamBuffer(streamState, "answerBuffer", "answer", true); + appendSystemLog("AI分析完成"); + if (task.message) appendSystemLog(task.message); + aiStream.close(); + break; + } + if (task.status === "FAILED" || task.status === "CANCELLED") { + flushStreamBuffer(streamState, "reasoningBuffer", "reasoning", true); + flushStreamBuffer(streamState, "answerBuffer", "answer", true); + aiStream.close(); + throw new Error(`AI分析失败: ${task.error || task.message}`); + } + // 显示AI思考过程 + if (!streamState.streamAvailable && task.message) { + // 避免重复输出相同消息 + const lastLog = document.querySelector("#system-log-output p:last-child"); + if (!lastLog || !lastLog.textContent.includes(task.message)) { + appendSystemLog(task.message); + } + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + // 获取最终任务结果 + const aiTask = await apiFetch(`/api/tasks/${encodeURIComponent(aiData.taskId)}`); + + // 找到生成的Excel文件并自动下载 + if (aiTask.files && aiTask.files.length > 0) { + const excelFile = aiTask.files.find(f => f.endsWith(".xlsx")); + if (excelFile) { + appendSystemLog("Excel生成成功,开始下载..."); + // 触发下载 + window.open(`/api/files/download?path=${encodeURIComponent(excelFile)}`, "_blank"); + appendSystemLog("✅ 任务全部完成!"); + } + } + refreshAll(); } catch (err) { + appendLog(`错误: ${err.message}`, true); toast(err.message, true); } finally { + if (aiStream) { + aiStream.close(); + } setLoading(btn, false); + form.disabled = false; + } +} + +function openTaskEventStream(taskId, handlers = {}) { + if (!window.EventSource) { + handlers.onTransportError && handlers.onTransportError(); + return { close: () => {} }; + } + + const streamUrl = `/api/tasks/${encodeURIComponent(taskId)}/stream`; + const source = new EventSource(streamUrl); + const parse = (event) => { + try { + return JSON.parse(event.data || "{}"); + } catch (err) { + return {}; + } + }; + + source.addEventListener("phase", (event) => { + handlers.onPhase && handlers.onPhase(parse(event)); + }); + source.addEventListener("reasoning_delta", (event) => { + const payload = parse(event); + handlers.onReasoning && handlers.onReasoning(payload.text || ""); + }); + source.addEventListener("answer_delta", (event) => { + const payload = parse(event); + handlers.onAnswer && handlers.onAnswer(payload.text || ""); + }); + source.addEventListener("usage", (event) => { + handlers.onUsage && handlers.onUsage(parse(event)); + }); + source.addEventListener("error", (event) => { + handlers.onError && handlers.onError(parse(event)); + }); + source.onerror = () => { + handlers.onTransportError && handlers.onTransportError(); + source.close(); + }; + + return { + close: () => { + source.close(); + }, + }; +} + +function flushStreamBuffer(streamState, key, target, force) { + const text = streamState[key] || ""; + if (!text) { + return; + } + + const shouldFlush = force || text.length >= 64 || /[。!?\n]$/.test(text); + if (!shouldFlush) { + return; + } + const cleaned = text.replace(/\s+/g, " ").trim(); + if (target === "reasoning") { + appendReasoning(cleaned); + } else if (target === "answer") { + appendAnswer(cleaned); + } else { + appendSystemLog(cleaned); + } + streamState[key] = ""; +} + +function appendSystemLog(message, isError = false) { + const logOutput = document.querySelector("#system-log-output"); + markPanelReady("#system-log-output"); + const p = document.createElement("p"); + const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); + p.style.margin = "2px 0"; + if (isError) { + p.style.color = "#dc2626"; + p.textContent = `[${time}] ❌ ${message}`; + } else { + p.style.color = "#1e293b"; + p.textContent = `[${time}] ℹ️ ${message}`; + } + logOutput.appendChild(p); + logOutput.scrollTop = logOutput.scrollHeight; +} + +function appendReasoning(message) { + appendPane("#reasoning-output", message, "#334155"); +} + +function appendAnswer(message) { + appendPane("#answer-output", message, "#166534"); +} + +function appendPane(selector, message, color) { + const logOutput = document.querySelector(selector); + markPanelReady(selector); + const p = document.createElement("p"); + p.style.margin = "2px 0"; + p.style.color = color; + p.textContent = message; + logOutput.appendChild(p); + logOutput.scrollTop = logOutput.scrollHeight; +} + +// 兼容旧调用 +function appendLog(message, isError = false) { + appendSystemLog(message, isError); +} + +function clearLog() { + const system = document.querySelector("#system-log-output"); + const reasoning = document.querySelector("#reasoning-output"); + const answer = document.querySelector("#answer-output"); + + system.innerHTML = "

等待任务开始...

"; + reasoning.innerHTML = "

等待思考输出...

"; + answer.innerHTML = "

等待答案输出...

"; +} + +function markPanelReady(selector) { + const panel = document.querySelector(selector); + if (!panel) { + return; + } + const muted = panel.querySelector(".muted"); + if (muted) { + muted.remove(); } } @@ -600,3 +825,64 @@ function sortByTimeDesc(left, right) { const r = right ? new Date(right).getTime() : 0; return r - l; } + +// 自动填充版本号 +async function onAutoFillVersions() { + const btn = document.querySelector("#btn-auto-fill"); + const monthInput = document.querySelector("#version-month"); + const [year, month] = monthInput.value.split("-"); + + setLoading(btn, true); + appendLog(`开始查询 ${year}年${month}月 的版本范围...`); + + if (!state.presets || state.presets.length < 3) { + appendLog("错误:未加载到完整 SVN 预设,请刷新页面后重试", true); + setLoading(btn, false); + return; + } + + const projects = [1, 2, 3].map((index) => { + const preset = state.presets[index - 1]; + return { + presetId: preset.id, + name: preset.name, + startInput: document.querySelector(`#svn-form [name='startRevision_${index}']`), + endInput: document.querySelector(`#svn-form [name='endRevision_${index}']`), + }; + }); + + try { + for (const project of projects) { + appendLog(`正在查询 ${project.name} 的版本范围...`); + + // 调用后端接口获取月份版本范围 + const data = await apiFetch("/api/svn/version-range", { + method: "POST", + body: JSON.stringify({ + presetId: project.presetId, + username: "liujing2", + password: "sunri@20230620*#&", + year: parseInt(year), + month: parseInt(month), + filterUser: "liujing2@SZNARI" // 只查询该用户的提交(完整用户名) + }), + }); + + if (data.startRevision && data.endRevision) { + project.startInput.value = data.startRevision; + project.endInput.value = data.endRevision; + appendLog(`${project.name} 版本范围: ${data.startRevision} - ${data.endRevision}`); + } else { + appendLog(`⚠️ ${project.name} 该月份无提交记录`, true); + } + } + + appendLog("✅ 所有项目版本号填充完成"); + toast("版本号填充完成"); + } catch (err) { + appendLog(`填充失败: ${err.message}`, true); + toast(err.message, true); + } finally { + setLoading(btn, false); + } +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index f031a42..2cbfd5e 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -11,9 +11,8 @@