# AI 对话记录服务表结构创建 > **日期**:2026-02-03 > **类型**:数据库迁移 > **迁移文件**:`20260203_1600_create_ai_conversations_tables.py` > **相关文档**:[AI 对话记录服务](../../requirements/backend/04-services/ai/ai-conversation-service.md) --- ## 变更概述 创建 AI 对话记录服务的核心表结构,支持用户与 AI 的多轮对话、上下文管理和任务关联。 **核心设计**:多态关联 + 标签系统 - 支持不同类型的 AI 生成任务(分镜/角色/场景/道具/资源/音效/配音) - 支持同一对象的不同变体(通过 tag_id 区分装扮/角度/时间/状态) - 支持不同媒体类型(图片/视频/音频/3D模型/文本) - 唯一性约束:同一用户、同一目标、同一标签、同一媒体类型只能有一个活跃会话 --- ## 新增表 ### 1. ai_conversations(AI 对话会话表) **用途**:记录对话会话的基本信息 **字段**: | 字段名 | 类型 | 约束 | 说明 | |--------|------|------|------| | conversation_id | UUID | PK | 对话会话唯一标识(UUID v7) | | user_id | UUID | NOT NULL | 用户 ID - 应用层验证 | | project_id | UUID | NULL | 项目 ID(可选)- 应用层验证 | | target_type | SMALLINT | NOT NULL | 目标类型(1=分镜 2=角色 3=场景 4=道具 5=资源 6=音效 7=配音) | | target_id | UUID | NOT NULL | 目标对象 ID - 应用层验证 | | tag_id | UUID | NULL | 标签 ID(可选,用于区分变体)- 应用层验证 | | media_type | SMALLINT | NOT NULL | 媒体类型(1=图片 2=视频 3=音频 4=3D模型 5=文本) | | title | TEXT | NULL | 会话标题 | | status | SMALLINT | NOT NULL, DEFAULT 1 | 会话状态(1=活跃 2=已归档 3=已删除) | | message_count | INTEGER | NOT NULL, DEFAULT 0 | 消息数量 | | last_message_at | TIMESTAMPTZ | NULL | 最后一条消息时间 | | metadata | JSONB | NOT NULL, DEFAULT '{}' | 额外元数据 | | created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | | updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | **约束**: - `target_type >= 1 AND target_type <= 7` - `media_type >= 1 AND media_type <= 5` - `status >= 1 AND status <= 3` - `message_count >= 0` **索引**: - `idx_ai_conversations_user_id` - 用户查询 - `idx_ai_conversations_project_id` - 项目查询(部分索引,WHERE project_id IS NOT NULL) - `idx_ai_conversations_target` - 目标类型+ID 查询 - `idx_ai_conversations_target_tag` - 目标类型+ID+标签 查询 - `idx_ai_conversations_tag_id` - 标签查询(部分索引,WHERE tag_id IS NOT NULL) - `idx_ai_conversations_media_type` - 媒体类型查询 - `idx_ai_conversations_status` - 状态查询 - `idx_ai_conversations_created_at` - 创建时间排序 - `idx_ai_conversations_last_message_at` - 最后消息时间排序(部分索引) - `idx_ai_conversations_user_status` - 用户+状态复合查询 - `idx_ai_conversations_user_target_tag_media` - 完整查询条件复合索引 - `idx_ai_conversations_metadata_gin` - 元数据 GIN 索引 **唯一约束**: - `idx_ai_conversations_unique_active` - 同一用户、同一目标、同一标签、同一媒体类型只能有一个活跃会话(NULLS NOT DISTINCT) **触发器**: - `update_ai_conversations_updated_at` - 自动更新 updated_at --- ### 2. ai_conversation_messages(AI 对话消息表) **用途**:记录对话中的每条消息 **字段**: | 字段名 | 类型 | 约束 | 说明 | |--------|------|------|------| | message_id | UUID | PK | 消息唯一标识(UUID v7) | | conversation_id | UUID | NOT NULL | 对话会话 ID - 应用层验证 | | user_id | UUID | NOT NULL | 用户 ID - 应用层验证 | | ai_job_id | UUID | NULL | 关联的 AI 任务 ID - 应用层验证 | | role | SMALLINT | NOT NULL | 消息角色(1=用户 2=AI 3=系统) | | content | TEXT | NOT NULL | 消息内容 | | metadata | JSONB | NOT NULL, DEFAULT '{}' | 额外元数据 | | order_index | INTEGER | NOT NULL | 消息顺序 | | created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | | updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | **约束**: - `role >= 1 AND role <= 3` - `order_index >= 0` **索引**: - `idx_ai_conversation_messages_conversation_id` - 对话查询 - `idx_ai_conversation_messages_user_id` - 用户查询 - `idx_ai_conversation_messages_ai_job_id` - AI 任务查询(部分索引,WHERE ai_job_id IS NOT NULL) - `idx_ai_conversation_messages_role` - 角色查询 - `idx_ai_conversation_messages_created_at` - 创建时间排序 - `idx_ai_conversation_messages_conversation_order` - 对话+顺序复合索引 - `idx_ai_conversation_messages_metadata_gin` - 元数据 GIN 索引 - `idx_ai_conversation_messages_content_gin` - 内容全文搜索 GIN 索引 **触发器**: - `update_ai_conversation_messages_updated_at` - 自动更新 updated_at - `update_conversation_stats_trigger` - 自动更新会话统计信息(message_count, last_message_at) --- ## 触发器函数 ### update_conversation_stats() **用途**:自动更新对话会话的统计信息 **逻辑**: - INSERT 消息时:`message_count + 1`,更新 `last_message_at` - DELETE 消息时:`message_count - 1` **触发时机**:AFTER INSERT OR DELETE ON ai_conversation_messages --- ## Python 枚举定义 ### ConversationStatus(对话会话状态) ```python class ConversationStatus(IntEnum): """对话会话状态""" ACTIVE = 1 # 活跃 ARCHIVED = 2 # 已归档 DELETED = 3 # 已删除 ``` ### TargetType(目标类型) ```python class TargetType(IntEnum): """目标类型""" STORYBOARD = 1 # 分镜 CHARACTER = 2 # 角色 SCENE = 3 # 场景 PROP = 4 # 道具 RESOURCE = 5 # 通用资源 SOUND_EFFECT = 6 # 音效 VOICEOVER = 7 # 配音 ``` ### MediaType(媒体类型) ```python class MediaType(IntEnum): """媒体类型""" IMAGE = 1 # 图片 VIDEO = 2 # 视频 AUDIO = 3 # 音频 MODEL_3D = 4 # 3D模型 TEXT = 5 # 文本 ``` ### MessageRole(消息角色) ```python class MessageRole(IntEnum): """消息角色""" USER = 1 # 用户消息 ASSISTANT = 2 # AI 回复 SYSTEM = 3 # 系统消息 ``` --- ## 设计亮点 ### 1. 多态关联 + 标签系统 **问题**:不同类型的 AI 生成任务需要独立的对话上下文,同一对象的不同变体也需要独立的对话。 **解决方案**: - `target_type` + `target_id`:支持多种目标类型(分镜/角色/场景/道具/资源/音效/配音) - `tag_id`:支持同一对象的不同变体(如角色的不同装扮、分镜的不同角度) - `media_type`:支持不同媒体类型(图片/视频/音频/3D模型/文本) **示例**: - 分镜001-正面角度-图片生成:`target_type=1, target_id=分镜001ID, tag_id=正面角度TagID, media_type=1` - 分镜001-侧面角度-图片生成:`target_type=1, target_id=分镜001ID, tag_id=侧面角度TagID, media_type=1` - 角色-张三-少年装扮-图片生成:`target_type=2, target_id=张三ID, tag_id=少年装扮TagID, media_type=1` ### 2. 唯一性约束(NULLS NOT DISTINCT) **规则**:同一用户、同一目标、同一标签、同一媒体类型只能有一个活跃会话。 **实现**: ```sql CREATE UNIQUE INDEX idx_ai_conversations_unique_active ON ai_conversations (user_id, target_type, target_id, tag_id, media_type) NULLS NOT DISTINCT WHERE status = 1; ``` **注意**:PostgreSQL 14+ 支持 `NULLS NOT DISTINCT`,使得 NULL 值被视为相同,确保唯一性约束正确工作。 ### 3. 自动更新会话统计 **触发器**:`update_conversation_stats_trigger` **功能**: - 插入消息时:自动增加 `message_count`,更新 `last_message_at` - 删除消息时:自动减少 `message_count` **优势**:避免手动维护统计信息,确保数据一致性。 ### 4. 全文搜索支持 **索引**:`idx_ai_conversation_messages_content_gin` **功能**:支持对消息内容进行全文搜索 **示例**: ```sql SELECT * FROM ai_conversation_messages WHERE to_tsvector('simple', content) @@ to_tsquery('simple', '咖啡厅 & 温馨'); ``` --- ## 应用层引用完整性保证 由于采用无物理外键设计,应用层需要保证以下引用完整性: ### 1. 验证目标对象是否存在 ```python async def _validate_target(self, target_type: int, target_id: UUID) -> bool: """验证目标对象是否存在""" if target_type == TargetType.STORYBOARD: from app.repositories.storyboard_repository import StoryboardRepository repo = StoryboardRepository(self.db) return await repo.exists(target_id) elif target_type == TargetType.CHARACTER: from app.repositories.screenplay_character_repository import ScreenplayCharacterRepository repo = ScreenplayCharacterRepository(self.db) return await repo.exists(target_id) # ... 其他类型 ``` ### 2. 验证标签是否存在且属于该对象 ```python async def _validate_tag(self, tag_id: UUID, target_type: int, target_id: UUID) -> bool: """验证标签是否存在且属于该对象""" from app.repositories.screenplay_tag_repository import ScreenplayTagRepository repo = ScreenplayTagRepository(self.db) tag = await repo.get_by_id(tag_id) if not tag: return False # 验证标签是否属于该对象 if tag.element_id != target_id: return False # 验证标签的元素类型是否匹配 from app.models.screenplay_tag import ElementType element_type_map = { TargetType.STORYBOARD: ElementType.STORYBOARD, TargetType.CHARACTER: ElementType.CHARACTER, TargetType.SCENE: ElementType.SCENE, TargetType.PROP: ElementType.PROP, } expected_element_type = element_type_map.get(target_type) if tag.element_type != expected_element_type: return False return True ``` ### 3. 验证组合有效性 ```python async def _validate_combination(self, target_type: int, media_type: int) -> bool: """验证 target_type 和 media_type 的组合是否有效""" VALID_COMBINATIONS = { TargetType.STORYBOARD: [MediaType.IMAGE, MediaType.VIDEO, MediaType.AUDIO], TargetType.CHARACTER: [MediaType.IMAGE, MediaType.AUDIO], TargetType.SCENE: [MediaType.IMAGE], TargetType.PROP: [MediaType.IMAGE], TargetType.RESOURCE: [MediaType.IMAGE, MediaType.VIDEO, MediaType.AUDIO], TargetType.SOUND_EFFECT: [MediaType.AUDIO], TargetType.VOICEOVER: [MediaType.AUDIO], } valid_media_types = VALID_COMBINATIONS.get(target_type, []) if media_type not in valid_media_types: raise ValidationError( f"目标类型 {target_type} 不支持媒体类型 {media_type}" ) return True ``` --- ## 使用场景映射 | 场景 | target_type | target_id | tag_id | media_type | 说明 | |------|-------------|-----------|--------|------------|------| | 分镜001基础图片 | 1 | 分镜001ID | NULL | 1 | 生成基础分镜图片 | | 分镜001正面角度 | 1 | 分镜001ID | 正面TagID | 1 | 生成正面角度图片 | | 分镜001侧面角度 | 1 | 分镜001ID | 侧面TagID | 1 | 生成侧面角度图片 | | 分镜001视频 | 1 | 分镜001ID | NULL | 2 | 生成分镜视频 | | 角色-张三基础形象 | 2 | 张三ID | NULL | 1 | 生成基础角色形象 | | 角色-张三-少年装扮 | 2 | 张三ID | 少年TagID | 1 | 生成少年装扮图片 | | 角色-张三-成年装扮 | 2 | 张三ID | 成年TagID | 1 | 生成成年装扮图片 | | 场景-花果山-白天 | 3 | 花果山ID | 白天TagID | 1 | 生成白天场景图片 | | 场景-花果山-夜晚 | 3 | 花果山ID | 夜晚TagID | 1 | 生成夜晚场景图片 | | 独立音效生成 | 6 | 音效ID | NULL | 3 | 生成独立音效 | | 独立配音生成 | 7 | 配音ID | NULL | 3 | 生成独立配音 | --- ## 查询示例 ### 查询"分镜001"的所有对话 ```sql SELECT * FROM ai_conversations WHERE user_id = '019d1234-5678-7abc-def0-111111111111' AND target_type = 1 -- STORYBOARD AND target_id = '019d1234-5678-7abc-def0-222222222222' -- 分镜001的ID AND status = 1; -- ACTIVE ``` ### 查询"分镜001-正面角度"的"图片生成"对话 ```sql SELECT * FROM ai_conversations WHERE user_id = '019d1234-5678-7abc-def0-111111111111' AND target_type = 1 -- STORYBOARD AND target_id = '019d1234-5678-7abc-def0-222222222222' -- 分镜001的ID AND tag_id = '019d1234-5678-7abc-def0-333333333333' -- 正面角度TagID AND media_type = 1 -- IMAGE AND status = 1; ``` ### 查询对话的所有消息(按顺序) ```sql SELECT * FROM ai_conversation_messages WHERE conversation_id = '019d1234-5678-7abc-def0-444444444444' ORDER BY order_index ASC; ``` ### 全文搜索消息内容 ```sql SELECT * FROM ai_conversation_messages WHERE to_tsvector('simple', content) @@ to_tsquery('simple', '咖啡厅 & 温馨') ORDER BY created_at DESC; ``` --- ## 迁移执行 ### 升级 ```bash docker exec jointo-server-app alembic upgrade head ``` ### 回滚 ```bash docker exec jointo-server-app alembic downgrade 20260203_1500 ``` --- ## 相关文档 - [AI 对话记录服务](../../requirements/backend/04-services/ai/ai-conversation-service.md) - [数据库设计规范](../../requirements/database-design.md) - [ADR 001: UUID v7 迁移](../adrs/001-uuid-v7-migration.md) - [ADR 006: TIMESTAMPTZ 时间戳规范](../adrs/006-timestamptz-for-event-timestamps.md) --- **创建日期**:2026-02-03 **创建人**:系统 **审核状态**:待审核