Browse Source

docs: 补充SaaS化改造与SQL交接说明

WangKang 1 week ago
parent
commit
1875c3569b

+ 312 - 0
docs/initiatives/FEAT-202606-005-saas-tenant-isolation/SaaS化代码改造与下游交接说明.md

@@ -0,0 +1,312 @@
+---
+doc_id: DEV-202606-005
+feature_id: FEAT-202606-005-saas-tenant-isolation
+type: dev-progress
+title: SaaS 化代码改造与下游交接说明
+status: reviewing
+owner: 医梦研发团队
+created_at: 2026-06-27
+updated_at: 2026-06-27
+reviewers: []
+related_docs:
+  - INT-202606-005
+  - RUN-202606-001
+  - STD-202605-001
+  - API-202606-002
+related_modules:
+  - emoon-openplatform
+  - emoon-system-api
+  - emoon-system
+  - emoon-ai-agent-api
+  - emoon-ai-agent
+  - emoon-ai-card-api
+  - emoon-ai-card
+  - emoon-ai-device
+  - emoon-ai-file
+  - emoon-ai-mcp-api
+  - emoon-ai-mcp
+tags:
+  - SaaS
+  - 代码交接
+  - 多租户
+  - 前后端联调
+---
+
+# SaaS 化代码改造与下游交接说明
+
+## 1. 给下游人员和 AI Coding 工具的读取顺序
+
+1. 阅读本文件,确认代码已经完成的范围和遗留项。
+2. 阅读
+   [SaaS化数据库候选变更与执行清单.md](SaaS化数据库候选变更与执行清单.md),
+   先盘点目标数据库,再选择 SQL。
+3. 使用以下命令查看本阶段完整代码差异:
+
+   ```bash
+   git diff --stat 074c4db4..c40b4779
+   git diff 074c4db4..c40b4779
+   git log --oneline 074c4db4..c40b4779
+   ```
+
+4. 数据库适配完成后启动后端,再按本文第 9 节进行前端和接口联调。
+
+## 2. 分支和提交
+
+| 提交 | 内容 |
+|---|---|
+| `99a608b4` | 完成开放平台租户隔离第一阶段改造 |
+| `f9c656c6` | 补齐卡片动作租户隔离 |
+| `95083df3` | 收敛文件元数据项目隔离查询 |
+| `9ef94dc2` | 隔离跨项目设备重复注册 |
+| `db2555ac` | 补齐设备命令租户隔离 |
+| `c40b4779` | 补齐租户上下文与 HIS 路由上下文 |
+
+相对 `master@074c4db4`,本分支共修改 54 个文件,约新增 1332 行、删除 142 行。
+
+## 3. 本阶段总体结论
+
+当前代码已经具备“从鉴权项目解析租户,并在主要 OpenPlatform 调用链中传递
+`projectId + tenantId`”的基础能力。会话、智能体配置、卡片、卡片动作、设备命令、
+文件元数据和任务查询的关键入口已经增加作用域约束。
+
+但当前仍是 SaaS 第一阶段,不应直接判断为生产级完整 SaaS:
+
+- 数据库字段和索引尚未在目标环境确认或执行。
+- HIS 上下文已经传递,但尚未实现按医院选择不同客户端、地址和凭证。
+- 部分运行时状态仍保存在单 JVM 内存中,不支持可靠多实例部署。
+- Workflow、RAG 和管理后台权限未在本分支做完整的双租户实库验收。
+- 兼容旧逻辑时保留了无作用域重载,内部调用若继续使用旧方法,仍可能绕过严格隔离。
+
+## 4. 已完成的代码改造
+
+### 4.1 鉴权和租户上下文
+
+涉及模块:`emoon-openplatform`、`emoon-system`。
+
+- HMAC 鉴权不再固定写入租户 `000000`,而是从 `sys_project.tenant_id` 解析。
+- Bearer Token 仍由配置映射项目,但会继续查询项目并解析真实租户。
+- 项目不存在时拒绝请求,避免只凭项目编号构造上下文。
+- 系统 Facade 在租户上下文中查询 Agent App 和 Engine Config,并同时约束项目。
+- 现有接口路径没有因本次改造发生变化。
+
+注意:
+
+- Bearer Token 目前仍是静态配置到项目的映射,不是完整的租户 Token/JWT 体系。
+- `tenant_id` 为空时部分兼容代码仍回退为 `000000`。生产数据必须避免空租户。
+
+### 4.2 会话和 Agent 调用
+
+涉及模块:`emoon-ai-agent`、`emoon-openplatform`。
+
+- 会话创建时写入租户。
+- 会话详情、列表、消息计数和 Token 累加按项目和租户过滤。
+- 终端会话加载同时校验 `projectId` 和 `tenantId`。
+- Agent App 和 Engine Config 按项目和租户查询。
+- OpenPlatform 将租户传入同步和流式 Agent 调用链。
+
+兼容策略:
+
+- 保留旧方法重载,未传租户时继续按旧逻辑工作,减少对现有调用方的阻塞。
+- 新的 OpenPlatform 主链路已经使用带租户参数的方法。
+
+### 4.3 卡片实例和卡片动作
+
+涉及模块:`emoon-ai-card`、`emoon-openplatform`。
+
+- 卡片实例详情、会话卡片列表、可执行动作和状态更新支持租户过滤。
+- 卡片动作提交、完成和失败前先按租户校验卡片实例。
+- 幂等查询通过动作日志关联卡片实例,以卡片实例的 `tenant_id` 限定租户。
+- Controller 从鉴权上下文传入租户,不接受前端自行指定租户。
+
+已知限制:
+
+- `ai_card_action_log.idempotency_key` 的数据库唯一约束目前可能仍是全局唯一。
+- 动作日志实体没有独立 `tenant_id`,现阶段通过 `ai_card_instance` 关联隔离。
+- 如需允许不同租户重复使用相同幂等键,必须先完成代码和表结构的下一阶段改造,
+  不能只改唯一索引。
+
+### 4.4 文件元数据
+
+涉及模块:`emoon-ai-file`。
+
+- 文件元数据查询由只按 `file_id` 改为按 `file_id + project_id` 查询。
+- 现有文件表已经使用 `project_id`,本阶段没有新增文件表字段。
+
+已知限制:
+
+- 本阶段只收敛了已使用的元数据查询入口。
+- 对象存储路径、签名 URL、删除和下载链路仍需在真实环境验证是否始终校验项目。
+
+### 4.5 设备注册和设备命令
+
+涉及模块:`emoon-ai-device`、`emoon-openplatform`。
+
+- 设备注册发现同名设备属于其他项目时拒绝复用,避免跨项目接管设备。
+- 设备命令新增 `projectId`、`tenantId` 上下文。
+- 命令创建、轮询和回执按设备、项目、租户共同过滤。
+- Device Controller 从鉴权上下文传入项目和租户。
+
+兼容策略:
+
+- 保留旧的无作用域方法,用于未迁移的内部调用。
+- 新的 OpenPlatform 路径已经使用带作用域方法。
+
+### 4.6 任务实例
+
+涉及模块:`emoon-ai-agent`。
+
+- `AiTaskInstanceDO` 新增 `tenantId`。
+- 创建任务时写入项目和租户。
+- 查询会话活跃任务时支持
+  `conversationId + projectId + tenantId + status` 作用域。
+
+已知限制:
+
+- 按 `taskId` 推进、补丁、完成和失败的方法仍主要依赖任务 ID 的全局唯一性。
+- 严格 SaaS 模式下建议为这些写操作继续增加项目和租户作用域重载。
+
+### 4.7 HIS 路由上下文
+
+涉及模块:`emoon-ai-mcp-api`、`emoon-ai-mcp`。
+
+- `ToolCallRequest` 新增 `hospitalId`。
+- HIS legacy 接口支持可选请求头:
+  `X-Emoon-Project-Id`、`X-Emoon-Tenant-Id`、
+  `X-Emoon-Hospital-Id`。
+- 上下文会写入工具调用请求,`hospitalId` 同时补充到工具输入。
+
+必须注意:
+
+这只完成了“路由上下文传递”,没有完成“真实按医院路由”。当前 `HisClient`
+仍可能是单一实现。后续应增加按 `tenantId/projectId/hospitalId` 选择 endpoint、
+凭证、超时、熔断和协议适配器的路由组件。
+
+## 5. 对现有接口的影响
+
+| 类型 | 影响 |
+|---|---|
+| URL 和 HTTP 方法 | 本阶段未主动修改 |
+| 请求体 | 主要保持不变 |
+| 鉴权语义 | 同一凭证只能访问其项目和租户下数据 |
+| 卡片接口 | 查询和动作会校验鉴权租户 |
+| 设备命令 | 轮询和回执会校验项目及租户 |
+| 文件查询 | 必须属于当前项目 |
+| HIS legacy 接口 | 新增可选上下文 Header,旧调用可继续不传 |
+| 错误表现 | 跨租户数据通常表现为不存在、拒绝或空结果 |
+
+前端不得新增 `tenantId` 输入框,也不得从页面参数决定租户。租户必须由后端根据登录
+账号、Token 或项目密钥解析。
+
+## 6. 已执行的开发验证
+
+2026-06-26 在 `c40b4779` 提交前执行过以下验证:
+
+| 模块/用例 | 结果 |
+|---|---|
+| `OpenPlatformAuthInterceptorTest` | 9 个通过 |
+| `AgentChatApplicationServiceImplTest` | 3 个通过 |
+| `TaskStateServiceTest` | 8 个通过 |
+| `HisMcpControllerTest` | 1 个通过 |
+| `emoon-ai-agent` 编译 | 通过 |
+| `emoon-openplatform` 编译 | 通过 |
+| `git diff --check` | 通过 |
+
+`TerminalMvpAcceptanceTest` 有 3 个既有业务链失败:
+
+- `robotNavigationCreatesRouteCard`
+- `kioskRegistrationCreatesTaskAndCard`
+- `traceIdPropagatesThroughFullChain`
+
+失败现象是降级引擎路径没有创建预期卡片,与本次租户字段传递没有直接证据关联。
+下游不能忽略该失败,应在工程启动后结合实际引擎配置重新判断。
+
+## 7. 尚未完成的代码工作
+
+| 优先级 | 项目 | 当前风险 | 建议 |
+|---|---|---|---|
+| P0 | 真实 HIS 多医院路由 | 不同医院可能仍共用同一个客户端配置 | 增加 `HisClientRouter` 或 Provider |
+| P0 | 数据库字段和索引适配 | 新代码可能因缺列启动或运行失败 | 按数据库 Runbook 执行 |
+| P0 | 双租户实库越权测试 | 单元测试不能证明全链路隔离 | 准备两个租户和同名业务数据 |
+| P1 | `IntentStackService` 持久化 | 当前内存态不适合多实例 | 改为 Redis 或数据库并使用租户复合键 |
+| P1 | `CardExecutorImpl` 上下文持久化 | 重启或多实例后状态不一致 | 改为 Redis 或数据库 |
+| P1 | 任务写操作增加作用域 | 依赖 taskId 全局唯一 | 增加项目和租户条件 |
+| P1 | 卡片动作幂等键租户化 | 数据库唯一键可能跨租户冲突 | 代码和表结构同步改造 |
+| P1 | Bearer Token 模型 | 静态 Token 配置不便运营和轮换 | 建立凭证表或短期 Token/JWT |
+| P1 | Workflow/RAG 管理端验收 | 本分支未逐接口验证 | 按租户执行 CRUD 和越权测试 |
+
+## 8. 下游数据库适配
+
+不要直接执行 `script/sql/ai-terminal-mvp.sql` 作为本次 SaaS 迁移。该文件是终端 MVP
+建表脚本,包含当前环境可能未启用的表和演示 Seed 数据。
+
+下游应执行:
+
+1. 备份目标数据库。
+2. 运行数据库 Runbook 的只读盘点 SQL。
+3. 确认实际启用的模块和表。
+4. 只对启用表执行对应候选 SQL。
+5. 回填租户数据并确认不存在未映射记录。
+6. 启动后端并做双租户验证。
+
+## 9. 后端启动和前端联调顺序
+
+### 9.1 后端启动前
+
+- 确认所有启用项目的 `sys_project.tenant_id` 非空且正确。
+- 确认 HMAC 公钥/私钥或 Bearer Token 映射到正确项目。
+- 确认新代码实际访问的表已经具备所需字段。
+- 确认每家医院的 `projectId`、`tenantId`、`hospitalId` 映射关系。
+- 确认真实 HIS 接入仍处于单客户端还是已完成医院路由。
+
+### 9.2 推荐验证命令
+
+```bash
+mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent -am -DskipTests compile
+mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp -am -DskipTests compile
+mvn -pl emoon-openplatform -am -DskipTests compile
+mvn -pl emoon-admin -DskipTests=false -Dprofiles.active= \
+  -Dtest=AiPlatformArchitectureTest test
+```
+
+涉及模块的测试应按目标环境可用性分别执行,不能用 `-DskipTests` 作为上线验收。
+
+### 9.3 前端适配原则
+
+- 前端继续使用现有接口路径。
+- 登录或项目凭证决定租户,前端不传可信 `tenantId`。
+- 对 404、空列表、无权限结果按“当前租户不可见”处理,禁止自动切换其他项目重试。
+- 卡片、会话、设备和文件缓存键至少包含当前登录项目。
+- 切换医院账号时清空本地会话、卡片、文件和设备缓存。
+- 前端日志不得输出 HMAC 私钥、Bearer Token、患者敏感信息。
+
+### 9.4 必须完成的双租户联调用例
+
+准备租户 A、租户 B,各自绑定独立项目,并创建可辨识的同类数据。
+
+| 编号 | 场景 | 预期 |
+|---|---|---|
+| SAAS-001 | A 查询自己的会话 | 成功 |
+| SAAS-002 | A 查询 B 的会话 ID | 不存在或拒绝 |
+| SAAS-003 | A 查询 B 的卡片实例 | 不存在或拒绝 |
+| SAAS-004 | A 对 B 的卡片提交动作 | 不执行、不写动作日志 |
+| SAAS-005 | A 查询 B 的文件 ID | 不存在或拒绝 |
+| SAAS-006 | A 轮询 B 设备的命令 | 返回空或拒绝 |
+| SAAS-007 | A 回执 B 的命令 | 不更新 |
+| SAAS-008 | A 使用 B 的 Agent/Engine 配置 | 不存在或拒绝 |
+| SAAS-009 | A 与 B 使用相同业务幂等键 | 不应互相影响 |
+| SAAS-010 | A、B 分别调用各自医院 HIS | 路由到不同 endpoint 和凭证 |
+
+`SAAS-009` 和 `SAAS-010` 在当前阶段可能失败,属于明确的下一阶段验收门禁。
+
+## 10. 完成交接的判断标准
+
+只有以下条件全部满足后,才能将本专题标记为 `implemented`:
+
+- 目标环境 SQL 已按启用表执行并留存执行记录。
+- 所有启用项目都能唯一映射到租户和医院。
+- 后端编译、架构测试和相关模块测试通过。
+- 后端工程成功启动,无缺列或 Mapper SQL 异常。
+- 前端完成账号切换和缓存隔离适配。
+- 双租户用例完成,跨租户读写全部被阻止。
+- 真实 HIS 多医院路由完成并通过至少两家医院验证。
+- 已知内存态组件的部署限制被接受,或已完成持久化改造。

