# 个人 AI 助理阶段二 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在不混用企业知识数据的前提下,为员工交付个人资料收藏、检索、带引用问答、工作整理,以及后续分享与 PPT 导出能力。 **Architecture:** 个人知识使用独立 MySQL 表、独立 Qdrant collection 和私有 OSS 前缀;只复用企业知识的无状态解析、模型与成本闸门。Mixed 问答先分别鉴权检索个人域和企业域,再融合已授权结果。 **Tech Stack:** Spring Boot / Java 21、JdbcTemplate、Sa-Token、MySQL 8 Fulltext、Qdrant REST、MinIO/sys_oss、Apache Tika、uni-app Vue 3、TypeScript、Node test runner。 **交互设计基准:** [工作助手语音优先与确认记忆设计图](prototypes/personal-assistant-work-helper-voice-first-v2.png)。该图用于现有确认式采集的视觉回归,不是当前生产截图;安全、权限、状态机和 API 冲突时以 [专项 TechSpec](个人AI助理阶段二专项TechSpec.md) 为准。 **当前边界:** Task 10 的“记一下”已复用 `/api/knowledge/query`、`aihr_memory_candidate` 和 `aihr_assistant_capture` 完成实现并部署;本计划其余个人文件、网页、独立 `/ask`、分享和 PPT 任务仍未实施,不得按计划文本推断已完成。项目名称选择、项目化会话、来源追溯和今日工作成果不在本文件重复规划,统一以[《工作助手与今日工作成果迭代计划》](工作助手与今日工作成果迭代计划-20260721.md)为施工入口。 --- ## 0. 执行边界 本计划按三个可独立验收的批次执行: | 批次 | 范围 | 完成闸门 | |---|---|---| | A:P0 数据地基 | 独立表、身份边界、文字/文件/网页收藏、解析、删除 | 两用户越权矩阵、SSRF、删除链路通过 | | B:P0 问答闭环 | 个人检索、企业授权融合、引用、会话、移动端页面 | 真实浏览器完成收藏→检索→问答→删除 | | C:P1 增强 | 分享入库、周报/月报提纲、PPTX 异步生成 | 审核链和 PPT 下载通过;不阻塞批次 A/B 发布 | 不进入本计划:北森、考勤、请假、组织任职数据接入;这些属于阶段三。 ## 1. 文件结构 ### 后端新增 | 文件 | 职责 | |---|---| | backend/script/sql/aihr_personal_knowledge_mysql8.sql | 个人知识表、索引、状态约束 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java | 登录态 API,不接受 owner 参数 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java | 请求、响应、引用类型 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalOwner.java | tenantId/userId/extPartyId 值对象 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalOwnerProvider.java | 从 LoginHelper 获取所有者 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java | 配额、超时、collection 配置 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java | 空间、资料 CRUD、配额与所有权 SQL | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionService.java | 入队和状态推进 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java | 持久化队列消费、解析和向量化 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalUrlFetchService.java | URL 校验、抓取和正文清洗 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java | 个人 Qdrant collection 的 upsert/query/delete | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java | 个人 Fulltext/向量检索 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAnswerService.java | personal/enterprise/mixed 融合和引用 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalCleanupService.java | 逻辑删除后的向量、片段和 OSS 清理 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantAuditController.java | 聚合指标和带工单的紧急审计 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAuditService.java | 审计权限、不可变日志和一次性查看 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/KnowledgeDocumentParser.java | 企业/个人共用的无状态解析接口 | | backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/TikaKnowledgeDocumentParser.java | Tika 实现 | ### 移动端新增 | 文件 | 职责 | |---|---| | mobile-uni/src/services/personal-assistant.ts | API 客户端和类型归一化 | | mobile-uni/src/pages/user/sop/index.vue | 复用既有微信式会话,作为工作助手主入口;内部来源默认自动路由 | | mobile-uni/src/pages/user/assistant/library.vue | 个人资料列表和筛选 | | mobile-uni/src/pages/user/assistant/capture.vue | 文字、文件、网页收藏 | | mobile-uni/src/pages/user/assistant/item.vue | 资料详情、状态、重试、删除 | | mobile-uni/src/pages/user/assistant/sessions.vue | 历史会话 | | mobile-uni/tests/personal-assistant.test.mjs | 页面和 API 源码契约测试 | --- ### Task 1: 建立独立数据库结构与 reset 导入 **Files:** - Create: backend/script/sql/aihr_personal_knowledge_mysql8.sql - Modify: scripts/reset-dev-db.sh - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java - [ ] **Step 1: 写失败的 SQL 契约测试** 在 PersonalSchemaContractTest.java 写入: package org.dromara.aihr.personal; import org.junit.jupiter.api.Test; import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertTrue; class PersonalSchemaContractTest { @Test void schemaUsesIndependentTablesAndOwnerIndexes() throws Exception { Path root = Path.of("").toAbsolutePath(); while (root != null && !Files.exists(root.resolve("backend/script/sql"))) { root = root.getParent(); } assertTrue(root != null, "repository root not found"); String sql = Files.readString(root.resolve( "backend/script/sql/aihr_personal_knowledge_mysql8.sql")); assertTrue(sql.contains("aihr_personal_space")); assertTrue(sql.contains("aihr_personal_item")); assertTrue(sql.contains("aihr_personal_fragment")); assertTrue(sql.contains("aihr_personal_chat_session")); assertTrue(sql.contains("aihr_personal_chat_message")); assertTrue(sql.contains("owner_user_id")); assertTrue(sql.contains("FULLTEXT KEY")); assertTrue(!sql.contains("ALTER TABLE aihr_knowledge_fragment")); } } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalSchemaContractTest test Expected: FAIL,提示 aihr_personal_knowledge_mysql8.sql 不存在。 - [ ] **Step 3: 创建独立表 DDL** 创建 aihr_personal_knowledge_mysql8.sql,至少完整包含: CREATE TABLE IF NOT EXISTS aihr_personal_space ( id bigint NOT NULL AUTO_INCREMENT, tenant_id varchar(20) NOT NULL, owner_user_id bigint NOT NULL, owner_ext_party_id varchar(100) DEFAULT NULL, status varchar(20) NOT NULL DEFAULT 'ACTIVE', quota_bytes bigint NOT NULL DEFAULT 524288000, used_bytes bigint NOT NULL DEFAULT 0, item_count int NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_personal_space_owner (tenant_id, owner_user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS aihr_personal_item ( id bigint NOT NULL AUTO_INCREMENT, tenant_id varchar(20) NOT NULL, space_id bigint NOT NULL, owner_user_id bigint NOT NULL, source_type varchar(20) NOT NULL, title varchar(500) NOT NULL, original_url varchar(2000) DEFAULT NULL, oss_id bigint DEFAULT NULL, mime_type varchar(100) DEFAULT NULL, size_bytes bigint NOT NULL DEFAULT 0, content_hash varchar(64) NOT NULL, status varchar(20) NOT NULL DEFAULT 'QUEUED', attempt_count int NOT NULL DEFAULT 0, error_code varchar(80) DEFAULT NULL, error_message varchar(500) DEFAULT NULL, summary text, tags_json json, captured_at datetime NOT NULL, parsed_at datetime DEFAULT NULL, deleted_at datetime DEFAULT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_personal_item_owner (tenant_id, owner_user_id, status, create_time), KEY idx_personal_item_date (tenant_id, owner_user_id, captured_at), KEY idx_personal_item_hash (space_id, content_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS aihr_personal_fragment ( id bigint NOT NULL AUTO_INCREMENT, tenant_id varchar(20) NOT NULL, space_id bigint NOT NULL, owner_user_id bigint NOT NULL, item_id bigint NOT NULL, idx int NOT NULL, content text NOT NULL, token_count int NOT NULL DEFAULT 0, embedding_json longtext, embedding_model varchar(100) DEFAULT NULL, embedding_time datetime DEFAULT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_personal_fragment_idx (item_id, idx), KEY idx_personal_fragment_owner (tenant_id, owner_user_id, item_id), FULLTEXT KEY ft_personal_fragment_content (content) WITH PARSER ngram ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS aihr_personal_chat_session ( id bigint NOT NULL AUTO_INCREMENT, tenant_id varchar(20) NOT NULL, owner_user_id bigint NOT NULL, title varchar(200) NOT NULL, default_scope_mode varchar(20) NOT NULL DEFAULT 'AUTO', default_scope_json json, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_personal_session_owner (tenant_id, owner_user_id, update_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS aihr_personal_chat_message ( id bigint NOT NULL AUTO_INCREMENT, tenant_id varchar(20) NOT NULL, owner_user_id bigint NOT NULL, session_id bigint NOT NULL, role varchar(20) NOT NULL, content longtext NOT NULL, scope_json json, citations_json json, model_name varchar(100) DEFAULT NULL, prompt_version varchar(100) DEFAULT NULL, input_tokens int NOT NULL DEFAULT 0, output_tokens int NOT NULL DEFAULT 0, latency_ms bigint NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_personal_message_owner (tenant_id, owner_user_id, session_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 在 reset-dev-db.sh 的知识库 SQL 导入之后增加: docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql" - [ ] **Step 4: 运行单测和真实 SQL 导入** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalSchemaContractTest test ./scripts/reset-dev-db.sh Expected: 单测 PASS;reset 完成;五张个人知识表存在,企业知识表未增加 owner 字段。 - [ ] **Step 5: 提交** git add backend/script/sql/aihr_personal_knowledge_mysql8.sql scripts/reset-dev-db.sh backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java git commit -m "feat(personal): add isolated personal knowledge schema" --- ### Task 2: 锁定服务端所有者身份 **Files:** - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalOwner.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalOwnerProvider.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalOwnerProviderTest.java - [ ] **Step 1: 写失败测试,证明 owner 不能来自请求体** package org.dromara.aihr.personal; import org.dromara.aihr.personal.domain.PersonalAssistantDto.TextItemRequest; import org.junit.jupiter.api.Test; import java.util.Arrays; import static org.junit.jupiter.api.Assertions.assertFalse; class PersonalOwnerProviderTest { @Test void requestDoesNotExposeOwnerOrTenantFields() { var names = Arrays.stream(TextItemRequest.class.getRecordComponents()) .map(component -> component.getName()) .toList(); assertFalse(names.contains("ownerUserId")); assertFalse(names.contains("tenantId")); } } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalOwnerProviderTest test Expected: FAIL,PersonalAssistantDto 不存在。 - [ ] **Step 3: 实现所有者值对象和 Provider** package org.dromara.aihr.personal.support; public record PersonalOwner(String tenantId, long userId, String extPartyId) { public PersonalOwner { if (tenantId == null || tenantId.isBlank() || userId <= 0) { throw new IllegalStateException("个人知识空间需要有效登录身份"); } } } package org.dromara.aihr.personal.support; import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.satoken.utils.LoginHelper; import org.springframework.stereotype.Component; @Component public class PersonalOwnerProvider { public PersonalOwner current() { LoginUser user = LoginHelper.getLoginUser(); if (user == null) { throw new IllegalStateException("请先登录"); } return new PersonalOwner(user.getTenantId(), user.getUserId(), null); } } 定义 PersonalAssistantDto 的 P0 records: public final class PersonalAssistantDto { private PersonalAssistantDto() {} public record TextItemRequest( String title, String content, java.time.LocalDateTime capturedAt, java.util.List tags) {} public record UrlItemRequest( String url, String title, java.time.LocalDateTime capturedAt) {} public record ItemCreatedResponse(long itemId, String status, Long duplicateOf) {} public enum ScopeMode { AUTO, MANUAL } public enum SearchScope { PERSONAL, PROJECT_SERVICE, ENTERPRISE } public record AskRequest( Long sessionId, String queryText, ScopeMode scopeMode, java.util.List scope, java.time.LocalDate dateFrom, java.time.LocalDate dateTo, java.util.List itemIds, String outputFormat) {} public record CitationResponse( String domain, String sourceId, String title, String excerpt, java.time.LocalDateTime capturedAt) {} public record AskResponse( long sessionId, String answer, java.util.List resolvedScopes, java.util.List citations, String model, String promptVersion) {} public record SpaceResponse( long spaceId, String status, long quotaBytes, long usedBytes, int itemCount) {} public record ItemResponse( long id, String sourceType, String title, String originalUrl, String mimeType, long sizeBytes, String status, String errorCode, String errorMessage, String summary, java.util.List tags, java.time.LocalDateTime capturedAt, java.time.LocalDateTime parsedAt) {} public record PageResponse( java.util.List rows, long total, int pageNum, int pageSize) {} public record DownloadUrlResponse(String url, java.time.LocalDateTime expiresAt) {} public record PersonalSearchRequest( String queryText, java.util.List scope, java.time.LocalDate dateFrom, java.time.LocalDate dateTo, java.util.List itemIds, Integer limit) {} public record SearchHitResponse( String domain, String sourceId, String title, String excerpt, java.time.LocalDateTime capturedAt, double score) {} public record PersonalSearchResponse( String queryText, java.util.List hits) {} public record SessionResponse( long id, String title, String defaultScopeMode, java.time.LocalDateTime updatedAt) {} public record SessionDetailResponse( long id, String title, java.util.List messages) {} public record ChatMessageResponse( long id, String role, String content, java.util.List citations, java.time.LocalDateTime createdAt) {} } - [ ] **Step 4: 运行测试与模块编译** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalOwnerProviderTest test mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am -DskipTests compile Expected: PASS;请求 DTO 不含 owner/tenant。 - [ ] **Step 5: 提交** git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalOwnerProviderTest.java git commit -m "feat(personal): derive ownership from login context" --- ### Task 3: 实现个人空间、配额和资料 CRUD **Files:** - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java - [ ] **Step 1: 写两个用户交叉访问的失败测试** @Test void itemLookupAlwaysUsesTenantAndOwner() { JdbcTemplate jdbc = mock(JdbcTemplate.class); PersonalSpaceService service = new PersonalSpaceService(jdbc, properties()); service.item(new PersonalOwner("000000", 101L, null), 9001L); verify(jdbc).queryForMap( contains("tenant_id = ? and owner_user_id = ? and id = ?"), eq("000000"), eq(101L), eq(9001L)); } @Test void quotaRejectsNewItemBeforeInsert() { JdbcTemplate jdbc = mock(JdbcTemplate.class); when(jdbc.queryForMap(contains("from aihr_personal_space"), any(), any())) .thenReturn(Map.of("id", 1L, "quota_bytes", 10L, "used_bytes", 10L, "item_count", 1)); PersonalSpaceService service = new PersonalSpaceService(jdbc, properties()); assertThrows(ServiceException.class, () -> service.reserve(new PersonalOwner("000000", 101L, null), 1L)); } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalSpaceServiceTest test Expected: FAIL,PersonalSpaceService 不存在。 - [ ] **Step 3: 实现配置和所有权 SQL** PersonalKnowledgeProperties 使用明确默认值: @lombok.Data @org.springframework.stereotype.Component @ConfigurationProperties(prefix = "aihr.personal") public class PersonalKnowledgeProperties { private long maxFileSizeMb = 20; private long maxUrlBodyMb = 10; private long maxSpaceMb = 500; private int maxItems = 1000; private int downloadUrlMinutes = 5; private String qdrantCollection = "aihr_personal_knowledge"; } PersonalSpaceService 的详情查询必须固定为: public Map item(PersonalOwner owner, long itemId) { try { return jdbcTemplate.queryForMap(""" select id, source_type, title, original_url, oss_id, mime_type, size_bytes, status, error_code, error_message, summary, tags_json, captured_at, parsed_at, create_time from aihr_personal_item where tenant_id = ? and owner_user_id = ? and id = ? and status <> 'DELETED' """, owner.tenantId(), owner.userId(), itemId); } catch (org.springframework.dao.EmptyResultDataAccessException ex) { throw new ServiceException("PERSONAL_ITEM_NOT_FOUND"); } } reserve 必须在事务内锁定 space: @Transactional public long reserve(PersonalOwner owner, long bytes) { Map space = ensureAndLockSpace(owner); long used = ((Number) space.get("used_bytes")).longValue(); long quota = ((Number) space.get("quota_bytes")).longValue(); int count = ((Number) space.get("item_count")).intValue(); if (bytes < 0 || used + bytes > quota || count >= properties.getMaxItems()) { throw new ServiceException("PERSONAL_SPACE_QUOTA_EXCEEDED"); } return ((Number) space.get("id")).longValue(); } - [ ] **Step 4: 运行测试** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalSpaceServiceTest test Expected: PASS;Mockito 验证所有详情 SQL 均带 tenant + owner。 - [ ] **Step 5: 提交** git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSpaceServiceTest.java git commit -m "feat(personal): add owner-scoped space and item storage" --- ### Task 4: 抽取无状态文档解析器 **Files:** - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/KnowledgeDocumentParser.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/ParsedDocument.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/TikaKnowledgeDocumentParser.java - Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/parse/TikaKnowledgeDocumentParserTest.java - [ ] **Step 1: 写失败的解析测试** @Test void parsesTextWithoutKnowingStorageDomain() { var parser = new TikaKnowledgeDocumentParser(); ParsedDocument doc = parser.parse( "note.txt", "text/plain", "收费沟通先说明费用构成".getBytes(StandardCharsets.UTF_8)); assertEquals("收费沟通先说明费用构成", doc.text().trim()); assertEquals("text/plain", doc.mimeType()); assertFalse(doc.chunks(800, 120).isEmpty()); } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=TikaKnowledgeDocumentParserTest test Expected: FAIL,解析器类型不存在。 - [ ] **Step 3: 实现解析接口** public interface KnowledgeDocumentParser { ParsedDocument parse(String fileName, String contentType, byte[] bytes); } public record ParsedDocument(String text, String mimeType, Map metadata) { public List chunks(int blockSize, int overlap) { if (blockSize <= 0 || overlap < 0 || overlap >= blockSize) { throw new IllegalArgumentException("invalid chunk settings"); } List result = new ArrayList<>(); for (int start = 0; start < text.length(); start += blockSize - overlap) { int end = Math.min(text.length(), start + blockSize); String chunk = text.substring(start, end).trim(); if (!chunk.isEmpty()) result.add(chunk); if (end == text.length()) break; } return result; } } TikaKnowledgeDocumentParser 使用现有 Tika 依赖解析;限制最大展开字符数,拒绝空内容。将 AihrSopSeedService 的文件解析入口改为调用该接口,但企业表写入逻辑保持原样。 - [ ] **Step 4: 跑解析器和企业知识回归测试** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=TikaKnowledgeDocumentParserTest,AihrSopSeedServiceTest test Expected: PASS;SOP 上传/解析契约没有回归。 - [ ] **Step 5: 提交** git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge git commit -m "refactor(aihr): extract shared document parser" --- ### Task 5: 实现文字、文件和异步解析队列 **Files:** - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java - [ ] **Step 1: 写状态机失败测试** @Test void failedItemRetriesThroughQueuedState() { JdbcTemplate jdbc = mock(JdbcTemplate.class); PersonalIngestionService service = new PersonalIngestionService(jdbc, spaceService, parser); service.retry(new PersonalOwner("000000", 101L, null), 9L); verify(jdbc).update(contains("status = 'QUEUED'"), eq("000000"), eq(101L), eq(9L), eq("FAILED")); } @Test void fileLargerThanConfiguredLimitIsRejectedBeforeOssUpload() { MultipartFile file = mock(MultipartFile.class); when(file.getSize()).thenReturn(21L * 1024 * 1024); assertThrows(ServiceException.class, () -> service.createFile(owner, file, null, null)); verifyNoInteractions(ossService); } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalIngestionServiceTest test Expected: FAIL,服务不存在。 - [ ] **Step 3: 实现持久化队列** 创建 item 时只完成:所有权、配额、hash、OSS、QUEUED。Worker 每次原子锁定一条: update aihr_personal_item set status = 'PARSING', attempt_count = attempt_count + 1, error_code = null, error_message = null where id = ? and status = 'QUEUED' 解析成功事务: delete from aihr_personal_fragment where tenant_id = ? and owner_user_id = ? and item_id = ?; -- 批量插入 chunks update aihr_personal_item set status = 'READY', parsed_at = now(), summary = ?, tags_json = ? where tenant_id = ? and owner_user_id = ? and id = ?; 解析失败只保存公开错误码: update aihr_personal_item set status = 'FAILED', error_code = ?, error_message = ? where tenant_id = ? and owner_user_id = ? and id = ?; - [ ] **Step 4: 运行测试和模块编译** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalIngestionServiceTest test mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am -DskipTests compile Expected: PASS;大文件不会先写 OSS;重试只允许 FAILED。 - [ ] **Step 5: 提交** git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionServiceTest.java git commit -m "feat(personal): ingest personal text and files asynchronously" --- ### Task 6: 实现安全网页采集 **Files:** - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalUrlFetchService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalUrlFetchServiceTest.java - [ ] **Step 1: 写 SSRF 失败测试** @ParameterizedTest @ValueSource(strings = { "http://127.0.0.1/admin", "http://localhost:8080", "http://169.254.169.254/latest/meta-data", "file:///etc/passwd", "ftp://example.com/a.txt" }) void blocksUnsafeUrls(String url) { assertThrows(ServiceException.class, () -> service.validate(url)); } @Test void revalidatesRedirectTarget() { FakeFetcher fetcher = new FakeFetcher() .respond("https://safe.example/a", 302, "http://10.0.0.1/internal"); assertThrows(ServiceException.class, () -> new PersonalUrlFetchService(fetcher, properties).fetch("https://safe.example/a")); } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalUrlFetchServiceTest test Expected: FAIL,URL 服务不存在。 - [ ] **Step 3: 实现 URL 规则** validate 必须执行: URI uri = URI.create(rawUrl); if (!Set.of("http", "https").contains(uri.getScheme()) || uri.getUserInfo() != null) { throw new ServiceException("PERSONAL_URL_BLOCKED"); } for (InetAddress address : resolver.resolve(uri.getHost())) { if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isLinkLocalAddress() || address.isSiteLocalAddress() || address.isMulticastAddress()) { throw new ServiceException("PERSONAL_URL_BLOCKED"); } } 抓取最多 3 次重定向;每次调用 validate;不转发 Cookie、Authorization、Referer;连接超时 5 秒、总超时 15 秒、正文上限 10MB。 - [ ] **Step 4: 运行 URL 安全测试** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalUrlFetchServiceTest test Expected: PASS;私网、元数据、非 HTTP 协议和恶意重定向全部被拒绝。 - [ ] **Step 5: 提交** git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalUrlFetchService.java backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalUrlFetchServiceTest.java git commit -m "feat(personal): add SSRF-safe web capture" --- ### Task 7: 实现个人 Qdrant 与检索 **Files:** - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalVectorStoreTest.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java - [ ] **Step 1: 写 owner filter 失败测试** @Test void qdrantQueryContainsTenantAndOwnerFilters() throws Exception { RecordingHttpClient client = new RecordingHttpClient(); PersonalVectorStore store = new PersonalVectorStore(client, mapper, properties); store.query(new PersonalOwner("000000", 101L, null), "[0.1,0.2]", 5); JsonNode body = mapper.readTree(client.lastBody()); String filter = body.path("filter").toString(); assertTrue(filter.contains("tenant_id")); assertTrue(filter.contains("000000")); assertTrue(filter.contains("owner_user_id")); assertTrue(filter.contains("101")); } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalVectorStoreTest,PersonalRetrievalServiceTest test Expected: FAIL,个人向量和检索服务不存在。 - [ ] **Step 3: 实现个人 collection** PersonalVectorStore 固定 collection 为配置值 aihr_personal_knowledge。filter 结构: { "must": [ {"key":"tenant_id","match":{"value":"000000"}}, {"key":"owner_user_id","match":{"value":101}} ] } PersonalRetrievalService 的 MySQL 查询固定带 owner: select f.id, f.item_id, i.title, i.source_type, i.captured_at, f.content, match(f.content) against (? in natural language mode) score from aihr_personal_fragment f join aihr_personal_item i on i.id = f.item_id and i.tenant_id = f.tenant_id and i.owner_user_id = f.owner_user_id where f.tenant_id = ? and f.owner_user_id = ? and i.status = 'READY' and (? is null or i.captured_at >= ?) and (? is null or i.captured_at < date_add(?, interval 1 day)) and match(f.content) against (? in natural language mode) order by score desc limit ? - [ ] **Step 4: 运行测试** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalVectorStoreTest,PersonalRetrievalServiceTest test Expected: PASS;测试用户 A 的查询 JSON 和 SQL 均不可能命中用户 B。 - [ ] **Step 5: 提交** git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalVectorStoreTest.java backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalRetrievalServiceTest.java git commit -m "feat(personal): add owner-filtered hybrid retrieval" --- ### Task 8: 实现带引用问答与企业知识授权融合 **Files:** - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAnswerService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java - Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java - Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java - [ ] **Step 1: 写 mixed 和无引用失败测试** @Test void mixedSearchLabelsCitationDomains() { when(personal.search(any(), any())).thenReturn(List.of(personalHit)); when(enterprise.searchAuthorized(any(), any())).thenReturn(List.of(enterpriseHit)); AskResponse response = service.ask(owner, mixedRequest()); assertEquals(List.of("PERSONAL", "ENTERPRISE"), response.citations().stream().map(CitationResponse::domain).toList()); } @Test void refusesBusinessAnswerWithoutAuthorizedCitations() { when(personal.search(any(), any())).thenReturn(List.of()); when(enterprise.searchAuthorized(any(), any())).thenReturn(List.of()); AskResponse response = service.ask(owner, personalRequest()); assertEquals("当前资料中没有足够依据", response.answer()); verifyNoInteractions(chatRuntime); } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalAnswerServiceTest test Expected: FAIL,PersonalAnswerService 不存在。 - [ ] **Step 3: 增加企业授权检索接口** 在 AihrSopSeedService 增加只返回当前登录用户有权访问结果的方法: // AihrSopDto.java public record AuthorizedKnowledgeHit(Long fragmentId, String title, String content) {} public List searchAuthorized( String queryText, String position, int limit) { SearchResponse response = search(new SearchRequest( queryText, "sop", position, "personal_assistant", limit)); return response.snippets().stream() .map(hit -> new AuthorizedKnowledgeHit( hit.fragmentId(), hit.title(), hit.text())) .toList(); } PersonalAnswerService 必须: 1. 分别检索两域。 2. 只融合返回的授权 hit。 3. 片段前加不可执行标签:[PERSONAL SOURCE] 或 [ENTERPRISE SOURCE]。 4. 要求 LLM 忽略资料内指令。 5. citations_json 保存域、sourceId、标题和摘录。 6. 无引用时不调用 LLM。 - [ ] **Step 4: 运行问答和 SOP 回归测试** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalAnswerServiceTest,AihrSopSeedServiceTest test Expected: PASS;mixed 引用域正确;无依据不生成答案。 - [ ] **Step 5: 提交** git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAnswerService.java backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAnswerServiceTest.java git commit -m "feat(personal): answer with authorized cross-domain citations" --- ### Task 9: 暴露认证 API 并完成删除链路 **Files:** - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalCleanupService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalCleanupServiceTest.java - [ ] **Step 1: 写删除幂等和 owner 约束测试** @Test void deletionHidesItemBeforeExternalCleanup() { service.requestDelete(new PersonalOwner("000000", 101L, null), 9L); verify(jdbc).update(contains("status = 'DELETING'"), eq("000000"), eq(101L), eq(9L), eq("READY")); verify(vectorStore, never()).deleteItem(any(), anyLong()); } @Test void cleanupIsIdempotent() { when(jdbc.queryForMap(contains("status = 'DELETING'"), anyLong())) .thenReturn(deletingItem); service.cleanup(9L); service.cleanup(9L); verify(vectorStore, atLeastOnce()).deleteItem(owner, 9L); } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalCleanupServiceTest test Expected: FAIL,清理服务不存在。 - [ ] **Step 3: 实现 Controller 和删除顺序** Controller 只通过 provider.current() 取得 owner: @RequiredArgsConstructor @RestController @RequestMapping("/api/aihr/personal-assistant") public class PersonalAssistantController { private final PersonalOwnerProvider ownerProvider; private final PersonalIngestionService ingestionService; private final PersonalAnswerService answerService; private final PersonalCleanupService cleanupService; @PostMapping("/items/text") public R createText(@RequestBody TextItemRequest request) { return R.ok(ingestionService.createText(ownerProvider.current(), request)); } @PostMapping("/ask") public R ask(@RequestBody AskRequest request) { return R.ok(answerService.ask(ownerProvider.current(), request)); } @DeleteMapping("/items/{id}") public R> delete(@PathVariable long id) { return R.ok(Map.of("cleanupJobId", cleanupService.requestDelete(ownerProvider.current(), id))); } } 同一 Controller 必须完成全部 P0 映射,且每个方法首参均来自 ownerProvider.current(): @GetMapping("/space") public R space(); @GetMapping("/items") public R> items( Integer pageNum, Integer pageSize, String status, String sourceType, LocalDate dateFrom, LocalDate dateTo, String keyword); @PostMapping(value = "/items/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public R createFile( @RequestPart("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) LocalDateTime capturedAt); @PostMapping("/items/url") public R createUrl(@RequestBody UrlItemRequest request); @GetMapping("/items/{id}") public R item(@PathVariable long id); @PostMapping("/items/{id}/retry") public R retry(@PathVariable long id); @GetMapping("/items/{id}/download-url") public R downloadUrl(@PathVariable long id); @PostMapping("/search") public R search(@RequestBody PersonalSearchRequest request); @GetMapping("/sessions") public R> sessions(); @GetMapping("/sessions/{id}") public R session(@PathVariable long id); @DeleteMapping("/sessions/{id}") public R deleteSession(@PathVariable long id); PersonalAssistantDto 必须同步定义上述 response/request records;分页响应只返回当前 owner 的数据。下载接口先校验 owner,再通过现有 OSS 服务生成 5 分钟私有 URL。 清理顺序固定:DELETING 隐藏 → Qdrant points → fragments → OSS → DELETED。每一步可重复执行。 - [ ] **Step 4: 运行测试与匿名 HTTP 负测** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalCleanupServiceTest test curl -sk -o /tmp/personal-anon.json -w '%{http_code}' https://wygj-api.localhost/api/aihr/personal-assistant/space Expected: 单测 PASS;匿名请求业务响应为 401,接口没有 @SaIgnore。 - [ ] **Step 5: 提交** git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalCleanupServiceTest.java git commit -m "feat(personal): expose authenticated API and deletion workflow" --- ### Task 10: 工作助手确认式采集(已完成,不重复施工) - 已复用 `ChatComposer`、`/api/knowledge/query`、`aihr_memory_candidate` 和 `aihr_assistant_capture` 完成确认式采集并部署。 - 当前生产能力、接口和限制以[专项 TechSpec](个人AI助理阶段二专项TechSpec.md)、[API 对接指南](API_INTEGRATION.md)和[BRD 功能审查](BRD_IMPLEMENTATION_AUDIT.md)为准。 - 本文件原有的页面脚手架、失败测试和重复实现步骤已删除,避免后续 Agent 创建第二套工作助手或把未实现的个人资料 API 当成现有接口。 - 后续项目选择、来源追溯、今日成果和主管项目视图只执行[工作助手迭代计划](工作助手与今日工作成果迭代计划-20260721.md)。 --- ### Task 11: 完成 P0 真实隔离验收与文档 **Files:** - Create: scripts/personal-assistant-smoke.sh - Modify: scripts/demo-check.sh - Modify: docs/API_INTEGRATION.md - Modify: docs/DEV_SETUP.md - Modify: docs/DEMO_ACCEPTANCE.md - [ ] **Step 1: 写失败的 smoke 脚本** 脚本必须创建用户 A/B,并断言:A 可见自己的 item;B 对 A 的详情、搜索、删除均得到 PERSONAL_ITEM_NOT_FOUND;私网 URL 得到 PERSONAL_URL_BLOCKED。 核心断言函数: expect_code() { actual="$1" expected="$2" label="$3" [ "$actual" = "$expected" ] || { echo "FAIL $label expected=$expected actual=$actual" >&2 exit 1 } } 脚本使用 trap 清理两个测试用户的个人 item、session、fragment、Qdrant point 和 sys_oss 测试对象。 - [ ] **Step 2: 在 API 未完整启动前验证脚本失败** Run: bash -n scripts/personal-assistant-smoke.sh ./scripts/personal-assistant-smoke.sh Expected: 语法 PASS;若服务未启动则明确失败于 health,不产生残留数据。 - [ ] **Step 3: 补 demo-check 和文档** demo-check 增加静态 marker: contains backend/script/sql/aihr_personal_knowledge_mysql8.sql "aihr_personal_fragment" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java "owner_user_id" contains mobile-uni/src/pages/user/sop/index.vue "资料范围" API_INTEGRATION 记录 API、owner 注入规则和 mixed 引用域;DEV_SETUP 记录 Qdrant collection、配额配置和 smoke 命令;DEMO_ACCEPTANCE 记录浏览器闭环。 - [ ] **Step 4: 跑完整 P0 gate** Run: ./scripts/dev.sh ./scripts/personal-assistant-smoke.sh npm --prefix mobile-uni run test:unit npm --prefix mobile-uni run typecheck npm --prefix mobile-uni run build:h5 mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am test git diff --check Expected: smoke 输出 PASS;用户 A/B 无交叉可见;SSRF 拦截;所有测试与构建通过。 - [ ] **Step 5: 真实浏览器验收** 使用内置浏览器依次验证: 1. 手机号 A 登录并确认岗位。 2. 收藏文字、PDF 和公开网页。 3. 等待 READY。 4. 按日期搜索。 5. 分别执行 PERSONAL、ENTERPRISE、mixed 问答并检查引用域。 6. 删除个人资料并确认立即不可搜索。 7. 手机号 B 登录,确认看不到 A 的资料标题、会话和引用。 Expected: 浏览器控制台无业务错误;失败提示明确;不出现无引用答案。 - [ ] **Step 6: 提交** git add scripts/personal-assistant-smoke.sh scripts/demo-check.sh docs/API_INTEGRATION.md docs/DEV_SETUP.md docs/DEMO_ACCEPTANCE.md git commit -m "test(personal): close personal knowledge pilot gates" --- ### Task 12: P1 分享与企业入库审核 **Files:** - Modify: backend/script/sql/aihr_personal_knowledge_mysql8.sql - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPublishService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPublishServiceTest.java - Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java - Create: frontend/src/api/aihr/personal.ts - Modify: frontend/src/views/knowledge/processing.vue - Modify: mobile-uni/src/services/personal-assistant.ts - Modify: mobile-uni/src/pages/user/assistant/item.vue - [ ] **Step 1: 写审核链失败测试** @Test void approvalCreatesEnterpriseCopyWithoutChangingPersonalOwner() { service.approve(88L, reviewer, "脱敏后可入库"); verify(enterprisePublisher).publishSanitizedCopy(1201L, reviewer); verify(jdbc, never()).update(contains("update aihr_personal_item set owner_user_id")); verify(jdbc).update(contains("status = 'APPROVED'"), eq(reviewer.userId()), eq("脱敏后可入库"), eq(88L), eq("PENDING")); } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalPublishServiceTest test Expected: FAIL,发布服务和表不存在。 - [ ] **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。 批准事务顺序:锁定 PENDING → 读取本人资料 → 脱敏副本 → 调企业知识发布器 → 写 published_knowledge_id → APPROVED。失败保持 PENDING 并返回错误,不修改个人 item。 员工资料详情增加明确的“申请沉淀企业知识”按钮;管理端复用资料处理页增加“个人知识入库审核”面板,只展示申请人、标题、来源类型、申请原因和脱敏预览,批准/驳回调用: export const reviewPersonalPublishRequest = ( id: number, payload: { approved: boolean; comment: string } ) => request({ url: '/api/aihr/personal-assistant/admin/publish-requests/' + id + '/review', method: 'post', data: payload }); - [ ] **Step 4: 运行测试** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalPublishServiceTest,AihrSopSeedServiceTest test npm --prefix frontend run build:dev npm --prefix mobile-uni run typecheck Expected: PASS;批准生成企业副本;个人 item owner 不变;员工可查看申请状态;HR/运营可审核。 - [ ] **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" --- ### Task 13: P1 汇报提纲与 PPTX 异步生成 **Files:** - Modify: backend/ruoyi-modules/ruoyi-aihr/pom.xml - Modify: backend/script/sql/aihr_personal_knowledge_mysql8.sql - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalExportService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalExportServiceTest.java - Modify: mobile-uni/src/services/personal-assistant.ts - Modify: mobile-uni/src/pages/user/sop/index.vue - [ ] **Step 1: 写“先大纲后 PPT”的失败测试** @Test void pptRequiresConfirmedOutlineOwnedByCurrentUser() { ExportRequest request = new ExportRequest(77L, false, "银城标准汇报"); assertThrows(ServiceException.class, () -> service.enqueuePpt(owner, request)); verifyNoInteractions(ossService); } @Test void generatedPptContainsTitleAndSourceSlide() throws Exception { byte[] bytes = service.renderPpt(confirmedOutline); try (XMLSlideShow ppt = new XMLSlideShow(new ByteArrayInputStream(bytes))) { assertEquals("保洁管理改进建议", ppt.getSlides().getFirst().getTitle()); assertTrue(ppt.getSlides().stream() .anyMatch(slide -> slide.getTitle().contains("资料来源"))); } } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalExportServiceTest test Expected: FAIL,导出服务和 Apache POI 类型不存在。 - [ ] **Step 3: 增加直接依赖和导出任务表** 在 ruoyi-aihr/pom.xml 增加: org.apache.poi poi-ooxml 5.4.1 新增 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: 实现两阶段导出** 1. POST /exports/outline:根据已授权引用生成结构化大纲。 2. 用户编辑并确认 outline。 3. POST /exports/pptx:仅接受本人且 outline_confirmed=true 的任务。 4. Worker 使用 XMLSlideShow 生成标题、结论、行动项、待确认项和资料来源页。 5. 文件写 private OSS,下载 URL 有效 5 分钟。 PPT 不嵌入无权访问的企业原文,只保留标题、受控摘录和来源标识。 - [ ] **Step 5: 运行 P1 检查** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalExportServiceTest test npm --prefix mobile-uni run typecheck npm --prefix mobile-uni run build:h5 Expected: PASS;未确认大纲不能生成;PPTX 可被 POI 重新打开;包含资料来源页。 - [ ] **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/sop/index.vue git commit -m "feat(personal): export reviewed outlines to pptx" --- ### Task 14: 补齐运营聚合与紧急审计 **Files:** - Modify: backend/script/sql/aihr_personal_knowledge_mysql8.sql - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantAuditController.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAuditService.java - Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAuditServiceTest.java - [ ] **Step 1: 写无权限、无工单和日志不可变测试** @Test void auditViewRequiresTicketAndReason() { assertThrows(ServiceException.class, () -> service.auditView(auditor, 1201L, "", "调查投诉")); assertThrows(ServiceException.class, () -> service.auditView(auditor, 1201L, "INC-2026-001", "")); verifyNoInteractions(itemReader); } @Test void auditLogNeverStoresContent() { service.auditView(auditor, 1201L, "INC-2026-001", "调查投诉"); verify(jdbc).update(contains("insert into aihr_personal_audit_log"), eq(auditor.tenantId()), eq(auditor.userId()), eq(targetUserId), eq(1201L), eq("VIEW"), eq("调查投诉"), eq("INC-2026-001"), eq("SUCCESS")); } - [ ] **Step 2: 运行测试确认失败** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalAuditServiceTest test Expected: FAIL,审计服务和表不存在。 - [ ] **Step 3: 添加不可变审计表和受保护 Controller** DDL: CREATE TABLE IF NOT EXISTS aihr_personal_audit_log ( id bigint NOT NULL AUTO_INCREMENT, tenant_id varchar(20) NOT NULL, auditor_user_id bigint NOT NULL, target_owner_user_id bigint NOT NULL, item_id bigint NOT NULL, action varchar(30) NOT NULL, reason varchar(500) NOT NULL, ticket_no varchar(100) NOT NULL, result varchar(30) NOT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_personal_audit_ticket (tenant_id, ticket_no, create_time), KEY idx_personal_audit_target (tenant_id, target_owner_user_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; Controller 强制权限: @SaCheckPermission("aihr:personal:audit") @PostMapping("/admin/items/{id}/audit-view") public R auditView( @PathVariable long id, @RequestBody AuditViewRequest request) { return R.ok(auditService.auditView( ownerProvider.current(), id, request.ticketNo(), request.reason())); } metrics 只能返回聚合数字;audit log 没有 update/delete API;审计内容响应使用一次性短时 URL,不能进入普通列表缓存。 - [ ] **Step 4: 运行权限与审计测试** Run: mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalAuditServiceTest test mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am -DskipTests compile Expected: PASS;无权限、空工单、空原因均拒绝;日志不含正文。 - [ ] **Step 5: 提交** git add backend/script/sql/aihr_personal_knowledge_mysql8.sql backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantAuditController.java backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAuditService.java backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalAuditServiceTest.java git commit -m "feat(personal): add immutable emergency audit trail" --- ## 2. TechSpec 覆盖矩阵 | TechSpec 能力 | 实施任务 | |---|---| | 独立 MySQL 表、reset 导入 | Task 1 | | 登录态所有者、禁止客户端 owner | Task 2–3、Task 9 | | 共用解析器但不共用存储 | Task 4–5 | | 文字、文件、图片收藏 | Task 5 | | 网页抓取与 SSRF 防护 | Task 6 | | 个人 Fulltext、Qdrant owner filter | Task 7 | | personal/enterprise/mixed 与引用 | Task 8 | | 删除、配额、OSS/Qdrant 清理 | Task 3、Task 9 | | mobile-uni 收藏、资料、问答、会话 | Task 10 | | 两用户真实隔离与浏览器验收 | Task 11 | | 分享、脱敏、企业入库审核 | Task 12 | | 提纲、PPTX 和私有下载 | Task 13 | | 聚合指标和紧急审计 | Task 14 | 自审结论:TechSpec P0、P1 和生产安全项均有对应任务;北森、考勤等阶段三能力明确排除,没有未分配需求。 ## 3. 最终完成定义 ### P0 工程完成 - [ ] 独立个人表和个人 Qdrant collection 已建立。 - [ ] 所有 API owner 来自服务端登录态。 - [ ] 两个用户的列表、详情、全文、向量、会话、删除均无交叉可见。 - [ ] 文字、文件、图片、网页链接可异步解析并可重试。 - [ ] URL 私网、元数据地址和恶意重定向全部被拒绝。 - [ ] PERSONAL / ENTERPRISE / mixed 问答返回正确引用域。 - [ ] 无授权引用时不调用 LLM 生成业务结论。 - [ ] 删除立即隐藏,24 小时内清理片段、Qdrant 和 OSS。 - [ ] mobile-uni 真实浏览器闭环通过。 ### P1 工程完成 - [ ] 分享/企业入库必须审核并生成脱敏企业副本。 - [ ] 个人 item 所有权不因发布改变。 - [ ] 周报/月报和汇报提纲可编辑、可追溯引用。 - [ ] PPTX 只能从已确认大纲生成。 - [ ] PPTX 保留资料来源页和私有下载策略。 - [ ] 运营后台只显示聚合指标;紧急查看必须带权限、工单和原因并生成不可变审计日志。 ### 业务试点完成 - [ ] 至少 20 名正式员工使用个人空间。 - [ ] 每人至少收藏 5 条资料并完成 2 次问答。 - [ ] 支持样本解析成功率 ≥95%。 - [ ] 引用可访问率 100%。 - [ ] 跨用户泄漏 0。 - [ ] URL 安全用例通过率 100%。 - [ ] 试点复盘确认个人资料与企业知识边界没有歧义。 ## 4. 执行顺序与停线规则 1. Task 1–3 完成前,不开发移动端页面。 2. Task 6 SSRF 未通过,不开放网页链接收藏。 3. Task 7 owner filter 未通过,不接入 Qdrant。 4. Task 8 企业权限融合未通过,只允许 PERSONAL 范围。 5. Task 11 P0 gate 未通过,不进入 20 人试点。 6. Task 12–13 属 P1,不能阻塞 P0 上线,也不能提前绕过审核和大纲确认。 7. Task 14 是生产前安全闸门;无不可变审计日志时不得开放紧急查看个人正文。 计划执行时每完成一个 Task 独立提交;不要把 schema、解析器重构、移动端 UI 和 P1 PPT 压成一个大提交。