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.
 

72 KiB

Jointo(jointo)PostgreSQL 数据库设计文档

项目名称:Jointo(jointo)
数据库:PostgreSQL 14+
文档版本:v1.2
创建日期:2025-01-27


目录

  1. 设计原则
  2. 核心实体关系图
  3. 数据表设计
  4. 索引设计
  5. 约束与触发器
  6. 性能优化策略
  7. 数据迁移策略

1. 设计原则

1.1 遵循 PostgreSQL 最佳实践

  • 使用 BIGINT GENERATED ALWAYS AS IDENTITY 作为主键
  • 使用 TIMESTAMPTZ 存储时间戳
  • 使用 TEXT 而非 VARCHAR(n)
  • 使用 NUMERIC 存储精确数值(时间、金额)
  • 使用 JSONB 存储灵活配置数据
  • 为所有外键创建索引
  • 使用 NOT NULL 约束确保数据完整性
  • 使用 CHECK 约束验证业务规则

1.2 规范化设计

  • 第三范式(3NF):消除数据冗余
  • 关联表:多对多关系使用关联表
  • 软删除:使用 deleted_at 而非物理删除
  • 审计字段created_at, updated_at, created_by, updated_by

1.3 扩展性考虑

  • JSONB 字段:存储灵活配置,便于扩展
  • 版本控制:支持项目版本管理
  • 多租户:支持未来 SaaS 扩展
  • 分区策略:大表考虑时间分区

2. 核心实体关系图

┌─────────┐         ┌──────────────┐         ┌─────────────┐
│  Users  │────────<│ProjectMembers│>────────│  Projects   │
└─────────┘         └──────────────┘         └─────────────┘
     │                                              │
     │                                              │
     │                                              │
     │                                              ├──────────┐
     │                                              │          │
     │                                              ▼          │
     │                                        ┌──────────┐    │
     │                                        │Storyboards│    │
     │                                        └──────────┘    │
     │                                              │          │
     │                                              │          │
     │                    ┌─────────────────────────┼──────────┘
     │                    │                         │
     │                    ▼                         ▼
     │              ┌──────────┐            ┌──────────────┐
     │              │Resources │<───────────│Storyboard    │
     │              └──────────┘          │Resources     │
     │                    │                 └──────────────┘
     │                    │
     │                    │
     │              ┌──────────┐
     │              │  Videos  │
     │              └──────────┘
     │                    │
     │                    │
     │              ┌──────────┐
     │              │SoundEffects│
     │              └──────────┘
     │                    │
     │                    │
     │              ┌──────────┐
     │              │ Subtitles│
     │              └──────────┘
     │                    │
     │                    │
     │              ┌──────────┐
     │              │Voiceovers│
     │              └──────────┘
     │                    │
     │                    │
     │              ┌──────────┐
     │              │TimelineTracks│
     │              └──────────┘
     │                    │
     │                    │
     │              ┌──────────┐
     │              │ Scripts  │◄──────┐
     │              └──────────┘       │
     │                    │            │
     │                    │            │
     │              ┌──────────┐       │
     │              │Attachments│──────┘
     │              └──────────┘
     │                    │
     │                    │
     │              ┌──────────┐
     │              │TimelineItems│
     │              └──────────┘
     │
     │
     ├──────────┐
     │          │
     │          ▼
     │    ┌──────────┐
     │    │ AIJobs   │
     │    └──────────┘
     │
     │
     ├──────────┐
     │          │
     │          ▼
     │    ┌──────────┐
     │    │Comments  │
     │    └──────────┘
     │
     │
     └──────────┐
                │
                ▼
          ┌──────────┐
          │Notifications│
          └──────────┘

3. 数据表设计

3.1 用户与认证

3.1.1 users(用户表)

CREATE TABLE users (
    user_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT NOT NULL,
    username TEXT NOT NULL,
    password_hash TEXT NOT NULL,
    avatar_url TEXT,
    subscription_tier TEXT NOT NULL DEFAULT 'free' CHECK (subscription_tier IN ('free', 'pro', 'enterprise')),
    subscription_expires_at TIMESTAMPTZ,
    ai_credits_remaining INTEGER NOT NULL DEFAULT 10,
    ai_credits_total INTEGER NOT NULL DEFAULT 10,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    CONSTRAINT users_email_unique UNIQUE (email) NULLS NOT DISTINCT,
    CONSTRAINT users_username_unique UNIQUE (username) NULLS NOT DISTINCT
);

-- 索引
CREATE UNIQUE INDEX idx_users_email_lower ON users (LOWER(email)) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_username_lower ON users (LOWER(username)) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_subscription_tier ON users (subscription_tier) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_created_at ON users (created_at) WHERE deleted_at IS NULL;

-- 更新时间触发器
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_users_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 使用 BIGINT GENERATED ALWAYS AS IDENTITY 作为主键
  • 邮箱和用户名使用 LOWER() 索引实现大小写不敏感
  • 软删除使用 deleted_at
  • 订阅信息存储在用户表中,便于快速查询
  • AI 积分系统支持按量付费

3.1.2 user_sessions(用户会话表)