+ 537 - 0
docs/initiatives/FEAT-202606-005-saas-tenant-isolation/SaaS化数据库候选变更与执行清单.md

@@ -0,0 +1,537 @@
+---
+doc_id: RUN-202606-001
+feature_id: FEAT-202606-005-saas-tenant-isolation
+type: runbook
+title: SaaS 化数据库候选变更与执行清单
+status: reviewing
+owner: 医梦研发团队
+created_at: 2026-06-27
+updated_at: 2026-06-27
+reviewers: []
+related_docs:
+  - INT-202606-005
+  - DEV-202606-005
+related_modules:
+  - emoon-system
+  - emoon-ai-agent
+  - emoon-ai-card
+  - emoon-ai-device
+  - emoon-ai-file
+tags:
+  - SaaS
+  - SQL
+  - 数据迁移
+  - Runbook
+---
+
+# SaaS 化数据库候选变更与执行清单
+
+## 1. 使用说明
+
+本文记录 `codex/saas-phase1-refactor` 分支可能需要的 SQL,不是可直接整份执行的
+数据库迁移脚本。
+
+执行人必须根据目标环境判断:
+
+- 表是否存在
+- 对应模块是否实际启用
+- 字段和索引是否已经存在
+- 存量数据能否映射到真实 `tenant_id`
+- 当前数据库版本是否支持所用语法
+
+本次代码直接新增持久化字段的表只有:
+
+- `ai_task_instance.tenant_id`
+- `ai_device_command.project_id`
+- `ai_device_command.tenant_id`
+
+其他表主要是查询条件变化或前置依赖检查,不应无条件修改。
+
+## 2. 执行前门禁
+
+1. 在维护窗口执行,先做全量或可恢复备份。
+2. 记录数据库名称、版本、执行人、执行时间和分支提交 `c40b4779`。
+3. 禁止在未确认租户映射前把所有历史数据统一写成 `000000`。
+4. 新字段先保持 `NULL`,完成回填和验证后再决定是否改成 `NOT NULL`。
+5. 大表创建索引前评估锁表时间,必要时使用在线 DDL 工具。
+6. 生产环境不执行终端 MVP 脚本中的演示 Seed 数据。
+
+## 3. 只读盘点 SQL
+
+### 3.1 数据库版本
+
+```sql
+SELECT VERSION() AS mysql_version, DATABASE() AS current_database;
+```
+
+### 3.2 相关表是否存在
+
+```sql
+SELECT table_name
+FROM information_schema.tables
+WHERE table_schema = DATABASE()
+  AND table_name IN (
+    'sys_project',
+    'ai_agent_app',
+    'ai_agent_engine_config',
+    'ai_conversation',
+    'ai_card_instance',
+    'ai_card_action_log',
+    'ai_file_object',
+    'ai_device_registry',
+    'ai_device_command',
+    'ai_task_instance'
+  )
+ORDER BY table_name;
+```
+
+不存在且业务未启用的表,不需要为了本次改造单独创建。
+
+### 3.3 字段盘点
+
+```sql
+SELECT table_name, column_name, column_type, is_nullable, column_default
+FROM information_schema.columns
+WHERE table_schema = DATABASE()
+  AND (
+    (table_name = 'sys_project'
+      AND column_name IN ('id', 'tenant_id'))
+    OR
+    (table_name IN ('ai_agent_app', 'ai_agent_engine_config')
+      AND column_name IN ('id', 'project_id', 'tenant_id'))
+    OR
+    (table_name IN ('ai_conversation', 'ai_card_instance')
+      AND column_name IN ('project_id', 'tenant_id', 'conversation_id', 'instance_id'))
+    OR
+    (table_name = 'ai_file_object'
+      AND column_name IN ('file_id', 'project_id'))
+    OR
+    (table_name = 'ai_device_registry'
+      AND column_name IN ('device_id', 'project_id', 'hospital_id'))
+    OR
+    (table_name = 'ai_device_command'
+      AND column_name IN ('command_id', 'device_id', 'project_id', 'tenant_id'))
+    OR
+    (table_name = 'ai_task_instance'
+      AND column_name IN ('task_id', 'conversation_id', 'project_id', 'tenant_id'))
+    OR
+    (table_name = 'ai_card_action_log'
+      AND column_name IN ('card_instance_id', 'idempotency_key', 'tenant_id'))
+  )
+ORDER BY table_name, ordinal_position;
+```
+
+### 3.4 索引盘点
+
+```sql
+SELECT table_name, index_name,
+       GROUP_CONCAT(column_name ORDER BY seq_in_index) AS columns_in_order,
+       non_unique
+FROM information_schema.statistics
+WHERE table_schema = DATABASE()
+  AND table_name IN (
+    'ai_task_instance',
+    'ai_device_command',
+    'ai_card_action_log',
+    'ai_conversation',
+    'ai_card_instance',
+    'ai_file_object'
+  )
+GROUP BY table_name, index_name, non_unique
+ORDER BY table_name, index_name;
+```
+
+### 3.5 项目和租户映射质量
+
+```sql
+SELECT id, tenant_id, organization, project, del_flag
+FROM sys_project
+WHERE tenant_id IS NULL OR TRIM(tenant_id) = '';
+```
+
+该查询有结果时,先修复 `sys_project`,再回填业务表。
+
+## 4. 必需候选 SQL:任务实例
+
+仅当 `ai_task_instance` 存在并实际启用时执行。
+
+### 4.1 增加租户字段
+
+```sql
+ALTER TABLE ai_task_instance
+  ADD COLUMN tenant_id VARCHAR(20) NULL
+  COMMENT '租户编号,用于任务实例隔离'
+  AFTER project_id;
+```
+
+如果字段已存在,跳过。
+
+### 4.2 从项目表回填
+
+`ai_task_instance.project_id` 是字符串,而 `sys_project.id` 是数字。执行前确认目标环境
+确实使用项目主键字符串作为 `project_id`。
+
+```sql
+UPDATE ai_task_instance t
+JOIN sys_project p
+  ON CAST(p.id AS CHAR) = t.project_id
+SET t.tenant_id = p.tenant_id
+WHERE (t.tenant_id IS NULL OR TRIM(t.tenant_id) = '')
+  AND p.tenant_id IS NOT NULL
+  AND TRIM(p.tenant_id) <> '';
+```
+
+### 4.3 检查未映射数据
+
+```sql
+SELECT project_id, COUNT(*) AS unmapped_count
+FROM ai_task_instance
+WHERE tenant_id IS NULL OR TRIM(tenant_id) = ''
+GROUP BY project_id
+ORDER BY unmapped_count DESC;
+```
+
+该查询必须返回 0 行,或由业务负责人明确确认剩余数据可废弃/归档。
+
+### 4.4 增加查询索引
+
+```sql
+CREATE INDEX idx_task_conv_scope_status
+  ON ai_task_instance(
+    conversation_id,
+    project_id,
+    tenant_id,
+    status,
+    created_at
+  );
+```
+
+索引已存在或已有等价覆盖索引时跳过。
+
+### 4.5 可选收紧
+
+只有未映射数据为 0,且所有任务创建入口都已经部署到本分支代码后才执行:
+
+```sql
+ALTER TABLE ai_task_instance
+  MODIFY COLUMN tenant_id VARCHAR(20) NOT NULL
+  COMMENT '租户编号,用于任务实例隔离';
+```
+
+## 5. 必需候选 SQL:设备命令
+
+仅当 `ai_device_command` 存在并实际启用时执行。
+
+### 5.1 增加项目和租户字段
+
+```sql
+ALTER TABLE ai_device_command
+  ADD COLUMN project_id VARCHAR(64) NULL
+  COMMENT '项目标识,用于设备命令隔离'
+  AFTER device_id,
+  ADD COLUMN tenant_id VARCHAR(20) NULL
+  COMMENT '租户编号,用于设备命令隔离'
+  AFTER project_id;
+```
+
+单个字段已经存在时,拆分语句,只增加缺失字段。
+
+### 5.2 从设备注册表回填项目
+
+仅当 `ai_device_registry` 存在,且 `device_id` 在该表中能唯一确定项目时执行:
+
+```sql
+UPDATE ai_device_command c
+JOIN ai_device_registry d
+  ON d.device_id = c.device_id
+SET c.project_id = d.project_id
+WHERE c.project_id IS NULL OR TRIM(c.project_id) = '';
+```
+
+先检查同一设备是否存在多项目记录:
+
+```sql
+SELECT device_id, COUNT(DISTINCT project_id) AS project_count
+FROM ai_device_registry
+GROUP BY device_id
+HAVING COUNT(DISTINCT project_id) > 1;
+```
+
+有结果时禁止自动回填,必须人工确定归属。
+
+### 5.3 从项目表回填租户
+
+仅适用于 `ai_device_registry.project_id` 存储 `sys_project.id` 字符串的环境:
+
+```sql
+UPDATE ai_device_command c
+JOIN sys_project p
+  ON CAST(p.id AS CHAR) = c.project_id
+SET c.tenant_id = p.tenant_id
+WHERE (c.tenant_id IS NULL OR TRIM(c.tenant_id) = '')
+  AND p.tenant_id IS NOT NULL
+  AND TRIM(p.tenant_id) <> '';
+```
+
+如果项目标识使用业务编码,例如 `hospital_demo`,应由下游提供明确映射表,不得直接
+回填默认租户。
+
+### 5.4 检查未映射数据
+
+```sql
+SELECT project_id, device_id, COUNT(*) AS unmapped_count
+FROM ai_device_command
+WHERE project_id IS NULL
+   OR TRIM(project_id) = ''
+   OR tenant_id IS NULL
+   OR TRIM(tenant_id) = ''
+GROUP BY project_id, device_id
+ORDER BY unmapped_count DESC;
+```
+
+### 5.5 增加索引
+
+```sql
+CREATE INDEX idx_command_scope_poll
+  ON ai_device_command(
+    device_id,
+    project_id,
+    tenant_id,
+    status,
+    expire_at,
+    created_at
+  );
+
+CREATE INDEX idx_command_scope_id
+  ON ai_device_command(
+    command_id,
+    device_id,
+    project_id,
+    tenant_id
+  );
+```
+
+已有等价索引时跳过。
+
+### 5.6 可选收紧
+
+仅在未映射数据为 0,且确认没有旧版本应用继续写入空字段后执行:
+
+```sql
+ALTER TABLE ai_device_command
+  MODIFY COLUMN project_id VARCHAR(64) NOT NULL
+  COMMENT '项目标识,用于设备命令隔离',
+  MODIFY COLUMN tenant_id VARCHAR(20) NOT NULL
+  COMMENT '租户编号,用于设备命令隔离';
+```
+
+## 6. 前置依赖检查:已有租户字段的表
+
+以下表不是本分支新增字段,但新查询依赖其作用域数据正确。
+
+### 6.1 会话
+
+```sql
+SELECT project_id, tenant_id, COUNT(*) AS row_count
+FROM ai_conversation
+WHERE tenant_id IS NULL OR TRIM(tenant_id) = ''
+GROUP BY project_id, tenant_id;
+```
+
+如有空租户,可按项目表回填:
+
+```sql
+UPDATE ai_conversation c
+JOIN sys_project p
+  ON p.id = c.project_id
+SET c.tenant_id = p.tenant_id
+WHERE (c.tenant_id IS NULL OR TRIM(c.tenant_id) = '')
+  AND p.tenant_id IS NOT NULL
+  AND TRIM(p.tenant_id) <> '';
+```
+
+建议确认存在覆盖作用域查询的索引:
+
+```sql
+CREATE INDEX idx_conversation_scope
+  ON ai_conversation(conversation_id, project_id, tenant_id);
+```
+
+已有等价索引时跳过。
+
+### 6.2 卡片实例
+
+本阶段卡片查询和动作依赖 `ai_card_instance.tenant_id`:
+
+```sql
+SELECT tenant_id, COUNT(*) AS row_count
+FROM ai_card_instance
+WHERE tenant_id IS NULL OR TRIM(tenant_id) = ''
+GROUP BY tenant_id;
+```
+
+卡片表没有 `project_id` 时,不要猜测租户。优先通过会话关系回填:
+
+```sql
+UPDATE ai_card_instance i
+JOIN ai_conversation c
+  ON c.conversation_id = i.conversation_id
+SET i.tenant_id = c.tenant_id
+WHERE (i.tenant_id IS NULL OR TRIM(i.tenant_id) = '')
+  AND c.tenant_id IS NOT NULL
+  AND TRIM(c.tenant_id) <> '';
+```
+
+建议索引:
+
+```sql
+CREATE INDEX idx_card_instance_tenant_instance
+  ON ai_card_instance(tenant_id, instance_id);
+
+CREATE INDEX idx_card_instance_tenant_conversation
+  ON ai_card_instance(tenant_id, conversation_id);
+```
+
+已有等价索引时跳过。
+
+### 6.3 Agent App 和 Engine Config
+
+新代码按 `id/agentId + project_id + tenant_id` 查询。只做数据质量检查:
+
+```sql
+SELECT 'ai_agent_app' AS source_table, project_id, tenant_id, COUNT(*) AS row_count
+FROM ai_agent_app
+WHERE tenant_id IS NULL OR TRIM(tenant_id) = ''
+GROUP BY project_id, tenant_id
+UNION ALL
+SELECT 'ai_agent_engine_config', project_id, tenant_id, COUNT(*)
+FROM ai_agent_engine_config
+WHERE tenant_id IS NULL OR TRIM(tenant_id) = ''
+GROUP BY project_id, tenant_id;
+```
+
+具体回填前必须确认两张表的 `project_id` 语义,不在本文中提供盲目更新语句。
+
+### 6.4 文件对象
+
+本阶段没有新增字段,但查询依赖 `file_id + project_id`:
+
+```sql
+SELECT file_id, COUNT(DISTINCT project_id) AS project_count
+FROM ai_file_object
+GROUP BY file_id
+HAVING COUNT(DISTINCT project_id) > 1;
+```
+
+如果 `file_id` 设计为全局唯一,上述查询应返回 0 行。建议确认索引:
+
+```sql
+CREATE INDEX idx_file_project_file
+  ON ai_file_object(project_id, file_id);
+```
+
+已有等价索引时跳过。
+
+## 7. 暂缓执行:卡片动作幂等键租户化
+
+当前代码通过 `ai_card_action_log -> ai_card_instance` 关联查询租户,但动作日志实体没有
+独立 `tenant_id`。如果数据库仍有:
+
+```text
+UNIQUE KEY uk_idempotency (idempotency_key)
+```
+
+则两个租户使用相同幂等键时,第二个租户仍可能因全局唯一约束插入失败。
+
+不要在当前代码版本下直接执行以下结构变更:
+
+```sql
+-- 仅为下一阶段设计草案,当前禁止直接执行。
+ALTER TABLE ai_card_action_log
+  ADD COLUMN tenant_id VARCHAR(20) NULL AFTER card_instance_id;
+
+UPDATE ai_card_action_log l
+JOIN ai_card_instance i
+  ON i.instance_id = l.card_instance_id
+SET l.tenant_id = i.tenant_id
+WHERE l.tenant_id IS NULL OR TRIM(l.tenant_id) = '';
+
+ALTER TABLE ai_card_action_log
+  DROP INDEX uk_idempotency,
+  ADD UNIQUE KEY uk_tenant_idempotency(tenant_id, idempotency_key);
+```
+
+执行前必须先修改代码,使新动作日志写入 `tenant_id`,并将幂等查询直接限定为
+`tenant_id + idempotency_key`。该项属于后续代码任务。
+
+## 8. 明确无需因本分支修改的表
+
+| 表 | 结论 |
+|---|---|
+| `ai_file_object` | 已有 `project_id`,只需检查数据和索引 |
+| `ai_device_registry` | 已有 `project_id/hospital_id`,本阶段代码拒绝跨项目复用同一设备 |
+| `ai_card_instance` | 应已有框架租户字段,只需检查和回填 |
+| `ai_conversation` | 应已有框架租户字段,只需检查和回填 |
+| `sys_project` | 应已有 `tenant_id`,必须保证数据正确 |
+| Workflow/RAG 相关表 | 本分支未直接修改,是否迁移需结合实际启用模块另行审计 |
+
+## 9. 执行后核验
+
+### 9.1 空作用域数据
+
+```sql
+SELECT 'ai_task_instance' AS source_table, COUNT(*) AS invalid_count
+FROM ai_task_instance
+WHERE project_id IS NULL OR TRIM(project_id) = ''
+   OR tenant_id IS NULL OR TRIM(tenant_id) = ''
+UNION ALL
+SELECT 'ai_device_command', COUNT(*)
+FROM ai_device_command
+WHERE project_id IS NULL OR TRIM(project_id) = ''
+   OR tenant_id IS NULL OR TRIM(tenant_id) = '';
+```
+
+如果某张表未启用或不存在,应删除对应 `UNION ALL` 分支后执行。
+
+### 9.2 项目租户一致性
+
+```sql
+SELECT t.task_id, t.project_id, t.tenant_id, p.tenant_id AS expected_tenant_id
+FROM ai_task_instance t
+JOIN sys_project p ON CAST(p.id AS CHAR) = t.project_id
+WHERE t.tenant_id <> p.tenant_id
+LIMIT 100;
+
+SELECT c.command_id, c.project_id, c.tenant_id, p.tenant_id AS expected_tenant_id
+FROM ai_device_command c
+JOIN sys_project p ON CAST(p.id AS CHAR) = c.project_id
+WHERE c.tenant_id <> p.tenant_id
+LIMIT 100;
+```
+
+两条查询都应返回 0 行。
+
+### 9.3 执行记录
+
+下游应在本专题目录新增一份执行记录,至少包含:
+
+- 环境和数据库版本
+- 实际执行的 SQL 段落
+- 跳过的表及原因
+- 执行前后行数
+- 未映射数据处理方式
+- 索引创建耗时
+- 后端启动结果
+- 双租户联调结果
+- 回滚或恢复点
+
+## 10. 回滚说明
+
+优先使用执行前备份恢复。字段已经被新版本代码写入数据后,不建议直接删除字段。
+
+如确需回滚应用:
+
+1. 先回滚后端到不依赖新字段的版本。
+2. 保留新增字段和索引,不会阻塞旧版本时无需删除。
+3. 如索引造成明显性能问题,可在确认无新版本流量后单独删除新增索引。
+4. 不回滚已完成的租户数据修复,除非有明确错误映射和可恢复备份。

