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:
本文记录 codex/saas-phase1-refactor 分支可能需要的 SQL,不是可直接整份执行的
数据库迁移脚本。
执行人必须根据目标环境判断:
tenant_id本次代码直接新增持久化字段的表只有:
ai_task_instance.tenant_idai_device_command.project_idHIS 多医院路由采用“配置文件 + HisClientProvider SPI”,没有新增持久化表或字段,
因此该方案本身:
ai_conversation 已有的 conversation_id、project_id、tenant_id 和
hospital_id 做作用域查询。如果下游改为数据库动态维护路由,必须另开需求和设计,补充加密、审计、缓存一致性与 迁移 Runbook;不得在联调期间临时建表。本文件后续 SQL 只服务此前任务、设备、卡片等 SaaS 隔离改造,不能作为 HIS 路由建表依据。
ai_device_command.tenant_idai_card_action_log.tenant_id其他表主要是查询条件变化或前置依赖检查,不应无条件修改。
HEAD 提交。000000。NULL,完成回填和验证后再决定是否改成 NOT NULL。SELECT VERSION() AS mysql_version, DATABASE() AS current_database;
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;
不存在且业务未启用的表,不需要为了本次改造单独创建。
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;
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;
SELECT id, tenant_id, organization, project, del_flag
FROM sys_project
WHERE tenant_id IS NULL OR TRIM(tenant_id) = '';
该查询有结果时,先修复 sys_project,再回填业务表。
仅当 ai_task_instance 存在并实际启用时执行。
ALTER TABLE ai_task_instance
ADD COLUMN tenant_id VARCHAR(20) NULL
COMMENT '租户编号,用于任务实例隔离'
AFTER project_id;
如果字段已存在,跳过。
ai_task_instance.project_id 是字符串,而 sys_project.id 是数字。执行前确认目标环境
确实使用项目主键字符串作为 project_id。
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) <> '';
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 行,或由业务负责人明确确认剩余数据可废弃/归档。
CREATE INDEX idx_task_conv_scope_status
ON ai_task_instance(
conversation_id,
project_id,
tenant_id,
status,
created_at
);
索引已存在或已有等价覆盖索引时跳过。
第二阶段代码会按 task_id + project_id + tenant_id 查找任务后再更新:
CREATE INDEX idx_task_id_scope
ON ai_task_instance(task_id, project_id, tenant_id);
如果 task_id 已经是全局唯一索引,该索引不是正确性必需项,但建议保留作用域索引,
使数据库执行计划与代码隔离条件一致。已有等价覆盖索引时跳过。
只有未映射数据为 0,且所有任务创建入口都已经部署到本分支代码后才执行:
ALTER TABLE ai_task_instance
MODIFY COLUMN tenant_id VARCHAR(20) NOT NULL
COMMENT '租户编号,用于任务实例隔离';
仅当 ai_device_command 存在并实际启用时执行。
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;
单个字段已经存在时,拆分语句,只增加缺失字段。
仅当 ai_device_registry 存在,且 device_id 在该表中能唯一确定项目时执行:
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) = '';
先检查同一设备是否存在多项目记录:
SELECT device_id, COUNT(DISTINCT project_id) AS project_count
FROM ai_device_registry
GROUP BY device_id
HAVING COUNT(DISTINCT project_id) > 1;
有结果时禁止自动回填,必须人工确定归属。
仅适用于 ai_device_registry.project_id 存储 sys_project.id 字符串的环境:
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,应由下游提供明确映射表,不得直接
回填默认租户。
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;
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
);
已有等价索引时跳过。
仅在未映射数据为 0,且确认没有旧版本应用继续写入空字段后执行:
ALTER TABLE ai_device_command
MODIFY COLUMN project_id VARCHAR(64) NOT NULL
COMMENT '项目标识,用于设备命令隔离',
MODIFY COLUMN tenant_id VARCHAR(20) NOT NULL
COMMENT '租户编号,用于设备命令隔离';
以下表不是本分支新增字段,但新查询依赖其作用域数据正确。
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;
如有空租户,可按项目表回填:
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) <> '';
建议确认存在覆盖作用域查询的索引:
CREATE INDEX idx_conversation_scope
ON ai_conversation(conversation_id, project_id, tenant_id);
已有等价索引时跳过。
本阶段卡片查询和动作依赖 ai_card_instance.tenant_id:
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 时,不要猜测租户。优先通过会话关系回填:
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) <> '';
建议索引:
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);
已有等价索引时跳过。
新代码按 id/agentId + project_id + tenant_id 查询。只做数据质量检查:
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 语义,不在本文中提供盲目更新语句。
本阶段没有新增字段,但查询依赖 file_id + project_id:
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 行。建议确认索引:
CREATE INDEX idx_file_project_file
ON ai_file_object(project_id, file_id);
已有等价索引时跳过。
第二阶段代码已经使动作日志直接写入 tenant_id,并按
tenant_id + idempotency_key 查询。仅当 ai_card_action_log 存在且实际启用时,
按以下顺序执行。
ALTER TABLE ai_card_action_log
ADD COLUMN tenant_id VARCHAR(20) NULL
COMMENT '租户编号,用于卡片动作隔离和租户内幂等'
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) = '')
AND i.tenant_id IS NOT NULL
AND TRIM(i.tenant_id) <> '';
检查未映射数据:
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;
该查询有结果时禁止切换唯一索引,必须先修复卡片实例租户或归档无效日志。
SELECT tenant_id, idempotency_key, COUNT(*) AS duplicate_count
FROM ai_card_action_log
GROUP BY tenant_id, idempotency_key
HAVING COUNT(*) > 1;
该查询必须返回 0 行。存在重复时,先由业务负责人确认保留记录,不能直接删除审计日志。
此时保留原全局唯一索引,不要立即切换。按以下顺序发布:
tenant_id。先确认当前唯一索引名称:
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:
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 后的名称替换为盘点结果,禁止猜测索引名。
执行后,不同租户可以复用相同幂等键,同一租户仍由数据库唯一约束防止并发重复写入。
| 表 | 结论 |
|---|---|
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 |
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 分支后执行。
卡片动作日志启用时另行执行:
SELECT COUNT(*) AS invalid_count
FROM ai_card_action_log
WHERE tenant_id IS NULL OR TRIM(tenant_id) = '';
应返回 0。
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 行。
下游应在本专题目录新增一份执行记录,至少包含:
优先使用执行前备份恢复。字段已经被新版本代码写入数据后,不建议直接删除字段。
如确需回滚应用: