fix(personal): harden pilot smoke and private storage

This commit is contained in:
2026-07-12 12:57:29 +08:00
parent 60a78fdd6a
commit 784384bf26
10 changed files with 249 additions and 132 deletions
@@ -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");
@@ -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;
@@ -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
@@ -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);
@@ -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 '主键',