From d2b0035e3aa47d24b9bd827fdc09258fb3dbe63a Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 21:27:47 +0800 Subject: [PATCH] fix(aihr): harden tenant resource boundaries --- .../aihr/controller/AihrMobileController.java | 3 +- .../aihr/service/AihrInterviewService.java | 18 ++++++-- .../aihr/service/AihrMobileSeedService.java | 4 ++ .../aihr/service/AihrPracticeSeedService.java | 13 ++++++ .../aihr/service/AihrSopSeedService.java | 42 +++++++++++++------ .../service/AihrInterviewServiceTest.java | 15 +++++++ .../service/AihrPracticeSeedServiceTest.java | 1 + .../aihr/service/AihrSopSeedServiceTest.java | 32 ++++++++++++++ docs/BRD_IMPLEMENTATION_AUDIT.md | 4 ++ scripts/demo-check.sh | 8 ++++ 10 files changed, 123 insertions(+), 17 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java index 57a20763..fe22e125 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -159,7 +159,8 @@ public class AihrMobileController { return; } if (UserType.SYS_USER.getUserType().equals(loginUser.getUserType()) - && !StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator")) { + && (!StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator") + || !mobileSeedService.canReadPracticeAudioForTenant(ossId))) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音"); return; } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java index 78a85836..783f7576 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java @@ -59,7 +59,7 @@ public class AihrInterviewService { ); GeneratedQuestions generated = generateQuestions(candidate).orElseGet(() -> new GeneratedQuestions(candidate.questions(), "local-rubric")); String sessionId = "iv-" + UUID.randomUUID(); - sessions.put(sessionId, new InterviewSession(candidate, generated.questions(), new ConcurrentHashMap<>(), generated.source())); + sessions.put(sessionId, new InterviewSession(tenantId(), candidate, generated.questions(), new ConcurrentHashMap<>(), generated.source())); return new StartResponse(sessionId, candidate.id(), candidate.name(), candidate.position(), generated.questions(), generated.source()); } @@ -70,6 +70,7 @@ public class AihrInterviewService { public AnswerResponse answer(AnswerRequest request, String ownerIdentity) { String answerText = request == null ? "" : clean(request.answerText()); InterviewSession session = sessions.get(request == null ? null : request.sessionId()); + requireSessionTenant(session, tenantId()); if (!isBlank(ownerIdentity)) { requireSessionOwner(session, ownerIdentity); } @@ -97,11 +98,12 @@ public class AihrInterviewService { sessionKey, key -> { CandidateProfile candidate = resolveCandidate(null, null, null, key); - return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric"); + return new InterviewSession(tenantId(), candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric"); } ); } else { session = sessions.get(sessionKey); + requireSessionTenant(session, tenantId()); requireSessionOwner(session, ownerIdentity); } if (request != null && request.answers() != null) { @@ -258,7 +260,8 @@ public class AihrInterviewService { private boolean candidateExists(String candidateId) { if (candidates.containsKey(candidateId) - || sessions.values().stream().anyMatch(session -> candidateId.equals(session.candidate().id()))) { + || sessions.values().stream().anyMatch(session -> tenantId().equals(session.tenantId()) + && candidateId.equals(session.candidate().id()))) { return true; } try { @@ -625,6 +628,12 @@ public class AihrInterviewService { } } + private static void requireSessionTenant(InterviewSession session, String tenantId) { + if (session != null && !clean(tenantId).equals(clean(session.tenantId()))) { + throw new IllegalArgumentException("面试会话不存在或无权访问"); + } + } + private static void requireKnownQuestion(InterviewSession session, String questionId) { boolean known = session.questions().stream().anyMatch(question -> question.questionId().equals(questionId)); if (!known) { @@ -748,7 +757,8 @@ public class AihrInterviewService { ) { } - private record InterviewSession(CandidateProfile candidate, List questions, Map answers, String source) { + private record InterviewSession(String tenantId, CandidateProfile candidate, List questions, + Map answers, String source) { } private record GeneratedQuestions(List questions, String source) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java index e4a908fe..631d39fd 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java @@ -122,6 +122,10 @@ public class AihrMobileSeedService { return practiceSeedService.canReadPracticeAudio(ossId, identity); } + public boolean canReadPracticeAudioForTenant(Long ossId) { + return practiceSeedService.canReadPracticeAudioForTenant(ossId); + } + public boolean markReviewed(Long id) { return markReviewed(id, null); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index b8afaa4c..68dca31d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -1469,6 +1469,19 @@ public class AihrPracticeSeedService { return sessionParties.stream().anyMatch(party -> owner.equals(party) || inTeamScope(owner, party)); } + public boolean canReadPracticeAudioForTenant(Long ossId) { + ensureAudioTable(); + if (ossId == null) { + return false; + } + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM aihr_practice_audio + WHERE tenant_id = ? AND oss_id = ? + """, Integer.class, tenantId(), ossId); + return count != null && count > 0; + } + @Transactional(rollbackFor = Exception.class) public boolean markReviewed(Long id) { return markReviewed(id, null); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index b6c7d4f6..17a5ec0d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -484,7 +484,7 @@ public class AihrSopSeedService { set embedding_json = null, embedding_model = null, embedding_time = null, update_time = now() where tenant_id = ? """, tenantId()); - deleteQdrantCollection(); + deleteQdrantTenantPoints(); return vectorizeMissing(); } @@ -1048,8 +1048,8 @@ public class AihrSopSeedService { return Optional.ofNullable(jdbcTemplate.queryForObject(""" select o.ext1, coalesce(k.name, '') as category from aihr_knowledge_attach a - join sys_oss o on o.oss_id = a.oss_id - left join aihr_knowledge_info k on k.id = a.knowledge_id + join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id + left join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id where a.tenant_id = ? and a.id = ? limit 1 """, (rs, rowNum) -> insightFromExt(rs.getString("ext1"), firstNonBlank(rs.getString("category"), fallbackCategory)), @@ -1079,7 +1079,7 @@ public class AihrSopSeedService { else 'name-size' end as match_type from aihr_knowledge_attach a - join sys_oss o on o.oss_id = a.oss_id + join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id where a.tenant_id = ? and ( json_unquote(json_extract(o.ext1, '$.aihrFileSha256')) = ? @@ -1137,7 +1137,7 @@ public class AihrSopSeedService { return; } try { - String ext1 = jdbcTemplate.queryForObject("select ext1 from sys_oss where oss_id = ? limit 1", String.class, ossId); + String ext1 = jdbcTemplate.queryForObject("select ext1 from sys_oss where tenant_id = ? and oss_id = ? limit 1", String.class, tenantId(), ossId); ObjectNode node = objectNode(ext1); node.put("md5", fingerprint.fileMd5()); node.put("aihrFileSha256", fingerprint.fileSha256()); @@ -1148,7 +1148,7 @@ public class AihrSopSeedService { node.put("aihrClassifyReason", insight.reason()); ArrayNode tags = node.putArray("aihrTags"); insight.tags().forEach(tags::add); - jdbcTemplate.update("update sys_oss set ext1 = ? where oss_id = ?", objectMapper.writeValueAsString(node), ossId); + jdbcTemplate.update("update sys_oss set ext1 = ? where tenant_id = ? and oss_id = ?", objectMapper.writeValueAsString(node), tenantId(), ossId); } catch (Exception ignored) { // Metadata is display-only; parsed fragments remain the source of truth. } @@ -1217,7 +1217,7 @@ public class AihrSopSeedService { from aihr_knowledge_attach a join aihr_knowledge_info i on i.id = a.knowledge_id and i.tenant_id = a.tenant_id left join aihr_knowledge_fragment f on f.tenant_id = a.tenant_id and f.knowledge_id = a.knowledge_id and f.doc_id = a.doc_id - left join sys_oss o on o.oss_id = a.oss_id + left join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id where a.tenant_id = ? group by a.id, a.oss_id, i.name, a.name, a.type, a.status, a.update_time, a.remark, o.ext1 order by a.update_time desc, a.id desc @@ -2966,8 +2966,8 @@ public class AihrSopSeedService { Integer dimension = result.path("config").path("params").path("vectors").path("size").isInt() ? result.path("config").path("params").path("vectors").path("size").asInt() : null; - long points = result.path("points_count").asLong(0); - return new QdrantStats(dimension, points, ""); + Long points = qdrantTenantPointCount(); + return new QdrantStats(dimension, points, points == null ? "Qdrant 当前租户点数读取失败" : ""); } catch (Exception e) { return new QdrantStats(null, null, "Qdrant 不可用"); } @@ -3003,20 +3003,38 @@ public class AihrSopSeedService { } } - private void deleteQdrantCollection() { + private void deleteQdrantTenantPoints() { try { - HttpResponse response = qdrantRequest("DELETE", "/collections/" + qdrantCollection(), null); + ObjectNode body = objectMapper.createObjectNode(); + body.set("filter", qdrantFilter(null, null, null)); + HttpResponse response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/delete?wait=true", body); if (response.statusCode() == 404) { return; } if (!ok(response.statusCode())) { - throw new IllegalStateException("qdrant delete collection HTTP " + response.statusCode()); + throw new IllegalStateException("qdrant delete tenant points HTTP " + response.statusCode()); } } catch (Exception ignored) { // ponytail: rebuild still refreshes MySQL embeddings; status shows if Qdrant stayed stale. } } + private Long qdrantTenantPointCount() { + try { + ObjectNode body = objectMapper.createObjectNode(); + body.set("filter", qdrantFilter(null, null, null)); + body.put("exact", true); + HttpResponse response = qdrantRequest("POST", "/collections/" + qdrantCollection() + "/points/count", body); + if (!ok(response.statusCode())) { + return null; + } + JsonNode count = objectMapper.readTree(response.body()).path("result").path("count"); + return count.isNumber() ? count.asLong() : null; + } catch (Exception ignored) { + return null; + } + } + private ObjectNode qdrantFilter(Long knowledgeId, String docId, String category) { ObjectNode filter = objectMapper.createObjectNode(); ArrayNode must = filter.putArray("must"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java index 9c32633b..bfa400bf 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrInterviewServiceTest.java @@ -315,4 +315,19 @@ class AihrInterviewServiceTest { service.answer(new AnswerRequest(ownerSession.sessionId(), "q1", "本人回答", null), "candidate-a"); } + + @Test + @Tag("dev") + void interviewMemorySessionsCarryTenantBoundary() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrInterviewService.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java"); + } + String serviceSource = Files.readString(source); + + assertTrue(serviceSource.contains("new InterviewSession(tenantId(), candidate")); + assertTrue(serviceSource.contains("requireSessionTenant(session, tenantId())")); + assertTrue(serviceSource.contains("tenantId().equals(session.tenantId())")); + assertTrue(serviceSource.contains("private record InterviewSession(String tenantId")); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 1fe6dca3..0db746ea 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -636,6 +636,7 @@ public class AihrPracticeSeedServiceTest { assertTrue(controllerSource.contains("UserType.SYS_USER.getUserType().equals(loginUser.getUserType())")); assertTrue(controllerSource.contains("StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, \"hr_operator\")")); assertTrue(controllerSource.contains("response.sendError(HttpServletResponse.SC_FORBIDDEN, \"无权访问该录音\")")); + assertTrue(controllerSource.contains("mobileSeedService.canReadPracticeAudioForTenant(ossId)")); assertFalse(controllerSource.contains("private static String supervisorScopeExtPartyId()")); assertFalse(controllerSource.contains("supervisor H5 keeps team-scope")); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java index 6d0a44cf..17c5f900 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java @@ -244,6 +244,38 @@ public class AihrSopSeedServiceTest { assertTrue(code.contains("WHERE tenant_id = ? AND id = ? AND query_text = ?")); } + @Test + @Tag("dev") + public void ossMetadataQueriesKeepTenantScope() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + } + String code = Files.readString(source); + + assertTrue(code.contains("join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id")); + assertTrue(code.contains("left join sys_oss o on o.oss_id = a.oss_id and o.tenant_id = a.tenant_id")); + assertTrue(code.contains("left join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id")); + assertTrue(code.contains("where tenant_id = ? and oss_id = ? limit 1")); + assertTrue(code.contains("where tenant_id = ? and oss_id = ?\", objectMapper.writeValueAsString(node), tenantId(), ossId")); + } + + @Test + @Tag("dev") + public void qdrantRebuildKeepsOtherTenantsVectors() throws Exception { + Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + if (!Files.exists(source)) { + source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); + } + String code = Files.readString(source); + + assertTrue(code.contains("deleteQdrantTenantPoints()")); + assertTrue(code.contains("/points/delete?wait=true")); + assertTrue(code.contains("/points/count")); + assertTrue(code.contains("body.set(\"filter\", qdrantFilter(null, null, null))")); + assertFalse(code.contains("qdrantRequest(\"DELETE\", \"/collections/\" + qdrantCollection()")); + } + private static AihrSopSeedService.KnowledgeHit hit(Long fragmentId, String title) { return new AihrSopSeedService.KnowledgeHit(fragmentId, title, "sop", "", "doc-" + fragmentId, "片段内容", 1, 1.0); } diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 750a45de..5bd91896 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -342,3 +342,7 @@ - 2026-07-14 BRD 本轮线上只读复核:生产根站、`/h5/`、`/prod-api/auth/tenant/list` 和 `/prod-api/api/aihr/mobile/home/user` 均返回 `200`;线上仍加载管理端 `assets/index-CJZ3Ax3Z.js` 与 H5 `assets/index-D4-NrEpb.js`。当前本地 `HEAD=8d5cb3d6` 的隐私/组织快照修复尚未重新构建或发布,本轮未执行生产静态同步、后端重启或业务数据写入;线上手机号登录仍会进入“确认岗位”但无法匹配正式组织岗位,P0 组织同步缺口保持未完成。 - 2026-07-14 BRD 5.4 组织快照响应字段复核:`OrgPersonRow` 不包含 `person_phone`,手机号只用于服务端身份映射、过滤和统计;`/api/aihr/org/snapshot` 仍要求 `superadmin/hr_operator`,`store-display-fields=false` 时姓名和部门返回空值。当前未发现新的手机号响应泄露,因此不新增重复脱敏代码;正式组织同步、手机号覆盖率和历史字段治理仍保持 P0 未完成。 - 2026-07-14 BRD 发布预检复核:在隔离干净工作树按当前 `HEAD=8f2ace4a` 重新构建管理端、`mobile-uni` H5 和后端 jar,`release-preflight.sh` 的产物新鲜度、根站和租户业务码检查通过;本地管理端主资源 `index-CX4fynEb.js`(SHA-256 `c800022a22e82e81782da590cfec81145852014a6521f7521775720e0f1087fe`)与 H5 `index-C1WoTt0t.js`(SHA-256 `bfb07609acecde3678ed18bf48ce022f36596c6b946e076436fe242cf3e15675`)均与线上 `index-CJZ3Ax3Z.js` / `index-D4-NrEpb.js` 不一致,预检按预期停止;本轮未执行生产静态同步、后端重启或业务数据写入。 +- 2026-07-14 BRD G3 OSS 元数据租户隔离修复:SOP 文档去重、处理概览和文件解析元数据此前通过 `JdbcTemplate` 仅按 `sys_oss.oss_id` 读取/更新,未显式约束 `sys_oss.tenant_id`;现统一要求 `sys_oss.tenant_id = aihr_knowledge_attach.tenant_id` 或当前租户,避免跨租户 OSS 元数据被误读/覆盖。新增 `AihrSopSeedServiceTest` 契约断言与 `demo-check` marker;未修改业务数据或生产环境。 +- 2026-07-14 BRD G3 Qdrant 共享 collection 隔离修复:重建单租户向量索引此前会删除整个共享 Qdrant collection,并用全局点数与当前租户 MySQL 数量比较;现改为按当前租户 payload filter 删除和统计点数,保留其他租户向量。新增回归契约与 `demo-check` marker;未修改业务数据或生产环境。 +- 2026-07-14 BRD G3 AI 面试内存会话租户隔离修复:候选人面试进行中的内存会话此前只按 `sessionId` 全局缓存,系统用户在已知会话 ID 时可能把其他租户会话带入当前租户完成并落库,候选人存在性判断也可能跨租户命中;现为内存会话保存创建租户,在回答、完成和候选人存在性判断时校验当前租户,并补源码回归断言与 `demo-check` marker;未修改业务数据或生产环境。 +- 2026-07-14 BRD G3 录音资源租户归属显式校验:系统用户读取训练录音此前仅检查 `superadmin/hr_operator` 角色,随后依赖通用 OSS 服务的隐式租户过滤;现额外要求 `aihr_practice_audio` 在当前租户存在对应 `oss_id`,再进入通用下载服务,避免跨租户对象 ID 被误读。新增服务路径、控制器回归断言与 `demo-check` marker;未修改业务数据或生产环境。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 38caa6ac..bfa217b4 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -625,6 +625,14 @@ 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/AihrSopSeedService.java "o.tenant_id = a.tenant_id" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "where tenant_id = ? and oss_id = ? limit 1" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "deleteQdrantTenantPoints()" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "/points/count" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "requireSessionTenant(session, tenantId())" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "private record InterviewSession(String tenantId" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "canReadPracticeAudioForTenant" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "mobileSeedService.canReadPracticeAudioForTenant(ossId)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "oss.getOssId(), \"\")" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "asr audio upload failed(处理错误已隐藏)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "visionAllowed"