feat(personal): export reviewed outlines to pptx

This commit is contained in:
2026-07-12 23:23:04 +08:00
parent 0e81fe66ee
commit 661980f43f
11 changed files with 469 additions and 15 deletions
@@ -7,6 +7,7 @@ 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.ExportPptRequest;
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse;
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse;
import org.dromara.aihr.personal.domain.PersonalAssistantDto.OcrProgressResponse;
@@ -179,6 +180,17 @@ public class PersonalAssistantController {
return R.ok();
}
@PostMapping("/exports/{id}/pptx")
public R<Void> enqueuePpt(@PathVariable long id, @RequestBody(required = false) ExportPptRequest request) {
exportService.enqueuePpt(owner(), id, request == null ? null : request.templateCode());
return R.ok();
}
@GetMapping("/exports/{id}/download-url")
public R<DownloadUrlResponse> exportDownloadUrl(@PathVariable long id) {
return R.ok(exportService.downloadUrl(owner(), id));
}
private PersonalOwner owner() {
return ownerProvider.current();
}
@@ -99,6 +99,9 @@ public final class PersonalAssistantDto {
public record ExportOutlineUpdateRequest(String title, List<ExportOutlineSection> sections) {
}
public record ExportPptRequest(String templateCode) {
}
public record ExportOutlineSource(String domain, String sourceId, Long itemId, String title, String excerpt) {
}
@@ -9,12 +9,33 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineRespon
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.domain.PersonalAssistantDto.DownloadUrlResponse;
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.entity.UploadResult;
import org.dromara.common.oss.enums.AccessPolicyType;
import org.dromara.common.oss.factory.OssFactory;
import org.apache.poi.sl.usermodel.TextParagraph.TextAlign;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.apache.poi.xslf.usermodel.XSLFSlide;
import org.apache.poi.xslf.usermodel.XSLFTextBox;
import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
import org.apache.poi.xslf.usermodel.XSLFTextRun;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.geom.Rectangle2D;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -27,21 +48,31 @@ public class PersonalExportService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final LongSupplier idSupplier;
private final ExportStorage storage;
@Autowired
public PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, IdWorker::getId);
public PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper,
PersonalKnowledgeProperties properties) {
this(jdbc, objectMapper, IdWorker::getId,
new DefaultExportStorage(jdbc, properties, PersonalExportService::ossClient));
}
private PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper, LongSupplier idSupplier) {
private PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper, LongSupplier idSupplier,
ExportStorage storage) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.idSupplier = idSupplier;
this.storage = storage;
}
public static PersonalExportService forTest(JdbcTemplate jdbc, ObjectMapper objectMapper,
LongSupplier idSupplier) {
return new PersonalExportService(jdbc, objectMapper, idSupplier);
return new PersonalExportService(jdbc, objectMapper, idSupplier, new UnavailableExportStorage());
}
public static PersonalExportService forTest(JdbcTemplate jdbc, ObjectMapper objectMapper,
LongSupplier idSupplier, ExportStorage storage) {
return new PersonalExportService(jdbc, objectMapper, idSupplier, storage);
}
public ExportOutlineResponse createOutline(PersonalOwner owner, ExportOutlineCreateRequest request) {
@@ -115,6 +146,129 @@ public class PersonalExportService {
if (changed != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
}
public void enqueuePpt(PersonalOwner owner, long exportId, String templateCode) {
ExportOutlineResponse outline = load(owner, exportId);
if (!outline.confirmed() || !("CONFIRMED".equals(outline.status()) || "FAILED".equals(outline.status()))) {
throw new ServiceException("PERSONAL_OUTLINE_NOT_CONFIRMED");
}
String template = clean(templateCode);
if (template.isEmpty()) template = "standard";
int changed = jdbc.update("""
update aihr_personal_export_task
set template_code = ?, status = 'PENDING', oss_id = null, error_message = null, update_time = now()
where binary tenant_id = binary ? and owner_user_id = ? and id = ?
and outline_confirmed = 1 and status in ('CONFIRMED','FAILED')
""", truncate(template, 50), owner.tenantId(), owner.userId(), exportId);
if (changed != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
}
public DownloadUrlResponse downloadUrl(PersonalOwner owner, long exportId) {
ExportOutlineResponse outline = load(owner, exportId);
if (!"READY".equals(outline.status()) || outline.ossId() == null) {
throw new ServiceException("PERSONAL_EXPORT_NOT_READY");
}
return storage.download(owner, exportId);
}
@Scheduled(fixedDelayString = "${aihr.personal.export-delay-ms:2000}", scheduler = "personalTaskScheduler")
public void processPending() {
List<Map<String, Object>> pending = jdbc.queryForList("""
select id, tenant_id, owner_user_id, outline_json
from aihr_personal_export_task
where outline_confirmed = 1 and status = 'PENDING'
order by update_time, id limit 5
""");
for (Map<String, Object> row : pending) process(row);
}
public byte[] renderPpt(ExportOutlineResponse outline) {
if (outline == null || !outline.confirmed()) throw new ServiceException("PERSONAL_OUTLINE_NOT_CONFIRMED");
try (XMLSlideShow ppt = new XMLSlideShow(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
ppt.setPageSize(new Dimension(960, 540));
XSLFSlide cover = ppt.createSlide();
addTitle(cover, outline.title(), 76, 150, 808, 100, 34, new Color(23, 31, 45));
addTitle(cover, "个人 AI 助理 · 汇报材料", 76, 270, 808, 50, 18, new Color(110, 119, 133));
for (ExportOutlineSection section : outline.sections()) {
XSLFSlide slide = ppt.createSlide();
addTitle(slide, section.title(), 64, 48, 832, 60, 26, new Color(23, 31, 45));
addBullets(slide, section.bullets(), 82, 130, 796, 340);
}
XSLFSlide sources = ppt.createSlide();
addTitle(sources, "资料来源", 64, 48, 832, 60, 26, new Color(23, 31, 45));
List<String> sourceLines = outline.sources().stream()
.map(source -> "[" + clean(source.domain()) + "] " + clean(source.title())
+ (clean(source.excerpt()).isEmpty() ? "" : " — " + clean(source.excerpt())))
.toList();
addBullets(sources, sourceLines, 82, 130, 796, 340);
ppt.write(output);
return output.toByteArray();
} catch (IOException exception) {
throw new ServiceException("PERSONAL_EXPORT_RENDER_FAILED");
}
}
private void process(Map<String, Object> row) {
long exportId = nullableLong(row.get("id")) == null ? 0 : nullableLong(row.get("id"));
long ownerId = nullableLong(row.get("owner_user_id")) == null ? 0 : nullableLong(row.get("owner_user_id"));
PersonalOwner owner = new PersonalOwner(clean(String.valueOf(row.get("tenant_id"))), ownerId, null);
int claimed = jdbc.update("""
update aihr_personal_export_task set status = 'RUNNING', update_time = now()
where id = ? and binary tenant_id = binary ? and owner_user_id = ? and status = 'PENDING'
""", exportId, owner.tenantId(), owner.userId());
if (claimed != 1) return;
try {
ExportOutlineResponse raw = objectMapper.readValue(String.valueOf(row.get("outline_json")),
ExportOutlineResponse.class);
ExportOutlineResponse confirmed = new ExportOutlineResponse(raw.id(), raw.sessionId(), raw.title(),
raw.sections(), raw.sources(), true, "RUNNING", null);
StoredExport stored = storage.store(owner, exportId, renderPpt(confirmed));
int completed = jdbc.update("""
update aihr_personal_export_task
set status = 'READY', oss_id = ?, error_message = null, update_time = now()
where id = ? and binary tenant_id = binary ? and owner_user_id = ? and status = 'RUNNING'
""", stored.ossId(), exportId, owner.tenantId(), owner.userId());
if (completed != 1) throw new ServiceException("PERSONAL_EXPORT_COMPLETE_FAILED");
} catch (Exception exception) {
jdbc.update("""
update aihr_personal_export_task
set status = 'FAILED', error_message = ?, update_time = now()
where id = ? and binary tenant_id = binary ? and owner_user_id = ? and status = 'RUNNING'
""", safeError(exception), exportId, owner.tenantId(), owner.userId());
}
}
private static void addTitle(XSLFSlide slide, String text, double x, double y, double width, double height,
double fontSize, Color color) {
XSLFTextBox box = slide.createTextBox();
box.setAnchor(new Rectangle2D.Double(x, y, width, height));
XSLFTextParagraph paragraph = box.addNewTextParagraph();
paragraph.setTextAlign(TextAlign.LEFT);
XSLFTextRun run = paragraph.addNewTextRun();
run.setText(clean(text));
run.setFontFamily("Microsoft YaHei");
run.setFontSize(fontSize);
run.setFontColor(color);
run.setBold(true);
}
private static void addBullets(XSLFSlide slide, List<String> bullets, double x, double y,
double width, double height) {
XSLFTextBox box = slide.createTextBox();
box.setAnchor(new Rectangle2D.Double(x, y, width, height));
for (String bullet : bullets == null ? List.<String>of() : bullets) {
XSLFTextParagraph paragraph = box.addNewTextParagraph();
paragraph.setBullet(true);
paragraph.setLeftMargin(28D);
paragraph.setIndent(-18D);
paragraph.setSpaceAfter(10D);
XSLFTextRun run = paragraph.addNewTextRun();
run.setText(truncate(clean(bullet), 360));
run.setFontFamily("Microsoft YaHei");
run.setFontSize(19D);
run.setFontColor(new Color(55, 65, 81));
}
}
private ExportOutlineResponse load(PersonalOwner owner, long exportId) {
requireOwner(owner);
if (exportId <= 0) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
@@ -221,4 +375,111 @@ public class PersonalExportService {
private static String truncate(String value, int max) {
return value.length() <= max ? value : value.substring(0, max);
}
private static String safeError(Exception exception) {
String message = clean(exception.getMessage());
return truncate(message.isEmpty() ? "PERSONAL_EXPORT_FAILED" : message, 500);
}
private static OssClient ossClient(String key) {
return key == null || key.isBlank() ? OssFactory.instance() : OssFactory.instance(key);
}
public interface ExportStorage {
StoredExport store(PersonalOwner owner, long exportId, byte[] bytes);
DownloadUrlResponse download(PersonalOwner owner, long exportId);
}
public record StoredExport(long ossId) {
}
@FunctionalInterface
interface OssClientProvider {
OssClient get(String configKey);
}
private static final class DefaultExportStorage implements ExportStorage {
private static final String PPTX_MIME =
"application/vnd.openxmlformats-officedocument.presentationml.presentation";
private final JdbcTemplate jdbc;
private final PersonalKnowledgeProperties properties;
private final OssClientProvider clients;
private DefaultExportStorage(JdbcTemplate jdbc, PersonalKnowledgeProperties properties,
OssClientProvider clients) {
this.jdbc = jdbc;
this.properties = properties;
this.clients = clients;
}
@Override
public StoredExport store(PersonalOwner owner, long exportId, byte[] bytes) {
OssClient client = privateClient();
long ossId = IdWorker.getId();
String objectKey = "personal/" + owner.tenantId() + "/" + owner.userId()
+ "/exports/" + exportId + ".pptx";
UploadResult upload = client.upload(new ByteArrayInputStream(bytes), objectKey,
(long) bytes.length, PPTX_MIME);
try {
int inserted = jdbc.update("""
insert into sys_oss
(oss_id, tenant_id, file_name, original_name, file_suffix, url, ext1,
create_time, create_by, update_time, update_by, service)
values (?, ?, ?, ?, '.pptx', ?, '{"domain":"PERSONAL_EXPORT"}',
now(), ?, now(), ?, ?)
""", ossId, owner.tenantId(), objectKey, "个人工作汇报.pptx", upload.getUrl(),
owner.userId(), owner.userId(), client.getConfigKey());
if (inserted != 1) throw new ServiceException("PERSONAL_EXPORT_OSS_FAILED");
return new StoredExport(ossId);
} catch (RuntimeException exception) {
client.delete(objectKey);
throw exception;
}
}
@Override
public DownloadUrlResponse download(PersonalOwner owner, long exportId) {
List<Map<String, Object>> rows = jdbc.queryForList("""
select o.file_name, o.service
from aihr_personal_export_task e
join sys_oss o on o.oss_id = e.oss_id and binary o.tenant_id = binary e.tenant_id
and o.create_by = e.owner_user_id
where binary e.tenant_id = binary ? and e.owner_user_id = ? and e.id = ?
and e.status = 'READY' and e.oss_id is not null
limit 1
""", owner.tenantId(), owner.userId(), exportId);
if (rows.size() != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_READY");
Map<String, Object> row = rows.get(0);
OssClient client = clients.get(clean(String.valueOf(row.get("service"))));
requirePrivate(client);
int minutes = Math.max(1, Math.min(60, properties.getDownloadUrlMinutes()));
return new DownloadUrlResponse(client.createPresignedGetUrl(
clean(String.valueOf(row.get("file_name"))), Duration.ofMinutes(minutes)),
LocalDateTime.now().plusMinutes(minutes));
}
private OssClient privateClient() {
OssClient client = clients.get(clean(properties.getOssConfigKey()));
requirePrivate(client);
return client;
}
private static void requirePrivate(OssClient client) {
if (client == null || client.getAccessPolicy() != AccessPolicyType.PRIVATE) {
throw new ServiceException("PERSONAL_OSS_NOT_PRIVATE");
}
}
}
private static final class UnavailableExportStorage implements ExportStorage {
@Override
public StoredExport store(PersonalOwner owner, long exportId, byte[] bytes) {
throw new ServiceException("PERSONAL_EXPORT_STORAGE_UNAVAILABLE");
}
@Override
public DownloadUrlResponse download(PersonalOwner owner, long exportId) {
throw new ServiceException("PERSONAL_EXPORT_STORAGE_UNAVAILABLE");
}
}
}
@@ -6,6 +6,7 @@ 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.domain.PersonalAssistantDto.ExportPptRequest;
import org.dromara.aihr.personal.service.PersonalAnswerService;
import org.dromara.aihr.personal.service.PersonalCleanupService;
import org.dromara.aihr.personal.service.PersonalIngestionService;
@@ -75,8 +76,10 @@ class PersonalAssistantControllerTest {
controller.outline(7L);
controller.updateOutline(7L, new ExportOutlineUpdateRequest("工作汇报", List.of()));
controller.confirmOutline(7L);
controller.enqueuePpt(7L, new ExportPptRequest("standard"));
controller.exportDownloadUrl(7L);
verify(owners, times(19)).current();
verify(owners, times(21)).current();
assertFalse(PersonalAssistantController.class.isAnnotationPresent(SaIgnore.class));
for (var method : PersonalAssistantController.class.getDeclaredMethods()) {
assertFalse(method.isAnnotationPresent(SaIgnore.class), method.getName());
@@ -6,12 +6,17 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineRespon
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.service.PersonalExportService.ExportStorage;
import org.dromara.aihr.personal.service.PersonalExportService.StoredExport;
import org.dromara.aihr.personal.support.PersonalOwner;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.apache.poi.xslf.usermodel.XSLFTextShape;
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.io.ByteArrayInputStream;
import java.lang.reflect.Constructor;
import java.util.Arrays;
import java.util.HashMap;
@@ -28,6 +33,7 @@ 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.verifyNoInteractions;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -39,7 +45,6 @@ class PersonalExportServiceTest {
@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));
@@ -136,4 +141,67 @@ class PersonalExportServiceTest {
assertEquals("CONFIRMED", result.status());
assertEquals(5566L, result.ossId());
}
@Test
void pptRequiresConfirmedOutlineOwnedByCurrentUser() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ExportStorage storage = mock(ExportStorage.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)));
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L,
storage);
assertThrows(RuntimeException.class, () -> service.enqueuePpt(OWNER, 9001L, "standard"));
verifyNoInteractions(storage);
}
@Test
void generatedPptContainsTitleAndSourceSlide() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L,
mock(ExportStorage.class));
ExportOutlineResponse outline = new ExportOutlineResponse(9001L, 77L, "保洁管理改进建议",
List.of(new ExportOutlineSection("核心结论", List.of("统一检查标准", "明确整改责任人"))),
List.of(new org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSource(
"PERSONAL", "9", 9L, "保洁检查记录", "每日复查")), true, "CONFIRMED", null);
byte[] bytes = service.renderPpt(outline);
try (XMLSlideShow ppt = new XMLSlideShow(new ByteArrayInputStream(bytes))) {
String allText = ppt.getSlides().stream()
.flatMap(slide -> slide.getShapes().stream())
.filter(XSLFTextShape.class::isInstance)
.map(XSLFTextShape.class::cast)
.map(XSLFTextShape::getText)
.reduce("", (left, right) -> left + "\n" + right);
assertTrue(allText.contains("保洁管理改进建议"));
assertTrue(allText.contains("资料来源"));
assertTrue(allText.contains("保洁检查记录"));
}
}
@Test
void pendingExportIsRenderedStoredAndMarkedReady() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ExportStorage storage = mock(ExportStorage.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("status = 'PENDING'"))).thenReturn(List.of(Map.of(
"id", 9001L, "tenant_id", "000000", "owner_user_id", 101L, "outline_json", stored)));
when(jdbc.update(contains("status = 'RUNNING'"), eq(9001L), eq("000000"), eq(101L))).thenReturn(1);
PersonalOwner storedOwner = new PersonalOwner("000000", 101L, null);
when(storage.store(eq(storedOwner), eq(9001L), any(byte[].class))).thenReturn(new StoredExport(5566L));
when(jdbc.update(contains("status = 'READY'"), eq(5566L), eq(9001L),
eq("000000"), eq(101L))).thenReturn(1);
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L,
storage);
service.processPending();
verify(storage).store(eq(storedOwner), eq(9001L), any(byte[].class));
verify(jdbc).update(contains("status = 'READY'"), eq(5566L), eq(9001L),
eq("000000"), eq(101L));
}
}
@@ -15,6 +15,7 @@ import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Tag("dev")
@@ -31,7 +32,7 @@ class PersonalOwnerProviderTest {
.filter(type -> type.getSimpleName().endsWith("Request"))
.toList();
assertEquals(4, requestTypes.size());
assertFalse(requestTypes.isEmpty());
requestTypes.forEach(type -> {
var componentNames = Arrays.stream(type.getRecordComponents())
.map(component -> component.getName())