Files
prop-ai-hr/docs/superpowers/plans/2026-07-16-multi-tenant-knowledge-platform.md
T

42 KiB
Raw Blame History

多租户多知识空间统一问答平台 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: 在保留现有 AI 陪练与 SOP 问答兼容性的前提下,交付银城、美途独立租户、多知识空间、多调用应用统一问答、受控实时数据查询和完整授权审计能力。

Architecture: 复用 aihr_knowledge_info/attach/fragment、现有 RAG、组织快照、训练统计与 LoginUser.clientKey;新增知识空间元数据、应用—空间和主体—空间授权。内部与外部保留不同认证入口,但共享授权、检索、生成、引用和审计服务;所有召回强制执行 tenant_id + effectiveKnowledgeIds。

Tech Stack: Java 21、Spring Boot、JdbcTemplate、Sa-Token、MySQL 8 Fulltext、Qdrant REST、Redis、MinIO/sys_oss、Vue 3 + Element Plus、uni-app Vue 3、TypeScript、JUnit 5、Vitest/Node test runner。


2026-07-16 执行状态

  • 工程范围已实施完成:Task 1—10 的 schema、授权、应用认证、统一查询、多空间资料、数据工具、管理端、多端迁移、初始化和运行手册均已落地。
  • Task 11 的自动化回归、真实本地 HTTP 正反例、数据库与 Qdrant 证据、四层成熟度审计已完成;本地双租户验证结果为 18/18。
  • 生产发布与正式试点未执行:它们需要生产变更授权以及业务负责人、内容清单、角色矩阵、标准题和密钥托管材料,不能由工程实现自动代替。
  • 当前分支为 codex/multi-tenant-knowledge-platform。为保护进入本轮前已存在且与实现文件重叠的用户改动,没有把全部工作区文件强行合并为一个提交;这不影响本地测试结论,但进入正式发布批次前必须由用户确认提交边界。

0. 执行边界与排期

0.1 交付批次

批次 范围 参考工作量 完成闸门
A:隔离与授权地基 schema、空间、应用、主体授权、外部令牌 5 人日 跨租户/应用/角色反例自动化测试通过
B:检索与内容 统一 query、受限 MySQL/Qdrant、兼容接口、多空间上传 5 人日 任意检索都带 tenant + space;同文件多空间不互相影响
C:数据工具与管理端 两个训练工具、空间/授权/应用管理页 4 人日 本人/团队范围验证;管理端完成完整配置闭环
D:多端联调与试点准备 移动端/管理端迁移、美途外部联调、审计、发布验证 4 人日 真实 HTTP、构建、越权矩阵和回滚开关验证通过

单人顺序执行约 18 个开发人日,不含业务整理知识文件和标准题的时间。并行执行时,批次 B 必须等待批次 A 的数据模型与授权 API 稳定,不能为了赶进度先在客户端硬编码空间。

0.2 第一版固定边界

  • 银城、美途作为独立租户。
  • 银城首批上线:yc_public_policy、yc_property_sop、yc_management_ops。
  • 美途首批上线:mt_customer_service。
  • 财务专业、经营决策只预留方案,有真实内容、负责人和授权矩阵后再启用。
  • 不做跨租户共享、个人知识空间、任意 SQL、工资财务明细、美途订单查询。
  • 第一版数据工具仅 MY_PRACTICE_SUMMARY 和 TEAM_PRACTICE_SUMMARY。
  • 当前阶段一验收继续按 docs/DEMO_ACCEPTANCE.md 和 docs/AI陪练完整交付计划-20260724.md;旧二期计划只用于追溯已完成工程工作包,本计划不改变当前阶段一范围。

0.3 实施总顺序

schema
  → 服务端主体与空间授权
  → 内外部应用认证
  → 受限统一检索
  → 多空间文件成员关系
  → 受控数据工具
  → 管理端配置
  → 内部多端迁移
  → 银城/美途初始化
  → 安全联调与发布

Task 1: 建立知识空间平台 schema 与迁移

Files:

  • Modify: backend/script/sql/aihr_knowledge_mysql8.sql

  • Create: backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql

  • Modify: scripts/reset-dev-db.sh

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrKnowledgePlatformSchemaTest.java

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

测试读取两个 SQL 文件并断言:

@Test
void schemaDefinesSpacesAppsGrantsAndAuditWithoutPersonalTables() throws Exception {
    String base = readRepoFile("backend/script/sql/aihr_knowledge_mysql8.sql");
    String migration = readRepoFile(
        "backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql");
    String sql = base + migration;

    assertTrue(sql.contains("`code` varchar(100)"));
    assertTrue(sql.contains("uk_aihr_knowledge_info_code"));
    assertTrue(sql.contains("aihr_knowledge_space_grant"));
    assertTrue(sql.contains("aihr_knowledge_app"));
    assertTrue(sql.contains("aihr_knowledge_app_space"));
    assertTrue(sql.contains("aihr_knowledge_query_log"));
    assertTrue(sql.contains("space_codes_json"));
    assertFalse(sql.contains("aihr_personal_"));
}
  • Step 2: 运行测试并确认先失败

