From 39fec1f73ac34717bb95778f1dda3e92718bc71a Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 12 Jul 2026 23:51:12 +0800 Subject: [PATCH] feat(personal): add reviewed enterprise publishing --- .../PersonalAssistantController.java | 15 + .../PersonalPublishAdminController.java | 39 +++ .../personal/domain/PersonalAssistantDto.java | 12 + .../service/PersonalPublishService.java | 267 ++++++++++++++++++ .../PersonalAssistantControllerTest.java | 10 +- .../personal/PersonalPublishServiceTest.java | 107 +++++++ .../personal/PersonalSchemaContractTest.java | 9 + .../sql/aihr_personal_knowledge_mysql8.sql | 20 ++ docs/个人AI助理阶段二开发推进计划.md | 10 +- frontend/src/api/aihr/personal.ts | 28 ++ frontend/src/views/knowledge/processing.vue | 60 +++- mobile-uni/src/pages/user/assistant/item.vue | 22 +- mobile-uni/src/services/personal-assistant.ts | 12 + mobile-uni/src/types/api.ts | 14 + mobile-uni/tests/personal-assistant.test.mjs | 2 + 15 files changed, 616 insertions(+), 11 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalPublishAdminController.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPublishService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPublishServiceTest.java create mode 100644 frontend/src/api/aihr/personal.ts diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java index 3f2eedaf..2e276bd9 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java @@ -8,6 +8,8 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreate 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.PublishRequestCreateRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestResponse; import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse; import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse; import org.dromara.aihr.personal.domain.PersonalAssistantDto.OcrProgressResponse; @@ -24,6 +26,7 @@ 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.PersonalPublishService; import org.dromara.aihr.personal.service.PersonalRetrievalService; import org.dromara.aihr.personal.service.PersonalSpaceService; import org.dromara.aihr.personal.service.PersonalUrlFetchService; @@ -63,6 +66,7 @@ public class PersonalAssistantController { private final PersonalCleanupService cleanupService; private final PersonalPdfOcrService pdfOcrService; private final PersonalExportService exportService; + private final PersonalPublishService publishService; @GetMapping("/space") public R space() { @@ -191,6 +195,17 @@ public class PersonalAssistantController { return R.ok(exportService.downloadUrl(owner(), id)); } + @PostMapping("/items/{id}/publish-requests") + public R requestPublish(@PathVariable long id, + @RequestBody PublishRequestCreateRequest request) { + return R.ok(publishService.request(owner(), id, request)); + } + + @GetMapping("/publish-requests") + public R> publishRequests() { + return R.ok(publishService.requests(owner())); + } + private PersonalOwner owner() { return ownerProvider.current(); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalPublishAdminController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalPublishAdminController.java new file mode 100644 index 00000000..de7e1b7e --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalPublishAdminController.java @@ -0,0 +1,39 @@ +package org.dromara.aihr.personal.controller; + +import cn.dev33.satoken.annotation.SaCheckRole; +import cn.dev33.satoken.annotation.SaMode; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishReviewRequest; +import org.dromara.aihr.personal.service.PersonalPublishService; +import org.dromara.aihr.personal.support.PersonalOwnerProvider; +import org.dromara.common.core.constant.TenantConstants; +import org.dromara.common.core.domain.R; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/aihr/personal-assistant/admin/publish-requests") +@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator"}, mode = SaMode.OR) +public class PersonalPublishAdminController { + + private final PersonalOwnerProvider ownerProvider; + private final PersonalPublishService publishService; + + @GetMapping + public R> pending() { + return R.ok(publishService.pending(ownerProvider.current())); + } + + @PostMapping("/{id}/review") + public R review(@PathVariable long id, @RequestBody PublishReviewRequest request) { + return R.ok(publishService.review(ownerProvider.current(), id, request)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java index e5513ea1..8fc0d4ee 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java @@ -110,6 +110,18 @@ public final class PersonalAssistantDto { Long ossId) { } + public record PublishRequestCreateRequest(String targetScope, String reason) { + } + + public record PublishReviewRequest(boolean approved, String comment) { + } + + public record PublishRequestResponse(long id, long itemId, String title, String sourceType, + String targetScope, String reason, String sanitizedPreview, String status, + Long reviewerUserId, String reviewComment, + Long publishedKnowledgeId, LocalDateTime createdAt) { + } + private static Long legacyId(String value) { if (value == null || value.isBlank()) return null; try { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPublishService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPublishService.java new file mode 100644 index 00000000..1b6f6167 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPublishService.java @@ -0,0 +1,267 @@ +package org.dromara.aihr.personal.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestCreateRequest; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestResponse; +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishReviewRequest; +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 org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.LongSupplier; + +@Service +public class PersonalPublishService { + + private static final String ALLOWED_SCOPE = "POSITION:生活顾问"; + + private final JdbcTemplate jdbc; + private final EnterprisePublisher publisher; + private final LongSupplier idSupplier; + + @Autowired + public PersonalPublishService(JdbcTemplate jdbc) { + this(jdbc, new DefaultEnterprisePublisher(jdbc), IdWorker::getId); + } + + private PersonalPublishService(JdbcTemplate jdbc, EnterprisePublisher publisher, LongSupplier idSupplier) { + this.jdbc = jdbc; + this.publisher = publisher; + this.idSupplier = idSupplier; + } + + public static PersonalPublishService forTest(JdbcTemplate jdbc, EnterprisePublisher publisher, + LongSupplier idSupplier) { + return new PersonalPublishService(jdbc, publisher, idSupplier); + } + + public PublishRequestResponse request(PersonalOwner owner, long itemId, PublishRequestCreateRequest request) { + requireOwner(owner); + String targetScope = clean(request == null ? null : request.targetScope()); + String reason = clean(request == null ? null : request.reason()); + if (!ALLOWED_SCOPE.equals(targetScope) || reason.isEmpty()) { + throw new ServiceException("PERSONAL_PUBLISH_REQUEST_INVALID"); + } + List> items = jdbc.queryForList(""" + select id, title, source_type from aihr_personal_item + where binary tenant_id = binary ? and owner_user_id = ? and id = ? and status = 'READY' + limit 1 + """, owner.tenantId(), owner.userId(), itemId); + if (items.size() != 1) throw new ServiceException("PERSONAL_ITEM_NOT_FOUND"); + Integer pending = jdbc.queryForObject(""" + select count(*) from aihr_personal_publish_request + where binary tenant_id = binary ? and applicant_user_id = ? and item_id = ? and status = 'PENDING' + """, Integer.class, owner.tenantId(), owner.userId(), itemId); + if (pending != null && pending > 0) throw new ServiceException("PERSONAL_PUBLISH_ALREADY_PENDING"); + long id = positive(idSupplier.getAsLong()); + int inserted = jdbc.update(""" + insert into aihr_personal_publish_request + (id, tenant_id, item_id, applicant_user_id, target_scope, reason, status, + create_time, update_time) + values (?, ?, ?, ?, ?, ?, 'PENDING', now(), now()) + """, id, owner.tenantId(), itemId, owner.userId(), targetScope, truncate(reason, 500)); + if (inserted != 1) throw new ServiceException("PERSONAL_PUBLISH_CREATE_FAILED"); + Map item = items.get(0); + return new PublishRequestResponse(id, itemId, clean(String.valueOf(item.get("title"))), + clean(String.valueOf(item.get("source_type"))), targetScope, reason, null, "PENDING", + null, null, null, LocalDateTime.now()); + } + + public List requests(PersonalOwner owner) { + requireOwner(owner); + return rows(jdbc.queryForList(""" + select r.*, i.title, i.source_type + from aihr_personal_publish_request r + join aihr_personal_item i on i.id = r.item_id and binary i.tenant_id = binary r.tenant_id + and i.owner_user_id = r.applicant_user_id + where binary r.tenant_id = binary ? and r.applicant_user_id = ? + order by r.create_time desc, r.id desc limit 100 + """, owner.tenantId(), owner.userId())); + } + + public List pending(PersonalOwner reviewer) { + requireOwner(reviewer); + return rows(jdbc.queryForList(""" + select r.*, i.title, i.source_type, + (select group_concat(pf.content order by pf.idx separator '\n') + from aihr_personal_fragment pf + where binary pf.tenant_id = binary r.tenant_id + and pf.owner_user_id = r.applicant_user_id and pf.item_id = r.item_id) preview_content + from aihr_personal_publish_request r + join aihr_personal_item i on i.id = r.item_id and binary i.tenant_id = binary r.tenant_id + and i.owner_user_id = r.applicant_user_id + where binary r.tenant_id = binary ? and r.status = 'PENDING' + order by r.create_time, r.id limit 200 + """, reviewer.tenantId())); + } + + @Transactional + public PublishRequestResponse review(PersonalOwner reviewer, long requestId, PublishReviewRequest request) { + requireOwner(reviewer); + String comment = clean(request == null ? null : request.comment()); + if (requestId <= 0 || comment.isEmpty()) throw new ServiceException("PERSONAL_PUBLISH_REVIEW_INVALID"); + List> rows = jdbc.queryForList(""" + select r.id, r.tenant_id, r.item_id, r.applicant_user_id, r.target_scope, r.reason, r.status, + i.title, i.source_type + from aihr_personal_publish_request r + join aihr_personal_item i on i.id = r.item_id and binary i.tenant_id = binary r.tenant_id + and i.owner_user_id = r.applicant_user_id + where r.id = ? and binary r.tenant_id = binary ? and r.status = 'PENDING' + limit 1 for update + """, requestId, reviewer.tenantId()); + if (rows.size() != 1) throw new ServiceException("PERSONAL_PUBLISH_NOT_FOUND"); + Map row = rows.get(0); + if (!request.approved()) { + int rejected = jdbc.update(""" + update aihr_personal_publish_request + set status = 'REJECTED', reviewer_user_id = ?, review_comment = ?, + review_time = now(), update_time = now() + where id = ? and status = 'PENDING' + """, reviewer.userId(), truncate(comment, 500), requestId); + if (rejected != 1) throw new ServiceException("PERSONAL_PUBLISH_NOT_FOUND"); + return response(row, "REJECTED", reviewer.userId(), comment, null); + } + long applicantId = number(row, "applicant_user_id"); + long itemId = number(row, "item_id"); + List> fragmentRows = jdbc.queryForList(""" + select content from aihr_personal_fragment + where binary tenant_id = binary ? and owner_user_id = ? and item_id = ? + order by idx + """, reviewer.tenantId(), applicantId, itemId); + List sanitized = new ArrayList<>(); + for (Map fragment : fragmentRows) { + String content = PersonalPromptSanitizer.sanitize(clean(String.valueOf(fragment.get("content")))); + if (!content.isEmpty()) sanitized.add(content); + } + if (sanitized.isEmpty() || sanitized.stream().anyMatch(PersonalPromptSanitizer::containsSensitive)) { + throw new ServiceException("PERSONAL_PUBLISH_SANITIZE_FAILED"); + } + String title = PersonalPromptSanitizer.sanitize(clean(String.valueOf(row.get("title")))); + String scope = clean(String.valueOf(row.get("target_scope"))); + if (!ALLOWED_SCOPE.equals(scope)) throw new ServiceException("PERSONAL_PUBLISH_SCOPE_FORBIDDEN"); + long knowledgeId = publisher.publish(reviewer.tenantId(), reviewer.userId(), requestId, + title, scope, List.copyOf(sanitized)); + int approved = jdbc.update(""" + update aihr_personal_publish_request + set status = 'APPROVED', reviewer_user_id = ?, review_comment = ?, + published_knowledge_id = ?, review_time = now(), update_time = now() + where id = ? and status = ? + """, reviewer.userId(), truncate(comment, 500), knowledgeId, requestId, "PENDING"); + if (approved != 1) throw new ServiceException("PERSONAL_PUBLISH_NOT_FOUND"); + return response(row, "APPROVED", reviewer.userId(), comment, knowledgeId); + } + + private static List rows(List> rows) { + return rows.stream().map(row -> response(row, clean(String.valueOf(row.get("status"))), + nullableLong(row.get("reviewer_user_id")), cleanNullable(row.get("review_comment")), + nullableLong(row.get("published_knowledge_id")))).toList(); + } + + private static PublishRequestResponse response(Map row, String status, Long reviewerId, + String comment, Long knowledgeId) { + Object created = row.get("create_time"); + LocalDateTime createdAt = created instanceof java.sql.Timestamp timestamp + ? timestamp.toLocalDateTime() : created instanceof LocalDateTime time ? time : null; + return new PublishRequestResponse(number(row, "id"), number(row, "item_id"), + clean(String.valueOf(row.get("title"))), clean(String.valueOf(row.get("source_type"))), + clean(String.valueOf(row.get("target_scope"))), clean(String.valueOf(row.get("reason"))), + sanitizedPreview(row.get("preview_content")), status, reviewerId, comment, knowledgeId, createdAt); + } + + private static String sanitizedPreview(Object value) { + String preview = PersonalPromptSanitizer.sanitize(clean(value == null ? null : String.valueOf(value))); + return preview.isEmpty() ? null : truncate(preview, 500); + } + + 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 number(Map row, String key) { + Object value = row.get(key); + if (!(value instanceof Number number)) throw new ServiceException("PERSONAL_PUBLISH_DATA_INVALID"); + return number.longValue(); + } + + private static Long nullableLong(Object value) { + return value instanceof Number number ? number.longValue() : null; + } + + private static String cleanNullable(Object value) { + String text = clean(value == null ? null : String.valueOf(value)); + return text.isEmpty() ? null : text; + } + + private static long positive(long value) { + if (value <= 0) throw new ServiceException("PERSONAL_PUBLISH_CREATE_FAILED"); + return value; + } + + 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); + } + + @FunctionalInterface + public interface EnterprisePublisher { + long publish(String tenantId, long reviewerUserId, long requestId, String title, + String targetScope, List sanitizedFragments); + } + + private static final class DefaultEnterprisePublisher implements EnterprisePublisher { + private final JdbcTemplate jdbc; + + private DefaultEnterprisePublisher(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public long publish(String tenantId, long reviewerUserId, long requestId, String title, + String targetScope, List sanitizedFragments) { + long knowledgeId = IdWorker.getId(); + String docId = UUID.randomUUID().toString().replace("-", ""); + int knowledge = jdbc.update(""" + insert into aihr_knowledge_info + (id, tenant_id, name, description, `separator`, overlap_char, retrieve_limit, + similarity_threshold, text_block_size, enable_hybrid, hybrid_alpha, system_prompt, + create_by, create_time, update_by, update_time, remark) + values (?, ?, ?, '由个人知识审核脱敏后生成', '\n\n', 120, 5, 0.5, 800, 1, 1.0, + '回答必须引用已审核片段,不得还原个人敏感信息。', ?, now(), ?, now(), ?) + """, knowledgeId, tenantId, truncate("个人沉淀 · " + title, 100), reviewerUserId, + reviewerUserId, "personal-publish:" + requestId); + if (knowledge != 1) throw new ServiceException("PERSONAL_PUBLISH_ENTERPRISE_FAILED"); + for (int index = 0; index < sanitizedFragments.size(); index++) { + int inserted = jdbc.update(""" + insert into aihr_knowledge_fragment + (tenant_id, knowledge_id, idx, doc_id, content, create_by, create_time, + update_by, update_time, remark) + values (?, ?, ?, ?, ?, ?, now(), ?, now(), ?) + """, tenantId, knowledgeId, index + 1, docId, sanitizedFragments.get(index), + reviewerUserId, reviewerUserId, "personal-publish:" + requestId); + if (inserted != 1) throw new ServiceException("PERSONAL_PUBLISH_ENTERPRISE_FAILED"); + } + String position = targetScope.substring("POSITION:".length()); + int acl = jdbc.update(""" + insert into aihr_knowledge_acl + (tenant_id, knowledge_id, access_scope, position_name, classification, + enabled, create_time, update_time) + values (?, ?, 'POSITION', ?, 'INTERNAL', 1, now(), now()) + """, tenantId, knowledgeId, position); + if (acl != 1) throw new ServiceException("PERSONAL_PUBLISH_ENTERPRISE_FAILED"); + return knowledgeId; + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAssistantControllerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAssistantControllerTest.java index 22c78b60..234e6209 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAssistantControllerTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAssistantControllerTest.java @@ -12,6 +12,7 @@ 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.PersonalPublishService; import org.dromara.aihr.personal.service.PersonalSpaceService; import org.dromara.aihr.personal.service.PersonalUrlFetchService; import org.dromara.aihr.personal.support.PersonalOwner; @@ -51,12 +52,13 @@ class PersonalAssistantControllerTest { org.dromara.aihr.personal.service.PersonalPdfOcrService pdfOcr = mock(org.dromara.aihr.personal.service.PersonalPdfOcrService.class); PersonalExportService exports = mock(PersonalExportService.class); + PersonalPublishService publishes = mock(PersonalPublishService.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, exports); + retrieval, answers, cleanup, pdfOcr, exports, publishes); controller.space(); controller.items(1, 20, null, null, null, null, null); controller.createText(new TextItemRequest("note", "body", null, List.of())); @@ -78,8 +80,12 @@ class PersonalAssistantControllerTest { controller.confirmOutline(7L); controller.enqueuePpt(7L, new ExportPptRequest("standard")); controller.exportDownloadUrl(7L); + controller.requestPublish(9L, + new org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestCreateRequest( + "POSITION:生活顾问", "沉淀经验")); + controller.publishRequests(); - verify(owners, times(21)).current(); + verify(owners, times(23)).current(); assertFalse(PersonalAssistantController.class.isAnnotationPresent(SaIgnore.class)); for (var method : PersonalAssistantController.class.getDeclaredMethods()) { assertFalse(method.isAnnotationPresent(SaIgnore.class), method.getName()); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPublishServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPublishServiceTest.java new file mode 100644 index 00000000..13537004 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPublishServiceTest.java @@ -0,0 +1,107 @@ +package org.dromara.aihr.personal; + +import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishReviewRequest; +import org.dromara.aihr.personal.service.PersonalPublishService; +import org.dromara.aihr.personal.service.PersonalPublishService.EnterprisePublisher; +import org.dromara.aihr.personal.controller.PersonalPublishAdminController; +import org.dromara.aihr.personal.support.PersonalOwner; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +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 PersonalPublishServiceTest { + + private static final PersonalOwner REVIEWER = new PersonalOwner("000000", 901L, null); + + @Test + void adminControllerRequiresHrOrSuperAdminRole() { + var annotation = PersonalPublishAdminController.class.getAnnotation(cn.dev33.satoken.annotation.SaCheckRole.class); + assertTrue(annotation != null); + assertEquals(cn.dev33.satoken.annotation.SaMode.OR, annotation.mode()); + assertTrue(List.of(annotation.value()).contains("superadmin")); + assertTrue(List.of(annotation.value()).contains("hr_operator")); + } + + @Test + void approvalCreatesSanitizedEnterpriseCopyWithoutChangingPersonalOwner() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + EnterprisePublisher publisher = mock(EnterprisePublisher.class); + when(jdbc.queryForList(contains("from aihr_personal_publish_request r"), eq(88L), eq("000000"))) + .thenReturn(List.of(Map.of( + "id", 88L, "tenant_id", "000000", "item_id", 1201L, "applicant_user_id", 101L, + "title", "投诉复盘", "source_type", "TEXT", "target_scope", "POSITION:生活顾问", + "reason", "沉淀优秀经验", "status", "PENDING"))); + when(jdbc.queryForList(contains("from aihr_personal_fragment"), + eq("000000"), eq(101L), eq(1201L))).thenReturn(List.of(Map.of( + "content", "联系人:张三,电话13800000000。投诉闭环要明确责任人。"))); + when(publisher.publish(eq("000000"), eq(901L), eq(88L), eq("投诉复盘"), + eq("POSITION:生活顾问"), org.mockito.ArgumentMatchers.anyList())) + .thenReturn(7001L); + when(jdbc.update(contains("status = 'APPROVED'"), eq(901L), eq("脱敏后可入库"), eq(7001L), + eq(88L), eq("PENDING"))).thenReturn(1); + PersonalPublishService service = PersonalPublishService.forTest(jdbc, publisher, () -> 5001L); + + var result = service.review(REVIEWER, 88L, new PublishReviewRequest(true, "脱敏后可入库")); + + assertEquals("APPROVED", result.status()); + assertEquals(7001L, result.publishedKnowledgeId()); + @SuppressWarnings("unchecked") ArgumentCaptor> fragments = ArgumentCaptor.forClass(List.class); + verify(publisher).publish(eq("000000"), eq(901L), eq(88L), eq("投诉复盘"), + eq("POSITION:生活顾问"), fragments.capture()); + assertFalse(fragments.getValue().get(0).contains("张三")); + assertFalse(fragments.getValue().get(0).contains("13800000000")); + verify(jdbc, never()).update(contains("update aihr_personal_item set owner_user_id"), + anyLong(), anyLong()); + } + + @Test + void approvalRejectsMissingTicketOrSensitiveResidualWithoutPublishing() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + EnterprisePublisher publisher = mock(EnterprisePublisher.class); + PersonalPublishService service = PersonalPublishService.forTest(jdbc, publisher, () -> 5001L); + + assertThrows(RuntimeException.class, + () -> service.review(REVIEWER, 88L, new PublishReviewRequest(true, ""))); + + verify(publisher, never()).publish(anyString(), anyLong(), anyLong(), anyString(), anyString(), + org.mockito.ArgumentMatchers.anyList()); + } + + @Test + void pendingReviewExposesOnlySanitizedPreview() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + EnterprisePublisher publisher = mock(EnterprisePublisher.class); + when(jdbc.queryForList(anyString(), eq("000000"))).thenReturn(List.of(Map.of( + "id", 88L, "item_id", 1201L, "title", "投诉复盘", "source_type", "TEXT", + "target_scope", "POSITION:生活顾问", "reason", "沉淀优秀经验", "status", "PENDING", + "preview_content", "姓名:张三,联系电话:13800000000。投诉闭环要明确责任人。"))); + PersonalPublishService service = PersonalPublishService.forTest(jdbc, publisher, () -> 5001L); + + var result = service.pending(REVIEWER).get(0); + + ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); + verify(jdbc).queryForList(sql.capture(), eq("000000")); + assertTrue(sql.getValue().contains("preview_content")); + assertEquals("[姓名],联系电话:[手机号]。投诉闭环要明确责任人。", result.sanitizedPreview()); + assertFalse(result.sanitizedPreview().contains("张三")); + assertFalse(result.sanitizedPreview().contains("13800000000")); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java index 5fdd4608..20152ab1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java @@ -62,6 +62,7 @@ class PersonalSchemaContractTest { String ocrJob = tableDefinition(sql, "aihr_personal_ocr_job"); String ocrPage = tableDefinition(sql, "aihr_personal_ocr_page"); String exportTask = tableDefinition(sql, "aihr_personal_export_task"); + String publishRequest = tableDefinition(sql, "aihr_personal_publish_request"); for (String definition : new String[] { space, item, fragment, session, message, cleanup, ocrJob, ocrPage, exportTask @@ -152,6 +153,14 @@ class PersonalSchemaContractTest { "key `idx_personal_export_owner` (`tenant_id`, `owner_user_id`, `update_time`)")); assertTrue(exportTask.contains("key `idx_personal_export_status` (`status`, `update_time`)")); + assertTrue(publishRequest.contains("`item_id` bigint not null")); + assertTrue(publishRequest.contains("`applicant_user_id` bigint not null")); + assertTrue(publishRequest.contains("`target_scope` varchar(100) not null")); + assertTrue(publishRequest.contains("`status` varchar(20) not null default 'pending'")); + assertTrue(publishRequest.contains("`published_knowledge_id` bigint default null")); + assertTrue(publishRequest.contains( + "key `idx_personal_publish_status` (`tenant_id`, `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`"), diff --git a/backend/script/sql/aihr_personal_knowledge_mysql8.sql b/backend/script/sql/aihr_personal_knowledge_mysql8.sql index 86ad4c8f..0185573e 100644 --- a/backend/script/sql/aihr_personal_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_personal_knowledge_mysql8.sql @@ -186,3 +186,23 @@ CREATE TABLE IF NOT EXISTS `aihr_personal_export_task` ( 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助理汇报导出任务'; + +CREATE TABLE IF NOT EXISTS `aihr_personal_publish_request` ( + `id` bigint NOT NULL COMMENT '发布申请ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `item_id` bigint NOT NULL COMMENT '个人资料ID', + `applicant_user_id` bigint NOT NULL COMMENT '申请人用户ID', + `target_scope` varchar(100) NOT NULL COMMENT '目标范围,阶段二仅POSITION:生活顾问', + `reason` varchar(500) NOT NULL COMMENT '申请原因', + `status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/APPROVED/REJECTED', + `reviewer_user_id` bigint DEFAULT NULL COMMENT '审核人用户ID', + `review_comment` varchar(500) DEFAULT NULL COMMENT '审核意见', + `review_time` datetime DEFAULT NULL COMMENT '审核时间', + `published_knowledge_id` bigint DEFAULT NULL COMMENT '企业知识库副本ID', + `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_publish_applicant` (`tenant_id`, `applicant_user_id`, `create_time`), + KEY `idx_personal_publish_status` (`tenant_id`, `status`, `update_time`), + KEY `idx_personal_publish_item` (`tenant_id`, `item_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人知识申请沉淀企业知识审核'; diff --git a/docs/个人AI助理阶段二开发推进计划.md b/docs/个人AI助理阶段二开发推进计划.md index 3c3b1b4a..a7b65064 100644 --- a/docs/个人AI助理阶段二开发推进计划.md +++ b/docs/个人AI助理阶段二开发推进计划.md @@ -1181,7 +1181,7 @@ Expected: 浏览器控制台无业务错误;失败提示明确;不出现无 - Modify: mobile-uni/src/services/personal-assistant.ts - Modify: mobile-uni/src/pages/user/assistant/item.vue -- [ ] **Step 1: 写审核链失败测试** +- [x] **Step 1: 写审核链失败测试** @Test void approvalCreatesEnterpriseCopyWithoutChangingPersonalOwner() { @@ -1192,7 +1192,7 @@ Expected: 浏览器控制台无业务错误;失败提示明确;不出现无 eq(reviewer.userId()), eq("脱敏后可入库"), eq(88L), eq("PENDING")); } -- [ ] **Step 2: 运行测试确认失败** +- [x] **Step 2: 运行测试确认失败** Run: @@ -1200,7 +1200,7 @@ Run: Expected: FAIL,发布服务和表不存在。 -- [ ] **Step 3: 添加 publish request 表和服务** +- [x] **Step 3: 添加 publish request 表和服务** 表字段固定为:tenant_id、item_id、applicant_user_id、target_scope、reason、status、reviewer_user_id、review_comment、review_time、published_knowledge_id、create_time、update_time。 @@ -1217,7 +1217,7 @@ Expected: FAIL,发布服务和表不存在。 data: payload }); -- [ ] **Step 4: 运行测试** +- [x] **Step 4: 运行测试** Run: @@ -1227,7 +1227,7 @@ Run: Expected: PASS;批准生成企业副本;个人 item owner 不变;员工可查看申请状态;HR/运营可审核。 -- [ ] **Step 5: 提交** +- [x] **Step 5: 提交** git add 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/PersonalPublishServiceTest.java frontend/src/api/aihr/personal.ts frontend/src/views/knowledge/processing.vue mobile-uni/src/services/personal-assistant.ts mobile-uni/src/pages/user/assistant/item.vue git commit -m "feat(personal): add reviewed enterprise publishing" diff --git a/frontend/src/api/aihr/personal.ts b/frontend/src/api/aihr/personal.ts new file mode 100644 index 00000000..2b30a156 --- /dev/null +++ b/frontend/src/api/aihr/personal.ts @@ -0,0 +1,28 @@ +import request from '@/utils/request'; + +export interface PersonalPublishRequestRow { + id: string; + itemId: string; + title: string; + sourceType: string; + targetScope: string; + reason: string; + sanitizedPreview?: string; + status: 'PENDING' | 'APPROVED' | 'REJECTED'; + reviewComment?: string; + publishedKnowledgeId?: string; + createdAt?: string; +} + +export const listPersonalPublishRequests = () => + request({ + url: '/api/aihr/personal-assistant/admin/publish-requests', + method: 'get' + }); + +export const reviewPersonalPublishRequest = (id: string, payload: { approved: boolean; comment: string }) => + request({ + url: `/api/aihr/personal-assistant/admin/publish-requests/${encodeURIComponent(id)}/review`, + method: 'post', + data: payload + }); diff --git a/frontend/src/views/knowledge/processing.vue b/frontend/src/views/knowledge/processing.vue index 003586ad..97587543 100644 --- a/frontend/src/views/knowledge/processing.vue +++ b/frontend/src/views/knowledge/processing.vue @@ -59,6 +59,31 @@ +
+
+
+

个人知识入库审核

+

只审核元数据与脱敏预览;批准后生成脱敏企业副本,不转移个人资料。

+
+ 刷新 +
+ + + + + + + + + + + + +
+
@@ -282,6 +307,7 @@ import { type ProcessingOverview } from '@/api/aihr/processing'; import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadItem } from '@/api/aihr/sop'; +import { listPersonalPublishRequests, reviewPersonalPublishRequest, type PersonalPublishRequestRow } from '@/api/aihr/personal'; const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v'; const autoCategory = '__auto__'; @@ -298,6 +324,8 @@ const loading = ref(false); const uploading = ref(false); const serverImporting = ref(false); const importTaskLoading = ref(false); +const publishLoading = ref(false); +const publishRequests = ref([]); const overview = ref(null); const overviewFailed = ref(false); const importTasks = ref([]); @@ -449,10 +477,40 @@ const filteredTasks = computed(() => displayOverview.value.tasks.filter((item) = const runningImport = computed(() => importTasks.value.some((item) => item.status === '导入中')); onMounted(async () => { - await Promise.all([loadOverview(), loadImportTasks()]); + await Promise.all([loadOverview(), loadImportTasks(), loadPublishRequests()]); scheduleImportPoll(); }); +async function loadPublishRequests() { + publishLoading.value = true; + try { + const response = await listPersonalPublishRequests(); + publishRequests.value = response.data ?? []; + } catch { + publishRequests.value = []; + } finally { + publishLoading.value = false; + } +} + +async function reviewPublish(row: PersonalPublishRequestRow, approved: boolean) { + try { + const result = await ElMessageBox.prompt( + approved ? '批准后将生成脱敏企业副本,请填写审核意见。' : '请填写驳回原因。', + approved ? '批准个人知识入库' : '驳回个人知识入库', + { + inputPlaceholder: approved ? '例如:脱敏后可入库' : '例如:内容不具备通用性', + inputValidator: (value) => Boolean(value?.trim()) || '请填写审核意见' + } + ); + await reviewPersonalPublishRequest(row.id, { approved, comment: result.value.trim() }); + ElMessage.success(approved ? '已批准并生成企业知识副本' : '已驳回'); + await loadPublishRequests(); + } catch (error) { + if (error !== 'cancel' && error !== 'close') ElMessage.error('审核失败,请重试'); + } +} + onUnmounted(() => { clearImportPoll(); clearBatchPoll(); diff --git a/mobile-uni/src/pages/user/assistant/item.vue b/mobile-uni/src/pages/user/assistant/item.vue index 66bab4b0..00f69a0a 100644 --- a/mobile-uni/src/pages/user/assistant/item.vue +++ b/mobile-uni/src/pages/user/assistant/item.vue @@ -25,6 +25,18 @@ 内容摘要{{ item.summary }} + + 沉淀企业知识 + 提交后由 HR/运营审核;批准时只生成脱敏企业副本,个人资料仍归你所有。 + + + 删除资料 删除后,附件、解析正文和搜索索引会清除,且无法恢复。 @@ -38,22 +50,25 @@