--- 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` HIS 多医院路由采用“配置文件 + `HisClientProvider` SPI”,没有新增持久化表或字段, 因此该方案本身: - 不需要新增 DDL。 - 不需要新增 DML 或 Seed 数据。 - 不需要执行医院路由迁移 SQL。 - 只依赖 `ai_conversation` 已有的 `conversation_id`、`project_id`、`tenant_id` 和 `hospital_id` 做作用域查询。 如果下游改为数据库动态维护路由,必须另开需求和设计,补充加密、审计、缓存一致性与 迁移 Runbook;不得在联调期间临时建表。本文件后续 SQL 只服务此前任务、设备、卡片等 SaaS 隔离改造,不能作为 HIS 路由建表依据。 - `ai_device_command.tenant_id` - `ai_card_action_log.tenant_id` 其他表主要是查询条件变化或前置依赖检查,不应无条件修改。 ## 2. 执行前门禁 1. 在维护窗口执行,先做全量或可恢复备份。 2. 记录数据库名称、版本、执行人、执行时间和分支实际 `HEAD` 提交。 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 增加任务写作用域索引 第二阶段代码会按 `task_id + project_id + tenant_id` 查找任务后再更新: ```sql CREATE INDEX idx_task_id_scope ON ai_task_instance(task_id, project_id, tenant_id); ``` 如果 `task_id` 已经是全局唯一索引,该索引不是正确性必需项,但建议保留作用域索引, 使数据库执行计划与代码隔离条件一致。已有等价覆盖索引时跳过。 ### 4.6 可选收紧 只有未映射数据为 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. 卡片动作幂等键租户化 第二阶段代码已经使动作日志直接写入 `tenant_id`,并按 `tenant_id + idempotency_key` 查询。仅当 `ai_card_action_log` 存在且实际启用时, 按以下顺序执行。 ### 7.1 增加租户字段 ```sql ALTER TABLE ai_card_action_log ADD COLUMN tenant_id VARCHAR(20) NULL COMMENT '租户编号,用于卡片动作隔离和租户内幂等' AFTER card_instance_id; ``` 字段已存在时跳过。 ### 7.2 从卡片实例回填 ```sql 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) = '') AND i.tenant_id IS NOT NULL AND TRIM(i.tenant_id) <> ''; ``` 检查未映射数据: ```sql SELECT card_instance_id, COUNT(*) AS unmapped_count FROM ai_card_action_log WHERE tenant_id IS NULL OR TRIM(tenant_id) = '' GROUP BY card_instance_id ORDER BY unmapped_count DESC; ``` 该查询有结果时禁止切换唯一索引,必须先修复卡片实例租户或归档无效日志。 ### 7.3 检查租户内重复键 ```sql SELECT tenant_id, idempotency_key, COUNT(*) AS duplicate_count FROM ai_card_action_log GROUP BY tenant_id, idempotency_key HAVING COUNT(*) > 1; ``` 该查询必须返回 0 行。存在重复时,先由业务负责人确认保留记录,不能直接删除审计日志。 ### 7.4 发布门禁 此时保留原全局唯一索引,不要立即切换。按以下顺序发布: 1. 先执行 7.1 和 7.2,让新代码查询所需字段存在并完成首次回填。 2. 部署本分支新代码,确认所有卡片动作写入实例都已升级。 3. 停止或摘除旧版本写实例,避免继续产生空 `tenant_id`。 4. 再执行一次 7.2 的回填 SQL,处理滚动发布期间产生的数据。 5. 重新执行 7.2、7.3 的检查,两项都通过后再切换唯一索引。 ### 7.5 切换为租户复合唯一索引并收紧字段 先确认当前唯一索引名称: ```sql SELECT 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 = 'ai_card_action_log' GROUP BY index_name, non_unique ORDER BY index_name; ``` 如果全局唯一索引名称确实为 `uk_idempotency`,且空租户和租户内重复检查均为 0, 执行一个原子 DDL: ```sql ALTER TABLE ai_card_action_log MODIFY COLUMN tenant_id VARCHAR(20) NOT NULL COMMENT '租户编号,用于卡片动作隔离和租户内幂等', DROP INDEX uk_idempotency, ADD UNIQUE KEY uk_tenant_idempotency(tenant_id, idempotency_key); ``` 实际索引名不同时,把 `DROP INDEX` 后的名称替换为盘点结果,禁止猜测索引名。 执行后,不同租户可以复用相同幂等键,同一租户仍由数据库唯一约束防止并发重复写入。 ## 8. 明确无需因本分支修改的表 | 表 | 结论 | |---|---| | `ai_file_object` | 已有 `project_id`,只需检查数据和索引 | | `ai_device_registry` | 已有 `project_id/hospital_id`,本阶段代码拒绝跨项目复用同一设备 | | `ai_card_instance` | 应已有框架租户字段,只需检查和回填 | | `ai_conversation` | 应已有框架租户字段,只需检查和回填 | | `sys_project` | 应已有 `tenant_id`,必须保证数据正确 | | Workflow/RAG 相关表 | 本分支未直接修改,是否迁移需结合实际启用模块另行审计 | | HIS 路由表 | 本方案不建表;路由来自配置文件和 Provider SPI | ## 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` 分支后执行。 卡片动作日志启用时另行执行: ```sql SELECT COUNT(*) AS invalid_count FROM ai_card_action_log WHERE tenant_id IS NULL OR TRIM(tenant_id) = ''; ``` 应返回 `0`。 ### 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. 不回滚已完成的租户数据修复,除非有明确错误映射和可恢复备份。