feat(personal): add editable report outlines
This commit is contained in:
+28
@@ -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<SpaceResponse> space() {
|
||||
@@ -151,6 +157,28 @@ public class PersonalAssistantController {
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/exports/outline")
|
||||
public R<ExportOutlineResponse> createOutline(@RequestBody ExportOutlineCreateRequest request) {
|
||||
return R.ok(exportService.createOutline(owner(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/exports/{id}")
|
||||
public R<ExportOutlineResponse> outline(@PathVariable long id) {
|
||||
return R.ok(exportService.outline(owner(), id));
|
||||
}
|
||||
|
||||
@PutMapping("/exports/{id}/outline")
|
||||
public R<ExportOutlineResponse> updateOutline(@PathVariable long id,
|
||||
@RequestBody ExportOutlineUpdateRequest request) {
|
||||
return R.ok(exportService.updateOutline(owner(), id, request));
|
||||
}
|
||||
|
||||
@PostMapping("/exports/{id}/confirm")
|
||||
public R<Void> confirmOutline(@PathVariable long id) {
|
||||
exportService.confirmOutline(owner(), id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
private PersonalOwner owner() {
|
||||
return ownerProvider.current();
|
||||
}
|
||||
|
||||
+17
@@ -90,6 +90,23 @@ public final class PersonalAssistantDto {
|
||||
LocalDateTime createdAt) {
|
||||
}
|
||||
|
||||
public record ExportOutlineCreateRequest(long sessionId, String title) {
|
||||
}
|
||||
|
||||
public record ExportOutlineSection(String title, List<String> bullets) {
|
||||
}
|
||||
|
||||
public record ExportOutlineUpdateRequest(String title, List<ExportOutlineSection> sections) {
|
||||
}
|
||||
|
||||
public record ExportOutlineSource(String domain, String sourceId, Long itemId, String title, String excerpt) {
|
||||
}
|
||||
|
||||
public record ExportOutlineResponse(long id, long sessionId, String title, List<ExportOutlineSection> sections,
|
||||
List<ExportOutlineSource> sources, boolean confirmed, String status,
|
||||
Long ossId) {
|
||||
}
|
||||
|
||||
private static Long legacyId(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try {
|
||||
|
||||
+224
@@ -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<Map<String, Object>> 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<Map<String, Object>> 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<ExportOutlineSource> 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<ExportOutlineSection> 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<ExportOutlineSection> 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<Map<String, Object>> 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<String, Object> 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<ExportOutlineSource> 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<String> bullets(String content) {
|
||||
List<String> 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<ExportOutlineSection> sanitizeSections(List<ExportOutlineSection> sections) {
|
||||
if (sections == null) return List.of();
|
||||
List<ExportOutlineSection> result = new ArrayList<>();
|
||||
for (ExportOutlineSection section : sections) {
|
||||
if (section == null) continue;
|
||||
String title = truncate(clean(section.title()), 80);
|
||||
if (title.isEmpty()) continue;
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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());
|
||||
|
||||
+139
@@ -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<String, Object> 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());
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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`"),
|
||||
|
||||
@@ -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助理汇报导出任务';
|
||||
|
||||
Reference in New Issue
Block a user