Run:

mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgePlatformSchemaTest test

Expected: FAIL,提示迁移文件不存在或目标表/字段缺失。

  • Step 3: 编写向前兼容迁移

迁移必须包含:

  1. aihr_knowledge_info 新增 code/space_type/sensitivity_level/status,建立 UNIQUE(tenant_id, code)。
  2. 创建 aihr_knowledge_space_grant、aihr_knowledge_app、aihr_knowledge_app_space、aihr_knowledge_query_log。
  3. aihr_knowledge_upload_item 增加 space_codes_json json NULL,保留原 category 供兼容读取。
  4. token_hash 长度固定为 64,表中不允许出现 token_plain、secret 明文字段。
  5. 所有业务表包含 tenant_id 和必要联合索引。
  6. 基础 SQL 与 update SQL 定义一致;重复执行 update 不破坏既有 attach、fragment 和 OSS 关系。

迁移旧行时按安全顺序执行:先增加可空 code,再将空值确定性回填为 legacy_<id>,核对租户内无重复后改成 NOT NULL 并建立唯一键;space_type/sensitivity_level/status 分别回填 BUSINESS/INTERNAL/ACTIVE。不得因为补空间字段移动原 attach 或 fragment。

关键 DDL 约束:

UNIQUE KEY uk_aihr_knowledge_info_code (tenant_id, code);
UNIQUE KEY uk_aihr_space_grant
  (tenant_id, knowledge_id, principal_type, principal_value, permission);
UNIQUE KEY uk_aihr_knowledge_app_code (app_code);
UNIQUE KEY uk_aihr_app_space (tenant_id, app_id, knowledge_id);
UNIQUE KEY uk_aihr_query_request (request_id);
  • Step 4: 将基础 SQL 接入本地 reset

保证 scripts/reset-dev-db.sh 在知识基础表后加载新增结构,所有 MySQL 导入继续使用 --default-character-set=utf8mb4。

  • Step 5: 运行 schema 测试和真实本地迁移

Run:

mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgePlatformSchemaTest test
./scripts/reset-dev-db.sh
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue -e \
  "show tables like 'aihr_knowledge_app%'; show columns from aihr_knowledge_info;"

Expected: 测试 PASS;reset 成功;新表和字段存在;原 aihr_knowledge_attach/fragment 数据结构仍在。

  • Step 6: 提交 Task 1
git add backend/script/sql/aihr_knowledge_mysql8.sql \
  backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql \
  scripts/reset-dev-db.sh \
  backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrKnowledgePlatformSchemaTest.java
git commit -m "feat(knowledge): add multi-space platform schema"

Task 2: 实现主体解析与有效空间授权交集

Files:

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgePrincipal.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAccessService.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAccessServiceTest.java

  • Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java(只复用或暴露已有只读身份解析,不改同步语义)

  • Step 1: 写授权交集失败测试

至少覆盖:

@Test void appAndPrincipalGrantsAreIntersected();
@Test void requestedSpacesCanOnlyNarrowEffectiveScope();
@Test void requestedForeignTenantSpaceIsRejectedBeforeRetrieval();
@Test void emptyEffectiveScopeIsRejectedBeforeRetrieval();
@Test void appUserSupervisorRoleComesFromOrgSnapshotNotRequestBody();
@Test void sysUserRolesComeFromLoginUserRolePermission();

核心断言示例:应用允许 {1,2}、主体允许 {2,3} 时,只返回 {2};请求 {2,3} 时因包含未授权空间返回 403,而不是返回 {2} 后静默成功。

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

Run:

mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeAccessServiceTest test

Expected: FAIL,目标类不存在。

  • Step 3: 实现不可伪造的主体对象

AihrKnowledgePrincipal 至少包含:

public record AihrKnowledgePrincipal(
    String tenantId,
    Long userId,
    String userType,
    String extPartyId,
    Set<String> roles,
    Set<String> projectCodes,
    String clientKey
) {}

AihrKnowledgePrincipalResolver 只从 LoginHelper.getLoginUser()、rolePermission 和组织快照生成该对象;Controller 请求 DTO 不允许出现 tenantId/userId/extPartyId/projectCodes/roles。

  • Step 4: 实现空间授权计算

AihrKnowledgeAccessService 提供:

Set<Long> resolveInternalSpaceIds(
    AihrKnowledgePrincipal principal,
    String appCode,
    List<String> requestedSpaceCodes);

Set<Long> resolveExternalSpaceIds(
    AuthenticatedKnowledgeApp app,
    List<String> requestedSpaceCodes);

内部算法固定为租户空间、应用授权、主体授权三者交集;外部算法固定为租户空间与应用授权交集。所有 SQL 显式包含 tenant_id,返回前验证空间状态为 ACTIVE。

  • Step 5: 运行测试

