diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java index 6b0e1c19..8acb612f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java @@ -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("(? scores, String label) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java new file mode 100644 index 00000000..86ced46f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java @@ -0,0 +1,38 @@ +package org.dromara.aihr.service; + +import java.util.regex.Pattern; + +/** + * 发送到外部 AI 模型前的最小文本脱敏规则。 + * + *
这不是正式的数据分类引擎;它只负责拦截当前一期已知的手机号、邮箱、房号和带称谓姓名, + * 避免“入库已脱敏、出境仍是原文”的边界漏洞。正式试点仍需配套供应商留存、审计和删除合同。
+ */ +final class AihrSensitiveText { + + // 保持案例服务既有展示口径:手机号保留前 4 位和后 4 位,中间统一遮蔽。 + private static final Pattern PHONE = Pattern.compile("(? 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")) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java index 4ec1ade3..34f2f87a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java @@ -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") diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java new file mode 100644 index 00000000..f0839903 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java @@ -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); + } +} diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 1358f233..8e8c9e8d 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -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 通过。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index a05b6e6d..41a4e8f3 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -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"