Files
prop-ai-hr/docs/个人AI助理阶段二开发推进计划.md
T

1362 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 个人 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<String> 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<SearchScope> scope,
java.time.LocalDate dateFrom, java.time.LocalDate dateTo,
java.util.List<Long> 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<SearchScope> resolvedScopes,
java.util.List<CitationResponse> 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<String> tags,
java.time.LocalDateTime capturedAt, java.time.LocalDateTime parsedAt) {}
public record PageResponse<T>(
java.util.List<T> rows, long total, int pageNum, int pageSize) {}
public record DownloadUrlResponse(String url, java.time.LocalDateTime expiresAt) {}
public record PersonalSearchRequest(
String queryText, java.util.List<SearchScope> scope,
java.time.LocalDate dateFrom, java.time.LocalDate dateTo,
java.util.List<Long> 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<SearchHitResponse> hits) {}
public record SessionResponse(
long id, String title, String defaultScopeMode,
java.time.LocalDateTime updatedAt) {}
public record SessionDetailResponse(
long id, String title, java.util.List<ChatMessageResponse> messages) {}
public record ChatMessageResponse(
long id, String role, String content,
java.util.List<CitationResponse> 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<String, Object> 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<String, Object> 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<String, String> metadata) {
public List<String> chunks(int blockSize, int overlap) {
if (blockSize <= 0 || overlap < 0 || overlap >= blockSize) {
throw new IllegalArgumentException("invalid chunk settings");
}
List<String> 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<AuthorizedKnowledgeHit> 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<ItemCreatedResponse> createText(@RequestBody TextItemRequest request) {
return R.ok(ingestionService.createText(ownerProvider.current(), request));
}
@PostMapping("/ask")
public R<AskResponse> ask(@RequestBody AskRequest request) {
return R.ok(answerService.ask(ownerProvider.current(), request));
}
@DeleteMapping("/items/{id}")
public R<Map<String, Long>> delete(@PathVariable long id) {
return R.ok(Map.of("cleanupJobId",
cleanupService.requestDelete(ownerProvider.current(), id)));
}
}
同一 Controller 必须完成全部 P0 映射,且每个方法首参均来自 ownerProvider.current():
@GetMapping("/space")
public R<SpaceResponse> space();
@GetMapping("/items")
public R<PageResponse<ItemResponse>> 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<ItemCreatedResponse> createFile(
@RequestPart("file") MultipartFile file,
@RequestParam(required = false) String title,
@RequestParam(required = false) LocalDateTime capturedAt);
@PostMapping("/items/url")
public R<ItemCreatedResponse> createUrl(@RequestBody UrlItemRequest request);
@GetMapping("/items/{id}")
public R<ItemResponse> item(@PathVariable long id);
@PostMapping("/items/{id}/retry")
public R<ItemResponse> retry(@PathVariable long id);
@GetMapping("/items/{id}/download-url")
public R<DownloadUrlResponse> downloadUrl(@PathVariable long id);
@PostMapping("/search")
public R<PersonalSearchResponse> search(@RequestBody PersonalSearchRequest request);
@GetMapping("/sessions")
public R<List<SessionResponse>> sessions();
@GetMapping("/sessions/{id}")
public R<SessionDetailResponse> session(@PathVariable long id);
@DeleteMapping("/sessions/{id}")
public R<Void> 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 增加:
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.4.1</version>
</dependency>
新增 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<AuditItemResponse> 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 压成一个大提交。