Run:

mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \
  -Dtest=AihrKnowledgeAccessServiceTest,AihrTenantContextContractTest test

Expected: PASS,现有租户上下文契约继续通过。

  • Step 6: 提交 Task 2
git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge \
  backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAccessServiceTest.java \
  backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java
git commit -m "feat(knowledge): enforce app and principal space grants"

Task 3: 实现知识调用应用、外部令牌与限流

Files:

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeAppDto.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppService.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeAppAuthService.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAppServiceTest.java

  • Modify: backend/ruoyi-modules/ruoyi-aihr/pom.xml(仅在当前模块无法访问 RedisUtils 时增加现有内部模块依赖,不引入第三方库)

  • Step 1: 写令牌安全失败测试

至少覆盖:

@Test void createApiTokenReturnsPlainTextOnceAndPersistsOnlySha256();
@Test void rotateTokenInvalidatesOldTokenImmediately();
@Test void disabledExpiredOrWrongTokenIsRejected();
@Test void tokenAppCodeCannotEscapeItsTenant();
@Test void sessionAppResolvesByTenantAndClientKey();
@Test void rateLimitUsesAppIdAsRedisDimension();

测试数据库写入捕获中不得出现完整 ak_ 令牌。

  • Step 2: 运行测试确认失败
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeAppServiceTest test

Expected: FAIL。

  • Step 3: 实现令牌生命周期

规则固定为:

  • 格式:ak_<appCode>_<Base64URL(SecureRandom 32 bytes)>。

  • app_code 使用 [a-z0-9_]{3,64}。

  • 使用 SHA-256 保存完整令牌摘要,常量时间比较。

  • 创建、轮换返回 TokenIssuedResponse(appCode, plainToken, expiresTime);普通详情和列表 DTO 永不包含明文或 hash。

  • 轮换后旧摘要在同一事务中失效,不做双令牌宽限期。

  • Step 4: 实现应用级限流

在认证成功、检索前使用现有 Redis 限流,key 固定含 knowledge:app:{appId},速率读取 rate_limit_per_minute。超限抛出映射为 HTTP 429 的业务异常;Redis 异常按安全策略拒绝外部请求,不绕过限流。

  • Step 5: 运行安全测试和依赖检查
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \
  -Dtest=AihrKnowledgeAppServiceTest test
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr dependency:tree \
  -Dincludes=org.dromara:ruoyi-common-redis

Expected: 测试 PASS;依赖来自本仓已有模块,无新第三方密钥库依赖。

  • Step 6: 提交 Task 3
git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge \
  backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAppServiceTest.java \
  backend/ruoyi-modules/ruoyi-aihr/pom.xml
git commit -m "feat(knowledge): add application tokens and rate limits"

Task 4: 建立统一 query 服务并锁定 MySQL/Qdrant 检索范围

Files:

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrOpenKnowledgeController.java

  • Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.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

  • Modify: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java

  • Step 1: 写查询边界失败测试

至少覆盖:

@Test void mysqlFulltextReceivesTenantAndAllowedKnowledgeIds();
@Test void qdrantFilterContainsTenantAndKnowledgeIdMatchAny();
@Test void emptyScopeStopsBeforeMysqlQdrantAndLlm();
@Test void citationsOutsideEffectiveScopeAreRemovedAndLogged();
@Test void noEvidenceDoesNotUseModelKnowledgeAsCompanyPolicy();
@Test void legacySopSearchMapsToAuthorizedSopSpaceNotAllTenantSpaces();
@Test void internalAndExternalControllersShareTheSameQueryService();

源代码契约中额外断言新查询路径不通过空 category 表示全租户。

  • Step 2: 运行测试确认失败
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \
  -Dtest=AihrKnowledgeQueryServiceTest,AihrSopSeedServiceTest test

Expected: 新测试 FAIL;现有测试保持可运行。

  • Step 3: 定义统一契约

QueryRequest 字段固定为:

String queryText;
List<String> spaceCodes;
String category;
String position;
String source;
Integer limit;
String toolCode;

QueryResponse 至少包含:

String requestId;
String queryText;
String answer;
List<Citation> citations;
List<String> usedSpaceCodes;
boolean noEvidence;
String promptVersion;
LegacySearchPayload legacy;

请求校验:问题 1–1000 字,空间编码最多 20 个,limit 1–10。

  • Step 4: 改造现有 RAG 为显式空间集合

在 AihrSopSeedService 增加带 Set<Long> allowedKnowledgeIds 的受限检索入口,并让以下路径使用同一个范围:

  • MySQL Fulltext;
  • 本地 embedding 兜底;
  • Qdrant 查询;
  • rerank 候选集;
  • 引用返回前复核;
  • 搜索评审和 answer feedback 关联结果。

