fix(aihr): harden tenant resource boundaries
This commit is contained in:
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+14
-4
@@ -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<QuestionResponse> questions, Map<String, String> answers, String source) {
|
||||
private record InterviewSession(String tenantId, CandidateProfile candidate, List<QuestionResponse> questions,
|
||||
Map<String, String> answers, String source) {
|
||||
}
|
||||
|
||||
private record GeneratedQuestions(List<QuestionResponse> questions, String source) {
|
||||
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
|
||||
+30
-12
@@ -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<String> response = qdrantRequest("DELETE", "/collections/" + qdrantCollection(), null);
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.set("filter", qdrantFilter(null, null, null));
|
||||
HttpResponse<String> 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<String> 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");
|
||||
|
||||
+15
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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"));
|
||||
}
|
||||
|
||||
+32
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user