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

76 KiB

ADR 01: 资源归属从剧本级迁移到项目级

状态:提议中 日期:2026-02-07 决策者:架构团队


目录


背景

当前架构问题

在现有架构中(参见 ADR 003),角色(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_nametag_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)

职责:存储父级项目的角色定义,所有子项目共享

-- 创建表
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 应用层生成
  • 枚举使用 SMALLINTrole_type 使用 SMALLINT + IntEnum 模式
  • 所有关联字段创建索引project_id 必须有索引以保证查询性能
  • 添加中文注释:使用 COMMENT ON 语法为表和列添加说明
  • 保留统计字段line_countappearance_count 从所有子项目的剧本汇总统计
  • 资源归属父级项目project_id 指向父级项目(parent_project_id = NULL

1.2 项目场景表(project_locations)

职责:存储父级项目的场景定义,所有子项目共享

-- 创建表
CREATE TABLE project_locations (
    location_id UUID PRIMARY KEY,  -- 应用层生成 UUID v7
    project_id UUID NOT NULL,      -- 所属父级项目(parent_project_id = NULL 的项目)
    name VARCHAR(255) NOT NULL,
    location VARCHAR(255),         -- 地点描述(如"室内/室外")
    description TEXT,
    order_index INTEGER DEFAULT 0,
    has_tags BOOLEAN DEFAULT FALSE,
    default_tag_id UUID,           -- 默认标签ID
    meta_data JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),

    CONSTRAINT project_locations_project_name_unique UNIQUE(project_id, name)
);

-- 添加索引
CREATE INDEX idx_project_locations_project_id ON project_locations(project_id);
CREATE INDEX idx_project_locations_name ON project_locations(name);

-- 添加中文注释
COMMENT ON TABLE project_locations IS '项目场景表:存储父级项目的场景定义,所有子项目共享';
COMMENT ON COLUMN project_locations.location_id IS '场景ID(UUID v7,应用层生成)';
COMMENT ON COLUMN project_locations.project_id IS '所属父级项目ID(parent_project_id = NULL 的项目)';
COMMENT ON COLUMN project_locations.name IS '场景名称';
COMMENT ON COLUMN project_locations.location IS '地点描述(如"室内/室外")';
COMMENT ON COLUMN project_locations.description IS '场景描述';
COMMENT ON COLUMN project_locations.order_index IS '排序索引';
COMMENT ON COLUMN project_locations.has_tags IS '是否有标签';
COMMENT ON COLUMN project_locations.default_tag_id IS '默认标签ID(指向 project_element_tags)';
COMMENT ON COLUMN project_locations.meta_data IS '扩展元数据(JSONB格式)';
COMMENT ON COLUMN project_locations.created_at IS '创建时间(UTC)';
COMMENT ON COLUMN project_locations.updated_at IS '更新时间(UTC)';

1.3 项目道具表(project_props)

职责:存储父级项目的道具定义,所有子项目共享

-- 创建表
CREATE TABLE project_props (
    prop_id UUID PRIMARY KEY,      -- 应用层生成 UUID v7
    project_id UUID NOT NULL,      -- 所属父级项目(parent_project_id = NULL 的项目)
    name VARCHAR(255) NOT NULL,
    description TEXT,
    order_index INTEGER DEFAULT 0,
    has_tags BOOLEAN DEFAULT FALSE,
    default_tag_id UUID,           -- 默认标签ID
    meta_data JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),

    CONSTRAINT project_props_project_name_unique UNIQUE(project_id, name)
);

-- 添加索引
CREATE INDEX idx_project_props_project_id ON project_props(project_id);
CREATE INDEX idx_project_props_name ON project_props(name);

-- 添加中文注释
COMMENT ON TABLE project_props IS '项目道具表:存储父级项目的道具定义,所有子项目共享';
COMMENT ON COLUMN project_props.prop_id IS '道具ID(UUID v7,应用层生成)';
COMMENT ON COLUMN project_props.project_id IS '所属父级项目ID(parent_project_id = NULL 的项目)';
COMMENT ON COLUMN project_props.name IS '道具名称';
COMMENT ON COLUMN project_props.description IS '道具描述';
COMMENT ON COLUMN project_props.order_index IS '排序索引';
COMMENT ON COLUMN project_props.has_tags IS '是否有标签';
COMMENT ON COLUMN project_props.default_tag_id IS '默认标签ID(指向 project_element_tags)';
COMMENT ON COLUMN project_props.meta_data IS '扩展元数据(JSONB格式)';
COMMENT ON COLUMN project_props.created_at IS '创建时间(UTC)';
COMMENT ON COLUMN project_props.updated_at IS '更新时间(UTC)';

1.4 剧本资源引用表(screenplay_element_refs)

职责:记录剧本使用了哪些项目资源

-- 创建表
CREATE TABLE screenplay_element_refs (
    ref_id UUID PRIMARY KEY,       -- 应用层生成 UUID v7
    screenplay_id UUID NOT NULL,   -- 所属剧本(禁止外键约束)
    element_type SMALLINT NOT NULL, -- 元素类型:1=角色, 2=场景, 3=道具
    element_id UUID NOT NULL,      -- 指向 project_characters/locations/props
    order_index INTEGER DEFAULT 0,
    -- 剧本特定的覆盖字段(可选)
    override_description TEXT,
    override_meta_data JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW(),

    CONSTRAINT screenplay_element_refs_unique UNIQUE(screenplay_id, element_type, element_id)
);

-- 添加索引
CREATE INDEX idx_screenplay_element_refs_screenplay ON screenplay_element_refs(screenplay_id);
CREATE INDEX idx_screenplay_element_refs_element ON screenplay_element_refs(element_type, element_id);
CREATE INDEX idx_screenplay_element_refs_element_id ON screenplay_element_refs(element_id);