CREATE TABLE user_sessions (
    session_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    token TEXT NOT NULL UNIQUE,
    refresh_token TEXT,
    expires_at TIMESTAMPTZ NOT NULL,
    ip_address INET,
    user_agent TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_user_sessions_user_id ON user_sessions (user_id);
CREATE INDEX idx_user_sessions_token ON user_sessions (token);
CREATE INDEX idx_user_sessions_expires_at ON user_sessions (expires_at);

3.2 文件夹管理

3.2.1 folders(文件夹表)

CREATE TABLE folders (
    folder_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    parent_folder_id BIGINT REFERENCES folders(folder_id) ON DELETE CASCADE,
    owner_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    path TEXT, -- 缓存路径,如 "/folder1/folder2/folder3"
    level INTEGER NOT NULL DEFAULT 0 CHECK (level >= 0 AND level <= 10), -- 层级深度,限制最大10层
    sort_order INTEGER NOT NULL DEFAULT 0, -- 同级排序
    color TEXT, -- 文件夹颜色(十六进制)
    icon TEXT, -- 文件夹图标名称
    is_shared BOOLEAN NOT NULL DEFAULT false, -- 是否共享文件夹
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    -- 确保同一父文件夹下名称唯一
    CONSTRAINT folders_name_unique UNIQUE (parent_folder_id, name) NULLS NOT DISTINCT
);

-- 索引
CREATE INDEX idx_folders_parent_folder_id ON folders (parent_folder_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_owner_id ON folders (owner_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_path ON folders (path) WHERE deleted_at IS NULL AND path IS NOT NULL;
CREATE INDEX idx_folders_level ON folders (level) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_sort_order ON folders (parent_folder_id, sort_order) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_is_shared ON folders (is_shared) WHERE deleted_at IS NULL AND is_shared = true;
-- 全文搜索索引
CREATE INDEX idx_folders_name_trgm ON folders USING GIN (name gin_trgm_ops) WHERE deleted_at IS NULL;

-- 触发器
CREATE TRIGGER update_folders_updated_at
    BEFORE UPDATE ON folders
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- 路径更新触发器
CREATE OR REPLACE FUNCTION update_folder_path()
RETURNS TRIGGER AS $$
DECLARE
    parent_path TEXT;
BEGIN
    -- 如果是根文件夹
    IF NEW.parent_folder_id IS NULL THEN
        NEW.path = '/' || NEW.name;
        NEW.level = 0;
    ELSE
        -- 获取父文件夹路径和层级
        SELECT f.path, f.level INTO parent_path, NEW.level
        FROM folders f
        WHERE f.folder_id = NEW.parent_folder_id;

        NEW.path = parent_path || '/' || NEW.name;
        NEW.level = NEW.level + 1;

        -- 检查层级限制
        IF NEW.level > 10 THEN
            RAISE EXCEPTION '文件夹层级不能超过10层';
        END IF;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_folder_path_trigger
    BEFORE INSERT OR UPDATE ON folders
    FOR EACH ROW
    EXECUTE FUNCTION update_folder_path();

设计说明

  • 使用邻接列表模式存储树形结构
  • path 字段缓存完整路径,提升查询性能
  • level 字段记录层级深度,限制最大10层
  • 支持文件夹颜色和图标自定义
  • 同一父文件夹下名称唯一约束

3.2.2 folder_members(文件夹成员表)

CREATE TABLE folder_members (
    folder_member_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    folder_id BIGINT NOT NULL REFERENCES folders(folder_id) ON DELETE CASCADE,
    user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    role member_role NOT NULL DEFAULT 'viewer',
    inherited BOOLEAN NOT NULL DEFAULT false, -- 是否从父文件夹继承权限
    invited_by BIGINT REFERENCES users(user_id),
    joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT folder_members_unique UNIQUE (folder_id, user_id) NULLS NOT DISTINCT
);

-- 索引
CREATE INDEX idx_folder_members_folder_id ON folder_members (folder_id);
CREATE INDEX idx_folder_members_user_id ON folder_members (user_id);
CREATE INDEX idx_folder_members_role ON folder_members (role);
CREATE INDEX idx_folder_members_inherited ON folder_members (inherited);

设计说明

  • 支持文件夹级别的协作权限
  • inherited 字段标记是否继承父文件夹权限
  • 权限角色复用项目的 member_role 枚举

3.3 项目管理

3.3.1 projects(项目表)

CREATE TYPE project_type AS ENUM ('mine', 'collab');

CREATE TABLE projects (
    project_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    type project_type NOT NULL DEFAULT 'mine',
    owner_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE RESTRICT,
    folder_id BIGINT REFERENCES folders(folder_id) ON DELETE SET NULL, -- 所属文件夹
    thumbnail_url TEXT,
    settings JSONB NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(settings) = 'object'),
    -- settings 结构: {"resolution": {"width": 1920, "height": 1080}, "frameRate": 30, "duration": 60, "exportFormat": "mp4", "exportQuality": "high"}
    status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleted')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    -- 确保同一文件夹下项目名称唯一
    CONSTRAINT projects_name_unique UNIQUE (folder_id, name) NULLS NOT DISTINCT
);

-- 索引
CREATE INDEX idx_projects_owner_id ON projects (owner_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_folder_id ON projects (folder_id) WHERE deleted_at IS NULL AND folder_id IS NOT NULL;
CREATE INDEX idx_projects_type ON projects (type) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_status ON projects (status) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_created_at ON projects (created_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_projects_updated_at ON projects (updated_at) WHERE deleted_at IS NULL;
-- JSONB 索引(用于查询设置)
CREATE INDEX idx_projects_settings_gin ON projects USING GIN (settings);
-- 全文搜索索引
CREATE INDEX idx_projects_name_trgm ON projects USING GIN (name gin_trgm_ops) WHERE deleted_at IS NULL;

-- 触发器
CREATE TRIGGER update_projects_updated_at
    BEFORE UPDATE ON projects
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 新增 folder_id 字段关联文件夹
  • 项目可以不属于任何文件夹(根目录项目)
  • 同一文件夹下项目名称唯一约束

设计说明

  • 使用 ENUM 类型定义项目类型
  • settings 使用 JSONB 存储灵活配置
  • 使用 GIN 索引加速 JSONB 查询
  • 软删除支持

3.3.2 project_members(项目成员表)

CREATE TYPE member_role AS ENUM ('owner', 'editor', 'viewer');

CREATE TABLE project_members (
    member_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    role member_role NOT NULL DEFAULT 'viewer',
    invited_by BIGINT REFERENCES users(user_id),
    joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT project_members_unique UNIQUE (project_id, user_id) NULLS NOT DISTINCT
);

-- 索引
CREATE INDEX idx_project_members_project_id ON project_members (project_id);
CREATE INDEX idx_project_members_user_id ON project_members (user_id);
CREATE INDEX idx_project_members_role ON project_members (role);

设计说明

  • 使用 ENUM 定义角色类型
  • 唯一约束确保用户不会重复加入同一项目
  • 记录邀请人信息

3.3.3 project_versions(项目版本表)

CREATE TABLE project_versions (
    version_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    version_number INTEGER NOT NULL,
    snapshot_data JSONB NOT NULL, -- 完整的项目快照数据
    created_by BIGINT NOT NULL REFERENCES users(user_id),
    note TEXT, -- 版本备注
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT project_versions_unique UNIQUE (project_id, version_number)
);

-- 索引
CREATE INDEX idx_project_versions_project_id ON project_versions (project_id);
CREATE INDEX idx_project_versions_created_at ON project_versions (created_at);
CREATE INDEX idx_project_versions_snapshot_gin ON project_versions USING GIN (snapshot_data);

设计说明

  • 版本号在项目内唯一
  • 使用 JSONB 存储完整快照,便于恢复
  • GIN 索引支持快照数据查询

3.4 分镜管理

3.4.1 storyboards(分镜表)

CREATE TABLE storyboards (
    storyboard_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    description TEXT NOT NULL,
    thumbnail_url TEXT,
    order_index INTEGER NOT NULL, -- 分镜顺序
    start_time NUMERIC(10, 3) NOT NULL DEFAULT 0, -- 开始时间(秒,支持毫秒)
    end_time NUMERIC(10, 3) NOT NULL, -- 结束时间(秒)
    transition_type TEXT, -- 转场类型
    transition_duration NUMERIC(5, 2), -- 转场时长(秒)
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT storyboards_order_unique UNIQUE (project_id, order_index) DEFERRABLE INITIALLY DEFERRED,
    CONSTRAINT storyboards_time_check CHECK (end_time > start_time)
);

-- 索引
CREATE INDEX idx_storyboards_project_id ON storyboards (project_id);
CREATE INDEX idx_storyboards_order ON storyboards (project_id, order_index);
CREATE INDEX idx_storyboards_time_range ON storyboards USING GIST (numrange(start_time, end_time));

-- 触发器
CREATE TRIGGER update_storyboards_updated_at
    BEFORE UPDATE ON storyboards
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 使用 NUMERIC(10, 3) 精确存储时间(支持毫秒)
  • 使用 DEFERRABLE INITIALLY DEFERRED 处理顺序调整时的唯一约束冲突
  • 使用 GiST 索引支持时间范围查询
  • 转场信息存储在分镜表中

3.5 资源管理

3.5.1 project_resources(项目素材表)

CREATE TYPE resource_type AS ENUM ('character', 'scene', 'prop', 'real');

CREATE TABLE project_resources (
    project_resource_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    type resource_type NOT NULL,

    -- 文件信息
    file_url TEXT NOT NULL,
    thumbnail_url TEXT,
    file_size BIGINT, -- 文件大小(字节)
    mime_type TEXT,
    width INTEGER, -- 图片宽度
    height INTEGER, -- 图片高度
    checksum TEXT NOT NULL, -- 文件校验和(SHA256)

    -- 来源(可选,后期扩展)
    source_resource_id BIGINT, -- 如果是从资源库复制的(后期扩展)

    -- AI 生成
    ai_job_id BIGINT REFERENCES ai_jobs(ai_job_id) ON DELETE SET NULL,
    metadata JSONB NOT NULL DEFAULT '{}', -- AI 生成参数等

    -- 审计
    created_by BIGINT NOT NULL REFERENCES users(user_id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ
);

-- 索引
CREATE INDEX idx_project_resources_project_id ON project_resources (project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_project_resources_type ON project_resources (type) WHERE deleted_at IS NULL;
CREATE INDEX idx_project_resources_created_by ON project_resources (created_by) WHERE deleted_at IS NULL;
CREATE INDEX idx_project_resources_checksum ON project_resources (checksum);
CREATE INDEX idx_project_resources_ai_job_id ON project_resources (ai_job_id) WHERE ai_job_id IS NOT NULL;
CREATE INDEX idx_project_resources_metadata_gin ON project_resources USING GIN (metadata);
-- 全文搜索索引(资源名称)
CREATE INDEX idx_project_resources_name_trgm ON project_resources USING GIN (name gin_trgm_ops) WHERE deleted_at IS NULL;

-- 触发器
CREATE TRIGGER update_project_resources_updated_at
    BEFORE UPDATE ON project_resources
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 项目专属素材表,每个素材归属一个项目
  • 使用 ENUM 定义资源类型:
    • character(角色):AI 生成或上传的角色素材
    • scene(场景):AI 生成或上传的场景素材
    • prop(道具):AI 生成或上传的道具素材
    • real(实拍):用户上传的实拍图片或视频
  • 支持 AI 生成素材(关联 ai_job_id)
  • 使用 checksum 支持去重
  • source_resource_id 字段预留,供后期资源库功能扩展

3.5.2 resources(系统资源库表 - 后期扩展)

-- 注意:此表暂不实现,保留设计供后期扩展
CREATE TABLE resources (
    resource_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    type resource_type NOT NULL,

    -- 文件信息
    file_url TEXT NOT NULL,
    thumbnail_url TEXT,
    file_size BIGINT,
    mime_type TEXT,
    width INTEGER,
    height INTEGER,
    checksum TEXT NOT NULL,

    -- 素材市场属性
    is_public BOOLEAN NOT NULL DEFAULT false, -- 是否公开到素材市场
    is_official BOOLEAN NOT NULL DEFAULT false, -- 是否官方资源
    price NUMERIC(10, 2), -- 价格
    download_count INTEGER NOT NULL DEFAULT 0, -- 下载次数
    rating NUMERIC(3, 2), -- 评分

    -- AI 生成
    ai_job_id BIGINT REFERENCES ai_jobs(ai_job_id),
    metadata JSONB NOT NULL DEFAULT '{}',

    -- 审计
    created_by BIGINT REFERENCES users(user_id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ
);

-- 索引(后期实现)
CREATE INDEX idx_resources_type ON resources (type) WHERE deleted_at IS NULL;
CREATE INDEX idx_resources_is_public ON resources (is_public) WHERE deleted_at IS NULL AND is_public = true;
CREATE INDEX idx_resources_is_official ON resources (is_official) WHERE deleted_at IS NULL AND is_official = true;
CREATE INDEX idx_resources_rating ON resources (rating DESC) WHERE deleted_at IS NULL AND is_public = true;

设计说明

  • 系统资源库 + 素材市场功能
  • 支持官方资源(is_official)和用户上传资源(is_public)
  • 支持定价、评分、下载统计
  • 前期开发暂不实现,保留表结构供后续扩展

3.5.3 storyboard_resources(分镜资源关联表)

CREATE TABLE storyboard_resources (
    storyboard_resource_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    storyboard_id BIGINT NOT NULL REFERENCES storyboards(storyboard_id) ON DELETE CASCADE,
    project_resource_id BIGINT NOT NULL REFERENCES project_resources(project_resource_id) ON DELETE CASCADE,
    resource_type resource_type NOT NULL, -- 冗余字段,便于查询
    display_order INTEGER NOT NULL DEFAULT 0, -- 显示顺序
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT storyboard_resources_unique UNIQUE (storyboard_id, project_resource_id) NULLS NOT DISTINCT
);

-- 索引
CREATE INDEX idx_storyboard_resources_storyboard_id ON storyboard_resources (storyboard_id);
CREATE INDEX idx_storyboard_resources_project_resource_id ON storyboard_resources (project_resource_id);
CREATE INDEX idx_storyboard_resources_type ON storyboard_resources (resource_type);

设计说明

  • 分镜与项目素材的多对多关联表
  • 关联 project_resources 表(项目专属素材)
  • resource_type 冗余存储,避免 JOIN 查询
  • 支持显示顺序

3.6 视频管理

3.6.1 videos(视频表)

CREATE TYPE video_generation_type AS ENUM ('img2video', 'text2video', 'keyframe', 'fusion', 'replace', 'real');

CREATE TABLE videos (
    video_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    storyboard_id BIGINT REFERENCES storyboards(storyboard_id) ON DELETE SET NULL,
    name TEXT NOT NULL,
    type video_generation_type NOT NULL,
    video_url TEXT,
    thumbnail_url TEXT,
    duration NUMERIC(10, 3), -- 视频时长(秒)
    file_size BIGINT, -- 文件大小(字节)
    width INTEGER,
    height INTEGER,
    frame_rate NUMERIC(5, 2), -- 帧率
    start_time NUMERIC(10, 3) NOT NULL DEFAULT 0, -- 在分镜看板上的开始时间
    end_time NUMERIC(10, 3) NOT NULL, -- 在分镜看板上的结束时间
    metadata JSONB NOT NULL DEFAULT '{}', -- AI 生成参数、提示词等
    -- metadata 结构: {"prompt": "...", "model": "...", "parameters": {...}, "aiJobId": 123}
    status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
    ai_job_id BIGINT, -- 关联 AI 任务
    created_by BIGINT NOT NULL REFERENCES users(user_id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT videos_time_check CHECK (end_time > start_time)
);

-- 索引
CREATE INDEX idx_videos_project_id ON videos (project_id);
CREATE INDEX idx_videos_storyboard_id ON videos (storyboard_id) WHERE storyboard_id IS NOT NULL;
CREATE INDEX idx_videos_type ON videos (type);
CREATE INDEX idx_videos_status ON videos (status);
CREATE INDEX idx_videos_time_range ON videos USING GIST (numrange(start_time, end_time));
CREATE INDEX idx_videos_created_by ON videos (created_by);
CREATE INDEX idx_videos_metadata_gin ON videos USING GIN (metadata);
CREATE INDEX idx_videos_ai_job_id ON videos (ai_job_id) WHERE ai_job_id IS NOT NULL;

-- 触发器
CREATE TRIGGER update_videos_updated_at
    BEFORE UPDATE ON videos
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 视频可以关联分镜,也可以独立存在
  • 使用 numrange 和 GiST 索引支持时间范围查询
  • 支持 AI 生成状态跟踪
  • 元数据存储 AI 生成参数

3.7 音效管理

3.7.1 sound_effects(音效表)

CREATE TABLE sound_effects (
    sound_effect_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    audio_url TEXT,
    duration NUMERIC(10, 3), -- 音效时长(秒)
    file_size BIGINT,
    start_time NUMERIC(10, 3) NOT NULL DEFAULT 0, -- 在分镜看板上的开始时间
    end_time NUMERIC(10, 3) NOT NULL, -- 在分镜看板上的结束时间
    volume INTEGER NOT NULL DEFAULT 100 CHECK (volume >= 0 AND volume <= 100), -- 音量 0-100
    fade_in NUMERIC(5, 2) DEFAULT 0, -- 淡入时长(秒)
    fade_out NUMERIC(5, 2) DEFAULT 0, -- 淡出时长(秒)
    metadata JSONB NOT NULL DEFAULT '{}', -- AI 生成参数等
    status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
    ai_job_id BIGINT,
    created_by BIGINT NOT NULL REFERENCES users(user_id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT sound_effects_time_check CHECK (end_time > start_time)
);

-- 索引
CREATE INDEX idx_sound_effects_project_id ON sound_effects (project_id);
CREATE INDEX idx_sound_effects_time_range ON sound_effects USING GIST (numrange(start_time, end_time));
CREATE INDEX idx_sound_effects_status ON sound_effects (status);
CREATE INDEX idx_sound_effects_created_by ON sound_effects (created_by);

-- 触发器
CREATE TRIGGER update_sound_effects_updated_at
    BEFORE UPDATE ON sound_effects
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

3.8 字幕管理

3.8.1 subtitles(字幕表)

CREATE TABLE subtitles (
    subtitle_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    text TEXT NOT NULL,
    start_time NUMERIC(10, 3) NOT NULL DEFAULT 0,
    end_time NUMERIC(10, 3) NOT NULL,
    style JSONB NOT NULL DEFAULT '{}', -- 样式配置
    -- style 结构: {"fontSize": 16, "color": "#FFFFFF", "position": "bottom", "backgroundColor": "#000000", "fontFamily": "Arial"}
    metadata JSONB NOT NULL DEFAULT '{}', -- AI 生成参数等
    status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
    ai_job_id BIGINT,
    created_by BIGINT NOT NULL REFERENCES users(user_id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT subtitles_time_check CHECK (end_time > start_time)
);

-- 索引
CREATE INDEX idx_subtitles_project_id ON subtitles (project_id);
CREATE INDEX idx_subtitles_time_range ON subtitles USING GIST (numrange(start_time, end_time));
CREATE INDEX idx_subtitles_text_trgm ON subtitles USING GIN (text gin_trgm_ops); -- 全文搜索
CREATE INDEX idx_subtitles_status ON subtitles (status);

-- 触发器
CREATE TRIGGER update_subtitles_updated_at
    BEFORE UPDATE ON subtitles
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

3.9 配音管理

3.9.1 voiceovers(配音表)

CREATE TABLE voiceovers (
    voiceover_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    text TEXT NOT NULL,
    audio_url TEXT,
    duration NUMERIC(10, 3), -- 配音时长(秒)
    file_size BIGINT,
    start_time NUMERIC(10, 3) NOT NULL DEFAULT 0,
    end_time NUMERIC(10, 3) NOT NULL,
    voice_type TEXT, -- 语音类型(如:male_01, female_01)
    voice_speed NUMERIC(3, 2) NOT NULL DEFAULT 1.0, -- 语速倍数
    volume INTEGER NOT NULL DEFAULT 100 CHECK (volume >= 0 AND volume <= 100),
    metadata JSONB NOT NULL DEFAULT '{}', -- AI 生成参数、语音克隆 ID 等
    status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
    ai_job_id BIGINT,
    created_by BIGINT NOT NULL REFERENCES users(user_id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT voiceovers_time_check CHECK (end_time > start_time)
);

-- 索引
CREATE INDEX idx_voiceovers_project_id ON voiceovers (project_id);
CREATE INDEX idx_voiceovers_time_range ON voiceovers USING GIST (numrange(start_time, end_time));
CREATE INDEX idx_voiceovers_status ON voiceovers (status);
CREATE INDEX idx_voiceovers_voice_type ON voiceovers (voice_type) WHERE voice_type IS NOT NULL;

-- 触发器
CREATE TRIGGER update_voiceovers_updated_at
    BEFORE UPDATE ON voiceovers
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

3.10 分镜看板管理

3.10.1 timeline_tracks(分镜看板轨道表)

CREATE TYPE track_type AS ENUM ('storyboard', 'resource', 'video', 'sound', 'subtitle', 'voice');

CREATE TABLE timeline_tracks (
    track_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    type track_type NOT NULL,
    name TEXT NOT NULL,
    display_order INTEGER NOT NULL DEFAULT 0, -- 显示顺序
    visible BOOLEAN NOT NULL DEFAULT true,
    locked BOOLEAN NOT NULL DEFAULT false,
    height INTEGER NOT NULL DEFAULT 60, -- 轨道高度(像素)
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT timeline_tracks_order_unique UNIQUE (project_id, display_order) DEFERRABLE INITIALLY DEFERRED
);

-- 索引
CREATE INDEX idx_timeline_tracks_project_id ON timeline_tracks (project_id);
CREATE INDEX idx_timeline_tracks_type ON timeline_tracks (type);
CREATE INDEX idx_timeline_tracks_order ON timeline_tracks (project_id, display_order);

-- 触发器
CREATE TRIGGER update_timeline_tracks_updated_at
    BEFORE UPDATE ON timeline_tracks
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

3.10.2 timeline_items(分镜看板项表)

CREATE TABLE timeline_items (
    timeline_item_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    track_id BIGINT NOT NULL REFERENCES timeline_tracks(track_id) ON DELETE CASCADE,
    item_type track_type NOT NULL,
    -- 关联到具体项的 ID(多态关联)
    storyboard_id BIGINT REFERENCES storyboards(storyboard_id) ON DELETE CASCADE,
    video_id BIGINT REFERENCES videos(video_id) ON DELETE CASCADE,
    sound_effect_id BIGINT REFERENCES sound_effects(sound_effect_id) ON DELETE CASCADE,
    subtitle_id BIGINT REFERENCES subtitles(subtitle_id) ON DELETE CASCADE,
    voiceover_id BIGINT REFERENCES voiceovers(voiceover_id) ON DELETE CASCADE,
    resource_id BIGINT REFERENCES resources(resource_id) ON DELETE CASCADE,
    -- 分镜看板上的位置
    start_time NUMERIC(10, 3) NOT NULL DEFAULT 0,
    end_time NUMERIC(10, 3) NOT NULL,
    display_order INTEGER NOT NULL DEFAULT 0, -- 同一轨道内的显示顺序
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT timeline_items_time_check CHECK (end_time > start_time),
    -- 确保只有一个关联项
    CONSTRAINT timeline_items_ref_check CHECK (
        (item_type = 'storyboard' AND storyboard_id IS NOT NULL AND video_id IS NULL AND sound_effect_id IS NULL AND subtitle_id IS NULL AND voiceover_id IS NULL AND resource_id IS NULL) OR
        (item_type = 'video' AND video_id IS NOT NULL AND storyboard_id IS NULL AND sound_effect_id IS NULL AND subtitle_id IS NULL AND voiceover_id IS NULL AND resource_id IS NULL) OR
        (item_type = 'sound' AND sound_effect_id IS NOT NULL AND storyboard_id IS NULL AND video_id IS NULL AND subtitle_id IS NULL AND voiceover_id IS NULL AND resource_id IS NULL) OR
        (item_type = 'subtitle' AND subtitle_id IS NOT NULL AND storyboard_id IS NULL AND video_id IS NULL AND sound_effect_id IS NULL AND voiceover_id IS NULL AND resource_id IS NULL) OR
        (item_type = 'voice' AND voiceover_id IS NOT NULL AND storyboard_id IS NULL AND video_id IS NULL AND sound_effect_id IS NULL AND subtitle_id IS NULL AND resource_id IS NULL) OR
        (item_type = 'resource' AND resource_id IS NOT NULL AND storyboard_id IS NULL AND video_id IS NULL AND sound_effect_id IS NULL AND subtitle_id IS NULL AND voiceover_id IS NULL)
    )
);

-- 索引
CREATE INDEX idx_timeline_items_track_id ON timeline_items (track_id);
CREATE INDEX idx_timeline_items_storyboard_id ON timeline_items (storyboard_id) WHERE storyboard_id IS NOT NULL;
CREATE INDEX idx_timeline_items_video_id ON timeline_items (video_id) WHERE video_id IS NOT NULL;
CREATE INDEX idx_timeline_items_sound_effect_id ON timeline_items (sound_effect_id) WHERE sound_effect_id IS NOT NULL;
CREATE INDEX idx_timeline_items_subtitle_id ON timeline_items (subtitle_id) WHERE subtitle_id IS NOT NULL;
CREATE INDEX idx_timeline_items_voiceover_id ON timeline_items (voiceover_id) WHERE voiceover_id IS NOT NULL;
CREATE INDEX idx_timeline_items_resource_id ON timeline_items (resource_id) WHERE resource_id IS NOT NULL;
CREATE INDEX idx_timeline_items_time_range ON timeline_items USING GIST (numrange(start_time, end_time));
CREATE INDEX idx_timeline_items_type ON timeline_items (item_type);

-- 触发器
CREATE TRIGGER update_timeline_items_updated_at
    BEFORE UPDATE ON timeline_items
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 使用多态关联(多个可空外键)实现不同类型项的关联
  • 使用 CHECK 约束确保只有一个关联项非空
  • 使用 GiST 索引支持时间范围查询

3.11 AI 任务管理

3.11.1 ai_jobs(AI 任务表)

CREATE TYPE ai_job_type AS ENUM ('image', 'video', 'sound', 'subtitle', 'voice', 'resource', 'storyboard_script', 'script_generation');
CREATE TYPE ai_job_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'cancelled');

CREATE TABLE ai_jobs (
    ai_job_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    project_id BIGINT REFERENCES projects(project_id) ON DELETE SET NULL,
    storyboard_id BIGINT REFERENCES storyboards(storyboard_id) ON DELETE SET NULL,
    job_type ai_job_type NOT NULL,
    status ai_job_status NOT NULL DEFAULT 'pending',
    prompt TEXT NOT NULL,
    model TEXT, -- 使用的 AI 模型
    parameters JSONB NOT NULL DEFAULT '{}', -- AI 生成参数
    result JSONB, -- 生成结果(URL、ID 等)
    error_message TEXT,
    progress INTEGER NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100), -- 进度 0-100
    estimated_completion_at TIMESTAMPTZ,
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    cost NUMERIC(10, 4), -- 成本(元)
    credits_used INTEGER NOT NULL DEFAULT 0, -- 使用的积分
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 索引
CREATE INDEX idx_ai_jobs_user_id ON ai_jobs (user_id);
CREATE INDEX idx_ai_jobs_project_id ON ai_jobs (project_id) WHERE project_id IS NOT NULL;
CREATE INDEX idx_ai_jobs_storyboard_id ON ai_jobs (storyboard_id) WHERE storyboard_id IS NOT NULL;
CREATE INDEX idx_ai_jobs_type ON ai_jobs (job_type);
CREATE INDEX idx_ai_jobs_status ON ai_jobs (status);
CREATE INDEX idx_ai_jobs_created_at ON ai_jobs (created_at);
CREATE INDEX idx_ai_jobs_status_created_at ON ai_jobs (status, created_at) WHERE status IN ('pending', 'processing');
CREATE INDEX idx_ai_jobs_parameters_gin ON ai_jobs USING GIN (parameters);
CREATE INDEX idx_ai_jobs_result_gin ON ai_jobs USING GIN (result) WHERE result IS NOT NULL;

-- 触发器
CREATE TRIGGER update_ai_jobs_updated_at
    BEFORE UPDATE ON ai_jobs
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 支持多种 AI 任务类型
  • 跟踪任务状态和进度
  • 记录成本和积分使用
  • 支持结果和参数的 JSONB 查询

3.12 协作功能

3.12.1 comments(评论表)

CREATE TABLE comments (
    comment_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    -- 多态关联:可以评论项目、分镜、视频等
    storyboard_id BIGINT REFERENCES storyboards(storyboard_id) ON DELETE CASCADE,
    video_id BIGINT REFERENCES videos(video_id) ON DELETE CASCADE,
    sound_effect_id BIGINT REFERENCES sound_effects(sound_effect_id) ON DELETE CASCADE,
    subtitle_id BIGINT REFERENCES subtitles(subtitle_id) ON DELETE CASCADE,
    voiceover_id BIGINT REFERENCES voiceovers(voiceover_id) ON DELETE CASCADE,
    timeline_item_id BIGINT REFERENCES timeline_items(timeline_item_id) ON DELETE CASCADE,
    -- 评论内容
    content TEXT NOT NULL,
    -- 父评论 ID(支持回复)
    parent_comment_id BIGINT REFERENCES comments(comment_id) ON DELETE CASCADE,
    -- 提及的用户(数组)
    mentioned_user_ids BIGINT[],
    -- 作者
    author_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    -- 位置信息(分镜看板上的位置)
    time_position NUMERIC(10, 3),
    -- 状态
    is_resolved BOOLEAN NOT NULL DEFAULT false,
    resolved_by BIGINT REFERENCES users(user_id),
    resolved_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    -- 确保只有一个关联项
    CONSTRAINT comments_ref_check CHECK (
        (storyboard_id IS NOT NULL AND video_id IS NULL AND sound_effect_id IS NULL AND subtitle_id IS NULL AND voiceover_id IS NULL AND timeline_item_id IS NULL) OR
        (video_id IS NOT NULL AND storyboard_id IS NULL AND sound_effect_id IS NULL AND subtitle_id IS NULL AND voiceover_id IS NULL AND timeline_item_id IS NULL) OR
        (sound_effect_id IS NOT NULL AND storyboard_id IS NULL AND video_id IS NULL AND subtitle_id IS NULL AND voiceover_id IS NULL AND timeline_item_id IS NULL) OR
        (subtitle_id IS NOT NULL AND storyboard_id IS NULL AND video_id IS NULL AND sound_effect_id IS NULL AND voiceover_id IS NULL AND timeline_item_id IS NULL) OR
        (voiceover_id IS NOT NULL AND storyboard_id IS NULL AND video_id IS NULL AND sound_effect_id IS NULL AND subtitle_id IS NULL AND timeline_item_id IS NULL) OR
        (timeline_item_id IS NOT NULL AND storyboard_id IS NULL AND video_id IS NULL AND sound_effect_id IS NULL AND subtitle_id IS NULL AND voiceover_id IS NULL) OR
        (storyboard_id IS NULL AND video_id IS NULL AND sound_effect_id IS NULL AND subtitle_id IS NULL AND voiceover_id IS NULL AND timeline_item_id IS NULL) -- 项目级评论
    )
);

-- 索引
CREATE INDEX idx_comments_project_id ON comments (project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_comments_storyboard_id ON comments (storyboard_id) WHERE storyboard_id IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_comments_video_id ON comments (video_id) WHERE video_id IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_comments_parent_comment_id ON comments (parent_comment_id) WHERE parent_comment_id IS NOT NULL;
CREATE INDEX idx_comments_author_id ON comments (author_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_comments_created_at ON comments (created_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_comments_mentioned_user_ids_gin ON comments USING GIN (mentioned_user_ids) WHERE mentioned_user_ids IS NOT NULL;

-- 触发器
CREATE TRIGGER update_comments_updated_at
    BEFORE UPDATE ON comments
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 支持多态关联(评论项目、分镜、视频等)
  • 支持回复(父评论)
  • 支持 @ 提及(数组存储)
  • 支持分镜看板位置标记

3.12.2 notifications(通知表)

CREATE TYPE notification_type AS ENUM ('project', 'comment', 'mention', 'system', 'ai_job_completed', 'collaboration');

CREATE TABLE notifications (
    notification_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    type notification_type NOT NULL,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    -- 关联数据(JSONB)
    related_data JSONB,
    -- related_data 结构: {"projectId": 123, "commentId": 456, "aiJobId": 789, ...}
    is_read BOOLEAN NOT NULL DEFAULT false,
    read_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 索引
CREATE INDEX idx_notifications_user_id ON notifications (user_id);
CREATE INDEX idx_notifications_user_id_unread ON notifications (user_id, is_read) WHERE is_read = false;
CREATE INDEX idx_notifications_type ON notifications (type);
CREATE INDEX idx_notifications_created_at ON notifications (created_at);
CREATE INDEX idx_notifications_related_data_gin ON notifications USING GIN (related_data);

设计说明

  • 使用 ENUM 定义通知类型
  • 使用 JSONB 存储关联数据,灵活支持不同类型
  • 部分索引优化未读通知查询

3.13 模板与素材市场

3.13.1 templates(模板表)

CREATE TABLE templates (
    template_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    category TEXT NOT NULL, -- 'advertisement', 'promotion', 'short_video', 'tutorial'
    thumbnail_url TEXT,
    template_data JSONB NOT NULL, -- 模板数据(项目结构)
    is_public BOOLEAN NOT NULL DEFAULT false,
    is_official BOOLEAN NOT NULL DEFAULT false, -- 是否官方模板
    price NUMERIC(10, 2) NOT NULL DEFAULT 0,
    download_count INTEGER NOT NULL DEFAULT 0,
    rating NUMERIC(3, 2) NOT NULL DEFAULT 0 CHECK (rating >= 0 AND rating <= 5),
    rating_count INTEGER NOT NULL DEFAULT 0,
    created_by BIGINT REFERENCES users(user_id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ
);

-- 索引
CREATE INDEX idx_templates_category ON templates (category) WHERE deleted_at IS NULL;
CREATE INDEX idx_templates_is_public ON templates (is_public) WHERE deleted_at IS NULL AND is_public = true;
CREATE INDEX idx_templates_is_official ON templates (is_official) WHERE deleted_at IS NULL AND is_official = true;
CREATE INDEX idx_templates_rating ON templates (rating DESC) WHERE deleted_at IS NULL AND is_public = true;
CREATE INDEX idx_templates_download_count ON templates (download_count DESC) WHERE deleted_at IS NULL AND is_public = true;
CREATE INDEX idx_templates_template_data_gin ON templates USING GIN (template_data);

3.13.2 template_purchases(模板购买记录表)

CREATE TABLE template_purchases (
    purchase_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    template_id BIGINT NOT NULL REFERENCES templates(template_id) ON DELETE RESTRICT,
    user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    price NUMERIC(10, 2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT template_purchases_unique UNIQUE (template_id, user_id) NULLS NOT DISTINCT
);

CREATE INDEX idx_template_purchases_template_id ON template_purchases (template_id);
CREATE INDEX idx_template_purchases_user_id ON template_purchases (user_id);

3.14 导出任务

3.14.1 export_jobs(导出任务表)

CREATE TYPE export_format AS ENUM ('mp4', 'mov', 'avi', 'webm', 'json', 'pdf');
CREATE TYPE export_quality AS ENUM ('low', 'medium', 'high', 'ultra');
CREATE TYPE export_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'cancelled');

CREATE TABLE export_jobs (
    export_job_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    format export_format NOT NULL,
    quality export_quality NOT NULL DEFAULT 'high',
    resolution_width INTEGER,
    resolution_height INTEGER,
    include_watermark BOOLEAN NOT NULL DEFAULT false,
    status export_status NOT NULL DEFAULT 'pending',
    output_url TEXT, -- 导出文件 URL
    file_size BIGINT,
    progress INTEGER NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
    error_message TEXT,
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    expires_at TIMESTAMPTZ, -- 下载链接过期时间
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 索引
CREATE INDEX idx_export_jobs_project_id ON export_jobs (project_id);
CREATE INDEX idx_export_jobs_user_id ON export_jobs (user_id);
CREATE INDEX idx_export_jobs_status ON export_jobs (status);
CREATE INDEX idx_export_jobs_status_created_at ON export_jobs (status, created_at) WHERE status IN ('pending', 'processing');
CREATE INDEX idx_export_jobs_expires_at ON export_jobs (expires_at) WHERE expires_at IS NOT NULL;

3.15 剧本管理

3.15.1 scripts(剧本表)

CREATE TYPE script_type AS ENUM ('text', 'file');
CREATE TYPE script_status AS ENUM ('draft', 'review', 'approved', 'archived');

CREATE TABLE scripts (
    script_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    project_id BIGINT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    type script_type NOT NULL,

    -- 文本剧本字段
    content TEXT, -- 剧本文本内容(Markdown/纯文本)

    -- 文件剧本字段
    file_url TEXT,
    file_size BIGINT,
    mime_type TEXT,
    checksum TEXT, -- SHA256 校验和,配合 file_checksums 表去重
    storage_path TEXT,

    -- 元数据
    version INTEGER NOT NULL DEFAULT 1, -- 版本号
    word_count INTEGER DEFAULT 0, -- 字数统计
    scene_count INTEGER DEFAULT 0, -- 场景数量
    character_count INTEGER DEFAULT 0, -- 角色数量

    -- AI 生成相关
    ai_job_id BIGINT REFERENCES ai_jobs(ai_job_id) ON DELETE SET NULL,
    ai_prompt TEXT,

    -- 状态管理
    status script_status NOT NULL DEFAULT 'draft',

    -- 协作字段
    created_by BIGINT NOT NULL REFERENCES users(user_id),
    updated_by BIGINT REFERENCES users(user_id),
    approved_by BIGINT REFERENCES users(user_id),
    approved_at TIMESTAMPTZ,

    -- 审计字段
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,

    -- 约束:content 和 file_url 二选一
    CONSTRAINT scripts_content_check CHECK (
        (type = 'text' AND content IS NOT NULL AND file_url IS NULL) OR
        (type = 'file' AND file_url IS NOT NULL AND content IS NULL)
    )
);

-- 索引
CREATE INDEX idx_scripts_project_id ON scripts (project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_scripts_type ON scripts (type) WHERE deleted_at IS NULL;
CREATE INDEX idx_scripts_status ON scripts (status) WHERE deleted_at IS NULL;
CREATE INDEX idx_scripts_created_by ON scripts (created_by) WHERE deleted_at IS NULL;
CREATE INDEX idx_scripts_ai_job_id ON scripts (ai_job_id) WHERE ai_job_id IS NOT NULL;
CREATE INDEX idx_scripts_checksum ON scripts (checksum) WHERE checksum IS NOT NULL;
-- 全文搜索索引(剧本名称和内容)
CREATE INDEX idx_scripts_name_trgm ON scripts USING GIN (name gin_trgm_ops) WHERE deleted_at IS NULL;
CREATE INDEX idx_scripts_content_trgm ON scripts USING GIN (content gin_trgm_ops) WHERE deleted_at IS NULL AND content IS NOT NULL;

-- 触发器
CREATE TRIGGER update_scripts_updated_at
    BEFORE UPDATE ON scripts
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 使用 ENUM 定义剧本类型(text, file)和状态
  • 支持文本和文件两种剧本形式
  • 文件剧本支持格式:TXT、DOC、DOCX、PDF、RTF、Markdown
  • 文件剧本直接存储文件信息(file_url, file_size, checksum 等)
  • checksum 字段配合 file_checksums 表实现去重
  • 与 videos、project_resources 表保持一致的设计模式
  • 使用 CHECK 约束确保 content 和 file_url 二选一
  • 支持版本号、字数统计等元数据
  • 支持 AI 生成剧本的关联
  • 支持审批流程(draft -> review -> approved -> archived)
  • 全文搜索支持剧本名称和内容

3.15.2 script_versions(剧本版本历史表)

CREATE TABLE script_versions (
    version_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    script_id BIGINT NOT NULL REFERENCES scripts(script_id) ON DELETE CASCADE,
    version_number INTEGER NOT NULL,
    content_snapshot TEXT, -- 内容快照
    change_summary TEXT, -- 变更摘要
    word_count INTEGER DEFAULT 0,
    scene_count INTEGER DEFAULT 0,
    character_count INTEGER DEFAULT 0,
    created_by BIGINT NOT NULL REFERENCES users(user_id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT script_versions_unique UNIQUE (script_id, version_number)
);

-- 索引
CREATE INDEX idx_script_versions_script_id ON script_versions (script_id);
CREATE INDEX idx_script_versions_created_at ON script_versions (created_at);
CREATE INDEX idx_script_versions_created_by ON script_versions (created_by);

设计说明

  • 记录剧本的每个版本快照
  • 版本号在剧本内唯一
  • 保存变更摘要,便于追溯
  • 支持版本对比和回滚

3.15.3 script_characters(剧本角色表)

注意:此表设计已过时。最新的剧本管理表设计(使用 UUID v7 和 SMALLINT 枚举)请参考 剧本管理服务文档

CREATE TYPE character_role_type AS ENUM ('main', 'supporting', 'extra');

CREATE TABLE script_characters (
    character_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    script_id BIGINT NOT NULL REFERENCES scripts(script_id) ON DELETE CASCADE,
    name TEXT NOT NULL, -- 角色名
    description TEXT, -- 角色描述
    character_image_url VARCHAR(500), -- 角色形象图片地址
    role_type character_role_type NOT NULL DEFAULT 'supporting',
    line_count INTEGER DEFAULT 0, -- 台词数量
    appearance_count INTEGER DEFAULT 0, -- 出场次数
    metadata JSONB NOT NULL DEFAULT '{}', -- 额外元数据(性格、背景等)
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT script_characters_name_unique UNIQUE (script_id, name) NULLS NOT DISTINCT
);

-- 索引
CREATE INDEX idx_script_characters_script_id ON script_characters (script_id);
CREATE INDEX idx_script_characters_role_type ON script_characters (role_type);
CREATE INDEX idx_script_characters_name_trgm ON script_characters USING GIN (name gin_trgm_ops);

-- 触发器
CREATE TRIGGER update_script_characters_updated_at
    BEFORE UPDATE ON script_characters
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 管理剧本中的角色信息
  • 支持角色类型分类(主角、配角、群演)
  • 统计台词数量和出场次数
  • 同一剧本内角色名称唯一

3.15.4 script_scenes(剧本场景表)

注意:此表设计已过时。最新的剧本管理表设计(使用 UUID v7 和 SMALLINT 枚举)请参考 剧本管理服务文档

CREATE TYPE time_of_day_type AS ENUM ('dawn', 'morning', 'noon', 'afternoon', 'dusk', 'night');

CREATE TABLE script_scenes (
    scene_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    script_id BIGINT NOT NULL REFERENCES scripts(script_id) ON DELETE CASCADE,
    scene_number INTEGER NOT NULL, -- 场景编号
    title TEXT NOT NULL, -- 场景标题
    location TEXT, -- 场景地点
    time_of_day time_of_day_type, -- 时间(白天/夜晚)
    description TEXT, -- 场景描述
    duration_estimate NUMERIC(10, 3), -- 预估时长(秒)
    order_index INTEGER NOT NULL, -- 排序
    metadata JSONB NOT NULL DEFAULT '{}', -- 额外元数据
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT script_scenes_order_unique UNIQUE (script_id, order_index) DEFERRABLE INITIALLY DEFERRED
);

-- 索引
CREATE INDEX idx_script_scenes_script_id ON script_scenes (script_id);
CREATE INDEX idx_script_scenes_order ON script_scenes (script_id, order_index);
CREATE INDEX idx_script_scenes_scene_number ON script_scenes (script_id, scene_number);

-- 触发器
CREATE TRIGGER update_script_scenes_updated_at
    BEFORE UPDATE ON script_scenes
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 管理剧本中的场景信息
  • 支持场景编号和排序
  • 记录场景地点、时间、描述
  • 预估场景时长,便于制作规划

3.16 附件管理

3.16.1 attachments(附件表)

CREATE TYPE attachment_category AS ENUM ('document', 'image');

CREATE TABLE attachments (
    attachment_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL, -- 文件名
    original_name TEXT NOT NULL, -- 原始文件名
    file_url TEXT NOT NULL, -- 文件URL
    file_size BIGINT NOT NULL, -- 文件大小(字节)
    mime_type TEXT NOT NULL, -- MIME类型

    -- 文件类型分类
    category attachment_category NOT NULL,

    -- 文件元数据
    extension TEXT, -- 文件扩展名 (.pdf, .docx, .txt, .jpg, .png)
    checksum TEXT NOT NULL, -- 文件校验和(SHA256)

    -- 关联信息(一对多)
    project_id BIGINT REFERENCES projects(project_id) ON DELETE CASCADE,

    -- 访问控制
    is_public BOOLEAN NOT NULL DEFAULT false, -- 是否公开
    access_count INTEGER NOT NULL DEFAULT 0, -- 访问次数
    download_count INTEGER NOT NULL DEFAULT 0, -- 下载次数

    -- 存储信息
    storage_provider TEXT, -- 存储提供商(MinIO/S3/OSS)
    storage_path TEXT, -- 存储路径

    -- 审计字段
    uploaded_by BIGINT NOT NULL REFERENCES users(user_id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    expires_at TIMESTAMPTZ -- 过期时间(临时文件)
);

-- 索引
CREATE INDEX idx_attachments_project_id ON attachments (project_id) WHERE deleted_at IS NULL AND project_id IS NOT NULL;
CREATE INDEX idx_attachments_category ON attachments (category) WHERE deleted_at IS NULL;
CREATE INDEX idx_attachments_uploaded_by ON attachments (uploaded_by) WHERE deleted_at IS NULL;
CREATE INDEX idx_attachments_created_at ON attachments (created_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_attachments_expires_at ON attachments (expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX idx_attachments_checksum ON attachments (checksum);
-- 全文搜索索引(文件名)
CREATE INDEX idx_attachments_name_trgm ON attachments USING GIN (name gin_trgm_ops) WHERE deleted_at IS NULL;
CREATE INDEX idx_attachments_original_name_trgm ON attachments USING GIN (original_name gin_trgm_ops) WHERE deleted_at IS NULL;

-- 触发器
CREATE TRIGGER update_attachments_updated_at
    BEFORE UPDATE ON attachments
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

设计说明

  • 专注于文档类附件管理
  • 分类简化为 document(文档)和 image(图片,用于头像、封面等)
  • 支持一对多关联(project_id, script_id)
  • checksum 必填,支持去重
  • 移除 user_id 关联(改为业务表存储,见下文)
  • 记录访问和下载次数
  • 支持临时文件(expires_at)

3.16.2 file_checksums(文件去重表)

CREATE TABLE file_checksums (
    checksum TEXT PRIMARY KEY,
    file_url TEXT NOT NULL,
    file_size BIGINT NOT NULL,
    mime_type TEXT NOT NULL,
    storage_provider TEXT,
    storage_path TEXT NOT NULL,
    reference_count INTEGER NOT NULL DEFAULT 1,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_file_checksums_file_url ON file_checksums (file_url);
CREATE INDEX idx_file_checksums_reference_count ON file_checksums (reference_count);
CREATE INDEX idx_file_checksums_last_accessed ON file_checksums (last_accessed_at);

设计说明

  • 全局文件去重表
  • 跨所有表(attachments, project_resources, videos 等)去重
  • 记录引用计数,便于清理无引用文件
  • 记录最后访问时间,便于清理过期文件

3.16.3 业务表新增字段(一对一关联)

users 表新增字段

ALTER TABLE users ADD COLUMN avatar_id BIGINT REFERENCES attachments(attachment_id) ON DELETE SET NULL;
CREATE INDEX idx_users_avatar_id ON users (avatar_id) WHERE avatar_id IS NOT NULL;

projects 表新增字段

ALTER TABLE projects ADD COLUMN cover_image_id BIGINT REFERENCES attachments(attachment_id) ON DELETE SET NULL;
CREATE INDEX idx_projects_cover_image_id ON projects (cover_image_id) WHERE cover_image_id IS NOT NULL;

storyboards 表新增字段

ALTER TABLE storyboards ADD COLUMN thumbnail_id BIGINT REFERENCES attachments(attachment_id) ON DELETE SET NULL;
CREATE INDEX idx_storyboards_thumbnail_id ON storyboards (thumbnail_id) WHERE thumbnail_id IS NOT NULL;

设计说明

  • 一对一关系使用业务表存储附件ID
  • 避免在 attachments 表中使用多态关联
  • 语义清晰:users.avatar_id 明确表示"这是我的头像"
  • 查询性能好:不需要 JOIN attachments 表即可获取ID

4. 索引设计

4.1 索引策略总结

  1. 主键索引:自动创建 B-tree 索引
  2. 外键索引:所有外键列都创建索引(PostgreSQL 不自动创建)
  3. 唯一索引:唯一约束自动创建索引
  4. 查询索引:根据常见查询模式创建索引
  5. 部分索引:对软删除数据使用 WHERE deleted_at IS NULL
  6. 复合索引:多列查询使用复合索引
  7. GIN 索引:JSONB、数组、全文搜索
  8. GiST 索引:范围查询(时间范围)

4.2 需要安装的扩展

-- 全文搜索(模糊搜索)
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- 如果需要向量搜索(AI 推荐)
-- CREATE EXTENSION IF NOT EXISTS vector;

5. 约束与触发器

5.1 常用约束

  • NOT NULL:必填字段
  • CHECK:业务规则验证(时间范围、数值范围等)
  • UNIQUE:唯一性约束
  • FOREIGN KEY:外键约束,指定 ON DELETE 行为
  • ENUM:枚举类型约束

5.2 触发器

  • updated_at 自动更新:所有表都有 updated_at 字段,通过触发器自动更新
  • 软删除:通过 deleted_at 实现软删除
  • 路径自动更新:文件夹路径和层级自动计算

5.3 文件夹权限检查函数

-- 检查用户是否有文件夹权限(包含继承权限)
CREATE OR REPLACE FUNCTION check_folder_permission(
    p_user_id BIGINT,
    p_folder_id BIGINT,
    p_required_role member_role DEFAULT 'viewer'
)
RETURNS BOOLEAN AS $$
DECLARE
    v_user_role member_role;
    v_folder_owner_id BIGINT;
    v_parent_folder_id BIGINT;
    v_role_priority INTEGER;
    v_required_priority INTEGER;
BEGIN
    -- 检查是否是文件夹所有者
    SELECT owner_id INTO v_folder_owner_id
    FROM folders
    WHERE folder_id = p_folder_id AND deleted_at IS NULL;

    IF v_folder_owner_id = p_user_id THEN
        RETURN true;
    END IF;

    -- 检查直接权限
    SELECT role INTO v_user_role
    FROM folder_members
    WHERE folder_id = p_folder_id AND user_id = p_user_id;

    -- 如果没有直接权限,检查继承权限
    IF v_user_role IS NULL THEN
        SELECT parent_folder_id INTO v_parent_folder_id
        FROM folders
        WHERE folder_id = p_folder_id;

        -- 递归检查父文件夹权限
        IF v_parent_folder_id IS NOT NULL THEN
            RETURN check_folder_permission(p_user_id, v_parent_folder_id, p_required_role);
        END IF;

        RETURN false;
    END IF;

    -- 角色优先级比较
    v_role_priority := CASE v_user_role
        WHEN 'owner' THEN 3
        WHEN 'editor' THEN 2
        WHEN 'viewer' THEN 1
        ELSE 0
    END;

    v_required_priority := CASE p_required_role
        WHEN 'owner' THEN 3
        WHEN 'editor' THEN 2
        WHEN 'viewer' THEN 1
        ELSE 0
    END;

    RETURN v_role_priority >= v_required_priority;
END;
$$ LANGUAGE plpgsql STABLE;

-- 检查用户是否有项目权限(包含文件夹继承权限)
CREATE OR REPLACE FUNCTION check_project_permission(
    p_user_id BIGINT,
    p_project_id BIGINT,
    p_required_role member_role DEFAULT 'viewer'
)
RETURNS BOOLEAN AS $$
DECLARE
    v_user_role member_role;
    v_project_owner_id BIGINT;
    v_folder_id BIGINT;
    v_role_priority INTEGER;
    v_required_priority INTEGER;
BEGIN
    -- 检查是否是项目所有者
    SELECT owner_id, folder_id INTO v_project_owner_id, v_folder_id
    FROM projects
    WHERE project_id = p_project_id AND deleted_at IS NULL;

    IF v_project_owner_id = p_user_id THEN
        RETURN true;
    END IF;

    -- 检查项目直接权限
    SELECT role INTO v_user_role
    FROM project_members
    WHERE project_id = p_project_id AND user_id = p_user_id;

    IF v_user_role IS NOT NULL THEN
        v_role_priority := CASE v_user_role
            WHEN 'owner' THEN 3
            WHEN 'editor' THEN 2
            WHEN 'viewer' THEN 1
            ELSE 0
        END;

        v_required_priority := CASE p_required_role
            WHEN 'owner' THEN 3
            WHEN 'editor' THEN 2
            WHEN 'viewer' THEN 1
            ELSE 0
        END;

        IF v_role_priority >= v_required_priority THEN
            RETURN true;
        END IF;
    END IF;

    -- 如果项目在文件夹中,检查文件夹权限
    IF v_folder_id IS NOT NULL THEN
        RETURN check_folder_permission(p_user_id, v_folder_id, p_required_role);
    END IF;

    RETURN false;
END;
$$ LANGUAGE plpgsql STABLE;

-- 获取用户可访问的文件夹列表(递归CTE)
CREATE OR REPLACE FUNCTION get_user_accessible_folders(p_user_id BIGINT)
RETURNS TABLE(folder_id BIGINT, name TEXT, path TEXT, level INTEGER) AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE accessible_folders AS (
        -- 用户拥有的根文件夹
        SELECT f.folder_id, f.name, f.path, f.level, f.parent_folder_id
        FROM folders f
        WHERE f.owner_id = p_user_id
          AND f.deleted_at IS NULL
          AND f.parent_folder_id IS NULL

        UNION ALL

        -- 用户有权限的根文件夹
        SELECT f.folder_id, f.name, f.path, f.level, f.parent_folder_id
        FROM folders f
        INNER JOIN folder_members fm ON f.folder_id = fm.folder_id
        WHERE fm.user_id = p_user_id
          AND f.deleted_at IS NULL
          AND f.parent_folder_id IS NULL

        UNION ALL

        -- 递归获取子文件夹
        SELECT f.folder_id, f.name, f.path, f.level, f.parent_folder_id
        FROM folders f
        INNER JOIN accessible_folders af ON f.parent_folder_id = af.folder_id
        WHERE f.deleted_at IS NULL
    )
    SELECT af.folder_id, af.name, af.path, af.level
    FROM accessible_folders af
    ORDER BY af.path;
END;
$$ LANGUAGE plpgsql STABLE;

-- 防止循环引用的检查函数
CREATE OR REPLACE FUNCTION check_folder_cycle(
    p_folder_id BIGINT,
    p_new_parent_id BIGINT
)
RETURNS BOOLEAN AS $$
DECLARE
    v_current_id BIGINT;
BEGIN
    -- 如果新父文件夹为空,不会产生循环
    IF p_new_parent_id IS NULL THEN
        RETURN false;
    END IF;

    -- 不能将文件夹移动到自己
    IF p_folder_id = p_new_parent_id THEN
        RETURN true;
    END IF;

    -- 检查新父文件夹的祖先链中是否包含当前文件夹
    v_current_id := p_new_parent_id;

    WHILE v_current_id IS NOT NULL LOOP
        IF v_current_id = p_folder_id THEN
            RETURN true; -- 发现循环
        END IF;

        SELECT parent_folder_id INTO v_current_id
        FROM folders
        WHERE folder_id = v_current_id;
    END LOOP;

    RETURN false; -- 无循环
END;
$$ LANGUAGE plpgsql STABLE;

5.3 示例:项目成员权限检查函数

-- 检查用户是否有项目权限
CREATE OR REPLACE FUNCTION check_project_permission(
    p_user_id BIGINT,
    p_project_id BIGINT,
    p_required_role member_role DEFAULT 'viewer'
)
RETURNS BOOLEAN AS $$
DECLARE
    v_user_role member_role;
    v_role_priority INTEGER;
    v_required_priority INTEGER;
BEGIN
    -- 获取用户角色
    SELECT role INTO v_user_role
    FROM project_members
    WHERE project_id = p_project_id AND user_id = p_user_id;

    -- 如果是项目所有者,直接返回 true
    SELECT owner_id INTO v_user_role
    FROM projects
    WHERE project_id = p_project_id;

    IF v_user_role = p_user_id THEN
        RETURN true;
    END IF;

    -- 角色优先级:owner > editor > viewer
    v_role_priority := CASE v_user_role
        WHEN 'owner' THEN 3
        WHEN 'editor' THEN 2
        WHEN 'viewer' THEN 1
        ELSE 0
    END;

    v_required_priority := CASE p_required_role
        WHEN 'owner' THEN 3
        WHEN 'editor' THEN 2
        WHEN 'viewer' THEN 1
        ELSE 0
    END;

    RETURN v_role_priority >= v_required_priority;
END;
$$ LANGUAGE plpgsql STABLE;

6. 性能优化策略

6.1 分区策略

对于大表,考虑按时间分区:

-- 示例:AI 任务表按时间分区(如果数据量很大)
CREATE TABLE ai_jobs (
    -- ... 字段定义
) PARTITION BY RANGE (created_at);

CREATE TABLE ai_jobs_2024_q1 PARTITION OF ai_jobs
    FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE ai_jobs_2024_q2 PARTITION OF ai_jobs
    FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
-- ...

6.2 物化视图

对于复杂查询,使用物化视图:

-- 项目统计信息物化视图
CREATE MATERIALIZED VIEW project_statistics AS
SELECT
    p.project_id,
    p.name,
    COUNT(DISTINCT s.storyboard_id) AS storyboard_count,
    COUNT(DISTINCT v.video_id) AS video_count,
    COUNT(DISTINCT se.sound_effect_id) AS sound_effect_count,
    COUNT(DISTINCT st.subtitle_id) AS subtitle_count,
    COUNT(DISTINCT vo.voiceover_id) AS voiceover_count,
    COUNT(DISTINCT pm.user_id) AS member_count
FROM projects p
LEFT JOIN storyboards s ON s.project_id = p.project_id
LEFT JOIN videos v ON v.project_id = p.project_id
LEFT JOIN sound_effects se ON se.project_id = p.project_id
LEFT JOIN subtitles st ON st.project_id = p.project_id
LEFT JOIN voiceovers vo ON vo.project_id = p.project_id
LEFT JOIN project_members pm ON pm.project_id = p.project_id
WHERE p.deleted_at IS NULL
GROUP BY p.project_id, p.name;

CREATE UNIQUE INDEX ON project_statistics (project_id);

-- 定期刷新(通过定时任务)
-- REFRESH MATERIALIZED VIEW CONCURRENTLY project_statistics;

6.3 连接池配置

  • 使用 PgBouncer 或 pgpool-II 进行连接池管理
  • 配置合理的连接数限制

6.4 查询优化建议

  1. 避免 N+1 查询:使用 JOIN 或批量查询
  2. 使用 EXPLAIN ANALYZE:分析查询计划
  3. 合理使用索引:避免过度索引
  4. 批量操作:使用 COPY 或批量 INSERT

7. 数据迁移策略

7.1 使用 Alembic(Python)或 Flyway(Java)

推荐使用 Alembic 进行数据库迁移管理。

7.2 迁移脚本示例

-- migrations/001_initial_schema.sql
-- 创建所有表、索引、约束

-- migrations/002_add_ai_jobs.sql
-- 添加 AI 任务表

-- migrations/003_add_comments.sql
-- 添加评论功能

7.3 数据迁移注意事项

  1. 向后兼容:新字段使用 DEFAULTNULL
  2. 分步迁移:大表迁移分步进行
  3. 回滚方案:准备回滚脚本
  4. 测试环境:先在测试环境验证

8. 安全考虑

8.1 行级安全(RLS)

-- 启用行级安全
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- 项目访问策略
CREATE POLICY project_access_policy ON projects
    FOR SELECT
    USING (
        owner_id = current_setting('app.current_user_id')::BIGINT
        OR project_id IN (
            SELECT project_id FROM project_members
            WHERE user_id = current_setting('app.current_user_id')::BIGINT
        )
    );

8.2 数据加密

  • 传输加密:使用 SSL/TLS
  • 存储加密:敏感字段加密存储
  • 密码哈希:使用 pgcrypto 扩展

9. 监控与维护

9.1 慢查询监控

-- 启用慢查询日志
ALTER SYSTEM SET log_min_duration_statement = 1000; -- 记录超过 1 秒的查询

9.2 统计信息

-- 更新统计信息
ANALYZE;

-- 查看表大小
SELECT
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;

9.3 定期维护

  • VACUUM:清理死元组
  • REINDEX:重建索引
  • ANALYZE:更新统计信息

10. 总结

10.1 设计亮点

  1. 规范化设计:遵循 3NF,减少冗余
  2. 灵活扩展:JSONB 存储灵活配置
  3. 性能优化:合理的索引策略
  4. 关联完整:所有关联关系都有外键约束
  5. 软删除:支持数据恢复
  6. 审计追踪:记录创建和更新时间
  7. 多态关联:支持灵活的关联关系

10.2 后续优化方向

  1. 分区:大表按时间分区
  2. 缓存:Redis 缓存热点数据
  3. 读写分离:主从复制
  4. 分库分表:如果数据量极大

10.3 剧本和附件模块设计亮点

  1. 灵活的剧本类型:支持文本和附件两种形式,满足不同输入方式
  2. 完整的版本管理:剧本版本历史表支持版本追溯和回滚
  3. 结构化剧本数据:角色表和场景表支持剧本的结构化管理
  4. 通用附件系统:附件表可复用于多种场景(剧本、合同、参考文档等)
  5. AI 生成支持:剧本表关联 AI 任务,支持 AI 辅助创作
  6. 审批流程:剧本状态管理支持草稿、审核、批准流程
  7. 全文搜索:剧本内容、角色名、场景信息均支持全文搜索
  8. 文件安全:附件表支持校验和、访问控制、过期时间

文档版本:v1.2
最后更新:2025-01-27
变更记录

v1.2 (2025-01-27)

  • 重构资源管理架构:
    • 新增 project_resources 表(项目专属素材)
    • 保留 resources 表设计(系统资源库+素材市场,后期扩展)
    • 更新 storyboard_resources 关联到 project_resources
  • 优化附件管理:
    • 简化 attachments 表分类(document, image)
    • 移除 user_id 关联,改为业务表存储(users.avatar_id, projects.cover_image_id, storyboards.thumbnail_id)
    • 新增 script_id 关联
    • checksum 字段改为必填
  • 新增 file_checksums 表(全局文件去重)
  • 新增业务表字段:
    • users.avatar_id
    • projects.cover_image_id
    • storyboards.thumbnail_id

v1.1 (2025-01-27)

  • 新增剧本管理模块(scripts、script_versions、script_characters、script_scenes)
  • 新增附件管理模块(attachments)
  • 更新 AI 任务类型,新增 script_generation
  • 更新核心实体关系图

v1.0 (2025-01-27)

  • 初始版本
  • 完整的数据库设计文档

下一步:根据本文档创建 Alembic 迁移脚本,初始化数据库