From 6568bb2720431df8b85b29043bff59409c7e7ca8 Mon Sep 17 00:00:00 2001
From: KDB <937925477@qq.com>
Date: Tue, 11 Aug 2026 01:11:10 +0800
Subject: [PATCH] =?UTF-8?q?feat(config):=20=E7=94=9F=E4=BA=A7=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=E5=87=AD=E6=8D=AE=20fail-fast=20=E5=AE=88=E5=8D=AB?=
=?UTF-8?q?=EF=BC=884.5=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 16 +++
.../config/ProductionCredentialGuard.java | 92 ++++++++++++++
.../main/resources/META-INF/spring.factories | 3 +-
.../config/ProductionCredentialGuardTest.java | 113 ++++++++++++++++++
docs/production-configuration.md | 64 ++++++++++
5 files changed, 287 insertions(+), 1 deletion(-)
create mode 100644 bootstrap/src/main/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuard.java
create mode 100644 bootstrap/src/test/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuardTest.java
create mode 100644 docs/production-configuration.md
diff --git a/AGENTS.md b/AGENTS.md
index 9c7297f5a..b5dc66393 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,6 +55,22 @@ mcp-server -> (独立应用,无内部模块依赖)
- **模型路由与容错**:Chat / Embedding / Rerank 均为候选模型配置驱动,含优先级、失败阈值、熔断恢复;供应商差异留在 infra-ai
- **入库管线**:文档入库为 `IngestionNode` 节点编排 Pipeline(解析→增强→分块→向量化→写库)
+## 配置与凭据
+
+application.yaml 携带本地开发默认凭据,生产部署必须覆盖。`ProductionCredentialGuard`(`bootstrap` 模块,`EnvironmentPostProcessor`,Boot 3 机制注册)在启动最前置阶段执行 fail-fast:
+
+- **放行条件**:未显式激活任何 profile,或激活 profile 集合含 `local` / `dev` / `test` 任一。本地直接启动(无 profile)行为保持不变。
+- **检查时机**:其他 profile(如 `prod`)下,对以下敏感键检查生效值,命中开发默认值即抛 `IllegalStateException` 中断启动(消息只含键名,不含值):
+ - `spring.datasource.username`
+ - `spring.datasource.password`
+ - `spring.data.redis.password`
+ - `rag.storage.s3.access-key`
+ - `rag.storage.s3.secret-key`
+- **占位符检查**:值以 `${` 开头且无默认值时(如 `${DB_PASSWORD}`),若环境变量/secret 无法解析同样中断启动;`${KEY:默认值}` 的默认值命中开发默认凭据集合也会中断。
+- **模型 API key 走环境变量属正向机制**(`BAILIAN_API_KEY`、`SILICONFLOW_API_KEY`、`AIHUBMIX_API_KEY`、`MINERU_API_KEY`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY`、`YDC_API_KEY` 等),不参与守卫检查,但生产必须提供。
+
+生产部署的完整键清单与示例见 `docs/production-configuration.md`。密钥轮换属部署侧运维,守卫不参与;轮换时保证新值先注入、旧值下线,避免空窗期。
+
## 扩展点
按 Spring Bean 自动发现,新增能力优先走扩展点而非改核心分发逻辑:
diff --git a/bootstrap/src/main/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuard.java b/bootstrap/src/main/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuard.java
new file mode 100644
index 000000000..519b3d7aa
--- /dev/null
+++ b/bootstrap/src/main/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuard.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.nageoffer.ai.ragent.config;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.env.EnvironmentPostProcessor;
+import org.springframework.core.Ordered;
+import org.springframework.core.env.ConfigurableEnvironment;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 生产 profile 下的开发默认凭据 fail-fast 守卫。
+ *
+ *
application.yaml 携带一组本地开发默认凭据(postgres / 123456 / rustfsadmin 等)。未显式激活
+ * profile 或激活集合含 local / dev / test 时完全放行,本地直接启动行为不变;其他 profile(如 prod)
+ * 下检查敏感键的生效值:命中已知开发默认值、或占位符无默认值且无法解析时抛
+ * {@link IllegalStateException} 中断启动,避免弱凭据静默上线。
+ *
+ * 通过 {@code META-INF/spring.factories} 注册(EnvironmentPostProcessor 在 Spring Boot 3 只从
+ * spring.factories 加载,不走 auto-configuration 的 .imports 机制),排到
+ * {@link Ordered#LOWEST_PRECEDENCE} 保证晚于 ConfigDataEnvironmentPostProcessor,能读到
+ * application.yaml 与 profile 解析结果。
+ *
+ * 异常消息只含键名与提示,绝不包含配置值。
+ */
+public class ProductionCredentialGuard implements EnvironmentPostProcessor, Ordered {
+
+ private static final Set DEV_PROFILES = Set.of("local", "dev", "test");
+
+ private static final Map> DEV_DEFAULT_CREDENTIALS = Map.of(
+ "spring.datasource.username", Set.of("postgres", "root"),
+ "spring.datasource.password", Set.of("postgres", "123456", "root", "password"),
+ "spring.data.redis.password", Set.of("123456", "password", "redis"),
+ "rag.storage.s3.access-key", Set.of("rustfsadmin", "minioadmin"),
+ "rag.storage.s3.secret-key", Set.of("rustfsadmin", "minioadmin"));
+
+ @Override
+ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
+ if (isDevLaunch(environment)) {
+ return;
+ }
+ DEV_DEFAULT_CREDENTIALS.forEach((key, defaults) -> check(environment, key, defaults));
+ }
+
+ @Override
+ public int getOrder() {
+ return Ordered.LOWEST_PRECEDENCE;
+ }
+
+ private boolean isDevLaunch(ConfigurableEnvironment environment) {
+ String active = environment.getProperty("spring.profiles.active");
+ if (active == null || active.isBlank()) {
+ return true;
+ }
+ return Arrays.stream(active.split(","))
+ .map(String::trim)
+ .filter(profile -> !profile.isEmpty())
+ .anyMatch(DEV_PROFILES::contains);
+ }
+
+ private void check(ConfigurableEnvironment environment, String key, Set devDefaults) {
+ String value;
+ try {
+ value = environment.getProperty(key);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalStateException(
+ "配置键 [" + key + "] 的占位符未配置且无默认值,请通过环境变量或 secret 覆盖后重启");
+ }
+ if (value != null && devDefaults.contains(value)) {
+ throw new IllegalStateException(
+ "配置键 [" + key + "] 检测到开发默认值,请通过环境变量或 secret 覆盖后重启");
+ }
+ }
+}
diff --git a/bootstrap/src/main/resources/META-INF/spring.factories b/bootstrap/src/main/resources/META-INF/spring.factories
index c958685d3..893265065 100644
--- a/bootstrap/src/main/resources/META-INF/spring.factories
+++ b/bootstrap/src/main/resources/META-INF/spring.factories
@@ -1,6 +1,7 @@
# 检索通道配置一致性校验:启动最前置的环境后置处理器(EnvironmentPostProcessor 仅从 spring.factories 加载,不走 .imports)
org.springframework.boot.env.EnvironmentPostProcessor=\
- com.nageoffer.ai.ragent.rag.config.validation.RetrievalConfigEnvironmentPostProcessor
+ com.nageoffer.ai.ragent.rag.config.validation.RetrievalConfigEnvironmentPostProcessor,\
+ com.nageoffer.ai.ragent.config.ProductionCredentialGuard
# 检索通道配置矛盾的启动诊断器(把 RetrievalConfigException 渲染成 APPLICATION FAILED TO START 框)
org.springframework.boot.diagnostics.FailureAnalyzer=\
diff --git a/bootstrap/src/test/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuardTest.java b/bootstrap/src/test/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuardTest.java
new file mode 100644
index 000000000..1b6d03115
--- /dev/null
+++ b/bootstrap/src/test/java/com/nageoffer/ai/ragent/config/ProductionCredentialGuardTest.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.nageoffer.ai.ragent.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.env.MockEnvironment;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ProductionCredentialGuardTest {
+
+ private final ProductionCredentialGuard guard = new ProductionCredentialGuard();
+
+ @Test
+ void noProfileAllowsDevDefaults() {
+ MockEnvironment environment = new MockEnvironment()
+ .withProperty("spring.datasource.password", "postgres")
+ .withProperty("spring.data.redis.password", "123456");
+ assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
+ }
+
+ @Test
+ void devProfileAllowsDevDefaults() {
+ MockEnvironment environment = new MockEnvironment()
+ .withProperty("spring.profiles.active", "dev")
+ .withProperty("spring.datasource.password", "postgres");
+ assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
+ }
+
+ @Test
+ void localProfileAllowsDevDefaults() {
+ MockEnvironment environment = new MockEnvironment()
+ .withProperty("spring.profiles.active", "local")
+ .withProperty("rag.storage.s3.secret-key", "rustfsadmin");
+ assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
+ }
+
+ @Test
+ void prodProfileWithDevCredentialFails() {
+ MockEnvironment environment = new MockEnvironment()
+ .withProperty("spring.profiles.active", "prod")
+ .withProperty("spring.datasource.password", "postgres");
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
+ () -> guard.postProcessEnvironment(environment, null));
+ assertTrue(ex.getMessage().contains("spring.datasource.password"));
+ assertFalse(ex.getMessage().contains("postgres"));
+ }
+
+ @Test
+ void prodProfileWithEnvOverridePasses() {
+ MockEnvironment environment = new MockEnvironment()
+ .withProperty("spring.profiles.active", "prod")
+ .withProperty("spring.datasource.password", "S3cret-Override!");
+ assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
+ }
+
+ @Test
+ void prodProfileWithUnresolvablePlaceholderFails() {
+ MockEnvironment environment = new MockEnvironment()
+ .withProperty("spring.profiles.active", "prod")
+ .withProperty("spring.datasource.password", "${DB_PASSWORD}");
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
+ () -> guard.postProcessEnvironment(environment, null));
+ assertTrue(ex.getMessage().contains("spring.datasource.password"));
+ assertFalse(ex.getMessage().contains("DB_PASSWORD"));
+ }
+
+ @Test
+ void prodProfileWithResolvablePlaceholderPasses() {
+ MockEnvironment environment = new MockEnvironment()
+ .withProperty("spring.profiles.active", "prod")
+ .withProperty("DB_PASSWORD", "S3cret-Override!")
+ .withProperty("spring.datasource.password", "${DB_PASSWORD}");
+ assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
+ }
+
+ @Test
+ void prodProfileWithWeakPlaceholderDefaultFails() {
+ MockEnvironment environment = new MockEnvironment()
+ .withProperty("spring.profiles.active", "prod")
+ .withProperty("spring.datasource.password", "${DB_PASSWORD:postgres}");
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
+ () -> guard.postProcessEnvironment(environment, null));
+ assertTrue(ex.getMessage().contains("spring.datasource.password"));
+ assertFalse(ex.getMessage().contains("postgres"));
+ }
+
+ @Test
+ void prodProfileWithEmptyPlaceholderDefaultPasses() {
+ MockEnvironment environment = new MockEnvironment()
+ .withProperty("spring.profiles.active", "prod")
+ .withProperty("spring.datasource.password", "${DB_PASSWORD:}");
+ assertDoesNotThrow(() -> guard.postProcessEnvironment(environment, null));
+ }
+}
diff --git a/docs/production-configuration.md b/docs/production-configuration.md
new file mode 100644
index 000000000..436fd05f5
--- /dev/null
+++ b/docs/production-configuration.md
@@ -0,0 +1,64 @@
+# 生产部署配置说明
+
+本文档说明 fork 在生产 profile 下的凭据覆盖要求与 fail-fast 守卫行为。所有示例均使用占位符,不包含真实凭据。
+
+## 1. 配置来源与开发默认值
+
+`bootstrap/src/main/resources/application.yaml` 为本地开发默认配置,包含一组开发默认凭据(本地数据库口令、对象存储访问密钥等)。这些值仅用于本地起服务,生产环境必须覆盖。
+
+模型供应商 API key(百炼、SiliconFlow、AIHubMix、MinerU、OSS、You.com 等)一律通过环境变量注入(如 `BAILIAN_API_KEY`、`SILICONFLOW_API_KEY`),yaml 中只保留 `${XXX_API_KEY:}` 占位符,属正向机制,不参与守卫检查。
+
+## 2. Fail-fast 守卫机制
+
+`ProductionCredentialGuard`(`com.nageoffer.ai.ragent.config`)是 Spring Boot 3 `EnvironmentPostProcessor`,通过 `bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor` 注册,在应用启动最前置阶段(早于任何 bean 与连接建立)执行:
+
+| 场景 | 行为 |
+|:---|:---|
+| 未激活任何 profile | 放行(本地直接启动体验不变) |
+| 激活集合含 `local` / `dev` / `test` 任一 | 放行 |
+| 其他 profile(如 `prod`)且生效值命中开发默认凭据 | 启动失败 |
+| 其他 profile 且占位符无默认值、无法从环境变量解析 | 启动失败 |
+| 其他 profile 且 `${KEY:默认值}` 的默认值命中开发默认凭据 | 启动失败 |
+
+失败消息只含配置键名与提示("请通过环境变量或 secret 覆盖"),不输出任何配置值。
+
+## 3. 生产必须覆盖的键
+
+启动 profile 为生产(非 local/dev/test)时,以下键的生效值不得命中开发默认凭据集合:
+
+| 键 | 环境变量注入方式(示例) |
+|:---|:---|
+| `spring.datasource.username` | `SPRING_DATASOURCE_USERNAME` |
+| `spring.datasource.password` | `SPRING_DATASOURCE_PASSWORD` |
+| `spring.data.redis.password` | `SPRING_DATA_REDIS_PASSWORD` |
+| `rag.storage.s3.access-key` | `RAG_STORAGE_S3_ACCESS_KEY` |
+| `rag.storage.s3.secret-key` | `RAG_STORAGE_S3_SECRET_KEY` |
+
+Spring Boot 的宽松绑定会把环境变量名映射到对应键;也可以使用 `--spring.datasource.password=...` 命令行参数或外部化配置文件。
+
+生产还应根据部署形态覆盖非凭据项(不触发守卫,但影响正确性):数据源 URL、Redis host/port、S3 endpoint、`rag.vector.type`、`rag.graph.type` 等。
+
+## 4. 启动示例
+
+```bash
+# 生产 profile 启动(凭据来自环境变量,示例占位符)
+export SPRING_PROFILES_ACTIVE=prod
+export SPRING_DATASOURCE_USERNAME=''
+export SPRING_DATASOURCE_PASSWORD=''
+export SPRING_DATA_REDIS_PASSWORD=''
+export RAG_STORAGE_S3_ACCESS_KEY=''
+export RAG_STORAGE_S3_SECRET_KEY=''
+export BAILIAN_API_KEY=''
+export SILICONFLOW_API_KEY=''
+./mvnw -B -ntp -pl bootstrap spring-boot:run
+```
+
+若任一敏感键仍为开发默认值,启动会立即失败并提示对应键名,不会带弱凭据上线。
+
+## 5. 密钥轮换提醒
+
+守卫只在启动时检查,不参与运行期密钥轮换:
+
+- 轮换采用"先注入新值、验证生效、再下线旧值"的顺序,避免空窗期;
+- 环境变量/secret 变更后需重启应用(或在支持动态刷新的场景单独处理);
+- 数据库、Redis、对象存储的凭据轮换建议与各自平台的密钥管理策略配合(Vault、KMS 等),本项目不做配置加密与密钥托管。