feat(personal): add editable report outlines

This commit is contained in:
2026-07-12 23:03:36 +08:00
parent 2cb31f524c
commit 0868f0adb5
11 changed files with 601 additions and 5 deletions
@@ -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();
}
@@ -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 {
@@ -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);
}
}
@@ -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());
@@ -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());
}
}
@@ -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助理汇报导出任务';