42 KiB
多租户多知识空间统一问答平台 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: 编写向前兼容迁移
迁移必须包含:
aihr_knowledge_info新增code/space_type/sensitivity_level/status,建立UNIQUE(tenant_id, code)。- 创建
aihr_knowledge_space_grant、aihr_knowledge_app、aihr_knowledge_app_space、aihr_knowledge_query_log。 aihr_knowledge_upload_item增加space_codes_json json NULL,保留原category供兼容读取。token_hash长度固定为 64,表中不允许出现token_plain、secret明文字段。- 所有业务表包含
tenant_id和必要联合索引。 - 基础 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 处理:
- 通过内容 hash 或已存在文件找到可复用
oss_id。 - 对每个目标空间插入或更新该空间自己的 attach;不移动其他空间 attach。
- 仅清理目标空间内同名旧 fragment/Qdrant point。
- 解绑时统计
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 / ACTIVEyc_property_sop/ 物业业务 SOP / BUSINESS / INTERNAL / ACTIVEyc_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
覆盖以下正例:
- 银城员工查询公共制度和物业业务 SOP。
- 银城主管查询管理运营。
- 员工查询本人训练概况。
- 主管查询团队训练概况。
- 美途应用查询客户咨询并获得美途空间引用。
- 同一文件在两个银城空间分别可检索,解绑一个后另一个仍可检索。
- 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 个标准问题完成真实引用验证。
- 日调用量、限流值、日志保留周期和告警责任人已确认。
只有工程完成和业务准入两张清单都通过,才能进入正式试点验收。