Qdrant filter 使用 tenant_id == currentTenant 与 knowledge_id match any allowedKnowledgeIds。不支持 match.any 的内部 helper 必须补充实现,不能循环时遗漏 tenant filter。

  • Step 5: 实现两个认证入口、一个业务服务

  • POST /api/knowledge/query:@SaCheckLogin,从内部会话解析 principal 与 SESSION app。

  • POST /api/open/knowledge/query:不加 @SaCheckLogin,显式读取 Authorization: Bearer ...,先认证 API_TOKEN app 再限流。

  • 两个 Controller 都只能调用 AihrKnowledgeQueryService.query(context, request)。

  • 外部入口拒绝 toolCode。

  • Step 6: 将旧 /search 改为兼容包装器

现有 SearchRequest、SearchResponse 保留给当前前端,但内部转成 QueryRequest。category=sop 映射到当前主体已授权且类型匹配的空间;没有授权返回无权限,不回退到租户全库或 seed 假成功。

  • Step 7: 写最小化查询日志

日志只保存 requestId、租户、应用、内部用户 ID、问题 SHA-256、使用空间、状态、耗时、模型和 prompt 版本;默认不写完整问题、答案和引用正文。

  • Step 8: 运行测试
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \
  -Dtest=AihrKnowledgeQueryServiceTest,AihrSopSeedServiceTest,AihrTenantContextContractTest test

Expected: PASS;空范围时验证 mock 的 MySQL、Qdrant、LLM 均为零调用。

  • Step 9: 提交 Task 4
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/controller/AihrSopController.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/knowledge/AihrKnowledgeQueryServiceTest.java \
  backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java
git commit -m "feat(knowledge): add authorized unified query service"

Task 5: 支持一份文件进入多个空间且不破坏原成员关系

Files:

  • Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.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

  • Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java

  • Modify: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java

  • Modify: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java

  • Step 1: 写复现现有破坏性逻辑的失败测试

当前 reuseDuplicateAttach 会更新原 attach 的 knowledge_id 并删除原空间 fragment。新增测试必须锁定正确行为:

@Test void sameFileInTwoSpacesCreatesTwoAttachMembershipsWithOneOssId();
@Test void addingSecondSpaceNeverUpdatesFirstAttachKnowledgeId();
@Test void addingSecondSpaceNeverDeletesFirstSpaceFragments();
@Test void unbindingOneSpaceKeepsOtherMembershipAndOssObject();
@Test void embeddingsAreGeneratedOnceAndPersistedForEveryTargetSpace();
@Test void uploadRejectsAnyTargetSpaceWithoutManagePermission();
  • Step 2: 运行测试确认失败
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \
  -Dtest=AihrSopSeedServiceTest,AihrUploadQueueServiceTest test

Expected: 至少“原空间不删除”和“双 attach”用例 FAIL。

  • Step 3: 扩展上传契约并保持旧 category 兼容

  • 新请求参数:重复表单字段 spaceCodes,至少 1 个、最多 20 个。

  • 旧 category 仍可传;服务端只在未传 spaceCodes 时将其解析为一个授权空间。

  • 异步队列将稳定编码数组保存到 space_codes_json,重试沿用原始目标集合。

  • 入队前一次性校验当前主体对所有目标空间均有 MANAGE 权限;部分授权不执行部分导入。

  • Step 4: 将 attach 明确作为空间成员关系

重写 duplicate 处理:

  1. 通过内容 hash 或已存在文件找到可复用 oss_id。
  2. 对每个目标空间插入或更新该空间自己的 attach;不移动其他空间 attach。
  3. 仅清理目标空间内同名旧 fragment/Qdrant point。
  4. 解绑时统计 oss_id 引用数,只有归零才删除 OSS。
  • Step 5: 拆分一次生成、多次持久化的 embedding 流程

将现有单空间 embedFragments(...) 拆为等价职责:

EmbeddingBatch generateEmbeddings(List<String> fragments);
void persistEmbeddings(long knowledgeId, String docId, EmbeddingBatch batch);
void upsertQdrant(long knowledgeId, String docId, EmbeddingBatch batch);

同一上传任务只执行一次 generateEmbeddings,再为每个目标空间写 fragment embedding 和携带各自 knowledge_id 的 Qdrant point。

  • Step 6: 运行测试和上传回归
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \
  -Dtest=AihrSopSeedServiceTest,AihrUploadQueueServiceTest,AihrMultipartFilesTest test

Expected: PASS;已有单空间上传、图片/视频处理与异步重试不回退。

  • Step 7: 提交 Task 5
git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.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/main/java/org/dromara/aihr/service/AihrUploadQueueService.java \
  backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java \
  backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java
git commit -m "fix(knowledge): preserve multi-space document memberships"

Task 6: 实现两个受控训练数据工具

Files:

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java

  • Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java

  • Modify: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java(仅在需要时抽取现有只读 DTO,不改既有接口范围)

  • Step 1: 写数据范围失败测试

@Test void myPracticeSummaryAlwaysUsesCurrentAppUserExtPartyId();
@Test void teamPracticeSummaryUsesResolvedSupervisorScope();
@Test void employeeCannotCallTeamPracticeSummary();
@Test void externalAppCannotCallAnyDataTool();
@Test void unknownToolCodeIsRejectedWithoutDatabaseCall();
@Test void requestCannotProvideTenantUserOrExtPartyId();
@Test void toolFailureNeverFallsBackToSeedOrLlmGuess();
  • Step 2: 运行测试确认失败
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeDataToolServiceTest test
  • Step 3: 实现固定枚举分派

只允许:

MY_PRACTICE_SUMMARY -> mobileSeedService.practiceHistory(principal.extPartyId())
TEAM_PRACTICE_SUMMARY -> mobileSeedService.practiceTeam(resolvedSupervisorExtPartyId)

不得接收表名、字段名、SQL、外部 ID 或任意工具 URL。结果包装为 sourceType=DATA_TOOL 的引用,并记录统计窗口和工具编码。

  • Step 4: 接入统一 query 服务

当 toolCode 非空时跳过文档 RAG;内部 SESSION app 才能进入工具分派。外部 API_TOKEN、未知工具、角色不符直接拒绝,不调用 LLM 做权限判断。

  • Step 5: 运行测试
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \
  -Dtest=AihrKnowledgeDataToolServiceTest,AihrMobileSeedServiceTest,AihrPracticeSeedServiceTest test

Expected: PASS;现有移动端本人/团队接口权限测试继续通过。

  • Step 6: 提交 Task 6
git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java \
  backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java \
  backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java \
  backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java
git commit -m "feat(knowledge): add scoped training data tools"

Task 7: 提供空间、授权和应用管理 API

Files:

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeSpaceController.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeSpaceDto.java

  • Create: backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java

  • Step 1: 写管理越权失败测试

覆盖:

@Test void listReturnsOnlyCurrentTenantSpaces();
@Test void codeCannotChangeAfterSpaceCreation();
@Test void cannotBindAppAndSpaceFromDifferentTenants();
@Test void readPermissionDoesNotGrantManagePermission();
@Test void tokenHashNeverAppearsInListOrDetailDto();
@Test void disablingSpaceRemovesItFromEffectiveScopeImmediately();
  • Step 2: 运行测试确认失败
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeSpaceAdminServiceTest test
  • Step 3: 实现管理 API

统一前缀 /api/knowledge/admin:

方法 路径 权限
GET/POST /spaces 列表 / 创建
PUT /spaces/{id} 编辑名称、类型、密级、状态;code 不可改
GET/PUT /spaces/{id}/grants 查看 / 全量替换授权
GET/POST /apps 列表 / 创建
PUT /apps/{id} 编辑名称、状态、限流、到期时间
PUT /apps/{id}/spaces 全量替换应用空间绑定
POST /apps/{id}/rotate-token 轮换外部令牌,仅返回一次明文

Controller 使用现有 super admin 或 hr_operator 管理角色作为首版管理入口;service 仍校验租户和 MANAGE 权限,不能只依赖菜单隐藏。

  • Step 4: 记录所有授权变化

创建、启停、密级变化、角色/用户授权、应用绑定和令牌轮换均记录操作人、租户、对象、前后状态摘要和时间,不记录 token 明文。

  • Step 5: 运行测试
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \
  -Dtest=AihrKnowledgeSpaceAdminServiceTest,AihrKnowledgeAccessServiceTest,AihrKnowledgeAppServiceTest test
  • Step 6: 提交 Task 7
git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge \
  backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java
git commit -m "feat(knowledge): add space and application administration"

Task 8: 建立管理端知识空间页面并改造上传入口

Files:

  • Create: frontend/src/api/aihr/space.ts

  • Create: frontend/src/views/knowledge/spaces.vue

  • Create: frontend/src/views/knowledge/spaces.test.ts

  • Modify: frontend/src/router/index.ts

  • Modify: frontend/src/layout/components/Sidebar/index.vue

  • Modify: frontend/src/views/knowledge/sop.vue

  • Modify: frontend/src/views/knowledge/processing.vue

  • Modify: frontend/src/api/aihr/sop.ts

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

使用 Vitest 读取源码,至少断言:

  • 路由存在 /knowledge/spaces,标题为“知识空间”。

  • 页面存在“知识空间 / 空间授权 / 调用应用”三个页签。

  • 普通应用列表不渲染 tokenHash。

  • 令牌创建/轮换弹窗明确“仅显示一次”。

  • SOP 和资料处理上传提交 spaceCodes,不再只依赖 category。

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

npm --prefix frontend exec vitest run src/views/knowledge/spaces.test.ts

Expected: FAIL,页面和 API 尚不存在。

  • Step 3: 实现类型化 API

