feat(personal): add owner-scoped space and item storage

This commit is contained in:
2026-07-12 02:10:41 +08:00
parent ed6bf4eaf9
commit de6d24b4fe
3 changed files with 247 additions and 0 deletions
@@ -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);
}
}
@@ -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";
}
@@ -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();
}
}