SaaS化数据库候选变更与执行清单.md 17 KB


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_idproject_idtenant_idhospital_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 数据库版本

SELECT VERSION() AS mysql_version, DATABASE() AS current_database;

3.2 相关表是否存在

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 字段盘点

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 索引盘点

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 项目和租户映射质量

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 增加租户字段

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

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 检查未映射数据

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 增加查询索引

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 查找任务后再更新:

CREATE INDEX idx_task_id_scope
  ON ai_task_instance(task_id, project_id, tenant_id);

如果 task_id 已经是全局唯一索引,该索引不是正确性必需项,但建议保留作用域索引, 使数据库执行计划与代码隔离条件一致。已有等价覆盖索引时跳过。

4.6 可选收紧

只有未映射数据为 0,且所有任务创建入口都已经部署到本分支代码后才执行:

ALTER TABLE ai_task_instance
  MODIFY COLUMN tenant_id VARCHAR(20) NOT NULL
  COMMENT '租户编号,用于任务实例隔离';

5. 必需候选 SQL:设备命令

仅当 ai_device_command 存在并实际启用时执行。

5.1 增加项目和租户字段

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 在该表中能唯一确定项目时执行:

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;

有结果时禁止自动回填,必须人工确定归属。

5.3 从项目表回填租户

仅适用于 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,应由下游提供明确映射表,不得直接 回填默认租户。

5.4 检查未映射数据

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 增加索引

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,且确认没有旧版本应用继续写入空字段后执行:

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 会话

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);

已有等价索引时跳过。

6.2 卡片实例

本阶段卡片查询和动作依赖 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);

已有等价索引时跳过。

6.3 Agent App 和 Engine Config

新代码按 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 语义,不在本文中提供盲目更新语句。

6.4 文件对象

本阶段没有新增字段,但查询依赖 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);

已有等价索引时跳过。

7. 卡片动作幂等键租户化

第二阶段代码已经使动作日志直接写入 tenant_id,并按 tenant_id + idempotency_key 查询。仅当 ai_card_action_log 存在且实际启用时, 按以下顺序执行。

7.1 增加租户字段

ALTER TABLE ai_card_action_log
  ADD COLUMN tenant_id VARCHAR(20) NULL
  COMMENT '租户编号,用于卡片动作隔离和租户内幂等'
  AFTER card_instance_id;

字段已存在时跳过。

7.2 从卡片实例回填

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;

该查询有结果时禁止切换唯一索引,必须先修复卡片实例租户或归档无效日志。

7.3 检查租户内重复键

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 切换为租户复合唯一索引并收紧字段

先确认当前唯一索引名称:

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 后的名称替换为盘点结果,禁止猜测索引名。

执行后,不同租户可以复用相同幂等键,同一租户仍由数据库唯一约束防止并发重复写入。

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 空作用域数据

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

9.2 项目租户一致性

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. 不回滚已完成的租户数据修复,除非有明确错误映射和可恢复备份。