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