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) { private static String maskSensitiveText(String text) {
if (text == null || text.isBlank()) { return clean(AihrSensitiveText.forModel(text));
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);
} }
private record CaseState( private record CaseState(
@@ -369,7 +369,7 @@ public class AihrInterviewService {
履历摘要:%s 履历摘要:%s
关注风险:%s 关注风险:%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); return modelService.tryChat(system, user, 0.25).flatMap(this::parseQuestions);
} }
@@ -388,7 +388,7 @@ public class AihrInterviewService {
岗位能力:%s 岗位能力:%s
问答记录: 问答记录:
%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); return modelService.tryChat(system, user, 0.0).flatMap(this::parseScore);
} }
@@ -385,11 +385,13 @@ public class AihrModelSeedService {
ArrayNode messages = body.putArray("messages"); ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject(); ObjectNode system = messages.addObject();
system.put("role", "system"); system.put("role", "system");
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合物业一线使用,简洁、可执行。" : systemPrompt); system.put("content", AihrSensitiveText.forModel(isBlank(systemPrompt)
? "你是物业 AI 人力资源系统助手,回答要适合物业一线使用,简洁、可执行。"
: systemPrompt));
ObjectNode user = messages.addObject(); ObjectNode user = messages.addObject();
user.put("role", "user"); user.put("role", "user");
user.put("content", prompt); user.put("content", AihrSensitiveText.forModel(prompt));
HttpRequest.Builder builder = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions")) .uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -2957,15 +2957,7 @@ public class AihrPracticeSeedService {
} }
private String maskSensitiveText(String text) { private String maskSensitiveText(String text) {
if (text == null || text.isBlank()) { return AihrSensitiveText.forModel(text);
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;
} }
private Integer scoreValue(List<DimensionResponse> scores, String label) { 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) update_time = VALUES(update_time)
""", """,
tenantId(), tenantId(),
truncate(queryText, 1000), truncate(AihrSensitiveText.forModel(queryText), 1000),
queryNorm, normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)),
fragmentId, fragmentId,
verdict, verdict,
firstNonBlank(request.category(), "sop"), firstNonBlank(request.category(), "sop"),
@@ -812,14 +812,14 @@ public class AihrSopSeedService {
ArrayNode messages = body.putArray("messages"); ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject(); ObjectNode system = messages.addObject();
system.put("role", "system"); 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 String categoryRule = autoCategory
? "根据资料内容选择最合适的已有分类;如果明显不属于已有分类,可给出一个不超过 12 个字的新分类。" ? "根据资料内容选择最合适的已有分类;如果明显不属于已有分类,可给出一个不超过 12 个字的新分类。"
: "分类必须保持为:" + manualCategory + "。只生成摘要、标签和归类理由。"; : "分类必须保持为:" + manualCategory + "。只生成摘要、标签和归类理由。";
ObjectNode user = messages.addObject(); ObjectNode user = messages.addObject();
user.put("role", "user"); user.put("role", "user");
user.put("content", """ user.put("content", AihrSensitiveText.forModel("""
已有分类: 已有分类:
%s %s
@@ -828,7 +828,7 @@ public class AihrSopSeedService {
文件名:%s 文件名:%s
正文摘录: 正文摘录:
%s %s
""".formatted(categoryOptionsText(), categoryRule, fileName, truncate(content, 6000))); """.formatted(categoryOptionsText(), categoryRule, fileName, truncate(content, 6000))));
HttpRequest.Builder builder = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions")) .uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -1635,7 +1635,7 @@ public class AihrSopSeedService {
ObjectNode body = objectMapper.createObjectNode(); ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName()); body.put("model", runtime.modelName());
ArrayNode input = body.putArray("input"); ArrayNode input = body.putArray("input");
fragments.forEach(input::add); fragments.stream().map(AihrSensitiveText::forModel).forEach(input::add);
HttpRequest.Builder builder = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/embeddings")) .uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/embeddings"))
@@ -1751,11 +1751,11 @@ public class AihrSopSeedService {
try { try {
ObjectNode body = objectMapper.createObjectNode(); ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.get().modelName()); body.put("model", runtime.get().modelName());
body.put("query", queryText); body.put("query", AihrSensitiveText.forModel(queryText));
body.put("top_n", hits.size()); body.put("top_n", hits.size());
body.put("return_documents", false); body.put("return_documents", false);
ArrayNode documents = body.putArray("documents"); 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() HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.get().baseUrl()) + "/rerank")) .uri(URI.create(normalizeBaseUrl(runtime.get().baseUrl()) + "/rerank"))
@@ -1820,7 +1820,7 @@ public class AihrSopSeedService {
VALUES (?, ?, ?, ?, ?, '待补充', ?) VALUES (?, ?, ?, ?, ?, '待补充', ?)
""", """,
tenantId(), tenantId(),
queryText.trim(), AihrSensitiveText.forModel(queryText.trim()),
firstNonBlank(category, "sop"), firstNonBlank(category, "sop"),
firstNonBlank(position, "生活顾问"), firstNonBlank(position, "生活顾问"),
normalizeSearchSource(source), normalizeSearchSource(source),
@@ -1864,10 +1864,10 @@ public class AihrSopSeedService {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
""", Statement.RETURN_GENERATED_KEYS); """, Statement.RETURN_GENERATED_KEYS);
ps.setString(1, tenantId()); 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(3, firstNonBlank(response.category(), "sop"));
ps.setString(4, truncate(response.answer(), 2000)); ps.setString(4, truncate(AihrSensitiveText.forModel(response.answer()), 2000));
ps.setString(5, truncate(response.reference(), 1000)); ps.setString(5, truncate(AihrSensitiveText.forModel(response.reference()), 1000));
ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size()); ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size());
ps.setString(7, normalizeSearchSource(source)); ps.setString(7, normalizeSearchSource(source));
ps.setString(8, firstNonBlank(response.promptVersion(), "unknown")); ps.setString(8, firstNonBlank(response.promptVersion(), "unknown"));
@@ -2418,11 +2418,11 @@ public class AihrSopSeedService {
ArrayNode messages = body.putArray("messages"); ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject(); ObjectNode system = messages.addObject();
system.put("role", "system"); system.put("role", "system");
system.put("content", prompt.systemPrompt()); system.put("content", AihrSensitiveText.forModel(prompt.systemPrompt()));
ObjectNode user = messages.addObject(); ObjectNode user = messages.addObject();
user.put("role", "user"); 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() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions")) .uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -2494,12 +2494,12 @@ public class AihrSopSeedService {
ObjectNode user = messages.addObject(); ObjectNode user = messages.addObject();
user.put("role", "user"); user.put("role", "user");
user.put("content", """ user.put("content", AihrSensitiveText.forModel("""
员工的问题:%s 员工的问题:%s
检索命中的知识片段: 检索命中的知识片段:
%s %s
""".formatted(queryText, context)); """.formatted(queryText, context)));
HttpRequest.Builder builder = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions")) .uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -83,7 +83,7 @@ public class AihrSpeechService {
try { try {
ObjectNode body = objectMapper.createObjectNode(); ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName()); 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("voice", resolveVoice(runtime.modelName(), voice));
body.put("response_format", "mp3"); body.put("response_format", "mp3");
HttpRequest httpRequest = authorized(runtime, "/audio/speech") 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 只读发布前复核:生产根站点、`/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 线上资源复核更新:生产管理端仍加载 `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 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 "仅支持 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 "/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/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/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 "position_level IN ('主管', '项目经理')"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "media_oss_id" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "media_oss_id"