feat(web): unify web entry, preset config, SSE streaming and dual-pane live logs

This commit is contained in:
liumangmang
2026-04-03 15:40:31 +08:00
parent 2d6c64ecff
commit 2150dfe24e
42 changed files with 1917 additions and 1533 deletions

5
.gitignore vendored
View File

@@ -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

8
.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

13
.idea/compiler.xml generated
View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="svn-log-tool" />
</profile>
</annotationProcessing>
</component>
</project>

9
.idea/encodings.xml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/svn-log-tool/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/svn-log-tool/src/main/resources" charset="UTF-8" />
</component>
</project>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://maven.aliyun.com/repository/central" />
</remote-repository>
</component>
</project>

27
.idea/misc.xml generated
View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="KubernetesApiProvider"><![CDATA[{}]]></component>
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/svn-log-tool/pom.xml" />
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectInspectionProfilesVisibleTreeState">
<entry key="Project Default">
<profile-state>
<selected-state>
<State>
<id>用户定义</id>
</State>
</selected-state>
</profile-state>
</entry>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="temurin-1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

9
.idea/日志.iml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -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 APISVN、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`)。
- 回退到交互输入。
- 不把真实值写入源码或日志。

28
README.md Normal file
View File

@@ -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/`

View File

@@ -1,121 +1,32 @@
# SVN日志工作量统计工具DeepSeek版
# SVN 日志 AI 分析Web
## 功能说明
这个工具可以根据SVN日志的markdown文件调用DeepSeek API分析日志内容并生成符合格式要求的工作量统计Excel文件。
通过 Web 工作台上传/选择 Markdown 日志,调用 DeepSeek API 分析并生成 Excel 工作量统计文件。
## 启动
```bash
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
```
访问:`http://localhost:8080`
## 使用步骤
### 1. 准备SVN日志markdown文件
1. 在「SVN 日志抓取」先生成 `.md` 文件
2. 在「系统设置」配置 DeepSeek API Key或使用环境变量 `DEEPSEEK_API_KEY`
3. 在「AI 工作量分析」选择 `.md` 文件并发起分析
4. 在「任务历史」或「产物列表」下载 `.xlsx`
使用原有的SVN日志工具生成markdown文件
```bash
java -jar svn-log-tool-1.0.0-jar-with-dependencies.jar
```
## API Key 读取优先级
按照提示输入SVN仓库地址、账号、密码等信息生成markdown格式的日志文件。
### 2. 运行DeepSeek日志处理工具
```bash
java -cp target/svn-log-tool-1.0.0-jar-with-dependencies.jar com.svnlog.DeepSeekLogProcessor
```
或者使用Maven运行
```bash
mvn exec:java -Dexec.mainClass="com.svnlog.DeepSeekLogProcessor"
```
### 3. 按照提示输入信息
程序会依次提示输入:
- **markdown日志文件路径**:可以直接回车使用当前目录下最新的`svn_log_*.md`文件
- **DeepSeek API Key**请提供有效的DeepSeek API Key也可以直接在代码中修改`API_KEY`常量)
- **输出Excel文件名**:可以直接回车使用默认文件名(格式:`YYYYMM工作量统计.xlsx`
### 4. 等待处理完成
程序会自动:
1. 读取markdown日志文件
2. 调用DeepSeek API分析日志内容
3. 根据分析结果生成Excel文件
生成的Excel文件格式与`202512工作量统计_刘靖.xlsx`保持一致。
## Excel文件格式说明
生成的Excel文件包含以下列
- 序号
- 所属班组
- 技术对接
- 开发人员
- 工作周期
- 开发项目名称
- 具体工作内容
- 空列4个
## DeepSeek API配置
`DeepSeekLogProcessor.java`中,可以修改以下配置:
```java
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
private static final String API_KEY = "YOUR_DEEPSEEK_API_KEY"; // 请替换为实际的API Key
```
## 提示词说明
工具会向DeepSeek发送以下提示词要求AI以JSON格式返回工作量统计
```json
{
"team": "所属班组",
"contact": "技术对接人",
"developer": "开发人员",
"period": "工作周期 (例如: 2025年12月)",
"records": [
{
"sequence": 1,
"project": "项目名称",
"content": "具体工作内容"
}
]
}
```
1. 请求中的 `apiKey`
2. 设置页保存的运行时 `apiKey`
3. 环境变量 `DEEPSEEK_API_KEY`
## 注意事项
1. **API Key安全**请妥善保管您的DeepSeek API Key不要将其提交到代码仓库中
2. **网络连接**:需要能够访问DeepSeek API服务器
3. **日志格式**markdown文件需要由SVN日志工具生成包含完整的日志信息
4. **成本控制**DeepSeek API可能产生费用请注意控制使用频率
## 故障排查
### 编译错误
```bash
mvn clean package -DskipTests
```
### 运行时找不到主类
确保使用正确的jar文件
```bash
java -cp target/svn-log-tool-1.0.0-jar-with-dependencies.jar com.svnlog.DeepSeekLogProcessor
```
### API调用失败
- 检查API Key是否正确
- 检查网络连接是否正常
- 检查DeepSeek API服务是否可用
## 依赖说明
项目使用以下主要依赖:
- SVNKit 1.10.11SVN操作
- Apache POI 5.2.5Excel文件读写
- OkHttp 4.12.0HTTP客户端
- Gson 2.10.1JSON处理
## 许可证
本工具仅供内部使用。
- 不要在源码和日志中写入真实密钥
- 需要可访问 DeepSeek API 的网络环境
- 接口调用可能产生费用,建议控制调用频率

16
docs/README_Migration.md Normal file
View File

@@ -0,0 +1,16 @@
# 包结构迁移说明
## 入口调整
- 当前只保留 `com.svnlog.web.WebApplication` 作为唯一入口。
- 已移除 CLI/AI 独立入口类。
## 核心类迁移
- `com.svnlog.SVNLogFetcher` -> `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 输出逻辑。

View File

@@ -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`

View File

@@ -95,7 +95,7 @@
<configuration>
<archive>
<manifest>
<mainClass>com.svnlog.Main</mainClass>
<mainClass>com.svnlog.web.WebApplication</mainClass>
</manifest>
</archive>
</configuration>
@@ -111,7 +111,7 @@
</descriptorRefs>
<archive>
<manifest>
<mainClass>com.svnlog.Main</mainClass>
<mainClass>com.svnlog.web.WebApplication</mainClass>
</manifest>
</archive>
</configuration>

View File

@@ -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 <T> java.util.List<JSONObject> toList() {
java.util.List<JSONObject> list = new ArrayList<>();
for (int i = 0; i < jsonArray.size(); i++) {
list.add(get(i));
}
return list;
}
}
}

View File

@@ -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 "";
}
}
}

