You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

81 KiB

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 接口
  6. 业务流程时序图
  7. 与 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 枚举定义

# 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-侧面角度"的对话历史
  • 用户在生成"图片"时,不应该看到"视频"的对话历史

优化后的方案

-- ✅ 多态关联 + 标签系统(最终方案)
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)

class TargetType(IntEnum):
    """目标类型"""
    STORYBOARD = 1      # 分镜
    CHARACTER = 2       # 角色
    SCENE = 3           # 场景
    PROP = 4            # 道具
    RESOURCE = 5        # 通用资源
    SOUND_EFFECT = 6    # 音效
    VOICEOVER = 7       # 配音

媒体类型(MediaType)

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"的所有对话(所有角度+所有媒体类型)

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-正面角度"的"图片生成"对话

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;

查询"角色-张三"的所有对话(所有装扮+所有媒体类型)

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;

查询"角色-张三-少年装扮"的"图片生成"对话

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;

唯一性约束

规则:同一用户、同一目标、同一标签、同一媒体类型只能有一个活跃会话。

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 值被视为相同,确保唯一性约束正确工作。

应用层引用完整性保证

验证逻辑

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 对话会话表)

核心表,记录对话会话的基本信息。

-- 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 对话消息表)

核心表,记录对话中的每条消息。

-- 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 类

# 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

请求体

{
  "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(可选):会话标题(不提供则自动生成)

响应

{
  "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"
  }
}

使用示例

// 前端:在分镜编辑页面,点击"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)

响应

{
  "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
  }
}

使用示例

// 前端:查询"分镜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

请求体

{
  "content": "帮我生成一张咖啡厅的图片,风格要温馨浪漫"
}

响应

{
  "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

响应

{
  "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

请求体

{
  "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"
  }
}

响应

{
  "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)

响应

{
  "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 创建对话并发送消息流程

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<br/>{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 模型分析上下文<br/>生成回复
    CS->>DB: 创建 AI 回复消息记录
    CS->>DB: 更新会话统计信息
    DB-->>CS: 返回 message_id
    CS-->>A: 返回 AI 回复
    A-->>F: 返回成功
    F->>F: 显示 AI 回复
    F-->>U: AI 回复已生成

6.2 从对话触发 AI 生成任务流程

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 多轮对话优化提示词流程

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: "您想要什么风格的咖啡厅?<br/>现代简约、复古怀旧还是温馨浪漫?"
    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: "好的,温馨浪漫风格。<br/>建议参数:<br/>- 暖色调灯光<br/>- 木质家具<br/>- 大窗户自然光<br/>是否需要我生成?"
    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: 使用对话上下文中的参数<br/>创建 AI 生成任务
    CS-->>A: 返回任务信息
    A-->>F: 返回成功
    F-->>U: 任务已创建,生成中...

与 AI Service 集成

集成架构

AI Conversation Service (对话管理) → AI Service (任务执行)

数据关联

-- 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 任务,生成图片

前端代码示例

// 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. 用户点击"生成"按钮

前端代码示例

// 打开"分镜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;
};

数据隔离验证

-- 查询"分镜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. 用户点击"生成"按钮

前端代码示例

// 打开"角色-张三-少年装扮"的对话窗口
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;
};

数据隔离验证

-- 查询"角色-张三-少年装扮"的对话
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. 两个对话是独立的,互不干扰

数据验证

-- 对话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. 两个对话是独立的,互不干扰

数据验证

-- 对话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:唯一性约束验证

规则:同一用户、同一目标、同一标签、同一媒体类型只能有一个活跃会话。

测试用例

-- 用户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. 用户点击"生成"按钮

前端代码示例

// 打开音效生成对话窗口
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;
};

相关文档


文档版本:v2.1
最后更新:2026-01-30
变更说明

  • v2.1 (2026-01-30): 完善 API 接口、业务流程时序图、使用场景示例,移除消息反馈功能,新增 @ 提及系统文档链接
  • v2.0 (2026-01-30): 重构为多态关联 + 标签系统设计,移除 generation_type 字段
  • v1.0 (2026-01-29): 初始版本