fix(personal): harden pilot smoke and private storage
This commit is contained in:
+20
-1
@@ -6,8 +6,9 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.SearchHitResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.SearchScope;
|
||||
import org.dromara.aihr.personal.support.PersonalKnowledgeProperties;
|
||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
@@ -58,6 +59,7 @@ public class PersonalRetrievalService {
|
||||
if (!validated.personalScope()) {
|
||||
return List.of();
|
||||
}
|
||||
requireOwnedReadyItems(owner, validated.itemIds());
|
||||
|
||||
List<SearchHitResponse> fulltext;
|
||||
try {
|
||||
@@ -235,6 +237,23 @@ public class PersonalRetrievalService {
|
||||
args.addAll(itemIds);
|
||||
}
|
||||
|
||||
private void requireOwnedReadyItems(PersonalOwner owner, List<Long> itemIds) {
|
||||
if (itemIds.isEmpty()) return;
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
select count(*) from aihr_personal_item
|
||||
where binary tenant_id = binary ? and owner_user_id = ? and status = 'READY' and id in (
|
||||
""");
|
||||
sql.append("?,".repeat(itemIds.size()));
|
||||
sql.setLength(sql.length() - 1);
|
||||
sql.append(")");
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(owner.tenantId());
|
||||
args.add(owner.userId());
|
||||
args.addAll(itemIds);
|
||||
Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray());
|
||||
if (count == null || count != itemIds.size()) throw new ServiceException("PERSONAL_ITEM_NOT_FOUND");
|
||||
}
|
||||
|
||||
private static void validateDates(LocalDate dateFrom, LocalDate dateTo) {
|
||||
if (dateFrom != null && dateTo != null && dateFrom.isAfter(dateTo)) {
|
||||
throw new IllegalArgumentException("PERSONAL_SEARCH_DATE_INVALID");
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ public class PersonalKnowledgeProperties {
|
||||
private String qdrantApiKey = "";
|
||||
private int qdrantTimeoutSeconds = 3;
|
||||
private int retrievalLimit = 10;
|
||||
/** Optional sys_oss_config key. Blank selects the system default client. */
|
||||
private String ossConfigKey = "";
|
||||
/** Dedicated private sys_oss_config key; never fall back to the shared public bucket. */
|
||||
private String ossConfigKey = "personal-minio";
|
||||
private int chunkSize = 800;
|
||||
private int chunkOverlap = 120;
|
||||
private int parsingLeaseMinutes = 15;
|
||||
|
||||
+6
@@ -52,6 +52,12 @@ import static org.mockito.Mockito.when;
|
||||
@Tag("dev")
|
||||
class PersonalIngestionServiceTest {
|
||||
|
||||
@Test
|
||||
void personalStorageDefaultsToDedicatedPrivateConfigKey() {
|
||||
PersonalKnowledgeProperties properties = new PersonalKnowledgeProperties();
|
||||
assertEquals("personal-minio", properties.getOssConfigKey());
|
||||
}
|
||||
|
||||
private static final PersonalOwner OWNER = new PersonalOwner("000000", 101L, "ext-101");
|
||||
|
||||
@Test
|
||||
|
||||
+46
@@ -7,6 +7,7 @@ import org.dromara.aihr.personal.service.PersonalRetrievalService;
|
||||
import org.dromara.aihr.personal.service.PersonalVectorStore;
|
||||
import org.dromara.aihr.personal.support.PersonalKnowledgeProperties;
|
||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -31,6 +32,7 @@ class PersonalRetrievalServiceTest {
|
||||
@Test
|
||||
void fulltextSqlPreservesOwnerJoinFiltersDatesAndPreparedItemIds() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(2L);
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of());
|
||||
PersonalRetrievalService service = service(jdbc, query -> java.util.Optional.empty(), vectorStore(List.of()));
|
||||
|
||||
@@ -57,6 +59,7 @@ class PersonalRetrievalServiceTest {
|
||||
@Test
|
||||
void excludesPersonalScopeAndRejectsInvalidRequests() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(1L);
|
||||
PersonalRetrievalService service = service(jdbc, query -> java.util.Optional.empty(), vectorStore(List.of()));
|
||||
PersonalOwner owner = new PersonalOwner("t", 1, null);
|
||||
assertTrue(service.search(owner, new PersonalSearchRequest("q", List.of(SearchScope.ENTERPRISE), null, null, null, 10)).isEmpty());
|
||||
@@ -71,6 +74,7 @@ class PersonalRetrievalServiceTest {
|
||||
@Test
|
||||
void vectorHydrationRechecksOwnerAndReadyAndRrfDedupesDeterministically() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(1L);
|
||||
SearchHitResponse lexical = hit("10", "Lexical");
|
||||
SearchHitResponse vector = hit("20", "Vector");
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
|
||||
@@ -110,6 +114,48 @@ class PersonalRetrievalServiceTest {
|
||||
verify(jdbc, times(1)).query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemIdsFailClosedWhenAnyRequestedItemIsNotOwnedAndReady() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(1L);
|
||||
PersonalRetrievalService service = service(jdbc, query -> Optional.empty(), vectorStore(List.of()));
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class, () -> service.search(
|
||||
new PersonalOwner("tenant-a", 7, null),
|
||||
new PersonalSearchRequest("隔离", List.of(SearchScope.PERSONAL), null, null, List.of(10L, 11L), 10)));
|
||||
|
||||
assertEquals("PERSONAL_ITEM_NOT_FOUND", error.getMessage());
|
||||
verify(jdbc, never()).query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameItemIdIsVisibleToOwnerAAndRejectedForOwnerB() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(1L, 0L);
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of());
|
||||
PersonalRetrievalService service = service(jdbc, query -> Optional.empty(), vectorStore(List.of()));
|
||||
PersonalSearchRequest request = new PersonalSearchRequest(
|
||||
"隔离", List.of(SearchScope.PERSONAL), null, null, List.of(10L), 10);
|
||||
|
||||
assertDoesNotThrow(() -> service.search(new PersonalOwner("tenant-a", 7, null), request));
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> service.search(new PersonalOwner("tenant-a", 8, null), request));
|
||||
|
||||
assertEquals("PERSONAL_ITEM_NOT_FOUND", error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithoutItemIdsRemainsOwnerScopedWithoutPreflightLookup() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of());
|
||||
PersonalRetrievalService service = service(jdbc, query -> Optional.empty(), vectorStore(List.of()));
|
||||
|
||||
assertDoesNotThrow(() -> service.search(new PersonalOwner("tenant-a", 7, null),
|
||||
new PersonalSearchRequest("隔离", List.of(SearchScope.PERSONAL), null, null, null, 10)));
|
||||
|
||||
verify(jdbc, never()).queryForObject(anyString(), eq(Long.class), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fulltextFailureStillAllowsScopedVectorHydration() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ class PersonalSpaceServiceTest {
|
||||
assertEquals(1000, properties.getMaxItems());
|
||||
assertEquals(5, properties.getDownloadUrlMinutes());
|
||||
assertEquals("aihr_personal_knowledge", properties.getQdrantCollection());
|
||||
assertEquals("", properties.getOssConfigKey());
|
||||
assertEquals("personal-minio", properties.getOssConfigKey());
|
||||
assertEquals(800, properties.getChunkSize());
|
||||
assertEquals(120, properties.getChunkOverlap());
|
||||
assertEquals(15, properties.getParsingLeaseMinutes());
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
-- 个人 AI 助理独立知识空间。
|
||||
-- 个人知识仅按 tenant_id + owner_user_id 隔离,不修改或复用企业 aihr_knowledge_* 表。
|
||||
|
||||
-- 个人资料禁止使用公开桶;本地默认 MinIO 改为私有访问,下载统一走短时签名 URL。
|
||||
UPDATE `sys_oss_config`
|
||||
SET `access_policy` = '0', `update_time` = CURRENT_TIMESTAMP
|
||||
WHERE `tenant_id` = '000000' AND `config_key` = 'minio';
|
||||
-- 个人资料使用独立私有桶;不得修改企业知识使用的默认 MinIO 配置。
|
||||
INSERT INTO `sys_oss_config`
|
||||
(`oss_config_id`, `tenant_id`, `config_key`, `access_key`, `secret_key`, `bucket_name`, `prefix`,
|
||||
`endpoint`, `domain`, `is_https`, `region`, `access_policy`, `status`, `ext1`, `create_dept`,
|
||||
`create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 9001, source.`tenant_id`, 'personal-minio', source.`access_key`, source.`secret_key`,
|
||||
'ruoyi-personal', 'personal', source.`endpoint`, source.`domain`, source.`is_https`, source.`region`,
|
||||
'0', '1', '', source.`create_dept`, source.`create_by`, CURRENT_TIMESTAMP,
|
||||
source.`update_by`, CURRENT_TIMESTAMP, '个人AI助理专用私有对象存储'
|
||||
FROM `sys_oss_config` source
|
||||
WHERE source.`tenant_id` = '000000' AND source.`config_key` = 'minio'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_oss_config` target
|
||||
WHERE target.`tenant_id` = source.`tenant_id` AND target.`config_key` = 'personal-minio'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_personal_space` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
|
||||
Reference in New Issue
Block a user