fix: harden file download flow

This commit is contained in:
liumangmang
2026-04-30 10:30:26 +08:00
parent 3555d19b26
commit aef59e354a
24 changed files with 2316 additions and 2443 deletions
+4
View File
@@ -0,0 +1,4 @@
target/
.git/
.idea/
outputs/
+8 -1
View File
@@ -1,10 +1,17 @@
# syntax=docker/dockerfile:1.7
FROM maven:3.9.6-eclipse-temurin-8 AS builder
WORKDIR /app
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -DskipTests clean package
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -DskipTests clean package
FROM eclipse-temurin:8-jre
WORKDIR /app
+4 -3
View File
@@ -1,17 +1,18 @@
.PHONY: up down status
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)
BUILD_ENV := DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1
up:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) up -d --build
@$(BUILD_ENV) $(COMPOSE_CMD) up -d --build
@echo "Application is starting at http://localhost:18088"
down:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) down
@$(BUILD_ENV) $(COMPOSE_CMD) down
status:
@if [ -z "$(COMPOSE_CMD)" ]; then echo "docker compose/docker-compose not found"; exit 1; fi
@$(COMPOSE_CMD) ps
@$(BUILD_ENV) $(COMPOSE_CMD) ps
@echo "Access URL: http://localhost:18088"
+7 -1
View File
@@ -9,7 +9,7 @@ SVN 日志抓取与 AI 工作量分析工具,统一使用 Web 工作台入口
## 常用命令
```bash
# 一键启动(Docker
# 一键启动(Docker,每次会重新构建镜像并打包最新代码
make up
# 查看状态
@@ -28,6 +28,12 @@ mvn clean package -DskipTests
mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
```
## Docker 构建缓存说明
- `make up` 会始终执行 `docker compose up -d --build`,因此每次都会重新打包最新代码。
- Docker 构建默认启用 BuildKit;首次构建会下载 Maven 依赖,后续在 `pom.xml` 未变化时会复用 `/root/.m2` 构建缓存,通常不会重复大规模下载。
- 以下情况会触发依赖重新解析或重新下载:修改 `pom.xml`、执行 `docker builder prune` 清理构建缓存、切换到另一套 Docker Builder / Docker 环境。
## 代码结构
- `com.svnlog.web`Web 入口、控制器、DTO、服务
+21 -4
View File
@@ -8,7 +8,7 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持
2. 异步抓取日志并导出 Markdown
3. 使用 DeepSeek 或 OpenAI 兼容接口分析 Markdown 并生成 Excel
4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
5. 下载输出文件、配置 API Key 与输出目录
5. 下载输出文件、配置 API Key、SVN 账号与输出目录
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
批量抓取策略:多个项目按顺序执行(前一个项目完成后才开始下一个)。
@@ -18,7 +18,7 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持
在仓库根目录执行:
```bash
# Docker 一键启动(推荐)
# Docker 一键启动(推荐;每次会重新构建镜像并打包最新代码
make up
```
@@ -34,13 +34,20 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
http://localhost:18088
```
## Docker 构建行为
- `make up` 保持“重新构建并启动”的语义,每次都会执行一次 Maven 打包,确保容器内是最新代码。
- Docker 构建使用 BuildKit 缓存 Maven 本地仓库;首次构建会下载依赖,后续在 `pom.xml` 未变更时会优先命中缓存,不会在每次构建时重复下载全部依赖。
- 如果修改了 `pom.xml`、执行了 `docker builder prune`、或切换到新的 Docker 环境,依赖缓存会失效并重新下载。
- 如果本机 Docker 未启用 BuildKit,可显式设置 `DOCKER_BUILDKIT=1``COMPOSE_DOCKER_CLI_BUILD=1` 后再执行 `make up`
## 页面说明
- 工作台:最近任务统计与最近产物
- SVN 日志抓取:SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址)
- SVN 日志抓取:版本区间、过滤用户与连接测试;SVN 账号密码统一在系统设置中托管
- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
- 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务
- 系统设置:AI 提供商、DeepSeek API Key、OpenAI 兼容 Base URL/API Key/阶段模型、输出目录、默认 SVN 预置项目
- 系统设置:AI 提供商、DeepSeek API Key、OpenAI 兼容 Base URL/API Key/阶段模型、SVN 用户名/密码、输出目录、默认 SVN 预置项目
## 输出目录
@@ -48,6 +55,8 @@ http://localhost:18088
- Markdown 输出:`outputs/md/*.md`
- Excel 输出:`outputs/excel/*.xlsx`
- 任务持久化:`outputs/task-history.json`(重启后可恢复历史)
- 设置页保存 `outputDir` 时会自动创建不存在的目录;如果目标路径已被普通文件占用,或当前运行用户对该目录无写权限,`PUT /api/settings` 会明确返回失败。
- 如果本机已有 `outputs/` 但属主或权限异常,可手动修复为当前用户可写后再保存设置,例如调整目录属主或写权限。
## AI 提供商设置
@@ -66,6 +75,14 @@ http://localhost:18088
建议在生产环境优先使用环境变量,避免敏感信息暴露。
## SVN 凭据读取优先级
1. 单次请求显式传入的 `username/password`(兼容旧接口)
2. 设置页保存的运行时 `svnUsername/svnPassword`
3. 环境变量 `SVN_USERNAME` / `SVN_PASSWORD`
`GET /api/settings` 不会回显 `openaiApiKey``svnPassword` 明文,前端通过 `openaiApiKeyConfigured``svnCredentialsConfigured` 展示配置状态。
## SVN 预设来源与调用方式
- SVN 地址统一维护在 `application.properties``svn.presets[*]` 中。
@@ -1,6 +1,8 @@
package com.svnlog.web.controller;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
@@ -27,8 +29,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.svnlog.core.svn.SVNLogFetcher;
import com.svnlog.web.dto.AiAnalyzeRequest;
import com.svnlog.web.dto.SettingsUpdateRequest;
import com.svnlog.web.dto.SvnConnectionRequest;
@@ -102,18 +102,26 @@ public class AppController {
final String traceId = safe(request.getClientTraceId());
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final String url = preset.getUrl();
final String username = request.getUsername();
final String password = request.getPassword();
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
);
final int year = request.getYear().intValue();
final int month = request.getMonth().intValue();
LOGGER.info(
"[SVN_VERSION_RANGE][REQUEST] traceId={} presetId={} presetName={} url={} year={} month={} username={} password={}",
traceId, request.getPresetId(), preset.getName(), url, year, month, username, maskPassword(password)
traceId,
request.getPresetId(),
preset.getName(),
url,
year,
month,
credentials.getUsername(),
maskPassword(credentials.getPassword())
);
SVNLogFetcher fetcher = new SVNLogFetcher(url, username, password);
long[] range = fetcher.getVersionRangeByMonth(year, month, traceId);
final long[] range = svnWorkflowService.getVersionRange(request);
final Map<String, Object> response = new HashMap<String, Object>();
response.put("presetId", request.getPresetId());
@@ -246,11 +254,10 @@ public class AppController {
}
final HttpHeaders headers = new HttpHeaders();
headers.setContentDispositionFormData("attachment", file.getFileName().toString());
headers.add(HttpHeaders.CONTENT_DISPOSITION, buildAttachmentDisposition(file.getFileName().toString()));
headers.add("X-Content-Type-Options", "nosniff");
final MediaType mediaType = relativePath.toLowerCase().endsWith(".md")
? MediaType.parseMediaType("text/markdown")
: MediaType.APPLICATION_OCTET_STREAM;
final MediaType mediaType = resolveMediaType(file);
return ResponseEntity.ok()
.headers(headers)
@@ -273,6 +280,8 @@ public class AppController {
request.getOpenaiApiKey(),
request.getOpenaiStageOneModel(),
request.getOpenaiStageTwoModel(),
request.getSvnUsername(),
request.getSvnPassword(),
request.getOutputDir(),
request.getDefaultSvnPresetId()
);
@@ -290,6 +299,50 @@ public class AppController {
return value == null ? "" : value.trim();
}
private MediaType resolveMediaType(Path file) throws IOException {
final String fileName = file.getFileName().toString().toLowerCase();
if (fileName.endsWith(".md")) {
return MediaType.parseMediaType("text/markdown; charset=UTF-8");
}
if (fileName.endsWith(".xlsx")) {
return MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
}
if (fileName.endsWith(".xls")) {
return MediaType.parseMediaType("application/vnd.ms-excel");
}
if (fileName.endsWith(".txt")) {
return MediaType.parseMediaType("text/plain; charset=UTF-8");
}
if (fileName.endsWith(".json")) {
return MediaType.APPLICATION_JSON;
}
if (fileName.endsWith(".csv")) {
return MediaType.parseMediaType("text/csv; charset=UTF-8");
}
final String detected = Files.probeContentType(file);
if (detected != null && !detected.trim().isEmpty()) {
return MediaType.parseMediaType(detected);
}
return MediaType.APPLICATION_OCTET_STREAM;
}
private String buildAttachmentDisposition(String fileName) {
final String encoded;
try {
encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name())
.replace("+", "%20");
} catch (java.io.UnsupportedEncodingException impossible) {
throw new IllegalStateException("UTF-8 encoding unavailable", impossible);
}
final String asciiFallback = fileName
.replace("\\", "_")
.replace("\"", "_")
.replace("\r", "_")
.replace("\n", "_")
.replaceAll("[^\\x20-\\x7E]", "_");
return "attachment; filename=\"" + asciiFallback + "\"; filename*=UTF-8''" + encoded;
}
private String buildSseErrorPayload(String error, String taskId, int status) {
final String safeError = sanitize(error);
final String safeTaskId = sanitize(taskId);
@@ -0,0 +1,26 @@
package com.svnlog.web.controller;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
@GetMapping(value = {"/", "/index.html"})
public ResponseEntity<Resource> index() {
final HttpHeaders headers = new HttpHeaders();
headers.setCacheControl(CacheControl.noStore().mustRevalidate().getHeaderValue());
headers.add(HttpHeaders.PRAGMA, "no-cache");
headers.add(HttpHeaders.EXPIRES, "0");
return ResponseEntity.ok()
.headers(headers)
.contentType(MediaType.TEXT_HTML)
.body(new ClassPathResource("static/index.html"));
}
}
@@ -8,6 +8,8 @@ public class SettingsUpdateRequest {
private String openaiApiKey;
private String openaiStageOneModel;
private String openaiStageTwoModel;
private String svnUsername;
private String svnPassword;
private String outputDir;
private String defaultSvnPresetId;
@@ -59,6 +61,22 @@ public class SettingsUpdateRequest {
this.openaiStageTwoModel = openaiStageTwoModel;
}
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 getOutputDir() {
return outputDir;
}
@@ -7,10 +7,8 @@ public class SvnConnectionRequest {
@NotBlank
private String presetId;
@NotBlank
private String username;
@NotBlank
private String password;
public String getPresetId() {
@@ -9,10 +9,8 @@ public class SvnFetchRequest {
private String projectName;
@NotBlank
private String username;
@NotBlank
private String password;
private Long startRevision;
@@ -8,10 +8,8 @@ public class SvnVersionRangeRequest {
@NotBlank
private String presetId;
@NotBlank
private String username;
@NotBlank
private String password;
@NotNull
@@ -0,0 +1,94 @@
package com.svnlog.web.model;
public class PersistedSettings {
private String provider;
private String apiKey;
private String openaiBaseUrl;
private String openaiApiKey;
private String openaiStageOneModel;
private String openaiStageTwoModel;
private String svnUsername;
private String svnPassword;
private String outputDir;
private String defaultSvnPresetId;
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
this.provider = provider;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getOpenaiBaseUrl() {
return openaiBaseUrl;
}
public void setOpenaiBaseUrl(String openaiBaseUrl) {
this.openaiBaseUrl = openaiBaseUrl;
}
public String getOpenaiApiKey() {
return openaiApiKey;
}
public void setOpenaiApiKey(String openaiApiKey) {
this.openaiApiKey = openaiApiKey;
}
public String getOpenaiStageOneModel() {
return openaiStageOneModel;
}
public void setOpenaiStageOneModel(String openaiStageOneModel) {
this.openaiStageOneModel = openaiStageOneModel;
}
public String getOpenaiStageTwoModel() {
return openaiStageTwoModel;
}
public void setOpenaiStageTwoModel(String openaiStageTwoModel) {
this.openaiStageTwoModel = openaiStageTwoModel;
}
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 getOutputDir() {
return outputDir;
}
public void setOutputDir(String outputDir) {
this.outputDir = outputDir;
}
public String getDefaultSvnPresetId() {
return defaultSvnPresetId;
}
public void setDefaultSvnPresetId(String defaultSvnPresetId) {
this.defaultSvnPresetId = defaultSvnPresetId;
}
}
@@ -28,11 +28,35 @@ public class OutputFileService {
}
}
public synchronized Path peekOutputRoot() {
return outputRoot;
}
public synchronized Path getOutputRoot() throws IOException {
Files.createDirectories(outputRoot);
return outputRoot;
}
public synchronized Path ensureOutputRootWritable() throws IOException {
final Path root = outputRoot;
if (Files.exists(root)) {
if (!Files.isDirectory(root)) {
throw new IOException("输出路径不是目录: " + root);
}
} else {
try {
Files.createDirectories(root);
} catch (IOException e) {
throw new IOException("无法创建输出目录: " + root + ",请检查父目录是否存在且当前用户具备写权限", e);
}
}
if (!Files.isWritable(root)) {
throw new IOException("输出目录不可写: " + root + ",请调整目录权限或改为当前用户可写的目录");
}
return root;
}
public Path resolveInOutput(String relative) throws IOException {
final Path root = getOutputRoot();
final Path resolved = root.resolve(relative).normalize();
@@ -0,0 +1,43 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.stereotype.Service;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.svnlog.web.model.PersistedSettings;
@Service
public class SettingsPersistenceService {
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
public PersistedSettings load(Path storePath) throws IOException {
if (storePath == null || !Files.exists(storePath) || !Files.isRegularFile(storePath)) {
return null;
}
try (Reader reader = Files.newBufferedReader(storePath, StandardCharsets.UTF_8)) {
return gson.fromJson(reader, PersistedSettings.class);
}
}
public void save(Path storePath, PersistedSettings settings) throws IOException {
if (storePath == null || settings == null) {
return;
}
if (storePath.getParent() != null) {
Files.createDirectories(storePath.getParent());
}
try (Writer writer = Files.newBufferedWriter(storePath, StandardCharsets.UTF_8)) {
gson.toJson(settings, writer);
}
}
}
@@ -1,15 +1,24 @@
package com.svnlog.web.service;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.svnlog.web.model.PersistedSettings;
@Service
public class SettingsService {
private static final Logger LOGGER = LoggerFactory.getLogger(SettingsService.class);
public static final String PROVIDER_DEEPSEEK = "deepseek";
public static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible";
private static final String SETTINGS_FILE_NAME = "settings.json";
// 启动默认 API Key(仅作为本地默认值,可在设置页覆盖)
private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7";
@@ -17,27 +26,63 @@ public class SettingsService {
private static final String DEFAULT_OPENAI_API_KEY = "sk-f8b3f43e1fdd4f50b287050f08a6c7ed";
private static final String DEFAULT_OPENAI_STAGE_ONE_MODEL = "deepseek-v4-flash";
private static final String DEFAULT_OPENAI_STAGE_TWO_MODEL = "deepseek-v4-pro";
private static final String ENV_SVN_USERNAME = "SVN_USERNAME";
private static final String ENV_SVN_PASSWORD = "SVN_PASSWORD";
private final OutputFileService outputFileService;
private final SettingsPersistenceService settingsPersistenceService;
private final SvnPresetService svnPresetService;
private final Path bootstrapOutputRoot;
private volatile String runtimeApiKey;
private volatile String runtimeProvider;
private volatile String runtimeOpenaiBaseUrl;
private volatile String runtimeOpenaiApiKey;
private volatile String runtimeOpenaiStageOneModel;
private volatile String runtimeOpenaiStageTwoModel;
private volatile String runtimeSvnUsername;
private volatile String runtimeSvnPassword;
private volatile String defaultSvnPresetId;
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
public static final class SvnCredentials {
private final String username;
private final String password;
public SvnCredentials(String username, String password) {
this.username = username;
this.password = password;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public boolean isConfigured() {
return !isBlank(username) && !isBlank(password);
}
}
@Autowired
public SettingsService(OutputFileService outputFileService,
SettingsPersistenceService settingsPersistenceService,
SvnPresetService svnPresetService) {
this.outputFileService = outputFileService;
this.settingsPersistenceService = settingsPersistenceService;
this.svnPresetService = svnPresetService;
this.bootstrapOutputRoot = initBootstrapOutputRoot(outputFileService);
this.runtimeApiKey = initStartupApiKey();
this.runtimeProvider = PROVIDER_DEEPSEEK;
this.runtimeOpenaiBaseUrl = DEFAULT_OPENAI_BASE_URL;
this.runtimeOpenaiApiKey = DEFAULT_OPENAI_API_KEY;
this.runtimeOpenaiStageOneModel = DEFAULT_OPENAI_STAGE_ONE_MODEL;
this.runtimeOpenaiStageTwoModel = DEFAULT_OPENAI_STAGE_TWO_MODEL;
this.runtimeSvnUsername = trim(System.getenv(ENV_SVN_USERNAME));
this.runtimeSvnPassword = trim(System.getenv(ENV_SVN_PASSWORD));
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
loadPersistedSettings();
}
public Map<String, Object> getSettings() throws IOException {
@@ -49,44 +94,120 @@ public class SettingsService {
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
result.put("apiKeySource", detectApiKeySource(envKey));
result.put("openaiBaseUrl", getOpenaiBaseUrl());
result.put("openaiApiKey", getOpenaiApiKey());
result.put("openaiApiKeyConfigured", isConfigured(getOpenaiApiKey()));
result.put("openaiStageOneModel", getOpenaiStageOneModel());
result.put("openaiStageTwoModel", getOpenaiStageTwoModel());
result.put("svnUsername", getSvnUsername());
result.put("svnCredentialsConfigured", getConfiguredSvnCredentials().isConfigured());
result.put("outputDir", outputFileService.getOutputRoot().toString());
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
return result;
}
public void updateSettings(String apiKey,
String provider,
String openaiBaseUrl,
String openaiApiKey,
String openaiStageOneModel,
String openaiStageTwoModel,
String outputDir,
String newDefaultSvnPresetId) {
if (apiKey != null && !apiKey.trim().isEmpty()) {
this.runtimeApiKey = apiKey.trim();
public synchronized void updateSettings(String apiKey,
String provider,
String openaiBaseUrl,
String openaiApiKey,
String openaiStageOneModel,
String openaiStageTwoModel,
String svnUsername,
String svnPassword,
String outputDir,
String newDefaultSvnPresetId) {
final String previousRuntimeApiKey = runtimeApiKey;
final String previousRuntimeProvider = runtimeProvider;
final String previousRuntimeOpenaiBaseUrl = runtimeOpenaiBaseUrl;
final String previousRuntimeOpenaiApiKey = runtimeOpenaiApiKey;
final String previousRuntimeOpenaiStageOneModel = runtimeOpenaiStageOneModel;
final String previousRuntimeOpenaiStageTwoModel = runtimeOpenaiStageTwoModel;
final String previousRuntimeSvnUsername = runtimeSvnUsername;
final String previousRuntimeSvnPassword = runtimeSvnPassword;
final String previousDefaultSvnPresetId = defaultSvnPresetId;
final Path previousOutputRoot = outputFileService.peekOutputRoot();
try {
if (apiKey != null && !apiKey.trim().isEmpty()) {
this.runtimeApiKey = apiKey.trim();
}
this.runtimeProvider = normalizeProvider(provider);
if (openaiBaseUrl != null && !openaiBaseUrl.trim().isEmpty()) {
this.runtimeOpenaiBaseUrl = openaiBaseUrl.trim();
}
if (openaiApiKey != null && !openaiApiKey.trim().isEmpty()) {
this.runtimeOpenaiApiKey = openaiApiKey.trim();
}
if (openaiStageOneModel != null && !openaiStageOneModel.trim().isEmpty()) {
this.runtimeOpenaiStageOneModel = openaiStageOneModel.trim();
}
if (openaiStageTwoModel != null && !openaiStageTwoModel.trim().isEmpty()) {
this.runtimeOpenaiStageTwoModel = openaiStageTwoModel.trim();
}
if (svnUsername != null && !svnUsername.trim().isEmpty()) {
this.runtimeSvnUsername = svnUsername.trim();
}
if (svnPassword != null && !svnPassword.trim().isEmpty()) {
this.runtimeSvnPassword = svnPassword.trim();
}
if (outputDir != null && !outputDir.trim().isEmpty()) {
outputFileService.setOutputRoot(outputDir);
}
if (svnPresetService.containsPresetId(newDefaultSvnPresetId)) {
this.defaultSvnPresetId = newDefaultSvnPresetId;
}
outputFileService.ensureOutputRootWritable();
persistCurrentSettings();
} catch (IOException e) {
rollbackSettings(
previousRuntimeApiKey,
previousRuntimeProvider,
previousRuntimeOpenaiBaseUrl,
previousRuntimeOpenaiApiKey,
previousRuntimeOpenaiStageOneModel,
previousRuntimeOpenaiStageTwoModel,
previousRuntimeSvnUsername,
previousRuntimeSvnPassword,
previousDefaultSvnPresetId,
previousOutputRoot
);
throw new IllegalStateException("保存系统设置失败: " + e.getMessage(), e);
} catch (RuntimeException e) {
rollbackSettings(
previousRuntimeApiKey,
previousRuntimeProvider,
previousRuntimeOpenaiBaseUrl,
previousRuntimeOpenaiApiKey,
previousRuntimeOpenaiStageOneModel,
previousRuntimeOpenaiStageTwoModel,
previousRuntimeSvnUsername,
previousRuntimeSvnPassword,
previousDefaultSvnPresetId,
previousOutputRoot
);
throw e;
}
this.runtimeProvider = normalizeProvider(provider);
if (openaiBaseUrl != null && !openaiBaseUrl.trim().isEmpty()) {
this.runtimeOpenaiBaseUrl = openaiBaseUrl.trim();
}
if (openaiApiKey != null && !openaiApiKey.trim().isEmpty()) {
this.runtimeOpenaiApiKey = openaiApiKey.trim();
}
if (openaiStageOneModel != null && !openaiStageOneModel.trim().isEmpty()) {
this.runtimeOpenaiStageOneModel = openaiStageOneModel.trim();
}
if (openaiStageTwoModel != null && !openaiStageTwoModel.trim().isEmpty()) {
this.runtimeOpenaiStageTwoModel = openaiStageTwoModel.trim();
}
if (outputDir != null && !outputDir.trim().isEmpty()) {
outputFileService.setOutputRoot(outputDir);
}
if (svnPresetService.containsPresetId(newDefaultSvnPresetId)) {
this.defaultSvnPresetId = newDefaultSvnPresetId;
}
private synchronized void rollbackSettings(String runtimeApiKey,
String runtimeProvider,
String runtimeOpenaiBaseUrl,
String runtimeOpenaiApiKey,
String runtimeOpenaiStageOneModel,
String runtimeOpenaiStageTwoModel,
String runtimeSvnUsername,
String runtimeSvnPassword,
String defaultSvnPresetId,
Path outputRoot) {
this.runtimeApiKey = runtimeApiKey;
this.runtimeProvider = runtimeProvider;
this.runtimeOpenaiBaseUrl = runtimeOpenaiBaseUrl;
this.runtimeOpenaiApiKey = runtimeOpenaiApiKey;
this.runtimeOpenaiStageOneModel = runtimeOpenaiStageOneModel;
this.runtimeOpenaiStageTwoModel = runtimeOpenaiStageTwoModel;
this.runtimeSvnUsername = runtimeSvnUsername;
this.runtimeSvnPassword = runtimeSvnPassword;
this.defaultSvnPresetId = defaultSvnPresetId;
if (outputRoot != null) {
outputFileService.setOutputRoot(outputRoot.toString());
}
}
@@ -116,6 +237,99 @@ public class SettingsService {
return null;
}
private synchronized void loadPersistedSettings() {
try {
final Path bootstrapStorePath = getBootstrapSettingsPath();
applyPersistedSettings(settingsPersistenceService.load(bootstrapStorePath));
final Path activeStorePath = getCurrentSettingsPath();
if (!activeStorePath.equals(bootstrapStorePath)) {
applyPersistedSettings(settingsPersistenceService.load(activeStorePath));
}
} catch (IOException e) {
LOGGER.warn("Failed to load persisted settings", e);
}
}
private void applyPersistedSettings(PersistedSettings persistedSettings) {
if (persistedSettings == null) {
return;
}
if (!isBlank(persistedSettings.getApiKey())) {
this.runtimeApiKey = persistedSettings.getApiKey().trim();
}
if (!isBlank(persistedSettings.getProvider())) {
this.runtimeProvider = normalizeProvider(persistedSettings.getProvider());
}
if (!isBlank(persistedSettings.getOpenaiBaseUrl())) {
this.runtimeOpenaiBaseUrl = persistedSettings.getOpenaiBaseUrl().trim();
}
if (!isBlank(persistedSettings.getOpenaiApiKey())) {
this.runtimeOpenaiApiKey = persistedSettings.getOpenaiApiKey().trim();
}
if (!isBlank(persistedSettings.getOpenaiStageOneModel())) {
this.runtimeOpenaiStageOneModel = persistedSettings.getOpenaiStageOneModel().trim();
}
if (!isBlank(persistedSettings.getOpenaiStageTwoModel())) {
this.runtimeOpenaiStageTwoModel = persistedSettings.getOpenaiStageTwoModel().trim();
}
if (!isBlank(persistedSettings.getSvnUsername())) {
this.runtimeSvnUsername = persistedSettings.getSvnUsername().trim();
}
if (!isBlank(persistedSettings.getSvnPassword())) {
this.runtimeSvnPassword = persistedSettings.getSvnPassword().trim();
}
if (!isBlank(persistedSettings.getOutputDir())) {
outputFileService.setOutputRoot(persistedSettings.getOutputDir());
}
if (svnPresetService.containsPresetId(persistedSettings.getDefaultSvnPresetId())) {
this.defaultSvnPresetId = persistedSettings.getDefaultSvnPresetId().trim();
}
}
private void persistCurrentSettings() throws IOException {
final PersistedSettings persistedSettings = snapshotCurrentSettings();
final Path activeStorePath = getCurrentSettingsPath();
settingsPersistenceService.save(activeStorePath, persistedSettings);
final Path bootstrapStorePath = getBootstrapSettingsPath();
if (!bootstrapStorePath.equals(activeStorePath)) {
settingsPersistenceService.save(bootstrapStorePath, persistedSettings);
}
}
private PersistedSettings snapshotCurrentSettings() throws IOException {
final PersistedSettings persistedSettings = new PersistedSettings();
persistedSettings.setProvider(getProvider());
persistedSettings.setApiKey(trim(runtimeApiKey));
persistedSettings.setOpenaiBaseUrl(getOpenaiBaseUrl());
persistedSettings.setOpenaiApiKey(trim(runtimeOpenaiApiKey));
persistedSettings.setOpenaiStageOneModel(getOpenaiStageOneModel());
persistedSettings.setOpenaiStageTwoModel(getOpenaiStageTwoModel());
persistedSettings.setSvnUsername(trim(runtimeSvnUsername));
persistedSettings.setSvnPassword(trim(runtimeSvnPassword));
persistedSettings.setOutputDir(outputFileService.getOutputRoot().toString());
persistedSettings.setDefaultSvnPresetId(getDefaultSvnPresetId());
return persistedSettings;
}
private Path getCurrentSettingsPath() throws IOException {
return outputFileService.getOutputRoot().resolve(SETTINGS_FILE_NAME);
}
private Path getBootstrapSettingsPath() {
return bootstrapOutputRoot.resolve(SETTINGS_FILE_NAME);
}
private Path initBootstrapOutputRoot(OutputFileService outputFileService) {
try {
return outputFileService.getOutputRoot();
} catch (IOException e) {
LOGGER.warn("Failed to resolve bootstrap output root, fallback to ./outputs", e);
return Paths.get("outputs").toAbsolutePath().normalize();
}
}
private String detectApiKeySource(String envKey) {
if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) {
if (envKey != null && !envKey.trim().isEmpty() && runtimeApiKey.equals(envKey.trim())) {
@@ -148,6 +362,23 @@ public class SettingsService {
return trimOrDefault(runtimeOpenaiApiKey, DEFAULT_OPENAI_API_KEY);
}
public String getSvnUsername() {
return trim(resolveSvnUsername(null));
}
public SvnCredentials resolveSvnCredentials(String requestUsername, String requestPassword) {
final String username = resolveSvnUsername(requestUsername);
final String password = resolveSvnPassword(requestPassword);
if (isBlank(username) || isBlank(password)) {
throw new IllegalArgumentException("未配置 SVN 账号,请先到系统设置页填写 SVN 用户名和密码");
}
return new SvnCredentials(username, password);
}
public SvnCredentials getConfiguredSvnCredentials() {
return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null));
}
public String getOpenaiStageOneModel() {
return trimOrDefault(runtimeOpenaiStageOneModel, DEFAULT_OPENAI_STAGE_ONE_MODEL);
}
@@ -172,7 +403,35 @@ public class SettingsService {
return value != null && !value.trim().isEmpty();
}
private String resolveSvnUsername(String requestUsername) {
final String requestValue = trim(requestUsername);
if (!requestValue.isEmpty()) {
return requestValue;
}
final String runtimeValue = trim(runtimeSvnUsername);
if (!runtimeValue.isEmpty()) {
return runtimeValue;
}
return trim(System.getenv(ENV_SVN_USERNAME));
}
private String resolveSvnPassword(String requestPassword) {
final String requestValue = trim(requestPassword);
if (!requestValue.isEmpty()) {
return requestValue;
}
final String runtimeValue = trim(runtimeSvnPassword);
if (!runtimeValue.isEmpty()) {
return runtimeValue;
}
return trim(System.getenv(ENV_SVN_PASSWORD));
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}
@@ -13,6 +13,7 @@ import com.svnlog.core.svn.LogEntry;
import com.svnlog.core.svn.SVNLogFetcher;
import com.svnlog.web.dto.SvnConnectionRequest;
import com.svnlog.web.dto.SvnFetchRequest;
import com.svnlog.web.dto.SvnVersionRangeRequest;
import com.svnlog.web.model.SvnPreset;
import com.svnlog.web.model.TaskResult;
@@ -20,23 +21,61 @@ import com.svnlog.web.model.TaskResult;
public class SvnWorkflowService {
private final OutputFileService outputFileService;
private final SettingsService settingsService;
private final SvnPresetService svnPresetService;
public SvnWorkflowService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
public SvnWorkflowService(OutputFileService outputFileService,
SettingsService settingsService,
SvnPresetService svnPresetService) {
this.outputFileService = outputFileService;
this.settingsService = settingsService;
this.svnPresetService = svnPresetService;
}
public void testConnection(SvnConnectionRequest request) throws SVNException {
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
);
final SVNLogFetcher fetcher = new SVNLogFetcher(
preset.getUrl(),
credentials.getUsername(),
credentials.getPassword()
);
fetcher.testConnection();
}
public long[] getVersionRange(SvnVersionRangeRequest request) throws SVNException {
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
);
final SVNLogFetcher fetcher = new SVNLogFetcher(
preset.getUrl(),
credentials.getUsername(),
credentials.getPassword()
);
return fetcher.getVersionRangeByMonth(
request.getYear().intValue(),
request.getMonth().intValue(),
request.getClientTraceId()
);
}
public TaskResult fetchToMarkdown(SvnFetchRequest request, TaskContext context) throws Exception {
final SvnPreset preset = svnPresetService.getById(request.getPresetId());
final SettingsService.SvnCredentials credentials = settingsService.resolveSvnCredentials(
request.getUsername(),
request.getPassword()
);
context.setProgress(10, "正在连接 SVN 仓库: " + preset.getName());
final SVNLogFetcher fetcher = new SVNLogFetcher(preset.getUrl(), request.getUsername(), request.getPassword());
final SVNLogFetcher fetcher = new SVNLogFetcher(
preset.getUrl(),
credentials.getUsername(),
credentials.getPassword()
);
fetcher.testConnection();
context.setProgress(30, "正在拉取 SVN 日志");
@@ -60,7 +99,7 @@ public class SvnWorkflowService {
MarkdownReportWriter.write(
outputPath,
preset.getUrl(),
request.getUsername(),
credentials.getUsername(),
start,
end,
request.getFilterUser(),
File diff suppressed because it is too large Load Diff
@@ -1,425 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SVN 日志工作台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles-redesign.css">
</head>
<body>
<!-- 主题切换按钮 -->
<button class="theme-toggle" id="theme-toggle" aria-label="切换主题">
<svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<div class="app-container">
<!-- 侧边栏 -->
<aside class="sidebar" role="navigation" aria-label="主导航">
<div class="sidebar-header">
<svg class="logo-icon" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
<h1 class="logo-text">SVN 工作台</h1>
</div>
<nav class="nav-menu">
<button class="nav-item active" data-view="dashboard">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
<span>工作台</span>
</button>
<button class="nav-item" data-view="svn">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>SVN 日志</span>
</button>
<button class="nav-item" data-view="ai">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<span>AI 分析</span>
</button>
<button class="nav-item" data-view="history">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>任务历史</span>
</button>
<button class="nav-item" data-view="settings">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v6m0 6v6m5.2-13.2l-4.2 4.2m-2 2l-4.2 4.2M23 12h-6m-6 0H5m13.2 5.2l-4.2-4.2m-2-2l-4.2-4.2"/>
</svg>
<span>系统设置</span>
</button>
</nav>
<div class="sidebar-footer">
<div class="status-indicator">
<span class="status-dot"></span>
<span class="status-text">系统正常</span>
</div>
</div>
</aside>
<!-- 主内容区 -->
<main class="main-content" id="main-content">
<!-- 页面头部 -->
<header class="page-header">
<div class="header-content">
<h2 class="page-title" id="page-title">工作台</h2>
<p class="page-description" id="page-description">查看系统状态与最近产物</p>
</div>
</header>
<!-- 工作台视图 -->
<section class="view active" id="view-dashboard" aria-live="polite">
<!-- 统计卡片 -->
<div class="stats-grid">
<article class="stat-card">
<div class="stat-icon stat-icon-primary">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-label">任务总数</p>
<p class="stat-value" id="stat-total">0</p>
</div>
</article>
<article class="stat-card">
<div class="stat-icon stat-icon-warning">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-label">执行中</p>
<p class="stat-value" id="stat-running">0</p>
</div>
</article>
<article class="stat-card">
<div class="stat-icon stat-icon-danger">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-label">失败任务</p>
<p class="stat-value" id="stat-failed">0</p>
</div>
</article>
<article class="stat-card">
<div class="stat-icon stat-icon-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-label">系统状态</p>
<p class="stat-value stat-value-small" id="stat-health">-</p>
</div>
</article>
</div>
<!-- 健康检查卡片 -->
<article class="glass-card" id="health-card">
<div class="card-header">
<h3 class="card-title">健康检查</h3>
</div>
<div class="card-body">
<p class="text-muted" id="health-details">加载中...</p>
</div>
</article>
<!-- 最近任务和文件 -->
<div class="grid-2">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">最近任务</h3>
</div>
<div class="card-body">
<ul id="recent-tasks" class="item-list"></ul>
</div>
</article>
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">最近文件</h3>
</div>
<div class="card-body">
<ul id="recent-files" class="item-list"></ul>
</div>
</article>
</div>
</section>
<!-- SVN 日志抓取视图 -->
<section class="view" id="view-svn">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">SVN 抓取参数</h3>
</div>
<div class="card-body">
<form id="svn-form" class="form-layout">
<div class="form-group">
<label for="svn-preset-select" class="form-label">预置项目</label>
<select name="presetId" id="svn-preset-select" class="form-select" aria-label="预置 SVN 项目"></select>
</div>
<div class="form-group">
<label for="project-name" class="form-label">项目名</label>
<input type="text" name="projectName" id="project-name" class="form-input" placeholder="如:PRS-7050">
</div>
<div class="form-group form-group-full">
<label for="svn-url" class="form-label">SVN 地址 <span class="required">*</span></label>
<input type="url" name="url" id="svn-url" class="form-input" placeholder="https://..." required aria-label="SVN 地址">
</div>
<div class="form-group">
<label for="svn-username" class="form-label">账号 <span class="required">*</span></label>
<input type="text" name="username" id="svn-username" class="form-input" placeholder="请输入账号" required>
</div>
<div class="form-group">
<label for="svn-password" class="form-label">密码 <span class="required">*</span></label>
<input type="password" name="password" id="svn-password" class="form-input" placeholder="请输入密码" required>
</div>
<div class="form-group">
<label for="start-revision" class="form-label">开始版本号</label>
<input type="text" name="startRevision" id="start-revision" class="form-input" inputmode="numeric" placeholder="默认最新">
</div>
<div class="form-group">
<label for="end-revision" class="form-label">结束版本号</label>
<input type="text" name="endRevision" id="end-revision" class="form-input" inputmode="numeric" placeholder="默认最新">
</div>
<div class="form-group form-group-full">
<label for="filter-user" class="form-label">过滤用户名</label>
<input type="text" name="filterUser" id="filter-user" class="form-input" placeholder="包含匹配,留空不过滤">
</div>
<div class="form-actions form-group-full">
<button type="button" id="btn-test-connection" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
测试连接
</button>
<button type="submit" id="btn-svn-run" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
开始抓取并导出
</button>
</div>
</form>
</div>
</article>
</section>
<!-- AI 工作量分析视图 -->
<section class="view" id="view-ai">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">AI 分析参数</h3>
</div>
<div class="card-body">
<form id="ai-form" class="form-layout">
<div class="form-group form-group-full">
<label class="form-label">选择 Markdown 输入文件</label>
<div class="file-picker" id="md-file-picker" role="group" aria-label="Markdown 文件选择"></div>
</div>
<div class="form-group">
<label for="work-period" class="form-label">工作周期</label>
<input type="text" name="period" id="work-period" class="form-input" placeholder="例如 2026年03月">
</div>
<div class="form-group">
<label for="output-filename" class="form-label">输出文件名</label>
<input type="text" name="outputFileName" id="output-filename" class="form-input" placeholder="例如 202603工作量统计.xlsx">
</div>
<div class="form-group form-group-full">
<label for="temp-api-key" class="form-label">临时 API Key(可选)</label>
<input type="password" name="apiKey" id="temp-api-key" class="form-input" placeholder="优先使用设置页或环境变量">
</div>
<div class="form-actions form-group-full">
<button type="submit" id="btn-ai-run" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
开始 AI 分析并导出 Excel
</button>
</div>
</form>
</div>
</article>
</section>
<!-- 任务历史视图 -->
<section class="view" id="view-history">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">任务列表</h3>
</div>
<div class="card-body">
<div class="filter-toolbar" id="history-toolbar">
<select id="task-filter-status" class="form-select" aria-label="状态筛选">
<option value="">全部状态</option>
<option value="PENDING">PENDING</option>
<option value="RUNNING">RUNNING</option>
<option value="SUCCESS">SUCCESS</option>
<option value="FAILED">FAILED</option>
<option value="CANCELLED">CANCELLED</option>
</select>
<select id="task-filter-type" class="form-select" aria-label="类型筛选">
<option value="">全部类型</option>
<option value="SVN_FETCH">SVN_FETCH</option>
<option value="AI_ANALYZE">AI_ANALYZE</option>
</select>
<input id="task-filter-keyword" class="form-input" placeholder="搜索任务ID/信息" aria-label="关键词搜索">
<button id="btn-task-filter" type="button" class="btn btn-secondary">查询</button>
</div>
<div id="task-table" class="table-container"></div>
<div class="pagination" id="task-pager"></div>
</div>
</article>
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">输出文件</h3>
</div>
<div class="card-body">
<div id="file-table" class="table-container"></div>
</div>
</article>
</section>
<!-- 系统设置视图 -->
<section class="view" id="view-settings">
<article class="glass-card">
<div class="card-header">
<h3 class="card-title">系统设置</h3>
</div>
<div class="card-body">
<form id="settings-form" class="form-layout">
<div class="form-group form-group-full">
<label for="settings-provider" class="form-label">AI 提供商</label>
<select name="provider" id="settings-provider" class="form-select">
<option value="deepseek">DeepSeek</option>
<option value="openai-compatible">OpenAI兼容</option>
</select>
</div>
<div class="form-group form-group-full">
<label for="api-key-input" class="form-label">DeepSeek API Key</label>
<input type="password" name="apiKey" id="api-key-input" class="form-input" placeholder="设置后将保存在当前进程内存">
</div>
<div class="form-group form-group-full" id="openai-settings-group" hidden>
<label for="openai-base-url" class="form-label">OpenAI兼容 Base URL</label>
<input type="text" name="openaiBaseUrl" id="openai-base-url" class="form-input" placeholder="例如 http://127.0.0.1:5001/v1">
</div>
<div class="form-group form-group-full" id="openai-api-key-group" hidden>
<label for="openai-api-key" class="form-label">OpenAI兼容 API Key</label>
<input type="password" name="openaiApiKey" id="openai-api-key" class="form-input" placeholder="设置后将保存在当前进程内存">
</div>
<div class="form-group" id="openai-stage-one-group" hidden>
<label for="openai-stage-one-model" class="form-label">第一阶段模型</label>
<select name="openaiStageOneModel" id="openai-stage-one-model" class="form-select">
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
</select>
</div>
<div class="form-group" id="openai-stage-two-group" hidden>
<label for="openai-stage-two-model" class="form-label">第二阶段模型</label>
<select name="openaiStageTwoModel" id="openai-stage-two-model" class="form-select">
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
</select>
</div>
<div class="form-group form-group-full">
<label for="default-preset" class="form-label">默认 SVN 项目</label>
<select name="defaultSvnPresetId" id="default-preset" class="form-select"></select>
</div>
<div class="form-group form-group-full">
<label for="output-dir" class="form-label">输出目录</label>
<input type="text" name="outputDir" id="output-dir" class="form-input" placeholder="默认 outputs">
</div>
<div class="form-actions form-group-full">
<button type="submit" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
保存设置
</button>
</div>
</form>
<p id="settings-state" class="text-muted"></p>
</div>
</article>
</section>
</main>
</div>
<!-- Toast 通知 -->
<div class="toast-container" id="toast-container" aria-live="assertive" aria-atomic="true"></div>
<!-- 加载指示器 -->
<div class="loading-overlay" id="loading-overlay">
<div class="loading-spinner"></div>
</div>
<script src="/app-redesign.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-746
View File
@@ -1,746 +0,0 @@
:root {
--bg-0: #0b1020;
--bg-1: #121a2f;
--bg-2: #1a2744;
--surface-0: rgba(19, 30, 53, 0.62);
--surface-1: rgba(24, 37, 64, 0.84);
--surface-2: #1f3158;
--text-0: #e8efff;
--text-1: #c4d2f0;
--text-2: #91a3cc;
--accent-0: #6ba6ff;
--accent-1: #8fc0ff;
--accent-soft: rgba(107, 166, 255, 0.16);
--success: #49c28a;
--warning: #f0b85d;
--danger: #ff7f87;
--border-0: rgba(150, 180, 230, 0.24);
--border-1: rgba(150, 180, 230, 0.4);
--shadow-0: 0 22px 54px rgba(4, 8, 20, 0.45);
--shadow-1: 0 10px 30px rgba(8, 14, 32, 0.36);
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 20px;
--space-1: 8px;
--space-2: 12px;
--space-3: 16px;
--space-4: 20px;
--space-5: 24px;
--z-bg: 0;
--z-layout: 5;
--z-toast: 50;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text-0);
background:
radial-gradient(1200px 800px at -15% 130%, #1d2749 0%, transparent 60%),
radial-gradient(900px 700px at 110% -10%, #1f3b67 0%, transparent 62%),
linear-gradient(160deg, var(--bg-0) 0%, var(--bg-1) 52%, #131f38 100%);
position: relative;
overflow-x: hidden;
}
.bg-grid {
position: fixed;
inset: 0;
z-index: var(--z-bg);
background-image:
linear-gradient(rgba(116, 153, 211, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(116, 153, 211, 0.08) 1px, transparent 1px);
background-size: 36px 36px;
mask-image: radial-gradient(circle at 35% 15%, black 0%, transparent 78%);
pointer-events: none;
}
.bg-glow {
position: fixed;
border-radius: 999px;
pointer-events: none;
z-index: var(--z-bg);
}
.bg-glow-1 {
width: 420px;
height: 420px;
left: -120px;
top: 30%;
background: rgba(86, 135, 214, 0.18);
filter: blur(60px);
}
.bg-glow-2 {
width: 360px;
height: 360px;
right: -90px;
top: 80px;
background: rgba(115, 168, 255, 0.12);
filter: blur(58px);
}
.app-shell {
position: relative;
z-index: var(--z-layout);
min-height: 100vh;
display: grid;
grid-template-columns: 280px 1fr;
gap: var(--space-4);
padding: var(--space-4);
}
.sidebar {
background: linear-gradient(165deg, rgba(30, 47, 80, 0.86) 0%, rgba(17, 27, 48, 0.95) 100%);
border: 1px solid var(--border-0);
box-shadow: var(--shadow-0);
border-radius: var(--radius-lg);
padding: var(--space-4);
position: sticky;
top: var(--space-4);
height: calc(100vh - 40px);
display: flex;
flex-direction: column;
gap: var(--space-4);
backdrop-filter: blur(8px);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
}
.brand-dot {
width: 14px;
height: 14px;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent-0), #80dbff);
box-shadow: 0 0 0 6px rgba(107, 166, 255, 0.22);
flex-shrink: 0;
}
.brand h1 {
margin: 0;
font-size: 21px;
letter-spacing: 0.4px;
}
.brand p {
margin: 6px 0 0;
color: var(--text-2);
font-size: 13px;
}
.nav-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.nav-item {
border: 1px solid transparent;
background: transparent;
color: var(--text-1);
text-align: left;
font-size: 15px;
line-height: 1.45;
padding: 12px 14px;
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.nav-item:hover,
.nav-item:focus-visible {
color: var(--text-0);
border-color: var(--border-1);
background: rgba(139, 177, 240, 0.14);
outline: none;
}
.nav-item.active {
color: #031126;
background: linear-gradient(135deg, #8ab6ff 0%, #9ed4ff 100%);
border-color: #acd2ff;
font-weight: 700;
box-shadow: 0 10px 24px rgba(99, 159, 243, 0.34);
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-right: var(--space-2);
}
.main-header {
background: var(--surface-0);
border: 1px solid var(--border-0);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--shadow-1);
backdrop-filter: blur(7px);
}
.main-header h2 {
margin: 0;
font-size: 28px;
letter-spacing: 0.3px;
}
.main-header p {
margin: 8px 0 0;
color: var(--text-1);
}
.view {
display: none;
}
.view.active {
display: block;
}
.grid {
display: grid;
gap: var(--space-3);
}
.grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: var(--space-3);
}
.grid.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card {
background: var(--surface-1);
border: 1px solid var(--border-0);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-1);
padding: var(--space-4);
backdrop-filter: blur(8px);
}
.card + .card {
margin-top: var(--space-3);
}
.card h3 {
margin: 0 0 var(--space-3);
font-size: 18px;
letter-spacing: 0.2px;
}
.stat {
position: relative;
overflow: hidden;
}
.stat::after {
content: "";
position: absolute;
right: -32px;
bottom: -38px;
width: 112px;
height: 112px;
border-radius: 999px;
background: radial-gradient(circle at center, rgba(118, 173, 255, 0.36) 0%, rgba(118, 173, 255, 0) 70%);
pointer-events: none;
}
.stat p {
margin: 0;
font-size: 38px;
font-weight: 700;
color: var(--accent-1);
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.list li {
border-bottom: 1px solid rgba(157, 185, 229, 0.2);
padding: 12px 0;
font-size: 14px;
line-height: 1.6;
}
.list li:last-child {
border-bottom: none;
}
.list a {
color: #afd0ff;
text-decoration: none;
}
.list a:hover,
.list a:focus-visible {
color: #cde4ff;
text-decoration: underline;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
label {
display: block;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
color: var(--text-1);
}
input,
select,
button {
font: inherit;
}
input,
select {
width: 100%;
margin-top: 6px;
min-height: 44px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-0);
color: var(--text-0);
background: rgba(13, 21, 39, 0.64);
padding: 10px 12px;
}
input::placeholder {
color: rgba(173, 192, 228, 0.66);
}
input:focus-visible,
select:focus-visible,
button:focus-visible {
outline: 2px solid rgba(136, 189, 255, 0.92);
outline-offset: 2px;
border-color: rgba(136, 189, 255, 0.92);
}
select {
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, #aacfff 50%),
linear-gradient(135deg, #aacfff 50%, transparent 50%);
background-position:
calc(100% - 16px) 17px,
calc(100% - 11px) 17px;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
padding-right: 34px;
}
.span-2 {
grid-column: span 2;
}
.alert.info {
border-radius: var(--radius-md);
border: 1px solid rgba(130, 174, 255, 0.38);
background: rgba(96, 150, 235, 0.15);
color: #c5dcff;
padding: 12px 14px;
}
.month-panel {
border: 1px solid var(--border-0);
border-radius: var(--radius-md);
background: rgba(21, 34, 61, 0.6);
padding: var(--space-3);
}
.month-grid {
align-items: end;
}
.month-action {
display: flex;
}
.month-action button {
width: 100%;
}
.project-item {
border: 1px solid var(--border-0);
border-radius: var(--radius-md);
background: rgba(20, 31, 55, 0.62);
padding: var(--space-3);
}
.project-item h4 {
margin: 0 0 10px;
color: var(--text-0);
font-size: 15px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
min-height: 44px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-0);
background: rgba(88, 120, 170, 0.22);
color: var(--text-0);
padding: 0 16px;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
button:hover,
button:focus-visible {
background: rgba(122, 160, 221, 0.35);
border-color: rgba(157, 194, 253, 0.72);
}
button:active {
transform: translateY(1px);
}
button.primary {
background: linear-gradient(135deg, #6fafff 0%, #8fcbff 100%);
border-color: #9cd0ff;
color: #04162d;
font-weight: 700;
}
button.primary:hover,
button.primary:focus-visible {
background: linear-gradient(135deg, #85bcff 0%, #a1d7ff 100%);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
#log-panel {
display: none;
margin-top: var(--space-3);
}
.live-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.live-column header,
.system-log-wrap > header {
font-size: 13px;
font-weight: 700;
color: var(--text-1);
margin-bottom: 7px;
letter-spacing: 0.2px;
}
.live-output,
.system-output {
height: 250px;
overflow-y: auto;
border-radius: var(--radius-sm);
padding: 12px;
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 12.5px;
line-height: 1.62;
border: 1px solid var(--border-0);
background: rgba(6, 12, 25, 0.72);
}
.live-output {
color: #d6e5ff;
}
.live-column.reasoning .live-output {
background: rgba(9, 18, 39, 0.82);
}
.live-column.answer .live-output {
background: rgba(12, 24, 37, 0.78);
}
.system-output {
color: #e8f1ff;
background: rgba(6, 12, 24, 0.9);
border-color: rgba(150, 180, 230, 0.34);
}
.live-output .muted,
.system-output .muted {
color: #a9bde3;
}
.log-line {
margin: 2px 0;
}
.system-output .log-line.is-info {
color: #dbe8ff;
}
.system-output .log-line.is-error {
color: #ffb6bc;
}
.live-output .log-line.is-reasoning {
color: #ccdeff;
}
.live-output .log-line.is-answer {
color: #baf2da;
}
.system-log-wrap {
margin-top: var(--space-2);
}
.history-toolbar {
display: grid;
grid-template-columns: 180px 180px minmax(220px, 1fr) 120px;
gap: 10px;
margin-bottom: 12px;
}
.table-wrap {
overflow-x: auto;
border: 1px solid rgba(157, 185, 229, 0.2);
border-radius: var(--radius-md);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
background: rgba(8, 15, 30, 0.35);
}
th,
td {
padding: 10px 8px;
border-bottom: 1px solid rgba(157, 185, 229, 0.16);
text-align: left;
font-size: 14px;
vertical-align: top;
}
th {
color: #d5e4ff;
background: rgba(129, 167, 229, 0.12);
position: sticky;
top: 0;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
color: var(--text-2);
font-size: 14px;
}
.pager .pager-actions {
display: flex;
gap: 8px;
}
.btn-cancel-task {
min-height: 32px;
padding: 0 10px;
font-size: 13px;
}
.tag {
display: inline-block;
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.tag.SUCCESS {
background: rgba(73, 194, 138, 0.16);
border-color: rgba(73, 194, 138, 0.5);
color: #95edc2;
}
.tag.RUNNING,
.tag.PENDING {
background: rgba(240, 184, 93, 0.16);
border-color: rgba(240, 184, 93, 0.45);
color: #ffd99d;
}
.tag.FAILED {
background: rgba(255, 127, 135, 0.16);
border-color: rgba(255, 127, 135, 0.5);
color: #ffb5bc;
}
.tag.CANCELLED {
background: rgba(148, 166, 196, 0.16);
border-color: rgba(148, 166, 196, 0.45);
color: #ced9f1;
}
.muted {
color: var(--text-2);
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: var(--z-toast);
border-radius: var(--radius-md);
padding: 12px 14px;
background: rgba(8, 16, 33, 0.96);
border: 1px solid rgba(132, 171, 235, 0.46);
color: #eef4ff;
min-width: 240px;
max-width: 400px;
display: none;
box-shadow: var(--shadow-0);
}
.toast.show {
display: block;
}
@media (max-width: 1180px) {
.grid.cols-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
gap: var(--space-3);
padding: var(--space-3);
}
.sidebar {
position: sticky;
top: 0;
height: auto;
padding: 14px;
gap: 12px;
border-radius: var(--radius-md);
}
.brand p {
display: none;
}
.nav-list {
flex-direction: row;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
}
.nav-item {
flex: 0 0 auto;
white-space: nowrap;
min-height: 44px;
padding: 10px 13px;
}
.main {
padding-right: 0;
}
.grid.cols-2,
.grid.cols-3,
.form-grid,
.history-toolbar {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: span 1;
}
.month-action {
grid-column: span 1;
}
}
@media (max-width: 760px) {
.grid.cols-4 {
grid-template-columns: 1fr;
}
.main-header h2 {
font-size: 24px;
}
.card {
padding: var(--space-3);
}
.live-grid {
grid-template-columns: 1fr;
}
.toast {
right: 12px;
left: 12px;
bottom: 12px;
min-width: 0;
max-width: none;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
@@ -0,0 +1,110 @@
package com.svnlog.web.controller;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.svnlog.web.service.AiWorkflowService;
import com.svnlog.web.service.HealthService;
import com.svnlog.web.service.OutputFileService;
import com.svnlog.web.service.SettingsService;
import com.svnlog.web.service.SvnPresetService;
import com.svnlog.web.service.SvnWorkflowService;
import com.svnlog.web.service.TaskService;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AppControllerDownloadTest {
@TempDir
Path tempDir;
@Test
void shouldDownloadMarkdownWithAttachmentHeaders() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
final Path file = outputFileService.resolveInOutput("nested/报告 2026.md");
Files.createDirectories(file.getParent());
Files.write(file, "# hello".getBytes(StandardCharsets.UTF_8));
buildMockMvc(outputFileService)
.perform(get("/api/files/download").param("path", "nested/报告 2026.md"))
.andExpect(status().isOk())
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().string("Content-Disposition", Matchers.containsString("attachment;")))
.andExpect(header().string("Content-Disposition", Matchers.containsString("filename*=UTF-8''")))
.andExpect(content().contentType("text/markdown;charset=UTF-8"))
.andExpect(content().string("# hello"));
}
@Test
void shouldDownloadExcelWithOfficeContentType() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
final byte[] payload = new byte[] { 0x01, 0x02, 0x03, 0x04 };
Files.write(outputFileService.resolveInOutput("report.xlsx"), payload);
final byte[] response = buildMockMvc(outputFileService)
.perform(get("/api/files/download").param("path", "report.xlsx"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.andReturn()
.getResponse()
.getContentAsByteArray();
Assertions.assertArrayEquals(payload, response);
}
@Test
void shouldReturnStructuredErrorWhenFileMissing() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
buildMockMvc(outputFileService)
.perform(get("/api/files/download").param("path", "missing.md"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.error").value("文件不存在: missing.md"));
}
@Test
void shouldRejectPathTraversal() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
buildMockMvc(outputFileService)
.perform(get("/api/files/download").param("path", "../secret.txt"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.error", Matchers.containsString("非法文件路径")));
}
private OutputFileService buildOutputFileService() {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
return outputFileService;
}
private MockMvc buildMockMvc(OutputFileService outputFileService) {
final AppController controller = new AppController(
Mockito.mock(SvnWorkflowService.class),
Mockito.mock(AiWorkflowService.class),
Mockito.mock(TaskService.class),
outputFileService,
Mockito.mock(SettingsService.class),
Mockito.mock(SvnPresetService.class),
Mockito.mock(HealthService.class)
);
return MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
}
@@ -21,7 +21,11 @@ class AiWorkflowServiceTest {
void shouldResolveDeepSeekProviderByDefault() {
final AiWorkflowService service = new AiWorkflowService(
buildOutputFileService(),
new SettingsService(buildOutputFileService(), new SvnPresetService()),
new SettingsService(
buildOutputFileService(),
new SettingsPersistenceService(),
new SvnPresetService()
),
new AiInputValidator()
);
@@ -35,7 +39,11 @@ class AiWorkflowServiceTest {
@Test
void shouldResolveOpenAiCompatibleModelsAndUrl() {
final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService()
);
settingsService.updateSettings(
null,
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
@@ -44,6 +52,8 @@ class AiWorkflowServiceTest {
"deepseek-v4-flash",
"deepseek-v4-pro",
null,
null,
null,
null
);
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
@@ -75,7 +85,11 @@ class AiWorkflowServiceTest {
@Test
void shouldParseCompatibleStreamWhenOnlyContentIsReturned() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService()
);
settingsService.updateSettings(
null,
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
@@ -84,6 +98,8 @@ class AiWorkflowServiceTest {
"deepseek-v4-flash",
"deepseek-v4-pro",
null,
null,
null,
null
);
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
@@ -127,7 +143,7 @@ class AiWorkflowServiceTest {
private final String openaiApiKey;
private StubSettingsService(OutputFileService outputFileService, String openaiBaseUrl, String openaiApiKey) {
super(outputFileService, new SvnPresetService());
super(outputFileService, new SettingsPersistenceService(), new SvnPresetService());
this.openaiBaseUrl = openaiBaseUrl;
this.openaiApiKey = openaiApiKey;
}
@@ -26,7 +26,11 @@ class HealthServiceTest {
void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
final SettingsService settingsService = new SettingsService(
outputFileService,
new SettingsPersistenceService(),
new SvnPresetService()
);
taskService = new TaskService(new TaskPersistenceService(), outputFileService);
final HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
@@ -1,59 +1,210 @@
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.nio.file.attribute.PosixFilePermission;
import java.util.EnumSet;
import java.util.Map;
import java.nio.file.Paths;
import java.util.Set;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SettingsServiceTest {
@TempDir
Path tempDir;
@Test
void shouldReturnDefaultProviderAndOpenAiDefaults() throws Exception {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
private String originalUserDir;
final Map<String, Object> settings = settingsService.getSettings();
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, settings.get("provider"));
Assertions.assertEquals("http://127.0.0.1:5001/v1", settings.get("openaiBaseUrl"));
Assertions.assertEquals("sk-f8b3f43e1fdd4f50b287050f08a6c7ed", settings.get("openaiApiKey"));
Assertions.assertEquals("deepseek-v4-flash", settings.get("openaiStageOneModel"));
Assertions.assertEquals("deepseek-v4-pro", settings.get("openaiStageTwoModel"));
@AfterEach
void restoreUserDir() {
if (originalUserDir != null) {
System.setProperty("user.dir", originalUserDir);
}
}
@Test
void shouldUpdateProviderAndOpenAiSettings() throws Exception {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
final String newOutputDir = tempDir.resolve("custom-output").toString();
void shouldPersistAndReloadSettingsFromOutputDirectory() throws IOException {
useTempWorkingDirectory();
final Path customOutputDir = tempDir.resolve("custom-output");
final SettingsService settingsService = newSettingsService();
settingsService.updateSettings(
"sk-deepseek-runtime",
"deepseek-key",
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
"http://localhost:5001/v1/",
"sk-openai-runtime",
"deepseek-v4-flash",
"deepseek-v4-pro",
newOutputDir,
"http://localhost:9000/v1",
"openai-key",
"model-stage-1",
"model-stage-2",
"svn-user",
"svn-pass",
customOutputDir.toString(),
"preset-2"
);
final Map<String, Object> settings = settingsService.getSettings();
assertTrue(Files.exists(customOutputDir.resolve("settings.json")));
assertTrue(Files.exists(tempDir.resolve("outputs").resolve("settings.json")));
Assertions.assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, settings.get("provider"));
Assertions.assertEquals("http://localhost:5001/v1/", settings.get("openaiBaseUrl"));
Assertions.assertEquals("sk-openai-runtime", settings.get("openaiApiKey"));
Assertions.assertEquals("deepseek-v4-flash", settings.get("openaiStageOneModel"));
Assertions.assertEquals("deepseek-v4-pro", settings.get("openaiStageTwoModel"));
Assertions.assertEquals("preset-2", settings.get("defaultSvnPresetId"));
Assertions.assertEquals(Paths.get(newOutputDir).toAbsolutePath().normalize().toString(), settings.get("outputDir"));
final SettingsService reloadedService = newSettingsService();
final Map<String, Object> settings = reloadedService.getSettings();
assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, settings.get("provider"));
assertEquals("http://localhost:9000/v1", settings.get("openaiBaseUrl"));
assertEquals("model-stage-1", settings.get("openaiStageOneModel"));
assertEquals("model-stage-2", settings.get("openaiStageTwoModel"));
assertEquals("svn-user", settings.get("svnUsername"));
assertEquals(customOutputDir.toAbsolutePath().normalize().toString(), settings.get("outputDir"));
assertEquals("preset-2", settings.get("defaultSvnPresetId"));
assertEquals("openai-key", reloadedService.getOpenaiApiKey());
assertEquals("deepseek-key", reloadedService.pickActiveKey(null));
assertEquals("svn-pass", reloadedService.resolveSvnCredentials(null, null).getPassword());
}
@Test
void shouldCreateMissingOutputDirectoryWhenSavingSettings() throws IOException {
useTempWorkingDirectory();
final Path missingOutputDir = tempDir.resolve("missing-output");
final SettingsService settingsService = newSettingsService();
settingsService.updateSettings(
null,
SettingsService.PROVIDER_DEEPSEEK,
null,
null,
null,
null,
null,
null,
missingOutputDir.toString(),
"preset-1"
);
assertTrue(Files.isDirectory(missingOutputDir));
assertTrue(Files.exists(missingOutputDir.resolve("settings.json")));
}
@Test
void shouldFailWhenOutputPathIsAFile() throws IOException {
useTempWorkingDirectory();
final Path occupiedPath = tempDir.resolve("occupied-output");
Files.write(occupiedPath, "not-a-directory".getBytes(StandardCharsets.UTF_8));
final SettingsService settingsService = newSettingsService();
final IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> settingsService.updateSettings(
null,
SettingsService.PROVIDER_DEEPSEEK,
null,
null,
null,
null,
null,
null,
occupiedPath.toString(),
"preset-1"
)
);
assertTrue(exception.getMessage().contains("输出路径不是目录"));
assertEquals(tempDir.resolve("outputs").toAbsolutePath().normalize().toString(),
settingsService.getSettings().get("outputDir"));
}
@Test
void shouldFailWhenOutputDirectoryIsNotWritable() throws IOException {
useTempWorkingDirectory();
final Path readOnlyDir = tempDir.resolve("read-only-output");
Files.createDirectories(readOnlyDir);
final Set<PosixFilePermission> originalPermissions = Files.getPosixFilePermissions(readOnlyDir);
Files.setPosixFilePermissions(readOnlyDir, EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_EXECUTE
));
Assumptions.assumeFalse(Files.isWritable(readOnlyDir), "Test requires a non-writable directory");
final SettingsService settingsService = newSettingsService();
try {
final IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> settingsService.updateSettings(
null,
SettingsService.PROVIDER_DEEPSEEK,
null,
null,
null,
null,
null,
null,
readOnlyDir.toString(),
"preset-1"
)
);
assertTrue(exception.getMessage().contains("输出目录不可写"));
assertEquals(tempDir.resolve("outputs").toAbsolutePath().normalize().toString(),
settingsService.getSettings().get("outputDir"));
} finally {
Files.setPosixFilePermissions(readOnlyDir, originalPermissions);
}
}
@Test
void shouldReturnSanitizedSettingsWhileKeepingRecoveredSecretsInRuntime() throws IOException {
useTempWorkingDirectory();
final SettingsService settingsService = newSettingsService();
settingsService.updateSettings(
"deepseek-key",
SettingsService.PROVIDER_DEEPSEEK,
"http://localhost:5001/v1",
"openai-key",
"stage-1",
"stage-2",
"svn-user",
"svn-pass",
tempDir.resolve("runtime-output").toString(),
"preset-1"
);
final SettingsService reloadedService = newSettingsService();
final Map<String, Object> settings = reloadedService.getSettings();
assertFalse(settings.containsKey("apiKey"));
assertFalse(settings.containsKey("openaiApiKey"));
assertFalse(settings.containsKey("svnPassword"));
assertEquals(Boolean.TRUE, settings.get("apiKeyConfigured"));
assertEquals(Boolean.TRUE, settings.get("openaiApiKeyConfigured"));
assertEquals(Boolean.TRUE, settings.get("svnCredentialsConfigured"));
assertEquals("deepseek-key", reloadedService.pickActiveKey(null));
assertEquals("openai-key", reloadedService.getOpenaiApiKey());
assertEquals("svn-pass", reloadedService.resolveSvnCredentials(null, null).getPassword());
assertNotNull(settings.get("apiKeySource"));
}
private SettingsService newSettingsService() {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsPersistenceService settingsPersistenceService = new SettingsPersistenceService();
final SvnPresetService svnPresetService = new SvnPresetService();
return new SettingsService(outputFileService, settingsPersistenceService, svnPresetService);
}
private void useTempWorkingDirectory() {
originalUserDir = System.getProperty("user.dir");
System.setProperty("user.dir", tempDir.toAbsolutePath().normalize().toString());
}
}