feat(personal): add owner-scoped space and item storage
This commit is contained in:
+83
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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";
|
||||
}
|
||||
+146
@@ -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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user