diff --git a/docs/README.md b/docs/README.md index 750065d6..ab9aa543 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ | [AI人力资源系统项目规划方案与AI接口说明.md](AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位、AI 接口和供应商规划 | | [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) | 三阶段总纲:陪练 → 大喇叭+知识学习平台(含学模块、开放问题榜、员工个人 AI 助理) → 北森对接;身份三轴模型与防遗漏分发策略 | | [个人AI助理阶段二专项TechSpec.md](个人AI助理阶段二专项TechSpec.md) | 员工个人知识空间的物理分域、数据模型、API、检索融合、权限、安全、删除和测试规格 | +| [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 个人 AI 助理阶段二按 P0/P1 拆分的逐任务实施、测试、提交和试点闸门 | | [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 | | [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | 当前用户侧 `mobile-uni/` 重建计划、三端页面拆分、portless 入口、真实浏览器验证和验收进展 | | [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 产品定义(学练问报+问题榜+学分晋升+个人 AI 助理)、半年会发布会方案分层及客户需求逐项处置对照 | diff --git a/docs/个人AI助理阶段二专项TechSpec.md b/docs/个人AI助理阶段二专项TechSpec.md index 0be58abd..b69dabc5 100644 --- a/docs/个人AI助理阶段二专项TechSpec.md +++ b/docs/个人AI助理阶段二专项TechSpec.md @@ -213,6 +213,10 @@ Message 保存: 状态固定为 `PENDING / APPROVED / REJECTED / CANCELLED`。批准后生成新的企业知识附件和片段,保留来源链,不改变个人 item 所有权。 +### 4.6 `aihr_personal_audit_log` + +紧急审计专用不可变日志:`tenant_id/auditor_user_id/target_owner_user_id/item_id/action/reason/ticket_no/result/create_time`。日志不保存个人正文、附件 URL、问题或答案内容;业务接口不提供更新和删除操作。 + ## 5. Qdrant 与 OSS 设计 ### 5.1 Qdrant @@ -331,10 +335,22 @@ Message 保存: |---|---|---| | POST | `/items/{id}/publish-requests` | 申请进入团队/企业知识库 | | GET | `/publish-requests` | 本人申请进度 | +| GET | `/admin/publish-requests` | HR/运营按 PENDING/APPROVED/REJECTED 查询审核列表 | +| POST | `/admin/publish-requests/{id}/review` | HR/运营批准或驳回;批准后生成脱敏企业知识副本 | | POST | `/exports/outline` | 生成可编辑汇报大纲 | | POST | `/exports/pptx` | 根据已确认大纲和模板异步生成 PPTX | | GET | `/exports/{id}` | 生成状态与下载地址 | +### 6.5 运营聚合与紧急审计 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/admin/metrics` | 仅返回用户数、资料数、状态、容量、失败率等聚合信息,不返回标题和正文 | +| POST | `/admin/items/{id}/audit-view` | 需要 `aihr:personal:audit` 权限、非空工单号和原因;先写审计日志,再返回一次性详情 | +| GET | `/admin/audit-logs` | 按审计人、目标用户、工单号和时间查询不可变日志 | + +紧急审计接口与员工接口使用不同 Controller;超级管理员也不能绕过权限、工单号和原因校验。 + ## 7. 网页采集安全 `PersonalUrlFetchService` 必须执行: diff --git a/docs/个人AI助理阶段二开发推进计划.md b/docs/个人AI助理阶段二开发推进计划.md new file mode 100644 index 00000000..47e9247e --- /dev/null +++ b/docs/个人AI助理阶段二开发推进计划.md @@ -0,0 +1,1459 @@ +# 个人 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。 + +--- + +## 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/assistant/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 varchar(50) NOT NULL DEFAULT 'PERSONAL', + 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 SearchScope { PERSONAL, ENTERPRISE } + + public record AskRequest( + Long sessionId, String queryText, 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 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 defaultScope, + 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: 实现 mobile-uni 个人助理页面 + +**Files:** +- Create: mobile-uni/src/services/personal-assistant.ts +- Create: mobile-uni/src/pages/user/assistant/index.vue +- Create: mobile-uni/src/pages/user/assistant/library.vue +- Create: mobile-uni/src/pages/user/assistant/capture.vue +- Create: mobile-uni/src/pages/user/assistant/item.vue +- Create: mobile-uni/src/pages/user/assistant/sessions.vue +- Create: mobile-uni/tests/personal-assistant.test.mjs +- Modify: mobile-uni/src/pages.json +- Modify: mobile-uni/src/pages/user/sop/index.vue +- Modify: mobile-uni/src/types/api.ts + +- [ ] **Step 1: 写失败的页面契约测试** + + import test from 'node:test'; + import assert from 'node:assert/strict'; + import { readFile } from 'node:fs/promises'; + + const source = (path) => readFile(new URL(path, import.meta.url), 'utf8'); + + test('个人助理页面提供范围切换、引用和收藏入口', async () => { + const page = await source('../src/pages/user/assistant/index.vue'); + assert.match(page, /我的资料/); + assert.match(page, /企业知识/); + assert.match(page, /收藏资料/); + assert.match(page, /citation\.domain/); + }); + + test('个人资料页面包含状态、重试和删除', async () => { + const page = await source('../src/pages/user/assistant/item.vue'); + assert.match(page, /PARSING|解析中/); + assert.match(page, /retryPersonalItem/); + assert.match(page, /deletePersonalItem/); + }); + +- [ ] **Step 2: 运行测试确认失败** + +Run: + + npm --prefix mobile-uni run test:unit + +Expected: FAIL,assistant 页面不存在。 + +- [ ] **Step 3: 实现 API 客户端** + +personal-assistant.ts 固定使用现有 apiRequest: + + export type KnowledgeScope = 'PERSONAL' | 'ENTERPRISE'; + + export const askPersonalAssistant = (payload: { + sessionId?: number; + queryText: string; + scope: KnowledgeScope[]; + dateFrom?: string; + dateTo?: string; + itemIds?: number[]; + outputFormat?: 'ANSWER' | 'ACTION_PLAN' | 'OUTLINE'; + }) => apiRequest({ + url: '/api/aihr/personal-assistant/ask', + method: 'POST', + data: payload, + timeout: 45000 + }); + + export const deletePersonalItem = (id: number) => + apiRequest<{ cleanupJobId: number }>({ + url: '/api/aihr/personal-assistant/items/' + id, + method: 'DELETE', + timeout: 15000 + }); + +- [ ] **Step 4: 实现页面交互** + +关键状态必须唯一: + + const scopes = ref(['PERSONAL']); + const queryText = ref(''); + const asking = ref(false); + const answer = ref(); + + const submitQuestion = async () => { + const question = queryText.value.trim(); + if (!question || asking.value) return; + asking.value = true; + try { + answer.value = await askPersonalAssistant({ + queryText: question, + scope: scopes.value, + outputFormat: 'ACTION_PLAN' + }); + } finally { + asking.value = false; + } + }; + +引用卡必须按 citation.domain 显示“我的资料 / 企业 SOP / 外部网页”。默认只选 PERSONAL;用户主动选择后才启用 mixed。 + +- [ ] **Step 5: 运行移动端检查** + +Run: + + npm --prefix mobile-uni run test:unit + npm --prefix mobile-uni run typecheck + npm --prefix mobile-uni run build:h5 + +Expected: 全部 PASS;无新增横向滚动;页面路由存在。 + +- [ ] **Step 6: 提交** + + git add mobile-uni/src/pages.json mobile-uni/src/pages/user/assistant mobile-uni/src/pages/user/sop/index.vue mobile-uni/src/services/personal-assistant.ts mobile-uni/src/types/api.ts mobile-uni/tests/personal-assistant.test.mjs + git commit -m "feat(mobile): add personal assistant knowledge flow" + +--- + +### 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/assistant/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/assistant/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/assistant/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 压成一个大提交。