View File

@@ -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<LogEntry> 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<LogEntry> 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;
}
}
}

View File

@@ -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<LogEntry> fetchLogs(long startRevision, long endRevision) throws SVNException {
return fetchLogs(startRevision, endRevision, null);
}
public List<LogEntry> fetchLogs(long startRevision, long endRevision, String filterUser) throws SVNException {
List<LogEntry> 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<SVNLogEntry> 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<String> paths = new ArrayList<>();
for (Map.Entry<String, SVNLogEntryPath> 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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<LogEntry> 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;
}
}

View File

@@ -1,4 +1,4 @@
package com.svnlog;
package com.svnlog.core.svn;
import java.util.Date;

View File

@@ -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<LogEntry> fetchLogs(long startRevision, long endRevision) throws SVNException {
return fetchLogs(startRevision, endRevision, null);
}
public List<LogEntry> fetchLogs(long startRevision, long endRevision, String filterUser) throws SVNException {
List<LogEntry> 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<SVNLogEntry> 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<String> paths = new ArrayList<>();
for (Map.Entry<String, SVNLogEntryPath> 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<SVNLogEntry> 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<SVNLogEntry> 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};
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,71 @@
package com.svnlog.web;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.security.cert.X509Certificate;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.svnlog")
public class WebApplication {
static {
// 配置 Java 全局 SSL 上下文(用于内网 SVN 服务器)
try {
// 移除 TLSv1 和 TLSv1.1 的禁用限制
String disabledAlgorithms = java.security.Security.getProperty("jdk.tls.disabledAlgorithms");
if (disabledAlgorithms != null && (disabledAlgorithms.contains("TLSv1") || disabledAlgorithms.contains("TLSv1.1"))) {
disabledAlgorithms = disabledAlgorithms
.replaceAll("TLSv1\\.1,\\s*", "")
.replaceAll("TLSv1,\\s*", "")
.replaceAll(",\\s*TLSv1\\.1", "")
.replaceAll(",\\s*TLSv1", "");
java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms);
System.out.println("TLS configuration updated: " + disabledAlgorithms);
}
// 配置信任所有证书的 SSL 上下文
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] certs, String authType) {
}
public void checkServerTrusted(X509Certificate[] certs, String authType) {
}
}
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
// 设置为默认 SSL 上下文
SSLContext.setDefault(sslContext);
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() {
public boolean verify(String hostname, SSLSession session) {
return true;
}
});
System.out.println("SSL context configured to trust all certificates");
} catch (Exception e) {
System.err.println("Warning: Failed to configure SSL context: " + e.getMessage());
}
// 配置 TLS 协议版本
System.setProperty("https.protocols", "TLSv1.2,TLSv1.1,TLSv1");
System.setProperty("jdk.tls.client.protocols", "TLSv1.2,TLSv1.1,TLSv1");
System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1");
}
public static void main(String[] args) {
SpringApplication.run(WebApplication.class, args);
}
}

View File

@@ -0,0 +1,33 @@
package com.svnlog.web.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import com.svnlog.web.model.SvnPreset;
@Component
@ConfigurationProperties(prefix = "svn")
public class SvnPresetProperties {
private String defaultPresetId;
private List<SvnPreset> presets = new ArrayList<SvnPreset>();
public String getDefaultPresetId() {
return defaultPresetId;
}
public void setDefaultPresetId(String defaultPresetId) {
this.defaultPresetId = defaultPresetId;
}
public List<SvnPreset> getPresets() {
return presets;
}
public void setPresets(List<SvnPreset> presets) {
this.presets = presets;
}
}

View File

