From b559bb315bbf3f5ecfd552f38726e8d91a5b13ac Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 12 Jul 2026 11:41:47 +0800 Subject: [PATCH] feat(personal): expose authenticated API and deletion workflow --- .../PersonalAssistantController.java | 148 ++++++++++ .../service/PersonalCleanupService.java | 227 +++++++++++++++ .../service/PersonalIngestionService.java | 56 +++- .../service/PersonalSpaceService.java | 275 +++++++++++++++++- .../PersonalAssistantControllerTest.java | 69 +++++ .../personal/PersonalCleanupServiceTest.java | 124 ++++++++ .../personal/PersonalSchemaContractTest.java | 7 +- .../personal/PersonalSpaceServiceTest.java | 44 +++ .../sql/aihr_personal_knowledge_mysql8.sql | 16 + 9 files changed, 957 insertions(+), 9 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalCleanupService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAssistantControllerTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalCleanupServiceTest.java diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java new file mode 100644 index 00000000..da6b2aa4 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java @@ -0,0 +1,148 @@ +package org.dromara.aihr.personal.controller; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.AskRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.AskResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.DownloadUrlResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PageResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PersonalSearchRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PersonalSearchResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.SessionDetailResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.SessionResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.SpaceResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.TextItemRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.UrlItemRequest; +import org.dromara.aihr.personal.service.PersonalAnswerService; +import org.dromara.aihr.personal.service.PersonalCleanupService; +import org.dromara.aihr.personal.service.PersonalIngestionService; +import org.dromara.aihr.personal.service.PersonalRetrievalService; +import org.dromara.aihr.personal.service.PersonalSpaceService; +import org.dromara.aihr.personal.service.PersonalUrlFetchService; +import org.dromara.aihr.personal.support.PersonalOwner; +import org.dromara.aihr.personal.support.PersonalOwnerProvider; +import org.dromara.common.core.domain.R; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/aihr/personal-assistant") +public class PersonalAssistantController { + + private final PersonalOwnerProvider ownerProvider; + private final PersonalSpaceService spaceService; + private final PersonalIngestionService ingestionService; + private final PersonalUrlFetchService urlFetchService; + private final PersonalRetrievalService retrievalService; + private final PersonalAnswerService answerService; + private final PersonalCleanupService cleanupService; + + @GetMapping("/space") + public R space() { + return R.ok(spaceService.space(owner())); + } + + @GetMapping("/items") + public R> items(@RequestParam(required = false) Integer pageNum, + @RequestParam(required = false) Integer pageSize, + @RequestParam(required = false) String status, + @RequestParam(required = false) String sourceType, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo, + @RequestParam(required = false) String keyword) { + return R.ok(spaceService.items(owner(), pageNum, pageSize, status, sourceType, dateFrom, dateTo, keyword)); + } + + @PostMapping("/items/text") + public R createText(@RequestBody TextItemRequest request) { + return R.ok(ingestionService.createText(owner(), request)); + } + + @PostMapping(value = "/items/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R createFile(@RequestPart("file") MultipartFile file, + @RequestParam(required = false) String title, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime capturedAt) { + return R.ok(ingestionService.createFile(owner(), file, title, capturedAt)); + } + + @PostMapping("/items/url") + public R createUrl(@RequestBody UrlItemRequest request) { + PersonalOwner owner = owner(); + PersonalUrlFetchService.FetchResult fetched = urlFetchService.fetch(request == null ? null : request.url()); + return R.ok(ingestionService.createUrl(owner, request, fetched)); + } + + @GetMapping("/items/{id}") + public R item(@PathVariable long id) { + return R.ok(spaceService.itemResponse(owner(), id)); + } + + @PostMapping("/items/{id}/retry") + public R retry(@PathVariable long id) { + PersonalOwner owner = owner(); + ingestionService.retry(owner, id); + return R.ok(spaceService.itemResponse(owner, id)); + } + + @DeleteMapping("/items/{id}") + public R> deleteItem(@PathVariable long id) { + return R.ok(Map.of("cleanupJobId", cleanupService.requestDelete(owner(), id))); + } + + @GetMapping("/items/{id}/download-url") + public R downloadUrl(@PathVariable long id) { + return R.ok(spaceService.downloadUrl(owner(), id)); + } + + @PostMapping("/search") + public R search(@RequestBody PersonalSearchRequest request) { + return R.ok(new PersonalSearchResponse(request == null ? null : request.queryText(), + retrievalService.search(owner(), request))); + } + + @PostMapping("/ask") + public R ask(@RequestBody AskRequest request) { + return R.ok(answerService.ask(owner(), request)); + } + + @GetMapping("/sessions") + public R> sessions() { + return R.ok(spaceService.sessions(owner())); + } + + @GetMapping("/sessions/{id}") + public R session(@PathVariable long id) { + return R.ok(spaceService.session(owner(), id)); + } + + @DeleteMapping("/sessions/{id}") + public R deleteSession(@PathVariable long id) { + spaceService.deleteSession(owner(), id); + return R.ok(); + } + + private PersonalOwner owner() { + return ownerProvider.current(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalCleanupService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalCleanupService.java new file mode 100644 index 00000000..1ed797f2 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalCleanupService.java @@ -0,0 +1,227 @@ +package org.dromara.aihr.personal.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; +import org.dromara.aihr.personal.support.PersonalOwner; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.system.service.ISysOssService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.Map; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +@Slf4j +@Service +public class PersonalCleanupService { + + private static final String ITEM_NOT_FOUND = "PERSONAL_ITEM_NOT_FOUND"; + private final JdbcTemplate jdbc; + private final PersonalVectorStore vectorStore; + private final OssCleanup ossCleanup; + private final LongSupplier idSupplier; + private final DbPhase dbPhase; + private final int batchSize; + + @Autowired + public PersonalCleanupService(JdbcTemplate jdbc, PersonalVectorStore vectorStore, ISysOssService ossService, + PlatformTransactionManager transactionManager, + PersonalKnowledgeProperties properties) { + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + this.jdbc = jdbc; + this.vectorStore = vectorStore; + this.ossCleanup = ossId -> ossService.deleteWithValidByIds(List.of(ossId), false); + this.idSupplier = IdWorker::getId; + this.dbPhase = action -> transaction.execute(status -> action.get()); + this.batchSize = Math.max(1, Math.min(100, properties.getCleanupBatchSize())); + } + + private PersonalCleanupService(JdbcTemplate jdbc, PersonalVectorStore vectorStore, OssCleanup ossCleanup, + LongSupplier idSupplier, DbPhase dbPhase, int batchSize) { + this.jdbc = jdbc; + this.vectorStore = vectorStore; + this.ossCleanup = ossCleanup; + this.idSupplier = idSupplier; + this.dbPhase = dbPhase; + this.batchSize = batchSize; + } + + public static PersonalCleanupService forTest(JdbcTemplate jdbc, PersonalVectorStore vectorStore, + OssCleanup ossCleanup, LongSupplier idSupplier, + DbPhase dbPhase) { + return new PersonalCleanupService(jdbc, vectorStore, ossCleanup, idSupplier, dbPhase, 20); + } + + public long requestDelete(PersonalOwner owner, long itemId) { + requireOwner(owner); + if (itemId <= 0) throw new ServiceException(ITEM_NOT_FOUND); + return inDb(() -> { + Map item; + try { + item = jdbc.queryForMap(""" + select i.id, i.space_id, i.size_bytes, i.status, i.oss_id, o.oss_id owned_oss_id + from aihr_personal_item i + left join sys_oss o on o.oss_id = i.oss_id and binary o.tenant_id = binary i.tenant_id + and o.create_by = i.owner_user_id + and cast(json_unquote(json_extract(o.ext1, '$.itemId')) as unsigned) = i.id + where binary i.tenant_id = binary ? and i.owner_user_id = ? and i.id = ? + and i.status in ('QUEUED','PARSING','READY','FAILED') + for update + """, owner.tenantId(), owner.userId(), itemId); + } catch (EmptyResultDataAccessException ex) { + throw new ServiceException(ITEM_NOT_FOUND); + } + if (item.get("oss_id") != null && item.get("owned_oss_id") == null) { + throw new ServiceException(ITEM_NOT_FOUND); + } + String status = String.valueOf(item.get("status")); + int hidden = jdbc.update(""" + update aihr_personal_item + set status = 'DELETING', update_time = now() + where tenant_id = ? and owner_user_id = ? and id = ? and status = ? + """, owner.tenantId(), owner.userId(), itemId, status); + if (hidden != 1) throw new ServiceException(ITEM_NOT_FOUND); + long jobId = positiveId(idSupplier.getAsLong()); + int inserted = jdbc.update(""" + insert into aihr_personal_cleanup_job + (id, tenant_id, owner_user_id, item_id, status, attempt_count, create_time, update_time) + values (?, ?, ?, ?, 'PENDING', 0, now(), now()) + """, jobId, owner.tenantId(), owner.userId(), itemId); + if (inserted != 1) throw new ServiceException("PERSONAL_CLEANUP_CREATE_FAILED"); + return jobId; + }); + } + + /** Executes external cleanup outside the database transaction. Every step is safe to repeat. */ + public void cleanup(long cleanupJobId) { + if (cleanupJobId <= 0) return; + List> rows = jdbc.queryForList(""" + select j.id job_id, j.tenant_id, j.owner_user_id, j.item_id, j.status job_status, + i.space_id, i.size_bytes, i.oss_id + from aihr_personal_cleanup_job j + join aihr_personal_item i + on i.id = j.item_id and binary i.tenant_id = binary j.tenant_id + and i.owner_user_id = j.owner_user_id + where j.id = ? and j.status in ('PENDING','RETRY') and i.status = 'DELETING' + limit 1 + """, cleanupJobId); + if (rows.isEmpty()) return; + CleanupItem item = cleanupItem(rows.get(0)); + try { + vectorStore.deleteItem(item.owner(), item.itemId()); + jdbc.update(""" + delete from aihr_personal_fragment + where tenant_id = ? and owner_user_id = ? and item_id = ? + """, item.owner().tenantId(), item.owner().userId(), item.itemId()); + if (item.ossId() != null && item.ossId() > 0) { + ossCleanup.delete(item.ossId()); + } + inDb(() -> finalizeDeletion(item)); + } catch (RuntimeException ex) { + jdbc.update(""" + update aihr_personal_cleanup_job + set status = 'RETRY', attempt_count = attempt_count + 1, + last_error = ?, update_time = now() + where id = ? and tenant_id = ? and owner_user_id = ? and item_id = ? and status <> 'DONE' + """, safeError(ex), item.jobId(), item.owner().tenantId(), item.owner().userId(), item.itemId()); + log.warn("event=personal_cleanup_retry jobId={} itemId={} exception={}", item.jobId(), item.itemId(), + ex.getClass().getSimpleName()); + throw new ServiceException("PERSONAL_CLEANUP_RETRY_PENDING"); + } + } + + @Scheduled(fixedDelayString = "${aihr.personal.cleanup-delay-ms:60000}", scheduler = "personalTaskScheduler") + public void poll() { + List jobs = jdbc.query(""" + select id from aihr_personal_cleanup_job + where status in ('PENDING','RETRY') + order by update_time, id limit ? + """, (rs, rowNum) -> rs.getLong("id"), batchSize); + for (Long jobId : jobs) { + try { + cleanup(jobId); + } catch (RuntimeException ignored) { + // cleanup() persisted the retry state; later polls resume it. + } + } + } + + private Void finalizeDeletion(CleanupItem item) { + int deleted = jdbc.update(""" + update aihr_personal_item + set status = 'DELETED', summary = null, original_url = null, oss_id = null, + error_code = null, error_message = null, deleted_at = now(), update_time = now() + where tenant_id = ? and owner_user_id = ? and id = ? and status = 'DELETING' + """, item.owner().tenantId(), item.owner().userId(), item.itemId()); + if (deleted == 1) { + jdbc.update(""" + update aihr_personal_space + set used_bytes = greatest(0, used_bytes - ?), item_count = greatest(0, item_count - 1), + update_time = now() + where tenant_id = ? and owner_user_id = ? and id = ? + """, item.sizeBytes(), item.owner().tenantId(), item.owner().userId(), item.spaceId()); + } + jdbc.update(""" + update aihr_personal_cleanup_job + set status = 'DONE', attempt_count = attempt_count + 1, last_error = null, + completed_at = now(), update_time = now() + where id = ? and tenant_id = ? and owner_user_id = ? and item_id = ? and status <> 'DONE' + """, item.jobId(), item.owner().tenantId(), item.owner().userId(), item.itemId()); + return null; + } + + private static CleanupItem cleanupItem(Map row) { + PersonalOwner owner = new PersonalOwner(String.valueOf(row.get("tenant_id")), number(row, "owner_user_id"), null); + Object oss = row.get("oss_id"); + return new CleanupItem(number(row, "job_id"), owner, number(row, "item_id"), number(row, "space_id"), + number(row, "size_bytes"), oss instanceof Number value ? value.longValue() : null); + } + + private static long number(Map row, String key) { + if (row.get(key) instanceof Number value) return value.longValue(); + throw new ServiceException("PERSONAL_CLEANUP_STATE_INVALID"); + } + + private static void requireOwner(PersonalOwner owner) { + if (owner == null || owner.tenantId() == null || owner.tenantId().isBlank() || owner.userId() <= 0) { + throw new ServiceException("PERSONAL_OWNER_INVALID"); + } + } + + private static long positiveId(long value) { + if (value <= 0) throw new ServiceException("PERSONAL_CLEANUP_CREATE_FAILED"); + return value; + } + + private static String safeError(RuntimeException ex) { + String value = ex.getClass().getSimpleName(); + return value.length() <= 80 ? value : value.substring(0, 80); + } + + @SuppressWarnings("unchecked") + private T inDb(Supplier action) { + return (T) dbPhase.execute(action); + } + + @FunctionalInterface + public interface OssCleanup { + void delete(Long ossId); + } + + @FunctionalInterface + public interface DbPhase { + Object execute(Supplier action); + } + + private record CleanupItem(long jobId, PersonalOwner owner, long itemId, long spaceId, long sizeBytes, + Long ossId) { + } +} 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 5dbe0e37..9493eddb 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 @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse; import org.dromara.aihr.personal.domain.PersonalAssistantDto.TextItemRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.UrlItemRequest; import org.dromara.aihr.personal.support.PersonalKnowledgeProperties; import org.dromara.aihr.personal.support.PersonalOwner; import org.dromara.common.core.exception.ServiceException; @@ -49,6 +50,8 @@ public class PersonalIngestionService { private static final Set SUPPORTED_FILE_SUFFIXES = Set.of( "txt", "md", "markdown", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx" ); + private static final Set INTERNAL_CAPTURE_SUFFIXES = Set.of("html", "txt", "pdf", "doc", "docx", + "xls", "xlsx", "ppt", "pptx"); private final JdbcTemplate jdbcTemplate; private final PersonalSpaceService spaceService; @@ -103,7 +106,7 @@ public class PersonalIngestionService { byte[] bytes = request.content().getBytes(StandardCharsets.UTF_8); validateSize(bytes.length); return create(owner, "TEXT", cleanTitle(request.title(), "文字资料"), "txt", "text/plain", bytes, - request.capturedAt(), request.tags()); + null, request.capturedAt(), request.tags()); } @Transactional(propagation = Propagation.NOT_SUPPORTED) @@ -120,7 +123,22 @@ public class PersonalIngestionService { validateSize(bytes.length); String originalName = safeFileName(file.getOriginalFilename()); return create(owner, "FILE", cleanTitle(title, originalName), suffix(originalName), - cleanMime(file.getContentType()), bytes, capturedAt, List.of()); + cleanMime(file.getContentType()), bytes, null, capturedAt, List.of()); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public ItemCreatedResponse createUrl(PersonalOwner owner, UrlItemRequest request, + PersonalUrlFetchService.FetchResult fetched) { + validateOwner(owner); + if (request == null || fetched == null || fetched.finalUri() == null || fetched.body() == null) { + throw new ServiceException("PERSONAL_URL_FETCH_FAILED"); + } + validateSize(fetched.body().length); + String suffix = captureSuffix(fetched.contentType()); + String fallbackTitle = fetched.finalUri().getHost() == null ? "网页收藏" : fetched.finalUri().getHost(); + return create(owner, "URL", cleanTitle(request.title(), fallbackTitle), suffix, + cleanMime(fetched.contentType()), fetched.body(), fetched.finalUri().toString(), request.capturedAt(), + List.of()); } public void retry(PersonalOwner owner, long itemId) { @@ -141,7 +159,8 @@ public class PersonalIngestionService { } private ItemCreatedResponse create(PersonalOwner owner, String sourceType, String title, String suffix, - String mimeType, byte[] bytes, LocalDateTime capturedAt, List tags) { + String mimeType, byte[] bytes, String originalUrl, LocalDateTime capturedAt, + List tags) { String serviceKey = objectStore.requirePrivateService(); long itemId = positiveId(idSupplier.getAsLong()); long ossId = positiveId(idSupplier.getAsLong()); @@ -152,7 +171,7 @@ public class PersonalIngestionService { String hash = sha256(bytes); PhaseOne phaseOne = phaseTransaction.execute(status -> phaseOne( - draft, sourceType, title, hash, capturedAt, tags)); + draft, sourceType, title, hash, originalUrl, capturedAt, tags)); if (phaseOne == null) { throw new ServiceException("PERSONAL_ITEM_CREATE_FAILED"); } @@ -177,7 +196,7 @@ public class PersonalIngestionService { } } - private PhaseOne phaseOne(UploadIntent draft, String sourceType, String title, String hash, + private PhaseOne phaseOne(UploadIntent draft, String sourceType, String title, String hash, String originalUrl, LocalDateTime capturedAt, List tags) { long spaceId = spaceService.reserve(draft.owner(), draft.sizeBytes()); ItemCreatedResponse duplicate = duplicate(draft.owner(), spaceId, hash); @@ -202,6 +221,13 @@ public class PersonalIngestionService { """, intent.itemId(), intent.owner().tenantId(), spaceId, intent.owner().userId(), sourceType, title, intent.ossId(), intent.mimeType(), intent.sizeBytes(), hash, tagsJson(tags), capturedAt == null ? LocalDateTime.now() : capturedAt); + if (originalUrl != null) { + int linked = jdbcTemplate.update(""" + update aihr_personal_item set original_url = ? + where tenant_id = ? and owner_user_id = ? and id = ? and status = 'QUEUED' + """, originalUrl, intent.owner().tenantId(), intent.owner().userId(), intent.itemId()); + if (linked != 1) throw new ServiceException("PERSONAL_ITEM_CREATE_FAILED"); + } int counterUpdated = jdbcTemplate.update(""" update aihr_personal_space set used_bytes = used_bytes + ?, item_count = item_count + 1, update_time = now() @@ -565,7 +591,9 @@ public class PersonalIngestionService { private static String objectKey(PersonalOwner owner, long itemId, String suffix) { validateOwner(owner); positiveId(itemId); String safeSuffix = suffix == null ? "" : suffix.toLowerCase(Locale.ROOT); - if (!SUPPORTED_FILE_SUFFIXES.contains(safeSuffix)) throw new ServiceException("PERSONAL_FILE_UNSUPPORTED"); + if (!SUPPORTED_FILE_SUFFIXES.contains(safeSuffix) && !INTERNAL_CAPTURE_SUFFIXES.contains(safeSuffix)) { + throw new ServiceException("PERSONAL_FILE_UNSUPPORTED"); + } return "personal/" + owner.tenantId() + "/" + owner.userId() + "/" + itemId + "/" + UUID.randomUUID().toString().replace("-", "") + "." + safeSuffix; } @@ -610,6 +638,22 @@ public class PersonalIngestionService { int separator = mime.indexOf(';'); return separator < 0 ? mime : mime.substring(0, separator).trim(); } + private static String captureSuffix(String mimeType) { + return switch (cleanMime(mimeType)) { + case "text/html" -> "html"; + case "text/plain", "text/markdown" -> "txt"; + case "application/pdf" -> "pdf"; + case "application/msword" -> "doc"; + case "application/vnd.ms-excel" -> "xls"; + case "application/vnd.ms-powerpoint" -> "ppt"; + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> "docx"; + case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" -> "xlsx"; + case "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx"; + default -> throw new ServiceException("PERSONAL_URL_CONTENT_TYPE_UNSUPPORTED"); + }; + } + + public interface PersonalObjectStore { String requirePrivateService(); String uploadPhysical(String serviceKey, String objectKey, String mimeType, byte[] bytes); 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 index f15e214d..a9e55259 100644 --- 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 @@ -1,27 +1,141 @@ package org.dromara.aihr.personal.service; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ChatMessageResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.DownloadUrlResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PageResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.SessionDetailResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.SessionResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.SpaceResponse; 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.enums.AccessPolicyType; +import org.dromara.common.oss.factory.OssFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.Set; @Service public class PersonalSpaceService { private static final String QUOTA_EXCEEDED = "PERSONAL_SPACE_QUOTA_EXCEEDED"; + private static final String ITEM_NOT_FOUND = "PERSONAL_ITEM_NOT_FOUND"; + private static final String SESSION_NOT_FOUND = "PERSONAL_SESSION_NOT_FOUND"; + private static final Set ITEM_STATUSES = Set.of("QUEUED", "PARSING", "READY", "FAILED"); + private static final Set SOURCE_TYPES = Set.of("TEXT", "FILE", "IMAGE", "URL"); private final JdbcTemplate jdbcTemplate; private final PersonalKnowledgeProperties properties; + private final ObjectMapper objectMapper; + private final DownloadSigner downloadSigner; public PersonalSpaceService(JdbcTemplate jdbcTemplate, PersonalKnowledgeProperties properties) { + this(jdbcTemplate, properties, new ObjectMapper(), PersonalSpaceService::presign); + } + + @Autowired + public PersonalSpaceService(JdbcTemplate jdbcTemplate, PersonalKnowledgeProperties properties, + ObjectMapper objectMapper) { + this(jdbcTemplate, properties, objectMapper, PersonalSpaceService::presign); + } + + private PersonalSpaceService(JdbcTemplate jdbcTemplate, PersonalKnowledgeProperties properties, + ObjectMapper objectMapper, DownloadSigner downloadSigner) { this.jdbcTemplate = jdbcTemplate; this.properties = properties; + this.objectMapper = objectMapper; + this.downloadSigner = downloadSigner; + } + + public static PersonalSpaceService forTest(JdbcTemplate jdbcTemplate, PersonalKnowledgeProperties properties, + ObjectMapper objectMapper, DownloadSigner downloadSigner) { + return new PersonalSpaceService(jdbcTemplate, properties, objectMapper, downloadSigner); + } + + public SpaceResponse space(PersonalOwner owner) { + requireOwner(owner); + List> rows = jdbcTemplate.queryForList(""" + select id, status, quota_bytes, used_bytes, item_count + from aihr_personal_space + where tenant_id = ? and owner_user_id = ? + limit 1 + """, owner.tenantId(), owner.userId()); + if (rows.isEmpty()) { + return new SpaceResponse(0L, "ACTIVE", defaultQuotaBytes(), 0L, 0); + } + Map row = rows.get(0); + return new SpaceResponse(number(row, "id"), text(row, "status"), number(row, "quota_bytes"), + number(row, "used_bytes"), Math.toIntExact(number(row, "item_count"))); + } + + public PageResponse items(PersonalOwner owner, Integer pageNum, Integer pageSize, String status, + String sourceType, LocalDate dateFrom, LocalDate dateTo, String keyword) { + requireOwner(owner); + int page = pageNum == null ? 1 : Math.max(1, pageNum); + int size = pageSize == null ? 20 : Math.max(1, Math.min(100, pageSize)); + String normalizedStatus = normalized(status, ITEM_STATUSES, "PERSONAL_ITEM_STATUS_INVALID"); + String normalizedSource = normalized(sourceType, SOURCE_TYPES, "PERSONAL_ITEM_SOURCE_INVALID"); + validateDates(dateFrom, dateTo); + StringBuilder where = new StringBuilder(""" + from aihr_personal_item + where tenant_id = ? and owner_user_id = ? + and status not in ('DELETING','DELETED') + """); + List args = new ArrayList<>(List.of(owner.tenantId(), owner.userId())); + if (normalizedStatus != null) { + where.append(" and status = ?"); + args.add(normalizedStatus); + } + if (normalizedSource != null) { + where.append(" and source_type = ?"); + args.add(normalizedSource); + } + if (dateFrom != null) { + where.append(" and captured_at >= ?"); + args.add(dateFrom.atStartOfDay()); + } + if (dateTo != null) { + where.append(" and captured_at < ?"); + args.add(dateTo.plusDays(1).atStartOfDay()); + } + if (keyword != null && !keyword.isBlank()) { + String value = keyword.trim(); + if (value.length() > 200) throw new ServiceException("PERSONAL_ITEM_FILTER_INVALID"); + where.append(" and title like ? escape '\\\\'"); + args.add("%" + escapeLike(value) + "%"); + } + Long total = jdbcTemplate.queryForObject("select count(*)" + where, Long.class, args.toArray()); + List dataArgs = new ArrayList<>(args); + dataArgs.add(size); + dataArgs.add((page - 1L) * size); + List rows = jdbcTemplate.query(""" + select id, source_type, title, original_url, mime_type, size_bytes, status, + error_code, error_message, summary, tags_json, captured_at, parsed_at + """ + where + " order by captured_at desc, id desc limit ? offset ?", + (rs, rowNum) -> new ItemResponse(rs.getLong("id"), rs.getString("source_type"), + rs.getString("title"), rs.getString("original_url"), rs.getString("mime_type"), + rs.getLong("size_bytes"), rs.getString("status"), rs.getString("error_code"), + rs.getString("error_message"), rs.getString("summary"), tags(rs.getString("tags_json")), + rs.getObject("captured_at", LocalDateTime.class), + rs.getObject("parsed_at", LocalDateTime.class)), dataArgs.toArray()); + return new PageResponse<>(List.copyOf(rows), total == null ? 0 : total, page, size); } public Map item(PersonalOwner owner, long itemId) { @@ -32,13 +146,80 @@ public class PersonalSpaceService { tags_json, captured_at, parsed_at, create_time from aihr_personal_item where tenant_id = ? and owner_user_id = ? and id = ? - and status <> 'DELETED' + and status not in ('DELETING','DELETED') """, owner.tenantId(), owner.userId(), itemId); } catch (EmptyResultDataAccessException ex) { - throw new ServiceException("PERSONAL_ITEM_NOT_FOUND"); + throw new ServiceException(ITEM_NOT_FOUND); } } + public ItemResponse itemResponse(PersonalOwner owner, long itemId) { + return itemResponse(item(owner, itemId)); + } + + public DownloadUrlResponse downloadUrl(PersonalOwner owner, long itemId) { + requireOwner(owner); + List> rows = jdbcTemplate.queryForList(""" + select i.oss_id, o.file_name, o.service + from aihr_personal_item i + join sys_oss o on o.oss_id = i.oss_id and binary o.tenant_id = binary i.tenant_id + and o.create_by = i.owner_user_id + where i.tenant_id = ? and i.owner_user_id = ? and i.id = ? + and i.status not in ('DELETING','DELETED') + limit 1 + """, owner.tenantId(), owner.userId(), itemId); + if (rows.isEmpty()) throw new ServiceException(ITEM_NOT_FOUND); + Map row = rows.get(0); + int minutes = Math.max(1, Math.min(60, properties.getDownloadUrlMinutes())); + String url = downloadSigner.sign(text(row, "service"), text(row, "file_name"), Duration.ofMinutes(minutes)); + return new DownloadUrlResponse(url, LocalDateTime.now().plusMinutes(minutes)); + } + + public List sessions(PersonalOwner owner) { + requireOwner(owner); + return List.copyOf(jdbcTemplate.query(""" + select id, title, default_scope, update_time + from aihr_personal_chat_session + where tenant_id = ? and owner_user_id = ? and status = 'ACTIVE' + order by update_time desc, id desc limit 100 + """, (rs, rowNum) -> new SessionResponse(rs.getLong("id"), rs.getString("title"), + rs.getString("default_scope"), rs.getObject("update_time", LocalDateTime.class)), + owner.tenantId(), owner.userId())); + } + + public SessionDetailResponse session(PersonalOwner owner, long sessionId) { + requireOwner(owner); + List> sessions = jdbcTemplate.queryForList(""" + select id, title from aihr_personal_chat_session + where tenant_id = ? and owner_user_id = ? and id = ? and status = 'ACTIVE' + limit 1 + """, owner.tenantId(), owner.userId(), sessionId); + if (sessions.isEmpty()) throw new ServiceException(SESSION_NOT_FOUND); + List messages = jdbcTemplate.query(""" + select id, role, content, citations_json, create_time + from aihr_personal_chat_message + where tenant_id = ? and owner_user_id = ? and session_id = ? + order by create_time, id + """, (rs, rowNum) -> new ChatMessageResponse(rs.getLong("id"), rs.getString("role"), + rs.getString("content"), citations(rs.getString("citations_json")), + rs.getObject("create_time", LocalDateTime.class)), owner.tenantId(), owner.userId(), sessionId); + return new SessionDetailResponse(sessionId, text(sessions.get(0), "title"), List.copyOf(messages)); + } + + @Transactional + public void deleteSession(PersonalOwner owner, long sessionId) { + requireOwner(owner); + int hidden = jdbcTemplate.update(""" + update aihr_personal_chat_session set status = 'DELETED', update_time = now() + where tenant_id = ? and owner_user_id = ? and id = ? and status = 'ACTIVE' + """, owner.tenantId(), owner.userId(), sessionId); + if (hidden != 1) throw new ServiceException(SESSION_NOT_FOUND); + jdbcTemplate.update(""" + delete from aihr_personal_chat_message + where tenant_id = ? and owner_user_id = ? and session_id = ? + """, owner.tenantId(), owner.userId(), sessionId); + } + /** * Ensures and locks the current owner's space, then validates that one future item of the given size can fit. * This check intentionally does not mutate usage counters. The future ingestion application service must call @@ -94,4 +275,94 @@ public class PersonalSpaceService { private long defaultQuotaBytes() { return Math.multiplyExact(properties.getMaxSpaceMb(), 1024L * 1024L); } + + private ItemResponse itemResponse(Map row) { + return new ItemResponse(number(row, "id"), text(row, "source_type"), text(row, "title"), + nullableText(row, "original_url"), nullableText(row, "mime_type"), number(row, "size_bytes"), + text(row, "status"), nullableText(row, "error_code"), nullableText(row, "error_message"), + nullableText(row, "summary"), tags(row.get("tags_json")), dateTime(row.get("captured_at")), + dateTime(row.get("parsed_at"))); + } + + private List tags(Object value) { + if (value == null || String.valueOf(value).isBlank()) return List.of(); + try { + return objectMapper.readValue(String.valueOf(value), new TypeReference<>() { }); + } catch (Exception ex) { + return List.of(); + } + } + + private List citations(String value) { + if (value == null || value.isBlank()) return List.of(); + try { + return objectMapper.readValue(value, new TypeReference<>() { }); + } catch (Exception ex) { + throw new ServiceException("PERSONAL_SESSION_DATA_INVALID"); + } + } + + private static String normalized(String value, Set allowed, String error) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); + if (!allowed.contains(normalized)) throw new ServiceException(error); + return normalized; + } + + private static void validateDates(LocalDate from, LocalDate to) { + if (from != null && to != null && from.isAfter(to)) throw new ServiceException("PERSONAL_ITEM_DATE_INVALID"); + if (to != null) { + try { + to.plusDays(1); + } catch (DateTimeException ex) { + throw new ServiceException("PERSONAL_ITEM_DATE_INVALID"); + } + } + } + + private static void requireOwner(PersonalOwner owner) { + if (owner == null || owner.tenantId() == null || owner.tenantId().isBlank() || owner.userId() <= 0) { + throw new ServiceException("PERSONAL_OWNER_INVALID"); + } + } + + private static long number(Map row, String key) { + if (row.get(key) instanceof Number number) return number.longValue(); + throw new ServiceException("PERSONAL_DATA_INVALID"); + } + + private static String text(Map row, String key) { + String value = nullableText(row, key); + if (value == null) throw new ServiceException("PERSONAL_DATA_INVALID"); + return value; + } + + private static String nullableText(Map row, String key) { + Object value = row.get(key); + return value == null || String.valueOf(value).isBlank() ? null : String.valueOf(value); + } + + private static LocalDateTime dateTime(Object value) { + if (value == null) return null; + if (value instanceof LocalDateTime time) return time; + if (value instanceof java.sql.Timestamp time) return time.toLocalDateTime(); + throw new ServiceException("PERSONAL_DATA_INVALID"); + } + + private static String escapeLike(String value) { + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } + + private static String presign(String service, String objectKey, Duration duration) { + OssClient client = service == null || service.isBlank() ? OssFactory.instance() : OssFactory.instance(service); + if (client == null || client.getAccessPolicy() != AccessPolicyType.PRIVATE) { + throw new ServiceException("PERSONAL_OSS_NOT_PRIVATE"); + } + return client.createPresignedGetUrl(objectKey, duration); + } + + @FunctionalInterface + public interface DownloadSigner { + String sign(String service, String objectKey, Duration duration); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAssistantControllerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAssistantControllerTest.java new file mode 100644 index 00000000..4963a2c2 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAssistantControllerTest.java @@ -0,0 +1,69 @@ +package org.dromara.aihr.personal; + +import cn.dev33.satoken.annotation.SaIgnore; +import org.dromara.aihr.personal.controller.PersonalAssistantController; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.TextItemRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.UrlItemRequest; +import org.dromara.aihr.personal.service.PersonalAnswerService; +import org.dromara.aihr.personal.service.PersonalCleanupService; +import org.dromara.aihr.personal.service.PersonalIngestionService; +import org.dromara.aihr.personal.service.PersonalRetrievalService; +import org.dromara.aihr.personal.service.PersonalSpaceService; +import org.dromara.aihr.personal.service.PersonalUrlFetchService; +import org.dromara.aihr.personal.support.PersonalOwner; +import org.dromara.aihr.personal.support.PersonalOwnerProvider; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class PersonalAssistantControllerTest { + + @Test + void everyP0EntryDerivesOwnerAndControllerHasNoAnonymousBypass() { + PersonalOwnerProvider owners = mock(PersonalOwnerProvider.class); + PersonalOwner owner = new PersonalOwner("000000", 101L, null); + when(owners.current()).thenReturn(owner); + PersonalSpaceService spaces = mock(PersonalSpaceService.class); + PersonalIngestionService ingestion = mock(PersonalIngestionService.class); + PersonalUrlFetchService urls = mock(PersonalUrlFetchService.class); + PersonalRetrievalService retrieval = mock(PersonalRetrievalService.class); + PersonalAnswerService answers = mock(PersonalAnswerService.class); + PersonalCleanupService cleanup = mock(PersonalCleanupService.class); + when(urls.fetch("https://example.com/a")).thenReturn(new PersonalUrlFetchService.FetchResult( + java.net.URI.create("https://example.com/a"), 200, "text/plain", "a".getBytes(), + java.time.Instant.now(), "hash")); + + PersonalAssistantController controller = new PersonalAssistantController(owners, spaces, ingestion, urls, + retrieval, answers, cleanup); + controller.space(); + controller.items(1, 20, null, null, null, null, null); + controller.createText(new TextItemRequest("note", "body", null, List.of())); + controller.createFile(mock(MultipartFile.class), null, null); + controller.createUrl(new UrlItemRequest("https://example.com/a", null, null)); + controller.item(9L); + controller.retry(9L); + controller.deleteItem(9L); + controller.downloadUrl(9L); + controller.search(null); + controller.ask(null); + controller.sessions(); + controller.session(3L); + controller.deleteSession(3L); + + verify(owners, times(14)).current(); + assertFalse(PersonalAssistantController.class.isAnnotationPresent(SaIgnore.class)); + for (var method : PersonalAssistantController.class.getDeclaredMethods()) { + assertFalse(method.isAnnotationPresent(SaIgnore.class), method.getName()); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalCleanupServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalCleanupServiceTest.java new file mode 100644 index 00000000..b3647e33 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalCleanupServiceTest.java @@ -0,0 +1,124 @@ +package org.dromara.aihr.personal; + +import org.dromara.aihr.personal.service.PersonalCleanupService; +import org.dromara.aihr.personal.service.PersonalVectorStore; +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.mockito.InOrder; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +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.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; + +@Tag("dev") +class PersonalCleanupServiceTest { + + private static final PersonalOwner OWNER = new PersonalOwner("000000", 101L, null); + + @Test + void deletionHidesItemAndPersistsJobBeforeExternalCleanup() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + PersonalVectorStore vectors = mock(PersonalVectorStore.class); + PersonalCleanupService.OssCleanup oss = mock(PersonalCleanupService.OssCleanup.class); + when(jdbc.queryForMap(contains("for update"), eq("000000"), eq(101L), eq(9L))) + .thenReturn(item("READY")); + when(jdbc.update(contains("set status = 'DELETING'"), eq("000000"), eq(101L), eq(9L), eq("READY"))) + .thenReturn(1); + when(jdbc.update(contains("insert into aihr_personal_cleanup_job"), any(), eq("000000"), eq(101L), eq(9L))) + .thenReturn(1); + PersonalCleanupService service = PersonalCleanupService.forTest(jdbc, vectors, oss, () -> 7001L, + action -> action.get()); + + assertEquals(7001L, service.requestDelete(OWNER, 9L)); + + InOrder order = inOrder(jdbc); + order.verify(jdbc).queryForMap(contains("for update"), eq("000000"), eq(101L), eq(9L)); + order.verify(jdbc).update(contains("set status = 'DELETING'"), eq("000000"), eq(101L), eq(9L), eq("READY")); + order.verify(jdbc).update(contains("insert into aihr_personal_cleanup_job"), eq(7001L), eq("000000"), + eq(101L), eq(9L)); + verify(vectors, never()).deleteItem(any(), any(Long.class)); + verify(oss, never()).delete(any(Long.class)); + } + + @Test + void foreignItemUsesStableNotFoundWithoutCreatingJob() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForMap(anyString(), eq("000000"), eq(202L), eq(9L))) + .thenThrow(new EmptyResultDataAccessException(1)); + PersonalCleanupService service = PersonalCleanupService.forTest(jdbc, mock(PersonalVectorStore.class), + mock(PersonalCleanupService.OssCleanup.class), () -> 7001L, action -> action.get()); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.requestDelete(new PersonalOwner("000000", 202L, null), 9L)); + + assertEquals("PERSONAL_ITEM_NOT_FOUND", error.getMessage()); + verify(jdbc, never()).update(contains("insert into aihr_personal_cleanup_job"), any(), any(), any(), any()); + } + + @Test + void cleanupUsesFixedOrderAndIsIdempotent() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + PersonalVectorStore vectors = mock(PersonalVectorStore.class); + PersonalCleanupService.OssCleanup oss = mock(PersonalCleanupService.OssCleanup.class); + when(jdbc.queryForList(contains("from aihr_personal_cleanup_job j"), eq(7001L))) + .thenReturn(List.of(cleanupRow()), List.of()); + when(jdbc.update(contains("delete from aihr_personal_fragment"), eq("000000"), eq(101L), eq(9L))) + .thenReturn(3); + when(jdbc.update(contains("set status = 'DELETED'"), eq("000000"), eq(101L), eq(9L))) + .thenReturn(1); + PersonalCleanupService service = PersonalCleanupService.forTest(jdbc, vectors, oss, () -> 7001L, + action -> action.get()); + + service.cleanup(7001L); + service.cleanup(7001L); + + InOrder order = inOrder(vectors, jdbc, oss); + order.verify(vectors).deleteItem(OWNER, 9L); + order.verify(jdbc).update(contains("delete from aihr_personal_fragment"), eq("000000"), eq(101L), eq(9L)); + order.verify(oss).delete(55L); + order.verify(jdbc).update(contains("set status = 'DELETED'"), eq("000000"), eq(101L), eq(9L)); + } + + @Test + void externalFailureKeepsDeletingAndPersistsRetry() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + PersonalVectorStore vectors = mock(PersonalVectorStore.class); + when(jdbc.queryForList(contains("from aihr_personal_cleanup_job j"), eq(7001L))) + .thenReturn(List.of(cleanupRow())); + doThrow(new IllegalStateException("qdrant unavailable")).when(vectors).deleteItem(OWNER, 9L); + PersonalCleanupService service = PersonalCleanupService.forTest(jdbc, vectors, + mock(PersonalCleanupService.OssCleanup.class), () -> 7001L, action -> action.get()); + + ServiceException error = assertThrows(ServiceException.class, () -> service.cleanup(7001L)); + + assertEquals("PERSONAL_CLEANUP_RETRY_PENDING", error.getMessage()); + verify(jdbc).update(contains("set status = 'RETRY'"), eq("IllegalStateException"), eq(7001L), + eq("000000"), eq(101L), eq(9L)); + verify(jdbc, never()).update(contains("set status = 'DELETED'"), any(), any(), any()); + } + + private static Map item(String status) { + return Map.of("id", 9L, "space_id", 3L, "size_bytes", 100L, "status", status); + } + + private static Map cleanupRow() { + return Map.of("job_id", 7001L, "tenant_id", "000000", "owner_user_id", 101L, "item_id", 9L, + "space_id", 3L, "size_bytes", 100L, "oss_id", 55L, "job_status", "PENDING"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java index 616f6058..bc704acc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java @@ -28,8 +28,9 @@ class PersonalSchemaContractTest { String fragment = tableDefinition(sql, "aihr_personal_fragment"); String session = tableDefinition(sql, "aihr_personal_chat_session"); String message = tableDefinition(sql, "aihr_personal_chat_message"); + String cleanup = tableDefinition(sql, "aihr_personal_cleanup_job"); - for (String definition : new String[] {space, item, fragment, session, message}) { + for (String definition : new String[] {space, item, fragment, session, message, cleanup}) { assertTrue(definition.contains("`owner_user_id` bigint not null"), "Every personal table must carry a non-null owner_user_id"); } @@ -88,6 +89,10 @@ class PersonalSchemaContractTest { assertTrue(message.contains( "key `idx_personal_message_owner` (`tenant_id`, `owner_user_id`, `session_id`, `create_time`)")); + assertTrue(cleanup.contains("`status` varchar(20) not null default 'pending'")); + assertTrue(cleanup.contains("unique key `uk_personal_cleanup_item` (`tenant_id`, `owner_user_id`, `item_id`)")); + assertTrue(cleanup.contains("key `idx_personal_cleanup_status` (`status`, `update_time`)")); + assertFalse(sql.contains("alter table aihr_knowledge_fragment"), "Personal schema must not mutate enterprise knowledge tables"); assertFalse(sql.contains("alter table `aihr_knowledge_fragment`"), 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 88138cc6..1f34d254 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 @@ -27,11 +27,13 @@ import org.springframework.transaction.support.TransactionTemplate; import org.mockito.InOrder; import java.util.Map; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -44,6 +46,48 @@ import static org.mockito.Mockito.when; @Tag("dev") class PersonalSpaceServiceTest { + @Test + void itemListAndSessionsAreAlwaysOwnerScoped() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(contains("from aihr_personal_item"), eq(Long.class), eq("000000"), eq(101L))) + .thenReturn(0L); + when(jdbc.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), + eq("000000"), eq(101L), eq(20), eq(0L))).thenReturn(List.of()); + when(jdbc.query(contains("from aihr_personal_chat_session"), + any(org.springframework.jdbc.core.RowMapper.class), eq("000000"), eq(101L))).thenReturn(List.of()); + PersonalSpaceService service = new PersonalSpaceService(jdbc, properties()); + PersonalOwner owner = new PersonalOwner("000000", 101L, null); + + service.items(owner, 1, 20, null, null, null, null, null); + service.sessions(owner); + + verify(jdbc).queryForObject(contains("tenant_id = ? and owner_user_id = ?"), eq(Long.class), + eq("000000"), eq(101L)); + verify(jdbc).query(contains("from aihr_personal_chat_session"), + any(org.springframework.jdbc.core.RowMapper.class), eq("000000"), eq(101L)); + } + + @Test + void downloadChecksOwnerBeforeSigningPrivateObject() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForList(contains("join sys_oss"), eq("000000"), eq(101L), eq(9L))) + .thenReturn(List.of(Map.of("oss_id", 55L, "file_name", "personal/000000/101/9/a.pdf", + "service", "private"))); + PersonalSpaceService.DownloadSigner signer = mock(PersonalSpaceService.DownloadSigner.class); + when(signer.sign(eq("private"), eq("personal/000000/101/9/a.pdf"), any(java.time.Duration.class))) + .thenReturn("https://signed.example/a"); + PersonalSpaceService service = PersonalSpaceService.forTest(jdbc, properties(), + new com.fasterxml.jackson.databind.ObjectMapper(), signer); + + assertEquals("https://signed.example/a", + service.downloadUrl(new PersonalOwner("000000", 101L, null), 9L).url()); + + verify(jdbc).queryForList(contains("i.tenant_id = ? and i.owner_user_id = ? and i.id = ?"), + eq("000000"), eq(101L), eq(9L)); + verify(signer).sign(eq("private"), eq("personal/000000/101/9/a.pdf"), + eq(java.time.Duration.ofMinutes(5))); + } + @Test void propertiesHaveExplicitSafeDefaults() { PersonalKnowledgeProperties properties = properties(); diff --git a/backend/script/sql/aihr_personal_knowledge_mysql8.sql b/backend/script/sql/aihr_personal_knowledge_mysql8.sql index 81b2392b..12319323 100644 --- a/backend/script/sql/aihr_personal_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_personal_knowledge_mysql8.sql @@ -96,3 +96,19 @@ CREATE TABLE IF NOT EXISTS `aihr_personal_chat_message` ( KEY `idx_personal_message_session` (`session_id`, `create_time`), KEY `idx_personal_message_owner` (`tenant_id`, `owner_user_id`, `session_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理对话消息'; + +CREATE TABLE IF NOT EXISTS `aihr_personal_cleanup_job` ( + `id` bigint NOT NULL COMMENT '清理任务ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `owner_user_id` bigint NOT NULL COMMENT '资料所属用户ID', + `item_id` bigint NOT NULL COMMENT '资料ID', + `status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RETRY/DONE', + `attempt_count` int NOT NULL DEFAULT 0 COMMENT '执行次数', + `last_error` varchar(100) DEFAULT NULL COMMENT '脱敏后的错误类型', + `completed_at` datetime DEFAULT NULL COMMENT '完成时间', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_personal_cleanup_item` (`tenant_id`, `owner_user_id`, `item_id`), + KEY `idx_personal_cleanup_status` (`status`, `update_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理资料清理任务';