# 多租户多知识空间统一问答平台 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 实施总顺序 ```text 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 文件并断言: ```java @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: ```bash 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_`,核对租户内无重复后改成 `NOT NULL` 并建立唯一键;`space_type/sensitivity_level/status` 分别回填 `BUSINESS/INTERNAL/ACTIVE`。不得因为补空间字段移动原 attach 或 fragment。 关键 DDL 约束: ```sql 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: ```bash 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** ```bash 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: 写授权交集失败测试** 至少覆盖: ```java @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: ```bash mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeAccessServiceTest test ``` Expected: FAIL,目标类不存在。 - [ ] **Step 3: 实现不可伪造的主体对象** `AihrKnowledgePrincipal` 至少包含: ```java public record AihrKnowledgePrincipal( String tenantId, Long userId, String userType, String extPartyId, Set roles, Set projectCodes, String clientKey ) {} ``` `AihrKnowledgePrincipalResolver` 只从 `LoginHelper.getLoginUser()`、`rolePermission` 和组织快照生成该对象;Controller 请求 DTO 不允许出现 `tenantId/userId/extPartyId/projectCodes/roles`。 - [ ] **Step 4: 实现空间授权计算** `AihrKnowledgeAccessService` 提供: ```java Set resolveInternalSpaceIds( AihrKnowledgePrincipal principal, String appCode, List requestedSpaceCodes); Set resolveExternalSpaceIds( AuthenticatedKnowledgeApp app, List requestedSpaceCodes); ``` 内部算法固定为租户空间、应用授权、主体授权三者交集;外部算法固定为租户空间与应用授权交集。所有 SQL 显式包含 `tenant_id`,返回前验证空间状态为 `ACTIVE`。 - [ ] **Step 5: 运行测试** Run: ```bash mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ -Dtest=AihrKnowledgeAccessServiceTest,AihrTenantContextContractTest test ``` Expected: PASS,现有租户上下文契约继续通过。 - [ ] **Step 6: 提交 Task 2** ```bash 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: 写令牌安全失败测试** 至少覆盖: ```java @Test void createApiTokenReturnsPlainTextOnceAndPersistsOnlySha256(); @Test void rotateTokenInvalidatesOldTokenImmediately(); @Test void disabledExpiredOrWrongTokenIsRejected(); @Test void tokenAppCodeCannotEscapeItsTenant(); @Test void sessionAppResolvesByTenantAndClientKey(); @Test void rateLimitUsesAppIdAsRedisDimension(); ``` 测试数据库写入捕获中不得出现完整 `ak_` 令牌。 - [ ] **Step 2: 运行测试确认失败** ```bash mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeAppServiceTest test ``` Expected: FAIL。 - [ ] **Step 3: 实现令牌生命周期** 规则固定为: - 格式:`ak__`。 - `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: 运行安全测试和依赖检查** ```bash 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** ```bash 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: 写查询边界失败测试** 至少覆盖: ```java @Test void mysqlFulltextReceivesTenantAndAllowedKnowledgeIds(); @Test void qdrantFilterContainsTenantAndKnowledgeIdMatchAny(); @Test void emptyScopeStopsBeforeMysqlQdrantAndLlm(); @Test void citationsOutsideEffectiveScopeAreRemovedAndLogged(); @Test void noEvidenceDoesNotUseModelKnowledgeAsCompanyPolicy(); @Test void legacySopSearchMapsToAuthorizedSopSpaceNotAllTenantSpaces(); @Test void internalAndExternalControllersShareTheSameQueryService(); ``` 源代码契约中额外断言新查询路径不通过空 `category` 表示全租户。 - [ ] **Step 2: 运行测试确认失败** ```bash mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ -Dtest=AihrKnowledgeQueryServiceTest,AihrSopSeedServiceTest test ``` Expected: 新测试 FAIL;现有测试保持可运行。 - [ ] **Step 3: 定义统一契约** `QueryRequest` 字段固定为: ```java String queryText; List spaceCodes; String category; String position; String source; Integer limit; String toolCode; ``` `QueryResponse` 至少包含: ```java String requestId; String queryText; String answer; List citations; List usedSpaceCodes; boolean noEvidence; String promptVersion; LegacySearchPayload legacy; ``` 请求校验:问题 1–1000 字,空间编码最多 20 个,limit 1–10。 - [ ] **Step 4: 改造现有 RAG 为显式空间集合** 在 `AihrSopSeedService` 增加带 `Set 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: 运行测试** ```bash 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** ```bash 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。新增测试必须锁定正确行为: ```java @Test void sameFileInTwoSpacesCreatesTwoAttachMembershipsWithOneOssId(); @Test void addingSecondSpaceNeverUpdatesFirstAttachKnowledgeId(); @Test void addingSecondSpaceNeverDeletesFirstSpaceFragments(); @Test void unbindingOneSpaceKeepsOtherMembershipAndOssObject(); @Test void embeddingsAreGeneratedOnceAndPersistedForEveryTargetSpace(); @Test void uploadRejectsAnyTargetSpaceWithoutManagePermission(); ``` - [ ] **Step 2: 运行测试确认失败** ```bash 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(...)` 拆为等价职责: ```java EmbeddingBatch generateEmbeddings(List 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: 运行测试和上传回归** ```bash mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ -Dtest=AihrSopSeedServiceTest,AihrUploadQueueServiceTest,AihrMultipartFilesTest test ``` Expected: PASS;已有单空间上传、图片/视频处理与异步重试不回退。 - [ ] **Step 7: 提交 Task 5** ```bash 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: 写数据范围失败测试** ```java @Test void myPracticeSummaryAlwaysUsesCurrentAppUserExtPartyId(); @Test void teamPracticeSummaryUsesResolvedSupervisorScope(); @Test void employeeCannotCallTeamPracticeSummary(); @Test void externalAppCannotCallAnyDataTool(); @Test void unknownToolCodeIsRejectedWithoutDatabaseCall(); @Test void requestCannotProvideTenantUserOrExtPartyId(); @Test void toolFailureNeverFallsBackToSeedOrLlmGuess(); ``` - [ ] **Step 2: 运行测试确认失败** ```bash mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=AihrKnowledgeDataToolServiceTest test ``` - [ ] **Step 3: 实现固定枚举分派** 只允许: ```java 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: 运行测试** ```bash mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ -Dtest=AihrKnowledgeDataToolServiceTest,AihrMobileSeedServiceTest,AihrPracticeSeedServiceTest test ``` Expected: PASS;现有移动端本人/团队接口权限测试继续通过。 - [ ] **Step 6: 提交 Task 6** ```bash 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: 写管理越权失败测试** 覆盖: ```java @Test void listReturnsOnlyCurrentTenantSpaces(); @Test void codeCannotChangeAfterSpaceCreation(); @Test void cannotBindAppAndSpaceFromDifferentTenants(); @Test void readPermissionDoesNotGrantManagePermission(); @Test void tokenHashNeverAppearsInListOrDetailDto(); @Test void disablingSpaceRemovesItFromEffectiveScopeImmediately(); ``` - [ ] **Step 2: 运行测试确认失败** ```bash 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: 运行测试** ```bash mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr \ -Dtest=AihrKnowledgeSpaceAdminServiceTest,AihrKnowledgeAccessServiceTest,AihrKnowledgeAppServiceTest test ``` - [ ] **Step 6: 提交 Task 7** ```bash 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: 运行测试确认失败** ```bash 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: 运行前端测试、类型和构建** ```bash npm --prefix frontend exec vitest run src/views/knowledge/spaces.test.ts npm --prefix frontend run build:prod ``` Expected: 测试和生产构建 PASS。 - [ ] **Step 8: 提交 Task 8** ```bash 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: 运行测试确认失败** ```bash 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: 运行客户端测试、类型检查和构建** ```bash 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** ```bash 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: 写幂等初始化脚本失败测试** 脚本接口固定为: ```bash ./scripts/provision-knowledge-platform.sh \ --silver-tenant-id 000000 \ --meitu-tenant-id 100001 \ --dry-run ``` 测试断言 dry-run 输出恰好包含四个 ACTIVE 空间、三个应用及预期绑定,不输出 SQL 密码或 API_TOKEN 明文;缺少任一租户参数时退出非零。 - [ ] **Step 2: 运行测试确认失败** ```bash 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 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** ```bash 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` - [x] **Step 1: 写真实接口验证器** 脚本读取环境变量而不硬编码密钥: ```text 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. 同一文件在两个银城空间分别可检索,解绑一个后另一个仍可检索。 - [x] **Step 2: 写安全反例测试** 至少覆盖: ```text 银城 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 零调用 ``` - [x] **Step 3: 运行完整后端和前端回归** ```bash 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。 - [x] **Step 4: 启动本地服务并运行真实 HTTP 验证** Run backend with portless: ```bash portless run --name wygj-api ./scripts/dev-backend.sh ``` 在另一个终端运行: ```bash AIHR_BASE_URL=https://wygj-api.localhost \ AIHR_SILVER_ADMIN_TOKEN='' \ AIHR_SILVER_EMPLOYEE_TOKEN='' \ AIHR_SILVER_SUPERVISOR_TOKEN='' \ AIHR_MEITU_APP_TOKEN='' \ node scripts/verify-knowledge-platform.mjs ``` Expected: 正例全部 PASS,安全反例返回预期 401/403/429;日志中 requestId 可追踪,未打印令牌或完整问题。 - [x] **Step 5: 检查真实数据库与 Qdrant 隔离证据** ```bash 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。 - [x] **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(待用户确认与既有未提交改动的提交边界)** ```bash 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 工程完成 - [x] schema 和 update migration 可重复执行,原知识数据无损。 - [x] 内部/外部 query 共用服务,认证边界独立。 - [x] 内部有效空间按 app 与 principal 交集计算。 - [x] MySQL 和 Qdrant 都强制 tenant + knowledge IDs。 - [x] 旧 `/search` 兼容但不再搜索租户全库。 - [x] 同文件多空间不会移动或删除原成员关系。 - [x] 两个数据工具只按服务端身份和范围执行。 - [x] 管理端可以管理空间、授权、应用、令牌与限流。 - [x] 移动端和管理端完成新契约迁移。 - [x] 自动化测试、类型检查、构建、真实 HTTP 和数据库核对通过。 ### 12.2 业务试点准入 - [ ] 银城三个空间和美途一个空间的负责人、维护人、审批人已确认。 - [ ] 初始文件清单、密级、禁止内容和版本日期已确认。 - [ ] 银城角色—应用—空间—项目范围矩阵已签字确认。 - [ ] 美途令牌已放在可信后端/云函数,密钥保管人和轮换责任人明确。 - [ ] 每空间至少 10 个标准问题完成真实引用验证。 - [ ] 日调用量、限流值、日志保留周期和告警责任人已确认。 只有工程完成和业务准入两张清单都通过,才能进入正式试点验收。