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
+6
View File
@@ -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>
@@ -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())
@@ -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"
+81 -2
View File
@@ -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, /资料来源不可编辑/);