refactor: optimize security baseline, task logging, frontend maven packaging, and redirect root page to v2

This commit is contained in:
liumangmang
2026-06-10 16:06:10 +08:00
parent 1b182c2930
commit 42214b33e3
10 changed files with 307 additions and 20 deletions
+3 -1
View File
@@ -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)
+6 -1
View File
@@ -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` 流式接口
- 接口调用可能产生费用,建议控制调用频率
+100
View File
@@ -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-nodev20 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
```
+57
View File
@@ -18,6 +18,11 @@
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<spring-boot.version>2.7.18</spring-boot.version>
<!-- 本地跳过前端构建:mvn clean package -DskipTests -Dskip.frontend.build=true -->
<skip.frontend.build>false</skip.frontend.build>
<!-- Node.js 下载源地址,默认官方。因网络下载失败时,可配置镜像源,如:-->
<!-- -Dnode.download.root=https://npmmirror.com/mirrors/node/ -->
<node.download.root>https://nodejs.org/dist/</node.download.root>
</properties>
<dependencies>
@@ -77,6 +82,58 @@
<build>
<plugins>
<!--
frontend-maven-plugin: 绑定到 prepare-package 阶段。
- mvn compile / mvn test:不触发前端构建。
- mvn package(含 make up Docker 构建的 Java 侧):
Docker 后端阶段已用 -Dskip.frontend.build=true
本地全量打包时自动执行 install-node + npm ci + npm run build。
- 跳过:mvn clean package -DskipTests -Dskip.frontend.build=true
详见 docs/frontend-build.md。
-->
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.15.0</version>
<configuration>
<workingDirectory>frontend-vue</workingDirectory>
<skip>${skip.frontend.build}</skip>
<nodeDownloadRoot>${node.download.root}</nodeDownloadRoot>
</configuration>
<executions>
<execution>
<id>install-node-and-npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<phase>prepare-package</phase>
<configuration>
<!-- Node 20 LTS,与 Vite 5.x 兼容 -->
<nodeVersion>v20.11.0</nodeVersion>
</configuration>
</execution>
<execution>
<id>npm-install</id>
<goals>
<goal>npm</goal>
</goals>
<phase>prepare-package</phase>
<configuration>
<arguments>ci</arguments>
</configuration>
</execution>
<execution>
<id>npm-build</id>
<goals>
<goal>npm</goal>
</goals>
<phase>prepare-package</phase>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
@@ -13,8 +13,8 @@ import org.springframework.web.bind.annotation.GetMapping;
public class IndexController {
@GetMapping(value = {"/", "/index.html"})
public ResponseEntity<Resource> index() {
return htmlResponse("static/index.html");
public String index() {
return "redirect:/v2/";
}
@GetMapping("/v2")
@@ -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;
@@ -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() {
@@ -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());
}
}
@@ -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");
}
}
@@ -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());