diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionService.java index e98e6390..df5b1d0c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionService.java @@ -11,6 +11,7 @@ import org.dromara.aihr.personal.support.PersonalOwner; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.oss.core.OssClient; import org.dromara.common.oss.entity.UploadResult; +import org.dromara.common.oss.enums.AccessPolicyType; import org.dromara.common.oss.factory.OssFactory; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.service.ISysOssService; @@ -60,7 +61,8 @@ public class PersonalIngestionService { PersonalKnowledgeProperties properties, ISysOssService ossService, ObjectMapper objectMapper) { this(jdbcTemplate, spaceService, properties, ossService, objectMapper, - new DefaultPersonalObjectStore(jdbcTemplate), IdWorker::getId); + new DefaultPersonalObjectStore(jdbcTemplate, properties, PersonalIngestionService::ossClient), + IdWorker::getId); } private PersonalIngestionService(JdbcTemplate jdbcTemplate, PersonalSpaceService spaceService, @@ -84,6 +86,12 @@ public class PersonalIngestionService { objectStore, itemIdSupplier); } + public static PersonalObjectStore objectStoreForTest(JdbcTemplate jdbcTemplate, + PersonalKnowledgeProperties properties, + OssClientProvider clientProvider) { + return new DefaultPersonalObjectStore(jdbcTemplate, properties, clientProvider); + } + @Transactional public ItemCreatedResponse createText(PersonalOwner owner, TextItemRequest request) { validateOwner(owner); @@ -355,17 +363,28 @@ public class PersonalIngestionService { void deletePhysical(SysOssVo uploaded); } + @FunctionalInterface + public interface OssClientProvider { + OssClient get(String configKey); + } + private static final class DefaultPersonalObjectStore implements PersonalObjectStore { private final JdbcTemplate jdbcTemplate; + private final PersonalKnowledgeProperties properties; + private final OssClientProvider clientProvider; - private DefaultPersonalObjectStore(JdbcTemplate jdbcTemplate) { + private DefaultPersonalObjectStore(JdbcTemplate jdbcTemplate, PersonalKnowledgeProperties properties, + OssClientProvider clientProvider) { this.jdbcTemplate = jdbcTemplate; + this.properties = properties; + this.clientProvider = clientProvider; } @Override public SysOssVo upload(PersonalOwner owner, long itemId, String objectKey, String suffix, String mimeType, byte[] bytes) { - OssClient storage = OssFactory.instance(); + OssClient storage = clientProvider.get(normalizedConfigKey(properties.getOssConfigKey())); + requirePrivate(storage); UploadResult result = storage.upload( new ByteArrayInputStream(bytes), objectKey, (long) bytes.length, mimeType); long ossId = IdWorker.getId(); @@ -406,4 +425,20 @@ public class PersonalIngestionService { } } } + + private static OssClient ossClient(String configKey) { + return configKey == null || configKey.isBlank() + ? OssFactory.instance() + : OssFactory.instance(configKey); + } + + private static String normalizedConfigKey(String value) { + return value == null ? "" : value.trim(); + } + + private static void requirePrivate(OssClient storage) { + if (storage == null || storage.getAccessPolicy() != AccessPolicyType.PRIVATE) { + throw new ServiceException("PERSONAL_OSS_NOT_PRIVATE"); + } + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java index b7ec3666..1ed9328e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java @@ -4,7 +4,9 @@ import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser; import org.dromara.aihr.knowledge.parse.ParsedDocument; import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; +import org.dromara.common.core.exception.ServiceException; import org.dromara.common.oss.core.OssClient; +import org.dromara.common.oss.enums.AccessPolicyType; import org.dromara.common.oss.factory.OssFactory; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.service.ISysOssService; @@ -39,7 +41,7 @@ public class PersonalIngestionWorker { KnowledgeDocumentParser parser, PersonalKnowledgeProperties properties, PlatformTransactionManager transactionManager) { this(jdbcTemplate, parser, new TransactionTemplate(transactionManager), - defaultReader(ossService), configuredMaxBytes(properties)); + defaultReader(ossService, PersonalIngestionWorker::ossClient), configuredMaxBytes(properties)); } private PersonalIngestionWorker(JdbcTemplate jdbcTemplate, KnowledgeDocumentParser parser, @@ -60,6 +62,11 @@ public class PersonalIngestionWorker { 20L * 1024 * 1024); } + public static StoredObjectReader objectReaderForTest(ISysOssService ossService, + OssClientProvider clientProvider) { + return defaultReader(ossService, clientProvider); + } + @Scheduled(fixedDelayString = "${aihr.personal.ingestion-delay-ms:2000}") public void poll() { processNext(); @@ -156,7 +163,7 @@ public class PersonalIngestionWorker { } } - private static StoredObjectReader defaultReader(ISysOssService ossService) { + private static StoredObjectReader defaultReader(ISysOssService ossService, OssClientProvider clientProvider) { return (ossId, expectedPrefix, ownerUserId, maxBytes) -> { SysOssVo object = ossService.getById(ossId); if (object == null || object.getFileName() == null || object.getFileName().isBlank() @@ -165,7 +172,10 @@ public class PersonalIngestionWorker { || !object.getFileName().startsWith(expectedPrefix)) { throw new IOException("personal source object is unavailable"); } - OssClient storage = OssFactory.instance(object.getService()); + OssClient storage = clientProvider.get(object.getService()); + if (storage == null || storage.getAccessPolicy() != AccessPolicyType.PRIVATE) { + throw new ServiceException("PERSONAL_OSS_NOT_PRIVATE"); + } try (InputStream input = storage.getObjectContent(object.getFileName())) { int boundedLimit = (int) Math.min(Integer.MAX_VALUE - 1L, maxBytes); byte[] bytes = input.readNBytes(boundedLimit + 1); @@ -210,6 +220,10 @@ public class PersonalIngestionWorker { case INVALID -> new Failure("PERSONAL_PARSE_INVALID", "资料解析失败,请检查文件后重试"); }; } + if (candidate instanceof ServiceException serviceError + && "PERSONAL_OSS_NOT_PRIVATE".equals(serviceError.getMessage())) { + return new Failure("PERSONAL_OSS_NOT_PRIVATE", "个人资料存储策略不可用"); + } candidate = candidate.getCause(); } return new Failure("PERSONAL_PARSE_FAILED", "资料处理失败,请稍后重试"); @@ -258,6 +272,11 @@ public class PersonalIngestionWorker { StoredObject read(long ossId, String expectedPrefix, long ownerUserId, long maxBytes) throws Exception; } + @FunctionalInterface + public interface OssClientProvider { + OssClient get(String configKey); + } + public record StoredObject(String fileName, byte[] bytes) { public StoredObject { bytes = bytes == null ? new byte[0] : bytes.clone(); @@ -275,4 +294,10 @@ public class PersonalIngestionWorker { private record Failure(String code, String message) { } + + private static OssClient ossClient(String configKey) { + return configKey == null || configKey.isBlank() + ? OssFactory.instance() + : OssFactory.instance(configKey); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java index 9668644b..59ba4a7e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java @@ -15,4 +15,6 @@ public class PersonalKnowledgeProperties { private int maxItems = 1000; private int downloadUrlMinutes = 5; private String qdrantCollection = "aihr_personal_knowledge"; + /** Optional sys_oss_config key. Blank selects the system default client. */ + private String ossConfigKey = ""; } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java index b89de717..ab8bd1d9 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java @@ -10,6 +10,9 @@ import org.dromara.aihr.personal.service.PersonalSpaceService; import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; import org.dromara.aihr.personal.support.PersonalOwner; import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.oss.core.OssClient; +import org.dromara.common.oss.entity.UploadResult; +import org.dromara.common.oss.enums.AccessPolicyType; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.service.ISysOssService; import org.junit.jupiter.api.Tag; @@ -249,6 +252,59 @@ class PersonalIngestionServiceTest { verifyNoInteractions(fixture.spaces, fixture.store, fixture.jdbc, fixture.oss); } + @Test + void defaultPublicOssClientIsRejectedBeforeUploadOrMetadataWrite() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + PersonalKnowledgeProperties properties = new PersonalKnowledgeProperties(); + PersonalIngestionService.OssClientProvider clients = + mock(PersonalIngestionService.OssClientProvider.class); + OssClient publicClient = mock(OssClient.class); + when(clients.get("")).thenReturn(publicClient); + when(publicClient.getAccessPolicy()).thenReturn(AccessPolicyType.PUBLIC); + PersonalObjectStore store = PersonalIngestionService.objectStoreForTest(jdbc, properties, clients); + + ServiceException error = assertThrows(ServiceException.class, () -> store.upload( + OWNER, 100L, "personal/000000/101/100/a.txt", "txt", "text/plain", new byte[]{1})); + + assertEquals("PERSONAL_OSS_NOT_PRIVATE", error.getMessage()); + verify(clients).get(""); + verify(publicClient, never()).upload(any(java.io.InputStream.class), anyString(), anyLong(), anyString()); + verifyNoInteractions(jdbc); + } + + @Test + void configuredPrivateOssClientIsSelectedAndWritesSystemMetadata() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + PersonalKnowledgeProperties properties = new PersonalKnowledgeProperties(); + properties.setOssConfigKey(" personal-private "); + PersonalIngestionService.OssClientProvider clients = + mock(PersonalIngestionService.OssClientProvider.class); + OssClient privateClient = mock(OssClient.class); + when(clients.get("personal-private")).thenReturn(privateClient); + when(privateClient.getAccessPolicy()).thenReturn(AccessPolicyType.PRIVATE); + when(privateClient.getConfigKey()).thenReturn("personal-private"); + when(privateClient.upload(any(java.io.InputStream.class), anyString(), anyLong(), eq("text/plain"))) + .thenReturn(UploadResult.builder() + .filename("personal/000000/101/100/a.txt") + .url("https://private.invalid/personal/000000/101/100/a.txt") + .build()); + when(jdbc.update(contains("insert into sys_oss"), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(1); + PersonalObjectStore store = PersonalIngestionService.objectStoreForTest(jdbc, properties, clients); + + SysOssVo uploaded = store.upload( + OWNER, 100L, "personal/000000/101/100/a.txt", "txt", "text/plain", new byte[]{1}); + + assertEquals("personal-private", uploaded.getService()); + verify(clients).get("personal-private"); + verify(privateClient).upload(any(java.io.InputStream.class), + eq("personal/000000/101/100/a.txt"), eq(1L), eq("text/plain")); + verify(jdbc).update(contains("insert into sys_oss"), any(), eq("000000"), + eq("personal/000000/101/100/a.txt"), eq("a.txt"), eq(".txt"), + eq("https://private.invalid/personal/000000/101/100/a.txt"), eq(101L), eq(101L), + eq("personal-private")); + } + private static Fixture fixture(long itemId) { return fixture(() -> itemId); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionWorkerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionWorkerTest.java index fc106737..b1d5df31 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionWorkerTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionWorkerTest.java @@ -4,6 +4,9 @@ import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser; import org.dromara.aihr.knowledge.parse.ParsedDocument; import org.dromara.aihr.personal.service.PersonalIngestionWorker; import org.dromara.system.service.ISysOssService; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.common.oss.core.OssClient; +import org.dromara.common.oss.enums.AccessPolicyType; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.BatchPreparedStatementSetter; @@ -94,6 +97,34 @@ class PersonalIngestionWorkerTest { eq("资料解析失败,请检查文件后重试"), eq("000000"), eq(101L), eq(9L)); } + @Test + void workerRejectsPublicPolicyBeforeReadingObjectContent() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + ISysOssService ossService = mock(ISysOssService.class); + OssClient publicClient = mock(OssClient.class); + SysOssVo object = new SysOssVo(); + object.setOssId(81L); + object.setFileName("personal/000000/101/9/a.txt"); + object.setService("public-client"); + object.setCreateBy(101L); + when(ossService.getById(81L)).thenReturn(object); + when(publicClient.getAccessPolicy()).thenReturn(AccessPolicyType.PUBLIC); + PersonalIngestionWorker.OssClientProvider clients = + mock(PersonalIngestionWorker.OssClientProvider.class); + when(clients.get("public-client")).thenReturn(publicClient); + when(jdbc.queryForList(contains("status = 'QUEUED'"))).thenReturn(List.of(item())); + when(jdbc.update(contains("status = 'PARSING'"), eq("000000"), eq(101L), eq(9L))).thenReturn(1); + PersonalIngestionWorker worker = PersonalIngestionWorker.forTest( + jdbc, ossService, mock(KnowledgeDocumentParser.class), immediateTransactions(), + PersonalIngestionWorker.objectReaderForTest(ossService, clients)); + + assertTrue(worker.processNext()); + + verify(jdbc).update(contains("status = 'FAILED'"), eq("PERSONAL_OSS_NOT_PRIVATE"), + eq("个人资料存储策略不可用"), eq("000000"), eq(101L), eq(9L)); + verify(publicClient, never()).getObjectContent(any(String.class)); + } + private static Map item() { return Map.of( "id", 9L, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java index b70806b0..58d309ff 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java @@ -46,6 +46,7 @@ class PersonalSpaceServiceTest { assertEquals(1000, properties.getMaxItems()); assertEquals(5, properties.getDownloadUrlMinutes()); assertEquals("aihr_personal_knowledge", properties.getQdrantCollection()); + assertEquals("", properties.getOssConfigKey()); } @Test