fix(aihr): harden tenant resource boundaries
This commit is contained in:
+2
-1
@@ -159,7 +159,8 @@ public class AihrMobileController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (UserType.SYS_USER.getUserType().equals(loginUser.getUserType())
|
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, "无权访问该录音");
|
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该录音");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -59,7 +59,7 @@ public class AihrInterviewService {
|
|||||||
);
|
);
|
||||||
GeneratedQuestions generated = generateQuestions(candidate).orElseGet(() -> new GeneratedQuestions(candidate.questions(), "local-rubric"));
|
GeneratedQuestions generated = generateQuestions(candidate).orElseGet(() -> new GeneratedQuestions(candidate.questions(), "local-rubric"));
|
||||||
String sessionId = "iv-" + UUID.randomUUID();
|
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());
|
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) {
|
public AnswerResponse answer(AnswerRequest request, String ownerIdentity) {
|
||||||
String answerText = request == null ? "" : clean(request.answerText());
|
String answerText = request == null ? "" : clean(request.answerText());
|
||||||
InterviewSession session = sessions.get(request == null ? null : request.sessionId());
|
InterviewSession session = sessions.get(request == null ? null : request.sessionId());
|
||||||
|
requireSessionTenant(session, tenantId());
|
||||||
if (!isBlank(ownerIdentity)) {
|
if (!isBlank(ownerIdentity)) {
|
||||||
requireSessionOwner(session, ownerIdentity);
|
requireSessionOwner(session, ownerIdentity);
|
||||||
}
|
}
|
||||||
@@ -97,11 +98,12 @@ public class AihrInterviewService {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
key -> {
|
key -> {
|
||||||
CandidateProfile candidate = resolveCandidate(null, null, null, 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 {
|
} else {
|
||||||
session = sessions.get(sessionKey);
|
session = sessions.get(sessionKey);
|
||||||
|
requireSessionTenant(session, tenantId());
|
||||||
requireSessionOwner(session, ownerIdentity);
|
requireSessionOwner(session, ownerIdentity);
|
||||||
}
|
}
|
||||||
if (request != null && request.answers() != null) {
|
if (request != null && request.answers() != null) {
|
||||||
@@ -258,7 +260,8 @@ public class AihrInterviewService {
|
|||||||
|
|
||||||
private boolean candidateExists(String candidateId) {
|
private boolean candidateExists(String candidateId) {
|
||||||
if (candidates.containsKey(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;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
private static void requireKnownQuestion(InterviewSession session, String questionId) {
|
||||||
boolean known = session.questions().stream().anyMatch(question -> question.questionId().equals(questionId));
|
boolean known = session.questions().stream().anyMatch(question -> question.questionId().equals(questionId));
|
||||||
if (!known) {
|
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) {
|
private record GeneratedQuestions(List<QuestionResponse> questions, String source) {
|
||||||
|
|||||||
+4
@@ -122,6 +122,10 @@ public class AihrMobileSeedService {
|
|||||||
return practiceSeedService.canReadPracticeAudio(ossId, identity);
|
return practiceSeedService.canReadPracticeAudio(ossId, identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean canReadPracticeAudioForTenant(Long ossId) {
|
||||||
|
return practiceSeedService.canReadPracticeAudioForTenant(ossId);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean markReviewed(Long id) {
|
public boolean markReviewed(Long id) {
|
||||||
return markReviewed(id, null);
|
return markReviewed(id, null);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -1469,6 +1469,19 @@ public class AihrPracticeSeedService {
|
|||||||
return sessionParties.stream().anyMatch(party -> owner.equals(party) || inTeamScope(owner, party));
|
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)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public boolean markReviewed(Long id) {
|
public boolean markReviewed(Long id) {
|
||||||
return markReviewed(id, null);
|
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()
|
set embedding_json = null, embedding_model = null, embedding_time = null, update_time = now()
|
||||||
where tenant_id = ?
|
where tenant_id = ?
|
||||||
""", tenantId());
|
""", tenantId());
|
||||||
deleteQdrantCollection();
|
deleteQdrantTenantPoints();
|
||||||
return vectorizeMissing();
|
return vectorizeMissing();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,8 +1048,8 @@ public class AihrSopSeedService {
|
|||||||
return Optional.ofNullable(jdbcTemplate.queryForObject("""
|
return Optional.ofNullable(jdbcTemplate.queryForObject("""
|
||||||
select o.ext1, coalesce(k.name, '') as category
|
select o.ext1, coalesce(k.name, '') as category
|
||||||
from aihr_knowledge_attach a
|
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
|
||||||
left join aihr_knowledge_info k on k.id = a.knowledge_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 = ?
|
where a.tenant_id = ? and a.id = ?
|
||||||
limit 1
|
limit 1
|
||||||
""", (rs, rowNum) -> insightFromExt(rs.getString("ext1"), firstNonBlank(rs.getString("category"), fallbackCategory)),
|
""", (rs, rowNum) -> insightFromExt(rs.getString("ext1"), firstNonBlank(rs.getString("category"), fallbackCategory)),
|
||||||
@@ -1079,7 +1079,7 @@ public class AihrSopSeedService {
|
|||||||
else 'name-size'
|
else 'name-size'
|
||||||
end as match_type
|
end as match_type
|
||||||
from aihr_knowledge_attach a
|
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 = ?
|
where a.tenant_id = ?
|
||||||
and (
|
and (
|
||||||
json_unquote(json_extract(o.ext1, '$.aihrFileSha256')) = ?
|
json_unquote(json_extract(o.ext1, '$.aihrFileSha256')) = ?
|
||||||
@@ -1137,7 +1137,7 @@ public class AihrSopSeedService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
ObjectNode node = objectNode(ext1);
|
||||||
node.put("md5", fingerprint.fileMd5());
|
node.put("md5", fingerprint.fileMd5());
|
||||||
node.put("aihrFileSha256", fingerprint.fileSha256());
|
node.put("aihrFileSha256", fingerprint.fileSha256());
|
||||||
@@ -1148,7 +1148,7 @@ public class AihrSopSeedService {
|
|||||||
node.put("aihrClassifyReason", insight.reason());
|
node.put("aihrClassifyReason", insight.reason());
|
||||||
ArrayNode tags = node.putArray("aihrTags");
|
ArrayNode tags = node.putArray("aihrTags");
|
||||||
insight.tags().forEach(tags::add);
|
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) {
|
} catch (Exception ignored) {
|
||||||
// Metadata is display-only; parsed fragments remain the source of truth.
|
// Metadata is display-only; parsed fragments remain the source of truth.
|
||||||
}
|
}
|
||||||
@@ -1217,7 +1217,7 @@ public class AihrSopSeedService {
|
|||||||
from aihr_knowledge_attach a
|
from aihr_knowledge_attach a
|
||||||
join aihr_knowledge_info i on i.id = a.knowledge_id and i.tenant_id = a.tenant_id
|
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 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 = ?
|
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
|
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
|
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()
|
Integer dimension = result.path("config").path("params").path("vectors").path("size").isInt()
|
||||||
? result.path("config").path("params").path("vectors").path("size").asInt()
|
? result.path("config").path("params").path("vectors").path("size").asInt()
|
||||||
: null;
|
: null;
|
||||||
long points = result.path("points_count").asLong(0);
|
Long points = qdrantTenantPointCount();
|
||||||
return new QdrantStats(dimension, points, "");
|
return new QdrantStats(dimension, points, points == null ? "Qdrant 当前租户点数读取失败" : "");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return new QdrantStats(null, null, "Qdrant 不可用");
|
return new QdrantStats(null, null, "Qdrant 不可用");
|
||||||
}
|
}
|
||||||
@@ -3003,20 +3003,38 @@ public class AihrSopSeedService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void deleteQdrantCollection() {
|
private void deleteQdrantTenantPoints() {
|
||||||
try {
|
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) {
|
if (response.statusCode() == 404) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ok(response.statusCode())) {
|
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) {
|
} catch (Exception ignored) {
|
||||||
// ponytail: rebuild still refreshes MySQL embeddings; status shows if Qdrant stayed stale.
|
// 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) {
|
private ObjectNode qdrantFilter(Long knowledgeId, String docId, String category) {
|
||||||
ObjectNode filter = objectMapper.createObjectNode();
|
ObjectNode filter = objectMapper.createObjectNode();
|
||||||
ArrayNode must = filter.putArray("must");
|
ArrayNode must = filter.putArray("must");
|
||||||
|
|||||||
+15
@@ -315,4 +315,19 @@ class AihrInterviewServiceTest {
|
|||||||
|
|
||||||
service.answer(new AnswerRequest(ownerSession.sessionId(), "q1", "本人回答", null), "candidate-a");
|
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("UserType.SYS_USER.getUserType().equals(loginUser.getUserType())"));
|
||||||
assertTrue(controllerSource.contains("StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, \"hr_operator\")"));
|
assertTrue(controllerSource.contains("StpUtil.hasRoleOr(TenantConstants.SUPER_ADMIN_ROLE_KEY, \"hr_operator\")"));
|
||||||
assertTrue(controllerSource.contains("response.sendError(HttpServletResponse.SC_FORBIDDEN, \"无权访问该录音\")"));
|
assertTrue(controllerSource.contains("response.sendError(HttpServletResponse.SC_FORBIDDEN, \"无权访问该录音\")"));
|
||||||
|
assertTrue(controllerSource.contains("mobileSeedService.canReadPracticeAudioForTenant(ossId)"));
|
||||||
assertFalse(controllerSource.contains("private static String supervisorScopeExtPartyId()"));
|
assertFalse(controllerSource.contains("private static String supervisorScopeExtPartyId()"));
|
||||||
assertFalse(controllerSource.contains("supervisor H5 keeps team-scope"));
|
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 = ?"));
|
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) {
|
private static AihrSopSeedService.KnowledgeHit hit(Long fragmentId, String title) {
|
||||||
return new AihrSopSeedService.KnowledgeHit(fragmentId, title, "sop", "", "doc-" + fragmentId, "片段内容", 1, 1.0);
|
return new AihrSopSeedService.KnowledgeHit(fragmentId, title, "sop", "", "doc-" + fragmentId, "片段内容", 1, 1.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 本轮线上只读复核:生产根站、`/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 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 发布预检复核:在隔离干净工作树按当前 `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;未修改业务数据或生产环境。
|
||||||
|
|||||||
@@ -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 "仅支持 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/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 "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/controller/AihrSpeechController.java "asr audio upload failed(处理错误已隐藏)"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "visionAllowed"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "visionAllowed"
|
||||||
|
|||||||
Reference in New Issue
Block a user