@@ -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;
@@ -85,6 +90,29 @@ public class AppController {
return response;
}
/**
* 查询指定月份的SVN版本范围
*/
@PostMapping("/svn/version-range")
public Map<String, Object> getVersionRange(@Valid @RequestBody SvnVersionRangeRequest request) throws Exception {
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final String url = preset.getUrl();
final String username = request.getUsername();
final String password = request.getPassword();
final int year = request.getYear().intValue();
final int month = request.getMonth().intValue();
SVNLogFetcher fetcher = new SVNLogFetcher(url, username, password);
long[] range = fetcher.getVersionRangeByMonth(year, month);
final Map<String, Object> response = new HashMap<String, Object>();
if (range != null) {
response.put("startRevision", range[0]);
response.put("endRevision", range[1]);
}
return response;
}
@PostMapping("/svn/fetch")
public Map<String, String> fetchSvnLogs(@Valid @RequestBody SvnFetchRequest request) {
final String taskId = taskService.submit("SVN_FETCH", context -> svnWorkflowService.fetchToMarkdown(request, context));
@@ -96,7 +124,7 @@ public class AppController {
@GetMapping("/svn/presets")
public Map<String, Object> listSvnPresets() {
final Map<String, Object> response = new HashMap<String, Object>();
final List<SvnPreset> presets = svnPresetService.listPresets();
final List<SvnPresetSummary> presets = svnPresetService.listPresetSummaries();
response.put("presets", presets);
response.put("defaultPresetId", settingsService.getDefaultSvnPresetId());
return response;
@@ -135,6 +163,11 @@ public class AppController {
return task;
}
@GetMapping(value = "/tasks/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamTask(@PathVariable("taskId") String taskId) {
return taskService.subscribeTaskStream(taskId);
}
@PostMapping("/tasks/{taskId}/cancel")
public Map<String, Object> cancelTask(@PathVariable("taskId") String taskId) {
final boolean cancelled = taskService.cancelTask(taskId);

View File

@@ -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() {

View File

@@ -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;
}

View File

@@ -0,0 +1,62 @@
package com.svnlog.web.dto;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public class SvnVersionRangeRequest {
@NotBlank
private String presetId;
@NotBlank
private String username;
@NotBlank
private String password;
@NotNull
private Integer year;
@NotNull
private Integer month;
public String getPresetId() {
return presetId;
}
public void setPresetId(String presetId) {
this.presetId = presetId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public Integer getYear() {
return year;
}
public void setYear(Integer year) {
this.year = year;
}
public Integer getMonth() {
return month;
}
public void setMonth(Integer month) {
this.month = month;
}
}

View File

@@ -0,0 +1,31 @@
package com.svnlog.web.model;
public class SvnPresetSummary {
private String id;
private String name;
public SvnPresetSummary() {
}
public SvnPresetSummary(String id, String name) {
this.id = id;
this.name = name;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -7,9 +7,15 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
@@ -42,6 +48,19 @@ import okhttp3.Response;
public class AiWorkflowService {
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
private static final String DEFAULT_TEAM = "系统部";
private static final String DEFAULT_CONTACT = "杨志强\n(系统平台组)";
private static final String DEFAULT_DEVELOPER = "刘靖";
private static final String[] FIXED_PROJECTS = {
"PRS-7050场站智慧管控",
"PRS-7950在线巡视",
"PRS-7950在线巡视电科院测试版"
};
private static final String FIXED_PROJECT_VALUE =
"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控";
private static final Pattern NUMBERED_ITEM_PATTERN = Pattern.compile("^\\s*(\\d+)[\\.、\\)]\\s*(.+)$");
private static final Pattern BULLET_ITEM_PATTERN = Pattern.compile("^\\s*[-*•]\\s*(.+)$");
private static final Pattern REVISION_ITEM_PATTERN = Pattern.compile("^\\s*(?:\\*\\s*)?r\\d+\\s*[-:]+\\s*(.+)$");
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
@@ -63,8 +82,14 @@ public class AiWorkflowService {
}
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
context.setProgress(10, "正在读取 Markdown 文件");
final Path outputRoot = outputFileService.getOutputRoot();
final List<String> requestedPaths = request.getFilePaths() == null
? java.util.Collections.<String>emptyList()
: request.getFilePaths();
context.setProgress(10, "正在读取 Markdown 文件,输出目录: " + outputRoot);
context.setProgress(12, "待处理文件: " + joinPaths(requestedPaths));
final List<Path> markdownFiles = resolveUserFiles(request.getFilePaths());
context.setProgress(18, "路径解析完成: " + joinResolvedPaths(markdownFiles));
aiInputValidator.validate(markdownFiles);
final String content = readMarkdownFiles(markdownFiles);
@@ -79,7 +104,7 @@ public class AiWorkflowService {
}
final String prompt = buildPrompt(content, period);
final String aiResponse = callDeepSeek(apiKey, prompt);
final String aiResponse = callDeepSeek(apiKey, prompt, context);
final JsonObject payload = extractJson(aiResponse);
context.setProgress(75, "正在生成 Excel 文件");
@@ -121,39 +146,101 @@ public class AiWorkflowService {
throw new IllegalArgumentException("文件路径不能为空");
}
final String normalizedInput = userPath.trim();
final Path outputRoot = outputFileService.getOutputRoot();
final Path rootPath = Paths.get("").toAbsolutePath().normalize();
final Path candidate = rootPath.resolve(userPath).normalize();
final Path docsRoot = rootPath.resolve("docs").normalize();
if (candidate.startsWith(outputRoot) || candidate.startsWith(rootPath.resolve("docs").normalize())) {
// 优先按输出目录相对路径解析(例如 md/*.md、excel/*.xlsx
final Path outputCandidate = outputFileService.resolveInOutput(normalizedInput);
if (Files.exists(outputCandidate) && Files.isRegularFile(outputCandidate)) {
return outputCandidate;
}
// 兼容绝对路径或历史路径输入,但仍限制在允许目录内
final Path raw = Paths.get(normalizedInput);
final Path candidate = raw.isAbsolute() ? raw.normalize() : rootPath.resolve(raw).normalize();
if (candidate.startsWith(outputRoot) || candidate.startsWith(docsRoot)) {
if (Files.exists(candidate) && Files.isRegularFile(candidate)) {
return candidate;
}
}
throw new IllegalArgumentException("文件不存在或不在允许目录:" + userPath);
final boolean outputCandidateExists = Files.exists(outputCandidate);
final boolean outputCandidateIsFile = outputCandidateExists && Files.isRegularFile(outputCandidate);
final boolean rootCandidateExists = Files.exists(candidate);
final boolean rootCandidateIsFile = rootCandidateExists && Files.isRegularFile(candidate);
throw new IllegalArgumentException(
"文件不存在或不在允许目录:" + normalizedInput
+ " | outputCandidate=" + outputCandidate
+ " (exists=" + outputCandidateExists + ", file=" + outputCandidateIsFile + ")"
+ " | rootCandidate=" + candidate
+ " (exists=" + rootCandidateExists + ", file=" + rootCandidateIsFile + ")"
+ " | outputRoot=" + outputRoot
+ " | docsRoot=" + docsRoot
);
}
private String joinPaths(List<String> paths) {
if (paths == null || paths.isEmpty()) {
return "(empty)";
}
final StringBuilder builder = new StringBuilder();
for (int i = 0; i < paths.size(); i++) {
if (i > 0) {
builder.append(", ");
}
builder.append(paths.get(i));
}
return builder.toString();
}
private String joinResolvedPaths(List<Path> paths) {
if (paths == null || paths.isEmpty()) {
return "(empty)";
}
final StringBuilder builder = new StringBuilder();
for (int i = 0; i < paths.size(); i++) {
if (i > 0) {
builder.append(", ");
}
builder.append(paths.get(i).toString());
}
return builder.toString();
}
private String buildPrompt(String markdownContent, String period) {
return "你是项目管理助手,请根据以下 SVN Markdown 日志生成工作量统计 JSON。\n"
+ "工作周期: " + period + "\n"
+ "要求:仅输出 JSON不要输出额外文字。\n"
+ "固定字段要求:\n"
+ "1. team 固定为 \"系统部\"\n"
+ "2. contact 固定为 \"杨志强\\n(系统平台组)\"\n"
+ "3. developer 固定为 \"刘靖\"\n"
+ "4. records 固定仅 1 条sequence 固定为 1\n"
+ "5. project 固定为 \"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控\"\n"
+ "6. content 必须按下面三段输出,且每段使用数字编号,不要包含 SVN 地址/仓库路径/版本范围/提交总数等元信息:\n"
+ "PRS-7050场站智慧管控\\n1. xxx\\n2. xxx\\n\\n"
+ "PRS-7950在线巡视\\n1. xxx\\n2. xxx\\n\\n"
+ "PRS-7950在线巡视电科院测试版\\n1. xxx\\n2. xxx\n"
+ "JSON结构:\n"
+ "{\n"
+ " \"team\": \"所属班组\",\n"
+ " \"contact\": \"技术对接人\",\n"
+ " \"developer\": \"开发人员\",\n"
+ " \"team\": \"系统部\",\n"
+ " \"contact\": \"杨志强\\n(系统平台组)\",\n"
+ " \"developer\": \"刘靖\",\n"
+ " \"period\": \"" + period + "\",\n"
+ " \"records\": [\n"
+ " {\"sequence\":1,\"project\":\"项目A/项目B\",\"content\":\"# 项目A\\n1.xxx\\n2.xxx\"}\n"
+ " {\"sequence\":1,\"project\":\"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控\",\"content\":\"PRS-7050场站智慧管控\\n1.xxx\\n2.xxx\\n\\nPRS-7950在线巡视\\n1.xxx\\n2.xxx\\n\\nPRS-7950在线巡视电科院测试版\\n1.xxx\"}\n"
+ " ]\n"
+ "}\n\n"
+ "日志内容:\n" + markdownContent;
}
private String callDeepSeek(String apiKey, String prompt) throws IOException {
private String callDeepSeek(String apiKey, String prompt, TaskContext context) throws IOException {
try {
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt), 3, 1000L);
return retrySupport.execute(() -> callDeepSeekOnce(apiKey, prompt, context), 3, 1000L);
} catch (IOException e) {
throw e;
} catch (Exception e) {
@@ -161,7 +248,7 @@ public class AiWorkflowService {
}
}
private String callDeepSeekOnce(String apiKey, String prompt) throws Exception {
private String callDeepSeekOnce(String apiKey, String prompt, TaskContext context) throws Exception {
final JsonObject message = new JsonObject();
message.addProperty("role", "user");
message.addProperty("content", prompt);
@@ -173,7 +260,13 @@ public class AiWorkflowService {
body.addProperty("model", "deepseek-reasoner");
body.add("messages", messages);
body.addProperty("max_tokens", 3500);
body.addProperty("stream", false);
body.addProperty("stream", true);
final JsonObject responseFormat = new JsonObject();
responseFormat.addProperty("type", "json_object");
body.add("response_format", responseFormat);
final JsonObject streamOptions = new JsonObject();
streamOptions.addProperty("include_usage", true);
body.add("stream_options", streamOptions);
final Request request = new Request.Builder()
.url(DEEPSEEK_API_URL)
@@ -198,19 +291,96 @@ public class AiWorkflowService {
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
}
final String raw = response.body().string();
final JsonObject data = JsonParser.parseString(raw).getAsJsonObject();
final StringBuilder answerBuilder = new StringBuilder();
final okhttp3.ResponseBody responseBody = response.body();
final okio.BufferedSource source = responseBody.source();
String finishReason = "";
context.emitEvent("phase", buildEventPayload("正在流式接收 DeepSeek 输出"));
while (!source.exhausted()) {
final String line = source.readUtf8Line();
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
continue;
}
final String dataLine = line.substring(5).trim();
if ("[DONE]".equals(dataLine)) {
break;
}
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
if (data.has("usage") && data.get("usage").isJsonObject()) {
final JsonObject usage = data.getAsJsonObject("usage");
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
usagePayload.put("promptTokens", optLong(usage, "prompt_tokens"));
usagePayload.put("completionTokens", optLong(usage, "completion_tokens"));
usagePayload.put("totalTokens", optLong(usage, "total_tokens"));
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
context.emitEvent("usage", usagePayload);
}
final JsonArray choices = data.getAsJsonArray("choices");
if (choices == null || choices.size() == 0) {
throw new IllegalStateException("DeepSeek API 未返回可用结果");
continue;
}
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 (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));
}
return messageObj.get("content").getAsString();
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();
}
}
if ("length".equalsIgnoreCase(finishReason)) {
throw new IllegalStateException("DeepSeek 输出被截断finish_reason=length请增大 max_tokens 或缩短输入");
}
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
throw new RetrySupport.RetryableException("DeepSeek 资源不足,请稍后重试");
}
final String answer = answerBuilder.toString().trim();
if (answer.isEmpty()) {
throw new IllegalStateException("DeepSeek 未返回有效 content 内容");
}
return answer;
}
}
private Map<String, Object> buildTextPayload(String text) {
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("text", text);
return payload;
}
private Map<String, Object> buildEventPayload(String message) {
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("message", message);
return payload;
}
private Long optLong(JsonObject object, String key) {
if (object == null || key == null || !object.has(key) || object.get(key).isJsonNull()) {
return null;
}
try {
return Long.valueOf(object.get(key).getAsLong());
} catch (Exception ignored) {
return null;
}
}
@@ -239,10 +409,14 @@ public class AiWorkflowService {
}
private void writeExcel(Path outputFile, JsonObject payload, String defaultPeriod) throws IOException {
final String team = optString(payload, "team");
final String contact = optString(payload, "contact");
final String developer = optString(payload, "developer");
final String period = payload.has("period") ? optString(payload, "period") : defaultPeriod;
final String period = payload.has("period")
? firstNonBlank(optString(payload, "period"), defaultPeriod)
: defaultPeriod;
final String team = DEFAULT_TEAM;
final String contact = DEFAULT_CONTACT;
final String developer = DEFAULT_DEVELOPER;
final String project = FIXED_PROJECT_VALUE;
final String content = buildContentFromPayload(payload);
try (Workbook workbook = new XSSFWorkbook()) {
final Sheet sheet = workbook.createSheet("工作量统计");
@@ -259,19 +433,15 @@ public class AiWorkflowService {
cell.setCellStyle(headerStyle);
}
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
for (int i = 0; i < records.size(); i++) {
final JsonObject record = records.get(i).getAsJsonObject();
final Row row = sheet.createRow(i + 1);
createCell(row, 0, getAsInt(record.get("sequence"), i + 1), textStyle);
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, optString(record, "project"), textStyle);
createCell(row, 6, optString(record, "content"), contentStyle);
}
createCell(row, 5, project, textStyle);
createCell(row, 6, content, contentStyle);
sheet.setColumnWidth(0, 2200);
sheet.setColumnWidth(1, 4200);
@@ -360,6 +530,164 @@ public class AiWorkflowService {
return object.get(key).getAsString();
}
private String buildContentFromPayload(JsonObject payload) {
final Map<String, LinkedHashSet<String>> groupedItems = createGroupedItems();
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
for (JsonElement element : records) {
if (element == null || !element.isJsonObject()) {
continue;
}
final JsonObject record = element.getAsJsonObject();
final String recordProject = optString(record, "project");
final String recordContent = optString(record, "content");
collectItems(groupedItems, recordProject, recordContent);
}
final StringBuilder builder = new StringBuilder();
for (String project : FIXED_PROJECTS) {
final LinkedHashSet<String> items = groupedItems.get(project);
if (items == null || items.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append("\n\n");
}
builder.append(project).append('\n');
int index = 1;
for (String item : items) {
builder.append(index++).append(". ").append(item).append('\n');
}
}
return builder.toString().trim();
}
private Map<String, LinkedHashSet<String>> createGroupedItems() {
final Map<String, LinkedHashSet<String>> groupedItems =
new LinkedHashMap<String, LinkedHashSet<String>>();
for (String project : FIXED_PROJECTS) {
groupedItems.put(project, new LinkedHashSet<String>());
}
return groupedItems;
}
private void collectItems(Map<String, LinkedHashSet<String>> groupedItems, String projectText, String content) {
final String fallbackProject = normalizeProject(projectText);
String currentProject = fallbackProject;
final String[] lines = content == null ? new String[0] : content.split("\\r?\\n");
for (String rawLine : lines) {
final String line = rawLine == null ? "" : rawLine.trim();
if (line.isEmpty()) {
continue;
}
final String headingProject = parseHeadingProject(line);
if (headingProject != null) {
currentProject = headingProject;
continue;
}
final String extracted = parseWorkItem(line);
if (extracted == null || extracted.isEmpty()) {
continue;
}
final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject;
groupedItems.get(targetProject).add(extracted);
}
}
private String parseHeadingProject(String line) {
if (line.startsWith("#")) {
final String stripped = line.replaceFirst("^#+\\s*", "");
return normalizeProject(stripped);
}
return normalizeProject(line);
}
private String parseWorkItem(String line) {
if (isMetaLine(line)) {
return null;
}
Matcher matcher = NUMBERED_ITEM_PATTERN.matcher(line);
if (matcher.matches()) {
return cleanWorkItem(matcher.group(2));
}
matcher = BULLET_ITEM_PATTERN.matcher(line);
if (matcher.matches()) {
return cleanWorkItem(matcher.group(1));
}
matcher = REVISION_ITEM_PATTERN.matcher(line);
if (matcher.matches()) {
return cleanWorkItem(matcher.group(1));
}
if (line.length() > 6 && !line.startsWith("=") && !line.startsWith("```")) {
return cleanWorkItem(line);
}
return null;
}
private boolean isMetaLine(String line) {
return line.startsWith("SVN")
|| line.startsWith("仓库")
|| line.startsWith("分支")
|| line.startsWith("版本")
|| line.startsWith("提交总数")
|| line.startsWith("日志详情")
|| line.startsWith("作者")
|| line.startsWith("时间")
|| line.startsWith("消息")
|| line.startsWith("文件")
|| line.startsWith("=== 文件:");
}
private String cleanWorkItem(String item) {
String cleaned = item == null ? "" : item.trim();
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
cleaned = cleaned.replaceAll("\\s+", " ");
return cleaned.trim();
}
private String normalizeProject(String value) {
if (value == null) {
return null;
}
final String input = value.trim();
if (input.isEmpty()) {
return null;
}
if (input.contains("7050")) {
return "PRS-7050场站智慧管控";
}
if (input.contains("电科院")) {
return "PRS-7950在线巡视电科院测试版";
}
if (input.contains("7950")) {
return "PRS-7950在线巡视";
}
return null;
}
private float calculateRowHeight(String content) {
final String safeContent = content == null ? "" : content;
final String[] lines = safeContent.split("\\r?\\n");
final int visibleLines = Math.max(lines.length, 1);
final float lineHeight = 19.0f;
final float minHeight = 220.0f;
return Math.max(minHeight, visibleLines * lineHeight);
}
private String firstNonBlank(String preferred, String fallback) {
if (preferred != null && !preferred.trim().isEmpty()) {
return preferred.trim();
}
return fallback == null ? "" : fallback;
}
private String sanitize(String value) {
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_");
}

View File

@@ -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;
}

View File

@@ -17,7 +17,7 @@ public class SettingsService {
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
this.outputFileService = outputFileService;
this.svnPresetService = svnPresetService;
this.defaultSvnPresetId = svnPresetService.firstPresetId();
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
}
public Map<String, Object> getSettings() throws IOException {
@@ -26,7 +26,7 @@ public class SettingsService {
final String activeKey = pickActiveKey(null);
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
result.put("apiKeySource", runtimeApiKey != null ? "runtime" : (envKey != null ? "env" : "none"));
result.put("apiKeySource", detectApiKeySource(envKey));
result.put("outputDir", outputFileService.getOutputRoot().toString());
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
return result;
@@ -58,6 +58,16 @@ public class SettingsService {
return null;
}
private String detectApiKeySource(String envKey) {
if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) {
return "runtime";
}
if (envKey != null && !envKey.trim().isEmpty()) {
return "env";
}
return "none";
}
public String getDefaultSvnPresetId() {
if (svnPresetService.containsPresetId(defaultSvnPresetId)) {
return defaultSvnPresetId;

View File

@@ -2,41 +2,66 @@ package com.svnlog.web.service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Service;
import com.svnlog.web.config.SvnPresetProperties;
import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.SvnPresetSummary;
@Service
public class SvnPresetService {
private final List<SvnPreset> presets;
private final String configuredDefaultPresetId;
public SvnPresetService() {
List<SvnPreset> list = new ArrayList<SvnPreset>();
list.add(new SvnPreset(
"preset-1",
"PRS-7050场站智慧管控",
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00/src_java"
));
list.add(new SvnPreset(
"preset-2",
"PRS-7950在线巡视",
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00/src_java"
));
list.add(new SvnPreset(
"preset-3",
"PRS-7950在线巡视电科院测试版",
"https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024/src_java"
));
public SvnPresetService(SvnPresetProperties properties) {
final List<SvnPreset> source = properties.getPresets() == null
? Collections.<SvnPreset>emptyList()
: properties.getPresets();
if (source.isEmpty()) {
throw new IllegalStateException("SVN 预设未配置,请检查 application.properties 中的 svn.presets");
}
final List<SvnPreset> list = new ArrayList<SvnPreset>();
final Set<String> ids = new HashSet<String>();
for (SvnPreset preset : source) {
final String id = trim(preset.getId());
final String name = trim(preset.getName());
final String url = trim(preset.getUrl());
if (id.isEmpty() || name.isEmpty() || url.isEmpty()) {
throw new IllegalStateException("SVN 预设配置不完整id/name/url 均不能为空");
}
if (!ids.add(id)) {
throw new IllegalStateException("SVN 预设 id 重复: " + id);
}
list.add(new SvnPreset(id, name, url));
}
this.presets = Collections.unmodifiableList(list);
final String configured = trim(properties.getDefaultPresetId());
if (!configured.isEmpty() && containsPresetId(configured)) {
this.configuredDefaultPresetId = configured;
} else {
this.configuredDefaultPresetId = this.presets.get(0).getId();
}
}
public List<SvnPreset> listPresets() {
return presets;
}
public List<SvnPresetSummary> listPresetSummaries() {
final List<SvnPresetSummary> summaries = new ArrayList<SvnPresetSummary>();
for (SvnPreset preset : presets) {
summaries.add(new SvnPresetSummary(preset.getId(), preset.getName()));
}
return summaries;
}
public boolean containsPresetId(String presetId) {
if (presetId == null || presetId.trim().isEmpty()) {
return false;
@@ -49,7 +74,25 @@ public class SvnPresetService {
return false;
}
public SvnPreset getById(String presetId) {
final String id = trim(presetId);
for (SvnPreset preset : presets) {
if (id.equals(preset.getId())) {
return preset;
}
}
throw new IllegalArgumentException("无效的 SVN 预设ID: " + presetId);
}
public String firstPresetId() {
return presets.isEmpty() ? "" : presets.get(0).getId();
}
public String configuredDefaultPresetId() {
return configuredDefaultPresetId;
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -1,9 +1,5 @@
package com.svnlog.web.service;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Date;
@@ -12,29 +8,35 @@ import java.util.List;
import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
import com.svnlog.LogEntry;
import com.svnlog.SVNLogFetcher;
import com.svnlog.core.report.MarkdownReportWriter;
import com.svnlog.core.svn.LogEntry;
import com.svnlog.core.svn.SVNLogFetcher;
import com.svnlog.web.dto.SvnConnectionRequest;
import com.svnlog.web.dto.SvnFetchRequest;
import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.TaskResult;
@Service
public class SvnWorkflowService {
private final OutputFileService outputFileService;
private final SvnPresetService svnPresetService;
public SvnWorkflowService(OutputFileService outputFileService) {
public SvnWorkflowService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
this.outputFileService = outputFileService;
this.svnPresetService = svnPresetService;
}
public void testConnection(SvnConnectionRequest request) throws SVNException {
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword());
fetcher.testConnection();
}
public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception {
context.setProgress(10, "正在连接 SVN 仓库");
final SVNLogFetcher fetcher = new SVNLogFetcher(request.getUrl(), request.getUsername(), request.getPassword());
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName());
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword());
fetcher.testConnection();
context.setProgress(30, "正在拉取 SVN 日志");
@@ -50,13 +52,25 @@ public class SvnWorkflowService {
context.setProgress(70, "正在生成 Markdown 文件");
final String projectName = request.getProjectName() != null && !request.getProjectName().trim().isEmpty()
? request.getProjectName().trim()
: "custom";
: preset.getName();
final String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
final String fileName = "md/svn_log_" + sanitize(projectName) + "_" + timestamp + ".md";
final Path outputPath = outputFileService.resolveInOutput(fileName);
Files.createDirectories(outputPath.getParent());
writeMarkdown(outputPath, request, start, end, logs, fetcher);
MarkdownReportWriter.write(
outputPath,
preset.getUrl(),
request.getUsername(),
start,
end,
request.getFilterUser(),
logs,
fetcher,
new MarkdownReportWriter.Options()
.includeAccount(true)
.includeGeneratedTime(true)
.includeStatistics(true)
);
context.setProgress(100, "SVN 日志导出完成");
final TaskResult result = new TaskResult("成功导出 " + logs.size() + " 条日志");
@@ -64,39 +78,6 @@ public class SvnWorkflowService {
return result;
}
private void writeMarkdown(Path path, SvnFetchRequest request, long startRevision, long endRevision,
List<LogEntry> logs, SVNLogFetcher fetcher) throws IOException {
final StringBuilder markdown = new StringBuilder();
markdown.append("# SVN 日志报告\n\n");
markdown.append("## 查询条件\n\n");
markdown.append("- **SVN地址**: `").append(request.getUrl()).append("`\n");
markdown.append("- **账号**: `").append(request.getUsername()).append("`\n");
markdown.append("- **版本范围**: r").append(startRevision).append(" - r").append(endRevision).append("\n");
if (!safe(request.getFilterUser()).isEmpty()) {
markdown.append("- **过滤用户**: `").append(request.getFilterUser()).append("`\n");
}
markdown.append("- **生成时间**: ").append(fetcher.formatDate(new Date())).append("\n\n");
markdown.append("## 统计信息\n\n");
markdown.append("- **总记录数**: ").append(logs.size()).append("\n\n");
markdown.append("## 日志详情\n\n");
for (LogEntry entry : logs) {
markdown.append("### r").append(entry.getRevision()).append("\n\n");
markdown.append("**作者**: `").append(entry.getAuthor()).append("` \n");
markdown.append("**时间**: ").append(fetcher.formatDate(entry.getDate())).append(" \n");
markdown.append("**版本**: r").append(entry.getRevision()).append("\n\n");
markdown.append("**提交信息**:\n\n");
markdown.append("```\n").append(safe(entry.getMessage())).append("\n```\n\n");
markdown.append("---\n\n");
}
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
writer.write(markdown.toString());
}
}
private String sanitize(String value) {
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_");
}

View File

@@ -1,15 +1,25 @@
package com.svnlog.web.service;
import java.util.HashMap;
import java.util.Map;
import com.svnlog.web.model.TaskInfo;
public class TaskContext {
@FunctionalInterface
public interface EventPublisher {
void publish(String eventName, Map<String, Object> payload);
}
private final TaskInfo taskInfo;
private final Runnable onUpdate;
private final EventPublisher eventPublisher;
public TaskContext(TaskInfo taskInfo, Runnable onUpdate) {
public TaskContext(TaskInfo taskInfo, Runnable onUpdate, EventPublisher eventPublisher) {
this.taskInfo = taskInfo;
this.onUpdate = onUpdate;
this.eventPublisher = eventPublisher;
}
public void setProgress(int progress, String message) {
@@ -20,5 +30,17 @@ public class TaskContext {
if (onUpdate != null) {
onUpdate.run();
}
final Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskInfo.getTaskId());
payload.put("status", taskInfo.getStatus() == null ? "" : taskInfo.getStatus().name());
payload.put("progress", taskInfo.getProgress());
payload.put("message", taskInfo.getMessage());
emitEvent("phase", payload);
}
public void emitEvent(String eventName, Map<String, Object> payload) {
if (eventPublisher != null && eventName != null && !eventName.trim().isEmpty()) {
eventPublisher.publish(eventName, payload == null ? new HashMap<String, Object>() : payload);
}
}
}

View File

@@ -3,9 +3,11 @@ package com.svnlog.web.service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import java.util.UUID;
import java.util.concurrent.Callable;
@@ -17,6 +19,7 @@ import java.util.concurrent.Future;
import javax.annotation.PreDestroy;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskPageResult;
@@ -33,6 +36,8 @@ public class TaskService {
private final ExecutorService executor = Executors.newFixedThreadPool(4);
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
private final Map<String, Future<?>> futures = new ConcurrentHashMap<String, Future<?>>();
private final Map<String, CopyOnWriteArrayList<SseEmitter>> taskEmitters =
new ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>>();
private final TaskPersistenceService persistenceService;
private final OutputFileService outputFileService;
@@ -56,6 +61,7 @@ public class TaskService {
taskInfo.setUpdatedAt(now);
tasks.put(taskId, taskInfo);
persistSafely();
publishTaskEvent(taskId, "phase", buildPhasePayload(taskInfo));
Future<?> future = executor.submit(new Callable<Void>() {
@Override
@@ -123,12 +129,44 @@ public class TaskService {
task.setMessage("任务已取消");
task.setUpdatedAt(Instant.now());
persistSafely();
publishTaskEvent(taskId, "error", buildTerminalPayload(task, task.getMessage()));
completeTaskStream(taskId);
return true;
}
public SseEmitter subscribeTaskStream(String taskId) {
final TaskInfo task = tasks.get(taskId);
if (task == null) {
throw new IllegalArgumentException("任务不存在: " + taskId);
}
final SseEmitter emitter = new SseEmitter(0L);
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.computeIfAbsent(
taskId, key -> new CopyOnWriteArrayList<SseEmitter>());
emitters.add(emitter);
emitter.onCompletion(() -> removeEmitter(taskId, emitter));
emitter.onTimeout(() -> removeEmitter(taskId, emitter));
emitter.onError(error -> removeEmitter(taskId, emitter));
try {
emitter.send(SseEmitter.event().name("phase").data(buildPhasePayload(task)));
} catch (Exception sendException) {
removeEmitter(taskId, emitter);
}
if (isTerminal(task.getStatus())) {
final String eventName = task.getStatus() == TaskStatus.SUCCESS ? "done" : "error";
publishTaskEvent(taskId, eventName, buildTerminalPayload(task, task.getError()));
completeTaskStream(taskId);
}
return emitter;
}
private void runTaskInternal(TaskInfo taskInfo, TaskRunner runner) {
try {
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
completeTaskStream(taskInfo.getTaskId());
return;
}
@@ -136,8 +174,13 @@ public class TaskService {
taskInfo.setMessage("任务执行中");
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
publishTaskEvent(taskInfo.getTaskId(), "phase", buildPhasePayload(taskInfo));
final TaskContext context = new TaskContext(taskInfo, this::persistSafely);
final TaskContext context = new TaskContext(
taskInfo,
this::persistSafely,
(eventName, payload) -> publishTaskEvent(taskInfo.getTaskId(), eventName, payload)
);
final TaskResult result = runner.run(context);
taskInfo.setStatus(TaskStatus.SUCCESS);
taskInfo.setProgress(100);
@@ -146,24 +189,29 @@ public class TaskService {
if (result != null && result.getFiles() != null) {
taskInfo.getFiles().addAll(result.getFiles());
}
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
publishTaskEvent(taskInfo.getTaskId(), "done", buildTerminalPayload(taskInfo, ""));
} catch (Exception e) {
if (taskInfo.getStatus() == TaskStatus.CANCELLED) {
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, "任务已取消"));
completeTaskStream(taskInfo.getTaskId());
return;
}
taskInfo.setStatus(TaskStatus.FAILED);
taskInfo.setError(e.getMessage());
taskInfo.setError(buildErrorMessage(e));
taskInfo.setMessage("执行失败");
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
publishTaskEvent(taskInfo.getTaskId(), "error", buildTerminalPayload(taskInfo, taskInfo.getError()));
completeTaskStream(taskInfo.getTaskId());
return;
} finally {
futures.remove(taskInfo.getTaskId());
}
taskInfo.setUpdatedAt(Instant.now());
persistSafely();
completeTaskStream(taskInfo.getTaskId());
}
private void loadPersistedTasks() {
@@ -226,6 +274,95 @@ public class TaskService {
return value != null && value.toLowerCase(Locale.ROOT).contains(keyword);
}
private String buildErrorMessage(Throwable throwable) {
if (throwable == null) {
return "未知异常";
}
final StringBuilder builder = new StringBuilder();
Throwable current = throwable;
int depth = 0;
while (current != null && depth < 5) {
if (depth > 0) {
builder.append(" | caused by: ");
}
final String className = current.getClass().getSimpleName();
final String message = current.getMessage() != null ? current.getMessage() : "(no message)";
builder.append(className).append(": ").append(message);
current = current.getCause();
depth++;
}
return builder.toString();
}
public void publishTaskEvent(String taskId, String eventName, Map<String, Object> payload) {
if (taskId == null || eventName == null || eventName.trim().isEmpty()) {
return;
}
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.get(taskId);
if (emitters == null || emitters.isEmpty()) {
return;
}
for (SseEmitter emitter : emitters) {
try {
emitter.send(SseEmitter.event().name(eventName).data(payload == null ? new HashMap<String, Object>() : payload));
} catch (Exception sendException) {
removeEmitter(taskId, emitter);
}
}
}
private void completeTaskStream(String taskId) {
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.remove(taskId);
if (emitters == null) {
return;
}
for (SseEmitter emitter : emitters) {
try {
emitter.complete();
} catch (Exception ignored) {
// ignore completion failures
}
}
}
private void removeEmitter(String taskId, SseEmitter emitter) {
final CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.get(taskId);
if (emitters == null) {
return;
}
emitters.remove(emitter);
if (emitters.isEmpty()) {
taskEmitters.remove(taskId);
}
}
private Map<String, Object> buildPhasePayload(TaskInfo taskInfo) {
final Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskInfo.getTaskId());
payload.put("type", taskInfo.getType());
payload.put("status", taskInfo.getStatus() == null ? "" : taskInfo.getStatus().name());
payload.put("progress", taskInfo.getProgress());
payload.put("message", taskInfo.getMessage());
payload.put("updatedAt", taskInfo.getUpdatedAt() == null ? "" : taskInfo.getUpdatedAt().toString());
return payload;
}
private Map<String, Object> buildTerminalPayload(TaskInfo taskInfo, String detail) {
final Map<String, Object> payload = buildPhasePayload(taskInfo);
payload.put("files", new ArrayList<String>(taskInfo.getFiles()));
if (detail != null && !detail.trim().isEmpty()) {
payload.put("detail", detail);
}
payload.put("error", taskInfo.getError());
return payload;
}
private boolean isTerminal(TaskStatus status) {
return status == TaskStatus.SUCCESS || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED;
}
@PreDestroy
public void destroy() {
executor.shutdownNow();

View File

@@ -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

View File

@@ -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 与输出目录" },
};
@@ -27,6 +26,20 @@ document.addEventListener("DOMContentLoaded", async () => {
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;
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 {
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({
projectName: payload.projectName || "",
url: payload.url,
username: payload.username,
password: payload.password,
startRevision: toNumberOrNull(payload.startRevision),
endRevision: toNumberOrNull(payload.endRevision),
presetId: project.presetId,
username: "liujing2",
password: "sunri@20230620*#&",
startRevision: toNumberOrNull(project.start),
endRevision: toNumberOrNull(project.end),
filterUser: payload.filterUser || "",
}),
});
toast(`SVN 抓取任务已创建:${data.taskId}`);
switchView("history");
refreshAll();
} catch (err) {
toast(err.message, true);
} finally {
setLoading(btn, false);
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));
}
}
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 = "<p class='muted'>暂无 Markdown 文件,请先执行 SVN 抓取。</p>";
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);
});
}
appendLog(`所有SVN抓取任务完成共生成 ${mdFiles.length} 个Markdown文件`);
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", {
// 调用AI分析接口
appendLog("正在提交AI分析任务...");
const aiData = 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 = "<p class='muted'>等待任务开始...</p>";
reasoning.innerHTML = "<p class='muted'>等待思考输出...</p>";
answer.innerHTML = "<p class='muted'>等待答案输出...</p>";
}
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);
}
}

View File

@@ -11,9 +11,8 @@
<aside class="sidebar" aria-label="主导航">
<h1>SVN 工作台</h1>
<nav>
<button class="nav-item active" data-view="dashboard">工作台</button>
<button class="nav-item" data-view="svn">SVN 日志抓取</button>
<button class="nav-item" data-view="ai">AI 工作量分析</button>
<button class="nav-item" data-view="dashboard">工作台</button>
<button class="nav-item active" data-view="svn">SVN 日志抓取</button>
<button class="nav-item" data-view="history">任务历史</button>
<button class="nav-item" data-view="settings">系统设置</button>
</nav>
@@ -64,41 +63,87 @@
<section class="view" id="view-svn">
<article class="card form-card">
<h3>SVN 抓取参数</h3>
<form id="svn-form" class="form-grid">
<label>预置项目
<select name="presetId" id="svn-preset-select" aria-label="预置 SVN 项目"></select>
<h3>SVN 批量抓取参数</h3>
<div class="alert info span-2" style="margin-bottom:16px;padding:12px;border-radius:10px;background:#d1f0eb;color:#0f766e">
默认已填充3个常用项目路径可选择月份自动填充版本号或手动填写
</div>
<div class="span-2" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:10px;">
<div class="grid cols-3" style="gap:10px;align-items:end;">
<label>统计月份
<input type="month" id="version-month">
</label>
<label>项目名<input name="projectName" placeholder="如PRS-7050"></label>
<label>SVN 地址<input required name="url" placeholder="https://..." aria-label="SVN 地址"></label>
<label>账号<input required name="username" placeholder="请输入账号"></label>
<label>密码<input required type="password" name="password" placeholder="请输入密码"></label>
<label>开始版本号<input name="startRevision" inputmode="numeric" placeholder="默认最新"></label>
<label>结束版本号<input name="endRevision" inputmode="numeric" placeholder="默认最新"></label>
<label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤"></label>
<div style="grid-column: span 2;">
<button type="button" id="btn-auto-fill" class="primary" style="width:100%">一键填充所有项目版本号</button>
</div>
</div>
</div>
<form id="svn-form" class="form-grid">
<!-- 项目1 -->
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px">
<h4 style="margin:0 0 10px 0">项目 1PRS-7050 场站智慧管控</h4>
<div class="grid cols-2" style="gap:10px">
<label>开始版本号<input name="startRevision_1" inputmode="numeric" placeholder="请输入开始版本"></label>
<label>结束版本号<input name="endRevision_1" inputmode="numeric" placeholder="请输入结束版本"></label>
</div>
</div>
<!-- 项目2 -->
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px">
<h4 style="margin:0 0 10px 0">项目 2PRS-7950 在线巡视</h4>
<div class="grid cols-2" style="gap:10px">
<label>开始版本号<input name="startRevision_2" inputmode="numeric" placeholder="请输入开始版本"></label>
<label>结束版本号<input name="endRevision_2" inputmode="numeric" placeholder="请输入结束版本"></label>
</div>
</div>
<!-- 项目3 -->
<div class="span-2 project-item" style="border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:12px">
<h4 style="margin:0 0 10px 0">项目 3PRS-7950 在线巡视电科院测试版</h4>
<div class="grid cols-2" style="gap:10px">
<label>开始版本号<input name="startRevision_3" inputmode="numeric" placeholder="请输入开始版本"></label>
<label>结束版本号<input name="endRevision_3" inputmode="numeric" placeholder="请输入结束版本"></label>
</div>
</div>
<!-- 通用配置 -->
<label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤" value="liujing"></label>
<label>工作周期<input name="period" placeholder="例如 2026年03月"></label>
<label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label>
<div class="actions span-2">
<button type="button" id="btn-test-connection">测试连接</button>
<button type="submit" id="btn-svn-run" class="primary">开始抓取并导出</button>
<button type="submit" id="btn-svn-run" class="primary">一键抓取并导出 Excel</button>
</div>
</form>
</article>
<!-- 执行日志面板 -->
<article class="card" id="log-panel" style="display:none;margin-top:16px;">
<h3>执行进度</h3>
<div class="live-grid">
<section class="live-column reasoning">
<header>思考过程</header>
<div id="reasoning-output" class="live-output">
<p class="muted">等待思考输出...</p>
</div>
</section>
<section class="live-column answer">
<header>最终输出</header>
<div id="answer-output" class="live-output">
<p class="muted">等待答案输出...</p>
</div>
</section>
</div>
<div class="system-log-wrap">
<header>系统日志</header>
<div id="system-log-output" class="system-output">
<p class="muted">等待任务开始...</p>
</div>
</div>
</article>
</section>
<section class="view" id="view-ai">
<article class="card form-card">
<h3>AI 分析参数</h3>
<form id="ai-form" class="form-grid">
<label class="span-2">选择 Markdown 输入文件</label>
<div class="span-2 file-picker" id="md-file-picker" role="group" aria-label="Markdown 文件选择"></div>
<label>工作周期<input name="period" placeholder="例如 2026年03月"></label>
<label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label>
<label class="span-2">临时 API Key可选<input type="password" name="apiKey" placeholder="优先使用设置页或环境变量"></label>
<div class="actions span-2">
<button type="submit" id="btn-ai-run" class="primary">开始 AI 分析并导出 Excel</button>
</div>
</form>
</article>
</section>
<section class="view" id="view-history">
<article class="card">

View File

@@ -368,6 +368,45 @@ td {
display: block;
}
.live-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.live-column header,
.system-log-wrap > header {
font-size: 13px;
font-weight: 700;
color: var(--muted);
margin-bottom: 6px;
}
.live-output,
.system-output {
height: 240px;
overflow-y: auto;
border-radius: 8px;
padding: 12px;
font-family: monospace;
font-size: 13px;
line-height: 1.6;
border: 1px solid var(--border);
background: #f8f9fa;
}
.live-column.reasoning .live-output {
background: #f6fbff;
}
.live-column.answer .live-output {
background: #f5fdf8;
}
.system-log-wrap {
margin-top: 10px;
}
@media (max-width: 1024px) {
.app-shell {
grid-template-columns: 1fr;
@@ -399,6 +438,12 @@ td {
}
}
@media (max-width: 900px) {
.live-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none !important;