space.ts 封装 Task 7 的所有 API;应用列表类型不定义 tokenHash。创建和轮换响应单独定义 IssuedToken,明文只保存在当前弹窗状态,关闭后清空。

  • Step 4: 实现三个页签

  • 空间:表格、创建/编辑、状态、密级、文档/片段数。

  • 授权:选择空间后配置 ROLE/USER 与 READ/MANAGE。

  • 应用:SESSION/API_TOKEN、空间多选、限流、到期时间、轮换和停用。

页面内所有错误展示服务端业务消息;不在 localStorage/sessionStorage 保存明文 token。

  • Step 5: 改造 SOP 和资料处理上传

  • 上传对话框从单 category 改为空间多选。

  • 只显示当前用户拥有 MANAGE 的 ACTIVE 空间。

  • 新请求发送多个 spaceCodes;旧 category 只作为兼容回退,不由新 UI 发送。

  • 资料处理列表展示目标空间名称集合。

  • Step 6: 加入路由与受控侧栏

在 frontend/src/router/index.ts 增加静态路由,并将“知识空间”加入当前侧栏白名单;不恢复若依默认全量菜单。

  • Step 7: 运行前端测试、类型和构建
npm --prefix frontend exec vitest run src/views/knowledge/spaces.test.ts
npm --prefix frontend run build:prod

Expected: 测试和生产构建 PASS。

  • Step 8: 提交 Task 8
git add frontend/src/api/aihr/space.ts \
  frontend/src/views/knowledge/spaces.vue \
  frontend/src/views/knowledge/spaces.test.ts \
  frontend/src/router/index.ts \
  frontend/src/layout/components/Sidebar/index.vue \
  frontend/src/views/knowledge/sop.vue \
  frontend/src/views/knowledge/processing.vue \
  frontend/src/api/aihr/sop.ts
git commit -m "feat(admin): add knowledge space management"

Task 9: 迁移内部 Web/H5 到统一 query,并增加训练快捷查询

Files:

  • Modify: mobile-uni/src/services/knowledge.ts

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

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

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

  • Modify: mobile-uni/tests/mobile-user-pages.test.mjs

  • Modify: frontend/src/api/aihr/sop.ts

  • Modify: frontend/src/views/knowledge/sop.vue

  • Create: scripts/tests/knowledge-query-client-contract.test.mjs

  • Step 1: 写客户端契约失败测试

断言:

  • 移动端和管理端都调用 /api/knowledge/query。

  • 请求中没有 tenantId/userId/extPartyId/projectCodes/roles。

  • UI 不提供能扩大权限的任意空间输入框。

  • 答案展示 citations 和 noEvidence。

  • 训练快捷入口只发送固定 MY_PRACTICE_SUMMARY 或 TEAM_PRACTICE_SUMMARY。

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

node --test scripts/tests/knowledge-query-client-contract.test.mjs
npm --prefix mobile-uni run test:unit

Expected: 新接口断言 FAIL;现有移动端测试基线可见。

  • Step 3: 更新共享类型和移动端服务

searchKnowledge 改用 /api/knowledge/query,由服务端计算空间。页面展示引用所属空间,但不提供任意空间选择器。保留现有 snippet 兼容归一化,避免已上线答案卡回退。

  • Step 4: 增加固定训练快捷操作

  • 普通员工显示“我的训练概况”,发送 toolCode=MY_PRACTICE_SUMMARY。

  • 主管端或主管身份显示“团队训练概况”,发送 toolCode=TEAM_PRACTICE_SUMMARY。

  • 不把用户输入自然语言直接转换为 toolCode。

  • 工具失败显示真实错误,不展示本地假训练数据。

  • Step 5: 更新管理端 SOP 搜索

管理端调用相同 /api/knowledge/query;可在已授权空间中多选做范围收窄。引用显示空间和标题,反馈继续带 request/review 关联信息。

  • Step 6: 运行客户端测试、类型检查和构建
node --test scripts/tests/knowledge-query-client-contract.test.mjs
npm --prefix mobile-uni run test:unit
npm --prefix mobile-uni run typecheck
npm --prefix mobile-uni run build:h5
npm --prefix frontend run build:prod

Expected: 全部 PASS;员工端“问师傅”现有文字问答和总结卡不回退。

  • Step 7: 提交 Task 9
git add mobile-uni/src/services/knowledge.ts \
  mobile-uni/src/types/api.ts \
  mobile-uni/src/pages/user/sop/index.vue \
  mobile-uni/src/pages/user/today/index.vue \
  mobile-uni/tests/mobile-user-pages.test.mjs \
  frontend/src/api/aihr/sop.ts \
  frontend/src/views/knowledge/sop.vue \
  scripts/tests/knowledge-query-client-contract.test.mjs
git commit -m "feat(clients): use unified authorized knowledge query"

Task 10: 初始化银城、美途空间、应用与默认授权

