58 KiB
个人 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。
交互设计基准: 工作助手语音优先与确认记忆设计图。该图用于现有确认式采集的视觉回归,不是当前生产截图;安全、权限、状态机和 API 冲突时以 专项 TechSpec 为准。
当前边界: Task 10 的“记一下”已复用 /api/knowledge/query、aihr_memory_candidate 和 aihr_assistant_capture 完成实现并部署;本计划其余个人文件、网页、独立 /ask、分享和 PPT 任务仍未实施,不得按计划文本推断已完成。项目名称选择、项目化会话、来源追溯和今日工作成果不在本文件重复规划,统一以《工作助手与今日工作成果迭代计划》为施工入口。
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 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<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 必须:
- 分别检索两域。
- 只融合返回的授权 hit。
- 片段前加不可执行标签:[PERSONAL SOURCE] 或 [ENTERPRISE SOURCE]。
- 要求 LLM 忽略资料内指令。
- citations_json 保存域、sourceId、标题和摘录。
- 无引用时不调用 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、API 对接指南和BRD 功能审查为准。
- 本文件原有的页面脚手架、失败测试和重复实现步骤已删除,避免后续 Agent 创建第二套工作助手或把未实现的个人资料 API 当成现有接口。
- 后续项目选择、来源追溯、今日成果和主管项目视图只执行工作助手迭代计划。
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: 真实浏览器验收
使用内置浏览器依次验证:
- 手机号 A 登录并确认岗位。
- 收藏文字、PDF 和公开网页。
- 等待 READY。
- 按日期搜索。
- 分别执行 PERSONAL、ENTERPRISE、mixed 问答并检查引用域。
- 删除个人资料并确认立即不可搜索。
- 手机号 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: 实现两阶段导出
- POST /exports/outline:根据已授权引用生成结构化大纲。
- 用户编辑并确认 outline。
- POST /exports/pptx:仅接受本人且 outline_confirmed=true 的任务。
- Worker 使用 XMLSlideShow 生成标题、结论、行动项、待确认项和资料来源页。
- 文件写 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. 执行顺序与停线规则
- Task 1–3 完成前,不开发移动端页面。
- Task 6 SSRF 未通过,不开放网页链接收藏。
- Task 7 owner filter 未通过,不接入 Qdrant。
- Task 8 企业权限融合未通过,只允许 PERSONAL 范围。
- Task 11 P0 gate 未通过,不进入 20 人试点。
- Task 12–13 属 P1,不能阻塞 P0 上线,也不能提前绕过审核和大纲确认。
- Task 14 是生产前安全闸门;无不可变审计日志时不得开放紧急查看个人正文。
计划执行时每完成一个 Task 独立提交;不要把 schema、解析器重构、移动端 UI 和 P1 PPT 压成一个大提交。