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 index 84e8af67..f5c45489 100644 --- 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 @@ -4,6 +4,9 @@ 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.ExportOutlineCreateRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest; import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse; import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse; import org.dromara.aihr.personal.domain.PersonalAssistantDto.OcrProgressResponse; @@ -18,6 +21,7 @@ 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.PersonalExportService; import org.dromara.aihr.personal.service.PersonalPdfOcrService; import org.dromara.aihr.personal.service.PersonalRetrievalService; import org.dromara.aihr.personal.service.PersonalSpaceService; @@ -31,6 +35,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -56,6 +61,7 @@ public class PersonalAssistantController { private final PersonalAnswerService answerService; private final PersonalCleanupService cleanupService; private final PersonalPdfOcrService pdfOcrService; + private final PersonalExportService exportService; @GetMapping("/space") public R space() { @@ -151,6 +157,28 @@ public class PersonalAssistantController { return R.ok(); } + @PostMapping("/exports/outline") + public R createOutline(@RequestBody ExportOutlineCreateRequest request) { + return R.ok(exportService.createOutline(owner(), request)); + } + + @GetMapping("/exports/{id}") + public R outline(@PathVariable long id) { + return R.ok(exportService.outline(owner(), id)); + } + + @PutMapping("/exports/{id}/outline") + public R updateOutline(@PathVariable long id, + @RequestBody ExportOutlineUpdateRequest request) { + return R.ok(exportService.updateOutline(owner(), id, request)); + } + + @PostMapping("/exports/{id}/confirm") + public R confirmOutline(@PathVariable long id) { + exportService.confirmOutline(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/domain/PersonalAssistantDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java index 6d4200a7..498f173d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java @@ -90,6 +90,23 @@ public final class PersonalAssistantDto { LocalDateTime createdAt) { } + public record ExportOutlineCreateRequest(long sessionId, String title) { + } + + public record ExportOutlineSection(String title, List bullets) { + } + + public record ExportOutlineUpdateRequest(String title, List sections) { + } + + public record ExportOutlineSource(String domain, String sourceId, Long itemId, String title, String excerpt) { + } + + public record ExportOutlineResponse(long id, long sessionId, String title, List sections, + List sources, boolean confirmed, String status, + Long ossId) { + } + private static Long legacyId(String value) { if (value == null || value.isBlank()) return null; try { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalExportService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalExportService.java new file mode 100644 index 00000000..b6f62318 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalExportService.java @@ -0,0 +1,224 @@ +package org.dromara.aihr.personal.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.CitationResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSection; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSource; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest; +import org.dromara.aihr.personal.support.PersonalOwner; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.LongSupplier; + +@Service +public class PersonalExportService { + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final LongSupplier idSupplier; + + @Autowired + public PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this(jdbc, objectMapper, IdWorker::getId); + } + + private PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper, LongSupplier idSupplier) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.idSupplier = idSupplier; + } + + public static PersonalExportService forTest(JdbcTemplate jdbc, ObjectMapper objectMapper, + LongSupplier idSupplier) { + return new PersonalExportService(jdbc, objectMapper, idSupplier); + } + + public ExportOutlineResponse createOutline(PersonalOwner owner, ExportOutlineCreateRequest request) { + requireOwner(owner); + if (request == null || request.sessionId() <= 0) throw new ServiceException("PERSONAL_SESSION_NOT_FOUND"); + List> sessions = jdbc.queryForList(""" + select title from aihr_personal_chat_session + where binary tenant_id = binary ? and owner_user_id = ? and id = ? and status = 'ACTIVE' + limit 1 + """, owner.tenantId(), owner.userId(), request.sessionId()); + if (sessions.size() != 1) throw new ServiceException("PERSONAL_SESSION_NOT_FOUND"); + List> messages = jdbc.queryForList(""" + select content, citations_json from aihr_personal_chat_message + where binary tenant_id = binary ? and owner_user_id = ? and session_id = ? and role = 'assistant' + order by create_time desc, id desc limit 1 + """, owner.tenantId(), owner.userId(), request.sessionId()); + if (messages.size() != 1) throw new ServiceException("PERSONAL_EXPORT_SOURCE_REQUIRED"); + String content = clean(String.valueOf(messages.get(0).get("content"))); + List sources = sources(messages.get(0).get("citations_json")); + if (content.isEmpty() || sources.isEmpty()) throw new ServiceException("PERSONAL_EXPORT_SOURCE_REQUIRED"); + String sessionTitle = clean(String.valueOf(sessions.get(0).get("title"))); + String title = clean(request.title()).isEmpty() ? sessionTitle : clean(request.title()); + if (title.isEmpty()) title = "个人工作汇报"; + List sections = List.of(new ExportOutlineSection("核心结论", bullets(content))); + long id = positiveId(idSupplier.getAsLong()); + ExportOutlineResponse response = new ExportOutlineResponse(id, request.sessionId(), title, sections, + sources, false, "DRAFT", null); + int inserted = jdbc.update(""" + insert into aihr_personal_export_task + (id, tenant_id, owner_user_id, session_id, outline_json, outline_confirmed, + template_code, status, create_time, update_time) + values (?, ?, ?, ?, ?, 0, 'standard', 'DRAFT', now(), now()) + """, id, owner.tenantId(), owner.userId(), request.sessionId(), json(response)); + if (inserted != 1) throw new ServiceException("PERSONAL_EXPORT_CREATE_FAILED"); + return response; + } + + public ExportOutlineResponse outline(PersonalOwner owner, long exportId) { + return load(owner, exportId); + } + + public ExportOutlineResponse updateOutline(PersonalOwner owner, long exportId, + ExportOutlineUpdateRequest request) { + ExportOutlineResponse current = load(owner, exportId); + if (current.confirmed() || !"DRAFT".equals(current.status())) { + throw new ServiceException("PERSONAL_OUTLINE_ALREADY_CONFIRMED"); + } + String title = clean(request == null ? null : request.title()); + List sections = sanitizeSections(request == null ? null : request.sections()); + if (title.isEmpty() || sections.isEmpty()) throw new ServiceException("PERSONAL_OUTLINE_INVALID"); + ExportOutlineResponse updated = new ExportOutlineResponse(current.id(), current.sessionId(), title, + sections, current.sources(), false, "DRAFT", null); + int changed = jdbc.update(""" + update aihr_personal_export_task set outline_json = ?, update_time = now() + where binary tenant_id = binary ? and owner_user_id = ? and id = ? + and outline_confirmed = 0 and status = 'DRAFT' + """, json(updated), owner.tenantId(), owner.userId(), exportId); + if (changed != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND"); + return updated; + } + + public void confirmOutline(PersonalOwner owner, long exportId) { + requireOwner(owner); + if (exportId <= 0) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND"); + int changed = jdbc.update(""" + update aihr_personal_export_task + set outline_confirmed = 1, status = 'CONFIRMED', confirmed_at = now(), update_time = now() + where binary tenant_id = binary ? and owner_user_id = ? and id = ? + and outline_confirmed = 0 and status = 'DRAFT' + """, owner.tenantId(), owner.userId(), exportId); + if (changed != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND"); + } + + private ExportOutlineResponse load(PersonalOwner owner, long exportId) { + requireOwner(owner); + if (exportId <= 0) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND"); + List> rows = jdbc.queryForList(""" + select outline_json, outline_confirmed, status, session_id, oss_id + from aihr_personal_export_task + where binary tenant_id = binary ? and owner_user_id = ? and id = ? + limit 1 + """, owner.tenantId(), owner.userId(), exportId); + if (rows.size() != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND"); + try { + Map row = rows.get(0); + ExportOutlineResponse outline = objectMapper.readValue(String.valueOf(row.get("outline_json")), + ExportOutlineResponse.class); + return new ExportOutlineResponse(outline.id(), outline.sessionId(), outline.title(), + outline.sections(), outline.sources(), truthy(row.get("outline_confirmed")), + clean(String.valueOf(row.get("status"))), nullableLong(row.get("oss_id"))); + } catch (JsonProcessingException exception) { + throw new ServiceException("PERSONAL_OUTLINE_INVALID"); + } + } + + private List sources(Object raw) { + if (raw == null) return List.of(); + try { + CitationResponse[] citations = objectMapper.readValue(String.valueOf(raw), CitationResponse[].class); + return Arrays.stream(citations) + .filter(citation -> citation != null && citation.domain() != null && citation.title() != null) + .map(citation -> new ExportOutlineSource(citation.domain(), citation.sourceId(), citation.itemId(), + clean(citation.title()), truncate(clean(citation.excerpt()), 300))) + .toList(); + } catch (JsonProcessingException exception) { + throw new ServiceException("PERSONAL_EXPORT_SOURCE_INVALID"); + } + } + + private static List bullets(String content) { + List result = new ArrayList<>(); + for (String line : content.split("\\R+")) { + String value = clean(line).replaceFirst("^[#*\\-\\d.、\\s]+", ""); + if (!value.isEmpty()) result.add(truncate(value, 240)); + if (result.size() == 8) break; + } + if (result.isEmpty()) result.add(truncate(content, 240)); + return List.copyOf(result); + } + + private static List sanitizeSections(List sections) { + if (sections == null) return List.of(); + List result = new ArrayList<>(); + for (ExportOutlineSection section : sections) { + if (section == null) continue; + String title = truncate(clean(section.title()), 80); + if (title.isEmpty()) continue; + List bullets = section.bullets() == null ? List.of() : section.bullets().stream() + .map(PersonalExportService::clean).filter(value -> !value.isEmpty()) + .limit(12).map(value -> truncate(value, 240)).toList(); + if (!bullets.isEmpty()) result.add(new ExportOutlineSection(title, bullets)); + if (result.size() == 12) break; + } + return List.copyOf(result); + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new ServiceException("PERSONAL_EXPORT_CREATE_FAILED"); + } + } + + private static void requireOwner(PersonalOwner owner) { + if (owner == null || owner.userId() <= 0 || clean(owner.tenantId()).isEmpty()) { + throw new ServiceException("PERSONAL_OWNER_REQUIRED"); + } + } + + private static long positiveId(long id) { + if (id <= 0) throw new ServiceException("PERSONAL_EXPORT_CREATE_FAILED"); + return id; + } + + private static boolean truthy(Object value) { + if (value instanceof Boolean bool) return bool; + if (value instanceof Number number) return number.intValue() == 1; + return "1".equals(clean(String.valueOf(value))) || "true".equalsIgnoreCase(clean(String.valueOf(value))); + } + + private static Long nullableLong(Object value) { + if (value instanceof Number number) return number.longValue(); + String text = clean(String.valueOf(value)); + if (text.isEmpty()) return null; + try { + return Long.parseLong(text); + } catch (NumberFormatException exception) { + return null; + } + } + + private static String clean(String value) { + return value == null || "null".equals(value) ? "" : value.trim(); + } + + private static String truncate(String value, int max) { + return value.length() <= max ? value : value.substring(0, max); + } +} 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 index c9163e79..eab69899 100644 --- 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 @@ -4,9 +4,12 @@ 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.domain.PersonalAssistantDto.ExportOutlineCreateRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest; 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.PersonalExportService; import org.dromara.aihr.personal.service.PersonalRetrievalService; import org.dromara.aihr.personal.service.PersonalSpaceService; import org.dromara.aihr.personal.service.PersonalUrlFetchService; @@ -46,12 +49,13 @@ class PersonalAssistantControllerTest { PersonalCleanupService cleanup = mock(PersonalCleanupService.class); org.dromara.aihr.personal.service.PersonalPdfOcrService pdfOcr = mock(org.dromara.aihr.personal.service.PersonalPdfOcrService.class); + PersonalExportService exports = mock(PersonalExportService.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, pdfOcr); + retrieval, answers, cleanup, pdfOcr, exports); controller.space(); controller.items(1, 20, null, null, null, null, null); controller.createText(new TextItemRequest("note", "body", null, List.of())); @@ -67,8 +71,12 @@ class PersonalAssistantControllerTest { controller.sessions(); controller.session(3L); controller.deleteSession(3L); + controller.createOutline(new ExportOutlineCreateRequest(3L, "工作汇报")); + controller.outline(7L); + controller.updateOutline(7L, new ExportOutlineUpdateRequest("工作汇报", List.of())); + controller.confirmOutline(7L); - verify(owners, times(15)).current(); + verify(owners, times(19)).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/PersonalExportServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalExportServiceTest.java new file mode 100644 index 00000000..a7a3823a --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalExportServiceTest.java @@ -0,0 +1,139 @@ +package org.dromara.aihr.personal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSection; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest; +import org.dromara.aihr.personal.service.PersonalExportService; +import org.dromara.aihr.personal.support.PersonalOwner; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.lang.reflect.Constructor; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class PersonalExportServiceTest { + + private static final PersonalOwner OWNER = new PersonalOwner("000000", 101L, "13800000000"); + + @Test + void productionConstructorIsExplicitlyAutowired() { + assertTrue(List.of(PersonalExportService.class.getDeclaredConstructors()).stream() + .filter(constructor -> constructor.getParameterCount() == 2) + .map(Constructor::getAnnotations) + .flatMap(Arrays::stream) + .anyMatch(annotation -> annotation.annotationType() == Autowired.class)); + } + + @Test + void outlineCreationUsesOwnedSessionAndPersistsAuthorizedSources() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForList(contains("from aihr_personal_chat_session"), + eq("000000"), eq(101L), eq(77L))).thenReturn(List.of(Map.of("title", "保洁改进讨论"))); + when(jdbc.queryForList(contains("from aihr_personal_chat_message"), + eq("000000"), eq(101L), eq(77L))).thenReturn(List.of(Map.of( + "content", "先统一检查标准,再明确整改责任人。", + "citations_json", "[{\"domain\":\"PERSONAL\",\"sourceId\":\"9\",\"itemId\":9,\"title\":\"保洁检查记录\",\"excerpt\":\"每日复查\"}]"))); + when(jdbc.update(contains("insert into aihr_personal_export_task"), + anyLong(), anyString(), anyLong(), anyLong(), anyString())).thenReturn(1); + PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9001L); + + ExportOutlineResponse result = service.createOutline(OWNER, + new ExportOutlineCreateRequest(77L, "保洁管理改进建议")); + + assertEquals(9001L, result.id()); + assertEquals("保洁管理改进建议", result.title()); + assertEquals("保洁检查记录", result.sources().get(0).title()); + verify(jdbc).update(contains("insert into aihr_personal_export_task"), + eq(9001L), eq("000000"), eq(101L), eq(77L), anyString()); + } + + @Test + void outlineCreationRejectsSessionOwnedByAnotherUser() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForList(contains("from aihr_personal_chat_session"), + eq("000000"), eq(101L), eq(77L))).thenReturn(List.of()); + PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9001L); + + assertThrows(RuntimeException.class, () -> service.createOutline(OWNER, + new ExportOutlineCreateRequest(77L, "保洁管理改进建议"))); + + verify(jdbc, never()).update(contains("insert into aihr_personal_export_task"), + anyLong(), anyString(), anyLong(), anyLong(), anyString()); + } + + @Test + void editingDraftPreservesServerAuthorizedSources() throws Exception { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + String stored = "{\"id\":9001,\"sessionId\":77,\"title\":\"旧标题\",\"sections\":[{\"title\":\"核心结论\",\"bullets\":[\"旧内容\"]}],\"sources\":[{\"domain\":\"PERSONAL\",\"sourceId\":\"9\",\"itemId\":9,\"title\":\"保洁检查记录\",\"excerpt\":\"每日复查\"}],\"confirmed\":false,\"status\":\"DRAFT\",\"ossId\":null}"; + when(jdbc.queryForList(contains("from aihr_personal_export_task"), + eq("000000"), eq(101L), eq(9001L))).thenReturn(List.of(Map.of( + "outline_json", stored, "outline_confirmed", 0, "status", "DRAFT", + "session_id", 77L))); + when(jdbc.update(contains("set outline_json = ?"), anyString(), + eq("000000"), eq(101L), eq(9001L))).thenReturn(1); + PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L); + + ExportOutlineResponse result = service.updateOutline(OWNER, 9001L, + new ExportOutlineUpdateRequest("新标题", List.of( + new ExportOutlineSection("行动计划", List.of("明确责任人", "下周复盘"))))); + + assertEquals("新标题", result.title()); + assertEquals("保洁检查记录", result.sources().get(0).title()); + assertEquals("行动计划", result.sections().get(0).title()); + } + + @Test + void confirmingDraftIsOwnerScopedAndFreezesOutline() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.update(contains("outline_confirmed = 1"), + eq("000000"), eq(101L), eq(9001L))).thenReturn(1); + PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L); + + service.confirmOutline(OWNER, 9001L); + + verify(jdbc).update(contains("status = 'CONFIRMED'"), + eq("000000"), eq(101L), eq(9001L)); + } + + @Test + void readingOutlineUsesDatabaseWorkflowStateInsteadOfStaleJsonState() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + String stored = "{\"id\":9001,\"sessionId\":77,\"title\":\"保洁改进\",\"sections\":[{\"title\":\"核心结论\",\"bullets\":[\"明确标准\"]}],\"sources\":[{\"domain\":\"PERSONAL\",\"sourceId\":\"9\",\"itemId\":9,\"title\":\"保洁检查记录\",\"excerpt\":\"每日复查\"}],\"confirmed\":false,\"status\":\"DRAFT\",\"ossId\":null}"; + Map row = new HashMap<>(); + row.put("outline_json", stored); + row.put("outline_confirmed", 1); + row.put("status", "CONFIRMED"); + row.put("session_id", 77L); + row.put("oss_id", 5566L); + when(jdbc.queryForList(contains("from aihr_personal_export_task"), + eq("000000"), eq(101L), eq(9001L))).thenReturn(List.of(row)); + PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L); + + ExportOutlineResponse result = service.outline(OWNER, 9001L); + + assertTrue(result.confirmed()); + assertEquals("CONFIRMED", result.status()); + assertEquals(5566L, result.ossId()); + } +} 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 0e52cba9..5fdd4608 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 @@ -61,8 +61,11 @@ class PersonalSchemaContractTest { String cleanup = tableDefinition(sql, "aihr_personal_cleanup_job"); String ocrJob = tableDefinition(sql, "aihr_personal_ocr_job"); String ocrPage = tableDefinition(sql, "aihr_personal_ocr_page"); + String exportTask = tableDefinition(sql, "aihr_personal_export_task"); - for (String definition : new String[] {space, item, fragment, session, message, cleanup, ocrJob, ocrPage}) { + for (String definition : new String[] { + space, item, fragment, session, message, cleanup, ocrJob, ocrPage, exportTask + }) { assertTrue(definition.contains("`owner_user_id` bigint not null"), "Every personal table must carry a non-null owner_user_id"); } @@ -139,6 +142,16 @@ class PersonalSchemaContractTest { assertTrue(ocrPage.contains("unique key `uk_personal_ocr_page_number` (`tenant_id`, `owner_user_id`, `item_id`, `page_number`)")); assertTrue(ocrPage.contains("key `idx_personal_ocr_page_status` (`status`, `update_time`)")); + assertTrue(exportTask.contains("`session_id` bigint not null")); + assertTrue(exportTask.contains("`outline_json` json not null")); + assertTrue(exportTask.contains("`outline_confirmed` tinyint(1) not null default 0")); + assertTrue(exportTask.contains("`template_code` varchar(50) not null default 'standard'")); + assertTrue(exportTask.contains("`status` varchar(20) not null default 'draft'")); + assertTrue(exportTask.contains("`oss_id` bigint default null")); + assertTrue(exportTask.contains( + "key `idx_personal_export_owner` (`tenant_id`, `owner_user_id`, `update_time`)")); + assertTrue(exportTask.contains("key `idx_personal_export_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/script/sql/aihr_personal_knowledge_mysql8.sql b/backend/script/sql/aihr_personal_knowledge_mysql8.sql index 8712d9ea..86ad4c8f 100644 --- a/backend/script/sql/aihr_personal_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_personal_knowledge_mysql8.sql @@ -167,3 +167,22 @@ CREATE TABLE IF NOT EXISTS `aihr_personal_ocr_page` ( UNIQUE KEY `uk_personal_ocr_page_number` (`tenant_id`, `owner_user_id`, `item_id`, `page_number`), KEY `idx_personal_ocr_page_status` (`status`, `update_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理扫描PDF OCR页结果'; + +CREATE TABLE IF NOT EXISTS `aihr_personal_export_task` ( + `id` bigint NOT NULL COMMENT '导出任务ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `owner_user_id` bigint NOT NULL COMMENT '任务所属用户ID', + `session_id` bigint NOT NULL COMMENT '来源会话ID', + `outline_json` json NOT NULL COMMENT '可编辑大纲及不可变来源JSON', + `outline_confirmed` tinyint(1) NOT NULL DEFAULT 0 COMMENT '大纲是否已确认', + `template_code` varchar(50) NOT NULL DEFAULT 'standard' COMMENT 'PPT模板代码', + `status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/CONFIRMED/PENDING/RUNNING/READY/FAILED', + `oss_id` bigint DEFAULT NULL COMMENT '生成PPTX的私有OSS ID', + `error_message` varchar(500) DEFAULT NULL COMMENT '公开错误信息', + `confirmed_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`), + KEY `idx_personal_export_owner` (`tenant_id`, `owner_user_id`, `update_time`), + KEY `idx_personal_export_status` (`status`, `update_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理汇报导出任务'; diff --git a/mobile-uni/src/pages/user/assistant/index.vue b/mobile-uni/src/pages/user/assistant/index.vue index 174812f9..b1958fb3 100644 --- a/mobile-uni/src/pages/user/assistant/index.vue +++ b/mobile-uni/src/pages/user/assistant/index.vue @@ -60,14 +60,37 @@ 本次回答没有可展示的引用,不补造来源。 + + + + + 汇报提纲{{ outline.confirmed ? '已确认' : '可编辑' }} + + + + + + + + + 资料来源不可编辑 + {{ source.domain === 'ENTERPRISE' ? '企业知识' : '我的资料' }} · {{ source.title }} + + + + + + {{ outlineMessage }}