-- 添加中文注释
COMMENT ON TABLE screenplay_element_refs IS '剧本资源引用表:记录剧本使用了哪些项目资源';
COMMENT ON COLUMN screenplay_element_refs.ref_id IS '引用ID(UUID v7,应用层生成)';
COMMENT ON COLUMN screenplay_element_refs.screenplay_id IS '所属剧本ID';
COMMENT ON COLUMN screenplay_element_refs.element_type IS '元素类型:1=角色(character), 2=场景(location), 3=道具(prop)';
COMMENT ON COLUMN screenplay_element_refs.element_id IS '元素ID(指向 project_characters/locations/props)';
COMMENT ON COLUMN screenplay_element_refs.order_index IS '排序索引';
COMMENT ON COLUMN screenplay_element_refs.override_description IS '剧本特定的描述覆盖';
COMMENT ON COLUMN screenplay_element_refs.override_meta_data IS '剧本特定的元数据覆盖(JSONB格式)';
COMMENT ON COLUMN screenplay_element_refs.created_at IS '创建时间(UTC)';

关键设计说明

  • ⚠️ 禁止外键约束screenplay_idelement_id 不使用 REFERENCES
  • ⚠️ UUID v7 应用层生成:主键不使用 DEFAULT uuid_generate_v7()
  • 枚举使用 SMALLINTelement_type 使用 SMALLINT + IntEnum 模式
  • 统一引用表设计:使用一个表而不是三个独立的表(简化设计)
  • 支持剧本特定覆盖override_* 字段支持剧本特定的描述(如角色在某集的特殊状态)

1.5 标签系统调整(project_element_tags)

现状分析

  • 当前 screenplay_element_tags已经使用 element_type (SMALLINT) + element_id (UUID) 的统一设计
  • 表结构无需调整,但需要重命名表以反映其项目级归属

调整方案:重命名表并更新关联关系

-- 1. 重命名表(从剧本级改为项目级)
ALTER TABLE screenplay_element_tags RENAME TO project_element_tags;

-- 2. 更新表注释
COMMENT ON TABLE project_element_tags IS '项目元素标签表:存储项目资源的变体标签';

-- 3. 更新列注释(确保准确性)
COMMENT ON COLUMN project_element_tags.tag_id IS '标签ID(UUID v7,应用层生成)';
COMMENT ON COLUMN project_element_tags.screenplay_id IS '所属剧本ID(保留用于标签来源追溯)';
COMMENT ON COLUMN project_element_tags.element_type IS '元素类型:1=角色(character), 2=场景(location), 3=道具(prop)';
COMMENT ON COLUMN project_element_tags.element_id IS '元素ID(指向 project_characters/locations/props)';
COMMENT ON COLUMN project_element_tags.tag_label IS '标签名称';
COMMENT ON COLUMN project_element_tags.key IS '标签键值(用于唯一标识)';
COMMENT ON COLUMN project_element_tags.description IS '标签描述';
COMMENT ON COLUMN project_element_tags.display_order IS '显示顺序';
COMMENT ON COLUMN project_element_tags.meta_data IS '扩展元数据(JSONB格式)';