+ 85 - 0
docs/initiatives/FEAT-202606-005-saas-tenant-isolation/专题索引.md

@@ -0,0 +1,85 @@
+---
+feature_id: FEAT-202606-005-saas-tenant-isolation
+title: AI 中台 SaaS 租户隔离改造
+status: reviewing
+owner: 医梦研发团队
+created_at: 2026-06-27
+updated_at: 2026-06-27
+tags:
+  - SaaS
+  - 多租户
+  - 租户隔离
+  - 下游交接
+---
+
+# AI 中台 SaaS 租户隔离改造
+
+## 目标
+
+记录 `codex/saas-phase1-refactor` 分支已经完成的租户隔离改造、可能需要执行的
+数据库 SQL、已知边界和下游启动联调步骤。
+
+本文档组用于帮助下游研发或 AI Coding 工具快速恢复上下文,不代表数据库迁移、
+工程启动、前端适配和真实多医院联调已经完成。
+
+## 版本基线
+
+| 项目 | 值 |
+|---|---|
+| 基线分支 | `master` |
+| 基线提交 | `074c4db4` |
+| SaaS 分支 | `codex/saas-phase1-refactor` |
+| 当前提交 | `c40b4779` |
+| 代码改造时间 | 2026-06-26 |
+| 文档补录时间 | 2026-06-27 |
+
+## 文档索引
+
+| 类型 | 文档 | 状态 | 用途 |
+|---|---|---|---|
+| 需求录入 | [需求录入.md](需求录入.md) | approved | 记录目标、边界和交付方式 |
+| 开发进度 | [SaaS化代码改造与下游交接说明.md](SaaS化代码改造与下游交接说明.md) | reviewing | 代码改动、验证结果、遗留项和联调步骤 |
+| Runbook | [SaaS化数据库候选变更与执行清单.md](SaaS化数据库候选变更与执行清单.md) | reviewing | 数据库盘点、候选 SQL、执行顺序和校验 |
+
+## 关联代码模块
+
+- `emoon-openplatform`
+- `emoon-system-api`
+- `emoon-system`
+- `emoon-ai-agent-api`
+- `emoon-ai-agent`
+- `emoon-ai-card-api`
+- `emoon-ai-card`
+- `emoon-ai-device`
+- `emoon-ai-file`
+- `emoon-ai-mcp-api`
+- `emoon-ai-mcp`
+
+## 当前状态
+
+代码侧第一阶段租户上下文传递和主要查询隔离已经完成并提交。
+
+尚未完成:
+
+- 目标环境数据库盘点和 SQL 执行
+- 真实租户、项目、医院基础数据初始化
+- 后端完整启动验证
+- 前端工程适配和接口联调
+- 两家医院以上的交叉租户越权测试
+- HIS/LIS 等院内系统的按医院动态路由
+- 内存态组件的多实例持久化改造
+
+因此当前状态为 `reviewing`,不能标记为已上线或生产可用。
+
+## 流程裁剪
+
+本专题是对已经发生的代码改造进行补录,不追溯性制造未经评审的完整 PRD 和设计。
+
+| 阶段 | 是否跳过 | 原因 | 风险 | 替代记录 |
+|---|---|---|---|---|
+| 需求文档 | 是 | 改造已在文档生命周期建立前完成 | 验收口径不够完整 | `需求录入.md` 与交接说明 |
+| 概要设计 | 是 | 本阶段未改变既有模块边界 | 缺少正式架构评审记录 | 既有平台概要设计与交接说明 |
+| 详细设计 | 是 | 本阶段以最小兼容改造为主 | 数据迁移规则需目标环境确认 | 数据库 Runbook |
+| 接口说明 | 是 | 未新增对外 URL,主要增加内部作用域参数和可选上下文头 | 下游可能忽略鉴权语义变化 | 交接说明中的接口影响章节 |
+| 测试计划 | 是 | 代码改造期间按模块补充回归测试 | 未覆盖真实数据库和多医院联调 | 交接说明中的验收清单 |
+| 测试结果 | 是 | 当前只有开发环境自动化验证记录 | 不能据此判断可上线 | 交接说明中的已执行验证 |

