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

62 KiB
Raw Blame History

个人 AI 助理阶段二 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在不混用企业知识数据的前提下,为员工交付个人资料收藏、检索、带引用问答、工作整理,以及后续分享与 PPT 导出能力。

Architecture: 个人知识使用独立 MySQL 表、独立 Qdrant collection 和私有 OSS 前缀;只复用企业知识的无状态解析、模型与成本闸门。Mixed 问答先分别鉴权检索个人域和企业域,再融合已授权结果。

Tech Stack: Spring Boot / Java 21、JdbcTemplate、Sa-Token、MySQL 8 Fulltext、Qdrant REST、MinIO/sys_oss、Apache Tika、uni-app Vue 3、TypeScript、Node test runner。


0. 执行边界

本计划按三个可独立验收的批次执行:

批次 范围 完成闸门
A:P0 数据地基 独立表、身份边界、文字/文件/网页收藏、解析、删除 两用户越权矩阵、SSRF、删除链路通过
B:P0 问答闭环 个人检索、企业授权融合、引用、会话、移动端页面 真实浏览器完成收藏→检索→问答→删除
C:P1 增强 分享入库、周报/月报提纲、PPTX 异步生成 审核链和 PPT 下载通过;不阻塞批次 A/B 发布

不进入本计划:北森、考勤、请假、组织任职数据接入;这些属于阶段三。

1. 文件结构

后端新增

文件 职责
backend/script/sql/aihr_personal_knowledge_mysql8.sql 个人知识表、索引、状态约束
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java 登录态 API,不接受 owner 参数
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java 请求、响应、引用类型
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalOwner.java tenantId/userId/extPartyId 值对象
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalOwnerProvider.java 从 LoginHelper 获取所有者
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/support/PersonalKnowledgeProperties.java 配额、超时、collection 配置
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java 空间、资料 CRUD、配额与所有权 SQL
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionService.java 入队和状态推进
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java 持久化队列消费、解析和向量化
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalUrlFetchService.java URL 校验、抓取和正文清洗
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java 个人 Qdrant collection 的 upsert/query/delete
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalRetrievalService.java 个人 Fulltext/向量检索
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAnswerService.java personal/enterprise/mixed 融合和引用
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalCleanupService.java 逻辑删除后的向量、片段和 OSS 清理
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantAuditController.java 聚合指标和带工单的紧急审计
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalAuditService.java 审计权限、不可变日志和一次性查看
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/KnowledgeDocumentParser.java 企业/个人共用的无状态解析接口
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/TikaKnowledgeDocumentParser.java Tika 实现

移动端新增

文件 职责
mobile-uni/src/services/personal-assistant.ts API 客户端和类型归一化
mobile-uni/src/pages/user/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 必须:

  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: 将现有「问」页升级为工作助手并接入个人助理

Files:

  • Create: mobile-uni/src/services/personal-assistant.ts

  • Create: mobile-uni/src/pages/user/assistant/library.vue

  • Create: mobile-uni/src/pages/user/assistant/capture.vue

  • Create: mobile-uni/src/pages/user/assistant/item.vue

  • Create: mobile-uni/src/pages/user/assistant/sessions.vue

  • Create: mobile-uni/tests/personal-assistant.test.mjs

  • Modify: mobile-uni/src/pages.json

  • Modify: mobile-uni/src/pages/user/sop/index.vue

  • Modify: mobile-uni/src/pages/user/web-ai/index.vue

  • Modify: mobile-uni/src/types/api.ts

  • Step 1: 写失败的页面契约测试

    import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises';

    const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');

    test('工作助手默认自动选择内部资料范围', async () => { const page = await source('../src/pages/user/sop/index.vue'); assert.match(page, /工作助手/); assert.match(page, /资料范围/); assert.match(page, /自动/); assert.match(page, /收藏资料/); assert.match(page, /citation.domain/); });

    test('一级入口只区分工作助手与查全网', async () => { const internalPage = await source('../src/pages/user/sop/index.vue'); const webPage = await source('../src/pages/user/web-ai/index.vue'); assert.match(internalPage, /工作助手/); assert.match(webPage, /查全网/); assert.doesNotMatch(internalPage, /企业问师傅\s*/\s个人助理\s/\s*全网/); });

    test('个人资料页面包含状态、重试和删除', async () => { const page = await source('../src/pages/user/assistant/item.vue'); assert.match(page, /PARSING|解析中/); assert.match(page, /retryPersonalItem/); assert.match(page, /deletePersonalItem/); });

  • Step 2: 运行测试确认失败

Run:

npm --prefix mobile-uni run test:unit

