fix: harden file download flow
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.git/
|
||||
.idea/
|
||||
outputs/
|
||||
+8
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,23 +94,37 @@ 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,
|
||||
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();
|
||||
}
|
||||
@@ -82,12 +141,74 @@ public class SettingsService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
public String pickActiveKey(String requestKey) {
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user