+ 62 - 0
docs/initiatives/FEAT-202606-005-saas-tenant-isolation/需求录入.md

@@ -0,0 +1,62 @@
+---
+doc_id: INT-202606-005
+feature_id: FEAT-202606-005-saas-tenant-isolation
+type: intake
+title: AI 中台 SaaS 租户隔离改造需求录入
+status: approved
+owner: 医梦研发团队
+created_at: 2026-06-27
+updated_at: 2026-06-27
+reviewers: []
+related_docs:
+  - DEV-202606-005
+  - RUN-202606-001
+related_modules:
+  - emoon-openplatform
+  - emoon-ai-agent
+  - emoon-ai-card
+  - emoon-ai-device
+  - emoon-ai-file
+  - emoon-ai-mcp
+tags:
+  - SaaS
+  - 多医院
+  - 租户隔离
+---
+
+# AI 中台 SaaS 租户隔离改造需求录入
+
+## 背景
+
+中台服务计划部署在卫健委或区域中心,各医院通过专线接入各自的 HIS、LIS 等系统。
+平台为每家医院分配独立账号,医院只能读写本租户下的 Workflow、RAG、Agent、Card、
+会话、任务、设备、文件和院内系统调用数据。
+
+## 本阶段目标
+
+在尽量不改变现有接口路径、不阻塞现有逻辑的前提下,先完成后端代码层的租户上下文
+传递和主要业务对象查询隔离,并为下游保留完整的数据库、启动和联调交接材料。
+
+## 本阶段交付
+
+- SaaS 代码改造分支及 6 个提交
+- 代码改造和遗留项交接说明
+- 按目标环境实际启用表选择执行的候选 SQL
+- 后端启动、前端适配、跨租户验收清单
+
+## 明确不在本阶段完成
+
+- 不直接连接或修改下游数据库
+- 不假设所有终端 MVP 表已经启用
+- 不完成前端工程改造
+- 不完成真实 HIS/LIS 厂商接口接入
+- 不完成生产配置、证书、专线和网络联通
+- 不宣称已经具备生产级完整 SaaS 能力
+
+## 交付原则
+
+1. 代码优先完成,数据库和前端由下游结合真实环境执行。
+2. SQL 不允许整份盲目执行,必须先盘点表、字段、索引和存量数据。
+3. 新字段先允许为空,完成数据回填和核验后再决定是否收紧为 `NOT NULL`。
+4. 保留旧方法和可选参数兼容,避免本阶段直接阻塞现有接口。
+5. 所有上线结论必须经过至少两个租户的交叉越权测试。

+ 1 - 0
docs/initiatives/专题索引.md

@@ -12,3 +12,4 @@
 | [FEAT-202606-002-fastgpt-integration](FEAT-202606-002-fastgpt-integration/专题索引.md) | FastGPT 接入 | reviewing | FastGPT 作为 Agent 实现层接入 |
 | [FEAT-202606-003-sqlite-his-mock-mcp-tools](FEAT-202606-003-sqlite-his-mock-mcp-tools/专题索引.md) | SQLite HIS Mock MCP 工具链 | implemented | 挂号演示 Mock HIS 和 MCP 工具 |
 | [FEAT-202606-004-opd-triage-agent](FEAT-202606-004-opd-triage-agent/专题索引.md) | 门诊分诊智能体 | approved | Dify 分诊智能体 Prompt |
+| [FEAT-202606-005-saas-tenant-isolation](FEAT-202606-005-saas-tenant-isolation/专题索引.md) | AI 中台 SaaS 租户隔离改造 | reviewing | 代码改造、候选 SQL 和下游联调交接 |