fix(aihr): sanitize outbound model text

This commit is contained in:
2026-07-14 08:28:25 +08:00
parent 4f304eb802
commit 6a08e56e96
10 changed files with 126 additions and 39 deletions
@@ -524,15 +524,7 @@ public class AihrCaseService {
}
private static String maskSensitiveText(String text) {
if (text == null || text.isBlank()) {
return clean(text);
}
String masked = text;
masked = masked.replaceAll("(?<!\\d)(1[3-9]\\d{2})\\d{3}(\\d{4})(?!\\d)", "$1****$2");
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})(栋|幢|号楼|单元)(\\d{3,4})(?!\\d)", "$1$2****");
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})[--](\\d{3,4})(?!\\d)", "$1-****");
masked = masked.replaceAll("([\\u4e00-\\u9fa5])([\\u4e00-\\u9fa5])(先生|女士|经理)", "$1*$3");
return clean(masked);
return clean(AihrSensitiveText.forModel(text));
}
private record CaseState(
@@ -369,7 +369,7 @@ public class AihrInterviewService {
履历摘要:%s
关注风险:%s
岗位能力:%s
""".formatted(candidate.name(), candidate.position(), candidate.project(), candidate.experience(), candidate.risk(), String.join("、", candidate.capabilities()));
""".formatted(AihrSensitiveText.personName(candidate.name(), "候选人"), candidate.position(), candidate.project(), candidate.experience(), candidate.risk(), String.join("、", candidate.capabilities()));
return modelService.tryChat(system, user, 0.25).flatMap(this::parseQuestions);
}
@@ -388,7 +388,7 @@ public class AihrInterviewService {
岗位能力:%s
问答记录:
%s
""".formatted(session.candidate().name(), session.candidate().position(), String.join("、", session.candidate().capabilities()), renderAnswers(session));
""".formatted(AihrSensitiveText.personName(session.candidate().name(), "候选人"), session.candidate().position(), String.join("、", session.candidate().capabilities()), renderAnswers(session));
return modelService.tryChat(system, user, 0.0).flatMap(this::parseScore);
}
@@ -385,11 +385,13 @@ public class AihrModelSeedService {
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合物业一线使用,简洁、可执行。" : systemPrompt);
system.put("content", AihrSensitiveText.forModel(isBlank(systemPrompt)
? "你是物业 AI 人力资源系统助手,回答要适合物业一线使用,简洁、可执行。"
: systemPrompt));
ObjectNode user = messages.addObject();
user.put("role", "user");
user.put("content", prompt);
user.put("content", AihrSensitiveText.forModel(prompt));
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -2957,15 +2957,7 @@ public class AihrPracticeSeedService {
}
private String maskSensitiveText(String text) {
if (text == null || text.isBlank()) {
return text;
}
String masked = text;
masked = masked.replaceAll("(?<!\\d)(1[3-9]\\d{2})\\d{3}(\\d{4})(?!\\d)", "$1****$2");
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})(栋|幢|号楼|单元)(\\d{3,4})(?!\\d)", "$1$2****");
masked = masked.replaceAll("(?<!\\d)(\\d{1,2})[--](\\d{3,4})(?!\\d)", "$1-****");
masked = masked.replaceAll("([\\u4e00-\\u9fa5])([\\u4e00-\\u9fa5])(先生|女士|经理)", "$1*$3");
return masked;
return AihrSensitiveText.forModel(text);
}
private Integer scoreValue(List<DimensionResponse> scores, String label) {
@@ -0,0 +1,38 @@
package org.dromara.aihr.service;
import java.util.regex.Pattern;
/**
* 发送到外部 AI 模型前的最小文本脱敏规则。
*
* <p>这不是正式的数据分类引擎;它只负责拦截当前一期已知的手机号、邮箱、房号和带称谓姓名,
* 避免“入库已脱敏、出境仍是原文”的边界漏洞。正式试点仍需配套供应商留存、审计和删除合同。</p>
*/
final class AihrSensitiveText {
// 保持案例服务既有展示口径:手机号保留前 4 位和后 4 位,中间统一遮蔽。
private static final Pattern PHONE = Pattern.compile("(?<!\\d)(1[3-9]\\d{2})\\d{3}(\\d{4})(?!\\d)");
private static final Pattern EMAIL = Pattern.compile("(?i)(?<![\\w.+-])[\\w.+-]+@[\\w-]+(?:\\.[\\w-]+)+(?![\\w.-])");
private static final Pattern ROOM = Pattern.compile("(?<!\\d)(\\d{1,2})(栋|幢|号楼|单元)(\\d{3,4})(?!\\d)");
private static final Pattern ROOM_SHORT = Pattern.compile("(?<!\\d)(\\d{1,2})[--](\\d{3,4})(?!\\d)");
private static final Pattern HONORIFIC_NAME = Pattern.compile("([\\u4e00-\\u9fa5])([\\u4e00-\\u9fa5])(先生|女士|经理)");
private AihrSensitiveText() {
}
static String forModel(String text) {
if (text == null || text.isBlank()) {
return text;
}
String masked = PHONE.matcher(text).replaceAll("$1****$2");
masked = EMAIL.matcher(masked).replaceAll("[邮箱已脱敏]");
masked = ROOM.matcher(masked).replaceAll("$1$2****");
masked = ROOM_SHORT.matcher(masked).replaceAll("$1-****");
return HONORIFIC_NAME.matcher(masked).replaceAll("$1*$3");
}
/** 已知的结构化姓名字段不应仅依赖称谓正则,直接用匿名角色替换。 */
static String personName(String name, String roleLabel) {
return name == null || name.isBlank() ? "" : "匿名" + roleLabel;
}
}
@@ -246,8 +246,8 @@ public class AihrSopSeedService {
update_time = VALUES(update_time)
""",
tenantId(),
truncate(queryText, 1000),
queryNorm,
truncate(AihrSensitiveText.forModel(queryText), 1000),
normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)),
fragmentId,
verdict,
firstNonBlank(request.category(), "sop"),
@@ -812,14 +812,14 @@ public class AihrSopSeedService {
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", "你是物业资料管理员。只返回 JSON,不要 markdown。字段固定为 category, summary, tags, reason。tags 为 3 到 6 个短标签。");
system.put("content", AihrSensitiveText.forModel("你是物业资料管理员。只返回 JSON,不要 markdown。字段固定为 category, summary, tags, reason。tags 为 3 到 6 个短标签。"));
String categoryRule = autoCategory
? "根据资料内容选择最合适的已有分类;如果明显不属于已有分类,可给出一个不超过 12 个字的新分类。"
: "分类必须保持为:" + manualCategory + "。只生成摘要、标签和归类理由。";
ObjectNode user = messages.addObject();
user.put("role", "user");
user.put("content", """
user.put("content", AihrSensitiveText.forModel("""
已有分类:
%s
@@ -828,7 +828,7 @@ public class AihrSopSeedService {
文件名:%s
正文摘录:
%s
""".formatted(categoryOptionsText(), categoryRule, fileName, truncate(content, 6000)));
""".formatted(categoryOptionsText(), categoryRule, fileName, truncate(content, 6000))));
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -1635,7 +1635,7 @@ public class AihrSopSeedService {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName());
ArrayNode input = body.putArray("input");
fragments.forEach(input::add);
fragments.stream().map(AihrSensitiveText::forModel).forEach(input::add);
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/embeddings"))
@@ -1751,11 +1751,11 @@ public class AihrSopSeedService {
try {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.get().modelName());
body.put("query", queryText);
body.put("query", AihrSensitiveText.forModel(queryText));
body.put("top_n", hits.size());
body.put("return_documents", false);
ArrayNode documents = body.putArray("documents");
hits.forEach(hit -> documents.add(truncate(hit.content(), 2000)));
hits.forEach(hit -> documents.add(AihrSensitiveText.forModel(truncate(hit.content(), 2000))));
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.get().baseUrl()) + "/rerank"))
@@ -1820,7 +1820,7 @@ public class AihrSopSeedService {
VALUES (?, ?, ?, ?, ?, '待补充', ?)
""",
tenantId(),
queryText.trim(),
AihrSensitiveText.forModel(queryText.trim()),
firstNonBlank(category, "sop"),
firstNonBlank(position, "生活顾问"),
normalizeSearchSource(source),
@@ -1864,10 +1864,10 @@ public class AihrSopSeedService {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
""", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, tenantId());
ps.setString(2, truncate(response.queryText(), 1000));
ps.setString(2, truncate(AihrSensitiveText.forModel(response.queryText()), 1000));
ps.setString(3, firstNonBlank(response.category(), "sop"));
ps.setString(4, truncate(response.answer(), 2000));
ps.setString(5, truncate(response.reference(), 1000));
ps.setString(4, truncate(AihrSensitiveText.forModel(response.answer()), 2000));
ps.setString(5, truncate(AihrSensitiveText.forModel(response.reference()), 1000));
ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size());
ps.setString(7, normalizeSearchSource(source));
ps.setString(8, firstNonBlank(response.promptVersion(), "unknown"));
@@ -2418,11 +2418,11 @@ public class AihrSopSeedService {
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", prompt.systemPrompt());
system.put("content", AihrSensitiveText.forModel(prompt.systemPrompt()));
ObjectNode user = messages.addObject();
user.put("role", "user");
user.put("content", renderPrompt(prompt.template(), queryText, context));
user.put("content", AihrSensitiveText.forModel(renderPrompt(prompt.template(), queryText, context)));
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -2494,12 +2494,12 @@ public class AihrSopSeedService {
ObjectNode user = messages.addObject();
user.put("role", "user");
user.put("content", """
user.put("content", AihrSensitiveText.forModel("""
员工的问题:%s
检索命中的知识片段:
%s
""".formatted(queryText, context));
""".formatted(queryText, context)));
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -83,7 +83,7 @@ public class AihrSpeechService {
try {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName());
body.put("input", text.trim());
body.put("input", AihrSensitiveText.forModel(text.trim()));
body.put("voice", resolveVoice(runtime.modelName(), voice));
body.put("response_format", "mp3");
HttpRequest httpRequest = authorized(runtime, "/audio/speech")
@@ -0,0 +1,58 @@
package org.dromara.aihr.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrSensitiveTextTest {
@Test
void masksKnownPiiBeforeExternalModelUse() {
String raw = "请联系张三先生,电话13812345678,邮箱zhangsan@example.com,住3栋1201和2-302。";
String masked = AihrSensitiveText.forModel(raw);
assertFalse(masked.contains("13812345678"));
assertFalse(masked.contains("zhangsan@example.com"));
assertFalse(masked.contains("3栋1201"));
assertFalse(masked.contains("2-302"));
assertTrue(masked.contains("1381****5678"));
assertTrue(masked.contains("[邮箱已脱敏]"));
assertTrue(masked.contains("3栋****"));
assertTrue(masked.contains("2-****"));
assertTrue(masked.contains("张*先生"));
}
@Test
void masksStructuredPersonNamesWithoutGuessingFreeTextNames() {
assertTrue(AihrSensitiveText.personName("张三", "候选人").equals("匿名候选人"));
assertTrue(AihrSensitiveText.personName("", "候选人").isEmpty());
}
@Test
void allTextModelBoundariesUseTheSanitizer() throws Exception {
String modelSource = Files.readString(source("AihrModelSeedService.java"));
String sopSource = Files.readString(source("AihrSopSeedService.java"));
String interviewSource = Files.readString(source("AihrInterviewService.java"));
String speechSource = Files.readString(source("AihrSpeechService.java"));
assertTrue(modelSource.contains("AihrSensitiveText.forModel(prompt)"));
assertTrue(sopSource.contains("AihrSensitiveText.forModel(renderPrompt(prompt.template(), queryText, context))"));
assertTrue(sopSource.contains("AihrSensitiveText.forModel(\"\"\""));
assertTrue(interviewSource.contains("AihrSensitiveText.personName(candidate.name(), \"候选人\")"));
assertTrue(speechSource.contains("AihrSensitiveText.forModel(text.trim())"));
}
private static Path source(String fileName) {
Path direct = Path.of("src/main/java/org/dromara/aihr/service", fileName);
return Files.exists(direct)
? direct
: Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service", fileName);
}
}
+1
View File
@@ -114,3 +114,4 @@
- 2026-07-14 只读发布前复核:生产根站点、`/h5/`、租户列表和员工首页 API 均返回 200;当前本地提交 `8105413d` 的管理端主资源 `index-BWnisJoz.js` 与后端 jar 尚未与生产资源/hash 对齐,H5 仍与生产 `index-D4-NrEpb.js` 一致。本轮未发布、未修改生产数据;后续若发布管理端 OCR 状态修正或后端批次,必须重新执行备份、资源 hash、服务状态和浏览器回归。
- 2026-07-14 线上资源复核更新:生产管理端仍加载 `index-CJZ3Ax3Z.js`(SHA-256 `8b9278a26ccb760abce489b12a20b221748c64d4c4153613d2511777bb9677bc`),当前本地管理端构建加载 `index-CIgkCgjC.js`(SHA-256 `f0e9bca71dc2ee484745f0d4058079c54f493cf0be51bfe279f1da64dd62dcfd`);两者不一致。生产主资源已包含既有“提示词模板”能力,但不能据此证明本地最新成长激励/资料处理修复已上线。
- 2026-07-14 07:30 只读发布预检:当前本地提交 `9832caa4` 的管理端构建加载 `index-Cz1rtpOj.js`(SHA-256 `558392609617377b8327918c411887fc6766d1e545e0a703e2fc3dbaaa1d1a66`),生产仍加载 `index-CJZ3Ax3Z.js`(SHA-256 `8b9278a26ccb760abce489b12a20b221748c64d4c4153613d2511777bb9677bc`);生产根站点和租户接口均返回 200。本次只读核对,未发布;最新主管权限与评级语义修复尚未在线上生效。
- 2026-07-14 BRD G3/G6 外发文本复核:发现对练、面试调用的共享 LLM 边界和 SOP 总结卡独立直连路径此前把原始文本直接放进外部模型请求;新增 `AihrSensitiveText`,在模型请求体写出前统一遮蔽手机号、邮箱、房号和带称谓姓名,结构化候选人姓名改为匿名角色,TTS 文本也走同一规则,并补源码契约/规则测试。案例转写入库前已有文本脱敏,但 ASR 接收的是音频本体、视觉 OCR 接收的是图片本体,无法在本地转写/识别前可靠遮蔽语音或图片中的 PII;正式试点仍需供应商不留存/合规合同或本地语音/视觉模型、审计与删除证明,不能把文本脱敏说成完整 G3/G6 通过。
+4
View File
@@ -459,6 +459,10 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "/embeddings"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "aihr_knowledge_gap"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java "外部 AI 模型前"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "AihrSensitiveText.forModel(prompt)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "AihrSensitiveText.forModel(renderPrompt"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "AihrSensitiveText.forModel(text.trim())"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "仅项目负责人或主管可提交案例素材"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "position_level IN ('主管', '项目经理')"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "media_oss_id"