feat(personal): export reviewed outlines to pptx
This commit is contained in:
@@ -60,6 +60,12 @@
|
||||
<version>3.0.5</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
+12
@@ -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();
|
||||
}
|
||||
|
||||
+3
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+265
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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());
|
||||
|
||||
+69
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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())
|
||||
|
||||
@@ -1244,7 +1244,7 @@ Expected: PASS;批准生成企业副本;个人 item owner 不变;员工可
|
||||
- Modify: mobile-uni/src/services/personal-assistant.ts
|
||||
- Modify: mobile-uni/src/pages/user/assistant/index.vue
|
||||
|
||||
- [ ] **Step 1: 写“先大纲后 PPT”的失败测试**
|
||||
- [x] **Step 1: 写“先大纲后 PPT”的失败测试**
|
||||
|
||||
@Test
|
||||
void pptRequiresConfirmedOutlineOwnedByCurrentUser() {
|
||||
@@ -1265,7 +1265,7 @@ Expected: PASS;批准生成企业副本;个人 item owner 不变;员工可
|
||||
}
|
||||
}
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
- [x] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -1273,7 +1273,7 @@ Run:
|
||||
|
||||
Expected: FAIL,导出服务和 Apache POI 类型不存在。
|
||||
|
||||
- [ ] **Step 3: 增加直接依赖和导出任务表**
|
||||
- [x] **Step 3: 增加直接依赖和导出任务表**
|
||||
|
||||
在 ruoyi-aihr/pom.xml 增加:
|
||||
|
||||
@@ -1285,7 +1285,7 @@ Expected: FAIL,导出服务和 Apache POI 类型不存在。
|
||||
|
||||
新增 aihr_personal_export_task:tenant_id、owner_user_id、session_id、outline_json、outline_confirmed、template_code、status、oss_id、error_message、create_time、update_time。
|
||||
|
||||
- [ ] **Step 4: 实现两阶段导出**
|
||||
- [x] **Step 4: 实现两阶段导出**
|
||||
|
||||
1. POST /exports/outline:根据已授权引用生成结构化大纲。
|
||||
2. 用户编辑并确认 outline。
|
||||
@@ -1295,7 +1295,7 @@ Expected: FAIL,导出服务和 Apache POI 类型不存在。
|
||||
|
||||
PPT 不嵌入无权访问的企业原文,只保留标题、受控摘录和来源标识。
|
||||
|
||||
- [ ] **Step 5: 运行 P1 检查**
|
||||
- [x] **Step 5: 运行 P1 检查**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -1305,7 +1305,7 @@ Run:
|
||||
|
||||
Expected: PASS;未确认大纲不能生成;PPTX 可被 POI 重新打开;包含资料来源页。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
- [x] **Step 6: 提交**
|
||||
|
||||
git add backend/ruoyi-modules/ruoyi-aihr/pom.xml backend/script/sql/aihr_personal_knowledge_mysql8.sql backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalExportServiceTest.java mobile-uni/src/services/personal-assistant.ts mobile-uni/src/pages/user/assistant/index.vue
|
||||
git commit -m "feat(personal): export reviewed outlines to pptx"
|
||||
|
||||
@@ -82,15 +82,22 @@
|
||||
<button class="secondary-button" :disabled="outlineBusy" @click="saveOutline">保存修改</button>
|
||||
<button class="primary-button" :disabled="outlineBusy" @click="confirmOutline">确认大纲</button>
|
||||
</view>
|
||||
<view v-else class="outline-actions export-actions">
|
||||
<button v-if="outline.status === 'CONFIRMED' || outline.status === 'FAILED'" class="primary-button" :disabled="outlineBusy" @click="generatePpt">
|
||||
{{ outline.status === 'FAILED' ? '重新生成 PPT' : '生成 PPT' }}
|
||||
</button>
|
||||
<button v-if="outline.status === 'PENDING' || outline.status === 'RUNNING'" class="secondary-button" disabled>正在生成 PPT</button>
|
||||
<button v-if="outline.status === 'READY'" class="primary-button" :disabled="outlineBusy" @click="downloadPpt">下载 PPT</button>
|
||||
</view>
|
||||
<text v-if="outlineMessage" class="status-message">{{ outlineMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import type { PersonalAskResponse, PersonalCitation, PersonalExportOutline } from '@/types/api';
|
||||
import { askPersonalAssistant, confirmPersonalOutline, createPersonalOutline, updatePersonalOutline, type KnowledgeScope } from '@/services/personal-assistant';
|
||||
import { askPersonalAssistant, confirmPersonalOutline, createPersonalOutline, downloadPersonalExport, enqueuePersonalPpt, loadPersonalExport, updatePersonalOutline, type KnowledgeScope } from '@/services/personal-assistant';
|
||||
import { personalItemRoute } from '@/services/personal-assistant-id';
|
||||
|
||||
const scopes = ref<KnowledgeScope[]>(['PERSONAL']);
|
||||
@@ -102,6 +109,7 @@ const outline = ref<PersonalExportOutline>();
|
||||
const outlineBusy = ref(false);
|
||||
const outlineMessage = ref('');
|
||||
let requestGeneration = 0;
|
||||
let exportPollTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
|
||||
|
||||
const scopeKey = (value: KnowledgeScope[]) => value.join(',');
|
||||
@@ -110,6 +118,7 @@ const changeScope = () => {
|
||||
requestGeneration += 1;
|
||||
answer.value = undefined;
|
||||
outline.value = undefined;
|
||||
stopExportPolling();
|
||||
message.value = '';
|
||||
scopes.value = enterpriseEnabled.value ? ['PERSONAL'] : ['PERSONAL', 'ENTERPRISE'];
|
||||
};
|
||||
@@ -122,6 +131,7 @@ const submitQuestion = async () => {
|
||||
asking.value = true;
|
||||
answer.value = undefined;
|
||||
outline.value = undefined;
|
||||
stopExportPolling();
|
||||
message.value = '';
|
||||
try {
|
||||
const response = await askPersonalAssistant({ queryText: question, scope: scopeSnapshot, outputFormat: 'ACTION_PLAN' });
|
||||
@@ -206,6 +216,74 @@ const confirmOutline = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const stopExportPolling = () => {
|
||||
if (exportPollTimer) clearTimeout(exportPollTimer);
|
||||
exportPollTimer = undefined;
|
||||
};
|
||||
|
||||
const pollExport = async (id: string, attempts = 0) => {
|
||||
stopExportPolling();
|
||||
try {
|
||||
const latest = await loadPersonalExport(id);
|
||||
if (!outline.value || outline.value.id !== id) return;
|
||||
outline.value = latest;
|
||||
if (latest.status === 'READY') {
|
||||
outlineMessage.value = 'PPT 已生成,可下载 5 分钟短链';
|
||||
return;
|
||||
}
|
||||
if (latest.status === 'FAILED') {
|
||||
outlineMessage.value = 'PPT 生成失败,可重新生成';
|
||||
return;
|
||||
}
|
||||
if (attempts >= 40) {
|
||||
outlineMessage.value = 'PPT 仍在后台生成,请稍后重试';
|
||||
return;
|
||||
}
|
||||
exportPollTimer = setTimeout(() => void pollExport(id, attempts + 1), 1500);
|
||||
} catch (error) {
|
||||
outlineMessage.value = error instanceof Error ? error.message : 'PPT 状态查询失败';
|
||||
}
|
||||
};
|
||||
|
||||
const generatePpt = async () => {
|
||||
if (!outline.value?.confirmed || outlineBusy.value) return;
|
||||
outlineBusy.value = true;
|
||||
outlineMessage.value = '';
|
||||
try {
|
||||
await enqueuePersonalPpt(outline.value.id);
|
||||
outline.value.status = 'PENDING';
|
||||
outlineMessage.value = 'PPT 已进入后台生成队列';
|
||||
void pollExport(outline.value.id);
|
||||
} catch (error) {
|
||||
outlineMessage.value = error instanceof Error ? error.message : 'PPT 生成请求失败';
|
||||
} finally {
|
||||
outlineBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPpt = async () => {
|
||||
if (!outline.value || outline.value.status !== 'READY' || outlineBusy.value) return;
|
||||
outlineBusy.value = true;
|
||||
try {
|
||||
const download = await downloadPersonalExport(outline.value.id);
|
||||
uni.downloadFile({
|
||||
url: download.url,
|
||||
success: (result) => {
|
||||
if (result.statusCode >= 200 && result.statusCode < 300) {
|
||||
uni.openDocument({ filePath: result.tempFilePath, showMenu: true });
|
||||
} else {
|
||||
outlineMessage.value = 'PPT 下载失败,请重试';
|
||||
}
|
||||
},
|
||||
fail: () => { outlineMessage.value = 'PPT 下载失败,请重试'; }
|
||||
});
|
||||
} catch (error) {
|
||||
outlineMessage.value = error instanceof Error ? error.message : 'PPT 下载失败';
|
||||
} finally {
|
||||
outlineBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addOutlineSection = () => outline.value?.sections.push({ title: '', bullets: [''] });
|
||||
const addOutlineBullet = (sectionIndex: number) => outline.value?.sections[sectionIndex]?.bullets.push('');
|
||||
|
||||
@@ -223,6 +301,7 @@ const goBack = () => uni.navigateBack();
|
||||
const openLibrary = () => uni.navigateTo({ url: '/pages/user/assistant/library' });
|
||||
const openCapture = () => uni.navigateTo({ url: '/pages/user/assistant/capture' });
|
||||
const openSessions = () => uni.navigateTo({ url: '/pages/user/assistant/sessions' });
|
||||
onUnmounted(stopExportPolling);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -187,3 +187,19 @@ export const confirmPersonalOutline = (id: string) => apiRequest<void>({
|
||||
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/confirm`,
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
export const loadPersonalExport = (id: string) => apiRequest<PersonalExportOutline>({
|
||||
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
export const enqueuePersonalPpt = (id: string) => apiRequest<void>({
|
||||
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/pptx`,
|
||||
method: 'POST',
|
||||
data: { templateCode: 'standard' }
|
||||
});
|
||||
|
||||
export const downloadPersonalExport = (id: string) => apiRequest<{ url: string; expiresAt?: string }>({
|
||||
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/download-url`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -61,6 +61,11 @@ test('个人助理先生成可编辑大纲并在确认后冻结来源', async ()
|
||||
assert.match(client, /createPersonalOutline/);
|
||||
assert.match(client, /updatePersonalOutline/);
|
||||
assert.match(client, /confirmPersonalOutline/);
|
||||
assert.match(client, /enqueuePersonalPpt/);
|
||||
assert.match(client, /loadPersonalExport/);
|
||||
assert.match(client, /downloadPersonalExport/);
|
||||
assert.match(page, /生成 PPT/);
|
||||
assert.match(page, /下载 PPT/);
|
||||
assert.match(page, /生成汇报提纲/);
|
||||
assert.match(page, /确认大纲/);
|
||||
assert.match(page, /资料来源不可编辑/);
|
||||
|
||||
Reference in New Issue
Block a user