-- 4. 确认索引存在(如不存在则创建)
CREATE INDEX IF NOT EXISTS idx_project_element_tags_screenplay 
    ON project_element_tags(screenplay_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_element 
    ON project_element_tags(element_type, element_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_element_id 
    ON project_element_tags(element_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_key 
    ON project_element_tags(key);

关键设计说明

  • 无需修改字段:后端实际实现已经使用 element_type + element_id 统一设计
  • 保留 screenplay_id:用于追溯标签来源(哪个剧本的 AI 拆解生成的)
  • 枚举使用 SMALLINTelement_type 已经是 SMALLINT + IntEnum 模式
  • 禁止外键约束element_id 不使用 REFERENCES,在应用层保证引用完整性
  • ⚠️ 迁移后更新关联:标签的 element_id 需要从 screenplay_* 的 ID 更新为 project_* 的 ID

1.6 素材表调整(project_resources)

现状分析

  • 当前 project_resources没有直接关联角色/场景/道具的字段
  • 实际字段:element_tag_id, element_name, tag_label(冗余字段)
  • 素材通过 element_tag_id 关联到 project_element_tags,再间接关联到资源

实际表结构

-- project_resources 表的实际字段(无需调整)
CREATE TABLE project_resources (
    project_resource_id UUID PRIMARY KEY,
    project_id UUID NOT NULL,
    name VARCHAR(255) NOT NULL,
    type SMALLINT NOT NULL,  -- 1=角色, 2=场景, 3=道具, 4=实拍
    description TEXT,
    
    -- 文件信息
    file_url VARCHAR(500) NOT NULL,
    thumbnail_url VARCHAR(500),
    file_size INTEGER,
    mime_type VARCHAR(100),
    width INTEGER,
    height INTEGER,
    checksum VARCHAR(64) NOT NULL,
    
    -- 标签关联(通过标签间接关联到资源)
    element_tag_id UUID,           -- 关联到 project_element_tags
    element_name VARCHAR(255),     -- 冗余字段:资源名称
    tag_label VARCHAR(100),        -- 冗余字段:标签名称
    
    -- 来源(后期扩展)
    source_resource_id UUID,
    
    -- AI 生成
    ai_job_id UUID,
    meta_data JSON,
    
    -- 使用统计
    usage_count INTEGER DEFAULT 0,
    
    -- 审计
    created_by UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL,
    deleted_at TIMESTAMPTZ
);

-- 确认索引存在
CREATE INDEX IF NOT EXISTS idx_project_resources_project_id 
    ON project_resources(project_id);
CREATE INDEX IF NOT EXISTS idx_project_resources_type 
    ON project_resources(type);
CREATE INDEX IF NOT EXISTS idx_project_resources_element_tag_id 
    ON project_resources(element_tag_id);
CREATE INDEX IF NOT EXISTS idx_project_resources_checksum 
    ON project_resources(checksum);
CREATE INDEX IF NOT EXISTS idx_project_resources_ai_job_id 
    ON project_resources(ai_job_id);
CREATE INDEX IF NOT EXISTS idx_project_resources_usage_count 
    ON project_resources(usage_count);
CREATE INDEX IF NOT EXISTS idx_project_resources_created_by 
    ON project_resources(created_by);

关键设计说明

  • 无需修改字段:表结构已经是项目级,通过 project_id 关联
  • 通过标签关联资源element_tag_idproject_element_tagselement_id (指向 project_characters/locations/props)
  • 实拍资源已支持type=4 表示实拍资源,同样属于父级项目(project_id 指向父级项目)
  • 冗余字段优化查询element_nametag_label 避免频繁 JOIN
  • ⚠️ 迁移后更新关联element_tag_id 需要指向迁移后的项目级标签
  • ⚠️ 实拍资源归属project_resources.project_id 应指向父级项目(parent_project_id = NULL

关联关系

project_resources (素材)
    ↓ project_id(指向父级项目)
父级项目
    ↓ element_tag_id(可选,用于角色/场景/道具的变体)
project_element_tags (标签)
    ↓ element_id
project_characters/locations/props (资源)

实拍资源说明

  • 实拍资源(type=4)由用户直接上传,不通过剧本 AI 拆解
  • 实拍资源同样属于父级项目,所有子项目共享
  • 实拍资源可以不关联标签(element_tag_id = NULL

2. API 设计

2.1 新增接口(项目级资源管理)

创建角色

POST /api/v1/projects/{project_id}/characters
Content-Type: application/json

# 请求体
{
  "name": "琼恩·雪诺",
  "description": "守夜人军团成员",
  "roleType": 1,
  "isOffscreen": false,
  "metaData": {
    "gender": "male",
    "age": 25
  }
}

# 响应(统一格式)
{
  "success": true,
  "code": 200,
  "message": "角色创建成功",
  "data": {
    "characterId": "01936d8a-7b2c-7890-abcd-ef1234567890",
    "projectId": "01936d8a-1234-7890-abcd-ef1234567890",
    "name": "琼恩·雪诺",
    "description": "守夜人军团成员",
    "roleType": 1,
    "isOffscreen": false,
    "metaData": {
      "gender": "male",
      "age": 25
    },
    "createdAt": "2026-02-07T10:30:00Z",
    "updatedAt": "2026-02-07T10:30:00Z"
  },
  "timestamp": "2026-02-07T10:30:00Z"
}

说明

  • roleType 使用数字:1=主角(main), 2=配角(supporting), 3=群演(extra)
  • 响应遵循统一格式:{success, code, message, data, timestamp}

创建场景

POST /api/v1/projects/{project_id}/locations
Content-Type: application/json

# 请求体
{
  "name": "临冬城",
  "location": "室外",
  "description": "史塔克家族的城堡"
}

# 响应(统一格式)
{
  "success": true,
  "code": 200,
  "message": "场景创建成功",
  "data": {
    "locationId": "01936d8a-7b2c-7890-abcd-ef1234567891",
    "projectId": "01936d8a-1234-7890-abcd-ef1234567890",
    "name": "临冬城",
    "location": "室外",
    "description": "史塔克家族的城堡",
    "createdAt": "2026-02-07T10:30:00Z",
    "updatedAt": "2026-02-07T10:30:00Z"
  },
  "timestamp": "2026-02-07T10:30:00Z"
}

创建道具

POST /api/v1/projects/{project_id}/props
Content-Type: application/json

# 请求体
{
  "name": "冰",
  "description": "史塔克家族的传家宝剑"
}

# 响应(统一格式)
{
  "success": true,
  "code": 200,
  "message": "道具创建成功",
  "data": {
    "propId": "01936d8a-7b2c-7890-abcd-ef1234567892",
    "projectId": "01936d8a-1234-7890-abcd-ef1234567890",
    "name": "冰",
    "description": "史塔克家族的传家宝剑",
    "createdAt": "2026-02-07T10:30:00Z",
    "updatedAt": "2026-02-07T10:30:00Z"
  },
  "timestamp": "2026-02-07T10:30:00Z"
}

2.2 查询接口(保持兼容)

查询项目资源(现有接口,无需修改)

GET /api/v1/projects/{project_id}/resource-library/characters?page=1&pageSize=20
GET /api/v1/projects/{project_id}/resource-library/locations?page=1&pageSize=20
GET /api/v1/projects/{project_id}/resource-library/props?page=1&pageSize=20

响应格式(统一格式 + 分页):

{
  "success": true,
  "code": 200,
  "message": "查询成功",
  "data": {
    "items": [
      {
        "characterId": "01936d8a-7b2c-7890-abcd-ef1234567890",
        "projectId": "01936d8a-1234-7890-abcd-ef1234567890",
        "name": "琼恩·雪诺",
        "description": "守夜人军团成员",
        "roleType": 1,
        "isOffscreen": false,
        "hasTags": true,
        "tags": [...]
      }
    ],
    "total": 50,
    "page": 1,
    "pageSize": 20,
    "totalPages": 3
  },
  "timestamp": "2026-02-07T10:30:00Z"
}

说明:数据来源从 screenplay_* 表改为 project_* 表,响应格式保持统一

2.3 剧本引用接口(新增)

为剧本添加资源引用

POST /api/v1/screenplays/{screenplay_id}/element-refs
Content-Type: application/json

# 请求体
{
  "elementType": 1,
  "elementId": "01936d8a-7b2c-7890-abcd-ef1234567890",
  "overrideDescription": "在第一集中受伤"
}

# 响应(统一格式)
{
  "success": true,
  "code": 200,
  "message": "资源引用创建成功",
  "data": {
    "refId": "01936d8a-7b2c-7890-abcd-ef1234567893",
    "screenplayId": "01936d8a-1234-7890-abcd-ef1234567891",
    "elementType": 1,
    "elementId": "01936d8a-7b2c-7890-abcd-ef1234567890",
    "overrideDescription": "在第一集中受伤",
    "createdAt": "2026-02-07T10:30:00Z"
  },
  "timestamp": "2026-02-07T10:30:00Z"
}

说明elementType 使用数字:1=角色(character), 2=场景(location), 3=道具(prop)

查询剧本使用的资源

GET /api/v1/screenplays/{screenplay_id}/elements?type=1

# 响应(统一格式)
{
  "success": true,
  "code": 200,
  "message": "查询成功",
  "data": {
    "items": [
      {
        "refId": "01936d8a-7b2c-7890-abcd-ef1234567893",
        "screenplayId": "01936d8a-1234-7890-abcd-ef1234567891",
        "elementType": 1,
        "elementId": "01936d8a-7b2c-7890-abcd-ef1234567890",
        "elementName": "琼恩·雪诺",
        "overrideDescription": "在第一集中受伤",
        "createdAt": "2026-02-07T10:30:00Z"
      }
    ]
  },
  "timestamp": "2026-02-07T10:30:00Z"
}

说明:查询参数 type 使用数字:1=角色, 2=场景, 3=道具

2.4 废弃接口(向后兼容)

以下接口保留但标记为废弃(Deprecated):

POST /api/v1/screenplays/{screenplay_id}/characters
POST /api/v1/screenplays/{screenplay_id}/locations
POST /api/v1/screenplays/{screenplay_id}/props

处理方式

  • 接口内部自动转换为项目级资源创建
  • 返回警告头:X-Deprecated: true
  • 文档标注废弃时间和替代方案

响应示例

HTTP/1.1 200 OK
X-Deprecated: true
X-Deprecated-Replacement: POST /api/v1/projects/{project_id}/characters

{
  "success": true,
  "code": 200,
  "message": "角色创建成功(警告:此接口已废弃,请使用项目级接口)",
  "data": {
    "characterId": "01936d8a-7b2c-7890-abcd-ef1234567890",
    "projectId": "01936d8a-1234-7890-abcd-ef1234567890",
    "name": "琼恩·雪诺",
    "roleType": 1
  },
  "timestamp": "2026-02-07T10:30:00Z"
}

2.5 时序图

2.5.1 创建父级项目资源流程

sequenceDiagram
    participant User as 用户
    participant Frontend as 前端
    participant API as API 层
    participant Service as ProjectResourceService
    participant DB as 数据库

    User->>Frontend: 在父级项目中点击"新增角色"
    Frontend->>Frontend: 打开 AddResourcePopover
    User->>Frontend: 填写角色信息(name, roleType, description)
    User->>Frontend: 点击"确认"

    Frontend->>API: POST /api/v1/projects/{parent_project_id}/characters
    Note over Frontend,API: 请求体: {name, roleType: 1, description}<br/>parent_project_id 是父级项目ID

    API->>Service: create_character(parent_project_id, data)
    Service->>Service: 生成 UUID v7 (character_id)
    Service->>Service: 验证 parent_project_id 存在且为父级项目
    Note over Service: 确认 parent_project_id = NULL
    
    Service->>DB: INSERT INTO project_characters
    Note over Service,DB: project_id = parent_project_id<br/>资源归属父级项目
    DB-->>Service: 返回创建结果

    Service-->>API: 返回角色数据
    API-->>Frontend: 统一响应格式
    Note over API,Frontend: {success, code, message, data, timestamp}

    Frontend->>Frontend: 更新父级项目资源列表
    Frontend-->>User: 显示创建成功提示

说明

  • 资源创建在父级项目层级(parent_project_id = NULL
  • 所有子项目自动共享父级项目的资源
  • 前端需要确保传递的是父级项目ID,而非子项目ID

2.5.2 分镜关联父级项目资源流程

sequenceDiagram
    participant User as 用户
    participant Frontend as 前端(分镜编辑器)
    participant API as API 层
    participant StoryboardService as StoryboardService
    participant TagService as TagService
    participant DB as 数据库

    User->>Frontend: 在分镜编辑器中点击"添加角色"
    
    Frontend->>API: GET /api/v1/projects/{parent_project_id}/resource-library/characters
    Note over Frontend,API: 查询父级项目的角色资源
    
    API->>StoryboardService: ResourceLibraryService.get_characters(parent_project_id)
    StoryboardService->>DB: SELECT FROM project_characters<br/>WHERE project_id = parent_project_id
    DB-->>StoryboardService: 返回父级项目的角色列表
    StoryboardService-->>API: 返回角色数据
    API-->>Frontend: 统一响应格式(分页)
    Frontend-->>User: 显示父级项目的角色选择列表

    User->>Frontend: 选择角色"琼恩·雪诺"
    Frontend->>API: GET /api/v1/characters/{character_id}/tags
    Note over Frontend,API: 查询角色的变体标签(如"少年"、"青年")
    
    API->>TagService: get_tags_by_element(character_id)
    TagService->>DB: SELECT FROM project_element_tags<br/>WHERE element_id = character_id
    DB-->>TagService: 返回标签列表
    TagService-->>API: 返回标签数据
    API-->>Frontend: 统一响应格式
    Frontend-->>User: 显示变体标签选择列表(少年、青年、中年)

    User->>Frontend: 选择标签"少年",填写动作"大笑"、位置"center"
    Frontend->>API: POST /api/v1/storyboards/{storyboard_id}/items
    Note over Frontend,API: 请求体: {<br/>  item_type: 1,<br/>  element_tag_id: tag-001,<br/>  action_description: "大笑",<br/>  spatial_position: "center"<br/>}

    API->>StoryboardService: add_element_to_storyboard()
    StoryboardService->>StoryboardService: 生成 UUID v7 (item_id)
    StoryboardService->>StoryboardService: 验证 element_tag_id 存在于父级项目
    Note over StoryboardService: 确认标签归属父级项目的资源
    
    StoryboardService->>TagService: get_tag_info(element_tag_id)
    TagService->>DB: SELECT FROM project_element_tags<br/>WHERE tag_id = element_tag_id
    DB-->>TagService: 返回标签信息(element_name, tag_label, cover_url)
    TagService-->>StoryboardService: 返回标签数据
    
    StoryboardService->>DB: INSERT INTO storyboard_items
    Note over StoryboardService,DB: storyboard_id = 分镜ID<br/>item_type = 1(ElementTag)<br/>element_tag_id = 标签ID<br/>element_name = "琼恩·雪诺"(冗余)<br/>tag_label = "少年"(冗余)<br/>action_description = "大笑"<br/>spatial_position = "center"
    DB-->>StoryboardService: 返回关联结果

    StoryboardService-->>API: 返回关联数据
    API-->>Frontend: 统一响应格式
    Frontend->>Frontend: 更新分镜元素列表
    Frontend-->>User: 显示关联成功

说明

  • 用户在分镜编辑器中操作,而非剧本编辑器
  • 分镜通过 storyboard_items 表关联标签,而非直接关联剧本
  • 关联流程
    1. 查询父级项目的角色资源(project_characters
    2. 查询角色的变体标签(project_element_tags
    3. 用户选择标签并填写关联属性(动作、位置)
    4. 创建 storyboard_items 记录,关联分镜和标签
  • 冗余字段优化element_nametag_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
sequenceDiagram
    participant User as 用户
    participant Frontend as 前端
    participant API as API 层
    participant ScreenplayService as ScreenplayService
    participant ProjectService as ProjectService
    participant AIService as AIService
    participant Celery as Celery Worker
    participant DB as 数据库

    User->>Frontend: 触发 AI 拆解剧本
    Frontend->>API: POST /api/v1/screenplays/{screenplay_id}/parse
    API->>ScreenplayService: parse_screenplay(screenplay_id)

    ScreenplayService->>DB: 查询剧本和所属项目
    DB-->>ScreenplayService: 返回 screenplay + subproject
    
    ScreenplayService->>ProjectService: get_parent_project_id(subproject_id)
    ProjectService->>DB: SELECT parent_project_id FROM projects<br/>WHERE project_id = subproject_id
    DB-->>ProjectService: 返回 parent_project_id
    ProjectService-->>ScreenplayService: 返回父级项目ID
    
    Note over ScreenplayService: 确认资源将存储到父级项目

    ScreenplayService->>AIService: parse_screenplay()
    Note over ScreenplayService,AIService: 传递 screenplay_id, parent_project_id, content

    AIService->>Celery: 创建异步任务
    Note over AIService,Celery: 调用 AI 模型提取元素
    AIService-->>ScreenplayService: 返回 task_id
    ScreenplayService-->>API: 返回 202 Accepted
    API-->>Frontend: 返回任务状态
    Frontend-->>User: 显示"解析中..."

    Celery->>Celery: AI 模型分析剧本
    Celery->>Celery: 提取角色、场景、道具、标签

    Celery->>ScreenplayService: store_parsed_elements()
    Note over Celery,ScreenplayService: 传递解析结果 + parent_project_id

    loop 处理每个角色
        ScreenplayService->>ScreenplayService: 生成 UUID v7 (character_id)
        ScreenplayService->>DB: INSERT INTO project_characters<br/>WHERE project_id = parent_project_id<br/>ON CONFLICT (project_id, name) DO UPDATE
        Note over ScreenplayService,DB: 资源存储到父级项目<br/>重名角色累加统计数据
        DB-->>ScreenplayService: 返回 character_id(新建或已存在)

        ScreenplayService->>ScreenplayService: 生成 UUID v7 (ref_id)
        ScreenplayService->>DB: INSERT INTO screenplay_element_refs
        Note over ScreenplayService,DB: screenplay_id = 子项目的剧本<br/>element_type = 1<br/>element_id = 父级项目的角色
    end

    loop 处理场景和道具
        ScreenplayService->>DB: 同理处理 locations (element_type=2) 和 props (element_type=3)
        Note over ScreenplayService,DB: 存储到父级项目,创建剧本引用
    end

    loop 处理标签
        ScreenplayService->>DB: INSERT INTO project_element_tags
        Note over ScreenplayService,DB: 标签关联到父级项目的资源<br/>element_id 指向父级项目资源
    end

    ScreenplayService->>DB: UPDATE screenplays SET parsing_status='completed'
    ScreenplayService->>DB: UPDATE project_characters SET line_count += X, appearance_count += Y
    Note over ScreenplayService,DB: 更新父级项目资源的统计数据

    Note over ScreenplayService: 自动创建分镜并关联标签

    loop 处理每个分镜
        ScreenplayService->>ScreenplayService: 生成 UUID v7 (storyboard_id)
        ScreenplayService->>DB: INSERT INTO storyboards
        Note over ScreenplayService,DB: 创建分镜记录(标题、描述、景别、运镜等)
        DB-->>ScreenplayService: 返回 storyboard_id

        loop 处理分镜的角色/场景/道具
            ScreenplayService->>ScreenplayService: 生成 UUID v7 (item_id)
            ScreenplayService->>DB: INSERT INTO storyboard_items
            Note over ScreenplayService,DB: item_type = 1(ElementTag)<br/>element_tag_id = 标签ID<br/>element_name = 资源名称(冗余)<br/>tag_label = 标签名称(冗余)<br/>action_description = 动作<br/>spatial_position = 位置
        end
    end

    ScreenplayService-->>Celery: 返回存储结果
    Celery-->>Frontend: WebSocket 通知解析完成
    Frontend-->>User: 显示拆解成功,展示父级项目资源列表和分镜列表

关键设计说明

  1. 父子项目关系确认

    • 剧本属于子项目(screenplay.project_id = subproject_id
    • 子项目关联父级项目(subproject.parent_project_id = parent_project_id
    • 资源存储到父级项目(project_characters.project_id = parent_project_id
  2. 资源去重与累加策略

    -- 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_countappearance_count 汇总所有子项目剧本的数据
    • 每次 AI 拆解新剧本时,累加统计数据
  6. 标签存储改造

    • 当前实现ScreenplayTagService.store_tags() 存储到 screenplay_element_tags
    • 新架构实现:存储到 project_element_tags,关联到父级项目资源
    • 标签的 element_id 指向 project_characters/locations/props 的主键
  7. 分镜自动创建

    • AI 拆解剧本后,自动创建分镜(storyboards 表)
    • 自动创建分镜元素关联(storyboard_items 表)
    • 分镜通过 element_tag_id 关联到标签,而非直接关联资源
    • 关联属性(动作、位置)为 AI 视频生成提供精准 Prompt
  8. 数据流总结

    • AI 拆解的资源直接存储到父级项目资源表
    • 自动创建剧本引用关系(screenplay_element_refs
    • 自动创建分镜并关联标签(storyboards + storyboard_items
    • 重名资源自动去重并累加统计数据,多个剧本共享同一资源
    • 标签关联到父级项目资源,支持跨剧本复用
    • 如需区分同名资源,可在 meta_data 中添加 AI 生成的唯一标识符

3. AI 拆解剧本的存储策略

3.1 业务场景

当用户上传剧本后,AI 会自动拆解剧本内容,提取以下信息:

  • 角色:姓名、性别、年龄、角色类型、外貌描述等
  • 场景:场景名称、室内/室外、时间、氛围等
  • 道具:道具名称、类别、重要性、功能等

在新的项目级资源架构下,AI 拆解的资源需要合理存储,避免重复创建。

3.2 存储策略

3.2.1 直接存储到项目级资源表

设计:改造 ScreenplayService.store_parsed_elements() 方法,将 AI 拆解的资源存储到 project_characters/locations/props

优点

  • 架构统一,所有资源都在项目级管理(无论单集还是多集)
  • 自动去重,避免重复创建同名资源(多集项目)
  • 多个剧本可以共享同一资源(多集项目、续集、番外篇)
  • 支持动态扩展:单集项目上传第2个剧本时,自动更新 episode_count,资源无缝共享
  • 保持方法签名不变,与现有 Celery Worker 兼容

实现

# ScreenplayService.store_parsed_elements() 方法改造
async def store_parsed_elements(
    self,
    screenplay_id: UUID,
    parsed_data: Dict[str, Any]
) -> Dict[str, Any]:
    """
    存储 AI 解析结果到父级项目资源表

    Args:
        screenplay_id: 剧本 ID
        parsed_data: AI 解析结果,包含 characters, locations, props, tags

    Returns:
        包含资源 ID 映射和标签 ID 映射的字典
    """
    try:
        # 0. 获取剧本所属的父级项目 ID
        screenplay = await self.repository.get_by_id(screenplay_id)
        subproject = await self.project_repository.get_by_id(screenplay.project_id)
        parent_project_id = subproject.parent_project_id  # 获取父级项目 ID

        # 1. 存储角色到父级项目资源表
        character_id_map = {}
        for character_data in parsed_data.get('characters', []):
            character_id = generate_uuid_v7()

            # 尝试插入到父级项目,如果重名则跳过
            result = await self.db.execute(
                """
                INSERT INTO project_characters (
                    character_id, project_id, name, description,
                    role_type, is_offscreen, meta_data, order_index
                )
                VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
                ON CONFLICT (project_id, name) DO NOTHING
                RETURNING character_id
                """,
                character_id, parent_project_id, character_data['name'],
                character_data.get('description'), character_data.get('role_type', 2),
                character_data.get('is_offscreen', False),
                character_data.get('meta_data', {}),
                character_data.get('order_index', 0)
            )

            # 如果冲突,查询已存在的资源
            if not result:
                existing = await self.db.fetch_one(
                    """
                    SELECT character_id FROM project_characters
                    WHERE project_id = $1 AND name = $2
                    """,
                    parent_project_id, character_data['name']
                )
                character_id = existing['character_id']

            character_id_map[character_data['name']] = character_id

            # 创建剧本引用关系(子项目的剧本引用父级项目的资源)
            ref_id = generate_uuid_v7()
            await self.db.execute(
                """
                INSERT INTO screenplay_element_refs (
                    ref_id, screenplay_id, element_type, element_id
                )
                VALUES ($1, $2, 1, $3)
                """,
                ref_id, screenplay_id, character_id
            )

        # 2. 存储场景到父级项目资源表
        location_id_map = {}
        for location_data in parsed_data.get('locations', []):
            location_id = generate_uuid_v7()

            result = await self.db.execute(
                """
                INSERT INTO project_locations (
                    location_id, project_id, name, location, description, meta_data
                )
                VALUES ($1, $2, $3, $4, $5, $6)
                ON CONFLICT (project_id, name) DO NOTHING
                RETURNING location_id
                """,
                location_id, parent_project_id, location_data['name'],
                location_data.get('location'), location_data.get('description'),
                location_data.get('meta_data', {})
            )

            if not result:
                existing = await self.db.fetch_one(
                    "SELECT location_id FROM project_locations WHERE project_id = $1 AND name = $2",
                    parent_project_id, location_data['name']
                )
                location_id = existing['location_id']

            location_id_map[location_data['name']] = location_id

            # 创建剧本引用关系
            ref_id = generate_uuid_v7()
            await self.db.execute(
                "INSERT INTO screenplay_element_refs (ref_id, screenplay_id, element_type, element_id) VALUES ($1, $2, 2, $3)",
                ref_id, screenplay_id, location_id
            )

        # 3. 存储道具到父级项目资源表
        prop_id_map = {}
        for prop_data in parsed_data.get('props', []):
            prop_id = generate_uuid_v7()

            result = await self.db.execute(
                """
                INSERT INTO project_props (
                    prop_id, project_id, name, description, meta_data
                )
                VALUES ($1, $2, $3, $4, $5)
                ON CONFLICT (project_id, name) DO NOTHING
                RETURNING prop_id
                """,
                prop_id, parent_project_id, prop_data['name'],
                prop_data.get('description'), prop_data.get('meta_data', {})
            )

            if not result:
                existing = await self.db.fetch_one(
                    "SELECT prop_id FROM project_props WHERE project_id = $1 AND name = $2",
                    parent_project_id, prop_data['name']
                )
                prop_id = existing['prop_id']

            prop_id_map[prop_data['name']] = prop_id

            # 创建剧本引用关系
            ref_id = generate_uuid_v7()
            await self.db.execute(
                "INSERT INTO screenplay_element_refs (ref_id, screenplay_id, element_type, element_id) VALUES ($1, $2, 3, $3)",
                ref_id, screenplay_id, prop_id
            )

        # 4. 存储标签到父级项目标签表
        from app.services.project_element_tag_service import ProjectElementTagService
        tag_service = ProjectElementTagService(self.db)

        tag_id_maps = await tag_service.store_tags(
            parent_project_id,
            parsed_data,
            character_id_map,
            location_id_map,
            prop_id_map
        )

        # 5. 自动创建分镜并关联标签
        from app.services.storyboard_service import StoryboardService
        storyboard_service = StoryboardService(self.db)

        storyboard_ids = await storyboard_service.create_storyboards_from_ai(
            project_id=parent_project_id,
            screenplay_id=screenplay_id,
            storyboards_data=parsed_data.get('storyboards', []),
            tag_id_maps=tag_id_maps
        )

        # 6. 更新剧本统计(注意:这里不再更新 character_count 等字段,因为资源已迁移到项目级)
        await self.repository.update(screenplay_id, {
            'parsing_status': 'completed'
        })

        await self.db.commit()

        return {
            'character_id_map': character_id_map,
            'location_id_map': location_id_map,
            'prop_id_map': prop_id_map,
            'tag_id_maps': tag_id_maps,
            'storyboard_ids': storyboard_ids
        }

    except Exception as e:
        await self.db.rollback()
        await self.repository.update(screenplay_id, {'parsing_status': 'failed'})
        raise Exception(f'数据存储失败: {str(e)}')

3.2.2 重名资源处理策略

场景 1:同一项目的不同剧本提取出同名角色

项目:《权力的游戏》第一季
├─ 剧本 1(第1集)→ AI 提取:琼恩·雪诺
├─ 剧本 2(第2集)→ AI 提取:琼恩·雪诺(同一人)
└─ 剧本 3(第3集)→ AI 提取:琼恩·雪诺(同一人)

处理

  • 第一次提取时,创建项目资源 project_characters.name = "琼恩·雪诺"
  • 后续提取时,检测到重名,跳过创建,直接引用已存在的资源
  • 三个剧本的 screenplay_element_refs 都指向同一个 character_id

场景 2:同一项目的不同剧本提取出同名但不同的角色

项目:《都市剧集》(episode_count = 10)
├─ 剧本 1(第1集)→ AI 提取:张三(主角,30岁男性)
└─ 剧本 2(第2集)→ AI 提取:张三(配角,50岁女性)

处理

  • 第一次提取时,创建项目资源 project_characters.name = "张三"
  • 第二次提取时,检测到重名,但实际是不同的人
  • 解决方案 A:在 meta_data 中添加唯一标识符(如 AI 生成的 entity_id)
  • 解决方案 B:在名称中添加后缀(如"张三(第2集)")
  • 解决方案 C:提供人工审核界面,让用户合并或分离资源

推荐:使用解决方案 A + C 的组合:

# AI 提取时生成唯一标识符
character_data = {
    "name": "张三",
    "meta_data": {
        "ai_entity_id": "char_abc123",  # AI 生成的唯一标识
        "gender": "male",
        "age": 30
    }
}

# 插入时先检查 ai_entity_id
existing = await db.fetch_one(
    """
    SELECT character_id FROM project_characters
    WHERE project_id = $1
      AND (name = $2 OR meta_data->>'ai_entity_id' = $3)
    """,
    project_id, character_data.name, character_data.meta_data.ai_entity_id
)

3.3 与现有架构的兼容性

3.3.1 更新 ADR 003 的数据流

原数据流(ADR 003):

用户上传剧本 → AI 拆解 → 存储到 screenplay_characters/locations/props

新数据流(ADR 012):

用户上传剧本
  ↓
AI 拆解剧本
  ↓
存储到 project_characters/locations/props(项目级)
  ↓
创建 screenplay_element_refs(剧本引用)

3.3.2 迁移现有 AI 拆解逻辑

步骤

  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:创建新表结构(无数据影响)

-- 1. 创建项目级资源表
CREATE TABLE project_characters (...);
CREATE TABLE project_locations (...);
CREATE TABLE project_props (...);

-- 2. 创建剧本引用表
CREATE TABLE screenplay_element_refs (...);

-- 3. 添加索引
CREATE INDEX ...;

风险:无,仅创建新表

Phase 2:数据迁移(只读操作)

-- 迁移角色数据
INSERT INTO project_characters (
    character_id, project_id, name, description,
    character_image_url, role_type, is_offscreen,
    line_count, appearance_count, order_index,
    has_tags, default_tag_id, meta_data,
    created_at, updated_at
)
SELECT
    sc.character_id,
    s.project_id, -- 从剧本获取项目ID
    sc.name,
    sc.description,
    sc.character_image_url,
    sc.role_type,
    sc.is_offscreen,
    sc.line_count,        -- 保留统计字段
    sc.appearance_count,  -- 保留统计字段
    sc.order_index,
    sc.has_tags,
    sc.default_tag_id,
    sc.meta_data,
    sc.created_at,
    sc.updated_at
FROM screenplay_characters sc
JOIN screenplays s ON s.screenplay_id = sc.screenplay_id
ON CONFLICT (project_id, name) DO UPDATE SET
    -- 重名角色时,累加统计数据
    line_count = project_characters.line_count + EXCLUDED.line_count,
    appearance_count = project_characters.appearance_count + EXCLUDED.appearance_count;

-- 迁移场景数据
INSERT INTO project_locations (
    location_id, project_id, name, location, description,
    order_index, has_tags, default_tag_id, meta_data,
    created_at, updated_at
)
SELECT
    sl.location_id,
    s.project_id,
    sl.name,
    sl.location,
    sl.description,
    sl.order_index,
    sl.has_tags,
    sl.default_tag_id,
    sl.meta_data,
    sl.created_at,
    sl.updated_at
FROM screenplay_locations sl
JOIN screenplays s ON s.screenplay_id = sl.screenplay_id
ON CONFLICT (project_id, name) DO NOTHING;

-- 迁移道具数据
INSERT INTO project_props (
    prop_id, project_id, name, description,
    order_index, has_tags, default_tag_id, meta_data,
    created_at, updated_at
)
SELECT
    sp.prop_id,
    s.project_id,
    sp.name,
    sp.description,
    sp.order_index,
    sp.has_tags,
    sp.default_tag_id,
    sp.meta_data,
    sp.created_at,
    sp.updated_at
FROM screenplay_props sp
JOIN screenplays s ON s.screenplay_id = sp.screenplay_id
ON CONFLICT (project_id, name) DO NOTHING;

-- 创建剧本引用关系(角色)
INSERT INTO screenplay_element_refs (
    ref_id, screenplay_id, element_type, element_id, order_index, created_at
)
SELECT
    gen_random_uuid(),  -- 生成新的引用ID
    sc.screenplay_id,
    1,  -- element_type: 1=角色(character)
    sc.character_id,
    sc.order_index,
    sc.created_at
FROM screenplay_characters sc;

-- 创建剧本引用关系(场景)
INSERT INTO screenplay_element_refs (
    ref_id, screenplay_id, element_type, element_id, order_index, created_at
)
SELECT
    gen_random_uuid(),
    sl.screenplay_id,
    2,  -- element_type: 2=场景(location)
    sl.location_id,
    sl.order_index,
    sl.created_at
FROM screenplay_locations sl;

-- 创建剧本引用关系(道具)
INSERT INTO screenplay_element_refs (
    ref_id, screenplay_id, element_type, element_id, order_index, created_at
)
SELECT
    gen_random_uuid(),
    sp.screenplay_id,
    3,  -- element_type: 3=道具(prop)
    sp.prop_id,
    sp.order_index,
    sp.created_at
FROM screenplay_props sp;

处理重名资源

  • 多个剧本有同名角色时,只保留第一个
  • 其他剧本通过引用表关联到同一个项目资源
  • 记录冲突日志供人工审核

风险:中等,需要处理数据冲突

Phase 3:重命名标签表(低风险操作)

-- 1. 重命名标签表(从剧本级改为项目级)
ALTER TABLE screenplay_element_tags RENAME TO project_element_tags;

-- 2. 更新表和列注释
COMMENT ON TABLE project_element_tags IS '项目元素标签表:存储项目资源的变体标签';
COMMENT ON COLUMN project_element_tags.screenplay_id IS '所属剧本ID(保留用于标签来源追溯)';
COMMENT ON COLUMN project_element_tags.element_id IS '元素ID(指向 project_characters/locations/props)';

-- 3. 确认索引存在
CREATE INDEX IF NOT EXISTS idx_project_element_tags_screenplay 
    ON project_element_tags(screenplay_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_element 
    ON project_element_tags(element_type, element_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_element_id 
    ON project_element_tags(element_id);
CREATE INDEX IF NOT EXISTS idx_project_element_tags_key 
    ON project_element_tags(key);

关键说明

  • 无需更新 element_id:因为迁移时保持了资源 ID 不变(character_id/location_id/prop_id)
  • 保留 screenplay_id:用于追溯标签来源(哪个剧本的 AI 拆解生成的)
  • 仅重命名表:反映标签的项目级归属
  • 素材表无需调整project_resources 已经是项目级,通过 element_tag_id 关联标签

风险:低,仅重命名表和更新注释

Phase 4:切换服务层(代码部署)

  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 = 购物车(实际买了什么)
  • 两者的差异 = 项目完成度和偏离度

性能优势

查询剧本资源清单的性能对比

-- 使用 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 创建,不允许手动修改

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 设计

对比"计划"和"执行"

async def get_screenplay_resource_comparison(
    screenplay_id: UUID
) -> Dict[str, Any]:
    """对比剧本计划和分镜执行"""
    # 1. 查询剧本要求的资源
    planned_resources = await screenplay_element_ref_repo.get_by_screenplay(screenplay_id)
    
    # 2. 查询分镜实际使用的资源
    actual_resources = await storyboard_item_repo.get_by_screenplay(screenplay_id)
    
    # 3. 计算差异
    missing = planned_resources - actual_resources  # 剧本提到但未制作
    extra = actual_resources - planned_resources    # 分镜使用但剧本未提到
    
    return {
        'planned': planned_resources,
        'actual': actual_resources,
        'missing': missing,
        'extra': extra,
        'completion_rate': len(actual_resources) / len(planned_resources) * 100
    }

前端展示

剧本编辑器 - 资源清单面板

📋 剧本资源清单(计划)
  ✅ 孙悟空(少年)- 已制作 3 个分镜
  ⚠️ 孙悟空(青年)- 未制作
  ⚠️ 孙悟空(中年)- 未制作
  ✅ 咖啡厅(白天)- 已制作 2 个分镜
  
🎬 实际使用资源(执行)
  ✅ 孙悟空(少年)- 3 个分镜
  ✅ 咖啡厅(白天)- 2 个分镜
  ⚠️ 猪八戒(成年)- 1 个分镜(不在剧本中)
  
📊 项目完成度:33% (1/3 角色已制作)

总结

screenplay_element_refs 不是冗余的,它与 storyboard_items 服务于不同的业务场景:

  • screenplay_element_refs:剧本计划层面,记录"剧本说要用什么"
  • storyboard_items:分镜执行层面,记录"分镜实际用了什么"

两者的差异本身就是有价值的项目管理信息,用于跟踪完成度、发现偏离、指导资源准备。


相关文档

架构决策记录

技术栈规范(jointo-tech-stack)

变更记录

v1.3 (2026-02-07)

  • 补充 screenplay_element_refs 必要性分析
    • 深度分析 screenplay_element_refs 和 storyboard_items 的区别
    • 明确两者是"计划层面"和"执行层面"的不同数据维度
    • 补充性能对比、业务价值、数据完整性分析
    • 补充用户手动添加资源的处理方式
    • 补充对比 API 设计和前端展示示例
    • 结论:screenplay_element_refs 是必要的,应该保留

v1.2 (2026-02-07)

  • 明确父子项目架构:资源归属于父级项目(parent_project_id = NULL),子项目通过 parent_project_id 关联
  • 补充核心概念澄清:详细说明资源、素材、变体标签的区别和关联关系
  • 补充完整关联链路:展示角色素材(有标签)和实拍素材(无标签)的完整数据流
  • 补充动态扩展流程:上传剧本自动创建子项目,episode_count 自动更新
  • 更新资源表设计:所有资源的 project_id 指向父级项目
  • 更新素材表设计:project_resources.project_id 指向父级项目,包含实拍资源(type=4
  • 明确实拍资源归属:实拍资源同样属于父级项目,所有子项目共享
  • 更新 AI 拆解逻辑:资源存储到父级项目,剧本引用关系指向父级资源
  • 删除"项目类型"概念:通过 episode_count 字段动态管理集数
  • 补充架构层级图:清晰展示父级项目、子项目、资源、素材、剧本的关系

v1.1 (2026-02-07)

  • 修正标签系统调整(1.5节):删除错误的字段删除操作,后端已使用 element_type + element_id 统一设计
  • 修正素材表调整(1.6节):删除错误的字段重命名操作,project_resources 通过 element_tag_id 关联标签
  • 修正 Phase 3 迁移脚本:删除基于不存在字段的 UPDATE 语句
  • 补充项目角色表的统计字段:line_count, appearance_count
  • 补充场景和道具表的标签字段:has_tags, default_tag_id
  • 更新数据迁移策略:重名角色累加统计数据

v1.0 (2026-02-07)

  • 初始版本
  • 提出资源归属从剧本级迁移到项目级的方案
  • 定义数据库表结构和迁移策略
  • 分析影响和实现计划

状态:提议中 决策日期:2026-02-07 最后更新:2026-02-07 预计工作量:15-20人天 预计完成时间:2-3周