diff --git a/AGENTS.md b/AGENTS.md
index b35a784..c1483bf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,56 +3,65 @@
目标:在不破坏现有行为的前提下,安全、可复现地修改本仓库。
## 1. 项目概览
-- 语言与构建:Java 8 + Maven(`pom.xml`)。
-- 打包产物:可执行 fat jar(`jar-with-dependencies`)。
-- 统一入口:`com.svnlog.web.WebApplication`(前后端一体,静态页面 + REST API)。
+- 产品名:SVN/Git 日报周报月报一键生成
+- 语言与构建:Java 8 + Maven(`backend/pom.xml`)
+- 打包产物:由 `backend/` 输出可执行 fat jar(`jar-with-dependencies`)
+- 统一入口:`com.svnlog.web.WebApplication`
- 核心目录:
- - `src/main/java/com/svnlog/`
+ - `backend/src/main/java/com/svnlog/core/`(svn / git / report)
+ - `backend/src/main/java/com/svnlog/web/`(controller / service / dto / model)
+ - `backend/src/test/java/`(后端测试)
- `docs/`
- - SVN 预设地址:`src/main/resources/application.properties`(`svn.presets[*]`)
+- SVN 预设:由 `SvnPresetService` 管理,当前为后端静态配置
## 2. 常用命令(Build / Lint / Test / Run)
以下命令默认在仓库根目录执行。
### 2.1 Build
-- 仅编译(推荐快速检查):`mvn clean compile`
-- 打包(跳过测试):`mvn clean package -DskipTests`
-- 打包(执行测试):`mvn clean package`
-- 产物(通常):`target/svn-log-tool-1.0.0-jar-with-dependencies.jar`
+- 后端仅编译:`cd backend && mvn clean compile`
+- 后端打包(跳过测试):`cd backend && mvn clean package -DskipTests`
+- 整体打包(推荐):`make build`
+- 产物(通常):`backend/target/svn-log-tool-1.0.0-jar-with-dependencies.jar`
### 2.2 Lint / 静态检查
-- 当前仓库未配置 Checkstyle / SpotBugs / PMD。
-- 将 `mvn clean compile` 作为基础语法与依赖检查。
-- 若需更严格检查,可使用 `mvn -DskipTests verify`。
+- 后端以 `cd backend && mvn clean compile` 作为基础语法检查。
+- 若需更严格检查,可使用 `cd backend && mvn -DskipTests verify`。
### 2.3 Test
-- 运行全部测试:`mvn test`
-- 运行单个测试类(重点):`mvn -Dtest=ClassName test`
-- 单测类示例:`mvn -Dtest=SVNLogFetcherTest test`
-- 运行单个测试方法(重点):`mvn -Dtest=ClassName#methodName test`
-- 单测方法示例:`mvn -Dtest=SVNLogFetcherTest#shouldFilterByUser test`
-- 调试失败测试(输出更完整):`mvn -Dtest=ClassName test -e`
-- 说明:当前 `src/test/java` 为空;新增测试时采用 Surefire 默认约定。
+- 运行后端全部测试:`cd backend && mvn test`
+- 运行单个测试类:`cd backend && mvn -Dtest=ClassName test`
+- 运行单个测试方法:`cd backend && mvn -Dtest=ClassName#methodName test`
+- 已有测试位于 `backend/src/test/java/com/svnlog/web/service/`
### 2.4 Run
-- 运行 Web 工作台(Docker,推荐):
+- 运行服务(Docker,推荐):
- `make up`
- `make status`
- `make down`
- 启动后访问:`http://localhost:18088`
-- 运行 Web 工作台(本机 Java + Maven,备用):
- - `mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication`
+- 运行后端(本机 Java + Maven):
+ - `cd backend && mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication`
## 3. 代码结构与职责边界
-- `SVNLogFetcher.java`:SVN 连接、版本区间处理、日志抓取、用户过滤。
-- `LogEntry.java`:日志数据模型(POJO)。
-- `web/controller/*`:REST API(SVN、AI、任务、文件、设置)。
-- `web/service/*`:异步任务与业务编排(SVN 抓取、AI 分析、输出目录管理)。
-- `src/main/resources/static/*`:Web 前端页面与交互脚本。
-- 变更原则:
- - 抓取逻辑改在 `SVNLogFetcher`。
- - AI/Excel 逻辑改在 `web/service/AiWorkflowService`。
- - 不把多种职责混入同一方法。
+
+### 核心层(`backend/src/main/java/com/svnlog/core/`)
+- `core/svn/SVNLogFetcher`:SVN 连接、版本区间处理、日志抓取、用户过滤
+- `core/git/GitLogFetcher`:Git 本地仓库日志抓取
+- `core/report/MarkdownReportWriter`:Markdown 报表生成
+
+### Web 层(`backend/src/main/java/com/svnlog/web/`)
+- `web/controller/AppController`:REST API 统一入口
+- `web/service/ReportWorkflowService`:统一报表生成工作流
+- `web/service/TaskService`:异步任务管理
+- `web/service/SettingsService`:API Key 与输出目录管理
+- `web/service/RepositoryConfigService`:仓库配置管理
+
+### 变更原则
+- SVN 抓取逻辑改在 `backend/src/main/java/com/svnlog/core/svn/`
+- Git 抓取逻辑改在 `backend/src/main/java/com/svnlog/core/git/`
+- 报表生成逻辑改在 `backend/src/main/java/com/svnlog/core/report/`
+- AI/Excel 逻辑改在 `backend/src/main/java/com/svnlog/web/service/AiWorkflowService`
+- 不把多种职责混入同一方法
## 4. 代码风格规范(必读)
@@ -71,78 +80,26 @@
### 4.3 格式与排版
- 缩进使用 4 个空格,不用 Tab。
- 单行长度建议 <= 120。
-- `if/for/while` 必须使用大括号(即使单行语句)。
-- 方法间保留一个空行;逻辑块间适度空行。
-
-### 4.4 类型与数据结构
-- 优先使用接口类型声明:`List` / `Map`,实现类写在右侧。
-- 泛型必须写完整,避免原生类型。
-- 可变共享状态最小化;能用 `final` 的局部变量尽量用 `final`。
-- 使用 `SimpleDateFormat` 等对象时,注意线程安全作用域。
-
-### 4.5 命名规范
-- 类名:`UpperCamelCase`(示例:`SVNLogFetcher`)。
-- 方法/变量:`lowerCamelCase`(示例:`fetchLogs`)。
-- 常量:`UPPER_SNAKE_CASE`(示例:`DEEPSEEK_API_URL`)。
-- 名称应表达业务意图,避免无语义缩写(如 `tmp`、`data1`)。
-
-### 4.6 异常与错误处理
-- 不吞异常;至少记录错误上下文。
-- CLI 提示要清晰,并给出可执行下一步。
-- 能在边界处校验输入时,尽早校验并快速失败。
-- 不要仅 `printStackTrace`;优先输出结构化错误信息。
-- 外部调用(SVN/API/文件)必须处理失败分支与空响应。
-
-### 4.7 I/O 与资源管理
-- 文件与网络资源统一使用 try-with-resources。
-- 路径与编码显式声明,默认 UTF-8。
-- 大文本拼接优先 `StringBuilder`。
-
-### 4.8 注释与文档
-- 注释解释“为什么”,避免重复“做了什么”。
-- 对外可见方法或复杂逻辑可补充简短 Javadoc。
-- 修改行为时同步更新 `docs/` 下文档。
+- `if/for/while` 必须使用大括号。
## 5. 安全与敏感信息
- 严禁提交真实密钥、口令、Token、内网敏感地址。
-- Web 端 AI 分析涉及 API Key;新增改动时应:
- - 优先从环境变量读取(如 `DEEPSEEK_API_KEY`)。
- - 回退到交互输入。
- - 不把真实值写入源码或日志。
-- 日志中避免打印完整凭据与隐私信息。
+- 优先从环境变量读取 API Key。
+- 不把真实值写入源码或日志。
## 6. 测试与验收建议
- 功能变更后至少执行:
- - `mvn clean compile`
- - `mvn test`(若已有测试)
-- 新增测试建议目录:`src/test/java/com/svnlog/`
-- 测试命名建议:
- - 类名:`<被测类名>Test`
- - 方法名:`should<行为>When<条件>`
+ - `cd backend && mvn clean compile`
+ - `cd backend && mvn test`(若已有测试)
## 7. Git 与提交建议(给 Agent)
- 小步提交,标题建议使用动词前缀:`fix:`、`feat:`、`refactor:`、`docs:`。
-- 一次提交只做一类改动(功能/重构/文档分离)。
+- 一次提交只做一类改动。
- 不顺手修改无关文件。
-- 提交前确认构建产物(`target/`)不入库。
-## 8. Cursor / Copilot 规则检查结果
-- 未发现 `.cursorrules`。
-- 未发现 `.cursor/rules/` 目录。
-- 未发现 `.github/copilot-instructions.md`。
-- 若后续新增这些规则文件,应同步更新本 AGENTS,并以更具体规则优先。
-
-## 9. 给自动化代理的执行清单
-- 先读 `pom.xml` 与目标类,再动代码。
+## 8. 给自动化代理的执行清单
+- 先读 `backend/pom.xml` 与目标类,再动代码。
- 先最小改动实现需求,再补测试与文档。
- 变更命令、入口、参数时必须更新本文档。
-- 无测试时至少确保 `mvn clean compile` 成功。
+- 无测试时至少确保 `cd backend && mvn clean compile` 成功。
- 输出结论时写明:改了什么、为什么、如何验证。
-
-## 10. 最小验证流程(建议)
-- 仅修改文档:至少检查 Markdown 渲染与命令可复制执行。
-- 修改 Java 代码:执行 `mvn clean compile`。
-- 涉及测试逻辑:执行 `mvn test` 或目标单测命令。
-- 涉及打包/入口:执行 `mvn clean package -DskipTests` 并验证产物。
-- 涉及 DeepSeek 调用:避免在 CI/自动化中使用真实密钥做在线调用。
-- 最终在变更说明中记录验证命令与结果。
diff --git a/Dockerfile b/Dockerfile
index afd37a7..a971bfa 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,15 +1,15 @@
-FROM maven:3.9.6-eclipse-temurin-8 AS builder
+FROM maven:3.9.6-eclipse-temurin-8 AS backend-builder
WORKDIR /app
-COPY pom.xml .
-COPY src ./src
-
-RUN mvn -DskipTests clean package
+COPY backend/pom.xml ./backend/pom.xml
+COPY backend/.mvn ./backend/.mvn
+COPY backend/src ./backend/src
+RUN cd backend && mvn -DskipTests clean package
FROM eclipse-temurin:8-jre
WORKDIR /app
-COPY --from=builder /app/target/svn-log-tool-1.0.0-jar-with-dependencies.jar app.jar
+COPY --from=backend-builder /app/backend/target/svn-log-tool-1.0.0-jar-with-dependencies.jar app.jar
EXPOSE 18088
diff --git a/Makefile b/Makefile
index 534a42a..2fc78a8 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,14 @@
-.PHONY: up down status
+.PHONY: up down status backend-build build release
COMPOSE_CMD := $(shell if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
+VERSION := 1.0.0
+JAR_NAME := svn-log-tool-$(VERSION)-jar-with-dependencies.jar
+
+backend-build:
+ @cd backend && mvn clean package -DskipTests
+
+build:
+ @cd backend && mvn clean package -DskipTests
up:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@@ -15,3 +23,17 @@ status:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) ps
@echo "Access URL: http://localhost:18088"
+
+release:
+ @echo "Building release packages..."
+ @cd backend && mvn clean package -DskipTests
+ @echo "Copying jar to release directories..."
+ @cp backend/target/$(JAR_NAME) release/windows/
+ @cp backend/target/$(JAR_NAME) release/unix/
+ @echo "Creating release archives..."
+ @cd release/windows && zip -r ../svn-log-tool-$(VERSION)-windows.zip * && cd ../..
+ @cd release/unix && tar czf ../svn-log-tool-$(VERSION)-unix.tar.gz * && cd ../..
+ @cd release/docker && cp ../../Dockerfile . && cp docker-compose.yml docker-compose.yml.release && tar czf ../svn-log-tool-$(VERSION)-docker.tar.gz * && rm Dockerfile docker-compose.yml.release && cd ../..
+ @echo "Release packages created in release/ directory:"
+ @ls -lh release/*.zip release/*.tar.gz 2>/dev/null || true
+ @echo "Done!"
diff --git a/README.md b/README.md
index 0b54ac2..44c2f2a 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,78 @@
-# svn-log-tool
+# SVN/Git 日报周报月报一键生成
-SVN 日志抓取与 AI 工作量分析工具,统一使用 Web 工作台入口。
+本地离线运行的代码仓库报表生成工具,支持 SVN 和 Git 仓库,一键生成日报、周报、月报。当前仓库仅保留 `Spring Boot` 后端与打包链路。
-## 入口
+## 核心能力
-- `com.svnlog.web.WebApplication`
+- SVN(远程)+ Git(本地路径)双仓库支持
+- 日报 / 周报 / 月报自动换算日期区间
+- Markdown + Excel 双产物导出
+- DeepSeek AI 摘要增强,可选开启
+- 异步任务管理、历史查询、文件下载
+- 单 jar、Docker、源码三种交付方式
-## 常用命令
+## 项目结构
+
+```text
+backend/ Spring Boot API 与打包入口
+docs/ 用户文档、销售文案、打包说明
+release/ 发行包模板目录
+```
+
+## 开发命令
+
+### 后端开发
```bash
-# 一键启动(Docker)
-make up
-
-# 查看状态
-make status
-
-# 一键关闭
-make down
-
-# 编译
+cd backend
mvn clean compile
-
-# 打包
-mvn clean package -DskipTests
-
-# 启动 Web(非 Docker 备用方式)
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 报告输出能力
+```bash
+make build
+```
-更多运行和功能说明见 `docs/`。
+或:
+
+```bash
+cd backend
+mvn clean package -DskipTests
+```
+
+说明:仓库已移除前端代码,`backend/pom.xml` 现在只负责后端构建与打包。
+仓库内置 `backend/.mvn/settings.xml`,默认使用国内镜像源完成 `maven` 依赖下载。
+
+## 运行方式
+
+### Docker 方式
+
+```bash
+make up
+```
+
+访问:`http://localhost:18088`
+
+### 本机运行
+
+```bash
+cd backend
+mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
+```
+
+## 文档
+
+- [发行版打包指南](docs/发行版打包指南.md)
+- [用户手册](docs/用户手册.md)
+- [快速开始](docs/快速开始.md)
+- [销售文案](docs/销售文案.md)
+
+## 技术栈
+
+- 后端:Java 8、Spring Boot 2.7.18
+- SVN:SVNKit
+- Git:JGit
+- Excel:Apache POI
+- AI:DeepSeek API、OkHttp、Gson
diff --git a/backend/.mvn/maven.config b/backend/.mvn/maven.config
new file mode 100644
index 0000000..7246ac4
--- /dev/null
+++ b/backend/.mvn/maven.config
@@ -0,0 +1 @@
+--settings=.mvn/settings.xml
diff --git a/backend/.mvn/settings.xml b/backend/.mvn/settings.xml
new file mode 100644
index 0000000..6ec0e48
--- /dev/null
+++ b/backend/.mvn/settings.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ aliyun-public
+ Aliyun Maven Public Mirror
+ https://maven.aliyun.com/repository/public
+ *
+
+
+
diff --git a/pom.xml b/backend/pom.xml
similarity index 85%
rename from pom.xml
rename to backend/pom.xml
index ed808dd..29be38e 100644
--- a/pom.xml
+++ b/backend/pom.xml
@@ -21,14 +21,18 @@
-
org.tmatesoft.svnkit
svnkit
1.10.11
-
+
+ org.eclipse.jgit
+ org.eclipse.jgit
+ 5.13.2.202306221912-r
+
+
org.apache.poi
poi
@@ -40,21 +44,30 @@
5.2.5
-
com.squareup.okhttp3
okhttp
4.12.0
-
com.google.code.gson
gson
2.10.1
-
+
+ org.commonmark
+ commonmark
+ 0.21.0
+
+
+
+ org.xhtmlrenderer
+ flying-saucer-pdf-openpdf
+ 9.1.22
+
+
org.springframework.boot
spring-boot-starter-web
@@ -73,6 +86,13 @@
${spring-boot.version}
test
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
@@ -87,7 +107,7 @@
UTF-8
-
+
org.apache.maven.plugins
maven-jar-plugin
diff --git a/backend/src/main/java/com/svnlog/core/git/GitLogFetcher.java b/backend/src/main/java/com/svnlog/core/git/GitLogFetcher.java
new file mode 100644
index 0000000..8d0bf68
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/core/git/GitLogFetcher.java
@@ -0,0 +1,137 @@
+package com.svnlog.core.git;
+
+import java.io.File;
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.LogCommand;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.springframework.stereotype.Component;
+
+import com.svnlog.core.report.CommitRecord;
+import com.svnlog.core.report.RepositoryType;
+
+@Component
+public class GitLogFetcher {
+
+ private static final ZoneId ZONE_ID = ZoneId.systemDefault();
+
+ public List fetchLogs(String repoPath,
+ String branch,
+ LocalDate startDate,
+ LocalDate endDate,
+ String filterAuthor) throws Exception {
+ validate(repoPath, startDate, endDate);
+
+ final File repoDir = new File(repoPath.trim());
+ final List records = new ArrayList();
+
+ try (Git git = Git.open(repoDir)) {
+ final Repository repository = git.getRepository();
+ final LogCommand logCommand = git.log();
+ final String effectiveBranch = branch == null || branch.trim().isEmpty() ? "HEAD" : branch.trim();
+ final ObjectId branchId = repository.resolve(effectiveBranch);
+ if (branchId == null) {
+ throw new IllegalArgumentException("无法解析 Git 分支或引用: " + effectiveBranch);
+ }
+ logCommand.add(branchId);
+
+ for (RevCommit commit : logCommand.call()) {
+ final Date committedAt = new Date(commit.getCommitTime() * 1000L);
+ if (!isWithinRange(committedAt, startDate, endDate)) {
+ continue;
+ }
+
+ final String author = commit.getAuthorIdent() == null ? "" : commit.getAuthorIdent().getName();
+ if (!matchesAuthor(author, filterAuthor)) {
+ continue;
+ }
+
+ final CommitRecord record = new CommitRecord();
+ record.setRepositoryType(RepositoryType.GIT);
+ record.setRepositoryName(resolveRepositoryName(repository, repoDir));
+ record.setRepositoryRef(effectiveBranch);
+ record.setRevision(commit.getName().substring(0, 8));
+ record.setAuthor(author);
+ record.setCommittedAt(committedAt);
+ record.setMessage(commit.getFullMessage() == null ? "" : commit.getFullMessage().trim());
+ records.add(record);
+ }
+ }
+
+ records.sort(new Comparator() {
+ @Override
+ public int compare(CommitRecord left, CommitRecord right) {
+ final Date leftDate = left.getCommittedAt();
+ final Date rightDate = right.getCommittedAt();
+ if (leftDate == null && rightDate == null) {
+ return 0;
+ }
+ if (leftDate == null) {
+ return 1;
+ }
+ if (rightDate == null) {
+ return -1;
+ }
+ return rightDate.compareTo(leftDate);
+ }
+ });
+ return records;
+ }
+
+ public void testConnection(String repoPath) throws IOException {
+ if (repoPath == null || repoPath.trim().isEmpty()) {
+ throw new IllegalArgumentException("Git 仓库路径不能为空");
+ }
+ final File repoDir = new File(repoPath.trim());
+ if (!repoDir.exists()) {
+ throw new IllegalArgumentException("Git 仓库路径不存在: " + repoDir.getAbsolutePath());
+ }
+ try (Git ignored = Git.open(repoDir)) {
+ // open success means valid local repository
+ }
+ }
+
+ private void validate(String repoPath, LocalDate startDate, LocalDate endDate) {
+ if (repoPath == null || repoPath.trim().isEmpty()) {
+ throw new IllegalArgumentException("Git 仓库路径不能为空");
+ }
+ if (startDate == null || endDate == null) {
+ throw new IllegalArgumentException("Git 时间范围不能为空");
+ }
+ if (startDate.isAfter(endDate)) {
+ throw new IllegalArgumentException("开始日期不能晚于结束日期");
+ }
+ }
+
+ private boolean isWithinRange(Date committedAt, LocalDate startDate, LocalDate endDate) {
+ if (committedAt == null) {
+ return false;
+ }
+ final LocalDate committedDate = committedAt.toInstant().atZone(ZONE_ID).toLocalDate();
+ return !committedDate.isBefore(startDate) && !committedDate.isAfter(endDate);
+ }
+
+ private boolean matchesAuthor(String author, String filterAuthor) {
+ if (filterAuthor == null || filterAuthor.trim().isEmpty()) {
+ return true;
+ }
+ return author != null && author.toLowerCase().contains(filterAuthor.trim().toLowerCase());
+ }
+
+ private String resolveRepositoryName(Repository repository, File repoDir) {
+ final String workTreeName = repository.getWorkTree() != null ? repository.getWorkTree().getName() : "";
+ if (workTreeName != null && !workTreeName.trim().isEmpty()) {
+ return workTreeName;
+ }
+ return repoDir.getName();
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/core/report/CommitRecord.java b/backend/src/main/java/com/svnlog/core/report/CommitRecord.java
new file mode 100644
index 0000000..6e0326a
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/core/report/CommitRecord.java
@@ -0,0 +1,77 @@
+package com.svnlog.core.report;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+public class CommitRecord {
+
+ private RepositoryType repositoryType;
+ private String repositoryName;
+ private String repositoryRef;
+ private String revision;
+ private String author;
+ private Date committedAt;
+ private String message;
+ private final List changedPaths = new ArrayList();
+
+ public RepositoryType getRepositoryType() {
+ return repositoryType;
+ }
+
+ public void setRepositoryType(RepositoryType repositoryType) {
+ this.repositoryType = repositoryType;
+ }
+
+ public String getRepositoryName() {
+ return repositoryName;
+ }
+
+ public void setRepositoryName(String repositoryName) {
+ this.repositoryName = repositoryName;
+ }
+
+ public String getRepositoryRef() {
+ return repositoryRef;
+ }
+
+ public void setRepositoryRef(String repositoryRef) {
+ this.repositoryRef = repositoryRef;
+ }
+
+ public String getRevision() {
+ return revision;
+ }
+
+ public void setRevision(String revision) {
+ this.revision = revision;
+ }
+
+ public String getAuthor() {
+ return author;
+ }
+
+ public void setAuthor(String author) {
+ this.author = author;
+ }
+
+ public Date getCommittedAt() {
+ return committedAt;
+ }
+
+ public void setCommittedAt(Date committedAt) {
+ this.committedAt = committedAt;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public List getChangedPaths() {
+ return changedPaths;
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/core/report/MarkdownReportWriter.java b/backend/src/main/java/com/svnlog/core/report/MarkdownReportWriter.java
new file mode 100644
index 0000000..2401de3
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/core/report/MarkdownReportWriter.java
@@ -0,0 +1,226 @@
+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.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+public final class MarkdownReportWriter {
+
+ private static final SimpleDateFormat DATE_TIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+
+ public static final class Options {
+ private boolean includeGeneratedTime;
+ private boolean includeStatistics;
+ private String sourceLabel;
+ private String filterLabel;
+ private ReportSummary summary;
+
+ public Options includeGeneratedTime(boolean includeGeneratedTime) {
+ this.includeGeneratedTime = includeGeneratedTime;
+ return this;
+ }
+
+ public Options includeStatistics(boolean includeStatistics) {
+ this.includeStatistics = includeStatistics;
+ return this;
+ }
+
+ public Options sourceLabel(String sourceLabel) {
+ this.sourceLabel = sourceLabel;
+ return this;
+ }
+
+ public Options filterLabel(String filterLabel) {
+ this.filterLabel = filterLabel;
+ return this;
+ }
+
+ public Options summary(ReportSummary summary) {
+ this.summary = summary;
+ return this;
+ }
+ }
+
+ private MarkdownReportWriter() {
+ }
+
+ public static void write(Path path,
+ String title,
+ String repositoryName,
+ String periodLabel,
+ List commits,
+ Options options) throws IOException {
+ final Options effectiveOptions = options == null ? new Options() : options;
+ final StringBuilder markdown = new StringBuilder();
+
+ markdown.append("# ").append(safe(title)).append("\n\n");
+ markdown.append("## 报告信息\n\n");
+ markdown.append("- **仓库**: `").append(safe(repositoryName)).append("`\n");
+ markdown.append("- **周期**: ").append(safe(periodLabel)).append("\n");
+ if (!safe(effectiveOptions.sourceLabel).isEmpty()) {
+ markdown.append("- **来源**: ").append(safe(effectiveOptions.sourceLabel)).append("\n");
+ }
+ if (!safe(effectiveOptions.filterLabel).isEmpty()) {
+ markdown.append("- **作者过滤**: `").append(safe(effectiveOptions.filterLabel)).append("`\n");
+ }
+ if (effectiveOptions.includeGeneratedTime) {
+ markdown.append("- **生成时间**: ").append(DATE_TIME_FORMAT.format(new Date())).append("\n");
+ }
+ markdown.append("\n");
+
+ if (effectiveOptions.summary != null) {
+ appendSummary(markdown, effectiveOptions.summary);
+ }
+
+ if (effectiveOptions.includeStatistics) {
+ appendStatistics(markdown, commits);
+ }
+
+ markdown.append("## 提交明细\n\n");
+ if (commits == null || commits.isEmpty()) {
+ markdown.append("> 本周期没有匹配的提交记录。\n");
+ } else {
+ for (CommitRecord commit : commits) {
+ markdown.append("### ").append(safe(commit.getRevision())).append("\n\n");
+ markdown.append("**作者**: `").append(safe(commit.getAuthor())).append("` \n");
+ markdown.append("**时间**: ").append(formatDate(commit.getCommittedAt())).append(" \n");
+ markdown.append("**仓库**: `").append(safe(commit.getRepositoryName())).append("` \n");
+ if (!safe(commit.getRepositoryRef()).isEmpty()) {
+ markdown.append("**引用**: `").append(safe(commit.getRepositoryRef())).append("` \n");
+ }
+ markdown.append("\n**提交信息**:\n\n");
+ markdown.append("```\n").append(safe(commit.getMessage())).append("\n```\n\n");
+ if (!commit.getChangedPaths().isEmpty()) {
+ markdown.append("**变更路径**:\n");
+ for (String pathValue : commit.getChangedPaths()) {
+ markdown.append("- `").append(safe(pathValue)).append("`\n");
+ }
+ markdown.append("\n");
+ }
+ markdown.append("---\n\n");
+ }
+ }
+
+ Files.createDirectories(path.getParent());
+ Files.write(path, markdown.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static void appendSummary(StringBuilder markdown, ReportSummary summary) {
+ markdown.append("## 工作摘要\n\n");
+ if (summary.getHighlights().isEmpty()) {
+ markdown.append("- 暂无摘要\n");
+ } else {
+ for (String highlight : summary.getHighlights()) {
+ markdown.append("- ").append(safe(highlight)).append("\n");
+ }
+ }
+ if (!summary.getRisks().isEmpty()) {
+ markdown.append("\n## 风险与说明\n\n");
+ for (String risk : summary.getRisks()) {
+ markdown.append("- ").append(safe(risk)).append("\n");
+ }
+ }
+ markdown.append("\n");
+ }
+
+ private static void appendStatistics(StringBuilder markdown, List commits) {
+ markdown.append("## 统计信息\n\n");
+
+ final int totalCommits = commits == null ? 0 : commits.size();
+ markdown.append("- **总提交数**: ").append(totalCommits).append("\n");
+
+ if (commits != null && !commits.isEmpty()) {
+ // 统计涉及文件数
+ final java.util.Set uniquePaths = new java.util.HashSet();
+ for (CommitRecord commit : commits) {
+ uniquePaths.addAll(commit.getChangedPaths());
+ }
+ markdown.append("- **涉及文件数**: ").append(uniquePaths.size()).append("\n");
+
+ // 统计活跃作者
+ final java.util.Map authorCounts = new java.util.LinkedHashMap();
+ for (CommitRecord commit : commits) {
+ final String author = safe(commit.getAuthor());
+ if (!author.isEmpty()) {
+ authorCounts.put(author, authorCounts.getOrDefault(author, 0) + 1);
+ }
+ }
+
+ if (!authorCounts.isEmpty()) {
+ markdown.append("- **活跃作者数**: ").append(authorCounts.size()).append("\n");
+ markdown.append("- **作者提交分布**:\n");
+
+ // 按提交数降序排列
+ final java.util.List> sortedAuthors =
+ new java.util.ArrayList>(authorCounts.entrySet());
+ sortedAuthors.sort(new java.util.Comparator>() {
+ public int compare(java.util.Map.Entry a, java.util.Map.Entry b) {
+ return b.getValue().compareTo(a.getValue());
+ }
+ });
+
+ for (java.util.Map.Entry entry : sortedAuthors) {
+ markdown.append(" - `").append(entry.getKey()).append("`: ")
+ .append(entry.getValue()).append(" 次\n");
+ }
+ }
+
+ // 统计涉及模块(基于路径前缀)
+ final java.util.Map moduleCounts = new java.util.LinkedHashMap();
+ for (String path : uniquePaths) {
+ final String module = extractModule(path);
+ if (!module.isEmpty()) {
+ moduleCounts.put(module, moduleCounts.getOrDefault(module, 0) + 1);
+ }
+ }
+
+ if (!moduleCounts.isEmpty()) {
+ markdown.append("- **涉及模块**:\n");
+
+ // 按文件数降序排列
+ final java.util.List> sortedModules =
+ new java.util.ArrayList>(moduleCounts.entrySet());
+ sortedModules.sort(new java.util.Comparator>() {
+ public int compare(java.util.Map.Entry a, java.util.Map.Entry b) {
+ return b.getValue().compareTo(a.getValue());
+ }
+ });
+
+ for (java.util.Map.Entry entry : sortedModules) {
+ markdown.append(" - `").append(entry.getKey()).append("`: ")
+ .append(entry.getValue()).append(" 个文件\n");
+ }
+ }
+ }
+
+ markdown.append("\n");
+ }
+
+ private static String extractModule(String path) {
+ if (path == null || path.isEmpty()) {
+ return "";
+ }
+ // 提取第一级目录作为模块名
+ final String normalized = path.startsWith("/") ? path.substring(1) : path;
+ final int slashIndex = normalized.indexOf('/');
+ if (slashIndex > 0) {
+ return normalized.substring(0, slashIndex);
+ }
+ return normalized;
+ }
+
+ private static String formatDate(Date value) {
+ if (value == null) {
+ return "";
+ }
+ return DATE_TIME_FORMAT.format(value);
+ }
+
+ private static String safe(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/core/report/PdfReportExporter.java b/backend/src/main/java/com/svnlog/core/report/PdfReportExporter.java
new file mode 100644
index 0000000..023e0ed
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/core/report/PdfReportExporter.java
@@ -0,0 +1,176 @@
+package com.svnlog.core.report;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+import org.xhtmlrenderer.pdf.ITextFontResolver;
+import org.xhtmlrenderer.pdf.ITextRenderer;
+
+import com.lowagie.text.DocumentException;
+import com.lowagie.text.pdf.BaseFont;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 将 Markdown 报表转换为 PDF 文件。
+ * 使用 commonmark 解析 Markdown,Flying Saucer + OpenPDF 渲染 PDF。
+ */
+public final class PdfReportExporter {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PdfReportExporter.class);
+
+ private static final String[] CHINESE_FONT_PATHS = {
+ // Windows
+ "C:/Windows/Fonts/msyh.ttc",
+ "C:/Windows/Fonts/simsun.ttc",
+ "C:/Windows/Fonts/simhei.ttf",
+ // macOS
+ "/System/Library/Fonts/PingFang.ttc",
+ "/System/Library/Fonts/STHeiti Light.ttc",
+ "/Library/Fonts/Arial Unicode.ttf",
+ // Linux
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
+ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
+ "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
+ };
+
+ private PdfReportExporter() {
+ }
+
+ /**
+ * 将 Markdown 文件转换为 PDF。
+ *
+ * @param markdownPath 输入 Markdown 文件路径
+ * @param pdfPath 输出 PDF 文件路径
+ * @throws IOException 文件读写异常
+ */
+ public static void export(Path markdownPath, Path pdfPath) throws IOException {
+ final String markdown = new String(Files.readAllBytes(markdownPath), StandardCharsets.UTF_8);
+ exportFromContent(markdown, pdfPath);
+ }
+
+ /**
+ * 将 Markdown 内容字符串转换为 PDF。
+ *
+ * @param markdownContent Markdown 文本内容
+ * @param pdfPath 输出 PDF 文件路径
+ * @throws IOException 文件读写异常
+ */
+ public static void exportFromContent(String markdownContent, Path pdfPath) throws IOException {
+ final String html = markdownToHtml(markdownContent);
+ final String xhtml = wrapAsXhtml(html);
+
+ Files.createDirectories(pdfPath.getParent());
+
+ try (OutputStream out = new FileOutputStream(pdfPath.toFile())) {
+ final ITextRenderer renderer = new ITextRenderer();
+ registerChineseFonts(renderer);
+ renderer.setDocumentFromString(xhtml);
+ renderer.layout();
+ renderer.createPDF(out);
+ } catch (DocumentException e) {
+ throw new IOException("PDF 渲染失败: " + e.getMessage(), e);
+ }
+ }
+
+ private static String markdownToHtml(String markdown) {
+ final Parser parser = Parser.builder().build();
+ final Node document = parser.parse(markdown);
+ final HtmlRenderer renderer = HtmlRenderer.builder().build();
+ return renderer.render(document);
+ }
+
+ private static String wrapAsXhtml(String bodyHtml) {
+ return "\n"
+ + "\n"
+ + "\n"
+ + "\n"
+ + "\n"
+ + "\n"
+ + "\n"
+ + "\n"
+ + bodyHtml
+ + "\n"
+ + "";
+ }
+
+ private static void registerChineseFonts(ITextRenderer renderer) {
+ final ITextFontResolver fontResolver = renderer.getFontResolver();
+ boolean fontRegistered = false;
+
+ for (String fontPath : CHINESE_FONT_PATHS) {
+ final java.io.File fontFile = new java.io.File(fontPath);
+ if (fontFile.exists()) {
+ try {
+ fontResolver.addFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
+ LOGGER.info("已注册中文字体: {}", fontPath);
+ fontRegistered = true;
+ break;
+ } catch (Exception e) {
+ LOGGER.debug("字体注册失败: {} - {}", fontPath, e.getMessage());
+ }
+ }
+ }
+
+ if (!fontRegistered) {
+ LOGGER.warn("未找到可用的中文字体,PDF 中的中文可能无法正常显示。"
+ + "建议安装 wqy-microhei 或 noto-cjk 字体包。");
+ }
+ }
+
+ private static final String PDF_CSS = ""
+ + "body {\n"
+ + " font-family: 'WenQuanYi Micro Hei', 'Noto Sans CJK SC', 'Microsoft YaHei',\n"
+ + " 'PingFang SC', 'SimHei', 'SimSun', sans-serif;\n"
+ + " font-size: 11pt;\n"
+ + " line-height: 1.6;\n"
+ + " color: #333;\n"
+ + " margin: 40px;\n"
+ + "}\n"
+ + "h1 { font-size: 18pt; border-bottom: 2px solid #2a6496; padding-bottom: 6px; }\n"
+ + "h2 { font-size: 14pt; color: #2a6496; margin-top: 20px; }\n"
+ + "h3 { font-size: 12pt; color: #555; }\n"
+ + "code {\n"
+ + " font-family: 'JetBrains Mono', 'Consolas', 'Courier New', monospace;\n"
+ + " background: #f5f5f5;\n"
+ + " padding: 1px 4px;\n"
+ + " border-radius: 3px;\n"
+ + " font-size: 10pt;\n"
+ + "}\n"
+ + "pre {\n"
+ + " background: #f8f8f8;\n"
+ + " border: 1px solid #ddd;\n"
+ + " border-radius: 4px;\n"
+ + " padding: 10px;\n"
+ + " font-size: 9pt;\n"
+ + " overflow: hidden;\n"
+ + " word-wrap: break-word;\n"
+ + "}\n"
+ + "pre code { background: none; padding: 0; }\n"
+ + "ul { padding-left: 20px; }\n"
+ + "li { margin-bottom: 3px; }\n"
+ + "hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; }\n"
+ + "blockquote {\n"
+ + " border-left: 3px solid #ccc;\n"
+ + " padding-left: 12px;\n"
+ + " color: #666;\n"
+ + " margin: 10px 0;\n"
+ + "}\n"
+ + "strong { color: #222; }\n"
+ + "table { border-collapse: collapse; width: 100%; margin: 10px 0; }\n"
+ + "th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 10pt; }\n"
+ + "th { background: #f0f0f0; }\n";
+}
diff --git a/backend/src/main/java/com/svnlog/core/report/ReportPeriodRange.java b/backend/src/main/java/com/svnlog/core/report/ReportPeriodRange.java
new file mode 100644
index 0000000..e8c2015
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/core/report/ReportPeriodRange.java
@@ -0,0 +1,34 @@
+package com.svnlog.core.report;
+
+import java.time.LocalDate;
+
+public class ReportPeriodRange {
+
+ private final ReportPeriodType periodType;
+ private final LocalDate startDate;
+ private final LocalDate endDate;
+ private final String label;
+
+ public ReportPeriodRange(ReportPeriodType periodType, LocalDate startDate, LocalDate endDate, String label) {
+ this.periodType = periodType;
+ this.startDate = startDate;
+ this.endDate = endDate;
+ this.label = label;
+ }
+
+ public ReportPeriodType getPeriodType() {
+ return periodType;
+ }
+
+ public LocalDate getStartDate() {
+ return startDate;
+ }
+
+ public LocalDate getEndDate() {
+ return endDate;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/core/report/ReportPeriodType.java b/backend/src/main/java/com/svnlog/core/report/ReportPeriodType.java
new file mode 100644
index 0000000..3868571
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/core/report/ReportPeriodType.java
@@ -0,0 +1,14 @@
+package com.svnlog.core.report;
+
+public enum ReportPeriodType {
+ DAILY,
+ WEEKLY,
+ MONTHLY;
+
+ public static ReportPeriodType from(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException("报表周期不能为空");
+ }
+ return ReportPeriodType.valueOf(value.trim().toUpperCase());
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/core/report/ReportSummary.java b/backend/src/main/java/com/svnlog/core/report/ReportSummary.java
new file mode 100644
index 0000000..ef340e7
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/core/report/ReportSummary.java
@@ -0,0 +1,18 @@
+package com.svnlog.core.report;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ReportSummary {
+
+ private final List highlights = new ArrayList();
+ private final List risks = new ArrayList();
+
+ public List getHighlights() {
+ return highlights;
+ }
+
+ public List getRisks() {
+ return risks;
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/core/report/RepositoryType.java b/backend/src/main/java/com/svnlog/core/report/RepositoryType.java
new file mode 100644
index 0000000..771db5a
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/core/report/RepositoryType.java
@@ -0,0 +1,13 @@
+package com.svnlog.core.report;
+
+public enum RepositoryType {
+ SVN,
+ GIT;
+
+ public static RepositoryType from(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException("仓库类型不能为空");
+ }
+ return RepositoryType.valueOf(value.trim().toUpperCase());
+ }
+}
diff --git a/src/main/java/com/svnlog/core/svn/LogEntry.java b/backend/src/main/java/com/svnlog/core/svn/LogEntry.java
similarity index 100%
rename from src/main/java/com/svnlog/core/svn/LogEntry.java
rename to backend/src/main/java/com/svnlog/core/svn/LogEntry.java
diff --git a/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java b/backend/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java
similarity index 92%
rename from src/main/java/com/svnlog/core/svn/SVNLogFetcher.java
rename to backend/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java
index 7448471..984e73b 100644
--- a/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java
+++ b/backend/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java
@@ -114,6 +114,23 @@ public class SVNLogFetcher {
return entries;
}
+ public List fetchLogsByDateRange(Date startDate, Date endDate, String filterUser) throws SVNException {
+ final List allEntries = fetchLogs(1L, repository.getLatestRevision(), filterUser);
+ final List filtered = new ArrayList();
+ final long startMillis = startDate == null ? Long.MIN_VALUE : startDate.getTime();
+ final long endMillis = endDate == null ? Long.MAX_VALUE : endDate.getTime();
+ for (LogEntry entry : allEntries) {
+ if (entry.getDate() == null) {
+ continue;
+ }
+ final long committedAt = entry.getDate().getTime();
+ if (committedAt >= startMillis && committedAt < endMillis) {
+ filtered.add(entry);
+ }
+ }
+ return filtered;
+ }
+
public long getLatestRevision() throws SVNException {
return repository.getLatestRevision();
}
diff --git a/src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java b/backend/src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java
similarity index 100%
rename from src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java
rename to backend/src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java
diff --git a/src/main/java/com/svnlog/web/WebApplication.java b/backend/src/main/java/com/svnlog/web/WebApplication.java
similarity index 100%
rename from src/main/java/com/svnlog/web/WebApplication.java
rename to backend/src/main/java/com/svnlog/web/WebApplication.java
diff --git a/src/main/java/com/svnlog/web/controller/AppController.java b/backend/src/main/java/com/svnlog/web/controller/AppController.java
similarity index 74%
rename from src/main/java/com/svnlog/web/controller/AppController.java
rename to backend/src/main/java/com/svnlog/web/controller/AppController.java
index c876bd0..10bfedc 100644
--- a/src/main/java/com/svnlog/web/controller/AppController.java
+++ b/backend/src/main/java/com/svnlog/web/controller/AppController.java
@@ -17,6 +17,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -30,10 +31,14 @@ 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.BatchGenerateRequest;
+import com.svnlog.web.dto.ReportGenerateRequest;
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.RepositoryConfig;
+import com.svnlog.web.model.ReportTemplateSummary;
import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.SvnPresetSummary;
import com.svnlog.web.model.TaskInfo;
@@ -41,6 +46,9 @@ import com.svnlog.web.model.TaskPageResult;
import com.svnlog.web.service.AiWorkflowService;
import com.svnlog.web.service.HealthService;
import com.svnlog.web.service.OutputFileService;
+import com.svnlog.web.service.ReportTemplateService;
+import com.svnlog.web.service.ReportWorkflowService;
+import com.svnlog.web.service.RepositoryConfigService;
import com.svnlog.web.service.SettingsService;
import com.svnlog.web.service.SvnPresetService;
import com.svnlog.web.service.SvnWorkflowService;
@@ -58,6 +66,9 @@ public class AppController {
private final SettingsService settingsService;
private final SvnPresetService svnPresetService;
private final HealthService healthService;
+ private final ReportWorkflowService reportWorkflowService;
+ private final ReportTemplateService reportTemplateService;
+ private final RepositoryConfigService repositoryConfigService;
public AppController(SvnWorkflowService svnWorkflowService,
AiWorkflowService aiWorkflowService,
@@ -65,7 +76,10 @@ public class AppController {
OutputFileService outputFileService,
SettingsService settingsService,
SvnPresetService svnPresetService,
- HealthService healthService) {
+ HealthService healthService,
+ ReportWorkflowService reportWorkflowService,
+ ReportTemplateService reportTemplateService,
+ RepositoryConfigService repositoryConfigService) {
this.svnWorkflowService = svnWorkflowService;
this.aiWorkflowService = aiWorkflowService;
this.taskService = taskService;
@@ -73,6 +87,9 @@ public class AppController {
this.settingsService = settingsService;
this.svnPresetService = svnPresetService;
this.healthService = healthService;
+ this.reportWorkflowService = reportWorkflowService;
+ this.reportTemplateService = reportTemplateService;
+ this.repositoryConfigService = repositoryConfigService;
}
@GetMapping("/health")
@@ -163,6 +180,31 @@ public class AppController {
return response;
}
+ @PostMapping("/git/test-connection")
+ public Map testGitConnection(@RequestBody ReportGenerateRequest request) throws IOException {
+ reportWorkflowService.testGitConnection(request.getGitRepoPath());
+ final Map response = new HashMap();
+ response.put("success", true);
+ response.put("message", "Git 仓库读取成功");
+ return response;
+ }
+
+ @PostMapping("/reports/generate")
+ public Map generateReport(@Valid @RequestBody ReportGenerateRequest request) {
+ final String taskId = taskService.submit("REPORT_GENERATE", context -> reportWorkflowService.generateReport(request, context));
+ final Map response = new HashMap();
+ response.put("taskId", taskId);
+ return response;
+ }
+
+ @GetMapping("/templates")
+ public Map listTemplates() {
+ final List templates = reportTemplateService.listTemplates();
+ final Map response = new HashMap();
+ response.put("templates", templates);
+ return response;
+ }
+
@GetMapping("/tasks")
public List listTasks() {
return taskService.getTasks();
@@ -270,6 +312,61 @@ public class AppController {
return settingsService.getSettings();
}
+ // ── 仓库配置管理 API ──
+
+ @GetMapping("/repositories")
+ public List listRepositories() {
+ return repositoryConfigService.listAll();
+ }
+
+ @PostMapping("/repositories")
+ public RepositoryConfig createRepository(@RequestBody RepositoryConfig config) {
+ return repositoryConfigService.create(config);
+ }
+
+ @PutMapping("/repositories/{id}")
+ public RepositoryConfig updateRepository(@PathVariable("id") String id, @RequestBody RepositoryConfig config) {
+ return repositoryConfigService.update(id, config);
+ }
+
+ @DeleteMapping("/repositories/{id}")
+ public Map deleteRepository(@PathVariable("id") String id) {
+ repositoryConfigService.delete(id);
+ final Map response = new HashMap();
+ response.put("success", true);
+ response.put("message", "仓库配置已删除");
+ return response;
+ }
+
+ @GetMapping("/repositories/export")
+ public ResponseEntity exportRepositories() {
+ final String json = repositoryConfigService.exportConfigs();
+ final byte[] bytes = json.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ final HttpHeaders headers = new HttpHeaders();
+ headers.setContentDispositionFormData("attachment", "repository-configs.json");
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ return ResponseEntity.ok().headers(headers).contentLength(bytes.length).body(bytes);
+ }
+
+ @PostMapping("/repositories/import")
+ public Map importRepositories(@RequestBody String json) {
+ final int count = repositoryConfigService.importConfigs(json);
+ final Map response = new HashMap();
+ response.put("success", true);
+ response.put("imported", count);
+ response.put("message", "成功导入 " + count + " 条仓库配置");
+ return response;
+ }
+
+ @PostMapping("/repositories/batch-generate")
+ public Map batchGenerate(@Valid @RequestBody BatchGenerateRequest request) {
+ final String taskId = taskService.submit("BATCH_GENERATE",
+ context -> reportWorkflowService.batchGenerate(request, context));
+ final Map response = new HashMap();
+ response.put("taskId", taskId);
+ return response;
+ }
+
private String maskPassword(String password) {
if (password == null || password.isEmpty()) {
return "(empty)";
diff --git a/backend/src/main/java/com/svnlog/web/controller/GlobalExceptionHandler.java b/backend/src/main/java/com/svnlog/web/controller/GlobalExceptionHandler.java
new file mode 100644
index 0000000..f758015
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/controller/GlobalExceptionHandler.java
@@ -0,0 +1,202 @@
+package com.svnlog.web.controller;
+
+import java.io.IOException;
+import java.net.ConnectException;
+import java.net.SocketTimeoutException;
+import java.net.UnknownHostException;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.MediaType;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+ private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) {
+ return build(HttpStatus.BAD_REQUEST, ex.getMessage(), request);
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
+ return build(HttpStatus.BAD_REQUEST, "请求参数校验失败,请检查必填项是否完整", request);
+ }
+
+ @ExceptionHandler(UnknownHostException.class)
+ public ResponseEntity> handleUnknownHost(UnknownHostException ex, HttpServletRequest request) {
+ final String host = ex.getMessage() != null ? ex.getMessage() : "未知";
+ return build(HttpStatus.BAD_GATEWAY, "无法解析主机地址: " + host + ",请检查 URL 是否正确或网络是否可用", request);
+ }
+
+ @ExceptionHandler(ConnectException.class)
+ public ResponseEntity> handleConnectException(ConnectException ex, HttpServletRequest request) {
+ return build(HttpStatus.BAD_GATEWAY, "连接被拒绝,请检查目标服务器是否可访问、端口是否正确", request);
+ }
+
+ @ExceptionHandler(SocketTimeoutException.class)
+ public ResponseEntity> handleSocketTimeout(SocketTimeoutException ex, HttpServletRequest request) {
+ return build(HttpStatus.GATEWAY_TIMEOUT, "连接超时,请检查网络状况或目标服务器是否响应", request);
+ }
+
+ @ExceptionHandler(IOException.class)
+ public ResponseEntity> handleIOException(IOException ex, HttpServletRequest request) {
+ final String message = translateIOException(ex);
+ return build(HttpStatus.INTERNAL_SERVER_ERROR, message, request);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity> handleAny(Exception ex, HttpServletRequest request) {
+ final String message = translateGenericException(ex);
+ return build(HttpStatus.INTERNAL_SERVER_ERROR, message, request);
+ }
+
+ private String translateIOException(IOException ex) {
+ final String raw = ex.getMessage();
+ if (raw == null) {
+ return "文件读写异常,请检查输出目录权限";
+ }
+ if (raw.contains("No such file") || raw.contains("not found")) {
+ return "文件或目录不存在: " + raw;
+ }
+ if (raw.contains("Permission denied") || raw.contains("Access denied")) {
+ return "权限不足,无法访问文件或目录: " + raw;
+ }
+ if (raw.contains("disk") || raw.contains("space")) {
+ return "磁盘空间不足,请清理后重试";
+ }
+ return "文件操作失败: " + raw;
+ }
+
+ private String translateGenericException(Exception ex) {
+ final String raw = ex.getMessage();
+ final String className = ex.getClass().getSimpleName();
+
+ if (raw == null) {
+ return "系统异常(" + className + "),请查看服务端日志";
+ }
+
+ // SVN 相关错误翻译
+ if (className.contains("SVN")) {
+ return translateSvnError(raw);
+ }
+
+ // Git/JGit 相关错误翻译
+ if (className.contains("Git") || className.contains("Jgit")
+ || className.contains("Repository")) {
+ return translateGitError(raw);
+ }
+
+ // 通用翻译
+ if (raw.contains("Authentication") || raw.contains("authentication")
+ || raw.contains("401") || raw.contains("Unauthorized")) {
+ return "认证失败,请检查用户名和密码是否正确";
+ }
+ if (raw.contains("403") || raw.contains("Forbidden")) {
+ return "访问被拒绝,当前账号无权限访问该资源";
+ }
+ if (raw.contains("404") || raw.contains("Not Found")) {
+ return "目标资源不存在,请检查地址是否正确";
+ }
+ if (raw.contains("timeout") || raw.contains("Timeout")) {
+ return "请求超时,请检查网络连接或稍后重试";
+ }
+ if (raw.contains("SSL") || raw.contains("certificate")) {
+ return "SSL 证书验证失败,请检查目标服务器证书配置";
+ }
+
+ return raw;
+ }
+
+ private String translateSvnError(String raw) {
+ if (raw.contains("authentication") || raw.contains("Authentication")
+ || raw.contains("E170001")) {
+ return "SVN 认证失败,请检查用户名和密码";
+ }
+ if (raw.contains("E175002") || raw.contains("PROPFIND")) {
+ return "SVN 地址无法访问,请确认 URL 是否正确";
+ }
+ if (raw.contains("E175003") || raw.contains("connection refused")) {
+ return "SVN 服务器连接被拒绝,请检查服务器地址和端口";
+ }
+ if (raw.contains("E200009")) {
+ return "SVN 操作被取消";
+ }
+ if (raw.contains("E160006") || raw.contains("No such revision")) {
+ return "SVN 版本号不存在,请检查起止版本范围";
+ }
+ if (raw.contains("E170000")) {
+ return "SVN 仓库地址格式错误,请检查 URL";
+ }
+ return "SVN 操作失败: " + raw;
+ }
+
+ private String translateGitError(String raw) {
+ if (raw.contains("not a git repository") || raw.contains("RepositoryNotFound")) {
+ return "指定路径不是有效的 Git 仓库,请检查路径是否正确";
+ }
+ if (raw.contains("does not exist") || raw.contains("No such file")) {
+ return "Git 仓库路径不存在,请确认路径";
+ }
+ if (raw.contains("branch") || raw.contains("ref")) {
+ return "Git 分支或引用不存在,请检查分支名称";
+ }
+ return "Git 操作失败: " + raw;
+ }
+
+ private ResponseEntity> build(HttpStatus status, String message, HttpServletRequest request) {
+ if (isSseRequest(request)) {
+ final String safeMessage = sanitize(message);
+ LOGGER.error("SSE request failed: status={} uri={} message={}",
+ status.value(),
+ request == null ? "" : request.getRequestURI(),
+ safeMessage);
+ final String payload = "event: error\ndata: {\"status\":" + status.value()
+ + ",\"error\":\"" + safeMessage + "\",\"timestamp\":\"" + Instant.now().toString() + "\"}\n\n";
+ return ResponseEntity.ok()
+ .contentType(MediaType.TEXT_EVENT_STREAM)
+ .body(payload);
+ }
+
+ final Map response = new HashMap();
+ response.put("status", status.value());
+ response.put("error", message);
+ response.put("timestamp", Instant.now().toString());
+ return ResponseEntity.status(status).body(response);
+ }
+
+ private boolean isSseRequest(HttpServletRequest request) {
+ if (request == null) {
+ return false;
+ }
+
+ final String accept = request.getHeader("Accept");
+ if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
+ return true;
+ }
+
+ final String uri = request.getRequestURI();
+ return uri != null && uri.endsWith("/stream");
+ }
+
+ private String sanitize(String message) {
+ if (message == null) {
+ return "系统异常";
+ }
+ return message
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\r", " ")
+ .replace("\n", " ");
+ }
+}
diff --git a/src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java b/backend/src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java
similarity index 100%
rename from src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java
rename to backend/src/main/java/com/svnlog/web/dto/AiAnalyzeRequest.java
diff --git a/backend/src/main/java/com/svnlog/web/dto/BatchGenerateRequest.java b/backend/src/main/java/com/svnlog/web/dto/BatchGenerateRequest.java
new file mode 100644
index 0000000..705e42e
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/dto/BatchGenerateRequest.java
@@ -0,0 +1,91 @@
+package com.svnlog.web.dto;
+
+import java.util.List;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+
+/**
+ * 批量生成报表请求。
+ */
+public class BatchGenerateRequest {
+
+ @NotEmpty
+ private List repositoryIds;
+
+ @NotBlank
+ private String reportPeriodType;
+
+ @NotBlank
+ private String referenceDate;
+
+ private String periodLabel;
+ private String filterAuthor;
+ private Boolean aiEnabled;
+ private String aiProvider;
+ private String apiKey;
+
+ public List getRepositoryIds() {
+ return repositoryIds;
+ }
+
+ public void setRepositoryIds(List repositoryIds) {
+ this.repositoryIds = repositoryIds;
+ }
+
+ public String getReportPeriodType() {
+ return reportPeriodType;
+ }
+
+ public void setReportPeriodType(String reportPeriodType) {
+ this.reportPeriodType = reportPeriodType;
+ }
+
+ public String getReferenceDate() {
+ return referenceDate;
+ }
+
+ public void setReferenceDate(String referenceDate) {
+ this.referenceDate = referenceDate;
+ }
+
+ public String getPeriodLabel() {
+ return periodLabel;
+ }
+
+ public void setPeriodLabel(String periodLabel) {
+ this.periodLabel = periodLabel;
+ }
+
+ public String getFilterAuthor() {
+ return filterAuthor;
+ }
+
+ public void setFilterAuthor(String filterAuthor) {
+ this.filterAuthor = filterAuthor;
+ }
+
+ public Boolean getAiEnabled() {
+ return aiEnabled;
+ }
+
+ public void setAiEnabled(Boolean aiEnabled) {
+ this.aiEnabled = aiEnabled;
+ }
+
+ public String getAiProvider() {
+ return aiProvider;
+ }
+
+ public void setAiProvider(String aiProvider) {
+ this.aiProvider = aiProvider;
+ }
+
+ public String getApiKey() {
+ return apiKey;
+ }
+
+ public void setApiKey(String apiKey) {
+ this.apiKey = apiKey;
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/web/dto/ReportGenerateRequest.java b/backend/src/main/java/com/svnlog/web/dto/ReportGenerateRequest.java
new file mode 100644
index 0000000..ce0c34b
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/dto/ReportGenerateRequest.java
@@ -0,0 +1,150 @@
+package com.svnlog.web.dto;
+
+import javax.validation.constraints.NotBlank;
+
+public class ReportGenerateRequest {
+
+ @NotBlank
+ private String repositoryType;
+
+ @NotBlank
+ private String reportPeriodType;
+
+ @NotBlank
+ private String referenceDate;
+
+ private String periodLabel;
+ private String repositoryName;
+ private String filterAuthor;
+ private Boolean aiEnabled;
+ private String aiProvider;
+ private String apiKey;
+ private String outputBaseName;
+
+ private String svnPresetId;
+ private String svnUsername;
+ private String svnPassword;
+
+ private String gitRepoPath;
+ private String gitBranch;
+
+ public String getRepositoryType() {
+ return repositoryType;
+ }
+
+ public void setRepositoryType(String repositoryType) {
+ this.repositoryType = repositoryType;
+ }
+
+ public String getReportPeriodType() {
+ return reportPeriodType;
+ }
+
+ public void setReportPeriodType(String reportPeriodType) {
+ this.reportPeriodType = reportPeriodType;
+ }
+
+ public String getReferenceDate() {
+ return referenceDate;
+ }
+
+ public void setReferenceDate(String referenceDate) {
+ this.referenceDate = referenceDate;
+ }
+
+ public String getPeriodLabel() {
+ return periodLabel;
+ }
+
+ public void setPeriodLabel(String periodLabel) {
+ this.periodLabel = periodLabel;
+ }
+
+ public String getRepositoryName() {
+ return repositoryName;
+ }
+
+ public void setRepositoryName(String repositoryName) {
+ this.repositoryName = repositoryName;
+ }
+
+ public String getFilterAuthor() {
+ return filterAuthor;
+ }
+
+ public void setFilterAuthor(String filterAuthor) {
+ this.filterAuthor = filterAuthor;
+ }
+
+ public Boolean getAiEnabled() {
+ return aiEnabled;
+ }
+
+ public void setAiEnabled(Boolean aiEnabled) {
+ this.aiEnabled = aiEnabled;
+ }
+
+ public String getAiProvider() {
+ return aiProvider;
+ }
+
+ public void setAiProvider(String aiProvider) {
+ this.aiProvider = aiProvider;
+ }
+
+ public String getApiKey() {
+ return apiKey;
+ }
+
+ public void setApiKey(String apiKey) {
+ this.apiKey = apiKey;
+ }
+
+ public String getOutputBaseName() {
+ return outputBaseName;
+ }
+
+ public void setOutputBaseName(String outputBaseName) {
+ this.outputBaseName = outputBaseName;
+ }
+
+ public String getSvnPresetId() {
+ return svnPresetId;
+ }
+
+ public void setSvnPresetId(String svnPresetId) {
+ this.svnPresetId = svnPresetId;
+ }
+
+ public String getSvnUsername() {
+ return svnUsername;
+ }
+
+ public void setSvnUsername(String svnUsername) {
+ this.svnUsername = svnUsername;
+ }
+
+ public String getSvnPassword() {
+ return svnPassword;
+ }
+
+ public void setSvnPassword(String svnPassword) {
+ this.svnPassword = svnPassword;
+ }
+
+ public String getGitRepoPath() {
+ return gitRepoPath;
+ }
+
+ public void setGitRepoPath(String gitRepoPath) {
+ this.gitRepoPath = gitRepoPath;
+ }
+
+ public String getGitBranch() {
+ return gitBranch;
+ }
+
+ public void setGitBranch(String gitBranch) {
+ this.gitBranch = gitBranch;
+ }
+}
diff --git a/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java b/backend/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java
similarity index 100%
rename from src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java
rename to backend/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java
diff --git a/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java b/backend/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java
similarity index 100%
rename from src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java
rename to backend/src/main/java/com/svnlog/web/dto/SvnConnectionRequest.java
diff --git a/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java b/backend/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java
similarity index 100%
rename from src/main/java/com/svnlog/web/dto/SvnFetchRequest.java
rename to backend/src/main/java/com/svnlog/web/dto/SvnFetchRequest.java
diff --git a/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java b/backend/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java
similarity index 100%
rename from src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java
rename to backend/src/main/java/com/svnlog/web/dto/SvnVersionRangeRequest.java
diff --git a/src/main/java/com/svnlog/web/model/OutputFileInfo.java b/backend/src/main/java/com/svnlog/web/model/OutputFileInfo.java
similarity index 100%
rename from src/main/java/com/svnlog/web/model/OutputFileInfo.java
rename to backend/src/main/java/com/svnlog/web/model/OutputFileInfo.java
diff --git a/backend/src/main/java/com/svnlog/web/model/ReportTemplateSummary.java b/backend/src/main/java/com/svnlog/web/model/ReportTemplateSummary.java
new file mode 100644
index 0000000..bc14564
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/model/ReportTemplateSummary.java
@@ -0,0 +1,41 @@
+package com.svnlog.web.model;
+
+public class ReportTemplateSummary {
+
+ private String id;
+ private String name;
+ private String description;
+
+ public ReportTemplateSummary() {
+ }
+
+ public ReportTemplateSummary(String id, String name, String description) {
+ this.id = id;
+ this.name = name;
+ this.description = description;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/web/model/RepositoryConfig.java b/backend/src/main/java/com/svnlog/web/model/RepositoryConfig.java
new file mode 100644
index 0000000..01b4b5f
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/model/RepositoryConfig.java
@@ -0,0 +1,147 @@
+package com.svnlog.web.model;
+
+import java.util.UUID;
+
+/**
+ * 仓库配置模型,支持 SVN 和 Git 双类型。
+ * 持久化到本地 JSON 文件,密码字段加密存储。
+ */
+public class RepositoryConfig {
+
+ private String id;
+ private String name;
+ private String type; // SVN / GIT
+ private boolean enabled;
+
+ // SVN 专用
+ private String svnUrl;
+ private String svnUsername;
+ private String svnPasswordEncrypted;
+
+ // Git 专用
+ private String gitRepoPath;
+ private String gitDefaultBranch;
+
+ // 通用
+ private String defaultFilterAuthor;
+ private long createdAt;
+ private long lastUsedAt;
+
+ public RepositoryConfig() {
+ this.id = UUID.randomUUID().toString();
+ this.enabled = true;
+ this.createdAt = System.currentTimeMillis();
+ }
+
+ // ── Getters & Setters ──
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getSvnUrl() {
+ return svnUrl;
+ }
+
+ public void setSvnUrl(String svnUrl) {
+ this.svnUrl = svnUrl;
+ }
+
+ public String getSvnUsername() {
+ return svnUsername;
+ }
+
+ public void setSvnUsername(String svnUsername) {
+ this.svnUsername = svnUsername;
+ }
+
+ public String getSvnPasswordEncrypted() {
+ return svnPasswordEncrypted;
+ }
+
+ public void setSvnPasswordEncrypted(String svnPasswordEncrypted) {
+ this.svnPasswordEncrypted = svnPasswordEncrypted;
+ }
+
+ public String getGitRepoPath() {
+ return gitRepoPath;
+ }
+
+ public void setGitRepoPath(String gitRepoPath) {
+ this.gitRepoPath = gitRepoPath;
+ }
+
+ public String getGitDefaultBranch() {
+ return gitDefaultBranch;
+ }
+
+ public void setGitDefaultBranch(String gitDefaultBranch) {
+ this.gitDefaultBranch = gitDefaultBranch;
+ }
+
+ public String getDefaultFilterAuthor() {
+ return defaultFilterAuthor;
+ }
+
+ public void setDefaultFilterAuthor(String defaultFilterAuthor) {
+ this.defaultFilterAuthor = defaultFilterAuthor;
+ }
+
+ public long getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(long createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public long getLastUsedAt() {
+ return lastUsedAt;
+ }
+
+ public void setLastUsedAt(long lastUsedAt) {
+ this.lastUsedAt = lastUsedAt;
+ }
+
+ /**
+ * 判断是否为 SVN 类型。
+ */
+ public boolean isSvn() {
+ return "SVN".equalsIgnoreCase(type);
+ }
+
+ /**
+ * 判断是否为 Git 类型。
+ */
+ public boolean isGit() {
+ return "GIT".equalsIgnoreCase(type);
+ }
+}
diff --git a/src/main/java/com/svnlog/web/model/SvnPreset.java b/backend/src/main/java/com/svnlog/web/model/SvnPreset.java
similarity index 100%
rename from src/main/java/com/svnlog/web/model/SvnPreset.java
rename to backend/src/main/java/com/svnlog/web/model/SvnPreset.java
diff --git a/src/main/java/com/svnlog/web/model/SvnPresetSummary.java b/backend/src/main/java/com/svnlog/web/model/SvnPresetSummary.java
similarity index 100%
rename from src/main/java/com/svnlog/web/model/SvnPresetSummary.java
rename to backend/src/main/java/com/svnlog/web/model/SvnPresetSummary.java
diff --git a/src/main/java/com/svnlog/web/model/TaskInfo.java b/backend/src/main/java/com/svnlog/web/model/TaskInfo.java
similarity index 100%
rename from src/main/java/com/svnlog/web/model/TaskInfo.java
rename to backend/src/main/java/com/svnlog/web/model/TaskInfo.java
diff --git a/src/main/java/com/svnlog/web/model/TaskPageResult.java b/backend/src/main/java/com/svnlog/web/model/TaskPageResult.java
similarity index 100%
rename from src/main/java/com/svnlog/web/model/TaskPageResult.java
rename to backend/src/main/java/com/svnlog/web/model/TaskPageResult.java
diff --git a/src/main/java/com/svnlog/web/model/TaskResult.java b/backend/src/main/java/com/svnlog/web/model/TaskResult.java
similarity index 100%
rename from src/main/java/com/svnlog/web/model/TaskResult.java
rename to backend/src/main/java/com/svnlog/web/model/TaskResult.java
diff --git a/src/main/java/com/svnlog/web/model/TaskStatus.java b/backend/src/main/java/com/svnlog/web/model/TaskStatus.java
similarity index 100%
rename from src/main/java/com/svnlog/web/model/TaskStatus.java
rename to backend/src/main/java/com/svnlog/web/model/TaskStatus.java
diff --git a/src/main/java/com/svnlog/web/service/AiInputValidator.java b/backend/src/main/java/com/svnlog/web/service/AiInputValidator.java
similarity index 100%
rename from src/main/java/com/svnlog/web/service/AiInputValidator.java
rename to backend/src/main/java/com/svnlog/web/service/AiInputValidator.java
diff --git a/backend/src/main/java/com/svnlog/web/service/AiProviderRegistry.java b/backend/src/main/java/com/svnlog/web/service/AiProviderRegistry.java
new file mode 100644
index 0000000..d69a5c2
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/AiProviderRegistry.java
@@ -0,0 +1,29 @@
+package com.svnlog.web.service;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+import com.svnlog.web.service.ai.AiProvider;
+
+@Service
+public class AiProviderRegistry {
+
+ private final List providers;
+
+ public AiProviderRegistry(List providers) {
+ this.providers = providers;
+ }
+
+ public AiProvider getProvider(String providerId) {
+ final String expected = providerId == null || providerId.trim().isEmpty()
+ ? "deepseek"
+ : providerId.trim().toLowerCase();
+ for (AiProvider provider : providers) {
+ if (provider.getProviderId().equalsIgnoreCase(expected)) {
+ return provider;
+ }
+ }
+ throw new IllegalArgumentException("不支持的 AI Provider: " + providerId);
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/web/service/AiWorkflowService.java b/backend/src/main/java/com/svnlog/web/service/AiWorkflowService.java
new file mode 100644
index 0000000..5ad2a47
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/AiWorkflowService.java
@@ -0,0 +1,230 @@
+package com.svnlog.web.service;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.stereotype.Service;
+
+import com.svnlog.core.report.CommitRecord;
+import com.svnlog.core.report.ReportSummary;
+import com.svnlog.core.report.RepositoryType;
+import com.svnlog.web.dto.AiAnalyzeRequest;
+import com.svnlog.web.model.TaskResult;
+import com.svnlog.web.service.ai.AiProvider;
+
+@Service
+public class AiWorkflowService {
+
+ private static final Pattern REVISION_PATTERN = Pattern.compile("###\\s+([^\\n]+)");
+ private static final Pattern AUTHOR_PATTERN = Pattern.compile("\\*\\*作者\\*\\*:\\s*`([^`]+)`");
+ private static final Pattern MESSAGE_PATTERN = Pattern.compile("(?s)\\*\\*提交信息\\*\\*:\\s*```\\s*(.*?)\\s*```");
+ private static final Pattern TIME_PATTERN = Pattern.compile("\\*\\*时间\\*\\*:\\s*([^\\n]+)");
+ private static final SimpleDateFormat FILE_NAME_TIME_FORMAT = new SimpleDateFormat("yyyyMMdd_HHmmss");
+
+ private final OutputFileService outputFileService;
+ private final SettingsService settingsService;
+ private final AiInputValidator aiInputValidator;
+ private final ReportSummaryService reportSummaryService;
+ private final AiProviderRegistry aiProviderRegistry;
+
+ public AiWorkflowService(OutputFileService outputFileService,
+ SettingsService settingsService,
+ AiInputValidator aiInputValidator,
+ ReportSummaryService reportSummaryService,
+ AiProviderRegistry aiProviderRegistry) {
+ this.outputFileService = outputFileService;
+ this.settingsService = settingsService;
+ this.aiInputValidator = aiInputValidator;
+ this.reportSummaryService = reportSummaryService;
+ this.aiProviderRegistry = aiProviderRegistry;
+ }
+
+ public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
+ final List markdownFiles = resolveUserFiles(request.getFilePaths());
+ aiInputValidator.validate(markdownFiles);
+
+ context.setProgress(20, "正在读取 Markdown 文件");
+ final List commits = parseMarkdownFiles(markdownFiles);
+ if (commits.isEmpty()) {
+ throw new IllegalStateException("未从 Markdown 文件中解析到提交记录");
+ }
+
+ final String period = request.getPeriod() == null || request.getPeriod().trim().isEmpty()
+ ? new SimpleDateFormat("yyyy-MM").format(new Date())
+ : request.getPeriod().trim();
+ final ReportSummary summary = reportSummaryService.buildLocalSummary(commits, period);
+
+ final String apiKey = settingsService.pickActiveKey(request.getApiKey());
+ if (apiKey != null && !apiKey.trim().isEmpty()) {
+ context.setProgress(55, "正在请求 AI 生成摘要");
+ final AiProvider provider = aiProviderRegistry.getProvider("deepseek");
+ final List aiHighlights = provider.summarize(apiKey, "Markdown 导入", period, commits, context);
+ if (!aiHighlights.isEmpty()) {
+ summary.getHighlights().clear();
+ summary.getHighlights().addAll(aiHighlights);
+ }
+ } else {
+ summary.getRisks().add("未提供 API Key,已使用本地规则摘要");
+ }
+
+ context.setProgress(80, "正在导出 Excel");
+ final String outputName = buildOutputFilename(request.getOutputFileName());
+ final String relative = "excel/" + outputName;
+ final Path outputFile = outputFileService.resolveInOutput(relative);
+ writeExcel(outputFile, period, summary, commits);
+
+ context.setProgress(100, "AI 分析已完成");
+ final TaskResult result = new TaskResult("工作量统计已生成");
+ result.addFile(relative);
+ return result;
+ }
+
+ private List parseMarkdownFiles(List markdownFiles) throws IOException {
+ final List commits = new ArrayList();
+ for (Path file : markdownFiles) {
+ final String content = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
+ final String[] sections = content.split("(?m)^###\\s+");
+ for (String section : sections) {
+ final String normalized = section == null ? "" : section.trim();
+ if (normalized.isEmpty()) {
+ continue;
+ }
+
+ final Matcher revisionMatcher = REVISION_PATTERN.matcher("### " + normalized);
+ final Matcher authorMatcher = AUTHOR_PATTERN.matcher(normalized);
+ final Matcher messageMatcher = MESSAGE_PATTERN.matcher(normalized);
+ final Matcher timeMatcher = TIME_PATTERN.matcher(normalized);
+
+ final CommitRecord record = new CommitRecord();
+ record.setRepositoryType(RepositoryType.SVN);
+ record.setRepositoryName(file.getFileName().toString());
+ record.setRevision(revisionMatcher.find() ? revisionMatcher.group(1).trim() : "unknown");
+ record.setAuthor(authorMatcher.find() ? authorMatcher.group(1).trim() : "");
+ record.setMessage(messageMatcher.find() ? messageMatcher.group(1).trim() : normalized);
+ if (timeMatcher.find()) {
+ record.setRepositoryRef(timeMatcher.group(1).trim());
+ }
+ commits.add(record);
+ }
+ }
+ return commits;
+ }
+
+ private void writeExcel(Path outputFile, String period, ReportSummary summary, List commits) throws IOException {
+ Files.createDirectories(outputFile.getParent());
+ try (XSSFWorkbook workbook = new XSSFWorkbook()) {
+ final Sheet summarySheet = workbook.createSheet("摘要");
+ int rowIndex = 0;
+ rowIndex = writeRow(summarySheet, rowIndex, "周期", period);
+ rowIndex = writeRow(summarySheet, rowIndex, "提交数", String.valueOf(commits.size()));
+ rowIndex++;
+ rowIndex = writeSection(summarySheet, rowIndex, "工作摘要", summary.getHighlights());
+ rowIndex++;
+ writeSection(summarySheet, rowIndex, "风险与说明", summary.getRisks());
+
+ final Sheet detailsSheet = workbook.createSheet("明细");
+ final Row header = detailsSheet.createRow(0);
+ header.createCell(0).setCellValue("版本");
+ header.createCell(1).setCellValue("作者");
+ header.createCell(2).setCellValue("引用");
+ header.createCell(3).setCellValue("提交信息");
+ for (int i = 0; i < commits.size(); i++) {
+ final CommitRecord commit = commits.get(i);
+ final Row row = detailsSheet.createRow(i + 1);
+ row.createCell(0).setCellValue(safe(commit.getRevision()));
+ row.createCell(1).setCellValue(safe(commit.getAuthor()));
+ row.createCell(2).setCellValue(safe(commit.getRepositoryRef()));
+ row.createCell(3).setCellValue(safe(commit.getMessage()));
+ }
+ for (int i = 0; i < 4; i++) {
+ detailsSheet.autoSizeColumn(i);
+ }
+
+ try (OutputStream outputStream = Files.newOutputStream(outputFile)) {
+ workbook.write(outputStream);
+ }
+ }
+ }
+
+ private int writeRow(Sheet sheet, int rowIndex, String key, String value) {
+ final Row row = sheet.createRow(rowIndex);
+ row.createCell(0).setCellValue(key);
+ row.createCell(1).setCellValue(value);
+ return rowIndex + 1;
+ }
+
+ private int writeSection(Sheet sheet, int rowIndex, String title, List items) {
+ final Row titleRow = sheet.createRow(rowIndex++);
+ titleRow.createCell(0).setCellValue(title);
+ if (items == null || items.isEmpty()) {
+ final Row emptyRow = sheet.createRow(rowIndex++);
+ emptyRow.createCell(1).setCellValue("(无)");
+ return rowIndex;
+ }
+ for (String item : items) {
+ final Row row = sheet.createRow(rowIndex++);
+ row.createCell(1).setCellValue(item);
+ }
+ return rowIndex;
+ }
+
+ private String buildOutputFilename(String requestedName) {
+ if (requestedName != null && !requestedName.trim().isEmpty()) {
+ final String normalized = requestedName.trim();
+ return normalized.toLowerCase().endsWith(".xlsx") ? normalized : normalized + ".xlsx";
+ }
+ return "ai_summary_" + FILE_NAME_TIME_FORMAT.format(new Date()) + ".xlsx";
+ }
+
+ private List resolveUserFiles(List userPaths) throws IOException {
+ final List files = new ArrayList();
+ if (userPaths == null) {
+ return files;
+ }
+ for (String userPath : userPaths) {
+ files.add(resolveUserFile(userPath));
+ }
+ return files;
+ }
+
+ private Path resolveUserFile(String userPath) throws IOException {
+ if (userPath == null || userPath.trim().isEmpty()) {
+ throw new IllegalArgumentException("文件路径不能为空");
+ }
+
+ final String normalizedInput = userPath.trim();
+ final Path outputRoot = outputFileService.getOutputRoot();
+ final Path rootPath = Paths.get("").toAbsolutePath().normalize();
+ final Path docsRoot = rootPath.resolve("docs").normalize();
+
+ 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))
+ && Files.exists(candidate) && Files.isRegularFile(candidate)) {
+ return candidate;
+ }
+ throw new IllegalArgumentException("文件不存在或不在允许目录: " + normalizedInput);
+ }
+
+ private String safe(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/src/main/java/com/svnlog/web/service/HealthService.java b/backend/src/main/java/com/svnlog/web/service/HealthService.java
similarity index 100%
rename from src/main/java/com/svnlog/web/service/HealthService.java
rename to backend/src/main/java/com/svnlog/web/service/HealthService.java
diff --git a/src/main/java/com/svnlog/web/service/OutputFileService.java b/backend/src/main/java/com/svnlog/web/service/OutputFileService.java
similarity index 100%
rename from src/main/java/com/svnlog/web/service/OutputFileService.java
rename to backend/src/main/java/com/svnlog/web/service/OutputFileService.java
diff --git a/backend/src/main/java/com/svnlog/web/service/ReportPeriodResolver.java b/backend/src/main/java/com/svnlog/web/service/ReportPeriodResolver.java
new file mode 100644
index 0000000..e81534a
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/ReportPeriodResolver.java
@@ -0,0 +1,60 @@
+package com.svnlog.web.service;
+
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAdjusters;
+
+import org.springframework.stereotype.Service;
+
+import com.svnlog.core.report.ReportPeriodRange;
+import com.svnlog.core.report.ReportPeriodType;
+
+@Service
+public class ReportPeriodResolver {
+
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
+
+ public ReportPeriodRange resolve(String periodTypeValue, String referenceDateValue, String customLabel) {
+ final ReportPeriodType periodType = ReportPeriodType.from(periodTypeValue);
+ final LocalDate referenceDate = LocalDate.parse(referenceDateValue, DATE_FORMATTER);
+ final LocalDate startDate;
+ final LocalDate endDate;
+
+ switch (periodType) {
+ case DAILY:
+ startDate = referenceDate;
+ endDate = referenceDate;
+ break;
+ case WEEKLY:
+ startDate = referenceDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
+ endDate = referenceDate.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY));
+ break;
+ case MONTHLY:
+ startDate = referenceDate.withDayOfMonth(1);
+ endDate = referenceDate.withDayOfMonth(referenceDate.lengthOfMonth());
+ break;
+ default:
+ throw new IllegalArgumentException("不支持的报表周期: " + periodTypeValue);
+ }
+
+ final String label = customLabel != null && !customLabel.trim().isEmpty()
+ ? customLabel.trim()
+ : buildDefaultLabel(periodType, startDate, endDate);
+ return new ReportPeriodRange(periodType, startDate, endDate, label);
+ }
+
+ private String buildDefaultLabel(ReportPeriodType type, LocalDate startDate, LocalDate endDate) {
+ if (type == ReportPeriodType.DAILY) {
+ return startDate.toString() + " 日报";
+ }
+ if (type == ReportPeriodType.WEEKLY) {
+ return startDate.toString() + " 至 " + endDate.toString() + " 周报";
+ }
+ return startDate.getYear() + "年" + pad(startDate.getMonthValue()) + "月 月报";
+ }
+
+ private String pad(int value) {
+ return value < 10 ? "0" + value : String.valueOf(value);
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/web/service/ReportSummaryService.java b/backend/src/main/java/com/svnlog/web/service/ReportSummaryService.java
new file mode 100644
index 0000000..028ae4a
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/ReportSummaryService.java
@@ -0,0 +1,110 @@
+package com.svnlog.web.service;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Service;
+
+import com.svnlog.core.report.CommitRecord;
+import com.svnlog.core.report.ReportSummary;
+
+@Service
+public class ReportSummaryService {
+
+ private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
+
+ public ReportSummary buildLocalSummary(List commits, String periodLabel) {
+ final ReportSummary summary = new ReportSummary();
+ if (commits == null || commits.isEmpty()) {
+ summary.getHighlights().add(periodLabel + " 没有匹配的提交记录");
+ summary.getRisks().add("请检查仓库、作者筛选条件或统计周期是否正确");
+ return summary;
+ }
+
+ final Map authorCounts = new LinkedHashMap();
+ final Map keywordCounts = new LinkedHashMap();
+
+ Date minDate = commits.get(0).getCommittedAt();
+ Date maxDate = commits.get(0).getCommittedAt();
+ for (CommitRecord commit : commits) {
+ accumulate(authorCounts, safe(commit.getAuthor()), 1);
+ for (String keyword : extractKeywords(commit.getMessage())) {
+ accumulate(keywordCounts, keyword, 1);
+ }
+ if (commit.getCommittedAt() != null && (minDate == null || commit.getCommittedAt().before(minDate))) {
+ minDate = commit.getCommittedAt();
+ }
+ if (commit.getCommittedAt() != null && (maxDate == null || commit.getCommittedAt().after(maxDate))) {
+ maxDate = commit.getCommittedAt();
+ }
+ }
+
+ summary.getHighlights().add(periodLabel + " 共计 " + commits.size() + " 次提交,涉及 "
+ + authorCounts.size() + " 位作者");
+ summary.getHighlights().add("提交时间范围: " + format(minDate) + " 至 " + format(maxDate));
+
+ final String topAuthor = firstKey(authorCounts);
+ if (!topAuthor.isEmpty()) {
+ summary.getHighlights().add("活跃作者: " + topAuthor + "(" + authorCounts.get(topAuthor) + " 次提交)");
+ }
+
+ final String topKeyword = firstKey(keywordCounts);
+ if (!topKeyword.isEmpty()) {
+ summary.getHighlights().add("高频主题: " + topKeyword);
+ } else {
+ summary.getRisks().add("提交信息较零散,建议补充更规范的 commit message 以提升摘要质量");
+ }
+ return summary;
+ }
+
+ private void accumulate(Map map, String key, int delta) {
+ if (key == null || key.trim().isEmpty()) {
+ return;
+ }
+ final Integer existing = map.get(key);
+ map.put(key, existing == null ? delta : existing.intValue() + delta);
+ }
+
+ private String[] extractKeywords(String message) {
+ final String safeMessage = safe(message).replace('\n', ' ').replace('\r', ' ');
+ final String[] rawTokens = safeMessage.split("[,,;;/\\\\|\\s]+");
+ final java.util.List result = new java.util.ArrayList();
+ for (String token : rawTokens) {
+ final String trimmed = token == null ? "" : token.trim();
+ if (trimmed.length() >= 2 && trimmed.length() <= 16) {
+ result.add(trimmed);
+ }
+ if (result.size() >= 20) {
+ break;
+ }
+ }
+ return result.toArray(new String[0]);
+ }
+
+ private String firstKey(Map map) {
+ String winner = "";
+ int max = -1;
+ for (Map.Entry entry : map.entrySet()) {
+ final int current = entry.getValue() == null ? 0 : entry.getValue().intValue();
+ if (current > max) {
+ max = current;
+ winner = entry.getKey();
+ }
+ }
+ return winner;
+ }
+
+ private String format(Date date) {
+ if (date == null) {
+ return "";
+ }
+ return DATE_FORMAT.format(date);
+ }
+
+ private String safe(String value) {
+ return value == null ? "" : value.trim();
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/web/service/ReportTemplateService.java b/backend/src/main/java/com/svnlog/web/service/ReportTemplateService.java
new file mode 100644
index 0000000..a0184cc
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/ReportTemplateService.java
@@ -0,0 +1,20 @@
+package com.svnlog.web.service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+import com.svnlog.web.model.ReportTemplateSummary;
+
+@Service
+public class ReportTemplateService {
+
+ public List listTemplates() {
+ final List templates = new ArrayList();
+ templates.add(new ReportTemplateSummary("daily", "日报", "适合个人或小组当日工作同步"));
+ templates.add(new ReportTemplateSummary("weekly", "周报", "按周聚合提交与工作摘要"));
+ templates.add(new ReportTemplateSummary("monthly", "月报", "按月汇总提交趋势、工作摘要与明细"));
+ return templates;
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/web/service/ReportWorkflowService.java b/backend/src/main/java/com/svnlog/web/service/ReportWorkflowService.java
new file mode 100644
index 0000000..1c7fbb2
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/ReportWorkflowService.java
@@ -0,0 +1,442 @@
+package com.svnlog.web.service;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import com.svnlog.core.git.GitLogFetcher;
+import com.svnlog.core.report.CommitRecord;
+import com.svnlog.core.report.MarkdownReportWriter;
+import com.svnlog.core.report.ReportPeriodRange;
+import com.svnlog.core.report.ReportSummary;
+import com.svnlog.core.report.RepositoryType;
+import com.svnlog.core.svn.LogEntry;
+import com.svnlog.core.svn.SVNLogFetcher;
+import com.svnlog.web.dto.BatchGenerateRequest;
+import com.svnlog.web.dto.ReportGenerateRequest;
+import com.svnlog.web.model.RepositoryConfig;
+import com.svnlog.web.model.SvnPreset;
+import com.svnlog.web.model.TaskResult;
+import com.svnlog.web.service.ai.AiProvider;
+
+@Service
+public class ReportWorkflowService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ReportWorkflowService.class);
+ private static final ZoneId ZONE_ID = ZoneId.systemDefault();
+ private static final SimpleDateFormat FILE_NAME_TIME_FORMAT = new SimpleDateFormat("yyyyMMdd_HHmmss");
+ private static final SimpleDateFormat DATE_TIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+
+ private final OutputFileService outputFileService;
+ private final SvnPresetService svnPresetService;
+ private final ReportPeriodResolver reportPeriodResolver;
+ private final ReportSummaryService reportSummaryService;
+ private final SettingsService settingsService;
+ private final AiProviderRegistry aiProviderRegistry;
+ private final GitLogFetcher gitLogFetcher;
+ private final RepositoryConfigService repositoryConfigService;
+
+ public ReportWorkflowService(OutputFileService outputFileService,
+ SvnPresetService svnPresetService,
+ ReportPeriodResolver reportPeriodResolver,
+ ReportSummaryService reportSummaryService,
+ SettingsService settingsService,
+ AiProviderRegistry aiProviderRegistry,
+ GitLogFetcher gitLogFetcher,
+ RepositoryConfigService repositoryConfigService) {
+ this.outputFileService = outputFileService;
+ this.svnPresetService = svnPresetService;
+ this.reportPeriodResolver = reportPeriodResolver;
+ this.reportSummaryService = reportSummaryService;
+ this.settingsService = settingsService;
+ this.aiProviderRegistry = aiProviderRegistry;
+ this.gitLogFetcher = gitLogFetcher;
+ this.repositoryConfigService = repositoryConfigService;
+ }
+
+ public TaskResult generateReport(ReportGenerateRequest request, TaskContext context) throws Exception {
+ final RepositoryType repositoryType = RepositoryType.from(request.getRepositoryType());
+ final ReportPeriodRange range = reportPeriodResolver.resolve(
+ request.getReportPeriodType(),
+ request.getReferenceDate(),
+ request.getPeriodLabel()
+ );
+
+ context.setProgress(10, "正在解析 " + repositoryType.name() + " 报表参数");
+ final List commits = collectCommits(repositoryType, request, range, context);
+ if (commits.isEmpty()) {
+ throw new IllegalStateException("当前条件下未找到提交记录");
+ }
+
+ context.setProgress(55, "正在整理工作摘要");
+ final String repositoryName = resolveRepositoryName(repositoryType, request);
+ final ReportSummary summary = reportSummaryService.buildLocalSummary(commits, range.getLabel());
+ enrichSummaryWithAi(summary, request, repositoryName, range.getLabel(), commits, context);
+
+ context.setProgress(75, "正在导出 Markdown 与 Excel");
+ final String baseName = buildBaseName(request.getOutputBaseName(), repositoryName, range.getPeriodType().name());
+ final String markdownRelative = "md/" + baseName + ".md";
+ final String excelRelative = "excel/" + baseName + ".xlsx";
+ final Path markdownPath = outputFileService.resolveInOutput(markdownRelative);
+ final Path excelPath = outputFileService.resolveInOutput(excelRelative);
+
+ MarkdownReportWriter.write(
+ markdownPath,
+ repositoryName + " " + range.getLabel(),
+ repositoryName,
+ range.getLabel(),
+ commits,
+ new MarkdownReportWriter.Options()
+ .includeGeneratedTime(true)
+ .includeStatistics(true)
+ .sourceLabel(buildSourceLabel(repositoryType, request))
+ .filterLabel(safe(request.getFilterAuthor()))
+ .summary(summary)
+ );
+ writeExcel(excelPath, repositoryName, range.getLabel(), summary, commits);
+
+ context.setProgress(100, "报表生成完成");
+ final TaskResult result = new TaskResult("已生成 Markdown 与 Excel 报表");
+ result.addFile(markdownRelative);
+ result.addFile(excelRelative);
+ return result;
+ }
+
+ public void testGitConnection(String repoPath) throws IOException {
+ gitLogFetcher.testConnection(repoPath);
+ }
+
+ private List collectCommits(RepositoryType repositoryType,
+ ReportGenerateRequest request,
+ ReportPeriodRange range,
+ TaskContext context) throws Exception {
+ if (repositoryType == RepositoryType.SVN) {
+ context.setProgress(20, "正在连接 SVN 仓库");
+ return collectSvnCommits(request, range);
+ }
+ context.setProgress(20, "正在读取 Git 仓库");
+ return gitLogFetcher.fetchLogs(
+ request.getGitRepoPath(),
+ request.getGitBranch(),
+ range.getStartDate(),
+ range.getEndDate(),
+ request.getFilterAuthor()
+ );
+ }
+
+ private List collectSvnCommits(ReportGenerateRequest request, ReportPeriodRange range) throws Exception {
+ if (request.getSvnPresetId() == null || request.getSvnPresetId().trim().isEmpty()) {
+ throw new IllegalArgumentException("SVN 模式下必须选择预设仓库");
+ }
+ if (request.getSvnUsername() == null || request.getSvnUsername().trim().isEmpty()) {
+ throw new IllegalArgumentException("SVN 用户名不能为空");
+ }
+ if (request.getSvnPassword() == null || request.getSvnPassword().trim().isEmpty()) {
+ throw new IllegalArgumentException("SVN 密码不能为空");
+ }
+
+ final SvnPreset preset = svnPresetService.getById(request.getSvnPresetId());
+ final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getSvnUsername(), request.getSvnPassword());
+ fetcher.testConnection();
+
+ final Date startDate = Date.from(range.getStartDate().atStartOfDay(ZONE_ID).toInstant());
+ final Date endDateExclusive = Date.from(range.getEndDate().plusDays(1).atStartOfDay(ZONE_ID).toInstant());
+ final List logs = fetcher.fetchLogsByDateRange(startDate, endDateExclusive, request.getFilterAuthor());
+ final List commits = new ArrayList();
+ for (LogEntry log : logs) {
+ final CommitRecord record = new CommitRecord();
+ record.setRepositoryType(RepositoryType.SVN);
+ record.setRepositoryName(preset.getName());
+ record.setRepositoryRef(preset.getId());
+ record.setRevision("r" + log.getRevision());
+ record.setAuthor(log.getAuthor());
+ record.setCommittedAt(log.getDate());
+ record.setMessage(log.getMessage());
+ if (log.getChangedPaths() != null) {
+ for (String pathValue : log.getChangedPaths()) {
+ record.getChangedPaths().add(pathValue);
+ }
+ }
+ commits.add(record);
+ }
+ return commits;
+ }
+
+ private void enrichSummaryWithAi(ReportSummary summary,
+ ReportGenerateRequest request,
+ String repositoryName,
+ String periodLabel,
+ List commits,
+ TaskContext context) throws Exception {
+ if (!Boolean.TRUE.equals(request.getAiEnabled())) {
+ return;
+ }
+ final String apiKey = settingsService.pickActiveKey(request.getApiKey());
+ if (apiKey == null || apiKey.trim().isEmpty()) {
+ summary.getRisks().add("AI 已启用但未配置 API Key,已使用本地规则摘要");
+ return;
+ }
+
+ final AiProvider provider = aiProviderRegistry.getProvider(request.getAiProvider());
+ final List aiSummary = provider.summarize(apiKey, repositoryName, periodLabel, commits, context);
+ if (!aiSummary.isEmpty()) {
+ summary.getHighlights().clear();
+ summary.getHighlights().addAll(aiSummary);
+ }
+ }
+
+ private void writeExcel(Path excelPath,
+ String repositoryName,
+ String periodLabel,
+ ReportSummary summary,
+ List commits) throws IOException {
+ Files.createDirectories(excelPath.getParent());
+ try (XSSFWorkbook workbook = new XSSFWorkbook()) {
+ writeSummarySheet(workbook.createSheet("摘要"), repositoryName, periodLabel, summary, commits);
+ writeDetailsSheet(workbook.createSheet("明细"), commits);
+ try (OutputStream outputStream = Files.newOutputStream(excelPath)) {
+ workbook.write(outputStream);
+ }
+ }
+ }
+
+ private void writeSummarySheet(Sheet sheet,
+ String repositoryName,
+ String periodLabel,
+ ReportSummary summary,
+ List commits) {
+ int rowIndex = 0;
+ rowIndex = writeKeyValue(sheet, rowIndex, "仓库", repositoryName);
+ rowIndex = writeKeyValue(sheet, rowIndex, "周期", periodLabel);
+ rowIndex = writeKeyValue(sheet, rowIndex, "提交数", String.valueOf(commits.size()));
+ rowIndex++;
+ rowIndex = writeSection(sheet, rowIndex, "工作摘要", summary.getHighlights());
+ rowIndex++;
+ writeSection(sheet, rowIndex, "风险与说明", summary.getRisks());
+ sheet.autoSizeColumn(0);
+ sheet.autoSizeColumn(1);
+ }
+
+ private void writeDetailsSheet(Sheet sheet, List commits) {
+ int rowIndex = 0;
+ final Row header = sheet.createRow(rowIndex++);
+ header.createCell(0).setCellValue("仓库类型");
+ header.createCell(1).setCellValue("仓库");
+ header.createCell(2).setCellValue("引用");
+ header.createCell(3).setCellValue("版本");
+ header.createCell(4).setCellValue("作者");
+ header.createCell(5).setCellValue("提交时间");
+ header.createCell(6).setCellValue("提交信息");
+ header.createCell(7).setCellValue("变更路径");
+
+ for (CommitRecord commit : commits) {
+ final Row row = sheet.createRow(rowIndex++);
+ row.createCell(0).setCellValue(commit.getRepositoryType() == null ? "" : commit.getRepositoryType().name());
+ row.createCell(1).setCellValue(safe(commit.getRepositoryName()));
+ row.createCell(2).setCellValue(safe(commit.getRepositoryRef()));
+ row.createCell(3).setCellValue(safe(commit.getRevision()));
+ row.createCell(4).setCellValue(safe(commit.getAuthor()));
+ row.createCell(5).setCellValue(formatDate(commit.getCommittedAt()));
+ row.createCell(6).setCellValue(safe(commit.getMessage()));
+ row.createCell(7).setCellValue(join(commit.getChangedPaths()));
+ }
+
+ for (int i = 0; i < 8; i++) {
+ sheet.autoSizeColumn(i);
+ }
+ }
+
+ private int writeKeyValue(Sheet sheet, int rowIndex, String key, String value) {
+ final Row row = sheet.createRow(rowIndex);
+ row.createCell(0).setCellValue(key);
+ row.createCell(1).setCellValue(value == null ? "" : value);
+ return rowIndex + 1;
+ }
+
+ private int writeSection(Sheet sheet, int rowIndex, String title, List values) {
+ final Row titleRow = sheet.createRow(rowIndex++);
+ titleRow.createCell(0).setCellValue(title);
+ if (values == null || values.isEmpty()) {
+ final Row row = sheet.createRow(rowIndex++);
+ row.createCell(1).setCellValue("(无)");
+ return rowIndex;
+ }
+ for (String value : values) {
+ final Row row = sheet.createRow(rowIndex++);
+ row.createCell(1).setCellValue(value == null ? "" : value);
+ }
+ return rowIndex;
+ }
+
+ private String buildBaseName(String outputBaseName, String repositoryName, String periodType) {
+ final String source = outputBaseName == null || outputBaseName.trim().isEmpty()
+ ? repositoryName + "_" + periodType + "_" + FILE_NAME_TIME_FORMAT.format(new Date())
+ : outputBaseName.trim();
+ return sanitize(source);
+ }
+
+ private String resolveRepositoryName(RepositoryType repositoryType, ReportGenerateRequest request) {
+ if (request.getRepositoryName() != null && !request.getRepositoryName().trim().isEmpty()) {
+ return request.getRepositoryName().trim();
+ }
+ if (repositoryType == RepositoryType.SVN) {
+ return svnPresetService.getById(request.getSvnPresetId()).getName();
+ }
+ final String repoPath = request.getGitRepoPath();
+ if (repoPath == null || repoPath.trim().isEmpty()) {
+ return "git-report";
+ }
+ final Path path = java.nio.file.Paths.get(repoPath.trim()).normalize();
+ final Path fileName = path.getFileName();
+ return fileName == null ? "git-report" : fileName.toString();
+ }
+
+ private String buildSourceLabel(RepositoryType repositoryType, ReportGenerateRequest request) {
+ if (repositoryType == RepositoryType.SVN) {
+ return "SVN 预设: " + request.getSvnPresetId();
+ }
+ final String branch = request.getGitBranch() == null || request.getGitBranch().trim().isEmpty()
+ ? "HEAD"
+ : request.getGitBranch().trim();
+ return "Git 路径: " + safe(request.getGitRepoPath()) + " | 分支: " + branch;
+ }
+
+ private String join(List values) {
+ if (values == null || values.isEmpty()) {
+ return "";
+ }
+ final Set distinct = new LinkedHashSet(values);
+ final StringBuilder builder = new StringBuilder();
+ int index = 0;
+ for (String value : distinct) {
+ if (index > 0) {
+ builder.append('\n');
+ }
+ builder.append(value);
+ index++;
+ }
+ return builder.toString();
+ }
+
+ private String sanitize(String value) {
+ return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_");
+ }
+
+ private String formatDate(Date value) {
+ if (value == null) {
+ return "";
+ }
+ return DATE_TIME_FORMAT.format(value);
+ }
+
+ private String safe(String value) {
+ return value == null ? "" : value.trim();
+ }
+
+ /**
+ * 批量生成报表:依次处理多个仓库配置,每个仓库生成独立的 Markdown + Excel。
+ * 某个仓库失败不影响后续仓库执行。
+ */
+ public TaskResult batchGenerate(BatchGenerateRequest request, TaskContext context) throws Exception {
+ final List repoIds = request.getRepositoryIds();
+ if (repoIds == null || repoIds.isEmpty()) {
+ throw new IllegalArgumentException("请至少选择一个仓库");
+ }
+
+ final int total = repoIds.size();
+ int success = 0;
+ int failed = 0;
+ final List errors = new ArrayList();
+ final TaskResult batchResult = new TaskResult();
+
+ context.setProgress(0, "开始批量生成,共 " + total + " 个仓库");
+
+ for (int i = 0; i < total; i++) {
+ final String repoId = repoIds.get(i);
+ RepositoryConfig config;
+ try {
+ config = repositoryConfigService.getById(repoId);
+ } catch (Exception e) {
+ failed++;
+ errors.add("仓库 " + repoId + ": 配置不存在");
+ continue;
+ }
+
+ final int baseProgress = (i * 100) / total;
+ context.setProgress(baseProgress, "正在处理仓库 " + (i + 1) + "/" + total + ": " + config.getName());
+
+ try {
+ final ReportGenerateRequest singleRequest = buildRequestFromConfig(config, request);
+ final TaskResult singleResult = generateReport(singleRequest, context);
+ for (String file : singleResult.getFiles()) {
+ batchResult.addFile(file);
+ }
+ repositoryConfigService.markUsed(repoId);
+ success++;
+ } catch (Exception e) {
+ failed++;
+ final String errorMsg = config.getName() + ": " + (e.getMessage() == null ? "未知错误" : e.getMessage());
+ errors.add(errorMsg);
+ LOGGER.warn("批量生成失败: repo={} error={}", config.getName(), e.getMessage());
+ }
+ }
+
+ final StringBuilder message = new StringBuilder();
+ message.append("批量生成完成,成功 ").append(success).append(" 个,失败 ").append(failed).append(" 个");
+ if (!errors.isEmpty()) {
+ message.append("\n失败详情:");
+ for (String error : errors) {
+ message.append("\n- ").append(error);
+ }
+ }
+ batchResult.setMessage(message.toString());
+ context.setProgress(100, batchResult.getMessage());
+ return batchResult;
+ }
+
+ private ReportGenerateRequest buildRequestFromConfig(RepositoryConfig config, BatchGenerateRequest batch) {
+ final ReportGenerateRequest request = new ReportGenerateRequest();
+ request.setRepositoryType(config.getType());
+ request.setReportPeriodType(batch.getReportPeriodType());
+ request.setReferenceDate(batch.getReferenceDate());
+ request.setPeriodLabel(batch.getPeriodLabel());
+ request.setRepositoryName(config.getName());
+ request.setAiEnabled(batch.getAiEnabled());
+ request.setAiProvider(batch.getAiProvider());
+ request.setApiKey(batch.getApiKey());
+
+ // 作者过滤:优先使用批量请求中的,其次使用仓库配置中的默认值
+ final String filterAuthor = batch.getFilterAuthor() != null && !batch.getFilterAuthor().trim().isEmpty()
+ ? batch.getFilterAuthor()
+ : config.getDefaultFilterAuthor();
+ request.setFilterAuthor(filterAuthor);
+
+ if (config.isSvn()) {
+ request.setSvnPresetId(config.getId());
+ request.setSvnUsername(config.getSvnUsername());
+ request.setSvnPassword(repositoryConfigService.decryptPassword(config));
+ } else if (config.isGit()) {
+ request.setGitRepoPath(config.getGitRepoPath());
+ request.setGitBranch(config.getGitDefaultBranch());
+ }
+
+ return request;
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/web/service/RepositoryConfigService.java b/backend/src/main/java/com/svnlog/web/service/RepositoryConfigService.java
new file mode 100644
index 0000000..999044e
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/RepositoryConfigService.java
@@ -0,0 +1,374 @@
+package com.svnlog.web.service;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import javax.annotation.PostConstruct;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+import com.svnlog.web.model.RepositoryConfig;
+import com.svnlog.web.util.CryptoUtils;
+
+/**
+ * 仓库配置管理服务。
+ * 支持 CRUD、加密持久化、导入导出。
+ * 配置文件:outputs/repository-configs.json
+ */
+@Service
+public class RepositoryConfigService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RepositoryConfigService.class);
+ private static final String CONFIG_FILE_NAME = "repository-configs.json";
+
+ private final OutputFileService outputFileService;
+ private final Gson gson;
+ private final List configs;
+
+ public RepositoryConfigService(OutputFileService outputFileService) {
+ this.outputFileService = outputFileService;
+ this.gson = new GsonBuilder().setPrettyPrinting().create();
+ this.configs = new ArrayList();
+ }
+
+ @PostConstruct
+ public void init() {
+ load();
+ }
+
+ // ── 查询 ──
+
+ public synchronized List listAll() {
+ return Collections.unmodifiableList(new ArrayList(configs));
+ }
+
+ public synchronized List listEnabled() {
+ final List result = new ArrayList();
+ for (RepositoryConfig config : configs) {
+ if (config.isEnabled()) {
+ result.add(config);
+ }
+ }
+ return result;
+ }
+
+ public synchronized List listByType(String type) {
+ final List result = new ArrayList();
+ for (RepositoryConfig config : configs) {
+ if (type != null && type.equalsIgnoreCase(config.getType())) {
+ result.add(config);
+ }
+ }
+ return result;
+ }
+
+ public synchronized RepositoryConfig getById(String id) {
+ for (RepositoryConfig config : configs) {
+ if (config.getId().equals(id)) {
+ return config;
+ }
+ }
+ throw new IllegalArgumentException("仓库配置不存在: " + id);
+ }
+
+ public synchronized boolean containsId(String id) {
+ if (id == null || id.trim().isEmpty()) {
+ return false;
+ }
+ for (RepositoryConfig config : configs) {
+ if (id.equals(config.getId())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // ── 新增 ──
+
+ public synchronized RepositoryConfig create(RepositoryConfig input) {
+ validate(input);
+ final RepositoryConfig config = new RepositoryConfig();
+ copyFields(input, config);
+ config.setCreatedAt(System.currentTimeMillis());
+ configs.add(config);
+ save();
+ LOGGER.info("新增仓库配置: id={} name={} type={}", config.getId(), config.getName(), config.getType());
+ return config;
+ }
+
+ // ── 更新 ──
+
+ public synchronized RepositoryConfig update(String id, RepositoryConfig input) {
+ final RepositoryConfig existing = getById(id);
+ validateForUpdate(input, existing);
+ copyFields(input, existing);
+ save();
+ LOGGER.info("更新仓库配置: id={} name={}", id, existing.getName());
+ return existing;
+ }
+
+ // ── 删除 ──
+
+ public synchronized void delete(String id) {
+ final Iterator iterator = configs.iterator();
+ boolean found = false;
+ while (iterator.hasNext()) {
+ final RepositoryConfig config = iterator.next();
+ if (config.getId().equals(id)) {
+ iterator.remove();
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ throw new IllegalArgumentException("仓库配置不存在: " + id);
+ }
+ save();
+ LOGGER.info("删除仓库配置: id={}", id);
+ }
+
+ // ── 更新最后使用时间 ──
+
+ public synchronized void markUsed(String id) {
+ try {
+ final RepositoryConfig config = getById(id);
+ config.setLastUsedAt(System.currentTimeMillis());
+ save();
+ } catch (Exception e) {
+ LOGGER.debug("标记使用时间失败: id={}", id);
+ }
+ }
+
+ // ── 导入导出 ──
+
+ /**
+ * 导出所有配置为 JSON 字符串(密码保持加密状态)。
+ */
+ public synchronized String exportConfigs() {
+ return gson.toJson(configs);
+ }
+
+ /**
+ * 从 JSON 字符串导入配置。
+ * 导入策略:合并(ID 相同则覆盖,不同则新增)。
+ */
+ public synchronized int importConfigs(String json) {
+ final List imported = gson.fromJson(json,
+ new TypeToken>() {}.getType());
+ if (imported == null || imported.isEmpty()) {
+ return 0;
+ }
+ int count = 0;
+ for (RepositoryConfig incoming : imported) {
+ if (incoming.getId() == null || incoming.getId().trim().isEmpty()) {
+ continue;
+ }
+ final RepositoryConfig existing = findById(incoming.getId());
+ if (existing != null) {
+ copyFieldsRaw(incoming, existing);
+ } else {
+ configs.add(incoming);
+ }
+ count++;
+ }
+ save();
+ LOGGER.info("导入仓库配置: {} 条", count);
+ return count;
+ }
+
+ /**
+ * 解密仓库密码(用于实际连接时)。
+ */
+ public String decryptPassword(RepositoryConfig config) {
+ if (config == null || config.getSvnPasswordEncrypted() == null
+ || config.getSvnPasswordEncrypted().isEmpty()) {
+ return "";
+ }
+ return CryptoUtils.decrypt(config.getSvnPasswordEncrypted());
+ }
+
+ // ── 持久化 ──
+
+ private synchronized void save() {
+ try {
+ final Path configFile = outputFileService.resolveInOutput(CONFIG_FILE_NAME);
+ Files.createDirectories(configFile.getParent());
+ final String json = gson.toJson(configs);
+ Files.write(configFile, json.getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ LOGGER.error("保存仓库配置失败", e);
+ }
+ }
+
+ private synchronized void load() {
+ try {
+ final Path configFile = outputFileService.resolveInOutput(CONFIG_FILE_NAME);
+ if (!Files.exists(configFile)) {
+ LOGGER.info("仓库配置文件不存在,使用空列表: {}", configFile);
+ return;
+ }
+ final String json = new String(Files.readAllBytes(configFile), StandardCharsets.UTF_8);
+ final List loaded = gson.fromJson(json,
+ new TypeToken>() {}.getType());
+ if (loaded != null) {
+ configs.clear();
+ configs.addAll(loaded);
+ LOGGER.info("加载仓库配置: {} 条", configs.size());
+ }
+ } catch (Exception e) {
+ LOGGER.error("加载仓库配置失败", e);
+ }
+ }
+
+ // ── 预设迁移 ──
+
+ /**
+ * 将硬编码的 SVN 预设迁移到配置文件(仅首次执行)。
+ */
+ public synchronized void migratePreset(String presetId, String name, String url) {
+ if (containsId(presetId)) {
+ return;
+ }
+ final RepositoryConfig config = new RepositoryConfig();
+ config.setId(presetId);
+ config.setName(name);
+ config.setType("SVN");
+ config.setEnabled(true);
+ config.setSvnUrl(url);
+ config.setCreatedAt(System.currentTimeMillis());
+ configs.add(config);
+ save();
+ LOGGER.info("迁移预设到仓库配置: id={} name={}", presetId, name);
+ }
+
+ // ── 内部方法 ──
+
+ private RepositoryConfig findById(String id) {
+ for (RepositoryConfig config : configs) {
+ if (config.getId().equals(id)) {
+ return config;
+ }
+ }
+ return null;
+ }
+
+ private void validate(RepositoryConfig config) {
+ if (config.getName() == null || config.getName().trim().isEmpty()) {
+ throw new IllegalArgumentException("仓库名称不能为空");
+ }
+ if (config.getType() == null || config.getType().trim().isEmpty()) {
+ throw new IllegalArgumentException("仓库类型不能为空");
+ }
+ final String type = config.getType().toUpperCase();
+ if (!"SVN".equals(type) && !"GIT".equals(type)) {
+ throw new IllegalArgumentException("仓库类型必须为 SVN 或 GIT");
+ }
+ if ("SVN".equals(type)) {
+ if (config.getSvnUrl() == null || config.getSvnUrl().trim().isEmpty()) {
+ throw new IllegalArgumentException("SVN 地址不能为空");
+ }
+ if (config.getSvnUsername() == null || config.getSvnUsername().trim().isEmpty()) {
+ throw new IllegalArgumentException("SVN 用户名不能为空");
+ }
+ // 新增时必须提供密码;更新时如果未提供表示保持不变,因此仅在创建无ID或目标记录无密码时强制
+ if (config.getSvnPasswordEncrypted() == null || config.getSvnPasswordEncrypted().trim().isEmpty()) {
+ throw new IllegalArgumentException("SVN 密码不能为空");
+ }
+ }
+ if ("GIT".equals(type)) {
+ if (config.getGitRepoPath() == null || config.getGitRepoPath().trim().isEmpty()) {
+ throw new IllegalArgumentException("Git 仓库路径不能为空");
+ }
+ }
+ }
+
+ /**
+ * 更新场景校验:允许不提交密码以保留原值。
+ */
+ private void validateForUpdate(RepositoryConfig input, RepositoryConfig existing) {
+ if (input.getName() == null || input.getName().trim().isEmpty()) {
+ throw new IllegalArgumentException("仓库名称不能为空");
+ }
+ if (input.getType() == null || input.getType().trim().isEmpty()) {
+ throw new IllegalArgumentException("仓库类型不能为空");
+ }
+ final String type = input.getType().toUpperCase();
+ if (!"SVN".equals(type) && !"GIT".equals(type)) {
+ throw new IllegalArgumentException("仓库类型必须为 SVN 或 GIT");
+ }
+ if ("SVN".equals(type)) {
+ if (input.getSvnUrl() == null || input.getSvnUrl().trim().isEmpty()) {
+ throw new IllegalArgumentException("SVN 地址不能为空");
+ }
+ final boolean hasUsername = input.getSvnUsername() != null && !input.getSvnUsername().trim().isEmpty();
+ final boolean keepUsername = existing.getSvnUsername() != null && !existing.getSvnUsername().trim().isEmpty();
+ if (!hasUsername && !keepUsername) {
+ throw new IllegalArgumentException("SVN 用户名不能为空");
+ }
+ final boolean providedPwd = input.getSvnPasswordEncrypted() != null && !input.getSvnPasswordEncrypted().trim().isEmpty();
+ final boolean hasStoredPwd = existing.getSvnPasswordEncrypted() != null && !existing.getSvnPasswordEncrypted().trim().isEmpty();
+ if (!providedPwd && !hasStoredPwd) {
+ throw new IllegalArgumentException("SVN 密码不能为空");
+ }
+ }
+ if ("GIT".equals(type)) {
+ if (input.getGitRepoPath() == null || input.getGitRepoPath().trim().isEmpty()) {
+ throw new IllegalArgumentException("Git 仓库路径不能为空");
+ }
+ }
+ }
+
+ /**
+ * 从输入复制字段到目标,密码字段自动加密。
+ * 用于 create/update 场景(输入中密码为明文)。
+ */
+ private void copyFields(RepositoryConfig input, RepositoryConfig target) {
+ target.setName(input.getName().trim());
+ target.setType(input.getType().toUpperCase());
+ target.setEnabled(input.isEnabled());
+ target.setSvnUrl(trimOrNull(input.getSvnUrl()));
+ target.setSvnUsername(trimOrNull(input.getSvnUsername()));
+ target.setGitRepoPath(trimOrNull(input.getGitRepoPath()));
+ target.setGitDefaultBranch(trimOrNull(input.getGitDefaultBranch()));
+ target.setDefaultFilterAuthor(trimOrNull(input.getDefaultFilterAuthor()));
+
+ // 密码处理:如果输入了新密码则加密存储,否则保留原值
+ final String rawPassword = input.getSvnPasswordEncrypted();
+ if (rawPassword != null && !rawPassword.trim().isEmpty()) {
+ target.setSvnPasswordEncrypted(CryptoUtils.encrypt(rawPassword.trim()));
+ }
+ }
+
+ /**
+ * 原样复制字段(用于导入场景,密码已经是加密状态)。
+ */
+ private void copyFieldsRaw(RepositoryConfig source, RepositoryConfig target) {
+ target.setName(source.getName());
+ target.setType(source.getType());
+ target.setEnabled(source.isEnabled());
+ target.setSvnUrl(source.getSvnUrl());
+ target.setSvnUsername(source.getSvnUsername());
+ target.setSvnPasswordEncrypted(source.getSvnPasswordEncrypted());
+ target.setGitRepoPath(source.getGitRepoPath());
+ target.setGitDefaultBranch(source.getGitDefaultBranch());
+ target.setDefaultFilterAuthor(source.getDefaultFilterAuthor());
+ }
+
+ private String trimOrNull(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return null;
+ }
+ return value.trim();
+ }
+}
diff --git a/src/main/java/com/svnlog/web/service/RetrySupport.java b/backend/src/main/java/com/svnlog/web/service/RetrySupport.java
similarity index 100%
rename from src/main/java/com/svnlog/web/service/RetrySupport.java
rename to backend/src/main/java/com/svnlog/web/service/RetrySupport.java
diff --git a/src/main/java/com/svnlog/web/service/SettingsService.java b/backend/src/main/java/com/svnlog/web/service/SettingsService.java
similarity index 89%
rename from src/main/java/com/svnlog/web/service/SettingsService.java
rename to backend/src/main/java/com/svnlog/web/service/SettingsService.java
index 427f263..a84d2fe 100644
--- a/src/main/java/com/svnlog/web/service/SettingsService.java
+++ b/backend/src/main/java/com/svnlog/web/service/SettingsService.java
@@ -8,9 +8,6 @@ import org.springframework.stereotype.Service;
@Service
public class SettingsService {
- // 启动默认 API Key(仅作为本地默认值,可在设置页覆盖)
- private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7";
-
private final OutputFileService outputFileService;
private final SvnPresetService svnPresetService;
private volatile String runtimeApiKey;
@@ -66,10 +63,6 @@ public class SettingsService {
if (envKey != null && !envKey.trim().isEmpty()) {
return envKey.trim();
}
- if (BOOTSTRAP_API_KEY != null && !BOOTSTRAP_API_KEY.trim().isEmpty()
- && !BOOTSTRAP_API_KEY.startsWith("REPLACE_WITH_")) {
- return BOOTSTRAP_API_KEY.trim();
- }
return null;
}
diff --git a/backend/src/main/java/com/svnlog/web/service/SvnPresetService.java b/backend/src/main/java/com/svnlog/web/service/SvnPresetService.java
new file mode 100644
index 0000000..299f79c
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/SvnPresetService.java
@@ -0,0 +1,115 @@
+package com.svnlog.web.service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.annotation.PostConstruct;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import com.svnlog.web.model.RepositoryConfig;
+import com.svnlog.web.model.SvnPreset;
+import com.svnlog.web.model.SvnPresetSummary;
+
+/**
+ * SVN 预设服务(向后兼容层)。
+ * 内部委托到 RepositoryConfigService,保留原有接口不变。
+ * 首次启动时自动将硬编码预设迁移到配置文件。
+ */
+@Service
+public class SvnPresetService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(SvnPresetService.class);
+
+ private final RepositoryConfigService repositoryConfigService;
+
+ public SvnPresetService(RepositoryConfigService repositoryConfigService) {
+ this.repositoryConfigService = repositoryConfigService;
+ }
+
+ @PostConstruct
+ public void init() {
+ migrateHardcodedPresets();
+ }
+
+ public List listPresets() {
+ final List configs = repositoryConfigService.listByType("SVN");
+ final List presets = new ArrayList();
+ for (RepositoryConfig config : configs) {
+ if (config.isEnabled()) {
+ presets.add(toSvnPreset(config));
+ }
+ }
+ return presets;
+ }
+
+ public List listPresetSummaries() {
+ final List presets = listPresets();
+ final List summaries = new ArrayList();
+ for (SvnPreset preset : presets) {
+ summaries.add(new SvnPresetSummary(preset.getId(), preset.getName()));
+ }
+ return summaries;
+ }
+
+ public boolean containsPresetId(String presetId) {
+ if (presetId == null || presetId.trim().isEmpty()) {
+ return false;
+ }
+ return repositoryConfigService.containsId(presetId);
+ }
+
+ public SvnPreset getById(String presetId) {
+ final RepositoryConfig config = repositoryConfigService.getById(trim(presetId));
+ return toSvnPreset(config);
+ }
+
+ public String firstPresetId() {
+ final List presets = listPresets();
+ return presets.isEmpty() ? "" : presets.get(0).getId();
+ }
+
+ public String configuredDefaultPresetId() {
+ return firstPresetId();
+ }
+
+ private SvnPreset toSvnPreset(RepositoryConfig config) {
+ return new SvnPreset(
+ config.getId(),
+ config.getName(),
+ config.getSvnUrl() == null ? "" : config.getSvnUrl()
+ );
+ }
+
+ /**
+ * 将硬编码的 SVN 预设迁移到 RepositoryConfigService。
+ * 仅在配置文件中不存在对应 ID 时才迁移。
+ */
+ private void migrateHardcodedPresets() {
+ final String[][] presets = {
+ {"preset-1", "PRS-7050场站智慧管控",
+ "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7050场站智慧管控/01_开发库/V1.00"},
+ {"preset-2", "PRS-7950在线巡视",
+ "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V2.00"},
+ {"preset-3", "PRS-7950在线巡视电科院测试版",
+ "https://10.6.221.149:48080/svn/houtai/001_后台软件/PRS-7950在线巡视/01_开发库/V1.00_2024"},
+ };
+
+ int migrated = 0;
+ for (String[] preset : presets) {
+ if (!repositoryConfigService.containsId(preset[0])) {
+ repositoryConfigService.migratePreset(preset[0], preset[1], preset[2]);
+ migrated++;
+ }
+ }
+ if (migrated > 0) {
+ LOGGER.info("已迁移 {} 个硬编码 SVN 预设到配置文件", migrated);
+ }
+ }
+
+ private String trim(String value) {
+ return value == null ? "" : value.trim();
+ }
+}
diff --git a/src/main/java/com/svnlog/web/service/SvnWorkflowService.java b/backend/src/main/java/com/svnlog/web/service/SvnWorkflowService.java
similarity index 74%
rename from src/main/java/com/svnlog/web/service/SvnWorkflowService.java
rename to backend/src/main/java/com/svnlog/web/service/SvnWorkflowService.java
index 82bfdc5..9b85a11 100644
--- a/src/main/java/com/svnlog/web/service/SvnWorkflowService.java
+++ b/backend/src/main/java/com/svnlog/web/service/SvnWorkflowService.java
@@ -2,6 +2,7 @@ package com.svnlog.web.service;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
+import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@@ -9,6 +10,8 @@ import org.springframework.stereotype.Service;
import org.tmatesoft.svn.core.SVNException;
import com.svnlog.core.report.MarkdownReportWriter;
+import com.svnlog.core.report.CommitRecord;
+import com.svnlog.core.report.RepositoryType;
import com.svnlog.core.svn.LogEntry;
import com.svnlog.core.svn.SVNLogFetcher;
import com.svnlog.web.dto.SvnConnectionRequest;
@@ -57,19 +60,35 @@ public class SvnWorkflowService {
final String fileName = "md/svn_log_" + sanitize(projectName) + "_" + timestamp + ".md";
final Path outputPath = outputFileService.resolveInOutput(fileName);
+ final List commits = new ArrayList();
+ for (LogEntry log : logs) {
+ final CommitRecord record = new CommitRecord();
+ record.setRepositoryType(RepositoryType.SVN);
+ record.setRepositoryName(projectName);
+ record.setRepositoryRef(preset.getId());
+ record.setRevision("r" + log.getRevision());
+ record.setAuthor(log.getAuthor());
+ record.setCommittedAt(log.getDate());
+ record.setMessage(log.getMessage());
+ if (log.getChangedPaths() != null) {
+ for (String pathValue : log.getChangedPaths()) {
+ record.getChangedPaths().add(pathValue);
+ }
+ }
+ commits.add(record);
+ }
+
MarkdownReportWriter.write(
outputPath,
- preset.getUrl(),
- request.getUsername(),
- start,
- end,
- request.getFilterUser(),
- logs,
- fetcher,
+ "SVN 日志报告",
+ projectName,
+ "r" + start + " - r" + end,
+ commits,
new MarkdownReportWriter.Options()
- .includeAccount(true)
.includeGeneratedTime(true)
.includeStatistics(true)
+ .sourceLabel(preset.getUrl())
+ .filterLabel(request.getFilterUser())
);
context.setProgress(100, "SVN 日志导出完成");
diff --git a/src/main/java/com/svnlog/web/service/TaskContext.java b/backend/src/main/java/com/svnlog/web/service/TaskContext.java
similarity index 100%
rename from src/main/java/com/svnlog/web/service/TaskContext.java
rename to backend/src/main/java/com/svnlog/web/service/TaskContext.java
diff --git a/src/main/java/com/svnlog/web/service/TaskPersistenceService.java b/backend/src/main/java/com/svnlog/web/service/TaskPersistenceService.java
similarity index 100%
rename from src/main/java/com/svnlog/web/service/TaskPersistenceService.java
rename to backend/src/main/java/com/svnlog/web/service/TaskPersistenceService.java
diff --git a/src/main/java/com/svnlog/web/service/TaskService.java b/backend/src/main/java/com/svnlog/web/service/TaskService.java
similarity index 100%
rename from src/main/java/com/svnlog/web/service/TaskService.java
rename to backend/src/main/java/com/svnlog/web/service/TaskService.java
diff --git a/backend/src/main/java/com/svnlog/web/service/ai/AiProvider.java b/backend/src/main/java/com/svnlog/web/service/ai/AiProvider.java
new file mode 100644
index 0000000..79ab6c0
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/ai/AiProvider.java
@@ -0,0 +1,17 @@
+package com.svnlog.web.service.ai;
+
+import java.util.List;
+
+import com.svnlog.core.report.CommitRecord;
+import com.svnlog.web.service.TaskContext;
+
+public interface AiProvider {
+
+ String getProviderId();
+
+ List summarize(String apiKey,
+ String repositoryName,
+ String periodLabel,
+ List commits,
+ TaskContext context) throws Exception;
+}
diff --git a/backend/src/main/java/com/svnlog/web/service/ai/DeepSeekAiProvider.java b/backend/src/main/java/com/svnlog/web/service/ai/DeepSeekAiProvider.java
new file mode 100644
index 0000000..d25ee1a
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/service/ai/DeepSeekAiProvider.java
@@ -0,0 +1,137 @@
+package com.svnlog.web.service.ai;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.springframework.stereotype.Component;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.svnlog.core.report.CommitRecord;
+import com.svnlog.web.service.TaskContext;
+
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+@Component
+public class DeepSeekAiProvider implements AiProvider {
+
+ private static final String PROVIDER_ID = "deepseek";
+ private static final String API_URL = "https://api.deepseek.com/chat/completions";
+ private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
+
+ private final OkHttpClient httpClient = new OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(120, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .build();
+
+ @Override
+ public String getProviderId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public List summarize(String apiKey,
+ String repositoryName,
+ String periodLabel,
+ List commits,
+ TaskContext context) throws Exception {
+ if (apiKey == null || apiKey.trim().isEmpty()) {
+ throw new IllegalArgumentException("未配置 DeepSeek API Key");
+ }
+ if (commits == null || commits.isEmpty()) {
+ return new ArrayList();
+ }
+
+ final String prompt = buildPrompt(repositoryName, periodLabel, commits);
+ context.setAiStreamStatus("RUNNING");
+ context.updateAiOutput("", "正在请求 DeepSeek 生成摘要...\n");
+
+ final JsonObject payload = new JsonObject();
+ payload.addProperty("model", "deepseek-chat");
+ payload.addProperty("temperature", 0.2);
+ payload.addProperty("stream", false);
+
+ final JsonArray messages = new JsonArray();
+ final JsonObject system = new JsonObject();
+ system.addProperty("role", "system");
+ system.addProperty("content", "你是研发日报周报月报助手。只输出 3 到 6 条中文项目摘要,每条单独一行,不要输出序号和额外说明。");
+ messages.add(system);
+ final JsonObject user = new JsonObject();
+ user.addProperty("role", "user");
+ user.addProperty("content", prompt);
+ messages.add(user);
+ payload.add("messages", messages);
+
+ final Request request = new Request.Builder()
+ .url(API_URL)
+ .addHeader("Authorization", "Bearer " + apiKey.trim())
+ .addHeader("Content-Type", "application/json")
+ .post(RequestBody.create(payload.toString(), JSON))
+ .build();
+
+ try (Response response = httpClient.newCall(request).execute()) {
+ if (!response.isSuccessful()) {
+ final String body = response.body() == null ? "" : response.body().string();
+ throw new IOException("DeepSeek 请求失败: HTTP " + response.code() + " " + body);
+ }
+ final String body = response.body() == null ? "" : response.body().string();
+ final String content = JsonParser.parseString(body)
+ .getAsJsonObject()
+ .getAsJsonArray("choices")
+ .get(0)
+ .getAsJsonObject()
+ .getAsJsonObject("message")
+ .get("content")
+ .getAsString();
+ context.updateAiOutput("", content);
+ context.setAiStreamStatus("DONE");
+ return parseLines(content);
+ }
+ }
+
+ private String buildPrompt(String repositoryName, String periodLabel, List commits) {
+ final StringBuilder builder = new StringBuilder();
+ builder.append("仓库: ").append(repositoryName).append('\n');
+ builder.append("周期: ").append(periodLabel).append('\n');
+ builder.append("提交列表:\n");
+ int count = 0;
+ for (CommitRecord commit : commits) {
+ builder.append("- [")
+ .append(commit.getRevision())
+ .append("] ")
+ .append(commit.getAuthor())
+ .append(": ")
+ .append(commit.getMessage())
+ .append('\n');
+ count++;
+ if (count >= 30) {
+ break;
+ }
+ }
+ builder.append("请提炼为可直接放入日报/周报/月报的工作摘要。");
+ return builder.toString();
+ }
+
+ private List parseLines(String content) {
+ final List lines = new ArrayList();
+ if (content == null) {
+ return lines;
+ }
+ final String[] split = content.split("\\r?\\n");
+ for (String rawLine : split) {
+ final String normalized = rawLine.replaceFirst("^\\s*[-*\\d\\.、)]+\\s*", "").trim();
+ if (!normalized.isEmpty()) {
+ lines.add(normalized);
+ }
+ }
+ return lines;
+ }
+}
diff --git a/backend/src/main/java/com/svnlog/web/util/CryptoUtils.java b/backend/src/main/java/com/svnlog/web/util/CryptoUtils.java
new file mode 100644
index 0000000..fc36b58
--- /dev/null
+++ b/backend/src/main/java/com/svnlog/web/util/CryptoUtils.java
@@ -0,0 +1,116 @@
+package com.svnlog.web.util;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Base64;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * AES 加密工具类,用于敏感信息(如密码)的加密存储。
+ *
+ * 加密方案:AES-128/CBC/PKCS5Padding
+ * 密钥:固定 16 字节密钥(硬编码)
+ * IV:每次加密随机生成,附加在密文前
+ * 输出格式:Base64(IV + Ciphertext)
+ */
+public final class CryptoUtils {
+
+ private static final String ALGORITHM = "AES";
+ private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
+ private static final int IV_LENGTH = 16;
+
+ // 固定密钥(Base64 编码),实际部署时可改为环境变量
+ // 当前密钥对应明文:SvnLogToolSecret
+ private static final String SECRET_KEY_BASE64 = "U3ZuTG9nVG9vbFNlY3JldA==";
+
+ private CryptoUtils() {
+ }
+
+ /**
+ * 加密明文字符串。
+ *
+ * @param plaintext 明文
+ * @return Base64 编码的密文(包含 IV)
+ * @throws RuntimeException 加密失败时抛出
+ */
+ public static String encrypt(String plaintext) {
+ if (plaintext == null || plaintext.isEmpty()) {
+ return "";
+ }
+ try {
+ final byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY_BASE64);
+ final SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
+
+ final byte[] iv = new byte[IV_LENGTH];
+ new SecureRandom().nextBytes(iv);
+ final IvParameterSpec ivSpec = new IvParameterSpec(iv);
+
+ final Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+ cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
+ final byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
+
+ final byte[] combined = new byte[IV_LENGTH + encrypted.length];
+ System.arraycopy(iv, 0, combined, 0, IV_LENGTH);
+ System.arraycopy(encrypted, 0, combined, IV_LENGTH, encrypted.length);
+
+ return Base64.getEncoder().encodeToString(combined);
+ } catch (Exception e) {
+ throw new RuntimeException("加密失败: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 解密密文字符串。
+ *
+ * @param ciphertext Base64 编码的密文(包含 IV)
+ * @return 明文
+ * @throws RuntimeException 解密失败时抛出
+ */
+ public static String decrypt(String ciphertext) {
+ if (ciphertext == null || ciphertext.isEmpty()) {
+ return "";
+ }
+ try {
+ final byte[] combined = Base64.getDecoder().decode(ciphertext);
+ if (combined.length < IV_LENGTH) {
+ throw new IllegalArgumentException("密文格式错误");
+ }
+
+ final byte[] iv = new byte[IV_LENGTH];
+ final byte[] encrypted = new byte[combined.length - IV_LENGTH];
+ System.arraycopy(combined, 0, iv, 0, IV_LENGTH);
+ System.arraycopy(combined, IV_LENGTH, encrypted, 0, encrypted.length);
+
+ final byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY_BASE64);
+ final SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
+ final IvParameterSpec ivSpec = new IvParameterSpec(iv);
+
+ final Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
+ final byte[] decrypted = cipher.doFinal(encrypted);
+
+ return new String(decrypted, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ throw new RuntimeException("解密失败: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 测试加密解密是否正常工作。
+ *
+ * @return true 如果测试通过
+ */
+ public static boolean test() {
+ try {
+ final String original = "test-password-123";
+ final String encrypted = encrypt(original);
+ final String decrypted = decrypt(encrypted);
+ return original.equals(decrypted);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/resources/application.properties b/backend/src/main/resources/application.properties
similarity index 100%
rename from src/main/resources/application.properties
rename to backend/src/main/resources/application.properties
diff --git a/src/test/java/com/svnlog/web/service/AiInputValidatorTest.java b/backend/src/test/java/com/svnlog/web/service/AiInputValidatorTest.java
similarity index 100%
rename from src/test/java/com/svnlog/web/service/AiInputValidatorTest.java
rename to backend/src/test/java/com/svnlog/web/service/AiInputValidatorTest.java
diff --git a/src/test/java/com/svnlog/web/service/HealthServiceTest.java b/backend/src/test/java/com/svnlog/web/service/HealthServiceTest.java
similarity index 100%
rename from src/test/java/com/svnlog/web/service/HealthServiceTest.java
rename to backend/src/test/java/com/svnlog/web/service/HealthServiceTest.java
diff --git a/backend/src/test/java/com/svnlog/web/service/ReportPeriodResolverTest.java b/backend/src/test/java/com/svnlog/web/service/ReportPeriodResolverTest.java
new file mode 100644
index 0000000..bcce187
--- /dev/null
+++ b/backend/src/test/java/com/svnlog/web/service/ReportPeriodResolverTest.java
@@ -0,0 +1,31 @@
+package com.svnlog.web.service;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.svnlog.core.report.ReportPeriodRange;
+import com.svnlog.core.report.ReportPeriodType;
+
+public class ReportPeriodResolverTest {
+
+ private final ReportPeriodResolver resolver = new ReportPeriodResolver();
+
+ @Test
+ public void shouldResolveWeeklyRangeFromReferenceDate() {
+ final ReportPeriodRange range = resolver.resolve("WEEKLY", "2026-04-15", "");
+
+ Assert.assertEquals(ReportPeriodType.WEEKLY, range.getPeriodType());
+ Assert.assertEquals("2026-04-13", range.getStartDate().toString());
+ Assert.assertEquals("2026-04-19", range.getEndDate().toString());
+ Assert.assertEquals("2026-04-13 至 2026-04-19 周报", range.getLabel());
+ }
+
+ @Test
+ public void shouldResolveMonthlyLabelWhenCustomLabelMissing() {
+ final ReportPeriodRange range = resolver.resolve("MONTHLY", "2026-04-15", null);
+
+ Assert.assertEquals("2026年04月 月报", range.getLabel());
+ Assert.assertEquals("2026-04-01", range.getStartDate().toString());
+ Assert.assertEquals("2026-04-30", range.getEndDate().toString());
+ }
+}
diff --git a/backend/src/test/java/com/svnlog/web/service/ReportSummaryServiceTest.java b/backend/src/test/java/com/svnlog/web/service/ReportSummaryServiceTest.java
new file mode 100644
index 0000000..3081ddb
--- /dev/null
+++ b/backend/src/test/java/com/svnlog/web/service/ReportSummaryServiceTest.java
@@ -0,0 +1,40 @@
+package com.svnlog.web.service;
+
+import java.util.Date;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.svnlog.core.report.CommitRecord;
+import com.svnlog.core.report.ReportSummary;
+import com.svnlog.core.report.RepositoryType;
+
+public class ReportSummaryServiceTest {
+
+ private final ReportSummaryService service = new ReportSummaryService();
+
+ @Test
+ public void shouldBuildSummaryFromCommitRecords() {
+ final java.util.List commits = new java.util.ArrayList();
+ commits.add(createCommit("a1b2c3d4", "alice", "修复登录问题", 1713139200000L));
+ commits.add(createCommit("e5f6g7h8", "alice", "优化登录接口", 1713225600000L));
+ commits.add(createCommit("i9j0k1l2", "bob", "新增报表导出", 1713312000000L));
+
+ final ReportSummary summary = service.buildLocalSummary(commits, "2026-04 月报");
+
+ Assert.assertFalse(summary.getHighlights().isEmpty());
+ Assert.assertTrue(summary.getHighlights().get(0).contains("3 次提交"));
+ Assert.assertTrue(summary.getHighlights().get(2).contains("alice"));
+ }
+
+ private CommitRecord createCommit(String revision, String author, String message, long timestamp) {
+ final CommitRecord commit = new CommitRecord();
+ commit.setRepositoryType(RepositoryType.GIT);
+ commit.setRepositoryName("demo");
+ commit.setRevision(revision);
+ commit.setAuthor(author);
+ commit.setMessage(message);
+ commit.setCommittedAt(new Date(timestamp));
+ return commit;
+ }
+}
diff --git a/src/test/java/com/svnlog/web/service/RetrySupportTest.java b/backend/src/test/java/com/svnlog/web/service/RetrySupportTest.java
similarity index 100%
rename from src/test/java/com/svnlog/web/service/RetrySupportTest.java
rename to backend/src/test/java/com/svnlog/web/service/RetrySupportTest.java
diff --git a/src/test/java/com/svnlog/web/service/TaskPersistenceServiceTest.java b/backend/src/test/java/com/svnlog/web/service/TaskPersistenceServiceTest.java
similarity index 100%
rename from src/test/java/com/svnlog/web/service/TaskPersistenceServiceTest.java
rename to backend/src/test/java/com/svnlog/web/service/TaskPersistenceServiceTest.java
diff --git a/src/test/java/com/svnlog/web/service/TaskServiceCancelTest.java b/backend/src/test/java/com/svnlog/web/service/TaskServiceCancelTest.java
similarity index 100%
rename from src/test/java/com/svnlog/web/service/TaskServiceCancelTest.java
rename to backend/src/test/java/com/svnlog/web/service/TaskServiceCancelTest.java
diff --git a/src/test/java/com/svnlog/web/service/TaskServiceQueryTest.java b/backend/src/test/java/com/svnlog/web/service/TaskServiceQueryTest.java
similarity index 100%
rename from src/test/java/com/svnlog/web/service/TaskServiceQueryTest.java
rename to backend/src/test/java/com/svnlog/web/service/TaskServiceQueryTest.java
diff --git a/docs/README_Web.md b/docs/README_Web.md
deleted file mode 100644
index dccbc76..0000000
--- a/docs/README_Web.md
+++ /dev/null
@@ -1,105 +0,0 @@
-# SVN 日志 Web 工作台
-
-## 功能概览
-
-Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持以下流程:
-
-1. SVN 参数录入与连接测试
-2. 异步抓取日志并导出 Markdown
-3. 使用 DeepSeek 分析 Markdown 并生成 Excel
-4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
-5. 下载输出文件、配置 API Key 与输出目录
-6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
-
-批量抓取策略:多个项目按顺序执行(前一个项目完成后才开始下一个)。
-
-## 启动方式
-
-在仓库根目录执行:
-
-```bash
-# Docker 一键启动(推荐)
-make up
-```
-
-备用方式(本机 Java + Maven):
-
-```bash
-mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
-```
-
-启动后访问:
-
-```text
-http://localhost:18088
-```
-
-## 页面说明
-
-- 工作台:最近任务统计与最近产物
-- SVN 日志抓取:SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址)
-- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
-- 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务
-- 系统设置:DeepSeek API Key、输出目录、默认 SVN 预置项目
-
-## 输出目录
-
-- 默认输出目录:`outputs/`
-- Markdown 输出:`outputs/md/*.md`
-- Excel 输出:`outputs/excel/*.xlsx`
-- 任务持久化:`outputs/task-history.json`(重启后可恢复历史)
-
-## API Key 读取优先级
-
-1. AI 分析请求中的临时 `apiKey`
-2. 设置页保存的运行时 `apiKey`
-3. 环境变量 `DEEPSEEK_API_KEY`
-
-建议在生产环境优先使用环境变量,避免敏感信息暴露。
-
-## SVN 预设来源与调用方式
-
-- SVN 地址统一维护在 `application.properties` 的 `svn.presets[*]` 中。
-- 前端不再传 SVN URL,业务接口统一传 `presetId`,后端按 `presetId` 解析地址。
-- `GET /api/svn/presets` 仅返回 `id` 与 `name`(不返回 `url`)。
-
-## 主要 API
-
-- `POST /api/svn/test-connection`
-- `POST /api/svn/fetch`
-- `GET /api/svn/presets`
-- `POST /api/ai/analyze`
-- `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`
-- `GET /api/files`
-- `GET /api/files/download?path=...`
-- `GET /api/settings`
-- `PUT /api/settings`
-
-## 验证建议
-
-至少执行:
-
-```bash
-mvn clean compile
-```
-
-如需验证完整流程:
-
-1. 启动 Web 服务
-2. 在「SVN 日志抓取」创建任务并生成 `.md`
-3. 在「AI 工作量分析」选择 `.md` 并生成 `.xlsx`
-4. 在「任务历史」中下载产物并核验内容
-
-## AI 输入校验
-
-为避免误操作和资源滥用,AI 分析接口增加输入约束:
-
-- 一次最多分析 20 个文件
-- 仅允许 `.md` 文件
-- 单文件大小不超过 2MB
diff --git a/docs/samples/sample_monthly_report.md b/docs/samples/sample_monthly_report.md
new file mode 100644
index 0000000..c5be716
--- /dev/null
+++ b/docs/samples/sample_monthly_report.md
@@ -0,0 +1,99 @@
+# SVN 月报
+
+## 报告信息
+
+- **仓库**: `ERP-Backend`
+- **周期**: 2025年05月 月报
+- **来源**: SVN 远程仓库
+- **生成时间**: 2025-06-01 09:00:00
+
+## 统计信息
+
+- **总提交数**: 45
+- **涉及文件数**: 128
+- **活跃作者数**: 4
+- **作者提交分布**:
+ - `lisi`: 18 次
+ - `wangwu`: 12 次
+ - `zhangsan`: 10 次
+ - `zhaoliu`: 5 次
+- **涉及模块**:
+ - `src`: 89 个文件
+ - `webapp`: 24 个文件
+ - `config`: 10 个文件
+ - `docs`: 5 个文件
+
+## 工作摘要
+
+- 完成采购审批流程模块开发,支持三级审批链路
+- 库存预警功能上线,支持邮件和站内信双通道通知
+- 报表导出模块重构,统一使用 Apache POI 模板引擎
+- 修复财务对账模块金额精度丢失问题(BigDecimal 替换 double)
+- 前端表单校验增强,新增 15 个字段级校验规则
+- 数据库慢查询优化 8 处,平均响应时间降低 40%
+
+## 风险与说明
+
+- 采购审批流程尚未覆盖退货场景,计划 6 月迭代补充
+- 库存预警阈值目前为全局配置,后续需支持按品类自定义
+
+## 提交明细
+
+### r15230
+
+**作者**: `lisi`
+**时间**: 2025-05-30 16:45:00
+**仓库**: `ERP-Backend`
+
+**提交信息**:
+
+```
+feat: 采购审批流程 - 三级审批链路完整实现
+```
+
+**变更路径**:
+- `src/main/java/com/erp/approval/ApprovalChainService.java`
+- `src/main/java/com/erp/approval/ApprovalController.java`
+- `src/main/java/com/erp/model/ApprovalRecord.java`
+- `webapp/views/approval/list.jsp`
+
+---
+
+### r15218
+
+**作者**: `wangwu`
+**时间**: 2025-05-28 14:20:00
+**仓库**: `ERP-Backend`
+
+**提交信息**:
+
+```
+feat: 库存预警功能 - 支持邮件和站内信通知
+```
+
+**变更路径**:
+- `src/main/java/com/erp/inventory/AlertService.java`
+- `src/main/java/com/erp/notification/EmailSender.java`
+- `src/main/java/com/erp/notification/InboxSender.java`
+- `config/alert-rules.xml`
+
+---
+
+### r15205
+
+**作者**: `zhangsan`
+**时间**: 2025-05-25 10:30:00
+**仓库**: `ERP-Backend`
+
+**提交信息**:
+
+```
+fix: 修复财务对账金额精度丢失,double 替换为 BigDecimal
+```
+
+**变更路径**:
+- `src/main/java/com/erp/finance/ReconciliationService.java`
+- `src/main/java/com/erp/model/FinanceRecord.java`
+- `src/test/java/com/erp/finance/ReconciliationServiceTest.java`
+
+---
diff --git a/docs/samples/sample_weekly_report.md b/docs/samples/sample_weekly_report.md
new file mode 100644
index 0000000..a1d5e15
--- /dev/null
+++ b/docs/samples/sample_weekly_report.md
@@ -0,0 +1,115 @@
+# Git 周报
+
+## 报告信息
+
+- **仓库**: `demo-web-app`
+- **周期**: 2025-05-19 至 2025-05-25 周报
+- **来源**: Git 本地仓库
+- **作者过滤**: `zhangsan`
+- **生成时间**: 2025-05-25 18:30:00
+
+## 统计信息
+
+- **总提交数**: 12
+- **涉及文件数**: 37
+- **活跃作者数**: 1
+- **作者提交分布**:
+ - `zhangsan`: 12 次
+- **涉及模块**:
+ - `src`: 28 个文件
+ - `docs`: 5 个文件
+ - `config`: 4 个文件
+
+## 工作摘要
+
+- 完成用户登录模块重构,统一使用 JWT 认证方案
+- 优化首页加载性能,接口响应时间从 800ms 降至 200ms
+- 修复订单列表分页查询在大数据量下的 SQL 慢查询问题
+- 新增导出功能,支持 CSV 和 Excel 两种格式
+- 补充单元测试 15 个,覆盖率从 62% 提升至 78%
+
+## 风险与说明
+
+- JWT 密钥目前硬编码在配置文件中,后续需迁移至密钥管理服务
+
+## 提交明细
+
+### abc1234
+
+**作者**: `zhangsan`
+**时间**: 2025-05-25 17:20:00
+**仓库**: `demo-web-app`
+**引用**: `develop`
+
+**提交信息**:
+
+```
+feat: 新增订单导出 Excel 功能
+```
+
+**变更路径**:
+- `src/main/java/com/demo/service/ExportService.java`
+- `src/main/java/com/demo/controller/OrderController.java`
+- `src/test/java/com/demo/service/ExportServiceTest.java`
+
+---
+
+### bcd2345
+
+**作者**: `zhangsan`
+**时间**: 2025-05-24 15:40:00
+**仓库**: `demo-web-app`
+**引用**: `develop`
+
+**提交信息**:
+
+```
+perf: 优化首页接口查询,添加缓存层
+```
+
+**变更路径**:
+- `src/main/java/com/demo/service/DashboardService.java`
+- `src/main/java/com/demo/config/CacheConfig.java`
+
+---
+
+### cde3456
+
+**作者**: `zhangsan`
+**时间**: 2025-05-23 11:15:00
+**仓库**: `demo-web-app`
+**引用**: `develop`
+
+**提交信息**:
+
+```
+fix: 修复订单分页查询慢查询问题,添加复合索引
+```
+
+**变更路径**:
+- `src/main/resources/db/migration/V20250523__add_order_index.sql`
+- `src/main/java/com/demo/mapper/OrderMapper.java`
+
+---
+
+### def4567
+
+**作者**: `zhangsan`
+**时间**: 2025-05-22 09:30:00
+**仓库**: `demo-web-app`
+**引用**: `develop`
+
+**提交信息**:
+
+```
+refactor: 重构登录模块,统一使用 JWT 认证
+```
+
+**变更路径**:
+- `src/main/java/com/demo/security/JwtTokenProvider.java`
+- `src/main/java/com/demo/security/JwtAuthFilter.java`
+- `src/main/java/com/demo/controller/AuthController.java`
+- `src/main/java/com/demo/config/SecurityConfig.java`
+- `docs/auth-design.md`
+
+---
diff --git a/docs/发行版打包指南.md b/docs/发行版打包指南.md
new file mode 100644
index 0000000..492fb62
--- /dev/null
+++ b/docs/发行版打包指南.md
@@ -0,0 +1,78 @@
+# 发行版打包指南
+
+本文档说明当前纯 `backend/` 结构下的发行版打包方式。
+
+## 一键打包
+
+在项目根目录执行:
+
+```bash
+make release
+```
+
+该命令会自动:
+
+1. 在 `backend/` 执行 `mvn clean package -DskipTests`
+2. 生成 `backend/target/svn-log-tool-1.0.0-jar-with-dependencies.jar`
+3. 将 jar 复制到 `release/windows/` 和 `release/unix/`
+4. 打包 Windows、Unix、Docker 三类发行包
+
+## 手动打包步骤
+
+### 1. 构建后端产物
+
+```bash
+cd backend
+mvn clean package -DskipTests
+```
+
+### 2. 复制 jar 到发行目录
+
+```bash
+cp backend/target/svn-log-tool-1.0.0-jar-with-dependencies.jar release/windows/
+cp backend/target/svn-log-tool-1.0.0-jar-with-dependencies.jar release/unix/
+```
+
+### 3. 打包 Windows 版本
+
+```bash
+cd release/windows
+zip -r ../svn-log-tool-1.0.0-windows.zip *
+cd ../..
+```
+
+### 4. 打包 Unix 版本
+
+```bash
+cd release/unix
+tar czf ../svn-log-tool-1.0.0-unix.tar.gz *
+cd ../..
+```
+
+### 5. 打包 Docker 版本
+
+```bash
+cd release/docker
+cp ../../Dockerfile .
+tar czf ../svn-log-tool-1.0.0-docker.tar.gz *
+rm Dockerfile
+cd ../..
+```
+
+## Docker 构建说明
+
+根目录 `Dockerfile` 采用多阶段构建:
+
+1. `maven:3.9.6-eclipse-temurin-8` 打包 `backend/`
+2. `eclipse-temurin:8-jre` 作为最终运行镜像
+
+## 发布清单
+
+- [ ] Windows 发行包(.zip)
+- [ ] Unix 发行包(.tar.gz)
+- [ ] Docker 发行包(.tar.gz)
+- [ ] 用户手册
+- [ ] 快速开始指南
+- [ ] 样例报表
+- [ ] 产品截图
+- [ ] 演示视频(可选)
diff --git a/docs/快速开始.md b/docs/快速开始.md
new file mode 100644
index 0000000..ab2fc5e
--- /dev/null
+++ b/docs/快速开始.md
@@ -0,0 +1,90 @@
+# 快速开始指南
+
+3 步快速上手「SVN/Git 日报周报月报一键生成」
+
+---
+
+## 方式一:Docker(推荐)
+
+### 第 1 步:启动服务
+
+```bash
+cd release/docker
+docker compose up -d
+```
+
+### 第 2 步:打开浏览器
+
+访问:http://localhost:18088
+
+### 第 3 步:生成报表
+
+1. 点击左侧「报表生成」
+2. 选择仓库类型(SVN 或 Git)
+3. 填写仓库信息
+4. 选择报表周期(日报/周报/月报)
+5. 点击「生成 Markdown + Excel」
+
+完成!报表在 `outputs/` 目录下。
+
+---
+
+## 方式二:Windows
+
+### 第 1 步:安装 Java
+
+如果尚未安装,访问 https://adoptium.net/ 下载 JRE 8 (LTS)。
+
+### 第 2 步:启动服务
+
+双击 `start.bat`
+
+### 第 3 步:生成报表
+
+浏览器打开 http://localhost:18088,按照界面提示操作。
+
+---
+
+## 方式三:macOS / Linux
+
+### 第 1 步:启动服务
+
+```bash
+cd release/unix
+./start.sh
+```
+
+### 第 2 步:生成报表
+
+浏览器打开 http://localhost:18088,按照界面提示操作。
+
+---
+
+## 示例:生成本周周报
+
+1. 打开 http://localhost:18088
+2. 左侧点击「报表生成」
+3. 选择「Git」仓库类型
+4. 填写 Git 仓库路径:`/home/user/my-project`
+5. 报表周期选择「周报」
+6. 基准日期选择本周任意一天(默认今天)
+7. 点击「生成 Markdown + Excel」
+8. 等待完成,自动下载 Excel
+
+---
+
+## 可选:配置 AI 分析
+
+如需 AI 自动生成工作摘要:
+
+1. 访问 https://platform.deepseek.com 获取 API Key
+2. 在 Web 工作台 → 系统设置 → 填入 API Key
+3. 生成报表时勾选「启用 AI 摘要」
+
+不配置也可正常使用基础功能。
+
+---
+
+## 需要帮助?
+
+查看完整文档:`docs/用户手册.md`
diff --git a/docs/用户手册.md b/docs/用户手册.md
new file mode 100644
index 0000000..28bd12c
--- /dev/null
+++ b/docs/用户手册.md
@@ -0,0 +1,253 @@
+# SVN/Git 日报周报月报一键生成 - 用户手册
+
+## 目录
+
+1. [产品简介](#1-产品简介)
+2. [环境要求](#2-环境要求)
+3. [安装与启动](#3-安装与启动)
+4. [功能说明](#4-功能说明)
+5. [AI 工作量分析](#5-ai-工作量分析)
+6. [常见问题](#6-常见问题)
+
+---
+
+## 1. 产品简介
+
+「SVN/Git 日报周报月报一键生成」是一款本地离线运行的代码仓库报表生成工具。
+
+**核心能力:**
+
+- 连接 SVN 远程仓库或 Git 本地仓库,自动抓取提交日志
+- 支持日报、周报、月报三种统计周期
+- 自动生成 Markdown 格式提交报表
+- 可选集成 DeepSeek AI,自动分析工作量并导出 Excel
+- Web 工作台操作,无需命令行
+
+**适用场景:**
+
+- 团队周报/月报编写,快速汇总代码提交记录
+- 项目经理查看团队成员工作量
+- 个人开发者整理工作日志
+- 外包项目交付工作量证明
+
+**数据安全:**
+
+- 所有数据本地处理,不上传任何服务器
+- AI 分析仅发送提交摘要(非源码),且可选关闭
+
+---
+
+## 2. 环境要求
+
+### 方式一:Docker 部署(推荐)
+
+- Docker 20.10+
+- Docker Compose v2+
+- 无需安装 Java
+
+### 方式二:本机 Java 运行
+
+- Java 8 或更高版本(JRE 即可)
+- Windows 7+ / macOS 10.12+ / Linux
+
+**Java 安装指引:**
+
+| 系统 | 安装方式 |
+|------|----------|
+| Windows | 访问 https://adoptium.net/ 下载 JRE 8 (LTS) |
+| macOS | `brew install openjdk@8` |
+| Ubuntu/Debian | `sudo apt install openjdk-8-jre` |
+| CentOS/RHEL | `sudo yum install java-1.8.0-openjdk` |
+
+---
+
+## 3. 安装与启动
+
+### 3.1 Docker 方式
+
+```bash
+# 进入 docker 发行包目录
+cd release/docker
+
+# 一键启动
+docker compose up -d
+
+# 查看状态
+docker compose ps
+
+# 停止服务
+docker compose down
+```
+
+启动后浏览器访问:**http://localhost:18088**
+
+### 3.2 Windows 方式
+
+1. 解压发行包到任意目录
+2. 双击 `start.bat`
+3. 等待控制台显示启动成功
+4. 浏览器访问:**http://localhost:18088**
+
+### 3.3 macOS / Linux 方式
+
+```bash
+# 进入 unix 发行包目录
+cd release/unix
+
+# 启动
+./start.sh
+```
+
+浏览器访问:**http://localhost:18088**
+
+---
+
+## 4. 功能说明
+
+### 4.1 工作台
+
+首页展示系统概览:
+
+- **任务统计**:总任务数、执行中、失败数
+- **系统状态**:输出目录可写性、API Key 配置状态
+- **报表模板**:可用的报表模板列表
+- **最近文件**:最新生成的报表文件,可直接下载
+
+### 4.2 报表生成
+
+这是核心功能页面,操作步骤:
+
+**第一步:选择仓库类型**
+
+- **SVN**:需要填写 SVN 预设项目、用户名、密码
+- **Git**:需要填写本地 Git 仓库路径(如 `/home/user/my-project`)
+
+**第二步:选择报表周期**
+
+- **日报**:统计指定日期当天的提交
+- **周报**:统计指定日期所在周(周一至周日)的提交
+- **月报**:统计指定日期所在月的全部提交
+
+**第三步:填写参数**
+
+- **基准日期**:报表统计的参考日期(默认今天)
+- **周期标签**:报表标题中的周期描述(自动生成,可修改)
+- **作者过滤**:只统计包含指定关键词的作者(留空不过滤)
+- **输出名称**:生成文件的名称前缀(自动生成,可修改)
+
+**第四步:可选 AI 增强**
+
+- 勾选「启用 AI 摘要」可让 DeepSeek 自动生成工作摘要
+- 需要先在系统设置中配置 API Key
+
+**第五步:点击生成**
+
+- 点击「测试连接」验证仓库可访问
+- 点击「生成 Markdown + Excel」开始生成
+- 执行日志区域会实时显示进度
+- 完成后自动下载 Excel 文件
+
+### 4.3 任务历史
+
+查看所有历史任务:
+
+- 支持按状态(PENDING/RUNNING/SUCCESS/FAILED/CANCELLED)筛选
+- 支持按类型筛选
+- 支持关键词搜索
+- 分页浏览
+- 可直接下载任务产物
+
+### 4.4 系统设置
+
+- **DeepSeek API Key**:配置 AI 分析所需的密钥
+- **默认 SVN 项目**:设置默认选中的 SVN 预设
+- **输出目录**:自定义报表输出路径(默认 `outputs`)
+
+---
+
+## 5. AI 工作量分析
+
+### 5.1 获取 API Key
+
+1. 访问 https://platform.deepseek.com
+2. 注册账号并登录
+3. 进入「API Keys」页面
+4. 创建新的 API Key
+5. 复制 Key(格式为 `sk-...`)
+
+### 5.2 配置 API Key
+
+**方式一:Web 设置页**
+
+在「系统设置」页面填入 API Key 并保存。
+
+**方式二:环境变量**
+
+```bash
+export DEEPSEEK_API_KEY=sk-your-key-here
+```
+
+Docker 方式可在 `docker-compose.yml` 中配置:
+
+```yaml
+environment:
+ - DEEPSEEK_API_KEY=sk-your-key-here
+```
+
+### 5.3 使用说明
+
+- AI 分析会将提交记录摘要发送给 DeepSeek API
+- 不会发送源代码内容,仅发送提交信息
+- 每次分析消耗少量 Token(通常 < 0.01 元)
+- 不配置 API Key 也可正常使用基础报表功能
+
+---
+
+## 6. 常见问题
+
+### Q: 启动后无法访问 http://localhost:18088?
+
+- 确认服务已启动(控制台无报错)
+- 检查端口 18088 是否被其他程序占用
+- 尝试使用 http://127.0.0.1:18088
+
+### Q: SVN 连接失败?
+
+- 检查 SVN 地址格式是否正确(以 `http://` 或 `https://` 开头)
+- 确认用户名和密码正确
+- 确认网络可以访问 SVN 服务器
+- 如果是 HTTPS,可能存在证书问题
+
+### Q: Git 仓库路径无效?
+
+- 确认路径指向 `.git` 所在的目录(不是 `.git` 本身)
+- 路径必须是本地绝对路径
+- Docker 模式下需要将 Git 仓库目录挂载到容器中
+
+### Q: AI 分析报错?
+
+- 检查 API Key 是否正确
+- 确认网络可以访问 api.deepseek.com
+- 检查 DeepSeek 账户余额是否充足
+
+### Q: 生成的报表在哪里?
+
+- 默认在程序目录下的 `outputs/` 文件夹
+- `outputs/md/` 存放 Markdown 报表
+- `outputs/excel/` 存放 Excel 工作量统计
+- 也可在 Web 工作台的「任务历史」页面直接下载
+
+### Q: 如何修改端口?
+
+在 jar 同目录下创建 `application.properties` 文件:
+
+```properties
+server.port=8080
+```
+
+Docker 方式修改 `docker-compose.yml` 中的端口映射:
+
+```yaml
+ports:
+ - "8080:18088"
+```
diff --git a/docs/销售文案.md b/docs/销售文案.md
new file mode 100644
index 0000000..6515e79
--- /dev/null
+++ b/docs/销售文案.md
@@ -0,0 +1,96 @@
+# 闲鱼商品文案
+
+## 商品标题(30字以内)
+
+SVN/Git日报周报月报一键生成工具 本地离线 AI分析
+
+---
+
+## 商品描述
+
+### 一句话介绍
+
+程序员必备效率工具!连接 SVN 或 Git 仓库,一键生成日报、周报、月报,支持 AI 自动分析工作量,导出 Markdown + Excel。
+
+### 痛点场景
+
+- 每周写周报要翻半天 Git 提交记录?
+- 项目经理要月度工作量统计,手动整理到崩溃?
+- 外包项目交付,需要提供工作量证明?
+- 团队成员多,汇总每个人的提交记录费时费力?
+
+### 产品亮点
+
+1. **一键生成**:选择仓库、选择周期、点击生成,3 步搞定
+2. **双仓库支持**:SVN 远程仓库 + Git 本地仓库都能用
+3. **三种周期**:日报 / 周报 / 月报,自动计算日期范围
+4. **AI 智能分析**:接入 DeepSeek AI,自动提炼工作摘要(可选)
+5. **Excel 导出**:直接生成工作量统计表,拿来就能用
+6. **Web 操作界面**:浏览器打开就能用,不需要命令行
+7. **本地离线运行**:数据不出你的电脑,安全放心
+8. **Docker 一键部署**:`make up` 一条命令启动
+
+### 你会得到什么
+
+- 完整源码(Java 8 + Spring Boot)
+- Windows 一键启动包(双击 start.bat 即可运行)
+- macOS / Linux 启动脚本
+- Docker 部署包
+- 详细用户手册 + 快速开始指南
+- 样例报表(周报 + 月报示例)
+- 售后技术支持
+
+### 技术栈
+
+Java 8 / Spring Boot 2.7 / SVNKit / JGit / Apache POI / DeepSeek API
+
+### 适合谁
+
+- 需要写日报周报月报的开发者
+- 需要统计团队工作量的项目经理
+- 需要提供工作量证明的外包团队
+- 想学习 Spring Boot + 前后端一体化项目的同学
+
+### 运行环境
+
+- Windows / macOS / Linux 均可
+- 需要 Java 8+(或 Docker)
+- AI 功能需要 DeepSeek API Key(免费额度够用很久)
+
+---
+
+## 定价建议
+
+- 基础版(源码 + 文档):¥149
+- 标准版(+ 启动包 + 视频教程):¥299
+- 专业版(+ 一对一部署支持 + 定制需求沟通):¥599
+
+---
+
+## 商品标签(搜索关键词)
+
+SVN, Git, 日报, 周报, 月报, 工作量统计, 报表工具, 程序员工具,
+AI分析, DeepSeek, Spring Boot, Java, 代码日志, 提交记录,
+工作量证明, 项目管理, 效率工具
+
+---
+
+## 买家常见问题(FAQ)
+
+**Q: 需要什么技术基础?**
+A: 不需要编程基础。Windows 用户双击 start.bat 即可启动,打开浏览器操作。
+
+**Q: AI 功能收费吗?**
+A: 工具本身不收费。AI 功能使用 DeepSeek API,新用户有免费额度,日常使用每月花费不到 1 元。不用 AI 也能正常生成报表。
+
+**Q: 支持哪些 SVN/Git 服务器?**
+A: SVN 支持所有标准 SVN 服务器(VisualSVN、CollabNet 等)。Git 支持本地仓库(GitHub、GitLab、Gitee 克隆到本地的都行)。
+
+**Q: 数据安全吗?**
+A: 完全本地运行,不上传任何数据。AI 分析只发送提交信息摘要(不含源码)。
+
+**Q: 买了之后怎么获取更新?**
+A: 提供售后群,有更新会在群里通知。
+
+**Q: 可以定制功能吗?**
+A: 专业版包含定制需求沟通,简单需求可免费实现。
diff --git a/release/docker/README.txt b/release/docker/README.txt
new file mode 100644
index 0000000..a1d6bd6
--- /dev/null
+++ b/release/docker/README.txt
@@ -0,0 +1,45 @@
+SVN/Git 日报周报月报一键生成 - Docker 使用说明
+================================================
+
+一、环境要求
+-----------
+- Docker 20.10+
+- Docker Compose v2+
+
+
+二、一键启动
+-----------
+将本目录下的 docker-compose.yml 放到任意位置,执行:
+
+ docker compose up -d
+
+启动后访问:http://localhost:18088
+
+查看状态:
+ docker compose ps
+
+停止服务:
+ docker compose down
+
+
+三、数据持久化
+-----------
+生成的报表保存在宿主机的 ./outputs/ 目录下。
+容器重启后任务历史和输出文件不会丢失。
+
+
+四、AI 功能配置(可选)
+--------------------
+方式一:在 Web 工作台 → 系统设置中填入 API Key
+
+方式二:通过环境变量传入(修改 docker-compose.yml):
+
+ services:
+ svn-log-tool:
+ environment:
+ - DEEPSEEK_API_KEY=sk-your-key-here
+
+
+五、技术支持
+-----------
+如有问题,请联系卖家获取支持。
diff --git a/release/docker/docker-compose.yml b/release/docker/docker-compose.yml
new file mode 100644
index 0000000..9dace56
--- /dev/null
+++ b/release/docker/docker-compose.yml
@@ -0,0 +1,15 @@
+services:
+ svn-log-tool:
+ image: svn-log-tool:latest
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: svn-log-tool
+ ports:
+ - "18088:18088"
+ volumes:
+ - ./outputs:/app/outputs
+ restart: unless-stopped
+ # 如需配置 AI 功能,取消下方注释并填入 API Key
+ # environment:
+ # - DEEPSEEK_API_KEY=sk-your-key-here
diff --git a/release/unix/README.txt b/release/unix/README.txt
new file mode 100644
index 0000000..997a055
--- /dev/null
+++ b/release/unix/README.txt
@@ -0,0 +1,49 @@
+SVN/Git 日报周报月报一键生成 - macOS / Linux 使用说明
+====================================================
+
+一、环境要求
+-----------
+- macOS 10.12+ 或 Linux(Ubuntu/CentOS/Debian 等)
+- Java 8 或更高版本(JRE 即可)
+
+安装 Java:
+ macOS (Homebrew): brew install openjdk@8
+ Ubuntu / Debian: sudo apt install openjdk-8-jre
+ CentOS / RHEL: sudo yum install java-1.8.0-openjdk
+
+
+二、使用方法
+-----------
+1. 打开终端,进入本目录
+2. 执行启动脚本:
+ ./start.sh
+3. 浏览器打开 http://localhost:18088
+4. 在 Web 工作台中操作
+
+
+三、AI 工作量分析(可选)
+-----------------------
+如需使用 AI 自动分析功能:
+1. 访问 https://platform.deepseek.com 获取 API Key
+2. 在 Web 工作台 → 系统设置 → 填入 API Key
+3. 生成报表时勾选"启用 AI 摘要"
+
+不配置 API Key 也可正常使用基础报表功能。
+
+
+四、常见问题
+-----------
+Q: 提示 Permission denied?
+A: 执行 chmod +x start.sh 赋予执行权限。
+
+Q: 端口 18088 被占用?
+A: 关闭占用该端口的程序,或在 jar 同目录下创建
+ application.properties 文件,添加 server.port=8080
+
+Q: 输出文件在哪里?
+A: 在 start.sh 同目录的 outputs/ 文件夹下。
+
+
+五、技术支持
+-----------
+如有问题,请联系卖家获取支持。
diff --git a/release/unix/start.sh b/release/unix/start.sh
new file mode 100755
index 0000000..31c6c67
--- /dev/null
+++ b/release/unix/start.sh
@@ -0,0 +1,53 @@
+#!/bin/bash
+# SVN/Git 日报周报月报一键生成 - 启动脚本
+
+echo "============================================"
+echo " SVN/Git 日报周报月报一键生成"
+echo " 本地离线报表生成工具"
+echo "============================================"
+echo ""
+
+# 检查 Java 环境
+if ! command -v java &> /dev/null; then
+ echo "[错误] 未检测到 Java 环境。"
+ echo ""
+ echo "请安装 Java 8 或更高版本:"
+ echo ""
+ echo " macOS (Homebrew):"
+ echo " brew install openjdk@8"
+ echo ""
+ echo " Ubuntu / Debian:"
+ echo " sudo apt install openjdk-8-jre"
+ echo ""
+ echo " CentOS / RHEL:"
+ echo " sudo yum install java-1.8.0-openjdk"
+ echo ""
+ echo "安装完成后,请重新运行本脚本。"
+ exit 1
+fi
+
+# 显示 Java 版本
+JAVA_VER=$(java -version 2>&1 | head -n 1)
+echo "[信息] 检测到 Java: $JAVA_VER"
+echo ""
+
+# 检查 jar 文件
+JAR_FILE="svn-log-tool-1.0.0-jar-with-dependencies.jar"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+if [ ! -f "$SCRIPT_DIR/$JAR_FILE" ]; then
+ echo "[错误] 未找到 $JAR_FILE"
+ echo "请确认 jar 文件与本脚本在同一目录下。"
+ exit 1
+fi
+
+# 创建输出目录
+mkdir -p "$SCRIPT_DIR/outputs"
+
+echo "[启动] 正在启动 Web 工作台..."
+echo "[信息] 启动后请在浏览器中访问: http://localhost:18088"
+echo "[信息] 按 Ctrl+C 可停止服务"
+echo ""
+
+cd "$SCRIPT_DIR"
+java -jar "$JAR_FILE"
diff --git a/release/windows/README.txt b/release/windows/README.txt
new file mode 100644
index 0000000..e2169b5
--- /dev/null
+++ b/release/windows/README.txt
@@ -0,0 +1,59 @@
+SVN/Git 日报周报月报一键生成 - Windows 使用说明
+================================================
+
+一、环境要求
+-----------
+- Windows 7 / 10 / 11
+- Java 8 或更高版本(JRE 即可)
+
+如果尚未安装 Java,请访问以下地址下载:
+ https://adoptium.net/
+ 选择 "JRE 8 (LTS)" → Windows x64 → 下载安装
+
+安装后在命令行输入 java -version 确认安装成功。
+
+
+二、使用方法
+-----------
+1. 双击 start.bat 启动服务
+2. 浏览器打开 http://localhost:18088
+3. 在 Web 工作台中操作:
+ - 选择仓库类型(SVN 或 Git)
+ - 选择报表周期(日报/周报/月报)
+ - 填写仓库信息,点击"生成"
+4. 生成的报表在 outputs/ 目录下
+
+
+三、AI 工作量分析(可选)
+-----------------------
+如需使用 AI 自动分析功能:
+1. 访问 https://platform.deepseek.com 注册并获取 API Key
+2. 在 Web 工作台 → 系统设置 → 填入 API Key
+3. 生成报表时勾选"启用 AI 摘要"
+
+不配置 API Key 也可正常使用基础报表功能。
+
+
+四、常见问题
+-----------
+Q: 双击 start.bat 闪退?
+A: 右键 start.bat → 以管理员身份运行,查看错误信息。
+ 通常是 Java 未安装或未加入 PATH。
+
+Q: 端口 18088 被占用?
+A: 关闭占用该端口的程序,或修改 jar 同目录下的
+ application.properties 中的 server.port。
+
+Q: SVN 连接失败?
+A: 检查 SVN 地址、用户名、密码是否正确。
+ 确认网络可以访问 SVN 服务器。
+
+Q: 输出文件在哪里?
+A: 在 start.bat 同目录的 outputs/ 文件夹下。
+ - outputs/md/ Markdown 报表
+ - outputs/excel/ Excel 工作量统计
+
+
+五、技术支持
+-----------
+如有问题,请联系卖家获取支持。
diff --git a/release/windows/start.bat b/release/windows/start.bat
new file mode 100644
index 0000000..2043dfc
--- /dev/null
+++ b/release/windows/start.bat
@@ -0,0 +1,53 @@
+@echo off
+chcp 65001 >nul 2>&1
+title SVN/Git 日报周报月报一键生成
+
+echo ============================================
+echo SVN/Git 日报周报月报一键生成
+echo 本地离线报表生成工具
+echo ============================================
+echo.
+
+:: 检查 Java 环境
+where java >nul 2>&1
+if %errorlevel% neq 0 (
+ echo [错误] 未检测到 Java 环境。
+ echo.
+ echo 请安装 Java 8 或更高版本:
+ echo 下载地址: https://adoptium.net/
+ echo 选择 JRE 8 (LTS) 下载安装即可。
+ echo.
+ echo 安装完成后,请重新运行本脚本。
+ echo.
+ pause
+ exit /b 1
+)
+
+:: 检查 Java 版本
+for /f "tokens=3" %%v in ('java -version 2^>^&1 ^| findstr /i "version"') do (
+ set JAVA_VER=%%v
+)
+echo [信息] 检测到 Java 版本: %JAVA_VER%
+echo.
+
+:: 检查 jar 文件
+set JAR_FILE=svn-log-tool-1.0.0-jar-with-dependencies.jar
+if not exist "%JAR_FILE%" (
+ echo [错误] 未找到 %JAR_FILE%
+ echo 请确认 jar 文件与本脚本在同一目录下。
+ echo.
+ pause
+ exit /b 1
+)
+
+:: 创建输出目录
+if not exist "outputs" mkdir outputs
+
+echo [启动] 正在启动 Web 工作台...
+echo [信息] 启动后请在浏览器中访问: http://localhost:18088
+echo [信息] 按 Ctrl+C 可停止服务
+echo.
+
+java -jar "%JAR_FILE%"
+
+pause
diff --git a/src/main/java/com/svnlog/core/report/MarkdownReportWriter.java b/src/main/java/com/svnlog/core/report/MarkdownReportWriter.java
deleted file mode 100644
index 3634236..0000000
--- a/src/main/java/com/svnlog/core/report/MarkdownReportWriter.java
+++ /dev/null
@@ -1,101 +0,0 @@
-package com.svnlog.core.report;
-
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Date;
-import java.util.List;
-
-import com.svnlog.core.svn.LogEntry;
-import com.svnlog.core.svn.SVNLogFetcher;
-
-public final class MarkdownReportWriter {
-
- public static final class Options {
- private boolean includeAccount;
- private boolean includeGeneratedTime;
- private boolean includeStatistics;
- private boolean placeholderForEmptyMessage;
-
- public Options includeAccount(boolean includeAccount) {
- this.includeAccount = includeAccount;
- return this;
- }
-
- public Options includeGeneratedTime(boolean includeGeneratedTime) {
- this.includeGeneratedTime = includeGeneratedTime;
- return this;
- }
-
- public Options includeStatistics(boolean includeStatistics) {
- this.includeStatistics = includeStatistics;
- return this;
- }
-
- public Options placeholderForEmptyMessage(boolean placeholderForEmptyMessage) {
- this.placeholderForEmptyMessage = placeholderForEmptyMessage;
- return this;
- }
- }
-
- private MarkdownReportWriter() {
- }
-
- public static void write(Path path,
- String url,
- String username,
- long startRevision,
- long endRevision,
- String filterUser,
- List logs,
- SVNLogFetcher fetcher,
- Options options) throws IOException {
- final Options effectiveOptions = options == null ? new Options() : options;
- final StringBuilder markdown = new StringBuilder();
-
- markdown.append("# SVN 日志报告\n\n");
- markdown.append("## 查询条件\n\n");
- markdown.append("- **SVN地址**: `").append(url).append("`\n");
- if (effectiveOptions.includeAccount) {
- markdown.append("- **账号**: `").append(safe(username)).append("`\n");
- }
- markdown.append("- **版本范围**: r").append(startRevision).append(" - r").append(endRevision).append("\n");
- if (!safe(filterUser).isEmpty()) {
- markdown.append("- **过滤用户**: `").append(filterUser).append("`\n");
- }
- if (effectiveOptions.includeGeneratedTime) {
- markdown.append("- **生成时间**: ").append(fetcher.formatDate(new Date())).append("\n");
- }
- markdown.append("\n");
-
- if (effectiveOptions.includeStatistics) {
- markdown.append("## 统计信息\n\n");
- markdown.append("- **总记录数**: ").append(logs.size()).append(" 条\n\n");
- }
-
- markdown.append("## 日志详情\n\n");
- for (LogEntry entry : logs) {
- markdown.append("### r").append(entry.getRevision()).append("\n\n");
- markdown.append("**作者**: `").append(safe(entry.getAuthor())).append("` \n");
- markdown.append("**时间**: ").append(fetcher.formatDate(entry.getDate())).append(" \n");
- markdown.append("**版本**: r").append(entry.getRevision()).append("\n\n");
-
- final String message = safe(entry.getMessage());
- if (message.isEmpty() && effectiveOptions.placeholderForEmptyMessage) {
- markdown.append("**提交信息**: (无)\n\n");
- } else {
- markdown.append("**提交信息**:\n\n");
- markdown.append("```\n").append(message).append("\n```\n\n");
- }
- markdown.append("---\n\n");
- }
-
- Files.createDirectories(path.getParent());
- Files.write(path, markdown.toString().getBytes(StandardCharsets.UTF_8));
- }
-
- private static String safe(String value) {
- return value == null ? "" : value;
- }
-}
diff --git a/src/main/java/com/svnlog/web/controller/GlobalExceptionHandler.java b/src/main/java/com/svnlog/web/controller/GlobalExceptionHandler.java
deleted file mode 100644
index cba33ed..0000000
--- a/src/main/java/com/svnlog/web/controller/GlobalExceptionHandler.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.svnlog.web.controller;
-
-import java.time.Instant;
-import java.util.HashMap;
-import java.util.Map;
-
-import javax.servlet.http.HttpServletRequest;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.http.MediaType;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.MethodArgumentNotValidException;
-import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.RestControllerAdvice;
-
-@RestControllerAdvice
-public class GlobalExceptionHandler {
- private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
-
- @ExceptionHandler(IllegalArgumentException.class)
- public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) {
- return build(HttpStatus.BAD_REQUEST, ex.getMessage(), request);
- }
-
- @ExceptionHandler(MethodArgumentNotValidException.class)
- public ResponseEntity> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
- return build(HttpStatus.BAD_REQUEST, "请求参数校验失败", request);
- }
-
- @ExceptionHandler(Exception.class)
- public ResponseEntity> handleAny(Exception ex, HttpServletRequest request) {
- return build(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage() == null ? "系统异常" : ex.getMessage(), request);
- }
-
- private ResponseEntity> build(HttpStatus status, String message, HttpServletRequest request) {
- if (isSseRequest(request)) {
- final String safeMessage = sanitize(message);
- LOGGER.error("SSE request failed: status={} uri={} message={}",
- status.value(),
- request == null ? "" : request.getRequestURI(),
- safeMessage);
- final String payload = "event: error\ndata: {\"status\":" + status.value()
- + ",\"error\":\"" + safeMessage + "\",\"timestamp\":\"" + Instant.now().toString() + "\"}\n\n";
- return ResponseEntity.ok()
- .contentType(MediaType.TEXT_EVENT_STREAM)
- .body(payload);
- }
-
- final Map response = new HashMap();
- response.put("status", status.value());
- response.put("error", message);
- response.put("timestamp", Instant.now().toString());
- return ResponseEntity.status(status).body(response);
- }
-
- private boolean isSseRequest(HttpServletRequest request) {
- if (request == null) {
- return false;
- }
-
- final String accept = request.getHeader("Accept");
- if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
- return true;
- }
-
- final String uri = request.getRequestURI();
- return uri != null && uri.endsWith("/stream");
- }
-
- private String sanitize(String message) {
- if (message == null) {
- return "系统异常";
- }
- return message
- .replace("\\", "\\\\")
- .replace("\"", "\\\"")
- .replace("\r", " ")
- .replace("\n", " ");
- }
-}
diff --git a/src/main/java/com/svnlog/web/service/AiWorkflowService.java b/src/main/java/com/svnlog/web/service/AiWorkflowService.java
deleted file mode 100644
index a1bbb46..0000000
--- a/src/main/java/com/svnlog/web/service/AiWorkflowService.java
+++ /dev/null
@@ -1,1593 +0,0 @@
-package com.svnlog.web.service;
-
-import java.io.IOException;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.text.SimpleDateFormat;
-import java.util.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;
-import org.apache.poi.ss.usermodel.CellStyle;
-import org.apache.poi.ss.usermodel.FillPatternType;
-import org.apache.poi.ss.usermodel.Font;
-import org.apache.poi.ss.usermodel.HorizontalAlignment;
-import org.apache.poi.ss.usermodel.IndexedColors;
-import org.apache.poi.ss.usermodel.Row;
-import org.apache.poi.ss.usermodel.Sheet;
-import org.apache.poi.ss.usermodel.VerticalAlignment;
-import org.apache.poi.ss.usermodel.Workbook;
-import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap;
-import org.apache.poi.xssf.usermodel.XSSFCellStyle;
-import org.apache.poi.xssf.usermodel.XSSFColor;
-import org.apache.poi.xssf.usermodel.XSSFWorkbook;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.stereotype.Service;
-
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import com.svnlog.web.dto.AiAnalyzeRequest;
-import com.svnlog.web.model.TaskResult;
-
-import okhttp3.MediaType;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.RequestBody;
-import okhttp3.Response;
-
-@Service
-public class AiWorkflowService {
- private static final Logger LOGGER = LoggerFactory.getLogger(AiWorkflowService.class);
-
- private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
- private static final String DEEPSEEK_MODEL_CHAT = "deepseek-chat";
- private static final String DEEPSEEK_MODEL_THINK = "deepseek-reasoner";
- 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 static final Pattern FILE_SECTION_PATTERN =
- Pattern.compile("(?s)=== 文件:\\s*(.*?)\\s*===\\s*(.*?)(?=(?:\\n\\s*=== 文件:)|\\z)");
- private static final Pattern COMMIT_ENTRY_PATTERN =
- Pattern.compile("(?s)###\\s+r(\\d+)\\b.*?\\*\\*提交信息\\*\\*:\\s*```\\s*(.*?)\\s*```");
- private static final Pattern REVISION_REF_PATTERN = Pattern.compile("r\\d+");
- private static final Pattern TOTAL_RECORDS_PATTERN = Pattern.compile("总记录数\\*\\*:\\s*(\\d+)\\s*条");
- private static final Pattern REVISION_HEADING_PATTERN = Pattern.compile("^###\\s+r\\d+.*$");
- private static final int STREAM_PERSIST_INTERVAL = 8;
- private static final int SUMMARY_RECORDS_PER_ITEM = 4;
- private static final int SUMMARY_MIN_ITEMS = 2;
- private static final int SUMMARY_MAX_ITEMS = 200;
- private static final int DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY = 8000;
- private static final int DEEPSEEK_CHAT_MAX_TOKENS_RETRY = 8000;
- private static final int DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY = 64000;
- private static final int DEEPSEEK_REASONER_MAX_TOKENS_RETRY = 64000;
- private static final int EXCEL_CELL_MAX_LENGTH = 32767;
-
- private final OkHttpClient httpClient = new OkHttpClient.Builder()
- .connectTimeout(30, TimeUnit.SECONDS)
- .writeTimeout(60, TimeUnit.SECONDS)
- .readTimeout(180, TimeUnit.SECONDS)
- .build();
-
- private final OutputFileService outputFileService;
- private final SettingsService settingsService;
- private final AiInputValidator aiInputValidator;
- private final RetrySupport retrySupport = new RetrySupport();
-
- public AiWorkflowService(OutputFileService outputFileService,
- SettingsService settingsService,
- AiInputValidator aiInputValidator) {
- this.outputFileService = outputFileService;
- this.settingsService = settingsService;
- this.aiInputValidator = aiInputValidator;
- }
-
- public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
- final Path outputRoot = outputFileService.getOutputRoot();
- final List requestedPaths = request.getFilePaths() == null
- ? java.util.Collections.emptyList()
- : request.getFilePaths();
- context.setProgress(10, "正在读取 Markdown 文件,输出目录: " + outputRoot);
- context.setProgress(12, "待处理文件: " + joinPaths(requestedPaths));
- final List markdownFiles = resolveUserFiles(request.getFilePaths());
- context.setProgress(18, "路径解析完成: " + joinResolvedPaths(markdownFiles));
- aiInputValidator.validate(markdownFiles);
- final String content = readMarkdownFiles(markdownFiles);
- final Map> projectCommits = buildProjectCommitMap(content);
- final Map projectLogCounts = buildProjectLogCountsFromCommits(projectCommits);
- final Map projectMinItems = buildProjectMinItems(projectLogCounts);
-
- context.setProgress(35, "正在请求 DeepSeek 分析");
- final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
- ? request.getPeriod().trim()
- : new SimpleDateFormat("yyyy年MM月").format(new Date());
-
- final String apiKey = settingsService.pickActiveKey(request.getApiKey());
- if (apiKey == null || apiKey.trim().isEmpty()) {
- throw new IllegalStateException("未配置 DeepSeek API Key(可在设置页配置或请求中传入)");
- }
-
- final Map> groupedFromAi = createGroupedItems();
- for (int i = 0; i < FIXED_PROJECTS.length; i++) {
- final String project = FIXED_PROJECTS[i];
- final int targetItems = projectMinItems.containsKey(project)
- ? projectMinItems.get(project).intValue()
- : SUMMARY_MIN_ITEMS;
- final List sourceCommits = projectCommits.get(project);
- final int stepProgress = 35 + (i * 10);
- context.setProgress(stepProgress, "正在执行 Chat 压缩总结: " + project);
- context.setAiStreamStatus("STREAMING");
- List stageOneItems = new ArrayList();
- try {
- final String stageOnePrompt = buildProjectCompressionPrompt(project, sourceCommits, period, targetItems);
- final String stageOneResponse = callDeepSeek(apiKey, stageOnePrompt, context, DEEPSEEK_MODEL_CHAT);
- stageOneItems = parseProjectSummaryItems(stageOneResponse, project);
- } catch (Exception ex) {
- LOGGER.warn("DeepSeek chat stage failed, fallback to local compression: project={}", project, ex);
- context.emitEvent("phase", buildEventPayload("Chat 压缩失败,已切换本地压缩: " + project));
- }
-
- if (stageOneItems.isEmpty()) {
- final LinkedHashSet localStageOne = buildLocalSummariesFromCommits(
- project,
- sourceCommits,
- Math.max(targetItems, Math.min(targetItems * 2, sourceCommits == null ? 0 : sourceCommits.size()))
- );
- for (String item : localStageOne) {
- stageOneItems.add(new ProjectSummaryItem(item, new LinkedHashSet()));
- }
- }
-
- context.setProgress(stepProgress + 5, "正在执行 Think 精炼总结: " + project);
- try {
- final String stageTwoPrompt = buildProjectRefinePrompt(project, period, targetItems, sourceCommits, stageOneItems);
- final String stageTwoResponse = callDeepSeek(apiKey, stageTwoPrompt, context, DEEPSEEK_MODEL_THINK);
- final List stageTwoItems = parseProjectSummaryItems(stageTwoResponse, project);
- groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageTwoItems, targetItems));
- } catch (Exception ex) {
- LOGGER.warn("DeepSeek think stage failed, fallback to chat merge: project={}", project, ex);
- context.emitEvent("phase", buildEventPayload("Think 精炼失败,使用 Chat 压缩结果: " + project));
- groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageOneItems, targetItems));
- }
- }
-
- final JsonObject payload = buildBasePayload(period);
- applyGroupedItemsToPayload(payload, groupedFromAi);
- enforceMinimumSummaryItems(payload, content, projectMinItems);
-
- context.setProgress(75, "正在生成 Excel 文件");
- final String filename = buildOutputFilename(request.getOutputFileName());
- final String relative = "excel/" + filename;
- final Path outputFile = outputFileService.resolveInOutput(relative);
- Files.createDirectories(outputFile.getParent());
- writeExcel(outputFile, payload, period);
-
- context.setProgress(100, "AI 分析已完成");
- final TaskResult result = new TaskResult("工作量统计已生成");
- result.addFile(relative);
- return result;
- }
-
- private String readMarkdownFiles(List filePaths) throws IOException {
- final StringBuilder builder = new StringBuilder();
- for (Path path : filePaths) {
- final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
- builder.append("\n\n=== 文件: ").append(path.getFileName().toString()).append(" ===\n");
- builder.append(content);
- }
- return builder.toString();
- }
-
- private List resolveUserFiles(List userPaths) throws IOException {
- java.util.ArrayList files = new java.util.ArrayList();
- if (userPaths == null) {
- return files;
- }
- for (String userPath : userPaths) {
- files.add(resolveUserFile(userPath));
- }
- return files;
- }
-
- private Path resolveUserFile(String userPath) throws IOException {
- if (userPath == null || userPath.trim().isEmpty()) {
- throw new IllegalArgumentException("文件路径不能为空");
- }
-
- final String normalizedInput = userPath.trim();
- final Path outputRoot = outputFileService.getOutputRoot();
- final Path rootPath = Paths.get("").toAbsolutePath().normalize();
- final Path docsRoot = 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;
- }
- }
-
- final boolean outputCandidateExists = Files.exists(outputCandidate);
- final boolean outputCandidateIsFile = outputCandidateExists && Files.isRegularFile(outputCandidate);
- final boolean rootCandidateExists = Files.exists(candidate);
- final boolean rootCandidateIsFile = rootCandidateExists && Files.isRegularFile(candidate);
-
- throw new IllegalArgumentException(
- "文件不存在或不在允许目录:" + normalizedInput
- + " | outputCandidate=" + outputCandidate
- + " (exists=" + outputCandidateExists + ", file=" + outputCandidateIsFile + ")"
- + " | rootCandidate=" + candidate
- + " (exists=" + rootCandidateExists + ", file=" + rootCandidateIsFile + ")"
- + " | outputRoot=" + outputRoot
- + " | docsRoot=" + docsRoot
- );
- }
-
- private String joinPaths(List paths) {
- if (paths == null || paths.isEmpty()) {
- return "(empty)";
- }
- final StringBuilder builder = new StringBuilder();
- for (int i = 0; i < paths.size(); i++) {
- if (i > 0) {
- builder.append(", ");
- }
- builder.append(paths.get(i));
- }
- return builder.toString();
- }
-
- private String joinResolvedPaths(List paths) {
- if (paths == null || paths.isEmpty()) {
- return "(empty)";
- }
- final StringBuilder builder = new StringBuilder();
- for (int i = 0; i < paths.size(); i++) {
- if (i > 0) {
- builder.append(", ");
- }
- builder.append(paths.get(i).toString());
- }
- return builder.toString();
- }
-
- private String buildProjectCompressionPrompt(String project,
- List sourceCommits,
- String period,
- int targetItems) {
- final StringBuilder workItems = new StringBuilder();
- final LinkedHashSet revisionSet = new LinkedHashSet();
- int index = 1;
- if (sourceCommits != null) {
- for (CommitEntry entry : sourceCommits) {
- if (entry == null || entry.message.trim().isEmpty()) {
- continue;
- }
- revisionSet.add(entry.revision);
- workItems.append(index++)
- .append(". [")
- .append(entry.revision)
- .append("] ")
- .append(entry.message.trim())
- .append('\n');
- }
- }
- if (workItems.length() == 0) {
- workItems.append("1. 本项目本周期存在代码提交,请按变更点进行归纳。\n");
- }
- final String allRevisions = revisionSet.isEmpty() ? "(none)" : joinValues(revisionSet, ", ");
-
- final int compressedTarget = Math.max(targetItems, Math.min(Math.max(targetItems * 2, 6), Math.max(targetItems, revisionSet.size())));
-
- return "你是项目管理助手,请仅根据以下“提交信息”先做压缩归纳。\n"
- + "工作周期: " + period + "\n"
- + "项目: " + project + "\n"
- + "目标压缩条数: " + Math.max(1, compressedTarget) + "\n"
- + "总提交数: " + revisionSet.size() + "\n"
- + "必须覆盖全部 revision(不可遗漏): " + allRevisions + "\n"
- + "要求:\n"
- + "1. 仅输出 JSON,不要输出额外文字\n"
- + "2. 不能包含 SVN 地址、账号、版本范围、作者、时间等元信息\n"
- + "3. 不要逐条复述提交,要把相近提交合并成压缩总结\n"
- + "4. 返回结构固定:{\"project\":\"" + project + "\",\"items\":[{\"summary\":\"...\",\"sources\":[\"r123\"]}]}\n"
- + "5. items 里每条都必须给出 sources;sources 只能来自上面给定 revision 列表\n"
- + "6. summary 仅描述功能开发/优化/修复,不输出流水账\n"
- + "提交信息列表:\n"
- + workItems.toString();
- }
-
- private String buildProjectRefinePrompt(String project,
- String period,
- int targetItems,
- List sourceCommits,
- List stageOneItems) {
- final LinkedHashSet revisionSet = new LinkedHashSet();
- if (sourceCommits != null) {
- for (CommitEntry commit : sourceCommits) {
- if (commit == null || commit.revision == null || commit.revision.trim().isEmpty()) {
- continue;
- }
- revisionSet.add(commit.revision.trim());
- }
- }
-
- final StringBuilder stageOneBuilder = new StringBuilder();
- int index = 1;
- if (stageOneItems != null) {
- for (ProjectSummaryItem item : stageOneItems) {
- if (item == null || item.summary == null || item.summary.trim().isEmpty()) {
- continue;
- }
- stageOneBuilder.append(index++)
- .append(". ")
- .append(item.summary.trim())
- .append(" | sources=")
- .append(item.sources == null || item.sources.isEmpty() ? "[]" : joinValues(item.sources, ","))
- .append('\n');
- }
- }
- if (stageOneBuilder.length() == 0) {
- stageOneBuilder.append("1. 本项目存在有效提交,请按目标条数进行归纳。\n");
- }
-
- return "你是项目管理助手,请将“阶段一压缩结果”精炼为固定条数总结。\n"
- + "工作周期: " + period + "\n"
- + "项目: " + project + "\n"
- + "最终条数(必须严格等于): " + Math.max(1, targetItems) + "\n"
- + "必须覆盖全部 revision(不可遗漏): "
- + (revisionSet.isEmpty() ? "(none)" : joinValues(revisionSet, ", "))
- + "\n"
- + "要求:\n"
- + "1. 仅输出 JSON,不要输出额外文字\n"
- + "2. 返回结构固定:{\"project\":\"" + project + "\",\"items\":[{\"summary\":\"...\",\"sources\":[\"r123\"]}]}\n"
- + "3. items 数量必须严格等于最终条数\n"
- + "4. 每条都必须有 sources,且 sources 仅可使用给定 revision\n"
- + "5. 不要逐条抄写提交,必须做合并归纳\n"
- + "阶段一压缩结果:\n"
- + stageOneBuilder.toString();
- }
-
- private List parseProjectSummaryItems(String aiResponse, String project) {
- final List items = new ArrayList();
- final JsonObject object = extractJson(aiResponse);
-
- if (object.has("items") && object.get("items").isJsonArray()) {
- final JsonArray rawItems = object.getAsJsonArray("items");
- for (JsonElement item : rawItems) {
- if (item == null || item.isJsonNull()) {
- continue;
- }
- if (item.isJsonObject()) {
- final JsonObject jsonItem = item.getAsJsonObject();
- final String summary = cleanWorkItem(
- firstNonBlank(
- firstNonBlank(
- optString(jsonItem, "summary"),
- optString(jsonItem, "content")
- ),
- optString(jsonItem, "text")
- )
- );
- final LinkedHashSet sources = parseSources(jsonItem.get("sources"));
- if (!summary.isEmpty()) {
- items.add(new ProjectSummaryItem(summary, sources));
- }
- } else {
- final String summary = cleanWorkItem(item.getAsString());
- if (!summary.isEmpty()) {
- items.add(new ProjectSummaryItem(summary, new LinkedHashSet()));
- }
- }
- }
- }
-
- if (object.has("content") && !object.get("content").isJsonNull()) {
- final String content = object.get("content").getAsString();
- final Map> tmp = createGroupedItems();
- collectItems(tmp, project, content);
- final LinkedHashSet parsed = tmp.get(project);
- if (parsed != null) {
- for (String value : parsed) {
- items.add(new ProjectSummaryItem(value, new LinkedHashSet()));
- }
- }
- }
-
- if (object.has("records") && object.get("records").isJsonArray()) {
- final JsonArray records = object.getAsJsonArray("records");
- for (JsonElement element : records) {
- if (element == null || !element.isJsonObject()) {
- continue;
- }
- final JsonObject record = element.getAsJsonObject();
- final String content = optString(record, "content");
- if (content == null || content.trim().isEmpty()) {
- continue;
- }
- final Map> tmp = createGroupedItems();
- collectItems(tmp, project, content);
- final LinkedHashSet parsed = tmp.get(project);
- if (parsed != null) {
- for (String value : parsed) {
- items.add(new ProjectSummaryItem(value, new LinkedHashSet()));
- }
- }
- }
- }
- return items;
- }
-
- private LinkedHashSet mergeProjectItems(String project,
- List sourceCommits,
- List aiItems,
- int targetItems) {
- final LinkedHashMap revisionToMessage = new LinkedHashMap();
- if (sourceCommits != null) {
- for (CommitEntry commit : sourceCommits) {
- if (commit == null || commit.revision == null || commit.revision.isEmpty()) {
- continue;
- }
- if (commit.message == null || commit.message.trim().isEmpty()) {
- continue;
- }
- revisionToMessage.put(commit.revision, commit.message);
- }
- }
-
- final LinkedHashSet allRevisions = new LinkedHashSet(revisionToMessage.keySet());
- final LinkedHashSet covered = new LinkedHashSet();
- final LinkedHashSet merged = new LinkedHashSet();
- final LinkedHashSet aiItemsWithoutSources = new LinkedHashSet();
- if (aiItems != null) {
- for (ProjectSummaryItem aiItem : aiItems) {
- if (aiItem == null || aiItem.summary == null || aiItem.summary.trim().isEmpty()) {
- continue;
- }
- final LinkedHashSet validSources = new LinkedHashSet();
- for (String source : aiItem.sources) {
- if (allRevisions.contains(source)) {
- validSources.add(source);
- }
- }
- if (validSources.isEmpty()) {
- aiItemsWithoutSources.add(cleanWorkItem(aiItem.summary));
- continue;
- }
- covered.addAll(validSources);
- merged.add(cleanWorkItem(aiItem.summary));
- }
- }
-
- if (merged.isEmpty() && !aiItemsWithoutSources.isEmpty()) {
- merged.addAll(aiItemsWithoutSources);
- covered.addAll(allRevisions);
- }
-
- final LinkedHashSet uncovered = new LinkedHashSet(allRevisions);
- uncovered.removeAll(covered);
- final List uncoveredCommits = new ArrayList();
- for (String revision : uncovered) {
- final String message = revisionToMessage.get(revision);
- if (message == null || message.trim().isEmpty()) {
- continue;
- }
- uncoveredCommits.add(new CommitEntry(revision, message));
- }
- if (!uncoveredCommits.isEmpty()) {
- merged.addAll(buildLocalSummariesFromCommits(project, uncoveredCommits, Math.max(1, targetItems)));
- }
-
- if (merged.isEmpty()) {
- merged.addAll(buildCommitFallbackItems(project, sourceCommits, targetItems));
- }
- return normalizeToTargetItemCount(project, merged, targetItems);
- }
-
- private LinkedHashSet buildCommitFallbackItems(String project, List sourceCommits, int targetItems) {
- final LinkedHashSet items = buildLocalSummariesFromCommits(project, sourceCommits, Math.max(1, targetItems));
- return normalizeToTargetItemCount(project, items, targetItems);
- }
-
- private LinkedHashSet buildLocalSummariesFromCommits(String project,
- List commits,
- int targetItems) {
- final LinkedHashSet items = new LinkedHashSet();
- final List messages = new ArrayList();
- if (commits != null) {
- for (CommitEntry entry : commits) {
- if (entry == null || entry.message == null || entry.message.trim().isEmpty()) {
- continue;
- }
- messages.add(entry.message.trim());
- }
- }
- if (messages.isEmpty()) {
- return items;
- }
-
- final int summaryCount = Math.max(1, Math.min(Math.max(1, targetItems), messages.size()));
- final int chunkSize = Math.max(1, (messages.size() + summaryCount - 1) / summaryCount);
-
- for (int i = 0; i < messages.size(); i += chunkSize) {
- final int to = Math.min(messages.size(), i + chunkSize);
- final LinkedHashSet condensed = new LinkedHashSet();
- for (int j = i; j < to; j++) {
- final String value = condenseMessageForSummary(messages.get(j));
- if (!value.isEmpty()) {
- condensed.add(value);
- }
- }
- if (condensed.isEmpty()) {
- continue;
- }
- final String summary = joinValues(condensed, ";");
- items.add(cleanWorkItem(summary));
- if (items.size() >= summaryCount) {
- break;
- }
- }
- return items;
- }
-
- private LinkedHashSet normalizeToTargetItemCount(String project,
- LinkedHashSet rawItems,
- int targetItems) {
- final int safeTarget = Math.max(1, targetItems);
- final List values = new ArrayList();
- if (rawItems != null) {
- for (String item : rawItems) {
- final String cleaned = cleanWorkItem(item);
- if (!cleaned.isEmpty()) {
- values.add(cleaned);
- }
- }
- }
-
- final LinkedHashSet result = new LinkedHashSet();
- if (values.isEmpty()) {
- int index = 1;
- while (result.size() < safeTarget) {
- result.add(buildFallbackItem(project, index++));
- }
- return result;
- }
-
- if (values.size() <= safeTarget) {
- result.addAll(values);
- int index = 1;
- while (result.size() < safeTarget) {
- result.add(buildFallbackItem(project, index++));
- }
- return result;
- }
-
- final int chunkSize = Math.max(1, (values.size() + safeTarget - 1) / safeTarget);
- for (int i = 0; i < values.size(); i += chunkSize) {
- final int to = Math.min(values.size(), i + chunkSize);
- final LinkedHashSet condensed = new LinkedHashSet();
- for (int j = i; j < to; j++) {
- condensed.add(condenseMessageForSummary(values.get(j)));
- }
- final String merged = cleanWorkItem(joinValues(condensed, ";"));
- if (!merged.isEmpty()) {
- result.add(merged);
- }
- if (result.size() >= safeTarget) {
- break;
- }
- }
- int index = 1;
- while (result.size() < safeTarget) {
- result.add(buildFallbackItem(project, index++));
- }
- return result;
- }
-
- private String condenseMessageForSummary(String message) {
- if (message == null) {
- return "";
- }
- String value = message.trim();
- value = value.replaceAll("(?i)^(feat|fix|chore|refactor|test|docs)(\\([^)]*\\))?:\\s*", "");
- value = value.replaceAll("\\s+", " ");
- value = value.replaceAll("[;;。]+$", "");
- return cleanWorkItem(value);
- }
-
- private LinkedHashSet parseSources(JsonElement element) {
- final LinkedHashSet sources = new LinkedHashSet();
- if (element == null || element.isJsonNull()) {
- return sources;
- }
- if (element.isJsonArray()) {
- for (JsonElement source : element.getAsJsonArray()) {
- if (source == null || source.isJsonNull()) {
- continue;
- }
- addRevisionFromText(sources, source.getAsString());
- }
- return sources;
- }
- addRevisionFromText(sources, element.getAsString());
- return sources;
- }
-
- private void addRevisionFromText(LinkedHashSet sources, String text) {
- if (sources == null || text == null || text.trim().isEmpty()) {
- return;
- }
- final Matcher matcher = REVISION_REF_PATTERN.matcher(text);
- while (matcher.find()) {
- sources.add(matcher.group().toLowerCase());
- }
- }
-
- private Map> buildProjectCommitMap(String markdownContent) {
- final Map> grouped = createProjectCommitMap();
- if (markdownContent == null || markdownContent.trim().isEmpty()) {
- return grouped;
- }
-
- final Matcher fileMatcher = FILE_SECTION_PATTERN.matcher(markdownContent);
- while (fileMatcher.find()) {
- final String filename = fileMatcher.group(1);
- final String section = fileMatcher.group(2);
- final String project = normalizeProject(filename);
- if (project == null) {
- continue;
- }
- final List commits = grouped.get(project);
- if (commits == null) {
- continue;
- }
- final Matcher commitMatcher = COMMIT_ENTRY_PATTERN.matcher(section == null ? "" : section);
- while (commitMatcher.find()) {
- final String revision = "r" + commitMatcher.group(1);
- final String message = normalizeCommitMessage(commitMatcher.group(2));
- if (message.isEmpty()) {
- continue;
- }
- commits.add(new CommitEntry(revision, message));
- }
- }
- return grouped;
- }
-
- private Map buildProjectLogCountsFromCommits(Map> projectCommits) {
- final Map counts = new LinkedHashMap();
- for (String project : FIXED_PROJECTS) {
- final List commits = projectCommits == null ? null : projectCommits.get(project);
- counts.put(project, Integer.valueOf(commits == null ? 0 : commits.size()));
- }
- return counts;
- }
-
- private Map> createProjectCommitMap() {
- final Map> map = new LinkedHashMap>();
- for (String project : FIXED_PROJECTS) {
- map.put(project, new ArrayList());
- }
- return map;
- }
-
- private String normalizeCommitMessage(String raw) {
- if (raw == null) {
- return "";
- }
- String cleaned = raw.replace("\r", "\n").trim();
- cleaned = cleaned.replaceAll("(?m)^\\s*[-*]\\s*", "");
- cleaned = cleaned.replaceAll("\\s+", " ");
- return cleanWorkItem(cleaned);
- }
-
- private String joinValues(Iterable values, String delimiter) {
- final StringBuilder builder = new StringBuilder();
- if (values == null) {
- return "";
- }
- for (String value : values) {
- if (value == null || value.trim().isEmpty()) {
- continue;
- }
- if (builder.length() > 0) {
- builder.append(delimiter);
- }
- builder.append(value.trim());
- }
- return builder.toString();
- }
-
- private JsonObject buildBasePayload(String period) {
- final JsonObject payload = new JsonObject();
- payload.addProperty("team", DEFAULT_TEAM);
- payload.addProperty("contact", DEFAULT_CONTACT);
- payload.addProperty("developer", DEFAULT_DEVELOPER);
- payload.addProperty("period", period);
-
- final JsonArray records = new JsonArray();
- final JsonObject record = new JsonObject();
- record.addProperty("sequence", 1);
- record.addProperty("project", FIXED_PROJECT_VALUE);
- record.addProperty("content", "");
- records.add(record);
- payload.add("records", records);
- return payload;
- }
-
- private Map buildProjectLogCounts(List markdownFiles) throws IOException {
- final Map counts = new LinkedHashMap();
- for (String project : FIXED_PROJECTS) {
- counts.put(project, Integer.valueOf(0));
- }
- for (Path path : markdownFiles) {
- final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
- final String project = normalizeProject(path.getFileName().toString());
- if (project == null) {
- continue;
- }
- int count = extractTotalRecordsCount(content);
- if (count <= 0) {
- count = estimateRevisionCount(content);
- }
- counts.put(project, Integer.valueOf(Math.max(count, 0)));
- }
- return counts;
- }
-
- private Map buildProjectMinItems(Map projectLogCounts) {
- final Map minimums = new LinkedHashMap();
- for (String project : FIXED_PROJECTS) {
- final int logCount = projectLogCounts.containsKey(project)
- ? projectLogCounts.get(project).intValue()
- : 0;
- minimums.put(project, Integer.valueOf(computeRequiredSummaryItems(
- logCount,
- SUMMARY_RECORDS_PER_ITEM,
- SUMMARY_MIN_ITEMS,
- SUMMARY_MAX_ITEMS
- )));
- }
- return minimums;
- }
-
- private int computeRequiredSummaryItems(int totalRecords,
- int recordsPerItem,
- int minItems,
- int maxItems) {
- final int safePerItem = Math.max(1, recordsPerItem);
- final int safeMin = Math.max(0, minItems);
- final int safeMax = Math.max(safeMin, maxItems);
- final int raw = (Math.max(0, totalRecords) + safePerItem - 1) / safePerItem;
- return Math.max(safeMin, Math.min(raw, safeMax));
- }
-
- private int extractTotalRecordsCount(String markdown) {
- if (markdown == null || markdown.trim().isEmpty()) {
- return 0;
- }
- final Matcher matcher = TOTAL_RECORDS_PATTERN.matcher(markdown);
- if (matcher.find()) {
- try {
- return Integer.parseInt(matcher.group(1));
- } catch (Exception ignored) {
- return 0;
- }
- }
- return 0;
- }
-
- private int estimateRevisionCount(String markdown) {
- if (markdown == null || markdown.trim().isEmpty()) {
- return 0;
- }
- int count = 0;
- final String[] lines = markdown.split("\\r?\\n");
- for (String line : lines) {
- if (line == null) {
- continue;
- }
- if (REVISION_HEADING_PATTERN.matcher(line.trim()).matches()) {
- count++;
- }
- }
- return count;
- }
-
- private String callDeepSeek(String apiKey, String prompt, TaskContext context, String model) throws IOException {
- final String modelName = model == null || model.trim().isEmpty() ? DEEPSEEK_MODEL_CHAT : model.trim();
- final int primaryMaxTokens = resolvePrimaryMaxTokens(modelName);
- final int retryMaxTokens = resolveRetryMaxTokens(modelName);
- try {
- final DeepSeekStreamResult primary = retrySupport.execute(
- () -> callDeepSeekOnce(apiKey, prompt, context, modelName, primaryMaxTokens),
- 3,
- 1000L
- );
- if (!"length".equalsIgnoreCase(primary.finishReason)) {
- return primary.answer;
- }
-
- // 输出达到 token 上限但 JSON 已完整时直接使用,避免误报失败。
- if (isValidJsonObjectText(primary.answer)) {
- LOGGER.warn("DeepSeek finish_reason=length, but JSON is complete; using primary response");
- return primary.answer;
- }
-
- context.emitEvent("phase", buildEventPayload(
- "DeepSeek(" + modelName + ") 输出长度触顶,自动重试(max_tokens=" + retryMaxTokens + ")"
- ));
- final DeepSeekStreamResult retried = retrySupport.execute(
- () -> callDeepSeekOnce(apiKey, prompt, context, modelName, retryMaxTokens),
- 2,
- 1200L
- );
- if ("length".equalsIgnoreCase(retried.finishReason) && !isValidJsonObjectText(retried.answer)) {
- throw new IllegalStateException(
- "DeepSeek 输出被截断(finish_reason=length),请缩短输入日志范围后重试"
- );
- }
- return retried.answer;
- } catch (IOException e) {
- context.setAiStreamStatus("FALLBACK");
- throw e;
- } catch (Exception e) {
- context.setAiStreamStatus("FALLBACK");
- throw new IOException(e.getMessage(), e);
- }
- }
-
- private int resolvePrimaryMaxTokens(String modelName) {
- if (DEEPSEEK_MODEL_THINK.equalsIgnoreCase(modelName)) {
- return DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY;
- }
- return DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY;
- }
-
- private int resolveRetryMaxTokens(String modelName) {
- if (DEEPSEEK_MODEL_THINK.equalsIgnoreCase(modelName)) {
- return DEEPSEEK_REASONER_MAX_TOKENS_RETRY;
- }
- return DEEPSEEK_CHAT_MAX_TOKENS_RETRY;
- }
-
- private DeepSeekStreamResult callDeepSeekOnce(String apiKey,
- String prompt,
- TaskContext context,
- String model,
- int maxTokens) throws Exception {
- final JsonObject message = new JsonObject();
- message.addProperty("role", "user");
- message.addProperty("content", prompt);
-
- final JsonArray messages = new JsonArray();
- messages.add(message);
-
- final JsonObject body = new JsonObject();
- body.addProperty("model", model);
- body.add("messages", messages);
- body.addProperty("max_tokens", maxTokens);
- 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)
- .addHeader("Authorization", "Bearer " + apiKey)
- .addHeader("Content-Type", "application/json")
- .post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
- .build();
-
- try (Response response = httpClient.newCall(request).execute()) {
- if (!response.isSuccessful()) {
- String errorBody = "";
- if (response.body() != null) {
- errorBody = response.body().string();
- }
- String detail = "DeepSeek API 调用失败: " + response.code() + " " + errorBody;
- if (response.code() == 429 || response.code() >= 500) {
- throw new RetrySupport.RetryableException(detail);
- }
- throw new IllegalStateException(detail);
- }
- if (response.body() == null) {
- throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
- }
-
- final StringBuilder answerBuilder = new StringBuilder();
- final StringBuilder reasoningBuilder = new StringBuilder();
- final okhttp3.ResponseBody responseBody = response.body();
- final okio.BufferedSource source = responseBody.source();
- String finishReason = "";
- int reasoningDeltaCount = 0;
- int answerDeltaCount = 0;
- Long usagePromptTokens = null;
- Long usageCompletionTokens = null;
- Long usageTotalTokens = null;
- String finalMessageContent = "";
-
- 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");
- usagePromptTokens = optLong(usage, "prompt_tokens");
- usageCompletionTokens = optLong(usage, "completion_tokens");
- usageTotalTokens = optLong(usage, "total_tokens");
- final Map usagePayload = new LinkedHashMap