From 5c893e832e21e1f4fedac0f7a9a5406d80c35610 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 06:40:54 +0800 Subject: [PATCH] fix(aihr): scope sop data to tenant --- .../aihr/service/AihrSopSeedService.java | 105 +++++++++--------- docs/BRD_IMPLEMENTATION_AUDIT.md | 1 + 2 files changed, 56 insertions(+), 50 deletions(-) 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 84f015dc..1cb379b8 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 @@ -37,6 +37,7 @@ import org.dromara.aihr.domain.AihrSopDto.UploadResponse; import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse; import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse; import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.satoken.utils.LoginHelper; import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.AutoDetectParser; @@ -205,7 +206,7 @@ public class AihrSopSeedService { firstNonBlank(request == null ? null : request.note(), ""), Timestamp.valueOf(java.time.LocalDateTime.now()), Timestamp.valueOf(java.time.LocalDateTime.now()), - TENANT_ID, + tenantId(), id ); return updated == 0 ? null : sopReview(id); @@ -244,7 +245,7 @@ public class AihrSopSeedService { review_status = VALUES(review_status), update_time = VALUES(update_time) """, - TENANT_ID, + tenantId(), truncate(queryText, 1000), queryNorm, fragmentId, @@ -285,7 +286,7 @@ public class AihrSopSeedService { rs.getString("review_status"), formatTimestamp(rs.getTimestamp("create_time")), formatTimestamp(rs.getTimestamp("update_time")) - ), TENANT_ID, reviewStatus, reviewStatus, limitValue); + ), tenantId(), reviewStatus, reviewStatus, limitValue); } public AnswerFeedbackReviewResponse reviewAnswerFeedback(Long id) { @@ -299,7 +300,7 @@ public class AihrSopSeedService { WHERE tenant_id = ? AND id = ? """, Timestamp.valueOf(java.time.LocalDateTime.now()), - TENANT_ID, + tenantId(), id ); return updated == 0 ? null : new AnswerFeedbackReviewResponse(id, "已复核"); @@ -397,7 +398,7 @@ public class AihrSopSeedService { rs.getLong("knowledge_id"), rs.getString("category"), rs.getString("doc_id") - ), TENANT_ID); + ), tenantId()); int documents = 0; int fragments = 0; @@ -408,7 +409,7 @@ public class AihrSopSeedService { from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ? order by idx asc - """, String.class, TENANT_ID, target.knowledgeId(), target.docId()); + """, String.class, tenantId(), target.knowledgeId(), target.docId()); EmbeddingBatch batch = embedFragments(target.knowledgeId(), target.category(), target.docId(), rows); if (batch.count() > 0) { documents++; @@ -452,7 +453,7 @@ public class AihrSopSeedService { update aihr_knowledge_fragment set embedding_json = null, embedding_model = null, embedding_time = null, update_time = now() where tenant_id = ? - """, TENANT_ID); + """, tenantId()); deleteQdrantCollection(); return vectorizeMissing(); } @@ -492,7 +493,7 @@ public class AihrSopSeedService { rs.getTimestamp("create_time"), rs.getTimestamp("update_time"), rs.getString("remark") - ), TENANT_ID); + ), tenantId()); } catch (DataAccessException e) { return List.of(); } @@ -506,7 +507,7 @@ public class AihrSopSeedService { update aihr_knowledge_import_task set status = 4, current_file = '', finished_time = now(), update_time = now(), remark = '用户已取消' where tenant_id = ? and id = ? and status = 1 - """, TENANT_ID, taskId); + """, tenantId(), taskId); return localImportTask(taskId).orElseThrow(() -> new IllegalArgumentException("导入任务不存在")); } @@ -605,7 +606,7 @@ public class AihrSopSeedService { (tenant_id, directory, category, status, total_files, imported_files, failed_files, current_file, started_time, create_time, update_time, remark) values (?, ?, ?, 1, ?, 0, 0, '', now(), now(), now(), '任务已启动') """, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, TENANT_ID); + ps.setString(1, tenantId()); ps.setString(2, plan.directory()); ps.setString(3, plan.category()); ps.setInt(4, plan.files().size()); @@ -633,7 +634,7 @@ public class AihrSopSeedService { rs.getTimestamp("create_time"), rs.getTimestamp("update_time"), rs.getString("remark") - ), TENANT_ID, taskId); + ), tenantId(), taskId); return rows.stream().findFirst(); } @@ -642,7 +643,7 @@ public class AihrSopSeedService { update aihr_knowledge_import_task set status = ?, imported_files = ?, failed_files = ?, current_file = ?, update_time = now(), remark = ? where tenant_id = ? and id = ? and status <> 4 - """, status, imported, failed, currentFile, limitRemark(remark), TENANT_ID, taskId); + """, status, imported, failed, currentFile, limitRemark(remark), tenantId(), taskId); } private void finishLocalImportTask(long taskId, int status, int imported, int failed, String remark) { @@ -650,7 +651,7 @@ public class AihrSopSeedService { update aihr_knowledge_import_task set status = ?, imported_files = ?, failed_files = ?, current_file = '', finished_time = now(), update_time = now(), remark = ? where tenant_id = ? and id = ? and status <> 4 - """, status, imported, failed, limitRemark(remark), TENANT_ID, taskId); + """, status, imported, failed, limitRemark(remark), tenantId(), taskId); } private boolean isLocalImportTaskCanceled(long taskId) { @@ -659,7 +660,7 @@ public class AihrSopSeedService { select count(1) from aihr_knowledge_import_task where tenant_id = ? and id = ? and status = 4 - """, Integer.class, TENANT_ID, taskId); + """, Integer.class, tenantId(), taskId); return count != null && count > 0; } catch (DataAccessException e) { return false; @@ -740,13 +741,13 @@ public class AihrSopSeedService { } deleteQdrantDoc(config.knowledgeId(), docId); - jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", TENANT_ID, config.knowledgeId(), docId); + jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", tenantId(), config.knowledgeId(), docId); for (int i = 0; i < fragments.size(); i++) { jdbcTemplate.update(""" insert into aihr_knowledge_fragment (tenant_id, knowledge_id, idx, doc_id, content, create_time, update_time, remark) values (?, ?, ?, ?, ?, now(), now(), ?) - """, TENANT_ID, config.knowledgeId(), i + 1, docId, fragments.get(i), "upload:" + fileName); + """, tenantId(), config.knowledgeId(), i + 1, docId, fragments.get(i), "upload:" + fileName); } embedFragments(config.knowledgeId(), knowledgeName, docId, fragments); updateOssInsight(ossId, insight, fingerprint); @@ -883,7 +884,7 @@ public class AihrSopSeedService { where tenant_id = ? order by update_time desc, id desc limit 50 - """, (rs, rowNum) -> new CategoryOption(rs.getString("name"), rs.getString("description")), TENANT_ID); + """, (rs, rowNum) -> new CategoryOption(rs.getString("name"), rs.getString("description")), tenantId()); } catch (DataAccessException e) { return List.of(); } @@ -908,7 +909,7 @@ public class AihrSopSeedService { rs.getString("model_name"), rs.getString("resolved_api_host"), rs.getString("resolved_api_key") - ), TENANT_ID); + ), tenantId()); return rows.stream() .filter(runtime -> !isBlank(runtime.modelName()) && !isBlank(runtime.baseUrl())) .findFirst(); @@ -938,7 +939,7 @@ public class AihrSopSeedService { rs.getString("model_name"), rs.getString("resolved_api_host"), rs.getString("resolved_api_key") - ), TENANT_ID); + ), tenantId()); return rows.stream() .filter(runtime -> !isBlank(runtime.modelName()) && !isBlank(runtime.baseUrl())) .findFirst(); @@ -1015,7 +1016,7 @@ public class AihrSopSeedService { where a.tenant_id = ? and a.id = ? limit 1 """, (rs, rowNum) -> insightFromExt(rs.getString("ext1"), firstNonBlank(rs.getString("category"), fallbackCategory)), - TENANT_ID, hit.attachId())); + tenantId(), hit.attachId())); } catch (DataAccessException e) { return Optional.empty(); } @@ -1061,7 +1062,7 @@ public class AihrSopSeedService { rs.getString("doc_id"), rs.getLong("oss_id"), rs.getString("match_type") - ), fingerprint.fileSha256(), fingerprint.textSha256(), TENANT_ID, fingerprint.fileSha256(), fingerprint.textSha256(), fileName, fingerprint.sizeBytes(), fingerprint.fileSha256(), fingerprint.textSha256()); + ), fingerprint.fileSha256(), fingerprint.textSha256(), tenantId(), fingerprint.fileSha256(), fingerprint.textSha256(), fileName, fingerprint.sizeBytes(), fingerprint.fileSha256(), fingerprint.textSha256()); } catch (DataAccessException e) { return List.of(); } @@ -1071,13 +1072,13 @@ public class AihrSopSeedService { String docId = isBlank(hit.docId()) ? UUID.randomUUID().toString().replace("-", "") : hit.docId(); if (hit.knowledgeId() != targetKnowledgeId && !isBlank(hit.docId())) { deleteQdrantDoc(hit.knowledgeId(), hit.docId()); - jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", TENANT_ID, hit.knowledgeId(), hit.docId()); + jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", tenantId(), hit.knowledgeId(), hit.docId()); } jdbcTemplate.update(""" update aihr_knowledge_attach set knowledge_id = ?, oss_id = ?, doc_id = ?, type = ?, status = 1, update_time = now(), remark = 'duplicate:reusing' where tenant_id = ? and id = ? - """, targetKnowledgeId, hit.ossId(), docId, fileType(fileName), TENANT_ID, hit.attachId()); + """, targetKnowledgeId, hit.ossId(), docId, fileType(fileName), tenantId(), hit.attachId()); return docId; } @@ -1088,9 +1089,9 @@ public class AihrSopSeedService { } if (!isBlank(hit.docId())) { deleteQdrantDoc(hit.knowledgeId(), hit.docId()); - jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", TENANT_ID, hit.knowledgeId(), hit.docId()); + jdbcTemplate.update("delete from aihr_knowledge_fragment where tenant_id = ? and knowledge_id = ? and doc_id = ?", tenantId(), hit.knowledgeId(), hit.docId()); } - jdbcTemplate.update("delete from aihr_knowledge_attach where tenant_id = ? and id = ?", TENANT_ID, hit.attachId()); + jdbcTemplate.update("delete from aihr_knowledge_attach where tenant_id = ? and id = ?", tenantId(), hit.attachId()); } } @@ -1203,7 +1204,7 @@ public class AihrSopSeedService { insight.tags(), insight.reason() ); - }, TENANT_ID); + }, tenantId()); } catch (DataAccessException e) { return List.of(); } @@ -1578,7 +1579,7 @@ public class AihrSopSeedService { update aihr_knowledge_fragment set embedding_json = ?, embedding_model = ?, embedding_time = now(), update_time = now() where tenant_id = ? and knowledge_id = ? and doc_id = ? and idx = ? - """, embeddings.get(i), runtime.modelName(), TENANT_ID, knowledgeId, docId, i + 1); + """, embeddings.get(i), runtime.modelName(), tenantId(), knowledgeId, docId, i + 1); } try { upsertQdrant(knowledgeId, category, docId, fragments, embeddings, runtime.modelName()); @@ -1612,7 +1613,7 @@ public class AihrSopSeedService { rs.getString("resolved_api_host"), rs.getString("resolved_api_key"), rs.getInt("model_dimension") - ), TENANT_ID).stream() + ), tenantId()).stream() .filter(runtime -> configured(runtime.baseUrl(), runtime.modelName(), runtime.apiKey())) .toList()); } catch (DataAccessException ignored) { @@ -1722,7 +1723,7 @@ public class AihrSopSeedService { rs.getString("model_name"), rs.getString("resolved_api_host"), rs.getString("resolved_api_key") - ), TENANT_ID); + ), tenantId()); return rows.stream() .filter(runtime -> !isBlank(runtime.modelName()) && !isBlank(runtime.baseUrl()) && !isBlank(runtime.apiKey())) .findFirst(); @@ -1814,7 +1815,7 @@ public class AihrSopSeedService { (tenant_id, query_text, category, position, source, status, create_time) VALUES (?, ?, ?, ?, ?, '待补充', ?) """, - TENANT_ID, + tenantId(), queryText.trim(), firstNonBlank(category, "sop"), firstNonBlank(position, "生活顾问"), @@ -1858,7 +1859,7 @@ public class AihrSopSeedService { (tenant_id, query_text, category, answer_text, reference_text, snippet_count, source, prompt_version, status, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?) """, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, TENANT_ID); + ps.setString(1, tenantId()); ps.setString(2, truncate(response.queryText(), 1000)); ps.setString(3, firstNonBlank(response.category(), "sop")); ps.setString(4, truncate(response.answer(), 2000)); @@ -1893,7 +1894,7 @@ public class AihrSopSeedService { reviewedTime == null ? "" : reviewedTime.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")), firstNonBlank(rs.getString("prompt_version"), "unknown") ); - }, TENANT_ID, id); + }, tenantId(), id); return rows.isEmpty() ? null : rows.get(0); } @@ -1903,7 +1904,7 @@ public class AihrSopSeedService { SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable FROM aihr_sop_answer_review WHERE tenant_id = ? AND status = '已评审' - """, (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), TENANT_ID); + """, (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), tenantId()); } public void prepareSopReviewStats() { @@ -1918,7 +1919,7 @@ public class AihrSopSeedService { WHERE tenant_id = ? AND status = '已评审' AND reviewed_time >= ? AND reviewed_time < ? """, (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), - TENANT_ID, Timestamp.valueOf(startTime), Timestamp.valueOf(endTime)); + tenantId(), Timestamp.valueOf(startTime), Timestamp.valueOf(endTime)); } private void ensureSopReviewTable() { @@ -2105,7 +2106,7 @@ public class AihrSopSeedService { WHERE tenant_id = ? AND query_norm = ? AND verdict = 'down' - """, Long.class, TENANT_ID, queryNorm)); + """, Long.class, tenantId(), queryNorm)); } catch (DataAccessException ignored) { return Set.of(); } @@ -2160,7 +2161,7 @@ public class AihrSopSeedService { rs.getString("content"), rs.getInt("idx"), rs.getDouble("score") - ), queryText, TENANT_ID, category, category, queryText, limit); + ), queryText, tenantId(), category, category, queryText, limit); } private List likeHits(String category, String queryText, int limit) { @@ -2194,7 +2195,7 @@ public class AihrSopSeedService { """.formatted(String.join(" + ", scoreParts), String.join(" or ", whereParts)); List args = new ArrayList<>(); terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add); - args.add(TENANT_ID); + args.add(tenantId()); args.add(category); args.add(category); terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add); @@ -2236,7 +2237,7 @@ public class AihrSopSeedService { rs.getString("content"), rs.getInt("idx"), match.score() - ), TENANT_ID, match.docId(), match.idx()); + ), tenantId(), match.docId(), match.idx()); return rows.stream().findFirst(); } @@ -2453,7 +2454,7 @@ public class AihrSopSeedService { firstNonBlank(rs.getString("system_prompt"), SOP_ANSWER_SYSTEM_PROMPT), firstNonBlank(rs.getString("template"), SOP_ANSWER_USER_TEMPLATE), "sop_answer:" + firstNonBlank(rs.getString("version"), "v1") - ), TENANT_ID); + ), tenantId()); if (!rows.isEmpty()) { return rows.get(0); } @@ -2682,7 +2683,7 @@ public class AihrSopSeedService { rs.getLong("id"), Math.max(200, rs.getInt("block_size")), Math.max(0, Math.min(rs.getInt("overlap_char"), 200)) - ), TENANT_ID, category); + ), tenantId(), category); if (!rows.isEmpty()) { return rows.get(0); } @@ -2695,7 +2696,7 @@ public class AihrSopSeedService { text_block_size, enable_hybrid, hybrid_alpha, system_prompt, create_time, update_time, remark) values (?, ?, ?, ?, 120, 5, 0.5, 800, 1, 1.0, ?, now(), now(), 'upload') """, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, TENANT_ID); + ps.setString(1, tenantId()); ps.setString(2, category); ps.setString(3, category + " 上传知识库"); ps.setString(4, "\n\n"); @@ -2712,20 +2713,20 @@ public class AihrSopSeedService { from aihr_knowledge_attach where tenant_id = ? and knowledge_id = ? and name = ? limit 1 - """, String.class, TENANT_ID, knowledgeId, fileName); + """, String.class, tenantId(), knowledgeId, fileName); String docId = rows.isEmpty() || rows.get(0) == null ? UUID.randomUUID().toString().replace("-", "") : rows.get(0); if (rows.isEmpty()) { jdbcTemplate.update(""" insert into aihr_knowledge_attach (tenant_id, knowledge_id, oss_id, doc_id, name, type, status, create_time, update_time, remark) values (?, ?, ?, ?, ?, ?, 1, now(), now(), 'oss-upload:parsing') - """, TENANT_ID, knowledgeId, ossId, docId, fileName, fileType(fileName)); + """, tenantId(), knowledgeId, ossId, docId, fileName, fileType(fileName)); } else { jdbcTemplate.update(""" update aihr_knowledge_attach set oss_id = ?, doc_id = ?, type = ?, status = 1, update_time = now(), remark = 'oss-upload:parsing' where tenant_id = ? and knowledge_id = ? and name = ? - """, ossId, docId, fileType(fileName), TENANT_ID, knowledgeId, fileName); + """, ossId, docId, fileType(fileName), tenantId(), knowledgeId, fileName); } return docId; } @@ -2735,7 +2736,7 @@ public class AihrSopSeedService { update aihr_knowledge_attach set status = ?, update_time = now(), remark = ? where tenant_id = ? and knowledge_id = ? and doc_id = ? - """, status, limitRemark(remark), TENANT_ID, knowledgeId, docId); + """, status, limitRemark(remark), tenantId(), knowledgeId, docId); } private void upsertQdrant(long knowledgeId, String category, String docId, List fragments, List embeddings, String modelName) throws Exception { @@ -2756,7 +2757,7 @@ public class AihrSopSeedService { continue; } ObjectNode payload = objectMapper.createObjectNode(); - payload.put("tenant_id", TENANT_ID); + payload.put("tenant_id", tenantId()); payload.put("knowledge_id", knowledgeId); payload.put("category", category); payload.put("doc_id", docId); @@ -2849,7 +2850,7 @@ public class AihrSopSeedService { model, dimension ); - }, TENANT_ID); + }, tenantId()); } catch (DataAccessException e) { return new VectorDbStats(0, 0, "", null); } @@ -2922,7 +2923,7 @@ public class AihrSopSeedService { private ObjectNode qdrantFilter(Long knowledgeId, String docId, String category) { ObjectNode filter = objectMapper.createObjectNode(); ArrayNode must = filter.putArray("must"); - must.add(qdrantMatch("tenant_id", TENANT_ID)); + must.add(qdrantMatch("tenant_id", tenantId())); if (knowledgeId != null) { must.add(qdrantMatch("knowledge_id", knowledgeId)); } @@ -3243,6 +3244,10 @@ public class AihrSopSeedService { return value.substring(0, maxLength); } + private String tenantId() { + return firstNonBlank(LoginHelper.getTenantId(), TENANT_ID); + } + private static String normalizeBaseUrl(String baseUrl) { String normalized = baseUrl == null ? "" : baseUrl.trim(); while (normalized.endsWith("/")) { @@ -3263,8 +3268,8 @@ public class AihrSopSeedService { return firstNonBlank(System.getProperty("aihr.qdrant.apiKey"), System.getenv("AIHR_QDRANT_API_KEY"), ""); } - private static String qdrantPointId(long knowledgeId, String docId, int idx) { - return UUID.nameUUIDFromBytes((TENANT_ID + ":" + knowledgeId + ":" + docId + ":" + idx).getBytes(StandardCharsets.UTF_8)).toString(); + private String qdrantPointId(long knowledgeId, String docId, int idx) { + return UUID.nameUUIDFromBytes((tenantId() + ":" + knowledgeId + ":" + docId + ":" + idx).getBytes(StandardCharsets.UTF_8)).toString(); } private static boolean ok(int statusCode) { diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index dce55a97..812c4caa 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -94,3 +94,4 @@ - 2026-07-14 门禁合同复核:`scripts/tests/demo-check-pilot-gates.test.sh` 通过,确认严格预检覆盖批次日期、唯一在职身份、1–2 个项目、每人至少 10 次训练、80% 完训、20 条校准、AI/人工分档一致率、SOP 可用率、满意度和音频/RAG schema;`scripts/tests/pilot-export-contract.test.mjs` 5/5 通过,AIHR 案例/对练定向测试 31/31 通过,`mobile-uni` 类型检查与 49 个单测通过。当前没有新增可安全实现的门禁缺口,剩余阻塞仍是正式组织映射、正式试点数据、视频样片和方言样本等外部输入。 - 2026-07-14 租户边界复核:AI 面试结果、候选人入职主体关联、候选人补充资料、模型配置和案例记录的查询、写入、组织身份解析均改为使用当前登录租户;无登录上下文时仅保留本地开发默认租户 `000000` 回退。该修复已覆盖案例服务的列表、详情、项目范围、贡献者校验、保存和持久化回读路径,仍需在正式多租户环境做隔离回归。 - 2026-07-14 仪表盘租户边界复核:管理端首页的面试、训练、知识库、案例、候选资料、组织和模型统计改为按当前登录租户查询;组织同步写入和组织快照查询也改为当前登录租户;无登录上下文时保留本地开发默认租户回退。尚未把批量知识处理队列和完整 SOP 服务的全部租户调用统一替换,后续继续按数据敏感度分批审查。 +- 2026-07-14 SOP/RAG 租户边界复核:知识库搜索、知识缺口、SOP 问答评审、答案反馈、异步导入任务、文档/片段/向量写入及 Qdrant tenant filter 改为当前登录租户;无登录上下文时回退本地开发租户 `000000`。定向 AIHR 测试 48/48 通过;正式多租户检索隔离仍需在测试环境做跨租户回归,批量上传队列仍待单独审查。