Expected: FAIL,现有「问」页尚未提供工作助手 AUTO 范围和简化后的一级入口。

  • Step 3: 实现 API 客户端

personal-assistant.ts 固定使用现有 apiRequest:

export type InternalKnowledgeScope = 'PERSONAL' | 'PROJECT_SERVICE' | 'ENTERPRISE';

export const askPersonalAssistant = (payload: {
  sessionId?: number;
  queryText: string;
  scopeMode: 'AUTO' | 'MANUAL';
  scope: InternalKnowledgeScope[];
  dateFrom?: string;
  dateTo?: string;
  itemIds?: number[];
  outputFormat?: 'ANSWER' | 'ACTION_PLAN' | 'OUTLINE';
}) => apiRequest<PersonalAskResponse>({
  url: '/api/aihr/personal-assistant/ask',
  method: 'POST',
  data: payload,
  timeout: 45000
});

export const deletePersonalItem = (id: number) =>
  apiRequest<{ cleanupJobId: number }>({
    url: '/api/aihr/personal-assistant/items/' + id,
    method: 'DELETE',
    timeout: 15000
  });
  • Step 4: 实现页面交互

关键状态必须唯一:

const scopeMode = ref<'AUTO' | 'MANUAL'>('AUTO');
const manualScopes = ref<InternalKnowledgeScope[]>([]);
const queryText = ref('');
const asking = ref(false);
const answer = ref<PersonalAskResponse>();

const submitQuestion = async () => {
  const question = queryText.value.trim();
  if (!question || asking.value) return;
  asking.value = true;
  try {
    answer.value = await askPersonalAssistant({
      queryText: question,
      scopeMode: scopeMode.value,
      scope: scopeMode.value === 'MANUAL' ? manualScopes.value : [],
      outputFormat: 'ACTION_PLAN'
    });
  } finally {
    asking.value = false;
  }
};

一级只显示“工作助手 / 查全网”。工作助手默认 AUTO,只在 PERSONAL / PROJECT_SERVICE / ENTERPRISE 三个内部授权域路由;“资料范围”放在输入区次级设置中。引用卡按 citation.domain 显示“我的资料 / 项目记录 / 企业 SOP”,让用户看得见实际来源。公网查询继续使用独立页面和接口,内部无命中时只能提示用户主动进入“查全网”,不得自动转发原问题或住户/项目内容。

  • Step 5: 运行移动端检查

Run:

npm --prefix mobile-uni run test:unit
npm --prefix mobile-uni run typecheck
npm --prefix mobile-uni run build:h5

Expected: 全部 PASS;无新增横向滚动;页面路由存在。

  • Step 6: 提交

    git add mobile-uni/src/pages.json mobile-uni/src/pages/user/assistant mobile-uni/src/pages/user/sop/index.vue mobile-uni/src/pages/user/web-ai/index.vue mobile-uni/src/services/personal-assistant.ts mobile-uni/src/types/api.ts mobile-uni/tests/personal-assistant.test.mjs git commit -m "feat(mobile): add personal assistant knowledge flow"


Task 11: 完成 P0 真实隔离验收与文档

Files:

  • Create: scripts/personal-assistant-smoke.sh

  • Modify: scripts/demo-check.sh

  • Modify: docs/API_INTEGRATION.md

  • Modify: docs/DEV_SETUP.md

  • Modify: docs/DEMO_ACCEPTANCE.md

  • Step 1: 写失败的 smoke 脚本

脚本必须创建用户 A/B,并断言:A 可见自己的 item;B 对 A 的详情、搜索、删除均得到 PERSONAL_ITEM_NOT_FOUND;私网 URL 得到 PERSONAL_URL_BLOCKED。

核心断言函数:

expect_code() {
  actual="$1"
  expected="$2"
  label="$3"
  [ "$actual" = "$expected" ] || {
    echo "FAIL $label expected=$expected actual=$actual" >&2
    exit 1
  }
}

脚本使用 trap 清理两个测试用户的个人 item、session、fragment、Qdrant point 和 sys_oss 测试对象。

  • Step 2: 在 API 未完整启动前验证脚本失败

Run:

bash -n scripts/personal-assistant-smoke.sh
./scripts/personal-assistant-smoke.sh

Expected: 语法 PASS;若服务未启动则明确失败于 health,不产生残留数据。

  • Step 3: 补 demo-check 和文档

demo-check 增加静态 marker:

contains backend/script/sql/aihr_personal_knowledge_mysql8.sql "aihr_personal_fragment"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVectorStore.java "owner_user_id"
contains mobile-uni/src/pages/user/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 压成一个大提交。