# ADR 01: 资源归属从剧本级迁移到项目级 > **状态**:提议中 > **日期**:2026-02-07 > **决策者**:架构团队 --- ## 目录 - [背景](#背景) - [当前架构问题](#当前架构问题) - [业务需求分析](#业务需求分析) - [决策](#决策) - [核心概念澄清](#核心概念澄清) - [核心方案](#核心方案) - [设计原则](#设计原则) - [架构设计](#架构设计) - [1. 数据库表结构](#1-数据库表结构) - [2. API 设计](#2-api-设计) - [3. AI 拆解剧本的存储策略](#3-ai-拆解剧本的存储策略) - [数据迁移策略](#数据迁移策略) - [3.1 迁移原则](#31-迁移原则) - [3.2 迁移步骤](#32-迁移步骤) - [影响分析](#影响分析) - [正面影响](#正面影响) - [负面影响](#负面影响) - [缓解措施](#缓解措施) - [实现计划](#实现计划) - [Phase 1:数据库迁移](#phase-1数据库迁移2-3天) - [Phase 2:后端服务层](#phase-2后端服务层5-7天) - [Phase 3:API 接口开发](#phase-3api-接口开发3-4天) - [Phase 4:前端适配](#phase-4前端适配3-4天) - [Phase 5:数据迁移执行](#phase-5数据迁移执行1天) - [Phase 6:监控与优化](#phase-6监控与优化持续) - [screenplay_element_refs 的必要性分析](#screenplay_element_refs-的必要性分析) - [核心问题](#核心问题) - [深度分析结论](#深度分析结论) - [设计原则](#设计原则-1) - [API 设计](#api-设计) - [总结](#总结) - [相关文档](#相关文档) - [变更记录](#变更记录) --- ## 背景 ### 当前架构问题 在现有架构中(参见 [ADR 003](003-screenplay-resource-architecture.md)),角色(Character)、场景(Location)、道具(Prop)属于剧本级别(screenplay),存储在以下表中: - `screenplay_characters` - `screenplay_locations` - `screenplay_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(可选,用于角色/场景/道具的变体) ``` ### 设计原则 1. **资源定义在父级项目**:角色/场景/道具在父级项目层级创建和管理(`parent_project_id = NULL`) 2. **子项目自动创建**:上传剧本时自动创建子项目(`parent_project_id` 指向父级项目) 3. **剧本引用资源**:子项目的剧本通过引用表关联父级项目资源,而不是拥有资源 4. **资源自动共享**:所有子项目的剧本共享父级项目的资源 5. **向后兼容**:保留旧表结构,通过数据迁移逐步过渡 6. **灵活扩展**:支持剧本特定的资源变体(如角色在不同集数的状态变化) ## 架构设计 ### 1. 数据库表结构 #### 1.1 项目角色表(project_characters) **职责**:存储父级项目的角色定义,所有子项目共享 ```sql -- 创建表 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) **职责**:存储父级项目的场景定义,所有子项目共享 ```sql -- 创建表 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) **职责**:存储父级项目的道具定义,所有子项目共享 ```sql -- 创建表 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) **职责**:记录剧本使用了哪些项目资源 ```sql -- 创建表 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) 的统一设计 - 表结构**无需调整**,但需要重命名表以反映其项目级归属 **调整方案**:重命名表并更新关联关系 ```sql -- 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`,再间接关联到资源 **实际表结构**: ```sql -- 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 新增接口(项目级资源管理) **创建角色** ```http 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}` **创建场景** ```http 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" } ``` **创建道具** ```http 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 查询接口(保持兼容) **查询项目资源**(现有接口,无需修改) ```http 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 ``` **响应格式**(统一格式 + 分页): ```json { "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 剧本引用接口(新增) **为剧本添加资源引用** ```http 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) **查询剧本使用的资源** ```http 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 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 创建父级项目资源流程 ```mermaid 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}
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
资源归属父级项目 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 分镜关联父级项目资源流程 ```mermaid 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
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
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: 请求体: {
item_type: 1,
element_tag_id: tag-001,
action_description: "大笑",
spatial_position: "center"
} 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
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
item_type = 1(ElementTag)
element_tag_id = 标签ID
element_name = "琼恩·雪诺"(冗余)
tag_label = "少年"(冗余)
action_description = "大笑"
spatial_position = "center" DB-->>StoryboardService: 返回关联结果 StoryboardService-->>API: 返回关联数据 API-->>Frontend: 统一响应格式 Frontend->>Frontend: 更新分镜元素列表 Frontend-->>User: 显示关联成功 ``` **说明**: - **用户在分镜编辑器中操作**,而非剧本编辑器 - **分镜通过 `storyboard_items` 表关联标签**,而非直接关联剧本 - **关联流程**: 1. 查询父级项目的角色资源(`project_characters`) 2. 查询角色的变体标签(`project_element_tags`) 3. 用户选择标签并填写关联属性(动作、位置) 4. 创建 `storyboard_items` 记录,关联分镜和标签 - **冗余字段优化**:`element_name` 和 `tag_label` 避免频繁 JOIN - **关联属性**:`action_description`(动作)、`spatial_position`(位置)为 AI 视频生成提供精准 Prompt #### 2.5.3 AI 拆解剧本并存储到父级项目流程 **前置条件**: 1. 用户创建了父级项目(`parent_project_id = NULL`) 2. 用户上传剧本,自动创建子项目(`parent_project_id = 父级项目ID`) 3. 剧本文件已解析为 Markdown 文本(通过 `POST /api/v1/screenplays/upload-and-parse`) ```mermaid 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
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
WHERE project_id = parent_project_id
ON CONFLICT (project_id, name) DO UPDATE Note over ScreenplayService,DB: 资源存储到父级项目
重名角色累加统计数据 DB-->>ScreenplayService: 返回 character_id(新建或已存在) ScreenplayService->>ScreenplayService: 生成 UUID v7 (ref_id) ScreenplayService->>DB: INSERT INTO screenplay_element_refs Note over ScreenplayService,DB: screenplay_id = 子项目的剧本
element_type = 1
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: 标签关联到父级项目的资源
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)
element_tag_id = 标签ID
element_name = 资源名称(冗余)
tag_label = 标签名称(冗余)
action_description = 动作
spatial_position = 位置 end end ScreenplayService-->>Celery: 返回存储结果 Celery-->>Frontend: WebSocket 通知解析完成 Frontend-->>User: 显示拆解成功,展示父级项目资源列表和分镜列表 ``` **关键设计说明**: 1. **父子项目关系确认** - 剧本属于子项目(`screenplay.project_id = subproject_id`) - 子项目关联父级项目(`subproject.parent_project_id = parent_project_id`) - 资源存储到父级项目(`project_characters.project_id = parent_project_id`) 2. **资源去重与累加策略** ```sql -- 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; ``` 3. **剧本引用关系** - 子项目的剧本通过 `screenplay_element_refs` 引用父级项目的资源 - `screenplay_id` 指向子项目的剧本 - `element_id` 指向父级项目的资源(角色/场景/道具) 4. **标签关联** - 标签存储到 `project_element_tags` 表 - `element_id` 指向父级项目的资源 - 多个子项目的剧本可以共享同一个标签 5. **统计数据汇总** - 父级项目资源的 `line_count` 和 `appearance_count` 汇总所有子项目剧本的数据 - 每次 AI 拆解新剧本时,累加统计数据 5. **标签存储改造** - **当前实现**:`ScreenplayTagService.store_tags()` 存储到 `screenplay_element_tags` - **新架构实现**:存储到 `project_element_tags`,关联到父级项目资源 - 标签的 `element_id` 指向 `project_characters/locations/props` 的主键 6. **分镜自动创建** - AI 拆解剧本后,自动创建分镜(`storyboards` 表) - 自动创建分镜元素关联(`storyboard_items` 表) - 分镜通过 `element_tag_id` 关联到标签,而非直接关联资源 - 关联属性(动作、位置)为 AI 视频生成提供精准 Prompt 7. **数据流总结** - 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 兼容 **实现**: ```python # 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 的组合: ```python # 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 拆解逻辑 **步骤**: 1. 更新 AI 拆解服务,改为写入项目级资源表 2. 添加重名检测和去重逻辑 3. 自动创建剧本引用关系 4. 保留旧的剧本级资源表,用于数据迁移 ### 3.4 实现建议 1. **AI 拆解服务更新** - 修改 `AIParseService.parse_screenplay()` 方法 - 添加 `ProjectResourceService.create_or_get_character()` 方法 - 添加 `ScreenplayService.add_element_ref()` 方法 2. **去重策略配置** - 提供配置项:`AI_PARSE_DEDUP_STRATEGY` - `strict`:严格去重,同名必定共享 - `smart`:智能去重,基于 ai_entity_id - `manual`:人工审核,提供合并界面 3. **人工审核界面** - 在前端提供"资源合并"功能 - 用户可以手动合并重复的资源 - 合并时自动更新所有引用关系 ## 数据迁移策略 ### 3.1 迁移原则 1. **零停机迁移**:使用蓝绿部署,不影响线上服务 2. **数据完整性**:迁移前后数据一致性校验 3. **可回滚**:保留旧表结构,支持快速回滚 4. **分阶段执行**:按项目分批迁移(先测试项目,再生产项目) ### 3.2 迁移步骤 #### Phase 1:创建新表结构(无数据影响) ```sql -- 1. 创建项目级资源表 CREATE TABLE project_characters (...); CREATE TABLE project_locations (...); CREATE TABLE project_props (...); -- 2. 创建剧本引用表 CREATE TABLE screenplay_element_refs (...); -- 3. 添加索引 CREATE INDEX ...; ``` **风险**:无,仅创建新表 #### Phase 2:数据迁移(只读操作) ```sql -- 迁移角色数据 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:重命名标签表(低风险操作) ```sql -- 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:切换服务层(代码部署) 1. 部署新版本代码(读取新表) 2. 监控错误率和性能 3. 确认无问题后,标记旧接口为废弃 **回滚方案**: - 保留旧表数据不删除 - 代码支持双写(同时写入新旧表) - 发现问题立即回滚到旧版本 ## 影响分析 ### 正面影响 1. **业务逻辑清晰** - 资源归属符合业务直觉(项目级管理) - 多集项目可以共享角色/场景/道具 - 前端实现简化(无需处理 screenplay_id) 2. **数据一致性提升** - 避免同一角色在多个剧本中重复创建 - 统一的资源管理入口 - 减少数据冗余 3. **扩展性增强** - 支持项目级资源库 - 支持资源跨剧本复用 - 为未来的资源市场功能打基础 ### 负面影响 1. **迁移成本** - 数据库结构变更(新增3个表) - 数据迁移脚本开发和测试 - 前后端代码调整 - 预计工作量:**15-20人天** 2. **兼容性风险** - 旧接口需要保持兼容 - 可能影响现有功能 - 需要充分测试 3. **查询复杂度** - 剧本查询资源需要 JOIN 引用表 - 可能影响查询性能(需要索引优化) ### 缓解措施 1. **性能优化** - 为所有外键创建索引 - 使用 Repository 层封装复杂查询 - 对高频查询结果进行缓存 2. **风险控制** - 分阶段迁移(先测试环境,再生产环境) - 保留旧表数据,支持快速回滚 - 充分的单元测试和集成测试 3. **兼容性保证** - 旧接口保持可用(标记为废弃) - 提供迁移指南和示例代码 - 设置过渡期(3-6个月) ## 实现计划 ### Phase 1:数据库迁移(2-3天) **任务**: - [ ] 编写数据库迁移脚本 - 创建 `project_characters/locations/props` 表 - 创建 `screenplay_element_refs` 表 - 创建索引和约束 - [ ] 在测试环境执行迁移 - [ ] 数据一致性校验 **负责人**:后端团队 **风险**:低 ### Phase 2:后端服务层(5-7天) **任务**: - [ ] 创建 Repository 层 - `ProjectCharacterRepository` - `ProjectLocationRepository` - `ProjectPropRepository` - `ScreenplayElementRefRepository` - [ ] 创建 Service 层 - `ProjectResourceService.create_character/location/prop()` - `ScreenplayService.add_element_ref()` - [ ] 更新现有 Service - `ResourceLibraryService` 改为查询新表 - [ ] 编写单元测试 **负责人**:后端团队 **风险**:中等 ### Phase 3:API 接口开发(3-4天) **任务**: - [ ] 实现新增接口 - `POST /api/v1/projects/{project_id}/characters` - `POST /api/v1/projects/{project_id}/locations` - `POST /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/Prop` hooks - [ ] 更新组件 - `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 = **购物车**(实际买了什么) - 两者的差异 = **项目完成度和偏离度** #### 性能优势 **查询剧本资源清单的性能对比**: ```sql -- 使用 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 支持以下关键业务场景: 1. **资源准备**:项目启动时,根据剧本清单提前准备素材(主动准备) 2. **项目管理**:跟踪项目完成度(对比"剧本要求"和"实际完成") 3. **质量控制**:发现用户偏离剧本的内容 4. **剧本编辑器**:快速显示"这个剧本用了哪些角色、场景、道具" 5. **剧本导出**:导出剧本时,附带"角色表"、"场景表"、"道具表" #### 数据完整性 **关键场景**:用户删除分镜后 - **使用 screenplay_element_refs**: - 剧本资源清单仍然保留 - 用户可以知道"还有哪些内容未制作" - 可以恢复删除的分镜 - **不使用**: - 删除分镜后,对应的资源关联也被删除 - 无法知道"这个资源曾经在剧本中出现" - 数据丢失,无法恢复 ### 设计原则 #### 数据来源 **screenplay_element_refs 只能由 AI 创建,不允许手动修改** ```python 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() ``` #### 用户手动添加资源的处理 **场景**:用户在父项目资源列表中手动添加角色(如"猪八戒") **处理方式**: 1. 资源归属父项目(project_characters) 2. 用户可以直接在分镜中使用(创建 storyboard_items) 3. **不自动添加到 screenplay_element_refs** 4. 查询对比时,发现"猪八戒"是"剧本外的资源"(这是有价值的信息) **如需"加入剧本"**: - 方式 1:修改剧本文本,加入"猪八戒"的描述,重新触发 AI 解析 - 方式 2:提供"手动关联"功能(可选,需要标记来源) **工作流图**: ``` 父项目资源库(project_characters) ├─ 孙悟空(AI 解析剧本时创建) ├─ 李四(AI 解析剧本时创建) └─ 猪八戒(用户手动添加)← 新增 ↓ ↓ 用户在分镜中选择 ↓ storyboard_items(分镜关联) ├─ 分镜1 → 孙悟空(少年) ├─ 分镜2 → 李四(成年) └─ 分镜3 → 猪八戒(成年)← 使用手动添加的资源 ↓ ↓ 查询对比 ↓ screenplay_element_refs(剧本清单) ├─ 孙悟空(少年) ├─ 孙悟空(青年) └─ 李四(成年) 对比结果: - 猪八戒不在 screenplay_element_refs 中 - 说明这是"剧本外的资源" - 前端可以标记为"⚠️ 不在剧本中" ``` ### API 设计 #### 对比"计划"和"执行" ```python 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**:分镜执行层面,记录"分镜实际用了什么" 两者的差异本身就是有价值的项目管理信息,用于跟踪完成度、发现偏离、指导资源准备。 --- ## 相关文档 ### 架构决策记录 - [ADR 003: 剧本拆解与素材关联架构设计](003-screenplay-resource-architecture.md) - [ADR 007: 数据库迁移最佳实践](007-database-migration-best-practices.md) ### 技术栈规范(jointo-tech-stack) - [数据库设计规范](../../.claude/skills/jointo-tech-stack/references/database.md) - [数据库迁移规范](../../.claude/skills/jointo-tech-stack/references/migration.md) - [后端架构规范](../../.claude/skills/jointo-tech-stack/references/backend.md) - [API 设计规范](../../.claude/skills/jointo-tech-stack/references/api-design.md) ## 变更记录 **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周