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());