diff --git a/Dockerfile b/Dockerfile
index 6d4809b..d809107 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -44,9 +44,11 @@ COPY src ./src
# vite.config.js 中 outDir 为相对 __dirname 的路径,容器内 __dirname=/frontend
COPY --from=frontend-builder /src/main/resources/static/v2 /app/src/main/resources/static/v2
+# 前端产物已由 frontend-builder 阶段构建并 COPY 进来;
+# 此阶段不含 frontend-vue/,且离线模式无法下载 Node,必须跳过前端构建。
# -T 1C: 按 CPU 核数并行; -o: 离线模式(依赖已缓存,跳过元数据检查)
RUN --mount=type=cache,target=/root/.m2 \
- mvn -B -DskipTests -T 1C -o clean package
+ mvn -B -DskipTests -T 1C -o clean package -Dskip.frontend.build=true
# ============================================================
# Stage 3: 运行镜像(最小化 JRE)
diff --git a/docs/README_DeepSeek.md b/docs/README_DeepSeek.md
index bc7ffac..bb7158b 100644
--- a/docs/README_DeepSeek.md
+++ b/docs/README_DeepSeek.md
@@ -30,13 +30,18 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
## DeepSeek API Key 读取优先级
-1. 请求中的 `apiKey`
+1. 请求中的 `apiKey`(临时传入,优先级最高)
2. 设置页保存的运行时 `apiKey`
3. 环境变量 `DEEPSEEK_API_KEY`
+> **首次启动未配置 API Key 是正常状态。**
+> `/api/settings` 会返回 `apiKeyConfigured: false`,
+> 请前往「系统设置」页面填写 API Key 后保存,或通过环境变量注入。
+
## 注意事项
- 不要在源码和日志中写入真实密钥
+- 使用环境变量注入密钥是更安全的做法(尤其在 Docker/CI 环境中)
- DeepSeek 模式需要可访问 DeepSeek API 的网络环境
- OpenAI 兼容模式要求兼容服务提供 `/chat/completions` 流式接口
- 接口调用可能产生费用,建议控制调用频率
diff --git a/docs/frontend-build.md b/docs/frontend-build.md
new file mode 100644
index 0000000..e22e734
--- /dev/null
+++ b/docs/frontend-build.md
@@ -0,0 +1,100 @@
+# 前端构建说明
+
+## 目录职责
+
+| 目录 | 说明 |
+|------|------|
+| `frontend-vue/` | 前端源码(Vue 3 + Vite) |
+| `src/main/resources/static/v2/` | **前端构建产物**,由 Vite 生成,不要手工修改 |
+
+> ⚠️ `static/v2/` 是自动生成目录,请勿直接手工编辑其中的文件,
+> 应始终通过构建前端源码来更新。
+
+---
+
+## 本地开发
+
+启动前端开发服务器(热重载,代理到后端 :8080):
+
+```bash
+cd frontend-vue
+npm install # 首次或依赖变更时
+npm run dev # 启动 Vite dev server,访问 http://localhost:5173/v2/
+```
+
+---
+
+## Maven 打包(含前端构建)
+
+项目使用 `frontend-maven-plugin` 绑定在 **`prepare-package`** 阶段,
+只有 `mvn package`(及后续阶段)才会触发前端构建:
+
+| 命令 | 前端构建 |
+|------|---------|
+| `mvn compile` | ❌ 不触发 |
+| `mvn test` | ❌ 不触发 |
+| `mvn package` | ✅ 自动执行 `install-node(v20 LTS) → npm ci → npm run build` |
+```bash
+# 完整打包(含前端构建,首次默认自动下载 Node)
+mvn clean package -DskipTests
+
+# 如果 nodejs.org 下载极慢或报错(如 Premature end of Content-Length),可指定国内镜像源:
+mvn clean package -DskipTests -Dnode.download.root=https://npmmirror.com/mirrors/node/
+
+# 跳过前端构建(前提:static/v2/ 中已存有最新的构建产物)
+mvn clean package -DskipTests -Dskip.frontend.build=true
+```
+
+### 离线与缓存方案 (CI / 弱网环境)
+
+由于默认全量打包依赖外网 Node.js 下载,当本地/CI环境无法直接访问外网或网络极其不稳定时,建议采用以下优化方案:
+
+1. **手动预缓存 Node.js 包(最稳妥的离线打包方案)**
+ 您可以直接手动下载对应平台的 Node 压缩包,并放置到本地 Maven 缓存目录中。以 Linux x64 平台为例:
+ - 下载对应版本的 Node 包:[node-v20.11.0-linux-x64.tar.gz](https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-x64.tar.gz)
+ - 保存到本地 Maven 仓库缓存路径下:
+ `~/.m2/repository/com/github/eirslett/node/20.11.0/node-20.11.0-linux-x64.tar.gz`
+ - 再次运行 `mvn package` 时,插件检测到该本地缓存文件存在,将**直接读取该文件进行解压**,不会发起任何外网下载请求。
+
+2. **切换镜像源**
+ 如果网络通畅但仅访问官方 nodejs.org 受限,可以使用 `-Dnode.download.root` 参数切换至其他镜像源:
+ - 阿里云镜像:`-Dnode.download.root=https://npmmirror.com/mirrors/node/`
+ - 华为云镜像:`-Dnode.download.root=https://mirrors.huaweicloud.com/nodejs/`
+
+3. **跳过前端构建**
+ 如果仅变更后端 Java 代码,不需要重新编译前端,可直接通过 `-Dskip.frontend.build=true` 跳过前端:
+ `mvn clean package -DskipTests -Dskip.frontend.build=true`
+
+---
+
+## Docker 构建(推荐生产方式)
+
+```bash
+make up
+```
+
+Dockerfile 使用多阶段构建:
+1. Stage 1(`frontend-builder`):`node:22-alpine` 执行 `npm ci && npm run build`
+2. Stage 2(`builder`):Maven 将前端产物 COPY 进来并打包 jar
+3. Stage 3(`runner`):最小 JRE 运行
+
+---
+
+## 首次配置 API Key 说明
+
+服务启动后,如果未配置 API Key,`/api/settings` 返回:
+
+```json
+{
+ "apiKeyConfigured": false,
+ "apiKeySource": "none"
+}
+```
+
+这是**正常状态**,请前往设置页配置 DeepSeek API Key 或 OpenAI 兼容 API Key。
+
+也可通过环境变量配置 DeepSeek API Key:
+
+```bash
+export DEEPSEEK_API_KEY=sk-xxxxxxxx
+```
diff --git a/pom.xml b/pom.xml
index 517a540..1eabc67 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,6 +18,11 @@
1.8
1.8
2.7.18
+
+ false
+
+
+ https://nodejs.org/dist/
@@ -77,6 +82,58 @@
+
+
+ com.github.eirslett
+ frontend-maven-plugin
+ 1.15.0
+
+ frontend-vue
+ ${skip.frontend.build}
+ ${node.download.root}
+
+
+
+ install-node-and-npm
+
+ install-node-and-npm
+
+ prepare-package
+
+
+ v20.11.0
+
+
+
+ npm-install
+
+ npm
+
+ prepare-package
+
+ ci
+
+
+
+ npm-build
+
+ npm
+
+ prepare-package
+
+ run build
+
+
+
+
org.apache.maven.plugins
maven-compiler-plugin
diff --git a/src/main/java/com/svnlog/web/controller/IndexController.java b/src/main/java/com/svnlog/web/controller/IndexController.java
index 8fc1cba..9c620cc 100644
--- a/src/main/java/com/svnlog/web/controller/IndexController.java
+++ b/src/main/java/com/svnlog/web/controller/IndexController.java
@@ -13,8 +13,8 @@ import org.springframework.web.bind.annotation.GetMapping;
public class IndexController {
@GetMapping(value = {"/", "/index.html"})
- public ResponseEntity index() {
- return htmlResponse("static/index.html");
+ public String index() {
+ return "redirect:/v2/";
}
@GetMapping("/v2")
diff --git a/src/main/java/com/svnlog/web/service/AiApiService.java b/src/main/java/com/svnlog/web/service/AiApiService.java
index 0dd52ec..e0c2af8 100644
--- a/src/main/java/com/svnlog/web/service/AiApiService.java
+++ b/src/main/java/com/svnlog/web/service/AiApiService.java
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,6 +32,18 @@ public class AiApiService {
private static final int DEEPSEEK_REASONER_MAX_TOKENS_RETRY = 64000;
private static final int STREAM_PERSIST_INTERVAL = 8;
+ // 脱敏正则:JSON 中常见敏感字段(api_key / token / password / secret 等)
+ private static final Pattern SENSITIVE_JSON_FIELD_PATTERN = Pattern.compile(
+ "(\"(?:api[_\\-]?key|apikey|token|authorization|password|secret"
+ + "|access[_\\-]?key|auth[_\\-]?token)\"\\s*:\\s*\")([^\"]*)(\")",
+ Pattern.CASE_INSENSITIVE
+ );
+ // 脱敏正则:HTTP Authorization 头(含 Bearer Token)— 匹配到实际凭证值
+ private static final Pattern SENSITIVE_BEARER_PATTERN = Pattern.compile(
+ "((?:Authorization[:\\s]+)?Bearer[:\\s]+)(\\S+)",
+ Pattern.CASE_INSENSITIVE
+ );
+
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
@@ -133,7 +146,9 @@ public class AiApiService {
if (response.body() != null) {
errorBody = response.body().string();
}
- String detail = providerContext.displayName + " API 调用失败: " + response.code() + " " + errorBody;
+ // 先脱敏、再截断:不将完整错误体透传到日志/任务错误
+ String detail = providerContext.displayName + " API 调用失败: "
+ + response.code() + " " + truncateErrorBody(errorBody);
if (response.code() == 429 || response.code() >= 500) {
throw new RetrySupport.RetryableException(detail);
}
@@ -367,6 +382,32 @@ public class AiApiService {
return object.get(key).getAsString();
}
+ /**
+ * 脱敏并截断错误体:先替换敏感字段的值为 ***,再截断到 500 字符以内。
+ * package-private 以支持单测。
+ */
+ String truncateErrorBody(String errorBody) {
+ if (errorBody == null || errorBody.trim().isEmpty()) {
+ return "(empty)";
+ }
+ // 先脱敏,再截断
+ final String redacted = redactSensitiveFields(errorBody.trim());
+ final int maxLen = 500;
+ if (redacted.length() <= maxLen) {
+ return redacted;
+ }
+ return redacted.substring(0, maxLen) + "... [已截断, 长度=" + redacted.length() + "]";
+ }
+
+ /**
+ * 替换常见敏感字段的值为 ***。
+ * 覆盖:JSON 敏感字段(api_key、token、password 等)和 HTTP Authorization/Bearer Token。
+ */
+ private String redactSensitiveFields(String text) {
+ final String step1 = SENSITIVE_JSON_FIELD_PATTERN.matcher(text).replaceAll("$1***$3");
+ return SENSITIVE_BEARER_PATTERN.matcher(step1).replaceAll("$1***");
+ }
+
static final class AiProviderContext {
private final String provider;
private final String displayName;
diff --git a/src/main/java/com/svnlog/web/service/SettingsService.java b/src/main/java/com/svnlog/web/service/SettingsService.java
index aab302d..c2c7ffb 100644
--- a/src/main/java/com/svnlog/web/service/SettingsService.java
+++ b/src/main/java/com/svnlog/web/service/SettingsService.java
@@ -21,10 +21,8 @@ public class SettingsService {
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";
+ // 默认值:API Key 不再内置,必须通过环境变量或设置页配置
private static final String DEFAULT_OPENAI_BASE_URL = "http://127.0.0.1:5001/v1";
- 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";
@@ -80,7 +78,7 @@ public class SettingsService {
this.runtimeApiKey = initStartupApiKey();
this.runtimeProvider = PROVIDER_DEEPSEEK;
this.runtimeOpenaiBaseUrl = DEFAULT_OPENAI_BASE_URL;
- this.runtimeOpenaiApiKey = DEFAULT_OPENAI_API_KEY;
+ this.runtimeOpenaiApiKey = null;
this.runtimeOpenaiStageOneModel = DEFAULT_OPENAI_STAGE_ONE_MODEL;
this.runtimeOpenaiStageTwoModel = DEFAULT_OPENAI_STAGE_TWO_MODEL;
this.runtimeSvnUsername = trim(System.getenv(ENV_SVN_USERNAME));
@@ -230,14 +228,12 @@ public class SettingsService {
}
private String initStartupApiKey() {
+ // 仅从环境变量读取,不内置默认值
final String envKey = System.getenv("DEEPSEEK_API_KEY");
if (envKey != null && !envKey.trim().isEmpty()) {
+ LOGGER.info("DeepSeek API Key loaded from environment variable DEEPSEEK_API_KEY");
return envKey.trim();
}
- if (BOOTSTRAP_API_KEY != null && !BOOTSTRAP_API_KEY.trim().isEmpty()
- && !BOOTSTRAP_API_KEY.startsWith("REPLACE_WITH_")) {
- return BOOTSTRAP_API_KEY.trim();
- }
return null;
}
@@ -363,7 +359,7 @@ public class SettingsService {
}
public String getOpenaiApiKey() {
- return trimOrDefault(runtimeOpenaiApiKey, DEFAULT_OPENAI_API_KEY);
+ return trim(runtimeOpenaiApiKey);
}
public String getSvnUsername() {
diff --git a/src/main/java/com/svnlog/web/service/TaskService.java b/src/main/java/com/svnlog/web/service/TaskService.java
index 274d718..f729f20 100644
--- a/src/main/java/com/svnlog/web/service/TaskService.java
+++ b/src/main/java/com/svnlog/web/service/TaskService.java
@@ -243,16 +243,18 @@ public class TaskService {
if (!loaded.isEmpty()) {
persistSafely();
}
- } catch (Exception ignored) {
- // ignore persistence loading failures to keep service available
+ } catch (Exception e) {
+ LOGGER.warn("Failed to load task history; service continues without history. Reason: {}",
+ e.getMessage(), e);
}
}
private synchronized void persistSafely() {
try {
persistenceService.save(buildStorePath(), tasks.values());
- } catch (Exception ignored) {
- // ignore persistence saving failures to avoid interrupting running tasks
+ } catch (Exception e) {
+ LOGGER.warn("Failed to persist task history; in-memory state intact. Reason: {}",
+ e.getMessage());
}
}
diff --git a/src/test/java/com/svnlog/web/service/AiApiServiceTest.java b/src/test/java/com/svnlog/web/service/AiApiServiceTest.java
new file mode 100644
index 0000000..a79e158
--- /dev/null
+++ b/src/test/java/com/svnlog/web/service/AiApiServiceTest.java
@@ -0,0 +1,83 @@
+package com.svnlog.web.service;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * AiApiService 中 truncateErrorBody / redactSensitiveFields 的单元测试。
+ * truncateErrorBody 为 package-private,可直接测试。
+ */
+class AiApiServiceTest {
+
+ // settingsService 未用到 truncateErrorBody,传 null 安全
+ private final AiApiService service = new AiApiService(null);
+
+ @Test
+ void shouldReturnEmptyPlaceholderWhenBodyIsNull() {
+ Assertions.assertEquals("(empty)", service.truncateErrorBody(null));
+ }
+
+ @Test
+ void shouldReturnEmptyPlaceholderWhenBodyIsBlank() {
+ Assertions.assertEquals("(empty)", service.truncateErrorBody(" "));
+ }
+
+ @Test
+ void shouldReturnShortBodyWithoutSensitiveDataAsIs() {
+ final String body = "{\"error\": \"model not found\", \"code\": 404}";
+ Assertions.assertEquals(body, service.truncateErrorBody(body));
+ }
+
+ @Test
+ void shouldTruncateLongBodyAndAppendMarker() {
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 600; i++) {
+ sb.append('x');
+ }
+ final String result = service.truncateErrorBody(sb.toString());
+ Assertions.assertTrue(result.contains("[已截断"), "should contain truncation marker");
+ Assertions.assertTrue(result.length() < 600, "result should be shorter than original");
+ }
+
+ @Test
+ void shouldRedactApiKeyFieldInJsonBody() {
+ final String body = "{\"error\": \"invalid\", \"api_key\": \"sk-secret-123456\"}";
+ final String result = service.truncateErrorBody(body);
+ Assertions.assertFalse(result.contains("sk-secret-123456"), "api_key value must be redacted");
+ Assertions.assertTrue(result.contains("***"), "redaction marker must be present");
+ Assertions.assertTrue(result.contains("api_key"), "field name must be preserved");
+ }
+
+ @Test
+ void shouldRedactTokenFieldInJsonBody() {
+ final String body = "{\"token\": \"eyJhbGciOiJIUzI1NiJ9.payload\", \"status\": 401}";
+ final String result = service.truncateErrorBody(body);
+ Assertions.assertFalse(result.contains("eyJhbGciOiJIUzI1NiJ9.payload"), "token value must be redacted");
+ Assertions.assertTrue(result.contains("***"));
+ }
+
+ @Test
+ void shouldRedactPasswordFieldInJsonBody() {
+ final String body = "{\"password\": \"my-secret-pw\", \"code\": 401}";
+ final String result = service.truncateErrorBody(body);
+ Assertions.assertFalse(result.contains("my-secret-pw"), "password value must be redacted");
+ Assertions.assertTrue(result.contains("***"));
+ }
+
+ @Test
+ void shouldRedactAuthorizationBearerToken() {
+ final String body = "Authorization: Bearer sk-real-token-abc\nError: unauthorized";
+ final String result = service.truncateErrorBody(body);
+ Assertions.assertFalse(result.contains("sk-real-token-abc"), "bearer token must be redacted");
+ Assertions.assertTrue(result.contains("***"), "redaction marker must be present");
+ Assertions.assertTrue(result.contains("Authorization"), "header name must be preserved");
+ }
+
+ @Test
+ void shouldPreserveNonSensitiveContentAfterRedaction() {
+ final String body = "{\"error\": \"rate limit exceeded\", \"code\": 429}";
+ final String result = service.truncateErrorBody(body);
+ Assertions.assertTrue(result.contains("rate limit exceeded"), "non-sensitive content must be preserved");
+ Assertions.assertTrue(result.contains("429"), "non-sensitive content must be preserved");
+ }
+}
diff --git a/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java b/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java
index 0a40bba..4d894b4 100644
--- a/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java
+++ b/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java
@@ -30,14 +30,15 @@ class AiWorkflowServiceTest {
final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = buildSettingsService(outputFileService);
final AiApiService aiApiService = buildAiApiService(settingsService);
- final AiWorkflowService service = new AiWorkflowService(
+ new AiWorkflowService(
outputFileService,
new AiInputValidator(),
aiApiService,
buildExcelExportService()
);
- final AiApiService.AiProviderContext context = aiApiService.resolveProviderContext(null);
+ // DeepSeek provider:通过请求临时传入 key 来解析 context(不依赖内置默认 key)
+ final AiApiService.AiProviderContext context = aiApiService.resolveProviderContext("sk-test-key");
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, context.getProvider());
Assertions.assertEquals("deepseek-chat", context.getStageOneModel());