feat(personal): add reviewed enterprise publishing

This commit is contained in:
2026-07-12 23:51:12 +08:00
parent 661980f43f
commit 39fec1f73a
15 changed files with 616 additions and 11 deletions
@@ -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<SpaceResponse> space() {
@@ -191,6 +195,17 @@ public class PersonalAssistantController {
return R.ok(exportService.downloadUrl(owner(), id));
}
@PostMapping("/items/{id}/publish-requests")
public R<PublishRequestResponse> requestPublish(@PathVariable long id,
@RequestBody PublishRequestCreateRequest request) {
return R.ok(publishService.request(owner(), id, request));
}
@GetMapping("/publish-requests")
public R<List<PublishRequestResponse>> publishRequests() {
return R.ok(publishService.requests(owner()));
}
private PersonalOwner owner() {
return ownerProvider.current();
}
@@ -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<List<PublishRequestResponse>> pending() {
return R.ok(publishService.pending(ownerProvider.current()));
}
@PostMapping("/{id}/review")
public R<PublishRequestResponse> review(@PathVariable long id, @RequestBody PublishReviewRequest request) {
return R.ok(publishService.review(ownerProvider.current(), id, request));
}
}
@@ -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 {
@@ -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<Map<String, Object>> 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<String, Object> 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<PublishRequestResponse> 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<PublishRequestResponse> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String> sanitized = new ArrayList<>();
for (Map<String, Object> 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<PublishRequestResponse> rows(List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String> 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<String> 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;
}
}
}
@@ -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());
@@ -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<List<String>> 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<String> 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"));
}
}
@@ -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`"),
@@ -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='个人知识申请沉淀企业知识审核';