# 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周