From 6853facbe03d2f533b37be9b46c89c85464f421d Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 19 Jul 2026 20:20:57 +0800 Subject: [PATCH] fix(web-ai): store encryption key in database --- .../aihr/webai/AihrWebAiProperties.java | 9 ----- .../aihr/webai/AihrWebSecretCodec.java | 40 ++++++++++++++++--- .../aihr/webai/AihrWebAiSecurityTest.java | 9 +++-- ...20260719_web_ai_database_secret_mysql8.sql | 10 +++++ docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 7 ++-- docs/DEV_SETUP.md | 3 +- scripts/release-preflight.sh | 1 + 7 files changed, 56 insertions(+), 23 deletions(-) create mode 100644 backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiProperties.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiProperties.java index a3fde096..d65fc1b1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiProperties.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebAiProperties.java @@ -11,7 +11,6 @@ public class AihrWebAiProperties { private String provider = "tavily"; private String endpoint = "https://api.tavily.com/search"; private String apiKey = ""; - private String secretKey = ""; private int maxResults = 5; public boolean isEnabled() { @@ -46,14 +45,6 @@ public class AihrWebAiProperties { this.apiKey = apiKey; } - public String getSecretKey() { - return secretKey; - } - - public void setSecretKey(String secretKey) { - this.secretKey = secretKey; - } - public int getMaxResults() { return maxResults; } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSecretCodec.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSecretCodec.java index 21e2cf06..ec12323f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSecretCodec.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/webai/AihrWebSecretCodec.java @@ -2,6 +2,7 @@ package org.dromara.aihr.webai; import lombok.RequiredArgsConstructor; import org.dromara.common.core.exception.ServiceException; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import javax.crypto.Cipher; @@ -17,9 +18,11 @@ import java.util.Base64; class AihrWebSecretCodec { private static final String PREFIX = "enc:v1:"; + private static final String CONFIG_KEY = "provider-encryption-v1"; private static final SecureRandom RANDOM = new SecureRandom(); - private final AihrWebAiProperties properties; + private final JdbcTemplate jdbcTemplate; + private volatile SecretKeySpec databaseKey; String encrypt(String plaintext) { if (plaintext == null || plaintext.isBlank()) { @@ -69,11 +72,36 @@ class AihrWebSecretCodec { } private SecretKeySpec key() throws Exception { - String secret = properties.getSecretKey() == null ? "" : properties.getSecretKey().trim(); - if (secret.length() < 16) { - throw new ServiceException("请先配置至少16位的 AIHR_WEB_AI_SECRET_KEY"); + if (databaseKey != null) { + return databaseKey; + } + synchronized (this) { + if (databaseKey != null) { + return databaseKey; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_web_ai_secret` ( + `config_key` varchar(64) NOT NULL, + `config_value` varchar(255) NOT NULL, + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`config_key`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); + byte[] random = new byte[32]; + RANDOM.nextBytes(random); + jdbcTemplate.update(""" + insert ignore into aihr_web_ai_secret (config_key, config_value, create_time) + values (?, ?, now()) + """, CONFIG_KEY, Base64.getEncoder().encodeToString(random)); + String secret = jdbcTemplate.queryForObject(""" + select config_value from aihr_web_ai_secret where config_key = ? + """, String.class, CONFIG_KEY); + if (secret == null || secret.length() < 16) { + throw new ServiceException("全网检索数据库主密钥无效"); + } + byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8)); + databaseKey = new SecretKeySpec(digest, "AES"); + return databaseKey; } - byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8)); - return new SecretKeySpec(digest, "AES"); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java index ba1cece4..c68d8dfe 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/webai/AihrWebAiSecurityTest.java @@ -22,6 +22,8 @@ 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.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -172,9 +174,10 @@ class AihrWebAiSecurityTest { @Test @Tag("dev") void databaseProviderSecretUsesAuthenticatedEncryption() { - AihrWebAiProperties properties = new AihrWebAiProperties(); - properties.setSecretKey("unit-test-secret-key-32-bytes-long"); - AihrWebSecretCodec codec = new AihrWebSecretCodec(properties); + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.queryForObject(anyString(), eq(String.class), eq("provider-encryption-v1"))) + .thenReturn("unit-test-secret-key-32-bytes-long"); + AihrWebSecretCodec codec = new AihrWebSecretCodec(jdbcTemplate); String encrypted = codec.encrypt("tvly-test-secret"); diff --git a/backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql b/backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql new file mode 100644 index 00000000..01514c56 --- /dev/null +++ b/backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql @@ -0,0 +1,10 @@ +-- 全网 AI 提供方密钥的数据库主密钥;不通过环境变量或通用参数接口暴露。 +CREATE TABLE IF NOT EXISTS `aihr_web_ai_secret` ( + `config_key` varchar(64) NOT NULL COMMENT '内部配置键', + `config_value` varchar(255) NOT NULL COMMENT '随机主密钥,仅后端读取', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`config_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='全网AI内部密钥配置'; + +INSERT IGNORE INTO `aihr_web_ai_secret` (`config_key`, `config_value`) +VALUES ('provider-encryption-v1', TO_BASE64(RANDOM_BYTES(32))); diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index f716ac64..d926afed 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -14,7 +14,7 @@ 上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。 -2026-07-19 增量在上述基线上新增“项目服务记忆最小切片”:候选信息只有在当前员工明确确认后,才会写入其已授权项目的服务记录;本批不包含个人想法/经验的长期知识库、跨项目共享、自动提醒或向量化个人资料。同日的“工作上报语音会话化”把既有 `aihr_work_report` 纳入结构门禁,并补充 `request_key/request_hash` 和租户内提交人唯一索引,用于确认上报的幂等重试,不新建草稿或会话表。迁移后发布门禁统一为 `39/39`。 +2026-07-19 增量在上述基线上新增“项目服务记忆最小切片”:候选信息只有在当前员工明确确认后,才会写入其已授权项目的服务记录;本批不包含个人想法/经验的长期知识库、跨项目共享、自动提醒或向量化个人资料。同日的“工作上报语音会话化”把既有 `aihr_work_report` 纳入结构门禁,并补充 `request_key/request_hash` 和租户内提交人唯一索引,用于确认上报的幂等重试,不新建草稿或会话表。全网 AI 提供方加密主密钥改为数据库内部配置后,发布门禁统一为 `40/40`。 ## 发布前置条件 @@ -53,6 +53,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql ``` 顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、正式记录和版本留痕三张表;工作上报幂等迁移随后只补原表列与唯一索引。两者同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 @@ -74,7 +75,7 @@ WHERE table_schema = DATABASE() 'aihr_practice_question_feedback', 'aihr_onboard_exam', 'aihr_onboard_exam_target', 'aihr_onboard_exam_question', 'aihr_onboard_exam_attempt', 'aihr_onboard_exam_answer', - 'aihr_web_search_provider', 'aihr_web_ai_query_audit', + 'aihr_web_search_provider', 'aihr_web_ai_secret', 'aihr_web_ai_query_audit', 'aihr_community_question', 'aihr_community_answer', 'aihr_incentive_rule', 'aihr_points_ledger', 'aihr_candidate_material', 'aihr_interview_result', 'aihr_candidate_employee_link', @@ -125,7 +126,7 @@ GROUP BY question_type ORDER BY question_type; ``` -预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、两张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及三张项目服务记忆表和原工作上报表均存在;工作上报幂等字段为 `2/2`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新服务记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。 +预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及三张项目服务记忆表和原工作上报表均存在;工作上报幂等字段为 `2/2`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新服务记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。 ## 迁移后应用回归 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 2b6bbe36..ed3484dd 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -79,10 +79,9 @@ AIHR_ORG_SYNC_SIGNING_SECRET= AIHR_AI_RUNTIME_ENABLED=true AIHR_AI_CHAT_ENABLED=true AIHR_AI_SPEECH_ENABLED=true -AIHR_WEB_AI_SECRET_KEY=replace-with-at-least-16-random-characters ``` -`AIHR_WEB_AI_SECRET_KEY` 用于加密数据库中的全网检索访问密钥,必须配置为至少 16 位的独立随机值;缺失或长度不足时不能保存或读取提供方密钥。全网检索提供方只接受公网 HTTPS 地址,且每次修改地址或密钥后都必须重新连接测试,测试成功后才能启用。 +全网检索访问密钥使用数据库表 `aihr_web_ai_secret` 中自动生成的主密钥加密,不依赖环境变量,也不通过通用参数接口回显。数据库迁移、备份和恢复必须同时保留该表;丢失或修改主密钥后需要重新保存提供方密钥。全网检索提供方只接受公网 HTTPS 地址,且每次修改地址或密钥后都必须重新连接测试,测试成功后才能启用。 `application-dev.yml` 只保留占位和默认值,不提交真实短信密钥。dev 环境不配阿里云短信也能登录移动端:验证码固定 `123456`。 diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 966cb977..af151f4c 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -141,6 +141,7 @@ FROM ( UNION ALL SELECT 'aihr_onboard_exam_attempt' UNION ALL SELECT 'aihr_onboard_exam_answer' UNION ALL SELECT 'aihr_web_search_provider' + UNION ALL SELECT 'aihr_web_ai_secret' UNION ALL SELECT 'aihr_web_ai_query_audit' UNION ALL SELECT 'aihr_community_question' UNION ALL SELECT 'aihr_community_answer'