76 KiB
ADR 01: 资源归属从剧本级迁移到项目级
状态:提议中 日期:2026-02-07 决策者:架构团队
目录
背景
当前架构问题
在现有架构中(参见 ADR 003),角色(Character)、场景(Location)、道具(Prop)属于剧本级别(screenplay),存储在以下表中:
screenplay_charactersscreenplay_locationsscreenplay_props
这种设计在以下业务场景中存在问题:
1. 多集项目的资源共享需求
场景:多集项目(episode_count > 1)
- 一个项目有多集(如《权力的游戏》第一季
episode_count = 10) - 每集对应一个子项目和一个剧本
- 问题:同一个角色(如"琼恩·雪诺")在每集的剧本中都需要重复创建
- 期望:角色应该在项目级别定义,所有集数共享
场景:单集项目动态扩展为多集(episode_count 动态增长)
- 项目创建时
episode_count = 1(如电影《阿凡达》) - 上传第1个剧本后,项目有1集
- 扩展需求:后续要拍续集,上传第2个剧本
- 问题:资源属于剧本,第2集无法复用第1集的角色/场景/道具
- 期望:资源属于项目,
episode_count自动更新为 2,所有集数共享资源
2. 项目层级结构与动态扩展
当前系统采用父子项目架构,通过 parent_project_id 字段建立层级关系:
架构层级:
父级项目(用户创建的项目)
├─ project_id: xxx-parent
├─ parent_project_id: NULL(标识为父级项目)
├─ episode_count: 动态更新(1 → 2 → 3...)
│
├─ 子项目1(上传剧本1时自动创建)
│ ├─ project_id: xxx-ep1
│ ├─ parent_project_id: xxx-parent(指向父级项目)
│ └─ screenplay_id: 剧本1
│
├─ 子项目2(上传剧本2时自动创建)
│ ├─ project_id: xxx-ep2
│ ├─ parent_project_id: xxx-parent
│ └─ screenplay_id: 剧本2
│
└─ 子项目3(上传剧本3时自动创建)
├─ project_id: xxx-ep3
├─ parent_project_id: xxx-parent
└─ screenplay_id: 剧本3
资源归属规则:
父级项目(xxx-parent)
├─ project_characters(角色属于父级项目)
│ ├─ character_id: char-001, project_id: xxx-parent, name: "琼恩·雪诺"
│ └─ character_id: char-002, project_id: xxx-parent, name: "艾莉亚"
│
├─ project_locations(场景属于父级项目)
│ └─ location_id: loc-001, project_id: xxx-parent, name: "临冬城"
│
├─ project_props(道具属于父级项目)
│ └─ prop_id: prop-001, project_id: xxx-parent, name: "冰剑"
│
└─ project_resources(素材属于父级项目,包含实拍)
├─ resource_id: res-001, project_id: xxx-parent, type: 1(角色素材)
├─ resource_id: res-002, project_id: xxx-parent, type: 2(场景素材)
├─ resource_id: res-003, project_id: xxx-parent, type: 3(道具素材)
└─ resource_id: res-004, project_id: xxx-parent, type: 4(实拍素材)
剧本引用关系(子项目的剧本引用父级项目的资源)
├─ screenplay_element_refs
│ ├─ screenplay_id: 剧本1, element_id: char-001(引用父级项目的角色)
│ ├─ screenplay_id: 剧本2, element_id: char-001(引用同一个角色)
│ └─ screenplay_id: 剧本3, element_id: char-001(引用同一个角色)
动态扩展流程:
1. 用户创建项目
→ 创建父级项目(parent_project_id = NULL, episode_count = 1)
2. 上传第1个剧本
→ 创建子项目1(parent_project_id = xxx-parent)
→ AI 拆解剧本,资源存储到父级项目(project_id = xxx-parent)
→ 创建剧本引用关系(screenplay_element_refs)
3. 上传第2个剧本
→ 创建子项目2(parent_project_id = xxx-parent)
→ 父级项目 episode_count 自动更新为 2
→ AI 拆解剧本,资源存储到父级项目(去重,复用已有资源)
→ 创建剧本引用关系
4. 上传第N个剧本
→ 创建子项目N(parent_project_id = xxx-parent)
→ 父级项目 episode_count 自动更新为 N
→ 资源持续累积到父级项目
设计优势:
- ✅ 资源归属清晰:所有资源属于父级项目
- ✅ 子项目自动创建:上传剧本时自动创建子项目
- ✅ 资源自动共享:所有子项目的剧本引用同一份父级资源
- ✅ 动态扩展:
episode_count随子项目数量自动更新
当前问题:
- ❌ 资源错误归属:角色/场景/道具属于剧本(screenplay_id),而非父级项目
- ❌ 素材错误归属:
project_resources.project_id可能指向子项目,而非父级项目 - ❌ 资源分散:每个子项目的剧本都有独立的资源,无法共享
- ❌ 重复创建:同一角色在多个剧本中重复创建
- ❌ 管理困难:无法在父级项目层级统一查看和管理资源(包括实拍)
3. 前端使用场景
前端的 ProjectResourcePanel 组件:
- 接收
projectId作为参数 - 通过
/projects/{project_id}/resource-library/*接口查询资源 - 用户期望在项目级别管理资源,而不是剧本级别
问题:
- 查询接口是项目级的,但创建接口需要
screenplay_id - 用户在项目面板中创建角色时,不应该关心剧本的概念
- 架构不一致导致前端实现复杂
业务需求分析
不同集数项目的资源管理需求:
| 集数设置 | 剧本数量 | 资源共享需求 | 扩展场景 | 示例 |
|---|---|---|---|---|
| episode_count > 1 | 多个(每集一个) | 高(角色跨集出现) | 预先规划多集 | 《权力的游戏》(10集)、《进击的巨人》(24集) |
| episode_count = 1 → N | 动态增长 | 高(续集复用资源) | 单集扩展为多集 | 《阿凡达》→《阿凡达2》、电影系列、番外篇 |
| episode_count = 1 | 1个 | 中(资源统一管理) | 暂无扩展计划 | 短视频、品牌广告、独立短片 |
核心设计理念:
- 无论单集还是多集,资源都应该属于项目级别
- 通过
episode_count字段动态管理集数,无需显式的"项目类型"枚举 - 支持动态扩展:上传新剧本时自动更新
episode_count - 单集项目可以无缝扩展为多集,资源自动共享
结论:资源应该属于项目级别,统一架构,支持灵活扩展。
决策
核心概念澄清
在开始架构设计之前,需要明确以下核心概念的区别和关联:
1. 资源(Resource)vs 素材(Material)
资源(Resource):
- 定义:从剧本中提炼出的抽象概念
- 类型:角色(Character)、场景(Location)、道具(Prop)
- 表结构:
project_characters,project_locations,project_props - 特点:
- 属于父级项目
- 包含名称、描述等元数据
- 可以有多个变体标签
- 不包含具体的可视化文件
素材(Material):
- 定义:资源的具体可视化实现(图片、视频文件)
- 类型:角色素材、场景素材、道具素材、实拍素材
- 表结构:
project_resources - 特点:
- 属于父级项目
- 包含文件 URL、缩略图、文件大小等
- 可以关联到资源的变体标签
- 实拍素材可以不关联任何资源
2. 变体标签(Variant Tag)
定义:资源的不同状态或版本
用途:
- 角色的不同状态:如"年轻时"、"受伤后"、"愤怒时"
- 场景的不同时间:如"白天"、"夜晚"、"下雨时"
- 道具的不同状态:如"完整"、"破损"、"修复后"
表结构:project_element_tags(原 screenplay_element_tags)
关联关系:
project_characters(资源:琼恩·雪诺)
↓ element_id
project_element_tags(变体标签)
├─ tag_id: tag-001, tag_label: "年轻时"
├─ tag_id: tag-002, tag_label: "受伤后"
└─ tag_id: tag-003, tag_label: "国王时期"
↓ element_tag_id
project_resources(素材)
├─ resource_id: res-001, element_tag_id: tag-001(年轻时的图片)
├─ resource_id: res-002, element_tag_id: tag-002(受伤后的图片)
└─ resource_id: res-003, element_tag_id: tag-003(国王时期的图片)
3. 完整关联链路
场景 1:角色素材(有变体标签)
父级项目
↓
project_characters(资源)
├─ character_id: char-001
├─ name: "琼恩·雪诺"
└─ has_tags: true
↓
project_element_tags(变体标签)
├─ tag_id: tag-001
├─ element_type: 1(角色)
├─ element_id: char-001
└─ tag_label: "年轻时"
↓
project_resources(素材)
├─ resource_id: res-001
├─ project_id: xxx-parent(父级项目)
├─ type: 1(角色素材)
├─ element_tag_id: tag-001(关联到变体标签)
├─ element_name: "琼恩·雪诺"(冗余字段)
├─ tag_label: "年轻时"(冗余字段)
└─ file_url: "https://..."
场景 2:实拍素材(无变体标签)
父级项目
↓
project_resources(素材)
├─ resource_id: res-004
├─ project_id: xxx-parent(父级项目)
├─ type: 4(实拍素材)
├─ element_tag_id: NULL(不关联任何标签)
├─ element_name: NULL
├─ tag_label: NULL
├─ name: "城市街景"
└─ file_url: "https://..."
4. 关键设计说明
为什么素材表有冗余字段?
element_name和tag_label是冗余字段- 目的:避免频繁 JOIN 查询,提升性能
- 更新策略:创建素材时从标签表复制,标签更新时同步更新
为什么实拍素材不需要标签?
- 实拍素材是用户直接上传的原始素材
- 不属于任何剧本资源(角色/场景/道具)
- 可以直接在分镜中使用,无需关联资源
标签的来源?
- AI 拆解剧本时自动生成
- 用户手动创建和管理
- 关联到父级项目的资源
核心方案
将角色、场景、道具的归属从剧本级别迁移到父级项目级别,采用以下架构:
父级项目层(资源定义)
├─ projects(项目表,parent_project_id = NULL)
│ ├─ project_characters(父级项目角色表)✨ 新增
│ ├─ project_locations(父级项目场景表)✨ 新增
│ ├─ project_props(父级项目道具表)✨ 新增
│ └─ project_resources(父级项目素材表,包含实拍资源 type=4)
│
子项目层(剧本归属)
├─ projects(项目表,parent_project_id != NULL)
│ └─ screenplays(剧本表)
│
剧本层(资源引用)
├─ screenplays(剧本表)
│ ├─ screenplay_element_refs(剧本资源引用表)✨ 新增
│ │ └─ 引用父级项目的资源(通过 element_id)
│
标签层(资源变体)
├─ project_element_tags(项目元素标签表)🔄 重命名
│ └─ 关联到父级项目的资源
│
素材层(可视化文件)
└─ project_resources(项目素材表)
├─ project_id(指向父级项目)
├─ type: 1=角色, 2=场景, 3=道具, 4=实拍
└─ element_tag_id(可选,用于角色/场景/道具的变体)
设计原则
- 资源定义在父级项目:角色/场景/道具在父级项目层级创建和管理(
parent_project_id = NULL) - 子项目自动创建:上传剧本时自动创建子项目(
parent_project_id指向父级项目) - 剧本引用资源:子项目的剧本通过引用表关联父级项目资源,而不是拥有资源
- 资源自动共享:所有子项目的剧本共享父级项目的资源
- 向后兼容:保留旧表结构,通过数据迁移逐步过渡
- 灵活扩展:支持剧本特定的资源变体(如角色在不同集数的状态变化)
架构设计
1. 数据库表结构
1.1 项目角色表(project_characters)
职责:存储父级项目的角色定义,所有子项目共享
-- 创建表
CREATE TABLE project_characters (
character_id UUID PRIMARY KEY, -- 应用层生成 UUID v7
project_id UUID NOT NULL, -- 所属父级项目(parent_project_id = NULL 的项目)
name VARCHAR(255) NOT NULL,
description TEXT,
character_image_url TEXT,
role_type SMALLINT DEFAULT 2, -- 角色类型:1=主角, 2=配角, 3=群演
is_offscreen BOOLEAN DEFAULT FALSE,
line_count INTEGER DEFAULT 0, -- 台词数量(从所有子项目的剧本统计汇总)
appearance_count INTEGER DEFAULT 0, -- 出场次数(从所有子项目的剧本统计汇总)
order_index INTEGER DEFAULT 0,
has_tags BOOLEAN DEFAULT FALSE,
default_tag_id UUID, -- 默认标签ID
meta_data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT project_characters_project_name_unique UNIQUE(project_id, name)
);
-- 添加索引(所有关联字段必须创建索引)
CREATE INDEX idx_project_characters_project_id ON project_characters(project_id);
CREATE INDEX idx_project_characters_name ON project_characters(name);
CREATE INDEX idx_project_characters_role_type ON project_characters(role_type);
-- 添加中文注释
COMMENT ON TABLE project_characters IS '项目角色表:存储父级项目的角色定义,所有子项目共享';
COMMENT ON COLUMN project_characters.character_id IS '角色ID(UUID v7,应用层生成)';
COMMENT ON COLUMN project_characters.project_id IS '所属父级项目ID(parent_project_id = NULL 的项目)';
COMMENT ON COLUMN project_characters.name IS '角色名称';
COMMENT ON COLUMN project_characters.description IS '角色描述';
COMMENT ON COLUMN project_characters.character_image_url IS '角色形象图片URL';
COMMENT ON COLUMN project_characters.role_type IS '角色类型:1=主角(main), 2=配角(supporting), 3=群演(extra)';
COMMENT ON COLUMN project_characters.is_offscreen IS '是否为画外音角色';
COMMENT ON COLUMN project_characters.line_count IS '台词数量(从所有子项目的剧本统计汇总)';
COMMENT ON COLUMN project_characters.appearance_count IS '出场次数(从所有子项目的剧本统计汇总)';
COMMENT ON COLUMN project_characters.order_index IS '排序索引';
COMMENT ON COLUMN project_characters.has_tags IS '是否有标签';
COMMENT ON COLUMN project_characters.default_tag_id IS '默认标签ID(指向 project_element_tags)';
COMMENT ON COLUMN project_characters.meta_data IS '扩展元数据(JSONB格式)';
COMMENT ON COLUMN project_characters.created_at IS '创建时间(UTC)';
COMMENT ON COLUMN project_characters.updated_at IS '更新时间(UTC)';
关键设计说明:
- ⚠️ 禁止外键约束:
project_id不使用REFERENCES,在应用层保证引用完整性 - ⚠️ UUID v7 应用层生成:主键不使用
DEFAULT uuid_generate_v7(),由 Python 应用层生成 - ✅ 枚举使用 SMALLINT:
role_type使用 SMALLINT + IntEnum 模式 - ✅ 所有关联字段创建索引:
project_id必须有索引以保证查询性能 - ✅ 添加中文注释:使用
COMMENT ON语法为表和列添加说明 - ✅ 保留统计字段:
line_count和appearance_count从所有子项目的剧本汇总统计 - ✅ 资源归属父级项目:
project_id指向父级项目(parent_project_id = NULL)
1.2 项目场景表(project_locations)
职责:存储父级项目的场景定义,所有子项目共享
-- 创建表
CREATE TABLE project_locations (
location_id UUID PRIMARY KEY, -- 应用层生成 UUID v7
project_id UUID NOT NULL, -- 所属父级项目(parent_project_id = NULL 的项目)
name VARCHAR(255) NOT NULL,
location VARCHAR(255), -- 地点描述(如"室内/室外")
description TEXT,
order_index INTEGER DEFAULT 0,
has_tags BOOLEAN DEFAULT FALSE,
default_tag_id UUID, -- 默认标签ID
meta_data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT project_locations_project_name_unique UNIQUE(project_id, name)
);
-- 添加索引
CREATE INDEX idx_project_locations_project_id ON project_locations(project_id);
CREATE INDEX idx_project_locations_name ON project_locations(name);
-- 添加中文注释
COMMENT ON TABLE project_locations IS '项目场景表:存储父级项目的场景定义,所有子项目共享';
COMMENT ON COLUMN project_locations.location_id IS '场景ID(UUID v7,应用层生成)';
COMMENT ON COLUMN project_locations.project_id IS '所属父级项目ID(parent_project_id = NULL 的项目)';
COMMENT ON COLUMN project_locations.name IS '场景名称';
COMMENT ON COLUMN project_locations.location IS '地点描述(如"室内/室外")';
COMMENT ON COLUMN project_locations.description IS '场景描述';
COMMENT ON COLUMN project_locations.order_index IS '排序索引';
COMMENT ON COLUMN project_locations.has_tags IS '是否有标签';
COMMENT ON COLUMN project_locations.default_tag_id IS '默认标签ID(指向 project_element_tags)';
COMMENT ON COLUMN project_locations.meta_data IS '扩展元数据(JSONB格式)';
COMMENT ON COLUMN project_locations.created_at IS '创建时间(UTC)';
COMMENT ON COLUMN project_locations.updated_at IS '更新时间(UTC)';
1.3 项目道具表(project_props)
职责:存储父级项目的道具定义,所有子项目共享
-- 创建表
CREATE TABLE project_props (
prop_id UUID PRIMARY KEY, -- 应用层生成 UUID v7
project_id UUID NOT NULL, -- 所属父级项目(parent_project_id = NULL 的项目)
name VARCHAR(255) NOT NULL,
description TEXT,
order_index INTEGER DEFAULT 0,
has_tags BOOLEAN DEFAULT FALSE,
default_tag_id UUID, -- 默认标签ID
meta_data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT project_props_project_name_unique UNIQUE(project_id, name)
);
-- 添加索引
CREATE INDEX idx_project_props_project_id ON project_props(project_id);
CREATE INDEX idx_project_props_name ON project_props(name);
-- 添加中文注释
COMMENT ON TABLE project_props IS '项目道具表:存储父级项目的道具定义,所有子项目共享';
COMMENT ON COLUMN project_props.prop_id IS '道具ID(UUID v7,应用层生成)';
COMMENT ON COLUMN project_props.project_id IS '所属父级项目ID(parent_project_id = NULL 的项目)';
COMMENT ON COLUMN project_props.name IS '道具名称';
COMMENT ON COLUMN project_props.description IS '道具描述';
COMMENT ON COLUMN project_props.order_index IS '排序索引';
COMMENT ON COLUMN project_props.has_tags IS '是否有标签';
COMMENT ON COLUMN project_props.default_tag_id IS '默认标签ID(指向 project_element_tags)';
COMMENT ON COLUMN project_props.meta_data IS '扩展元数据(JSONB格式)';
COMMENT ON COLUMN project_props.created_at IS '创建时间(UTC)';
COMMENT ON COLUMN project_props.updated_at IS '更新时间(UTC)';
1.4 剧本资源引用表(screenplay_element_refs)
职责:记录剧本使用了哪些项目资源
-- 创建表
CREATE TABLE screenplay_element_refs (
ref_id UUID PRIMARY KEY, -- 应用层生成 UUID v7
screenplay_id UUID NOT NULL, -- 所属剧本(禁止外键约束)
element_type SMALLINT NOT NULL, -- 元素类型:1=角色, 2=场景, 3=道具
element_id UUID NOT NULL, -- 指向 project_characters/locations/props
order_index INTEGER DEFAULT 0,
-- 剧本特定的覆盖字段(可选)
override_description TEXT,
override_meta_data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT screenplay_element_refs_unique UNIQUE(screenplay_id, element_type, element_id)
);
-- 添加索引
CREATE INDEX idx_screenplay_element_refs_screenplay ON screenplay_element_refs(screenplay_id);
CREATE INDEX idx_screenplay_element_refs_element ON screenplay_element_refs(element_type, element_id);
CREATE INDEX idx_screenplay_element_refs_element_id ON screenplay_element_refs(element_id);
-- 添加中文注释
COMMENT ON TABLE screenplay_element_refs IS '剧本资源引用表:记录剧本使用了哪些项目资源';
COMMENT ON COLUMN screenplay_element_refs.ref_id IS '引用ID(UUID v7,应用层生成)';
COMMENT ON COLUMN screenplay_element_refs.screenplay_id IS '所属剧本ID';
COMMENT ON COLUMN screenplay_element_refs.element_type IS '元素类型:1=角色(character), 2=场景(location), 3=道具(prop)';
COMMENT ON COLUMN screenplay_element_refs.element_id IS '元素ID(指向 project_characters/locations/props)';
COMMENT ON COLUMN screenplay_element_refs.order_index IS '排序索引';
COMMENT ON COLUMN screenplay_element_refs.override_description IS '剧本特定的描述覆盖';
COMMENT ON COLUMN screenplay_element_refs.override_meta_data IS '剧本特定的元数据覆盖(JSONB格式)';
COMMENT ON COLUMN screenplay_element_refs.created_at IS '创建时间(UTC)';
关键设计说明:
- ⚠️ 禁止外键约束:
screenplay_id和element_id不使用REFERENCES - ⚠️ UUID v7 应用层生成:主键不使用
DEFAULT uuid_generate_v7() - ✅ 枚举使用 SMALLINT:
element_type使用 SMALLINT + IntEnum 模式 - ✅ 统一引用表设计:使用一个表而不是三个独立的表(简化设计)
- ✅ 支持剧本特定覆盖:
override_*字段支持剧本特定的描述(如角色在某集的特殊状态)
1.5 标签系统调整(project_element_tags)
现状分析:
- 当前
screenplay_element_tags表已经使用element_type(SMALLINT) +element_id(UUID) 的统一设计 - 表结构无需调整,但需要重命名表以反映其项目级归属
调整方案:重命名表并更新关联关系
-- 1. 重命名表(从剧本级改为项目级)
ALTER TABLE screenplay_element_tags RENAME TO project_element_tags;
-- 2. 更新表注释
COMMENT ON TABLE project_element_tags IS '项目元素标签表:存储项目资源的变体标签';
-- 3. 更新列注释(确保准确性)
COMMENT ON COLUMN project_element_tags.tag_id IS '标签ID(UUID v7,应用层生成)';
COMMENT ON COLUMN project_element_tags.screenplay_id IS '所属剧本ID(保留用于标签来源追溯)';
COMMENT ON COLUMN project_element_tags.element_type IS '元素类型:1=角色(character), 2=场景(location), 3=道具(prop)';
COMMENT ON COLUMN project_element_tags.element_id IS '元素ID(指向 project_characters/locations/props)';
COMMENT ON COLUMN project_element_tags.tag_label IS '标签名称';
COMMENT ON COLUMN project_element_tags.key IS '标签键值(用于唯一标识)';
COMMENT ON COLUMN project_element_tags.description IS '标签描述';
COMMENT ON COLUMN project_element_tags.display_order IS '显示顺序';
COMMENT ON COLUMN project_element_tags.meta_data IS '扩展元数据(JSONB格式)';
-- 4. 确认索引存在(如不存在则创建)
CREATE INDEX IF NOT EXISTS idx_project_element_tags_screenplay
ON project_element_tags(screenplay_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_element
ON project_element_tags(element_type, element_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_element_id
ON project_element_tags(element_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_key
ON project_element_tags(key);
关键设计说明:
- ✅ 无需修改字段:后端实际实现已经使用
element_type + element_id统一设计 - ✅ 保留 screenplay_id:用于追溯标签来源(哪个剧本的 AI 拆解生成的)
- ✅ 枚举使用 SMALLINT:
element_type已经是 SMALLINT + IntEnum 模式 - ✅ 禁止外键约束:
element_id不使用REFERENCES,在应用层保证引用完整性 - ⚠️ 迁移后更新关联:标签的
element_id需要从screenplay_*的 ID 更新为project_*的 ID
1.6 素材表调整(project_resources)
现状分析:
- 当前
project_resources表没有直接关联角色/场景/道具的字段 - 实际字段:
element_tag_id,element_name,tag_label(冗余字段) - 素材通过
element_tag_id关联到project_element_tags,再间接关联到资源
实际表结构:
-- project_resources 表的实际字段(无需调整)
CREATE TABLE project_resources (
project_resource_id UUID PRIMARY KEY,
project_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
type SMALLINT NOT NULL, -- 1=角色, 2=场景, 3=道具, 4=实拍
description TEXT,
-- 文件信息
file_url VARCHAR(500) NOT NULL,
thumbnail_url VARCHAR(500),
file_size INTEGER,
mime_type VARCHAR(100),
width INTEGER,
height INTEGER,
checksum VARCHAR(64) NOT NULL,
-- 标签关联(通过标签间接关联到资源)
element_tag_id UUID, -- 关联到 project_element_tags
element_name VARCHAR(255), -- 冗余字段:资源名称
tag_label VARCHAR(100), -- 冗余字段:标签名称
-- 来源(后期扩展)
source_resource_id UUID,
-- AI 生成
ai_job_id UUID,
meta_data JSON,
-- 使用统计
usage_count INTEGER DEFAULT 0,
-- 审计
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
deleted_at TIMESTAMPTZ
);
-- 确认索引存在
CREATE INDEX IF NOT EXISTS idx_project_resources_project_id
ON project_resources(project_id);
CREATE INDEX IF NOT EXISTS idx_project_resources_type
ON project_resources(type);
CREATE INDEX IF NOT EXISTS idx_project_resources_element_tag_id
ON project_resources(element_tag_id);
CREATE INDEX IF NOT EXISTS idx_project_resources_checksum
ON project_resources(checksum);
CREATE INDEX IF NOT EXISTS idx_project_resources_ai_job_id
ON project_resources(ai_job_id);
CREATE INDEX IF NOT EXISTS idx_project_resources_usage_count
ON project_resources(usage_count);
CREATE INDEX IF NOT EXISTS idx_project_resources_created_by
ON project_resources(created_by);
关键设计说明:
- ✅ 无需修改字段:表结构已经是项目级,通过
project_id关联 - ✅ 通过标签关联资源:
element_tag_id→project_element_tags→element_id(指向 project_characters/locations/props) - ✅ 实拍资源已支持:
type=4表示实拍资源,同样属于父级项目(project_id指向父级项目) - ✅ 冗余字段优化查询:
element_name和tag_label避免频繁 JOIN - ⚠️ 迁移后更新关联:
element_tag_id需要指向迁移后的项目级标签 - ⚠️ 实拍资源归属:
project_resources.project_id应指向父级项目(parent_project_id = NULL)
关联关系:
project_resources (素材)
↓ project_id(指向父级项目)
父级项目
↓ element_tag_id(可选,用于角色/场景/道具的变体)
project_element_tags (标签)
↓ element_id
project_characters/locations/props (资源)
实拍资源说明:
- 实拍资源(
type=4)由用户直接上传,不通过剧本 AI 拆解 - 实拍资源同样属于父级项目,所有子项目共享
- 实拍资源可以不关联标签(
element_tag_id = NULL)
2. API 设计
2.1 新增接口(项目级资源管理)
创建角色
POST /api/v1/projects/{project_id}/characters
Content-Type: application/json
# 请求体
{
"name": "琼恩·雪诺",
"description": "守夜人军团成员",
"roleType": 1,
"isOffscreen": false,
"metaData": {
"gender": "male",
"age": 25
}
}
# 响应(统一格式)
{
"success": true,
"code": 200,
"message": "角色创建成功",
"data": {
"characterId": "01936d8a-7b2c-7890-abcd-ef1234567890",
"projectId": "01936d8a-1234-7890-abcd-ef1234567890",
"name": "琼恩·雪诺",
"description": "守夜人军团成员",
"roleType": 1,
"isOffscreen": false,
"metaData": {
"gender": "male",
"age": 25
},
"createdAt": "2026-02-07T10:30:00Z",
"updatedAt": "2026-02-07T10:30:00Z"
},
"timestamp": "2026-02-07T10:30:00Z"
}
说明:
roleType使用数字:1=主角(main), 2=配角(supporting), 3=群演(extra)- 响应遵循统一格式:
{success, code, message, data, timestamp}
创建场景
POST /api/v1/projects/{project_id}/locations
Content-Type: application/json
# 请求体
{
"name": "临冬城",
"location": "室外",
"description": "史塔克家族的城堡"
}
# 响应(统一格式)
{
"success": true,
"code": 200,
"message": "场景创建成功",
"data": {
"locationId": "01936d8a-7b2c-7890-abcd-ef1234567891",
"projectId": "01936d8a-1234-7890-abcd-ef1234567890",
"name": "临冬城",
"location": "室外",
"description": "史塔克家族的城堡",
"createdAt": "2026-02-07T10:30:00Z",
"updatedAt": "2026-02-07T10:30:00Z"
},
"timestamp": "2026-02-07T10:30:00Z"
}
创建道具
POST /api/v1/projects/{project_id}/props
Content-Type: application/json
# 请求体
{
"name": "冰",
"description": "史塔克家族的传家宝剑"
}
# 响应(统一格式)
{
"success": true,
"code": 200,
"message": "道具创建成功",
"data": {
"propId": "01936d8a-7b2c-7890-abcd-ef1234567892",
"projectId": "01936d8a-1234-7890-abcd-ef1234567890",
"name": "冰",
"description": "史塔克家族的传家宝剑",
"createdAt": "2026-02-07T10:30:00Z",
"updatedAt": "2026-02-07T10:30:00Z"
},
"timestamp": "2026-02-07T10:30:00Z"
}
2.2 查询接口(保持兼容)
查询项目资源(现有接口,无需修改)
GET /api/v1/projects/{project_id}/resource-library/characters?page=1&pageSize=20
GET /api/v1/projects/{project_id}/resource-library/locations?page=1&pageSize=20
GET /api/v1/projects/{project_id}/resource-library/props?page=1&pageSize=20
响应格式(统一格式 + 分页):
{
"success": true,
"code": 200,
"message": "查询成功",
"data": {
"items": [
{
"characterId": "01936d8a-7b2c-7890-abcd-ef1234567890",
"projectId": "01936d8a-1234-7890-abcd-ef1234567890",
"name": "琼恩·雪诺",
"description": "守夜人军团成员",
"roleType": 1,
"isOffscreen": false,
"hasTags": true,
"tags": [...]
}
],
"total": 50,
"page": 1,
"pageSize": 20,
"totalPages": 3
},
"timestamp": "2026-02-07T10:30:00Z"
}
说明:数据来源从 screenplay_* 表改为 project_* 表,响应格式保持统一
2.3 剧本引用接口(新增)
为剧本添加资源引用
POST /api/v1/screenplays/{screenplay_id}/element-refs
Content-Type: application/json
# 请求体
{
"elementType": 1,
"elementId": "01936d8a-7b2c-7890-abcd-ef1234567890",
"overrideDescription": "在第一集中受伤"
}
# 响应(统一格式)
{
"success": true,
"code": 200,
"message": "资源引用创建成功",
"data": {
"refId": "01936d8a-7b2c-7890-abcd-ef1234567893",
"screenplayId": "01936d8a-1234-7890-abcd-ef1234567891",
"elementType": 1,
"elementId": "01936d8a-7b2c-7890-abcd-ef1234567890",
"overrideDescription": "在第一集中受伤",
"createdAt": "2026-02-07T10:30:00Z"
},
"timestamp": "2026-02-07T10:30:00Z"
}
说明:elementType 使用数字:1=角色(character), 2=场景(location), 3=道具(prop)
查询剧本使用的资源
GET /api/v1/screenplays/{screenplay_id}/elements?type=1
# 响应(统一格式)
{
"success": true,
"code": 200,
"message": "查询成功",
"data": {
"items": [
{
"refId": "01936d8a-7b2c-7890-abcd-ef1234567893",
"screenplayId": "01936d8a-1234-7890-abcd-ef1234567891",
"elementType": 1,
"elementId": "01936d8a-7b2c-7890-abcd-ef1234567890",
"elementName": "琼恩·雪诺",
"overrideDescription": "在第一集中受伤",
"createdAt": "2026-02-07T10:30:00Z"
}
]
},
"timestamp": "2026-02-07T10:30:00Z"
}
说明:查询参数 type 使用数字:1=角色, 2=场景, 3=道具
2.4 废弃接口(向后兼容)
以下接口保留但标记为废弃(Deprecated):
POST /api/v1/screenplays/{screenplay_id}/characters
POST /api/v1/screenplays/{screenplay_id}/locations
POST /api/v1/screenplays/{screenplay_id}/props
处理方式:
- 接口内部自动转换为项目级资源创建
- 返回警告头:
X-Deprecated: true - 文档标注废弃时间和替代方案
响应示例:
HTTP/1.1 200 OK
X-Deprecated: true
X-Deprecated-Replacement: POST /api/v1/projects/{project_id}/characters
{
"success": true,
"code": 200,
"message": "角色创建成功(警告:此接口已废弃,请使用项目级接口)",
"data": {
"characterId": "01936d8a-7b2c-7890-abcd-ef1234567890",
"projectId": "01936d8a-1234-7890-abcd-ef1234567890",
"name": "琼恩·雪诺",
"roleType": 1
},
"timestamp": "2026-02-07T10:30:00Z"
}
2.5 时序图
2.5.1 创建父级项目资源流程
sequenceDiagram
participant User as 用户
participant Frontend as 前端
participant API as API 层
participant Service as ProjectResourceService
participant DB as 数据库
User->>Frontend: 在父级项目中点击"新增角色"
Frontend->>Frontend: 打开 AddResourcePopover
User->>Frontend: 填写角色信息(name, roleType, description)
User->>Frontend: 点击"确认"
Frontend->>API: POST /api/v1/projects/{parent_project_id}/characters
Note over Frontend,API: 请求体: {name, roleType: 1, description}<br/>parent_project_id 是父级项目ID
API->>Service: create_character(parent_project_id, data)
Service->>Service: 生成 UUID v7 (character_id)
Service->>Service: 验证 parent_project_id 存在且为父级项目
Note over Service: 确认 parent_project_id = NULL
Service->>DB: INSERT INTO project_characters
Note over Service,DB: project_id = parent_project_id<br/>资源归属父级项目
DB-->>Service: 返回创建结果
Service-->>API: 返回角色数据
API-->>Frontend: 统一响应格式
Note over API,Frontend: {success, code, message, data, timestamp}
Frontend->>Frontend: 更新父级项目资源列表
Frontend-->>User: 显示创建成功提示
说明:
- 资源创建在父级项目层级(
parent_project_id = NULL) - 所有子项目自动共享父级项目的资源
- 前端需要确保传递的是父级项目ID,而非子项目ID
2.5.2 分镜关联父级项目资源流程
sequenceDiagram
participant User as 用户
participant Frontend as 前端(分镜编辑器)
participant API as API 层
participant StoryboardService as StoryboardService
participant TagService as TagService
participant DB as 数据库
User->>Frontend: 在分镜编辑器中点击"添加角色"
Frontend->>API: GET /api/v1/projects/{parent_project_id}/resource-library/characters
Note over Frontend,API: 查询父级项目的角色资源
API->>StoryboardService: ResourceLibraryService.get_characters(parent_project_id)
StoryboardService->>DB: SELECT FROM project_characters<br/>WHERE project_id = parent_project_id
DB-->>StoryboardService: 返回父级项目的角色列表
StoryboardService-->>API: 返回角色数据
API-->>Frontend: 统一响应格式(分页)
Frontend-->>User: 显示父级项目的角色选择列表
User->>Frontend: 选择角色"琼恩·雪诺"
Frontend->>API: GET /api/v1/characters/{character_id}/tags
Note over Frontend,API: 查询角色的变体标签(如"少年"、"青年")
API->>TagService: get_tags_by_element(character_id)
TagService->>DB: SELECT FROM project_element_tags<br/>WHERE element_id = character_id
DB-->>TagService: 返回标签列表
TagService-->>API: 返回标签数据
API-->>Frontend: 统一响应格式
Frontend-->>User: 显示变体标签选择列表(少年、青年、中年)
User->>Frontend: 选择标签"少年",填写动作"大笑"、位置"center"
Frontend->>API: POST /api/v1/storyboards/{storyboard_id}/items
Note over Frontend,API: 请求体: {<br/> item_type: 1,<br/> element_tag_id: tag-001,<br/> action_description: "大笑",<br/> spatial_position: "center"<br/>}
API->>StoryboardService: add_element_to_storyboard()
StoryboardService->>StoryboardService: 生成 UUID v7 (item_id)
StoryboardService->>StoryboardService: 验证 element_tag_id 存在于父级项目
Note over StoryboardService: 确认标签归属父级项目的资源
StoryboardService->>TagService: get_tag_info(element_tag_id)
TagService->>DB: SELECT FROM project_element_tags<br/>WHERE tag_id = element_tag_id
DB-->>TagService: 返回标签信息(element_name, tag_label, cover_url)
TagService-->>StoryboardService: 返回标签数据
StoryboardService->>DB: INSERT INTO storyboard_items
Note over StoryboardService,DB: storyboard_id = 分镜ID<br/>item_type = 1(ElementTag)<br/>element_tag_id = 标签ID<br/>element_name = "琼恩·雪诺"(冗余)<br/>tag_label = "少年"(冗余)<br/>action_description = "大笑"<br/>spatial_position = "center"
DB-->>StoryboardService: 返回关联结果
StoryboardService-->>API: 返回关联数据
API-->>Frontend: 统一响应格式
Frontend->>Frontend: 更新分镜元素列表
Frontend-->>User: 显示关联成功
说明:
- 用户在分镜编辑器中操作,而非剧本编辑器
- 分镜通过
storyboard_items表关联标签,而非直接关联剧本 - 关联流程:
- 查询父级项目的角色资源(
project_characters) - 查询角色的变体标签(
project_element_tags) - 用户选择标签并填写关联属性(动作、位置)
- 创建
storyboard_items记录,关联分镜和标签
- 查询父级项目的角色资源(
- 冗余字段优化:
element_name和tag_label避免频繁 JOIN - 关联属性:
action_description(动作)、spatial_position(位置)为 AI 视频生成提供精准 Prompt
2.5.3 AI 拆解剧本并存储到父级项目流程
前置条件:
- 用户创建了父级项目(
parent_project_id = NULL) - 用户上传剧本,自动创建子项目(
parent_project_id = 父级项目ID) - 剧本文件已解析为 Markdown 文本(通过
POST /api/v1/screenplays/upload-and-parse)
sequenceDiagram
participant User as 用户
participant Frontend as 前端
participant API as API 层
participant ScreenplayService as ScreenplayService
participant ProjectService as ProjectService
participant AIService as AIService
participant Celery as Celery Worker
participant DB as 数据库
User->>Frontend: 触发 AI 拆解剧本
Frontend->>API: POST /api/v1/screenplays/{screenplay_id}/parse
API->>ScreenplayService: parse_screenplay(screenplay_id)
ScreenplayService->>DB: 查询剧本和所属项目
DB-->>ScreenplayService: 返回 screenplay + subproject
ScreenplayService->>ProjectService: get_parent_project_id(subproject_id)
ProjectService->>DB: SELECT parent_project_id FROM projects<br/>WHERE project_id = subproject_id
DB-->>ProjectService: 返回 parent_project_id
ProjectService-->>ScreenplayService: 返回父级项目ID
Note over ScreenplayService: 确认资源将存储到父级项目
ScreenplayService->>AIService: parse_screenplay()
Note over ScreenplayService,AIService: 传递 screenplay_id, parent_project_id, content
AIService->>Celery: 创建异步任务
Note over AIService,Celery: 调用 AI 模型提取元素
AIService-->>ScreenplayService: 返回 task_id
ScreenplayService-->>API: 返回 202 Accepted
API-->>Frontend: 返回任务状态
Frontend-->>User: 显示"解析中..."
Celery->>Celery: AI 模型分析剧本
Celery->>Celery: 提取角色、场景、道具、标签
Celery->>ScreenplayService: store_parsed_elements()
Note over Celery,ScreenplayService: 传递解析结果 + parent_project_id
loop 处理每个角色
ScreenplayService->>ScreenplayService: 生成 UUID v7 (character_id)
ScreenplayService->>DB: INSERT INTO project_characters<br/>WHERE project_id = parent_project_id<br/>ON CONFLICT (project_id, name) DO UPDATE
Note over ScreenplayService,DB: 资源存储到父级项目<br/>重名角色累加统计数据
DB-->>ScreenplayService: 返回 character_id(新建或已存在)
ScreenplayService->>ScreenplayService: 生成 UUID v7 (ref_id)
ScreenplayService->>DB: INSERT INTO screenplay_element_refs
Note over ScreenplayService,DB: screenplay_id = 子项目的剧本<br/>element_type = 1<br/>element_id = 父级项目的角色
end
loop 处理场景和道具
ScreenplayService->>DB: 同理处理 locations (element_type=2) 和 props (element_type=3)
Note over ScreenplayService,DB: 存储到父级项目,创建剧本引用
end
loop 处理标签
ScreenplayService->>DB: INSERT INTO project_element_tags
Note over ScreenplayService,DB: 标签关联到父级项目的资源<br/>element_id 指向父级项目资源
end
ScreenplayService->>DB: UPDATE screenplays SET parsing_status='completed'
ScreenplayService->>DB: UPDATE project_characters SET line_count += X, appearance_count += Y
Note over ScreenplayService,DB: 更新父级项目资源的统计数据
Note over ScreenplayService: 自动创建分镜并关联标签
loop 处理每个分镜
ScreenplayService->>ScreenplayService: 生成 UUID v7 (storyboard_id)
ScreenplayService->>DB: INSERT INTO storyboards
Note over ScreenplayService,DB: 创建分镜记录(标题、描述、景别、运镜等)
DB-->>ScreenplayService: 返回 storyboard_id
loop 处理分镜的角色/场景/道具
ScreenplayService->>ScreenplayService: 生成 UUID v7 (item_id)
ScreenplayService->>DB: INSERT INTO storyboard_items
Note over ScreenplayService,DB: item_type = 1(ElementTag)<br/>element_tag_id = 标签ID<br/>element_name = 资源名称(冗余)<br/>tag_label = 标签名称(冗余)<br/>action_description = 动作<br/>spatial_position = 位置
end
end
ScreenplayService-->>Celery: 返回存储结果
Celery-->>Frontend: WebSocket 通知解析完成
Frontend-->>User: 显示拆解成功,展示父级项目资源列表和分镜列表
关键设计说明:
-
父子项目关系确认
- 剧本属于子项目(
screenplay.project_id = subproject_id) - 子项目关联父级项目(
subproject.parent_project_id = parent_project_id) - 资源存储到父级项目(
project_characters.project_id = parent_project_id)
- 剧本属于子项目(
-
资源去重与累加策略
-- AI 拆解时的插入逻辑(累加统计数据) INSERT INTO project_characters ( character_id, project_id, name, description, role_type, line_count, appearance_count, meta_data ) VALUES ( '01936d8a-7b2c-7890-abcd-ef1234567890', 'parent-project-id', -- 父级项目ID '琼恩·雪诺', 'AI 提取的描述', 1, 10, -- 本剧本的台词数 5, -- 本剧本的出场次数 '{"gender": "male", "age": 25}' ) ON CONFLICT (project_id, name) DO UPDATE SET line_count = project_characters.line_count + EXCLUDED.line_count, appearance_count = project_characters.appearance_count + EXCLUDED.appearance_count, updated_at = NOW() RETURNING character_id; -
剧本引用关系
- 子项目的剧本通过
screenplay_element_refs引用父级项目的资源 screenplay_id指向子项目的剧本element_id指向父级项目的资源(角色/场景/道具)
- 子项目的剧本通过
-
标签关联
- 标签存储到
project_element_tags表 element_id指向父级项目的资源- 多个子项目的剧本可以共享同一个标签
- 标签存储到
-
统计数据汇总
- 父级项目资源的
line_count和appearance_count汇总所有子项目剧本的数据 - 每次 AI 拆解新剧本时,累加统计数据
- 父级项目资源的
-
标签存储改造
- 当前实现:
ScreenplayTagService.store_tags()存储到screenplay_element_tags - 新架构实现:存储到
project_element_tags,关联到父级项目资源 - 标签的
element_id指向project_characters/locations/props的主键
- 当前实现:
-
分镜自动创建
- AI 拆解剧本后,自动创建分镜(
storyboards表) - 自动创建分镜元素关联(
storyboard_items表) - 分镜通过
element_tag_id关联到标签,而非直接关联资源 - 关联属性(动作、位置)为 AI 视频生成提供精准 Prompt
- AI 拆解剧本后,自动创建分镜(
-
数据流总结
- AI 拆解的资源直接存储到父级项目资源表
- 自动创建剧本引用关系(
screenplay_element_refs) - 自动创建分镜并关联标签(
storyboards+storyboard_items) - 重名资源自动去重并累加统计数据,多个剧本共享同一资源
- 标签关联到父级项目资源,支持跨剧本复用
- 如需区分同名资源,可在
meta_data中添加 AI 生成的唯一标识符
3. AI 拆解剧本的存储策略
3.1 业务场景
当用户上传剧本后,AI 会自动拆解剧本内容,提取以下信息:
- 角色:姓名、性别、年龄、角色类型、外貌描述等
- 场景:场景名称、室内/室外、时间、氛围等
- 道具:道具名称、类别、重要性、功能等
在新的项目级资源架构下,AI 拆解的资源需要合理存储,避免重复创建。
3.2 存储策略
3.2.1 直接存储到项目级资源表
设计:改造 ScreenplayService.store_parsed_elements() 方法,将 AI 拆解的资源存储到 project_characters/locations/props 表
优点:
- 架构统一,所有资源都在项目级管理(无论单集还是多集)
- 自动去重,避免重复创建同名资源(多集项目)
- 多个剧本可以共享同一资源(多集项目、续集、番外篇)
- 支持动态扩展:单集项目上传第2个剧本时,自动更新
episode_count,资源无缝共享 - 保持方法签名不变,与现有 Celery Worker 兼容
实现:
# ScreenplayService.store_parsed_elements() 方法改造
async def store_parsed_elements(
self,
screenplay_id: UUID,
parsed_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
存储 AI 解析结果到父级项目资源表
Args:
screenplay_id: 剧本 ID
parsed_data: AI 解析结果,包含 characters, locations, props, tags
Returns:
包含资源 ID 映射和标签 ID 映射的字典
"""
try:
# 0. 获取剧本所属的父级项目 ID
screenplay = await self.repository.get_by_id(screenplay_id)
subproject = await self.project_repository.get_by_id(screenplay.project_id)
parent_project_id = subproject.parent_project_id # 获取父级项目 ID
# 1. 存储角色到父级项目资源表
character_id_map = {}
for character_data in parsed_data.get('characters', []):
character_id = generate_uuid_v7()
# 尝试插入到父级项目,如果重名则跳过
result = await self.db.execute(
"""
INSERT INTO project_characters (
character_id, project_id, name, description,
role_type, is_offscreen, meta_data, order_index
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (project_id, name) DO NOTHING
RETURNING character_id
""",
character_id, parent_project_id, character_data['name'],
character_data.get('description'), character_data.get('role_type', 2),
character_data.get('is_offscreen', False),
character_data.get('meta_data', {}),
character_data.get('order_index', 0)
)
# 如果冲突,查询已存在的资源
if not result:
existing = await self.db.fetch_one(
"""
SELECT character_id FROM project_characters
WHERE project_id = $1 AND name = $2
""",
parent_project_id, character_data['name']
)
character_id = existing['character_id']
character_id_map[character_data['name']] = character_id
# 创建剧本引用关系(子项目的剧本引用父级项目的资源)
ref_id = generate_uuid_v7()
await self.db.execute(
"""
INSERT INTO screenplay_element_refs (
ref_id, screenplay_id, element_type, element_id
)
VALUES ($1, $2, 1, $3)
""",
ref_id, screenplay_id, character_id
)
# 2. 存储场景到父级项目资源表
location_id_map = {}
for location_data in parsed_data.get('locations', []):
location_id = generate_uuid_v7()
result = await self.db.execute(
"""
INSERT INTO project_locations (
location_id, project_id, name, location, description, meta_data
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, name) DO NOTHING
RETURNING location_id
""",
location_id, parent_project_id, location_data['name'],
location_data.get('location'), location_data.get('description'),
location_data.get('meta_data', {})
)
if not result:
existing = await self.db.fetch_one(
"SELECT location_id FROM project_locations WHERE project_id = $1 AND name = $2",
parent_project_id, location_data['name']
)
location_id = existing['location_id']
location_id_map[location_data['name']] = location_id
# 创建剧本引用关系
ref_id = generate_uuid_v7()
await self.db.execute(
"INSERT INTO screenplay_element_refs (ref_id, screenplay_id, element_type, element_id) VALUES ($1, $2, 2, $3)",
ref_id, screenplay_id, location_id
)
# 3. 存储道具到父级项目资源表
prop_id_map = {}
for prop_data in parsed_data.get('props', []):
prop_id = generate_uuid_v7()
result = await self.db.execute(
"""
INSERT INTO project_props (
prop_id, project_id, name, description, meta_data
)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, name) DO NOTHING
RETURNING prop_id
""",
prop_id, parent_project_id, prop_data['name'],
prop_data.get('description'), prop_data.get('meta_data', {})
)
if not result:
existing = await self.db.fetch_one(
"SELECT prop_id FROM project_props WHERE project_id = $1 AND name = $2",
parent_project_id, prop_data['name']
)
prop_id = existing['prop_id']
prop_id_map[prop_data['name']] = prop_id
# 创建剧本引用关系
ref_id = generate_uuid_v7()
await self.db.execute(
"INSERT INTO screenplay_element_refs (ref_id, screenplay_id, element_type, element_id) VALUES ($1, $2, 3, $3)",
ref_id, screenplay_id, prop_id
)
# 4. 存储标签到父级项目标签表
from app.services.project_element_tag_service import ProjectElementTagService
tag_service = ProjectElementTagService(self.db)
tag_id_maps = await tag_service.store_tags(
parent_project_id,
parsed_data,
character_id_map,
location_id_map,
prop_id_map
)
# 5. 自动创建分镜并关联标签
from app.services.storyboard_service import StoryboardService
storyboard_service = StoryboardService(self.db)
storyboard_ids = await storyboard_service.create_storyboards_from_ai(
project_id=parent_project_id,
screenplay_id=screenplay_id,
storyboards_data=parsed_data.get('storyboards', []),
tag_id_maps=tag_id_maps
)
# 6. 更新剧本统计(注意:这里不再更新 character_count 等字段,因为资源已迁移到项目级)
await self.repository.update(screenplay_id, {
'parsing_status': 'completed'
})
await self.db.commit()
return {
'character_id_map': character_id_map,
'location_id_map': location_id_map,
'prop_id_map': prop_id_map,
'tag_id_maps': tag_id_maps,
'storyboard_ids': storyboard_ids
}
except Exception as e:
await self.db.rollback()
await self.repository.update(screenplay_id, {'parsing_status': 'failed'})
raise Exception(f'数据存储失败: {str(e)}')
3.2.2 重名资源处理策略
场景 1:同一项目的不同剧本提取出同名角色
项目:《权力的游戏》第一季
├─ 剧本 1(第1集)→ AI 提取:琼恩·雪诺
├─ 剧本 2(第2集)→ AI 提取:琼恩·雪诺(同一人)
└─ 剧本 3(第3集)→ AI 提取:琼恩·雪诺(同一人)
处理:
- 第一次提取时,创建项目资源
project_characters.name = "琼恩·雪诺" - 后续提取时,检测到重名,跳过创建,直接引用已存在的资源
- 三个剧本的
screenplay_element_refs都指向同一个character_id
场景 2:同一项目的不同剧本提取出同名但不同的角色
项目:《都市剧集》(episode_count = 10)
├─ 剧本 1(第1集)→ AI 提取:张三(主角,30岁男性)
└─ 剧本 2(第2集)→ AI 提取:张三(配角,50岁女性)
处理:
- 第一次提取时,创建项目资源
project_characters.name = "张三" - 第二次提取时,检测到重名,但实际是不同的人
- 解决方案 A:在
meta_data中添加唯一标识符(如 AI 生成的 entity_id) - 解决方案 B:在名称中添加后缀(如"张三(第2集)")
- 解决方案 C:提供人工审核界面,让用户合并或分离资源
推荐:使用解决方案 A + C 的组合:
# AI 提取时生成唯一标识符
character_data = {
"name": "张三",
"meta_data": {
"ai_entity_id": "char_abc123", # AI 生成的唯一标识
"gender": "male",
"age": 30
}
}
# 插入时先检查 ai_entity_id
existing = await db.fetch_one(
"""
SELECT character_id FROM project_characters
WHERE project_id = $1
AND (name = $2 OR meta_data->>'ai_entity_id' = $3)
""",
project_id, character_data.name, character_data.meta_data.ai_entity_id
)
3.3 与现有架构的兼容性
3.3.1 更新 ADR 003 的数据流
原数据流(ADR 003):
用户上传剧本 → AI 拆解 → 存储到 screenplay_characters/locations/props
新数据流(ADR 012):
用户上传剧本
↓
AI 拆解剧本
↓
存储到 project_characters/locations/props(项目级)
↓
创建 screenplay_element_refs(剧本引用)
3.3.2 迁移现有 AI 拆解逻辑
步骤:
- 更新 AI 拆解服务,改为写入项目级资源表
- 添加重名检测和去重逻辑
- 自动创建剧本引用关系
- 保留旧的剧本级资源表,用于数据迁移
3.4 实现建议
-
AI 拆解服务更新
- 修改
AIParseService.parse_screenplay()方法 - 添加
ProjectResourceService.create_or_get_character()方法 - 添加
ScreenplayService.add_element_ref()方法
- 修改
-
去重策略配置
- 提供配置项:
AI_PARSE_DEDUP_STRATEGYstrict:严格去重,同名必定共享smart:智能去重,基于 ai_entity_idmanual:人工审核,提供合并界面
- 提供配置项:
-
人工审核界面
- 在前端提供"资源合并"功能
- 用户可以手动合并重复的资源
- 合并时自动更新所有引用关系
数据迁移策略
3.1 迁移原则
- 零停机迁移:使用蓝绿部署,不影响线上服务
- 数据完整性:迁移前后数据一致性校验
- 可回滚:保留旧表结构,支持快速回滚
- 分阶段执行:按项目分批迁移(先测试项目,再生产项目)
3.2 迁移步骤
Phase 1:创建新表结构(无数据影响)
-- 1. 创建项目级资源表
CREATE TABLE project_characters (...);
CREATE TABLE project_locations (...);
CREATE TABLE project_props (...);
-- 2. 创建剧本引用表
CREATE TABLE screenplay_element_refs (...);
-- 3. 添加索引
CREATE INDEX ...;
风险:无,仅创建新表
Phase 2:数据迁移(只读操作)
-- 迁移角色数据
INSERT INTO project_characters (
character_id, project_id, name, description,
character_image_url, role_type, is_offscreen,
line_count, appearance_count, order_index,
has_tags, default_tag_id, meta_data,
created_at, updated_at
)
SELECT
sc.character_id,
s.project_id, -- 从剧本获取项目ID
sc.name,
sc.description,
sc.character_image_url,
sc.role_type,
sc.is_offscreen,
sc.line_count, -- 保留统计字段
sc.appearance_count, -- 保留统计字段
sc.order_index,
sc.has_tags,
sc.default_tag_id,
sc.meta_data,
sc.created_at,
sc.updated_at
FROM screenplay_characters sc
JOIN screenplays s ON s.screenplay_id = sc.screenplay_id
ON CONFLICT (project_id, name) DO UPDATE SET
-- 重名角色时,累加统计数据
line_count = project_characters.line_count + EXCLUDED.line_count,
appearance_count = project_characters.appearance_count + EXCLUDED.appearance_count;
-- 迁移场景数据
INSERT INTO project_locations (
location_id, project_id, name, location, description,
order_index, has_tags, default_tag_id, meta_data,
created_at, updated_at
)
SELECT
sl.location_id,
s.project_id,
sl.name,
sl.location,
sl.description,
sl.order_index,
sl.has_tags,
sl.default_tag_id,
sl.meta_data,
sl.created_at,
sl.updated_at
FROM screenplay_locations sl
JOIN screenplays s ON s.screenplay_id = sl.screenplay_id
ON CONFLICT (project_id, name) DO NOTHING;
-- 迁移道具数据
INSERT INTO project_props (
prop_id, project_id, name, description,
order_index, has_tags, default_tag_id, meta_data,
created_at, updated_at
)
SELECT
sp.prop_id,
s.project_id,
sp.name,
sp.description,
sp.order_index,
sp.has_tags,
sp.default_tag_id,
sp.meta_data,
sp.created_at,
sp.updated_at
FROM screenplay_props sp
JOIN screenplays s ON s.screenplay_id = sp.screenplay_id
ON CONFLICT (project_id, name) DO NOTHING;
-- 创建剧本引用关系(角色)
INSERT INTO screenplay_element_refs (
ref_id, screenplay_id, element_type, element_id, order_index, created_at
)
SELECT
gen_random_uuid(), -- 生成新的引用ID
sc.screenplay_id,
1, -- element_type: 1=角色(character)
sc.character_id,
sc.order_index,
sc.created_at
FROM screenplay_characters sc;
-- 创建剧本引用关系(场景)
INSERT INTO screenplay_element_refs (
ref_id, screenplay_id, element_type, element_id, order_index, created_at
)
SELECT
gen_random_uuid(),
sl.screenplay_id,
2, -- element_type: 2=场景(location)
sl.location_id,
sl.order_index,
sl.created_at
FROM screenplay_locations sl;
-- 创建剧本引用关系(道具)
INSERT INTO screenplay_element_refs (
ref_id, screenplay_id, element_type, element_id, order_index, created_at
)
SELECT
gen_random_uuid(),
sp.screenplay_id,
3, -- element_type: 3=道具(prop)
sp.prop_id,
sp.order_index,
sp.created_at
FROM screenplay_props sp;
处理重名资源:
- 多个剧本有同名角色时,只保留第一个
- 其他剧本通过引用表关联到同一个项目资源
- 记录冲突日志供人工审核
风险:中等,需要处理数据冲突
Phase 3:重命名标签表(低风险操作)
-- 1. 重命名标签表(从剧本级改为项目级)
ALTER TABLE screenplay_element_tags RENAME TO project_element_tags;
-- 2. 更新表和列注释
COMMENT ON TABLE project_element_tags IS '项目元素标签表:存储项目资源的变体标签';
COMMENT ON COLUMN project_element_tags.screenplay_id IS '所属剧本ID(保留用于标签来源追溯)';
COMMENT ON COLUMN project_element_tags.element_id IS '元素ID(指向 project_characters/locations/props)';
-- 3. 确认索引存在
CREATE INDEX IF NOT EXISTS idx_project_element_tags_screenplay
ON project_element_tags(screenplay_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_element
ON project_element_tags(element_type, element_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_element_id
ON project_element_tags(element_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_key
ON project_element_tags(key);
关键说明:
- ✅ 无需更新 element_id:因为迁移时保持了资源 ID 不变(character_id/location_id/prop_id)
- ✅ 保留 screenplay_id:用于追溯标签来源(哪个剧本的 AI 拆解生成的)
- ✅ 仅重命名表:反映标签的项目级归属
- ✅ 素材表无需调整:
project_resources已经是项目级,通过element_tag_id关联标签
风险:低,仅重命名表和更新注释
Phase 4:切换服务层(代码部署)
- 部署新版本代码(读取新表)
- 监控错误率和性能
- 确认无问题后,标记旧接口为废弃
回滚方案:
- 保留旧表数据不删除
- 代码支持双写(同时写入新旧表)
- 发现问题立即回滚到旧版本
影响分析
正面影响
-
业务逻辑清晰
- 资源归属符合业务直觉(项目级管理)
- 多集项目可以共享角色/场景/道具
- 前端实现简化(无需处理 screenplay_id)
-
数据一致性提升
- 避免同一角色在多个剧本中重复创建
- 统一的资源管理入口
- 减少数据冗余
-
扩展性增强
- 支持项目级资源库
- 支持资源跨剧本复用
- 为未来的资源市场功能打基础
负面影响
-
迁移成本
- 数据库结构变更(新增3个表)
- 数据迁移脚本开发和测试
- 前后端代码调整
- 预计工作量:15-20人天
-
兼容性风险
- 旧接口需要保持兼容
- 可能影响现有功能
- 需要充分测试
-
查询复杂度
- 剧本查询资源需要 JOIN 引用表
- 可能影响查询性能(需要索引优化)
缓解措施
-
性能优化
- 为所有外键创建索引
- 使用 Repository 层封装复杂查询
- 对高频查询结果进行缓存
-
风险控制
- 分阶段迁移(先测试环境,再生产环境)
- 保留旧表数据,支持快速回滚
- 充分的单元测试和集成测试
-
兼容性保证
- 旧接口保持可用(标记为废弃)
- 提供迁移指南和示例代码
- 设置过渡期(3-6个月)
实现计划
Phase 1:数据库迁移(2-3天)
任务:
- 编写数据库迁移脚本
- 创建
project_characters/locations/props表 - 创建
screenplay_element_refs表 - 创建索引和约束
- 创建
- 在测试环境执行迁移
- 数据一致性校验
负责人:后端团队 风险:低
Phase 2:后端服务层(5-7天)
任务:
- 创建 Repository 层
ProjectCharacterRepositoryProjectLocationRepositoryProjectPropRepositoryScreenplayElementRefRepository
- 创建 Service 层
ProjectResourceService.create_character/location/prop()ScreenplayService.add_element_ref()
- 更新现有 Service
ResourceLibraryService改为查询新表
- 编写单元测试
负责人:后端团队 风险:中等
Phase 3:API 接口开发(3-4天)
任务:
- 实现新增接口
POST /api/v1/projects/{project_id}/charactersPOST /api/v1/projects/{project_id}/locationsPOST /api/v1/projects/{project_id}/props
- 更新查询接口(改为查询新表)
GET /api/v1/projects/{project_id}/resource-library/*
- 标记旧接口为废弃
POST /api/v1/screenplays/{screenplay_id}/characters(Deprecated)
- 编写 API 文档
- 集成测试
负责人:后端团队 风险:中等
Phase 4:前端适配(3-4天)
任务:
- 更新 API Service
client/src/services/api/project-resources.ts(新增)- 添加
createCharacter/Location/Prop方法
- 更新 Hooks
client/src/hooks/api/useProjectResources.ts(新增)- 添加
useCreateCharacter/Location/Prophooks
- 更新组件
ProjectResourcePanel.tsx实现handleCreateResource
- 类型定义更新
client/src/types/resource.ts
- 前端测试
负责人:前端团队 风险:低
Phase 5:数据迁移执行(1天)
任务:
- 在生产环境执行迁移脚本
- 数据一致性校验
- 监控错误日志
- 性能监控
负责人:运维团队 + 后端团队 风险:高
Phase 6:监控与优化(持续)
任务:
- 监控新接口的调用情况
- 监控查询性能
- 收集用户反馈
- 优化索引和查询
负责人:全团队 风险:低
screenplay_element_refs 的必要性分析
核心问题
用户质疑:既然资源和标签都归属父级项目,分镜又直接关联标签,那么 screenplay_element_refs(剧本引用资源)是否还有必要?
深度分析结论
screenplay_element_refs 是必要的,应该保留。
不同的数据维度
screenplay_element_refs 和 storyboard_items 是两种完全不同维度的数据:
| 维度 | screenplay_element_refs | storyboard_items |
|---|---|---|
| 数据来源 | AI 解析剧本文本 | 用户创建分镜时关联 |
| 数据性质 | 计划层面("剧本说要用什么") | 执行层面("分镜实际用了什么") |
| 数据稳定性 | 静态(除非重新解析剧本) | 动态(随分镜创建/删除变化) |
| 数据完整性 | 完整(包含所有剧本提到的资源) | 可能不完整(只包含已创建分镜的资源) |
类比:
- screenplay_element_refs = 购物清单(计划买什么)
- storyboard_items = 购物车(实际买了什么)
- 两者的差异 = 项目完成度和偏离度
性能优势
查询剧本资源清单的性能对比:
-- 使用 screenplay_element_refs(单表查询)
SELECT * FROM screenplay_element_refs
WHERE screenplay_id = '剧本ID';
-- 不使用(4 表 JOIN,性能差 10-100 倍)
SELECT DISTINCT
et.element_id,
pc.name,
pc.cover_url
FROM storyboards sb
JOIN storyboard_items si ON si.storyboard_id = sb.storyboard_id
JOIN screenplay_element_tags et ON et.tag_id = si.element_tag_id
JOIN project_characters pc ON pc.character_id = et.element_id
WHERE sb.screenplay_id = '剧本ID'
AND et.element_type = 1;
业务价值
screenplay_element_refs 支持以下关键业务场景:
- 资源准备:项目启动时,根据剧本清单提前准备素材(主动准备)
- 项目管理:跟踪项目完成度(对比"剧本要求"和"实际完成")
- 质量控制:发现用户偏离剧本的内容
- 剧本编辑器:快速显示"这个剧本用了哪些角色、场景、道具"
- 剧本导出:导出剧本时,附带"角色表"、"场景表"、"道具表"
数据完整性
关键场景:用户删除分镜后
-
使用 screenplay_element_refs:
- 剧本资源清单仍然保留
- 用户可以知道"还有哪些内容未制作"
- 可以恢复删除的分镜
-
不使用:
- 删除分镜后,对应的资源关联也被删除
- 无法知道"这个资源曾经在剧本中出现"
- 数据丢失,无法恢复
设计原则
数据来源
screenplay_element_refs 只能由 AI 创建,不允许手动修改
class ScreenplayElementRef(SQLModel, table=True):
__tablename__ = "screenplay_element_refs"
ref_id: UUID = Field(primary_key=True)
screenplay_id: UUID = Field(foreign_key='screenplays.screenplay_id')
element_type: int = Field(sa_column=Column(SmallInteger))
element_id: UUID
tag_id: Optional[UUID] = None
# 数据来源固定为 AI 解析
source: str = Field(default='ai_parse')
created_at: datetime
updated_at: datetime
创建时机
在 AI 拆解剧本流程中创建:
AI 解析剧本
→ ScreenplayTagService.store_tags()
→ ScreenplayElementRefService.store_refs() ← 创建 screenplay_element_refs
→ StoryboardService.create_storyboards_from_ai()
用户手动添加资源的处理
场景:用户在父项目资源列表中手动添加角色(如"猪八戒")
处理方式:
- 资源归属父项目(project_characters)
- 用户可以直接在分镜中使用(创建 storyboard_items)
- 不自动添加到 screenplay_element_refs
- 查询对比时,发现"猪八戒"是"剧本外的资源"(这是有价值的信息)
如需"加入剧本":
- 方式 1:修改剧本文本,加入"猪八戒"的描述,重新触发 AI 解析
- 方式 2:提供"手动关联"功能(可选,需要标记来源)
工作流图:
父项目资源库(project_characters)
├─ 孙悟空(AI 解析剧本时创建)
├─ 李四(AI 解析剧本时创建)
└─ 猪八戒(用户手动添加)← 新增
↓
↓ 用户在分镜中选择
↓
storyboard_items(分镜关联)
├─ 分镜1 → 孙悟空(少年)
├─ 分镜2 → 李四(成年)
└─ 分镜3 → 猪八戒(成年)← 使用手动添加的资源
↓
↓ 查询对比
↓
screenplay_element_refs(剧本清单)
├─ 孙悟空(少年)
├─ 孙悟空(青年)
└─ 李四(成年)
对比结果:
- 猪八戒不在 screenplay_element_refs 中
- 说明这是"剧本外的资源"
- 前端可以标记为"⚠️ 不在剧本中"
API 设计
对比"计划"和"执行"
async def get_screenplay_resource_comparison(
screenplay_id: UUID
) -> Dict[str, Any]:
"""对比剧本计划和分镜执行"""
# 1. 查询剧本要求的资源
planned_resources = await screenplay_element_ref_repo.get_by_screenplay(screenplay_id)
# 2. 查询分镜实际使用的资源
actual_resources = await storyboard_item_repo.get_by_screenplay(screenplay_id)
# 3. 计算差异
missing = planned_resources - actual_resources # 剧本提到但未制作
extra = actual_resources - planned_resources # 分镜使用但剧本未提到
return {
'planned': planned_resources,
'actual': actual_resources,
'missing': missing,
'extra': extra,
'completion_rate': len(actual_resources) / len(planned_resources) * 100
}
前端展示
剧本编辑器 - 资源清单面板:
📋 剧本资源清单(计划)
✅ 孙悟空(少年)- 已制作 3 个分镜
⚠️ 孙悟空(青年)- 未制作
⚠️ 孙悟空(中年)- 未制作
✅ 咖啡厅(白天)- 已制作 2 个分镜
🎬 实际使用资源(执行)
✅ 孙悟空(少年)- 3 个分镜
✅ 咖啡厅(白天)- 2 个分镜
⚠️ 猪八戒(成年)- 1 个分镜(不在剧本中)
📊 项目完成度:33% (1/3 角色已制作)
总结
screenplay_element_refs 不是冗余的,它与 storyboard_items 服务于不同的业务场景:
- screenplay_element_refs:剧本计划层面,记录"剧本说要用什么"
- storyboard_items:分镜执行层面,记录"分镜实际用了什么"
两者的差异本身就是有价值的项目管理信息,用于跟踪完成度、发现偏离、指导资源准备。
相关文档
架构决策记录
技术栈规范(jointo-tech-stack)
变更记录
v1.3 (2026-02-07)
- ✅ 补充 screenplay_element_refs 必要性分析:
- 深度分析 screenplay_element_refs 和 storyboard_items 的区别
- 明确两者是"计划层面"和"执行层面"的不同数据维度
- 补充性能对比、业务价值、数据完整性分析
- 补充用户手动添加资源的处理方式
- 补充对比 API 设计和前端展示示例
- 结论:screenplay_element_refs 是必要的,应该保留
v1.2 (2026-02-07)
- 明确父子项目架构:资源归属于父级项目(
parent_project_id = NULL),子项目通过parent_project_id关联 - 补充核心概念澄清:详细说明资源、素材、变体标签的区别和关联关系
- 补充完整关联链路:展示角色素材(有标签)和实拍素材(无标签)的完整数据流
- 补充动态扩展流程:上传剧本自动创建子项目,
episode_count自动更新 - 更新资源表设计:所有资源的
project_id指向父级项目 - 更新素材表设计:
project_resources.project_id指向父级项目,包含实拍资源(type=4) - 明确实拍资源归属:实拍资源同样属于父级项目,所有子项目共享
- 更新 AI 拆解逻辑:资源存储到父级项目,剧本引用关系指向父级资源
- 删除"项目类型"概念:通过
episode_count字段动态管理集数 - 补充架构层级图:清晰展示父级项目、子项目、资源、素材、剧本的关系
v1.1 (2026-02-07)
- 修正标签系统调整(1.5节):删除错误的字段删除操作,后端已使用
element_type + element_id统一设计 - 修正素材表调整(1.6节):删除错误的字段重命名操作,
project_resources通过element_tag_id关联标签 - 修正 Phase 3 迁移脚本:删除基于不存在字段的 UPDATE 语句
- 补充项目角色表的统计字段:
line_count,appearance_count - 补充场景和道具表的标签字段:
has_tags,default_tag_id - 更新数据迁移策略:重名角色累加统计数据
v1.0 (2026-02-07)
- 初始版本
- 提出资源归属从剧本级迁移到项目级的方案
- 定义数据库表结构和迁移策略
- 分析影响和实现计划
状态:提议中 决策日期:2026-02-07 最后更新:2026-02-07 预计工作量:15-20人天 预计完成时间:2-3周