Files:

  • Create: scripts/provision-knowledge-platform.sh

  • Create: scripts/tests/provision-knowledge-platform.test.sh

  • Create: docs/KNOWLEDGE_PLATFORM_RUNBOOK.md

  • Modify: docs/API_INTEGRATION.md

  • Modify: docs/README.md

  • Step 1: 写幂等初始化脚本失败测试

脚本接口固定为:

./scripts/provision-knowledge-platform.sh \
  --silver-tenant-id 000000 \
  --meitu-tenant-id 100001 \
  --dry-run

测试断言 dry-run 输出恰好包含四个 ACTIVE 空间、三个应用及预期绑定,不输出 SQL 密码或 API_TOKEN 明文;缺少任一租户参数时退出非零。

  • Step 2: 运行测试确认失败
bash scripts/tests/provision-knowledge-platform.test.sh
  • Step 3: 实现显式、幂等初始化

脚本通过参数接收实际租户 ID,先验证 sys_tenant 中存在且名称由操作者确认,再 upsert:

银城空间:

  • yc_public_policy / 公共制度 / PUBLIC / INTERNAL / ACTIVE
  • yc_property_sop / 物业业务 SOP / BUSINESS / INTERNAL / ACTIVE
  • yc_management_ops / 管理运营 / MANAGEMENT / CONFIDENTIAL / ACTIVE

美途空间:

  • mt_customer_service / 美途客户咨询 / EXTERNAL / PUBLIC / ACTIVE

应用:

  • 银城 yc_admin / SESSION / pc,绑定三个银城空间。
  • 银城 yc_mobile / SESSION / app,绑定公共制度、物业业务 SOP、管理运营;主体授权决定最终交集。
  • 美途 mt_card_miniapp / API_TOKEN,绑定美途客户咨询;初始化时保持 DISABLED,联调前由管理端启用并生成令牌。

默认角色模板:employee 读公共制度和物业业务 SOP;supervisor 额外读管理运营;管理角色拥有相应 MANAGE。脚本不得创建跨租户绑定。

现有“投诉处理 SOP / 催缴沟通 SOP / 报修跟进 SOP”等细粒度 aihr_knowledge_info 不直接改名为新空间。初始化脚本核对 Task 1 已生成的租户内唯一 legacy_* 编码且不向新应用授权;业务确认源文件清单后,通过 Task 5 的多空间导入或 OSS 成员复用进入 yc_property_sop。新空间标准题验证通过后再将不再使用的 legacy 空间置为 DISABLED,回滚时可重新启用。

  • Step 4: 编写运行手册

KNOWLEDGE_PLATFORM_RUNBOOK.md 必须包含:

  • 如何确认两个生产租户 ID;

  • dry-run、执行、结果核对和重复执行;

  • 如何生成/轮换/停用美途令牌;

  • 密钥只能进入小程序云函数或后端;

  • 内容导入、空间解绑、孤立 OSS 核对;

  • 现有细粒度 SOP 到 yc_property_sop 的清单映射、验证和 legacy 停用顺序;

  • 查询日志与限流观察;

  • 回滚优先停用 app,不删除知识数据。

  • Step 5: 运行脚本测试和本地 dry-run

bash scripts/tests/provision-knowledge-platform.test.sh
./scripts/provision-knowledge-platform.sh \
  --silver-tenant-id 000000 \
  --meitu-tenant-id 100001 \
  --dry-run

Expected: 测试 PASS;输出四空间、三应用和绑定差异,不执行写入。

  • Step 6: 提交 Task 10
git add scripts/provision-knowledge-platform.sh \
  scripts/tests/provision-knowledge-platform.test.sh \
  docs/KNOWLEDGE_PLATFORM_RUNBOOK.md \
  docs/API_INTEGRATION.md \
  docs/README.md
git commit -m "docs(knowledge): add tenant provisioning and runbook"

Task 11: 完成真实 HTTP、安全矩阵和发布闸门验证

Files:

  • Create: scripts/verify-knowledge-platform.mjs

  • Create: scripts/tests/knowledge-platform-security.test.mjs

  • Modify: docs/KNOWLEDGE_PLATFORM_RUNBOOK.md

  • Modify: docs/BRD_IMPLEMENTATION_AUDIT.md

  • Step 1: 写真实接口验证器

脚本读取环境变量而不硬编码密钥:

AIHR_BASE_URL
AIHR_SILVER_ADMIN_TOKEN
AIHR_SILVER_EMPLOYEE_TOKEN
AIHR_SILVER_SUPERVISOR_TOKEN
AIHR_MEITU_APP_TOKEN

覆盖以下正例:

  1. 银城员工查询公共制度和物业业务 SOP。
  2. 银城主管查询管理运营。
  3. 员工查询本人训练概况。
  4. 主管查询团队训练概况。
  5. 美途应用查询客户咨询并获得美途空间引用。
  6. 同一文件在两个银城空间分别可检索,解绑一个后另一个仍可检索。
  • Step 2: 写安全反例测试

