# AI 对话记录服务 > **文档版本**:v2.2 > **最后更新**:2026-02-03 > **变更说明**:修复技术栈合规性问题(日志格式、枚举定义、异常处理) > **符合规范**:jointo-tech-stack v1.0 --- ## 技术规范说明 本文档遵循 jointo-tech-stack 规范: - **UUID 规范**:所有 UUID 字段使用 UUID v7(应用层生成),符合 ADR 001 规范 - **时间戳规范**:所有时间字段使用 TIMESTAMPTZ 类型,符合 ADR 006 规范 - **枚举类型**:使用 SMALLINT 存储,Python 使用 IntEnum - **无物理外键**:应用层保证引用完整性 - **日志格式**:使用 %-formatting,错误日志包含 exc_info=True - **异步编程**:所有数据库操作使用 async/await --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [数据库设计](#数据库设计) 4. [服务实现](#服务实现) 5. [API 接口](#api-接口) 6. [业务流程时序图](#业务流程时序图) 7. [与 AI Service 集成](#与-ai-service-集成) --- ## 服务概述 AI 对话记录服务负责管理用户与 AI 的对话历史,支持多轮对话、上下文管理和任务关联。用户可以通过对话的方式提交 AI 生图/生视频任务,系统会记录完整的对话历史。 ### 核心设计:多态关联 + 标签系统 **设计理念**:不同类型的 AI 生成任务需要独立的对话上下文,同一对象的不同变体(如角色的不同装扮、分镜的不同角度)也需要独立的对话,避免对话混淆。 **多态关联模式**: - `target_type`:目标类型(分镜/角色/场景/道具/资源/音效/配音) - `target_id`:目标对象 ID(具体的分镜ID/角色ID/场景ID等) - `tag_id`:标签 ID(可选,用于区分同一对象的不同变体) - `media_type`:媒体类型(图片/视频/音频/3D模型/文本) **隔离策略**: 1. **类型隔离**:分镜图对话 ≠ 分镜视频对话 2. **对象隔离**:分镜A的对话 ≠ 分镜B的对话 3. **变体隔离**:角色-张三-少年装扮 ≠ 角色-张三-成年装扮 4. **用户隔离**:用户A的对话 ≠ 用户B的对话 **示例场景**: ``` 用户在"分镜001"的"生成图片"界面: - 只能看到"分镜001"的"图片生成"对话历史 - 看不到"分镜001"的"视频生成"对话历史 - 看不到"分镜002"的任何对话历史 用户在"分镜001-正面角度"的"生成图片"界面: - 只能看到"分镜001-正面角度"的"图片生成"对话历史 - 看不到"分镜001-侧面角度"的对话历史 用户在"角色-张三-少年装扮"的"生成图片"界面: - 只能看到"角色-张三-少年装扮"的"图片生成"对话历史 - 看不到"角色-张三-成年装扮"的对话历史 - 看不到"角色-李四"的对话历史 ``` ### 职责 - **对话会话管理**:创建、查询、删除对话会话 - **消息记录**:记录用户消息和 AI 回复 - **上下文管理**:维护对话上下文,支持多轮对话 - **任务关联**:将对话与 AI 任务(图片/视频/音频生成)关联 - **对话历史查询**:支持按项目、用户、类型、对象、标签查询 - **标签支持**:支持同一对象的不同变体(如角色的不同装扮、分镜的不同角度) ### 应用场景 1. **分镜图片生成对话**: - 基础图片:用户在分镜编辑页面,通过对话生成分镜图片 - 不同角度:分镜001-正面角度、分镜001-侧面角度、分镜001-俯视角度 2. **分镜视频生成对话**:用户在分镜编辑页面,通过对话生成分镜视频 3. **角色图片生成对话**: - 基础形象:用户在角色管理页面,通过对话生成角色图片 - 不同装扮:角色-张三-少年装扮、角色-张三-成年装扮、角色-张三-老年装扮 4. **场景图片生成对话**: - 基础场景:用户在场景管理页面,通过对话生成场景图片 - 不同时间:场景-花果山-白天、场景-花果山-夜晚、场景-花果山-黄昏 5. **道具图片生成对话**: - 基础道具:用户在道具管理页面,通过对话生成道具图片 - 不同状态:道具-金箍棒-新状态、道具-金箍棒-旧状态 6. **独立音效生成对话**:用户在音效库页面,通过对话生成音效 7. **独立配音生成对话**:用户在配音库页面,通过对话生成配音 8. **提示词优化**:用户输入初始提示词,AI 提供优化建议 9. **参数调整**:用户通过对话调整生成参数(风格、尺寸等) 10. **历史回溯**:查看之前的对话历史,复用成功的提示词 --- ## 核心功能 ### Python 枚举定义 ```python # app/models/ai_conversation.py from enum import IntEnum class ConversationStatus(IntEnum): """对话会话状态""" ACTIVE = 1 # 活跃 ARCHIVED = 2 # 已归档 DELETED = 3 # 已删除 class TargetType(IntEnum): """目标类型""" STORYBOARD = 1 # 分镜 CHARACTER = 2 # 角色 SCENE = 3 # 场景 PROP = 4 # 道具 RESOURCE = 5 # 通用资源 SOUND_EFFECT = 6 # 音效 VOICEOVER = 7 # 配音 class MediaType(IntEnum): """媒体类型""" IMAGE = 1 # 图片 VIDEO = 2 # 视频 AUDIO = 3 # 音频 MODEL_3D = 4 # 3D模型 TEXT = 5 # 文本 ``` ### 设计说明:多态关联 + 标签系统 #### 为什么需要多态关联? **问题场景**: - 用户在"分镜001"生成图片时,不应该看到"分镜002"的对话历史 - 用户在"角色-张三-少年装扮"生成图片时,不应该看到"角色-张三-成年装扮"的对话历史 - 用户在"分镜001-正面角度"生成图片时,不应该看到"分镜001-侧面角度"的对话历史 - 用户在生成"图片"时,不应该看到"视频"的对话历史 **优化后的方案**: ```sql -- ✅ 多态关联 + 标签系统(最终方案) target_type SMALLINT -- 目标类型(分镜/角色/场景/道具/资源/音效/配音) target_id UUID -- 目标对象 ID tag_id UUID -- 标签 ID(可选,用于区分变体) media_type SMALLINT -- 媒体类型(图片/视频/音频/3D模型/文本) -- 优势: -- 1. 消除冗余(target_type + media_type 独立) -- 2. 支持变体(通过 tag_id 区分装扮/角度/时间/状态) -- 3. 灵活扩展(新增类型只需添加枚举值) -- 4. 查询高效(复合索引优化) ``` #### 枚举定义 **目标类型(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 # 文本(剧本生成、提示词优化等) ``` #### 使用场景映射 | 场景 | target_type | target_id | tag_id | media_type | 说明 | |------|-------------|-----------|--------|------------|------| | 分镜001基础图片 | STORYBOARD (1) | 分镜001ID | NULL | IMAGE (1) | 生成基础分镜图片 | | 分镜001正面角度 | STORYBOARD (1) | 分镜001ID | 正面TagID | IMAGE (1) | 生成正面角度图片 | | 分镜001侧面角度 | STORYBOARD (1) | 分镜001ID | 侧面TagID | IMAGE (1) | 生成侧面角度图片 | | 分镜001视频 | STORYBOARD (1) | 分镜001ID | NULL | VIDEO (2) | 生成分镜视频 | | 角色-张三基础形象 | CHARACTER (2) | 张三ID | NULL | IMAGE (1) | 生成基础角色形象 | | 角色-张三-少年装扮 | CHARACTER (2) | 张三ID | 少年TagID | IMAGE (1) | 生成少年装扮图片 | | 角色-张三-成年装扮 | CHARACTER (2) | 张三ID | 成年TagID | IMAGE (1) | 生成成年装扮图片 | | 角色-张三对话配音 | CHARACTER (2) | 张三ID | NULL | AUDIO (3) | 生成角色对话配音 | | 场景-花果山基础 | SCENE (3) | 花果山ID | NULL | IMAGE (1) | 生成基础场景图片 | | 场景-花果山-白天 | SCENE (3) | 花果山ID | 白天TagID | IMAGE (1) | 生成白天场景图片 | | 场景-花果山-夜晚 | SCENE (3) | 花果山ID | 夜晚TagID | IMAGE (1) | 生成夜晚场景图片 | | 道具-金箍棒基础 | PROP (4) | 金箍棒ID | NULL | IMAGE (1) | 生成基础道具图片 | | 道具-金箍棒-新状态 | PROP (4) | 金箍棒ID | 新状态TagID | IMAGE (1) | 生成新状态道具图片 | | 独立音效生成 | SOUND_EFFECT (6) | 音效ID | NULL | AUDIO (3) | 生成独立音效 | | 独立配音生成 | VOICEOVER (7) | 配音ID | NULL | AUDIO (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_conversations WHERE user_id = '019d1234-5678-7abc-def0-111111111111' AND target_type = 2 -- CHARACTER AND target_id = '019d1234-5678-7abc-def0-444444444444' -- 角色-张三的ID AND status = 1; ``` **查询"角色-张三-少年装扮"的"图片生成"对话**: ```sql SELECT * FROM ai_conversations WHERE user_id = '019d1234-5678-7abc-def0-111111111111' AND target_type = 2 -- CHARACTER AND target_id = '019d1234-5678-7abc-def0-444444444444' -- 角色-张三的ID AND tag_id = '019d1234-5678-7abc-def0-555555555555' -- 少年装扮TagID AND media_type = 1 -- IMAGE AND status = 1; ``` #### 唯一性约束 **规则**:同一用户、同一目标、同一标签、同一媒体类型只能有一个活跃会话。 ```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; ``` **示例**: - ✅ 用户A可以同时有"分镜001-图片"和"分镜001-视频"两个活跃会话 - ✅ 用户A可以同时有"分镜001-正面角度-图片"和"分镜001-侧面角度-图片"两个活跃会话 - ✅ 用户A可以同时有"角色-张三-少年装扮-图片"和"角色-张三-成年装扮-图片"两个活跃会话 - ❌ 用户A不能同时有两个"分镜001-正面角度-图片"活跃会话(会自动归档旧会话) **注意**:PostgreSQL 14+ 支持 `NULLS NOT DISTINCT`,使得 NULL 值被视为相同,确保唯一性约束正确工作。 #### 应用层引用完整性保证 **验证逻辑**: ```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) elif target_type == TargetType.SCENE: from app.repositories.screenplay_scene_repository import ScreenplaySceneRepository repo = ScreenplaySceneRepository(self.db) return await repo.exists(target_id) elif target_type == TargetType.PROP: from app.repositories.screenplay_prop_repository import ScreenplayPropRepository repo = ScreenplayPropRepository(self.db) return await repo.exists(target_id) elif target_type == TargetType.RESOURCE: from app.repositories.project_resource_repository import ProjectResourceRepository repo = ProjectResourceRepository(self.db) return await repo.exists(target_id) elif target_type == TargetType.SOUND_EFFECT: from app.repositories.sound_effect_repository import SoundEffectRepository repo = SoundEffectRepository(self.db) return await repo.exists(target_id) elif target_type == TargetType.VOICEOVER: from app.repositories.voiceover_repository import VoiceoverRepository repo = VoiceoverRepository(self.db) return await repo.exists(target_id) else: raise ValidationError(f"不支持的目标类型: {target_type}") 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 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 ``` --- ### 1. 对话会话管理 **功能**: - 创建新的对话会话 - 查询对话会话列表 - 查询对话会话详情 - 删除对话会话 **特性**: - 每个会话关联到项目或分镜 - 支持会话标题自动生成 - 支持会话归档和删除 ### 2. 消息记录 **功能**: - 记录用户发送的消息 - 记录 AI 的回复消息 - 支持消息编辑和删除 **消息类型**: - `user`:用户消息 - `assistant`:AI 回复 - `system`:系统消息(如任务状态通知) ### 3. 上下文管理 **功能**: - 维护对话上下文(最近 N 条消息) - 自动截断过长的上下文 - 支持上下文重置 **上下文策略**: - 默认保留最近 10 条消息 - 超过限制时自动截断最早的消息 - 用户可手动清空上下文 ### 4. 任务关联 **功能**: - 将对话消息与 AI 任务关联 - 支持从对话直接触发 AI 生成任务 - 任务完成后自动回复结果 **关联方式**: - 对话消息 → AI 任务(一对一) - 对话会话 → 多个 AI 任务(一对多) ### 5. 对话历史查询 **功能**: - 按项目查询对话历史 - 按用户查询对话历史 - 按时间范围查询 - 按关键词搜索 --- ## 数据库设计 ### 3.1 ai_conversations(AI 对话会话表) **核心表**,记录对话会话的基本信息。 ```sql -- Python 枚举定义(app/models/ai_conversation.py) -- class ConversationStatus(IntEnum): -- ACTIVE = 1 # 活跃 -- ARCHIVED = 2 # 已归档 -- DELETED = 3 # 已删除 -- class TargetType(IntEnum): -- STORYBOARD = 1 # 分镜 -- CHARACTER = 2 # 角色 -- SCENE = 3 # 场景 -- PROP = 4 # 道具 -- RESOURCE = 5 # 通用资源 -- SOUND_EFFECT = 6 # 音效 -- VOICEOVER = 7 # 配音 -- class MediaType(IntEnum): -- IMAGE = 1 # 图片 -- VIDEO = 2 # 视频 -- AUDIO = 3 # 音频 -- MODEL_3D = 4 # 3D模型 -- TEXT = 5 # 文本 CREATE TABLE ai_conversations ( conversation_id UUID PRIMARY KEY, -- 对话会话唯一标识 -- 关联信息(无外键约束,应用层保证引用完整性) user_id UUID NOT NULL, -- 用户 ID project_id UUID, -- 项目 ID(可选) -- 多态关联(最终版) target_type SMALLINT NOT NULL, -- 目标类型(1=分镜 2=角色 3=场景 4=道具 5=资源 6=音效 7=配音) target_id UUID NOT NULL, -- 目标对象 ID tag_id UUID, -- 标签 ID(可选,用于区分同一对象的不同变体) media_type SMALLINT NOT NULL, -- 媒体类型(1=图片 2=视频 3=音频 4=3D模型 5=文本) -- 会话信息 title TEXT, -- 会话标题(自动生成或用户自定义) status SMALLINT NOT NULL DEFAULT 1, -- 会话状态(1=活跃 2=已归档 3=已删除) -- 统计信息 message_count INTEGER NOT NULL DEFAULT 0, -- 消息数量 last_message_at TIMESTAMPTZ, -- 最后一条消息时间 -- 元数据 meta_data JSONB NOT NULL DEFAULT '{}', -- 额外元数据 -- 时间信息 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间 updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -- 更新时间 ); -- 字段注释 COMMENT ON TABLE ai_conversations IS 'AI 对话会话表 - 多态关联 + 标签系统 - 应用层保证引用完整性'; COMMENT ON COLUMN ai_conversations.conversation_id IS '对话会话唯一标识'; COMMENT ON COLUMN ai_conversations.user_id IS '用户 ID - 应用层验证'; COMMENT ON COLUMN ai_conversations.project_id IS '项目 ID(可选)- 应用层验证'; COMMENT ON COLUMN ai_conversations.target_type IS '目标类型(1=分镜 2=角色 3=场景 4=道具 5=资源 6=音效 7=配音)'; COMMENT ON COLUMN ai_conversations.target_id IS '目标对象 ID - 应用层验证'; COMMENT ON COLUMN ai_conversations.tag_id IS '标签 ID(可选,用于区分同一对象的不同变体)- 应用层验证'; COMMENT ON COLUMN ai_conversations.media_type IS '媒体类型(1=图片 2=视频 3=音频 4=3D模型 5=文本)'; COMMENT ON COLUMN ai_conversations.title IS '会话标题'; COMMENT ON COLUMN ai_conversations.status IS '会话状态(1=活跃 2=已归档 3=已删除)'; COMMENT ON COLUMN ai_conversations.message_count IS '消息数量'; COMMENT ON COLUMN ai_conversations.last_message_at IS '最后一条消息时间'; COMMENT ON COLUMN ai_conversations.meta_data IS '额外元数据'; COMMENT ON COLUMN ai_conversations.created_at IS '创建时间'; COMMENT ON COLUMN ai_conversations.updated_at IS '更新时间'; -- 索引(优化后) CREATE INDEX idx_ai_conversations_user_id ON ai_conversations (user_id); CREATE INDEX idx_ai_conversations_project_id ON ai_conversations (project_id) WHERE project_id IS NOT NULL; CREATE INDEX idx_ai_conversations_target ON ai_conversations (target_type, target_id); CREATE INDEX idx_ai_conversations_target_tag ON ai_conversations (target_type, target_id, tag_id); CREATE INDEX idx_ai_conversations_tag_id ON ai_conversations (tag_id) WHERE tag_id IS NOT NULL; CREATE INDEX idx_ai_conversations_media_type ON ai_conversations (media_type); CREATE INDEX idx_ai_conversations_status ON ai_conversations (status); CREATE INDEX idx_ai_conversations_created_at ON ai_conversations (created_at); CREATE INDEX idx_ai_conversations_last_message_at ON ai_conversations (last_message_at) WHERE last_message_at IS NOT NULL; CREATE INDEX idx_ai_conversations_user_status ON ai_conversations (user_id, status); CREATE INDEX idx_ai_conversations_user_target_tag_media ON ai_conversations (user_id, target_type, target_id, tag_id, media_type); CREATE INDEX idx_ai_conversations_meta_data_gin ON ai_conversations USING GIN (meta_data); -- 唯一约束:同一用户、同一目标、同一标签、同一媒体类型只能有一个活跃会话 -- 注意:PostgreSQL 14+ 支持 NULLS NOT DISTINCT,使得 NULL 值被视为相同 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; -- 触发器 CREATE TRIGGER update_ai_conversations_updated_at BEFORE UPDATE ON ai_conversations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` ### 3.2 ai_conversation_messages(AI 对话消息表) **核心表**,记录对话中的每条消息。 ```sql -- Python 枚举定义(app/models/ai_conversation_message.py) -- class MessageRole(IntEnum): -- USER = 1 # 用户消息 -- ASSISTANT = 2 # AI 回复 -- SYSTEM = 3 # 系统消息 CREATE TABLE ai_conversation_messages ( message_id UUID PRIMARY KEY, -- 消息唯一标识 -- 关联信息(无外键约束,应用层保证引用完整性) conversation_id UUID NOT NULL, -- 对话会话 ID user_id UUID NOT NULL, -- 用户 ID ai_job_id UUID, -- 关联的 AI 任务 ID(可选) -- 消息信息 role SMALLINT NOT NULL, -- 消息角色(1=用户 2=AI 3=系统) content TEXT NOT NULL, -- 消息内容 -- 消息元数据 meta_data JSONB NOT NULL DEFAULT '{}', -- 额外元数据(如生成参数、附件等) -- 排序 order_index INTEGER NOT NULL, -- 消息顺序 -- 时间信息 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间 updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -- 更新时间 ); -- 字段注释 COMMENT ON TABLE ai_conversation_messages IS 'AI 对话消息表 - 应用层保证引用完整性'; COMMENT ON COLUMN ai_conversation_messages.message_id IS '消息唯一标识'; COMMENT ON COLUMN ai_conversation_messages.conversation_id IS '对话会话 ID - 应用层验证'; COMMENT ON COLUMN ai_conversation_messages.user_id IS '用户 ID - 应用层验证'; COMMENT ON COLUMN ai_conversation_messages.ai_job_id IS '关联的 AI 任务 ID - 应用层验证'; COMMENT ON COLUMN ai_conversation_messages.role IS '消息角色(1=用户 2=AI 3=系统)'; COMMENT ON COLUMN ai_conversation_messages.content IS '消息内容'; COMMENT ON COLUMN ai_conversation_messages.meta_data IS '额外元数据'; COMMENT ON COLUMN ai_conversation_messages.order_index IS '消息顺序'; COMMENT ON COLUMN ai_conversation_messages.created_at IS '创建时间'; COMMENT ON COLUMN ai_conversation_messages.updated_at IS '更新时间'; -- 索引 CREATE INDEX idx_ai_conversation_messages_conversation_id ON ai_conversation_messages (conversation_id); CREATE INDEX idx_ai_conversation_messages_user_id ON ai_conversation_messages (user_id); CREATE INDEX idx_ai_conversation_messages_ai_job_id ON ai_conversation_messages (ai_job_id) WHERE ai_job_id IS NOT NULL; CREATE INDEX idx_ai_conversation_messages_role ON ai_conversation_messages (role); CREATE INDEX idx_ai_conversation_messages_created_at ON ai_conversation_messages (created_at); CREATE INDEX idx_ai_conversation_messages_conversation_order ON ai_conversation_messages (conversation_id, order_index); CREATE INDEX idx_ai_conversation_messages_meta_data_gin ON ai_conversation_messages USING GIN (meta_data); CREATE INDEX idx_ai_conversation_messages_content_gin ON ai_conversation_messages USING GIN (to_tsvector('simple', content)); -- 触发器 CREATE TRIGGER update_ai_conversation_messages_updated_at BEFORE UPDATE ON ai_conversation_messages FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- 自动更新会话的 message_count 和 last_message_at CREATE OR REPLACE FUNCTION update_conversation_stats() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' THEN UPDATE ai_conversations SET message_count = message_count + 1, last_message_at = NEW.created_at, updated_at = now() WHERE conversation_id = NEW.conversation_id; ELSIF TG_OP = 'DELETE' THEN UPDATE ai_conversations SET message_count = message_count - 1, updated_at = now() WHERE conversation_id = OLD.conversation_id; END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_conversation_stats_trigger AFTER INSERT OR DELETE ON ai_conversation_messages FOR EACH ROW EXECUTE FUNCTION update_conversation_stats(); ``` ### 3.3 数据表关系图 ``` ┌─────────────────────┐ │ users │ └──────┬──────────────┘ │ ├──────────────────────────────────┐ │ │ ▼ ▼ ┌─────────────────────┐ ┌─────────────────────┐ │ ai_conversations │ │ projects │ └──────┬──────────────┘ └─────────────────────┘ │ ▲ │ │ ▼ │ ┌─────────────────────┐ │ │ai_conversation_ │ │ │messages │──────────────────┘ └──────┬──────────────┘ │ ▼ ┌─────────────────────┐ │ ai_jobs │ └─────────────────────┘ ``` --- ## 服务实现 ### AIConversationService 类 ```python # app/services/ai_conversation_service.py from typing import Dict, Any, Optional, List from uuid import UUID from datetime import datetime, timezone from sqlalchemy.orm import Session from app.repositories.ai_conversation_repository import AIConversationRepository from app.repositories.ai_conversation_message_repository import AIConversationMessageRepository from app.services.ai_service import AIService from app.core.exceptions import ValidationError, NotFoundError, PermissionDeniedError from app.core.logging import get_logger logger = get_logger(__name__) class AIConversationService: """AI 对话记录服务""" def __init__(self, db: Session): self.db = db self.conversation_repo = AIConversationRepository(db) self.message_repo = AIConversationMessageRepository(db) self.ai_service = AIService(db) # ==================== 对话会话管理 ==================== async def create_conversation( self, user_id: UUID, target_type: int, target_id: UUID, media_type: int, project_id: Optional[UUID] = None, tag_id: Optional[UUID] = None, title: Optional[str] = None ) -> Dict[str, Any]: """创建新的对话会话 Args: user_id: 用户 ID target_type: 目标类型(TargetType 枚举值) target_id: 目标对象 ID media_type: 媒体类型(MediaType 枚举值) project_id: 项目 ID(可选) tag_id: 标签 ID(可选,用于区分变体) title: 会话标题(可选,默认自动生成) """ logger.info( "创建对话会话: user_id=%s, target_type=%d, target_id=%s, tag_id=%s, media_type=%d", user_id, target_type, target_id, tag_id, media_type ) # 验证用户是否存在 from app.repositories.user_repository import UserRepository user_repo = UserRepository(self.db) if not await user_repo.exists(user_id): raise ValidationError("用户不存在") # 验证目标对象是否存在(应用层引用完整性保证) if not await self._validate_target(target_type, target_id): raise ValidationError("目标对象不存在") # 验证标签是否存在(如果提供了) if tag_id: if not await self._validate_tag(tag_id, target_type, target_id): raise ValidationError("标签不存在或不属于该对象") # 验证组合是否有效 await self._validate_combination(target_type, media_type) # 如果指定了项目,验证项目是否存在 if project_id: from app.repositories.project_repository import ProjectRepository project_repo = ProjectRepository(self.db) if not await project_repo.exists(project_id): raise ValidationError("项目不存在") # 检查是否已存在活跃会话 existing = await self.conversation_repo.get_active_conversation( user_id=user_id, target_type=target_type, target_id=target_id, tag_id=tag_id, media_type=media_type ) if existing: # 如果已存在活跃会话,直接返回 logger.info( "已存在活跃会话: conversation_id=%s", existing.conversation_id ) return { 'conversation_id': str(existing.conversation_id), 'title': existing.title, 'status': existing.status, 'message_count': existing.message_count, 'created_at': existing.created_at.isoformat() } # 生成默认标题 if not title: title = await self._generate_title(target_type, target_id, tag_id, media_type) # 创建对话会话 from app.models.ai_conversation import ConversationStatus try: conversation = await self.conversation_repo.create({ 'user_id': user_id, 'project_id': project_id, 'target_type': target_type, 'target_id': target_id, 'tag_id': tag_id, 'media_type': media_type, 'title': title, 'status': ConversationStatus.ACTIVE }) logger.info( "对话会话创建成功: conversation_id=%s", conversation.conversation_id ) except Exception as e: logger.error( "创建对话会话失败: user_id=%s, target_type=%d, 错误=%s", user_id, target_type, str(e), exc_info=True ) raise return { 'conversation_id': str(conversation.conversation_id), 'title': conversation.title, 'target_type': conversation.target_type, 'target_id': str(conversation.target_id), 'tag_id': str(conversation.tag_id) if conversation.tag_id else None, 'media_type': conversation.media_type, 'status': conversation.status, 'message_count': conversation.message_count, 'created_at': conversation.created_at.isoformat() } async def get_conversation( self, conversation_id: UUID, user_id: UUID ) -> Dict[str, Any]: """获取对话会话详情 Args: conversation_id: 对话会话 ID user_id: 用户 ID(用于权限验证) """ conversation = await self.conversation_repo.get_by_id(conversation_id) if not conversation: raise NotFoundError("对话会话不存在") # 验证权限 if conversation.user_id != user_id: raise PermissionDeniedError("没有权限访问此对话") # 获取标签名称(如果有) tag_label = await self._get_tag_name(conversation.tag_id) if conversation.tag_id else None return { 'conversation_id': str(conversation.conversation_id), 'user_id': str(conversation.user_id), 'project_id': str(conversation.project_id) if conversation.project_id else None, 'target_type': conversation.target_type, 'target_id': str(conversation.target_id), 'tag_id': str(conversation.tag_id) if conversation.tag_id else None, 'tag_label': tag_label, 'media_type': conversation.media_type, 'title': conversation.title, 'status': conversation.status, 'message_count': conversation.message_count, 'last_message_at': conversation.last_message_at.isoformat() if conversation.last_message_at else None, 'created_at': conversation.created_at.isoformat(), 'updated_at': conversation.updated_at.isoformat() } async def list_conversations( self, user_id: UUID, project_id: Optional[UUID] = None, target_type: Optional[int] = None, target_id: Optional[UUID] = None, tag_id: Optional[UUID] = None, media_type: Optional[int] = None, status: Optional[int] = None, page: int = 1, page_size: int = 20 ) -> Dict[str, Any]: """获取对话会话列表 Args: user_id: 用户 ID project_id: 项目 ID(可选,筛选条件) target_type: 目标类型(可选,筛选条件) target_id: 目标对象 ID(可选,筛选条件) tag_id: 标签 ID(可选,筛选条件) media_type: 媒体类型(可选,筛选条件) status: 会话状态(可选,筛选条件) page: 页码 page_size: 每页数量 """ conversations, total = await self.conversation_repo.list_by_user( user_id=user_id, project_id=project_id, target_type=target_type, target_id=target_id, tag_id=tag_id, media_type=media_type, status=status, page=page, page_size=page_size ) # 批量获取标签名称 tag_ids = [c.tag_id for c in conversations if c.tag_id] tag_labels = {} if tag_ids: from app.repositories.screenplay_tag_repository import ScreenplayTagRepository tag_repo = ScreenplayTagRepository(self.db) tags = await tag_repo.get_by_ids(tag_ids) tag_labels = {tag.tag_id: tag.tag_label for tag in tags} return { 'items': [ { 'conversation_id': str(c.conversation_id), 'title': c.title, 'target_type': c.target_type, 'target_id': str(c.target_id), 'tag_id': str(c.tag_id) if c.tag_id else None, 'tag_label': tag_labels.get(c.tag_id) if c.tag_id else None, 'media_type': c.media_type, 'status': c.status, 'message_count': c.message_count, 'last_message_at': c.last_message_at.isoformat() if c.last_message_at else None, 'created_at': c.created_at.isoformat() } for c in conversations ], 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size } async def update_conversation( self, conversation_id: UUID, user_id: UUID, title: Optional[str] = None, status: Optional[int] = None ) -> Dict[str, Any]: """更新对话会话 Args: conversation_id: 对话会话 ID user_id: 用户 ID(用于权限验证) title: 新标题(可选) status: 新状态(可选) """ conversation = await self.conversation_repo.get_by_id(conversation_id) if not conversation: raise NotFoundError("对话会话不存在") # 验证权限 if conversation.user_id != user_id: raise PermissionDeniedError("没有权限修改此对话") # 更新字段 update_data = {} if title is not None: update_data['title'] = title if status is not None: update_data['status'] = status if update_data: conversation = await self.conversation_repo.update(conversation_id, update_data) return { 'conversation_id': str(conversation.conversation_id), 'title': conversation.title, 'status': conversation.status } async def delete_conversation( self, conversation_id: UUID, user_id: UUID ) -> None: """删除对话会话(软删除) Args: conversation_id: 对话会话 ID user_id: 用户 ID(用于权限验证) """ conversation = await self.conversation_repo.get_by_id(conversation_id) if not conversation: raise NotFoundError("对话会话不存在") # 验证权限 if conversation.user_id != user_id: raise PermissionDeniedError("没有权限删除此对话") # 软删除 from app.models.ai_conversation import ConversationStatus await self.conversation_repo.update(conversation_id, { 'status': ConversationStatus.DELETED }) logger.info("对话会话已删除: conversation_id=%s", conversation_id) # ==================== 消息管理 ==================== async def send_message( self, conversation_id: UUID, user_id: UUID, content: str, meta_data: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """发送用户消息 Args: conversation_id: 对话会话 ID user_id: 用户 ID content: 消息内容 meta_data: 额外元数据(可选) """ logger.info( "发送消息: conversation_id=%s, user_id=%s", conversation_id, user_id ) # 验证对话会话是否存在 conversation = await self.conversation_repo.get_by_id(conversation_id) if not conversation: raise NotFoundError("对话会话不存在") # 验证权限 if conversation.user_id != user_id: raise PermissionDeniedError("没有权限在此对话中发送消息") # 获取当前消息数量(用于 order_index) order_index = conversation.message_count # 创建用户消息 from app.models.ai_conversation_message import MessageRole try: message = await self.message_repo.create({ 'conversation_id': conversation_id, 'user_id': user_id, 'role': MessageRole.USER, 'content': content, 'meta_data': meta_data or {}, 'order_index': order_index }) logger.info( "用户消息已创建: message_id=%s", message.message_id ) except Exception as e: logger.error( "创建用户消息失败: conversation_id=%s, 错误=%s", conversation_id, str(e), exc_info=True ) raise return { 'message_id': str(message.message_id), 'role': 'user', 'content': message.content, 'created_at': message.created_at.isoformat() } async def get_ai_response( self, conversation_id: UUID, user_id: UUID, user_message_id: UUID ) -> Dict[str, Any]: """获取 AI 回复(调用 AI 模型) Args: conversation_id: 对话会话 ID user_id: 用户 ID user_message_id: 用户消息 ID """ logger.info( "获取 AI 回复: conversation_id=%s, user_message_id=%s", conversation_id, user_message_id ) # 验证对话会话 conversation = await self.conversation_repo.get_by_id(conversation_id) if not conversation: raise NotFoundError("对话会话不存在") # 验证权限 if conversation.user_id != user_id: raise PermissionDeniedError("没有权限访问此对话") # 获取对话上下文(最近 10 条消息) messages = await self.message_repo.get_recent_messages( conversation_id=conversation_id, limit=10 ) # 构建上下文 context = [ { 'role': 'user' if m.role == 1 else 'assistant', 'content': m.content } for m in messages ] # 调用 AI 模型 try: ai_response = await self._call_ai_model(context) except Exception as e: logger.error( "调用 AI 模型失败: conversation_id=%s, 错误=%s", conversation_id, str(e), exc_info=True ) raise # 保存 AI 回复 from app.models.ai_conversation_message import MessageRole order_index = conversation.message_count try: ai_message = await self.message_repo.create({ 'conversation_id': conversation_id, 'user_id': user_id, 'role': MessageRole.ASSISTANT, 'content': ai_response['content'], 'meta_data': ai_response.get('meta_data', {}), 'order_index': order_index }) logger.info( "AI 回复已创建: message_id=%s", ai_message.message_id ) except Exception as e: logger.error( "创建 AI 回复失败: conversation_id=%s, 错误=%s", conversation_id, str(e), exc_info=True ) raise return { 'message_id': str(ai_message.message_id), 'role': 'assistant', 'content': ai_message.content, 'created_at': ai_message.created_at.isoformat() } async def list_messages( self, conversation_id: UUID, user_id: UUID, page: int = 1, page_size: int = 50 ) -> Dict[str, Any]: """获取对话消息列表 Args: conversation_id: 对话会话 ID user_id: 用户 ID(用于权限验证) page: 页码 page_size: 每页数量 """ # 验证对话会话 conversation = await self.conversation_repo.get_by_id(conversation_id) if not conversation: raise NotFoundError("对话会话不存在") # 验证权限 if conversation.user_id != user_id: raise PermissionDeniedError("没有权限访问此对话") # 获取消息列表 messages, total = await self.message_repo.list_by_conversation( conversation_id=conversation_id, page=page, page_size=page_size ) return { 'items': [ { 'message_id': str(m.message_id), 'role': 'user' if m.role == 1 else 'assistant' if m.role == 2 else 'system', 'content': m.content, 'meta_data': m.meta_data, 'ai_job_id': str(m.ai_job_id) if m.ai_job_id else None, 'created_at': m.created_at.isoformat() } for m in messages ], 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size } # ==================== AI 任务关联 ==================== async def trigger_ai_generation( self, conversation_id: UUID, user_id: UUID, message_id: UUID, generation_type: str, params: Dict[str, Any] ) -> Dict[str, Any]: """从对话触发 AI 生成任务 Args: conversation_id: 对话会话 ID user_id: 用户 ID message_id: 消息 ID(用于关联) generation_type: 生成类型(image/video) params: 生成参数 """ logger.info( "触发 AI 生成: conversation_id=%s, type=%s", conversation_id, generation_type ) # 验证对话会话 conversation = await self.conversation_repo.get_by_id(conversation_id) if not conversation: raise NotFoundError("对话会话不存在") # 验证权限 if conversation.user_id != user_id: raise PermissionDeniedError("没有权限在此对话中触发 AI 生成") # 调用 AI Service 创建任务 try: if generation_type == 'image': result = await self.ai_service.generate_image( user_id=user_id, prompt=params.get('prompt'), model=params.get('model'), width=params.get('width', 1024), height=params.get('height', 1024), style=params.get('style') ) elif generation_type == 'video': result = await self.ai_service.generate_video( user_id=user_id, video_type=params.get('video_type'), prompt=params.get('prompt'), image_url=params.get('image_url'), duration=params.get('duration', 5), fps=params.get('fps', 30), model=params.get('model') ) else: raise ValidationError(f"不支持的生成类型: {generation_type}") except Exception as e: logger.error( "创建 AI 生成任务失败: conversation_id=%s, type=%s, 错误=%s", conversation_id, generation_type, str(e), exc_info=True ) raise # 更新消息,关联 AI 任务 await self.message_repo.update(message_id, { 'ai_job_id': result['job_id'] }) # 创建系统消息通知任务已创建 from app.models.ai_conversation_message import MessageRole order_index = conversation.message_count await self.message_repo.create({ 'conversation_id': conversation_id, 'user_id': user_id, 'role': MessageRole.SYSTEM, 'content': f'AI {generation_type} 生成任务已创建,任务 ID: {result["job_id"]}', 'meta_data': {'job_id': result['job_id'], 'generation_type': generation_type}, 'order_index': order_index, 'ai_job_id': result['job_id'] }) logger.info( "AI 生成任务已创建: job_id=%s", result['job_id'] ) return result # ==================== 私有方法 ==================== async def _validate_target(self, target_type: int, target_id: UUID) -> bool: """验证目标对象是否存在(应用层引用完整性保证) Args: target_type: 目标类型(TargetType 枚举值) target_id: 目标对象 ID """ from app.models.ai_conversation import TargetType 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) elif target_type == TargetType.SCENE: from app.repositories.screenplay_scene_repository import ScreenplaySceneRepository repo = ScreenplaySceneRepository(self.db) return await repo.exists(target_id) elif target_type == TargetType.PROP: from app.repositories.screenplay_prop_repository import ScreenplayPropRepository repo = ScreenplayPropRepository(self.db) return await repo.exists(target_id) elif target_type == TargetType.RESOURCE: from app.repositories.project_resource_repository import ProjectResourceRepository repo = ProjectResourceRepository(self.db) return await repo.exists(target_id) else: raise ValidationError(f"不支持的目标类型: {target_type}") async def _generate_title( self, target_type: int, target_id: UUID, tag_id: Optional[UUID], media_type: int ) -> str: """自动生成对话标题 Args: target_type: 目标类型 target_id: 目标对象 ID tag_id: 标签 ID(可选) media_type: 媒体类型 """ from app.models.ai_conversation import TargetType, MediaType # 获取目标对象名称 target_name = await self._get_target_name(target_type, target_id) # 获取标签名称(如果有) tag_name = await self._get_tag_name(tag_id) if tag_id else None # 媒体类型名称映射 media_type_names = { MediaType.IMAGE: "图片生成", MediaType.VIDEO: "视频生成", MediaType.AUDIO: "音频生成", MediaType.MODEL_3D: "3D模型生成", MediaType.TEXT: "文本生成" } media_name = media_type_names.get(media_type, "AI生成") # 组合标题 if tag_name: return f"{target_name}-{tag_name} - {media_name}" else: return f"{target_name} - {media_name}" async def _get_target_name(self, target_type: int, target_id: UUID) -> str: """获取目标对象名称 Args: target_type: 目标类型 target_id: 目标对象 ID """ from app.models.ai_conversation import TargetType if target_type == TargetType.STORYBOARD: from app.repositories.storyboard_repository import StoryboardRepository repo = StoryboardRepository(self.db) obj = await repo.get_by_id(target_id) return obj.title if obj else "未知分镜" elif target_type == TargetType.CHARACTER: from app.repositories.screenplay_character_repository import ScreenplayCharacterRepository repo = ScreenplayCharacterRepository(self.db) obj = await repo.get_by_id(target_id) return obj.name if obj else "未知角色" elif target_type == TargetType.SCENE: from app.repositories.screenplay_scene_repository import ScreenplaySceneRepository repo = ScreenplaySceneRepository(self.db) obj = await repo.get_by_id(target_id) return obj.title if obj else "未知场景" elif target_type == TargetType.PROP: from app.repositories.screenplay_prop_repository import ScreenplayPropRepository repo = ScreenplayPropRepository(self.db) obj = await repo.get_by_id(target_id) return obj.name if obj else "未知道具" elif target_type == TargetType.RESOURCE: from app.repositories.project_resource_repository import ProjectResourceRepository repo = ProjectResourceRepository(self.db) obj = await repo.get_by_id(target_id) return obj.name if obj else "未知资源" elif target_type == TargetType.SOUND_EFFECT: from app.repositories.sound_effect_repository import SoundEffectRepository repo = SoundEffectRepository(self.db) obj = await repo.get_by_id(target_id) return obj.name if obj else "未知音效" elif target_type == TargetType.VOICEOVER: from app.repositories.voiceover_repository import VoiceoverRepository repo = VoiceoverRepository(self.db) obj = await repo.get_by_id(target_id) return obj.name if obj else "未知配音" else: return "未知对象" async def _get_tag_name(self, tag_id: UUID) -> Optional[str]: """获取标签名称 Args: tag_id: 标签 ID """ from app.repositories.screenplay_tag_repository import ScreenplayTagRepository repo = ScreenplayTagRepository(self.db) tag = await repo.get_by_id(tag_id) return tag.tag_label if tag else None async def _call_ai_model(self, context: List[Dict[str, str]]) -> Dict[str, Any]: """调用 AI 模型获取回复 Args: context: 对话上下文 """ # 这里调用 AI 模型(如 GPT-4、Claude 等) # 实际实现需要根据具体的 AI 提供商进行调用 # 示例:调用 OpenAI API import openai response = await openai.ChatCompletion.acreate( model="gpt-4", messages=context, temperature=0.7, max_tokens=1000 ) return { 'content': response.choices[0].message.content, 'meta_data': { 'model': response.model, 'usage': { 'prompt_tokens': response.usage.prompt_tokens, 'completion_tokens': response.usage.completion_tokens, 'total_tokens': response.usage.total_tokens } } } ``` --- ## API 接口 ### 1. 创建对话会话 ``` POST /api/v1/ai/conversations ``` **请求体**: ```json { "projectId": "019d1234-5678-7abc-def0-111111111111", "targetType": 1, "targetId": "019d1234-5678-7abc-def0-222222222222", "tagId": "019d1234-5678-7abc-def0-333333333333", "mediaType": 1, "title": "分镜001-正面角度 - 生成图片" } ``` **字段说明**: - `projectId`(可选):项目 ID - `targetType`(必填):目标类型 - `1`:分镜(STORYBOARD) - `2`:角色(CHARACTER) - `3`:场景(SCENE) - `4`:道具(PROP) - `5`:资源(RESOURCE) - `6`:音效(SOUND_EFFECT) - `7`:配音(VOICEOVER) - `targetId`(必填):目标对象 ID - `tagId`(可选):标签 ID(用于区分变体,如角色装扮、分镜角度) - `mediaType`(必填):媒体类型 - `1`:图片(IMAGE) - `2`:视频(VIDEO) - `3`:音频(AUDIO) - `4`:3D模型(MODEL_3D) - `5`:文本(TEXT) - `title`(可选):会话标题(不提供则自动生成) **响应**: ```json { "code": 200, "message": "Success", "data": { "conversationId": "019d1234-5678-7abc-def0-444444444444", "title": "分镜001-正面角度 - 图片生成", "targetType": 1, "targetId": "019d1234-5678-7abc-def0-222222222222", "tagId": "019d1234-5678-7abc-def0-333333333333", "mediaType": 1, "status": 1, "messageCount": 0, "createdAt": "2026-01-30T10:00:00Z" } } ``` **使用示例**: ```javascript // 前端:在分镜编辑页面,点击"AI生成图片"按钮(基础图片,无标签) const createStoryboardConversation = async (storyboardId) => { const response = await fetch('/api/v1/ai/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: currentProjectId, targetType: 1, // STORYBOARD targetId: storyboardId, tagId: null, // 无标签(基础图片) mediaType: 1 // IMAGE }) }); return response.json(); }; // 前端:在分镜编辑页面,选择"正面角度"后点击"AI生成图片" const createStoryboardAngleConversation = async (storyboardId, angleTagId) => { const response = await fetch('/api/v1/ai/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: currentProjectId, targetType: 1, // STORYBOARD targetId: storyboardId, tagId: angleTagId, // 正面角度标签ID mediaType: 1 // IMAGE }) }); return response.json(); }; // 前端:在角色管理页面,选择"少年装扮"后点击"AI生成图片" const createCharacterConversation = async (characterId, costumeTagId) => { const response = await fetch('/api/v1/ai/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: currentProjectId, targetType: 2, // CHARACTER targetId: characterId, tagId: costumeTagId, // 少年装扮标签ID mediaType: 1 // IMAGE }) }); return response.json(); }; // 前端:在音效库页面,点击"AI生成音效" const createSoundEffectConversation = async (soundEffectId) => { const response = await fetch('/api/v1/ai/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetType: 6, // SOUND_EFFECT targetId: soundEffectId, tagId: null, // 音效通常无标签 mediaType: 3 // AUDIO }) }); return response.json(); }; ``` ### 2. 获取对话会话列表 ``` GET /api/v1/ai/conversations ``` **查询参数**: - `projectId`(可选):项目 ID - `targetType`(可选):目标类型(1-7) - `targetId`(可选):目标对象 ID - `tagId`(可选):标签 ID - `mediaType`(可选):媒体类型(1-5) - `status`(可选):会话状态(1=活跃 2=已归档 3=已删除) - `page`(可选):页码(默认 1) - `pageSize`(可选):每页数量(默认 20) **响应**: ```json { "code": 200, "message": "Success", "data": { "items": [ { "conversationId": "019d1234-5678-7abc-def0-444444444444", "title": "分镜001-正面角度 - 图片生成", "targetType": 1, "targetId": "019d1234-5678-7abc-def0-222222222222", "tagId": "019d1234-5678-7abc-def0-333333333333", "tagLabel": "正面角度", "mediaType": 1, "status": 1, "messageCount": 5, "lastMessageAt": "2026-01-30T10:05:00Z", "createdAt": "2026-01-30T10:00:00Z" }, { "conversationId": "019d1234-5678-7abc-def0-555555555555", "title": "分镜001-侧面角度 - 图片生成", "targetType": 1, "targetId": "019d1234-5678-7abc-def0-222222222222", "tagId": "019d1234-5678-7abc-def0-666666666666", "tagLabel": "侧面角度", "mediaType": 1, "status": 1, "messageCount": 3, "lastMessageAt": "2026-01-30T10:10:00Z", "createdAt": "2026-01-30T10:08:00Z" } ], "total": 2, "page": 1, "pageSize": 20, "totalPages": 1 } } ``` **使用示例**: ```javascript // 前端:查询"分镜001"的所有对话(所有角度+所有媒体类型) const getStoryboardConversations = async (storyboardId) => { const response = await fetch( `/api/v1/ai/conversations?targetType=1&targetId=${storyboardId}`, { headers: { 'Authorization': `Bearer ${token}` } } ); return response.json(); }; // 前端:查询"分镜001-正面角度"的"图片生成"对话 const getStoryboardAngleImageConversation = async (storyboardId, angleTagId) => { const response = await fetch( `/api/v1/ai/conversations?targetType=1&targetId=${storyboardId}&tagId=${angleTagId}&mediaType=1`, { headers: { 'Authorization': `Bearer ${token}` } } ); return response.json(); }; // 前端:查询"角色-张三"的所有对话(所有装扮+所有媒体类型) const getCharacterConversations = async (characterId) => { const response = await fetch( `/api/v1/ai/conversations?targetType=2&targetId=${characterId}`, { headers: { 'Authorization': `Bearer ${token}` } } ); return response.json(); }; // 前端:查询"角色-张三-少年装扮"的"图片生成"对话 const getCharacterCostumeImageConversation = async (characterId, costumeTagId) => { const response = await fetch( `/api/v1/ai/conversations?targetType=2&targetId=${characterId}&tagId=${costumeTagId}&mediaType=1`, { headers: { 'Authorization': `Bearer ${token}` } } ); return response.json(); }; ``` ### 3. 发送消息 ``` POST /api/v1/ai/conversations/{conversation_id}/messages ``` **请求体**: ```json { "content": "帮我生成一张咖啡厅的图片,风格要温馨浪漫" } ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "messageId": "019d1234-5678-7abc-def0-444444444444", "role": "user", "content": "帮我生成一张咖啡厅的图片,风格要温馨浪漫", "createdAt": "2026-01-30T10:01:00Z" } } ``` ### 4. 获取 AI 回复 ``` POST /api/v1/ai/conversations/{conversation_id}/messages/{message_id}/reply ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "messageId": "019d1234-5678-7abc-def0-555555555555", "role": "assistant", "content": "好的,我理解您的需求。您想要生成一张温馨浪漫的咖啡厅图片。我建议使用以下参数:\n\n- 风格:realistic\n- 尺寸:1024x1024\n- 提示词:A cozy and romantic coffee shop with warm lighting, wooden furniture, and large windows letting in natural sunlight\n\n是否需要我帮您生成这张图片?", "createdAt": "2026-01-30T10:01:05Z" } } ``` ### 5. 触发 AI 生成任务 ``` POST /api/v1/ai/conversations/{conversation_id}/generate ``` **请求体**: ```json { "messageId": "019d1234-5678-7abc-def0-555555555555", "generationType": "image", "params": { "prompt": "A cozy and romantic coffee shop with warm lighting", "model": "stable_diffusion", "width": 1024, "height": 1024, "style": "realistic" } } ``` **响应**: ```json { "code": 200, "message": "Success", "data": { "jobId": "019d1234-5678-7abc-def0-666666666666", "taskId": "abc-123-def", "status": "pending", "estimatedCredits": 10 } } ``` ### 6. 获取对话消息列表 ``` GET /api/v1/ai/conversations/{conversation_id}/messages ``` **查询参数**: - `page`(可选):页码(默认 1) - `pageSize`(可选):每页数量(默认 50) **响应**: ```json { "code": 200, "message": "Success", "data": { "items": [ { "messageId": "019d1234-5678-7abc-def0-444444444444", "role": "user", "content": "帮我生成一张咖啡厅的图片", "meta_data": {}, "aiJobId": null, "feedback": null, "createdAt": "2026-01-30T10:01:00Z" }, { "messageId": "019d1234-5678-7abc-def0-555555555555", "role": "assistant", "content": "好的,我理解您的需求...", "meta_data": {}, "aiJobId": null, "feedback": 1, "createdAt": "2026-01-30T10:01:05Z" } ], "total": 5, "page": 1, "pageSize": 50, "totalPages": 1 } } ``` --- ## 业务流程时序图 ### 6.1 创建对话并发送消息流程 ```mermaid sequenceDiagram participant U as 用户 participant F as 前端 participant A as API 服务 participant CS as ConversationService participant DB as 数据库 Note over U,DB: 阶段1:创建对话会话 U->>F: 点击"AI 对话"按钮(选择角度/装扮) F->>A: POST /api/v1/ai/conversations
{targetType, targetId, tagId, mediaType} A->>CS: create_conversation() CS->>DB: 验证用户/项目/目标对象 CS->>DB: 验证标签(如果提供) CS->>DB: 验证组合有效性 CS->>DB: 检查是否已存在活跃会话 alt 已存在活跃会话 CS-->>A: 返回现有会话 else 不存在活跃会话 CS->>DB: 创建对话会话记录 DB-->>CS: 返回 conversation_id end CS-->>A: 返回对话信息 A-->>F: 返回成功 F->>F: 打开对话窗口 F-->>U: 显示对话界面 Note over U,DB: 阶段2:发送用户消息 U->>F: 输入消息并发送 F->>A: POST /api/v1/ai/conversations/{id}/messages A->>CS: send_message() CS->>DB: 验证对话会话和权限 CS->>DB: 创建用户消息记录 CS->>DB: 更新会话统计信息 DB-->>CS: 返回 message_id CS-->>A: 返回消息信息 A-->>F: 返回成功 F->>F: 显示用户消息 F-->>U: 消息已发送 Note over U,DB: 阶段3:获取 AI 回复 F->>A: POST /api/v1/ai/conversations/{id}/messages/{msgId}/reply A->>CS: get_ai_response() CS->>DB: 获取对话上下文(最近10条消息) CS->>CS: 构建上下文数组 CS->>CS: 调用 AI 模型(GPT-4/Claude) Note over CS: AI 模型分析上下文
生成回复 CS->>DB: 创建 AI 回复消息记录 CS->>DB: 更新会话统计信息 DB-->>CS: 返回 message_id CS-->>A: 返回 AI 回复 A-->>F: 返回成功 F->>F: 显示 AI 回复 F-->>U: AI 回复已生成 ``` ### 6.2 从对话触发 AI 生成任务流程 ```mermaid sequenceDiagram participant U as 用户 participant F as 前端 participant A as API 服务 participant CS as ConversationService participant AS as AIService participant DB as 数据库 participant Q as Celery 队列 participant W as Worker participant AI as AI 提供商 participant S3 as 对象存储 Note over U,S3: 阶段1:用户确认生成参数 U->>F: 查看 AI 建议的参数 U->>F: 点击"生成图片"按钮 F->>A: POST /api/v1/ai/conversations/{id}/generate A->>CS: trigger_ai_generation() CS->>DB: 验证对话会话和权限 Note over U,S3: 阶段2:创建 AI 任务 CS->>AS: generate_image() AS->>DB: 检查用户积分 AS->>DB: 创建 AI 任务记录 AS->>DB: 扣除积分 AS->>Q: 提交异步任务 AS-->>CS: 返回 job_id Note over U,S3: 阶段3:更新对话记录 CS->>DB: 更新消息,关联 ai_job_id CS->>DB: 创建系统消息(任务已创建) CS-->>A: 返回任务信息 A-->>F: 返回成功 F->>F: 显示系统消息 F-->>U: 任务已创建,生成中... Note over U,S3: 阶段4:异步生成图片 Q->>W: 分配任务 W->>AI: 调用 AI API 生成图片 AI-->>W: 返回生成的图片 W->>S3: 上传图片到对象存储 S3-->>W: 返回图片 URL W->>DB: 更新 AI 任务状态(completed) W->>DB: 保存图片 URL Note over U,S3: 阶段5:通知用户任务完成 W->>DB: 查询关联的对话会话 W->>DB: 创建系统消息(任务已完成) Note over U,S3: 阶段6:用户查看结果 loop 轮询任务状态 F->>A: GET /api/v1/ai/jobs/{jobId} A->>DB: 查询任务状态 alt 任务完成 A-->>F: 返回完成状态 + 图片 URL F->>F: 显示系统消息 F->>F: 显示生成的图片 F-->>U: 图片生成完成 else 任务进行中 A-->>F: 返回进行中状态 end end ``` ### 6.3 多轮对话优化提示词流程 ```mermaid sequenceDiagram participant U as 用户 participant F as 前端 participant A as API 服务 participant CS as ConversationService participant DB as 数据库 Note over U,DB: 第1轮对话 U->>F: "帮我生成一张咖啡厅的图片" F->>A: POST /api/v1/ai/conversations/{id}/messages A->>CS: send_message() CS->>DB: 创建用户消息 CS-->>A: 返回成功 A-->>F: 返回消息 F->>A: POST /api/v1/ai/conversations/{id}/messages/{msgId}/reply A->>CS: get_ai_response() CS->>DB: 获取上下文(1条消息) CS->>CS: 调用 AI 模型 Note over CS: AI: "您想要什么风格的咖啡厅?
现代简约、复古怀旧还是温馨浪漫?" CS->>DB: 创建 AI 回复消息 CS-->>A: 返回 AI 回复 A-->>F: 返回成功 F-->>U: 显示 AI 回复 Note over U,DB: 第2轮对话 U->>F: "温馨浪漫的风格" F->>A: POST /api/v1/ai/conversations/{id}/messages A->>CS: send_message() CS->>DB: 创建用户消息 CS-->>A: 返回成功 F->>A: POST /api/v1/ai/conversations/{id}/messages/{msgId}/reply A->>CS: get_ai_response() CS->>DB: 获取上下文(3条消息) CS->>CS: 调用 AI 模型 Note over CS: AI: "好的,温馨浪漫风格。
建议参数:
- 暖色调灯光
- 木质家具
- 大窗户自然光
是否需要我生成?" CS->>DB: 创建 AI 回复消息 CS-->>A: 返回 AI 回复 A-->>F: 返回成功 F-->>U: 显示 AI 回复和"生成"按钮 Note over U,DB: 第3轮对话 - 触发生成 U->>F: 点击"生成"按钮 F->>A: POST /api/v1/ai/conversations/{id}/generate A->>CS: trigger_ai_generation() Note over CS: 使用对话上下文中的参数
创建 AI 生成任务 CS-->>A: 返回任务信息 A-->>F: 返回成功 F-->>U: 任务已创建,生成中... ``` --- ## 与 AI Service 集成 ### 集成架构 ``` AI Conversation Service (对话管理) → AI Service (任务执行) ``` ### 数据关联 ```sql -- ai_conversation_messages 表关联 ai_jobs 表 ALTER TABLE ai_conversation_messages ADD COLUMN ai_job_id UUID; CREATE INDEX idx_ai_conversation_messages_ai_job_id ON ai_conversation_messages (ai_job_id) WHERE ai_job_id IS NOT NULL; COMMENT ON COLUMN ai_conversation_messages.ai_job_id IS 'AI 任务 ID - 应用层验证'; ``` ### 集成流程 1. **用户发送消息** → AI Conversation Service 记录消息 2. **获取 AI 回复** → AI Conversation Service 调用 AI 模型 3. **触发 AI 生成** → AI Conversation Service 调用 AI Service 4. **任务完成通知** → AI Service 回调 AI Conversation Service --- ## 完整使用场景示例 ### 场景1:分镜基础图片生成对话(无标签) **用户操作流程**: 1. 用户在项目编辑页面,点击"分镜001" 2. 点击"AI生成图片"按钮(不选择角度) 3. 系统打开对话窗口 4. 用户输入:"帮我生成一张咖啡厅的图片" 5. AI 回复:"您想要什么风格?" 6. 用户回复:"温馨浪漫的风格" 7. AI 回复:"好的,建议参数:..." 8. 用户点击"生成"按钮 9. 系统创建 AI 任务,生成图片 **前端代码示例**: ```javascript // 1. 打开对话窗口时,创建或获取对话会话(基础图片,无标签) const openConversation = async (storyboardId) => { const response = await fetch('/api/v1/ai/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: currentProjectId, targetType: 1, // STORYBOARD targetId: storyboardId, tagId: null, // 无标签(基础图片) mediaType: 1 // IMAGE }) }); const { data } = await response.json(); return data.conversationId; }; // 2. 发送用户消息 const sendMessage = async (conversationId, content) => { const response = await fetch(`/api/v1/ai/conversations/${conversationId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }); return response.json(); }; // 3. 获取 AI 回复 const getAIReply = async (conversationId, messageId) => { const response = await fetch( `/api/v1/ai/conversations/${conversationId}/messages/${messageId}/reply`, { method: 'POST' } ); return response.json(); }; // 4. 触发 AI 生成 const triggerGeneration = async (conversationId, messageId, params) => { const response = await fetch(`/api/v1/ai/conversations/${conversationId}/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId, generationType: 'image', params }) }); return response.json(); }; ``` ### 场景2:分镜不同角度图片生成对话(有标签) **用户操作流程**: 1. 用户在项目编辑页面,点击"分镜001" 2. 选择"正面角度"标签 3. 点击"AI生成图片"按钮 4. 系统打开对话窗口(只显示"分镜001-正面角度"的对话历史) 5. 用户输入:"生成一张正面角度的咖啡厅图片" 6. AI 回复:"好的,建议参数:..." 7. 用户点击"生成"按钮 **前端代码示例**: ```javascript // 打开"分镜001-正面角度"的对话窗口 const openAngleConversation = async (storyboardId, angleTagId) => { const response = await fetch('/api/v1/ai/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: currentProjectId, targetType: 1, // STORYBOARD targetId: storyboardId, tagId: angleTagId, // 正面角度标签ID mediaType: 1 // IMAGE }) }); const { data } = await response.json(); return data.conversationId; }; ``` **数据隔离验证**: ```sql -- 查询"分镜001-正面角度"的对话(只能看到正面角度的) 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; -- 结果:只返回"分镜001-正面角度"的对话,不会返回"分镜001-侧面角度"的对话 ``` ### 场景3:角色不同装扮图片生成对话 **用户操作流程**: 1. 用户在角色管理页面,点击"角色-张三" 2. 选择"少年装扮"标签 3. 点击"AI生成图片"按钮 4. 系统打开对话窗口(只显示"角色-张三-少年装扮"的对话历史) 5. 用户输入:"生成一张15岁的张三" 6. AI 回复:"好的,建议参数:..." 7. 用户点击"生成"按钮 **前端代码示例**: ```javascript // 打开"角色-张三-少年装扮"的对话窗口 const openCharacterCostumeConversation = async (characterId, costumeTagId) => { const response = await fetch('/api/v1/ai/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: currentProjectId, targetType: 2, // CHARACTER targetId: characterId, tagId: costumeTagId, // 少年装扮标签ID mediaType: 1 // IMAGE }) }); const { data } = await response.json(); return data.conversationId; }; ``` **数据隔离验证**: ```sql -- 查询"角色-张三-少年装扮"的对话 SELECT * FROM ai_conversations WHERE user_id = '019d1234-5678-7abc-def0-111111111111' AND target_type = 2 -- CHARACTER AND target_id = '019d1234-5678-7abc-def0-444444444444' -- 张三的ID AND tag_id = '019d1234-5678-7abc-def0-555555555555' -- 少年装扮TagID AND media_type = 1 -- IMAGE AND status = 1; -- 结果:只返回"角色-张三-少年装扮"的对话,不会返回"角色-张三-成年装扮"的对话 ``` ### 场景4:同一对象的不同媒体类型对话 **用户操作流程**: 1. 用户在"分镜001"生成图片(对话A) 2. 用户在"分镜001"生成视频(对话B) 3. 两个对话是独立的,互不干扰 **数据验证**: ```sql -- 对话A:分镜001的图片生成对话 INSERT INTO ai_conversations ( conversation_id, user_id, project_id, target_type, target_id, tag_id, media_type, title, status ) VALUES ( '019d1234-5678-7abc-def0-aaaaaaaaaaaa', '019d1234-5678-7abc-def0-111111111111', '019d1234-5678-7abc-def0-999999999999', 1, -- STORYBOARD '019d1234-5678-7abc-def0-222222222222', -- 分镜001 NULL, -- 无标签 1, -- IMAGE '分镜001 - 图片生成', 1 ); -- 对话B:分镜001的视频生成对话 INSERT INTO ai_conversations ( conversation_id, user_id, project_id, target_type, target_id, tag_id, media_type, title, status ) VALUES ( '019d1234-5678-7abc-def0-bbbbbbbbbbbb', '019d1234-5678-7abc-def0-111111111111', '019d1234-5678-7abc-def0-999999999999', 1, -- STORYBOARD '019d1234-5678-7abc-def0-222222222222', -- 分镜001 NULL, -- 无标签 2, -- VIDEO '分镜001 - 视频生成', 1 ); -- ✅ 两条记录可以同时存在(media_type 不同) ``` ### 场景5:同一对象的不同标签对话 **用户操作流程**: 1. 用户在"分镜001-正面角度"生成图片(对话A) 2. 用户在"分镜001-侧面角度"生成图片(对话B) 3. 两个对话是独立的,互不干扰 **数据验证**: ```sql -- 对话A:分镜001-正面角度的图片生成对话 INSERT INTO ai_conversations ( conversation_id, user_id, project_id, target_type, target_id, tag_id, media_type, title, status ) VALUES ( '019d1234-5678-7abc-def0-aaaaaaaaaaaa', '019d1234-5678-7abc-def0-111111111111', '019d1234-5678-7abc-def0-999999999999', 1, -- STORYBOARD '019d1234-5678-7abc-def0-222222222222', -- 分镜001 '019d1234-5678-7abc-def0-333333333333', -- 正面角度TagID 1, -- IMAGE '分镜001-正面角度 - 图片生成', 1 ); -- 对话B:分镜001-侧面角度的图片生成对话 INSERT INTO ai_conversations ( conversation_id, user_id, project_id, target_type, target_id, tag_id, media_type, title, status ) VALUES ( '019d1234-5678-7abc-def0-bbbbbbbbbbbb', '019d1234-5678-7abc-def0-111111111111', '019d1234-5678-7abc-def0-999999999999', 1, -- STORYBOARD '019d1234-5678-7abc-def0-222222222222', -- 分镜001 '019d1234-5678-7abc-def0-444444444444', -- 侧面角度TagID 1, -- IMAGE '分镜001-侧面角度 - 图片生成', 1 ); -- ✅ 两条记录可以同时存在(tag_id 不同) ``` ### 场景6:唯一性约束验证 **规则**:同一用户、同一目标、同一标签、同一媒体类型只能有一个活跃会话。 **测试用例**: ```sql -- 用户A在"分镜001-正面角度"创建"图片生成"对话 INSERT INTO ai_conversations ( conversation_id, user_id, project_id, target_type, target_id, tag_id, media_type, title, status ) VALUES ( '019d1234-5678-7abc-def0-aaaaaaaaaaaa', '019d1234-5678-7abc-def0-111111111111', '019d1234-5678-7abc-def0-999999999999', 1, -- STORYBOARD '019d1234-5678-7abc-def0-222222222222', -- 分镜001 '019d1234-5678-7abc-def0-333333333333', -- 正面角度TagID 1, -- IMAGE '分镜001-正面角度 - 图片生成', 1 -- ACTIVE ); -- 用户A再次在"分镜001-正面角度"创建"图片生成"对话 -- ❌ 违反唯一约束,插入失败 INSERT INTO ai_conversations ( conversation_id, user_id, project_id, target_type, target_id, tag_id, media_type, title, status ) VALUES ( '019d1234-5678-7abc-def0-bbbbbbbbbbbb', '019d1234-5678-7abc-def0-111111111111', '019d1234-5678-7abc-def0-999999999999', 1, -- STORYBOARD '019d1234-5678-7abc-def0-222222222222', -- 分镜001 '019d1234-5678-7abc-def0-333333333333', -- 正面角度TagID 1, -- IMAGE '分镜001-正面角度 - 图片生成', 1 -- ACTIVE ); -- ERROR: duplicate key value violates unique constraint "idx_ai_conversations_unique_active" -- 解决方案:应用层先查询是否存在活跃会话,如果存在则直接返回 ``` ### 场景7:独立音效生成对话 **用户操作流程**: 1. 用户在音效库页面,点击"新建音效" 2. 点击"AI生成音效"按钮 3. 系统打开对话窗口 4. 用户输入:"生成一段雨声音效" 5. AI 回复:"好的,建议参数:时长5秒,音量适中" 6. 用户点击"生成"按钮 **前端代码示例**: ```javascript // 打开音效生成对话窗口 const openSoundEffectConversation = async (soundEffectId) => { const response = await fetch('/api/v1/ai/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetType: 6, // SOUND_EFFECT targetId: soundEffectId, tagId: null, // 音效通常无标签 mediaType: 3 // AUDIO }) }); const { data } = await response.json(); return data.conversationId; }; ``` --- ## 相关文档 - [AI 对话 @ 提及与参考图系统](./ai-conversation-mention-system.md) ⭐ **新增功能** - [AI 生成服务](./ai-service.md) - [数据库设计](../../database-design.md) - [API 设计规范](../../api-design-specification.md) --- **文档版本**:v2.1 **最后更新**:2026-01-30 **变更说明**: - v2.1 (2026-01-30): 完善 API 接口、业务流程时序图、使用场景示例,移除消息反馈功能,新增 @ 提及系统文档链接 - v2.0 (2026-01-30): 重构为多态关联 + 标签系统设计,移除 `generation_type` 字段 - v1.0 (2026-01-29): 初始版本