diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java new file mode 100644 index 00000000..7cf92845 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java @@ -0,0 +1,83 @@ +package org.dromara.aihr.personal.service; + +import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; +import org.dromara.aihr.personal.support.PersonalOwner; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +public class PersonalSpaceService { + + private static final String QUOTA_EXCEEDED = "PERSONAL_SPACE_QUOTA_EXCEEDED"; + + private final JdbcTemplate jdbcTemplate; + private final PersonalKnowledgeProperties properties; + + public PersonalSpaceService(JdbcTemplate jdbcTemplate, PersonalKnowledgeProperties properties) { + this.jdbcTemplate = jdbcTemplate; + this.properties = properties; + } + + public Map item(PersonalOwner owner, long itemId) { + try { + return jdbcTemplate.queryForMap(""" + select id, source_type, title, original_url, oss_id, mime_type, + size_bytes, status, error_code, error_message, summary, + tags_json, captured_at, parsed_at, create_time + from aihr_personal_item + where tenant_id = ? and owner_user_id = ? and id = ? + and status <> 'DELETED' + """, owner.tenantId(), owner.userId(), itemId); + } catch (EmptyResultDataAccessException ex) { + throw new ServiceException("PERSONAL_ITEM_NOT_FOUND"); + } + } + + @Transactional + public long reserve(PersonalOwner owner, long bytes) { + if (bytes < 0) { + throw new ServiceException(QUOTA_EXCEEDED); + } + + Map space = ensureAndLockSpace(owner); + long used = ((Number) space.get("used_bytes")).longValue(); + long quota = ((Number) space.get("quota_bytes")).longValue(); + int count = ((Number) space.get("item_count")).intValue(); + if (used < 0 || quota < 0 || used > quota || bytes > quota - used + || count >= properties.getMaxItems()) { + throw new ServiceException(QUOTA_EXCEEDED); + } + return ((Number) space.get("id")).longValue(); + } + + private Map ensureAndLockSpace(PersonalOwner owner) { + try { + return lockSpace(owner); + } catch (EmptyResultDataAccessException ex) { + jdbcTemplate.update(""" + insert ignore into aihr_personal_space + (tenant_id, owner_user_id, owner_ext_party_id, quota_bytes) + values (?, ?, ?, ?) + """, owner.tenantId(), owner.userId(), owner.extPartyId(), defaultQuotaBytes()); + return lockSpace(owner); + } + } + + private Map lockSpace(PersonalOwner owner) { + return jdbcTemplate.queryForMap(""" + select id, quota_bytes, used_bytes, item_count + from aihr_personal_space + where tenant_id = ? and owner_user_id = ? + for update + """, owner.tenantId(), owner.userId()); + } + + private long defaultQuotaBytes() { + return Math.multiplyExact(properties.getMaxSpaceMb(), 1024L * 1024L); + } +} 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 new file mode 100644 index 00000000..9668644b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java @@ -0,0 +1,18 @@ +package org.dromara.aihr.personal.support; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "aihr.personal") +public class PersonalKnowledgeProperties { + + private long maxFileSizeMb = 20; + private long maxUrlBodyMb = 10; + private long maxSpaceMb = 500; + private int maxItems = 1000; + private int downloadUrlMinutes = 5; + private String qdrantCollection = "aihr_personal_knowledge"; +} 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 new file mode 100644 index 00000000..21425ed5 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java @@ -0,0 +1,146 @@ +package org.dromara.aihr.personal; + +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.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@Tag("dev") +class PersonalSpaceServiceTest { + + @Test + void propertiesHaveExplicitSafeDefaults() { + PersonalKnowledgeProperties properties = properties(); + + assertEquals(20L, properties.getMaxFileSizeMb()); + assertEquals(10L, properties.getMaxUrlBodyMb()); + assertEquals(500L, properties.getMaxSpaceMb()); + assertEquals(1000, properties.getMaxItems()); + assertEquals(5, properties.getDownloadUrlMinutes()); + assertEquals("aihr_personal_knowledge", properties.getQdrantCollection()); + } + + @Test + void itemLookupAlwaysUsesTenantOwnerAndItemId() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForMap(anyString(), eq("000000"), eq(101L), eq(9001L))) + .thenReturn(Map.of("id", 9001L, "title", "A 的资料")); + when(jdbc.queryForMap(anyString(), eq("000000"), eq(202L), eq(9001L))) + .thenReturn(Map.of("id", 9001L, "title", "B 的资料")); + PersonalSpaceService service = new PersonalSpaceService(jdbc, properties()); + + assertEquals("A 的资料", service.item(new PersonalOwner("000000", 101L, null), 9001L).get("title")); + assertEquals("B 的资料", service.item(new PersonalOwner("000000", 202L, null), 9001L).get("title")); + + verify(jdbc).queryForMap( + contains("tenant_id = ? and owner_user_id = ? and id = ?"), + eq("000000"), eq(101L), eq(9001L)); + verify(jdbc).queryForMap( + contains("tenant_id = ? and owner_user_id = ? and id = ?"), + eq("000000"), eq(202L), eq(9001L)); + } + + @Test + void missingOrForeignItemUsesNonDisclosingNotFoundError() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForMap(anyString(), eq("000000"), eq(202L), eq(9001L))) + .thenThrow(new EmptyResultDataAccessException(1)); + PersonalSpaceService service = new PersonalSpaceService(jdbc, properties()); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.item(new PersonalOwner("000000", 202L, null), 9001L)); + + assertEquals("PERSONAL_ITEM_NOT_FOUND", error.getMessage()); + verify(jdbc).queryForMap( + contains("tenant_id = ? and owner_user_id = ? and id = ?"), + eq("000000"), eq(202L), eq(9001L)); + } + + @Test + void reserveCreatesMissingOwnerSpaceWithConfiguredQuotaThenLocksIt() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForMap(contains("from aihr_personal_space"), eq("000000"), eq(101L))) + .thenThrow(new EmptyResultDataAccessException(1)) + .thenReturn(space(7L, 500L * 1024 * 1024, 0L, 0)); + PersonalSpaceService service = new PersonalSpaceService(jdbc, properties()); + + assertEquals(7L, service.reserve(new PersonalOwner("000000", 101L, "ext-101"), 1024L)); + + verify(jdbc).update(contains("insert ignore into aihr_personal_space"), + eq("000000"), eq(101L), eq("ext-101"), eq(500L * 1024 * 1024)); + verify(jdbc, org.mockito.Mockito.times(2)).queryForMap( + contains("for update"), eq("000000"), eq(101L)); + } + + @Test + void reserveRejectsNegativeBytesBeforeTouchingStorage() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + PersonalSpaceService service = new PersonalSpaceService(jdbc, properties()); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.reserve(new PersonalOwner("000000", 101L, null), -1L)); + + assertEquals("PERSONAL_SPACE_QUOTA_EXCEEDED", error.getMessage()); + verifyNoInteractions(jdbc); + } + + @Test + void reserveRejectsQuotaOverflowWithoutMutatingSpace() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForMap(contains("from aihr_personal_space"), eq("000000"), eq(101L))) + .thenReturn(space(7L, Long.MAX_VALUE, Long.MAX_VALUE - 1, 0)); + PersonalSpaceService service = new PersonalSpaceService(jdbc, properties()); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.reserve(new PersonalOwner("000000", 101L, null), 2L)); + + assertEquals("PERSONAL_SPACE_QUOTA_EXCEEDED", error.getMessage()); + verify(jdbc).queryForMap(contains("for update"), eq("000000"), eq(101L)); + verifyNoMoreInteractions(jdbc); + } + + @Test + void reserveRejectsItemLimitForTheCurrentOwner() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForMap(contains("from aihr_personal_space"), eq("000000"), eq(101L))) + .thenReturn(space(7L, 1024L, 0L, 1000)); + PersonalSpaceService service = new PersonalSpaceService(jdbc, properties()); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.reserve(new PersonalOwner("000000", 101L, null), 1L)); + + assertEquals("PERSONAL_SPACE_QUOTA_EXCEEDED", error.getMessage()); + verify(jdbc).queryForMap(contains("for update"), eq("000000"), eq(101L)); + verifyNoMoreInteractions(jdbc); + } + + private static Map space(long id, long quota, long used, int count) { + return Map.of( + "id", id, + "quota_bytes", quota, + "used_bytes", used, + "item_count", count + ); + } + + private static PersonalKnowledgeProperties properties() { + return new PersonalKnowledgeProperties(); + } +}