至少覆盖:

银城 token + mt_customer_service                 -> 403
美途 app token + yc_public_policy                 -> 403
员工调用 TEAM_PRACTICE_SUMMARY                    -> 403
美途 app 调用 MY_PRACTICE_SUMMARY                 -> 403
禁用/过期/错误 app token                          -> 401
超过 app rate_limit_per_minute                    -> 429
请求体伪造 tenantId/userId/extPartyId             -> 不影响服务端身份
没有有效空间                                      -> MySQL/Qdrant/LLM 零调用
  • Step 3: 运行完整后端和前端回归
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr test
npm --prefix frontend run build:prod
npm --prefix mobile-uni run test:unit
npm --prefix mobile-uni run typecheck
npm --prefix mobile-uni run build:h5
git diff --check

Expected: 全部 PASS,无 whitespace error。

  • Step 4: 启动本地服务并运行真实 HTTP 验证

Run backend with portless:

portless run --name wygj-api ./scripts/dev-backend.sh

在另一个终端运行:

AIHR_BASE_URL=https://wygj-api.localhost \
AIHR_SILVER_ADMIN_TOKEN='<local-admin-token>' \
AIHR_SILVER_EMPLOYEE_TOKEN='<local-employee-token>' \
AIHR_SILVER_SUPERVISOR_TOKEN='<local-supervisor-token>' \
AIHR_MEITU_APP_TOKEN='<local-meitu-token>' \
node scripts/verify-knowledge-platform.mjs

Expected: 正例全部 PASS,安全反例返回预期 401/403/429;日志中 requestId 可追踪,未打印令牌或完整问题。

  • Step 5: 检查真实数据库与 Qdrant 隔离证据
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue -e \
  "select tenant_id,code,status from aihr_knowledge_info order by tenant_id,code; \
   select tenant_id,app_code,auth_type,status from aihr_knowledge_app order by tenant_id,app_code; \
   select tenant_id,status,count(*) from aihr_knowledge_query_log group by tenant_id,status;"
curl -fsS http://127.0.0.1:6333/collections/aihr_knowledge | head -c 1000

Expected: 两租户空间和应用归属正确;日志按租户可分;Qdrant collection 健康。再从服务测试日志确认查询 filter 同时包含 tenant 和 knowledge IDs。

  • Step 6: 按四层成熟度更新审计文档

只在取得对应证据后分别标记:

  • 开发完成;
  • 联调可用;
  • 线上生效;
  • 正式试点验收。

缺少业务负责人、内容清单、授权矩阵或标准题结果时,不标记正式试点验收。

  • Step 7: 生产发布与回滚验证(需生产变更授权,第一版工程交付不冒充线上生效)

发布前:备份数据库与 /opt/wygj/www,先执行 migration dry-run/结构核对,再发后端、管理端和 H5。管理端发布不得对 /opt/wygj/www/ 根目录使用 --delete;移动端只对 /opt/wygj/www/h5/ 使用既定 --delete 口径。

发布后:重跑真实 HTTP 正反例、健康检查和查询日志核对。回滚优先停用 mt_card_miniapp 和新 SESSION app,客户端临时回到兼容 /search;不删除新表、不搬回 attach、不清理 OSS。

  • Step 8: 提交 Task 11(待用户确认与既有未提交改动的提交边界)
git add scripts/verify-knowledge-platform.mjs \
  scripts/tests/knowledge-platform-security.test.mjs \
  docs/KNOWLEDGE_PLATFORM_RUNBOOK.md \
  docs/BRD_IMPLEMENTATION_AUDIT.md
git commit -m "test(knowledge): verify tenant and space isolation"

12. 最终完成清单

12.1 工程完成

  • schema 和 update migration 可重复执行,原知识数据无损。
  • 内部/外部 query 共用服务,认证边界独立。
  • 内部有效空间按 app 与 principal 交集计算。
  • MySQL 和 Qdrant 都强制 tenant + knowledge IDs。
  • 旧 /search 兼容但不再搜索租户全库。
  • 同文件多空间不会移动或删除原成员关系。
  • 两个数据工具只按服务端身份和范围执行。
  • 管理端可以管理空间、授权、应用、令牌与限流。
  • 移动端和管理端完成新契约迁移。
  • 自动化测试、类型检查、构建、真实 HTTP 和数据库核对通过。

12.2 业务试点准入

  • 银城三个空间和美途一个空间的负责人、维护人、审批人已确认。
  • 初始文件清单、密级、禁止内容和版本日期已确认。
  • 银城角色—应用—空间—项目范围矩阵已签字确认。
  • 美途令牌已放在可信后端/云函数,密钥保管人和轮换责任人明确。
  • 每空间至少 10 个标准问题完成真实引用验证。
  • 日调用量、限流值、日志保留周期和告警责任人已确认。

只有工程完成和业务准入两张清单都通过,才能进入正式试点验收。