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

1029 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 多租户多知识空间统一问答平台 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_<id>`,核对租户内无重复后改成 `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<String> roles,
Set<String> projectCodes,
String clientKey
) {}
```
`AihrKnowledgePrincipalResolver` 只从 `LoginHelper.getLoginUser()`、`rolePermission` 和组织快照生成该对象;Controller 请求 DTO 不允许出现 `tenantId/userId/extPartyId/projectCodes/roles`。
- [ ] **Step 4: 实现空间授权计算**
`AihrKnowledgeAccessService` 提供:
```java
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:
```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_<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: 运行安全测试和依赖检查**
```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<String> spaceCodes;
String category;
String position;
String source;
Integer limit;
String toolCode;
```
`QueryResponse` 至少包含:
```java
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: 运行测试**
```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<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: 运行测试和上传回归**
```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='<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 可追踪,未打印令牌或完整问题。
- [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 个标准问题完成真实引用验证。
- [ ] 日调用量、限流值、日志保留周期和告警责任人已确认。
只有工程完成和业务准入两张清单都通过,才能进入正式试点验收。