状态:实施中(身份、组合校验、File 一对一生命周期和完整性扫描已落地;生产迁移仍需先执行盘点)
适用范围:Media、Track、File、媒体导入与同步、媒体删除与回收、封面生成、播放解析、相关文档和测试。
Stationary 当前采用以下媒体资产模型:
Library
└── Post
└── Media 逻辑媒体资产,post_id 可以为空
└── Track 内容轨、音频轨、字幕、封面、缩略图和变体
└── File? S3 中的物理文件
Media 表示用户可见的逻辑资产,Track 表示该资产的一个物理组成部分或变体,File 表示实际存储对象。该模型用于支持:
- 独立上传且不绑定 Post 的媒体;
- 一个视频同时拥有视频轨、音频轨、字幕和封面;
- Live Photo 的图片轨和视频轨;
- 多质量、多语言和多编码版本;
- DASH / HLS 播放;
- 异步下载、封面生成和软删除。
当前模型方向不需要推翻重做。改进重点是身份语义、应用层一致性、软删除生命周期、Track 元数据边界和历史结构清理。
以下内容是本任务书的前提,不属于待决策事项。
数据库中不引入 .references()、显式 FOREIGN KEY、物理级联删除或依赖数据库外键的跨服务事务。
Post、Media、Track、File 之间的关系属于逻辑关系:
- Drizzle
relations仅用于查询和类型推导; - 写入前由应用层检查父对象和租户范围;
- 删除由应用层执行软删除;
- 后台通过完整性扫描发现异常记录;
- 任何 Worker 或 API 都不得假设数据库会自动级联清理。
用户不需要填写 external_id。必须区分请求输入和持久化结果:
- 外部同步媒体:调用方提供稳定 ID,或按照确定算法生成稳定 ID;
- 手动上传媒体:服务端生成稳定 ID;
- 进入领域服务和数据库前,
Media.eid必须是非空字符串; - 不允许用
sort_order作为身份匹配 fallback; - 不允许使用每次请求都会变化的随机值解决外部同步幂等问题;
- 如果外部同步既没有外部 ID,也无法推导稳定身份,应拒绝请求。
当前数据库列名为 eid,API 常用名称为 external_id。本阶段可以保留数据库列名,但必须统一二者的语义和映射。
post_id = NULL 的 Media 不是异常数据,而是正式的独立媒体:
- 必须拥有
library_id; - 必须拥有服务端生成的
eid; sort_order只用于展示,通常为0;- 可以拥有完整的 Track、File、标签、AI 元数据和封面;
- 不得因为没有 Post 而被同步、搜索、删除或封面任务错误过滤。
已经被 Track/File 取代的旧 URL 字段和兼容逻辑,在确认数据不再使用后直接移除,不增加长期 fallback、双写或迁移兼容层。
Media.eid虽然是NOT NULL,但空字符串仍然可以写入。- Task API 的
external_id目前是 optional。 TaskService.saveMetadata在缺少外部 ID 时写入空字符串。- 同步逻辑在缺少外部 ID 时会退化到
sort_order。 sort_order是可变展示字段,不适合作为资产身份。- Media 没有针对其业务身份的 active 唯一约束。
- 手动上传、外部同步和独立媒体目前没有统一的身份生成入口。
涉及文件:
apps/server/src/api/task.tsapps/server/src/services/task.tsapps/server/src/api/import.tsapps/server/src/db/schema/index.tsdocs/external_api.contract.zh-Hans.md
在不使用外键的前提下,仍需要处理以下逻辑关系:
- Media 的
post_id对应的 Post 是否存在; - Media 的
library_id是否与所属 Post 的library_id一致; - Track 的
media_id是否对应有效 Media; - Track 的
file_id是否对应有效 File; source_track_id是否指向允许的源 Track;- 标签关联是否跨越了错误的 Library;
- 搜索、AI、任务和封面记录是否仍指向可见的实体。
当前这些约束分散在 API、Service 和任务处理器中,缺少统一的写入检查和定期对账机制。
以下规则目前主要由部分 API 的 Valibot 校验负责,数据库和所有写入路径没有统一保证:
- IMAGE Media 至少拥有有效的 IMAGE CONTENT Track;
- VIDEO Media 至少拥有有效的 VIDEO CONTENT Track;
- LIVE_PHOTO 至少拥有默认 IMAGE CONTENT 和默认 VIDEO CONTENT;
- AUDIO Media 至少拥有默认 AUDIO CONTENT;
- PDF Media 至少拥有默认 PDF CONTENT;
- COVER 和 THUMBNAIL 应使用 IMAGE Track;
- SUBTITLE 和 PDF 应使用 CONTENT purpose;
- Track 的
variant_key必须非空; - Track 的
priority不应为负数; - 一个 Media 下每个
(type, purpose)最多一个 active default; - 一个 Media 下最多一个 active primary;
is_original和is_generated的组合必须有明确语义。
当前业务不支持复用同一个 File。每个 Track、Author 头像、Library 封面和 Draft 上传都使用各自的物理 File。File 的生命周期由其所属业务记录控制,不需要跨业务记录执行引用计数。
需要保证:
- 删除 Post 或 Media 时,在同一事务中删除对应的 Track 和 File 状态;
- Track 替换 File 后,旧 File 进入删除状态;
- 封面替换、同步失败、重试和回收站清理使用一致的 File 生命周期;
- Track 引用不存在的 File 时能够被识别;
- 过期的 DELETED File 可以由 purge 任务清理。
Track 的格式和展示信息同时存在于独立列、metadata JSONB 和 streams JSONB 中,例如:
codec/metadata.codecs;language/metadata.language;width、height、duration、bandwidth/ metadata 对应字段;container、is_fragmented、stream_layout/ metadata 对应字段;source_track_id/metadata.source_track_id。
当前有 cleanTrackMetadata 和 deriveTrackFormat,但同步、TrackService、封面服务存在多个写入入口,
仍然可能产生列和 JSON 不一致。
需要处理以下明确的历史债务:
Media.primary_url、alternative_url、live_photo_url、cover_url已被标记 deprecated;track_quality的历史 migration 包含ORIGINAL,当前 TypeScriptTrackQualityEnum不包含它;- 当前原始性同时由
quality、is_original、is_generated表达; - 旧文档仍描述 File 保存图片尺寸和视频时长,但这些字段已经迁移到 Track;
- 旧文档仍把 Media URL 描述为主要资产来源;
File.size使用 PostgreSQLinteger,多 GB 视频可能超过其上限;File.hash的注释提到去重,但当前没有明确的去重语义或唯一约束;File.path全局唯一,但同时存在bucket字段,需要明确是否永远只有一个 bucket。
Media 和 Track 的部分更新路径没有统一维护 update_time。同时,sync_status、delete_status、
delete_time、recycle_time 的合法组合缺少集中定义。
需要明确:
- 哪些状态变更必须更新
update_time; - 重新激活记录时是否清除
delete_time; PENDING、IN_PROGRESS、COMPLETED、FAILED的合法转换;DELETED、PURGED和回收站状态的边界;- Track 没有 File 时哪些
COMPLETED状态是合法的,例如无 URL 的可选 Track。
当前索引能够支持部分访问,但没有完全贴合常用查询:
- Media 列表通常过滤
library_id、delete_status、recycle_time,并按create_time排序; - Post 下的 Media 常按
post_id、active 状态和sort_order查询; - Track 常按
media_id、active 状态、同步状态和 priority 查询; - 当前部分索引使用
delete_time,不一定覆盖 active/recycle 查询条件; - Post 的
(source, eid)当前是全局唯一,而 Library 是多租户边界,需要明确帖子身份到底是全局还是库内。
不能在没有数据和执行计划的情况下盲目增加索引,必须先用代表性数据验证。
Media.id
Stationary 内部主键,由服务端生成
Media.eid
持久化后的稳定外部身份,永不为空
Media.sort_order
Post 内展示顺序,仅用于排列和堆叠视图
手动上传:
生成 media_id = UUIDv7
设置 id = media_id
设置 eid = media_id
post_id = NULL 或用户选择的 Post
外部同步:
有外部 ID -> 使用外部 ID
无外部 ID -> 使用规范化主资源的确定性派生 ID
动态流地址 -> 使用稳定的上游身份和固定媒体锚点
无法确定 -> 拒绝同步
按照当前服务层的匹配语义,建议使用两类 active partial unique index:
绑定 Post 的 Media:
(post_id, source, eid)
WHERE post_id IS NOT NULL
AND delete_status = 'ACTIVE'
AND trim(eid) <> ''
独立 Media:
(library_id, source, eid)
WHERE post_id IS NULL
AND delete_status = 'ACTIVE'
AND trim(eid) <> ''
在实现前必须确认业务是否允许同一个外部媒体在同一 Library 的多个 Post 中重复出现。
如果外部平台 ID 是全局唯一且媒体不允许重复,则应改为 (library_id, source, eid) 或 (source, eid),
不能同时保留相互矛盾的规则。
独立列作为播放选择、排序和过滤的规范来源:
variant_keytypepurposequalitypriorityis_defaultis_primarylanguagecodecdurationwidthheightbandwidthcontaineris_fragmentedstream_layouthas_videohas_audiostreamssource_track_id
metadata 只保留不适合固定列的低频或格式特有信息,例如:
- DASH
segment_base; - 封面 recipe 和 generation mode;
- 原始平台扩展字段;
- 不参与常规查询的解析信息。
同一字段不应同时在独立列和 metadata 中作为两个可写真相。
优先级:P0
任务:
- 统计
Media.eid IS NULL和trim(eid) = ''; - 统计绑定 Media 的重复
(post_id, source, eid); - 统计独立 Media 的重复
(library_id, source, eid); - 统计以
sort_order作为唯一可匹配依据的历史数据; - 统计 Media 与 Post 的
library_id不一致; - 统计不存在 Media 的 Track;
- 统计不存在 File 的 Track;
- 统计非法
source_track_id; - 统计
is_original和is_generated同时为 true 的 Track; - 统计负数 priority、尺寸、时长和带宽;
- 统计废弃 URL 字段仍有非空值的 Media;
- 确认 Post 的
(source, eid)是全局身份还是 Library 内身份; - 确认同一外部媒体是否允许挂载到多个 Post。
交付物:
- 一份只读 SQL 盘点脚本:
apps/server/src/scripts/audit_media_track_integrity.ts; - 一份可阻断迁移的前置检查:
apps/server/src/scripts/verify_media_track_migration.ts; - 一份数据问题统计结果;
- 一份冻结后的身份范围和媒体组合规则。
验收标准:
- 所有历史异常都有数量;
- 每类异常都有处理策略:修复、保留并标记、或拒绝迁移;
- 没有在未盘点数据前直接创建唯一索引。
优先级:P0
建议新增一个小型领域工具,例如:
apps/server/src/lib/utils/media-identity.ts
职责:
- 校验非空外部身份;
- 规范化外部 ID;
- 为手动 Media 生成 UUIDv7;
- 为无原生 ID 的外部媒体生成确定性派生 ID;
- 处理动态流地址的固定锚点;
- 明确区分“调用方提供的 ID”和“服务端生成的 ID”;
- 不依赖
sort_order。
实现要求:
- 手动上传创建 Media 时,在领域层生成
eid; - 外部同步 payload 在进入
saveMetadata前完成身份解析; - 外部同步无法得到稳定身份时返回明确的参数错误;
Media.eid不允许写入空字符串;- 所有 Media API 响应继续返回稳定身份;
- 重新同步同一 Media 时必须复用原有身份;
- 不用每次请求随机生成的 ID 作为外部同步 fallback。
验收标准:
- 手动上传不需要用户输入
external_id,但数据库记录一定有非空eid; - 缺少稳定外部身份的同步请求不会创建新 Media;
- 删除
sort_order匹配逻辑后,已有同步测试仍能通过; - 同一 payload 重复提交不会产生重复 Media。
优先级:P0
修改范围:
TaskService.saveMetadata;TaskService.processMedia;TaskService.processMediaById;- 任何通过
sort_order查找 Media 的同步或重试路径; - 相关文档和测试。
规则:
sort_order只用于展示、排序和 Post 内重排;- 同步、重试、变更检测和删除必须使用
media.id或稳定eid; - 如果调用方只知道
post_id + sort_order,调用方需要先完成身份解析,不能由服务端继续 fallback。
验收标准:
- 全仓库搜索不再存在用
sort_order识别 Media 的业务逻辑; - Media 重排不会改变后续同步对象;
- 外部媒体顺序变化不会造成错误覆盖或错误删除。
优先级:P0
迁移内容:
Media.eid增加非空语义检查:trim(eid) <> '';- 增加绑定 Media 的 active 身份唯一索引;
- 增加独立 Media 的 active 身份唯一索引;
- 移除
Track.variant_key的temp-migration默认值; - Track 的所有写入路径必须显式生成
variant_key; - 根据盘点结果增加
priority >= 0; - 根据盘点结果增加尺寸、时长和带宽的非负检查;
- 根据最终语义增加
is_original/is_generated组合检查; - 根据最终规则增加
sort_order >= 0。
注意:当前 RecycleService 会先使用负数临时 sort order 再重排。若增加 sort_order >= 0,必须先把临时值改为不冲突的正数临时值,再增加检查。
验收标准:
- 数据库可以拒绝空
eid、重复身份和非法数值; - 所有现有写入路径都能通过新约束;
- 软删除记录不阻塞 active 身份重建;
- 没有引入物理外键或级联删除。
优先级:P1
实现内容:
- 创建 Media 时校验 Library;
- 绑定 Post 时校验 Post 存在且 Library 一致;
- 更新或移动 Media 时校验新的 Library 和 Post 组合;
- 创建或更新 Track 时校验 Media;
- 绑定 File 时校验 File 记录和可用状态;
- 设置
source_track_id时校验来源是否允许; - 更新 MediaTag、PostTag 时校验目标 Tag 属于同一 Library 或明确允许共享;
- 所有访问路径统一执行 Library 权限检查;
- 增加周期性完整性扫描任务:
POST /api/task/sweep-media-track-integrity,默认只报告和计数,不自动物理删除。
reconciliation 至少检查:
Media.post_id -> Post
Media.library_id -> Library
Media.post_id + Media.library_id -> Post.library_id
Track.media_id -> Media
Track.file_id -> File
Track.source_track_id -> Track
MediaTag -> Media / Tag
PostTag -> Post / Tag
AI / Search / Job records -> canonical entity
处理策略:
- 默认只报告和计数,不自动物理删除;
- 可安全修复的记录进入软删除或 FAILED 状态;
- 修复过程必须可重试、可审计;
- 任务结果记录发现数量、修复数量和跳过原因。
验收标准:
- 不依赖 FK 也能发现孤儿和跨 Library 引用;
- 所有新增写入路径都有应用层 guard;
- reconciliation 重复执行不会产生额外副作用;
- 任务异常不会删除仍可能被使用的物理文件。
优先级:P1
把 media-composition.ts 中的规则提升为所有写入路径共享的领域校验:
- 外部同步;
- 手动导入;
- Media API 注册 Track;
- 从 Draft 添加 Track;
- 封面生成;
- 任何后台修复和重试任务。
需要单独定义两种状态:
- 创建中的暂态:允许 Media 或 Track 处于 PENDING,允许 Track 暂时没有 File;
- 可见完成态:必须满足 Media 类型对应的最小 Track 组合,并且主内容轨可解析。
不能简单增加 sync_status = COMPLETED AND file_id IS NOT NULL 这种全局规则,因为无 URL 的可选 Track
可能合法地以无 File 状态结束。需要先定义状态矩阵,再实现校验。
验收标准:
- 所有 API 和后台路径对同一非法组合返回相同错误;
- PENDING 暂态仍能正常创建和重试;
- COMPLETED Media 不会缺少必要的主内容轨;
- LIVE_PHOTO、PDF、SUBTITLE 等特殊规则有测试覆盖。
优先级:P1
修改范围:
TrackService;TaskService;CoverService;track-format.ts;- Track metadata 类型定义。
规则:
- 所有 Track 写入先经过同一个格式归一化入口;
container、is_fragmented、stream_layout、has_video、has_audio、streams使用独立列作为规范来源;codec、language、duration、尺寸和 bandwidth 使用独立列作为规范来源;metadata不再重复存储这些字段;source_track_id只保留独立列;metadata只保留 DASH、封面 recipe 和低频原始扩展信息;- 所有更新路径都重新计算派生格式字段;
- 收紧
TrackMetadata的类型,避免无限制的any覆盖核心字段。
验收标准:
- 同一个 Track 的列和 metadata 不再出现冲突值;
- 同步下载、替换 File、生成封面和手动修改后格式字段一致;
- DASH、HLS、progressive、字幕和 PDF 的现有播放行为不回归;
- 新增字段有明确的 canonical owner。
优先级:P1
审计并修改:
DeleteService.deletePost;DeleteService.deleteMedia;TrackService.replaceFile;TrackService.deleteTrack;TaskService的过期 Track 清理;CoverService.generateCover;- File purge 和孤儿扫描任务。
规则:
- File 记录存在但对应业务记录已删除时,进入 purge 队列;
- S3 物理删除只能由 purge 任务执行;
- File 软删除和所属业务记录的软删除必须在同一个业务事务内完成。
验收标准:
- Track 删除会同步处理其所属 File;
- Track 替换 File;
- 封面重新生成;
- 回收和恢复;
- 过期 File purge 可以安全重试;
优先级:P2
确认没有运行时代码依赖后,删除:
primary_url;alternative_url;live_photo_url;cover_url。
同步更新 API、OpenAPI、前端类型和文档。所有物理资源地址从 Track/File 派生。
在数据盘点后选择唯一方案:
- 推荐方案:
quality只表达HIGH、MEDIUM、LOW,原始性由is_original表达;迁移并清理数据库中的ORIGINALenum 值; - 备选方案:保留
ORIGINAL,将其加入 TypeScript enum、API schema、播放和封面配置,并明确is_original是否仍然保留。
禁止让 migration、TypeScript enum、文档和测试分别使用不同语义。
- 更新文档,明确尺寸、时长和播放格式属于 Track;
File.size评估并迁移为bigint,确保支持多 GB 文件;- 明确
File.hash是仅用于审计,还是用于内容去重; - 如果实现内容去重,定义 hash 算法、空值策略、碰撞处理和唯一范围;
当前决策:
File.hash仅作为可选审计/内容指纹字段,不参与自动去重;File.path是当前全局唯一的 S3 对象身份,bucket是存储位置记录。若未来支持多 bucket 下同名 path,需要先新增明确的(bucket, path)迁移 和唯一约束。
验收标准:
- 生产代码和文档不再引用已删除 URL 列;
- enum 的数据库值和 TypeScript 值完全一致;
- 多 GB 文件的 size 写入、读取和 API 序列化正确;
- File hash 和 path 的唯一性语义有文档和测试。
优先级:P2
实现内容:
- 审计所有 Media、Track 的 update 路径;
- 所有业务字段更新统一维护
update_time; - 定义 Media、Track 的状态转换表;
- 明确 retry、restore、replace、soft delete、purge 的字段清理规则;
- 重新激活时明确是否清除
delete_time; - 让状态对账任务可以发现状态和物理 File 不一致。
验收标准:
- 任何可观察到的业务更新都有正确的 update_time;
- 非法状态转换会被拒绝;
- 重试不会遗留旧的
last_error、file_id或 stale 标记; - 回收、恢复和物理清理的字段状态可预测。
优先级:P2
先使用真实或代表性数据执行 EXPLAIN (ANALYZE, BUFFERS),再决定索引。
重点评估:
- Library 下 active Media 列表;
- Post 下 active Media 排序;
- 独立 Media 列表;
- Media 批量读取 active Track;
- Cover reconcile 批量扫描;
- File purge;
- 外部 ID 幂等查询。
候选索引包括:
- active Media 的
(library_id, create_time); - active Media 的
(post_id, sort_order); - active Track 的
(media_id, priority); - 身份匹配的 partial unique index;
- reconciliation 所需的状态索引。
候选索引不能未经执行计划验证直接添加,避免重复索引和写入放大。
验收标准:
- 代表性数据下核心列表和详情查询没有大范围顺序扫描;
- 新增索引能够被目标查询使用;
- 写入吞吐和 migration 时间没有不可接受的回归;
- 删除和 reconciliation 查询也有可接受的执行计划。
优先级:P1/P2
新增或补充 PostgreSQL 集成测试,不能只依赖静态源码匹配测试。
必须覆盖:
- 手动上传不输入 external_id 仍生成非空 eid;
- 外部同步使用调用方 external_id;
- URL 派生 ID 稳定且规范化;
- 缺少稳定身份时请求失败;
- sort_order 改变不会改变 Media 身份;
- 重复提交同一 payload 不产生重复 Media;
- 并发重复提交受到唯一约束保护。
post_id = NULL可以创建、查询、移动 Library、生成封面和删除;- 独立 Media 不依赖 Post 的排序;
- 独立 Media 的 eid 在 Library 范围内正确隔离。
- 每种 MediaType 的最小 Track 组合;
- 默认轨和主轨唯一性;
- 多语言音频和字幕;
- Live Photo;
- PDF;
- PENDING Track 无 File 的合法暂态;
- 无效 Track 组合在每个写入入口都被拒绝。
- Media 删除会同步处理所属 File;
- Track 替换 File;
- 封面重新生成;
- 回收和恢复;
- 过期 File purge 可以安全重试;
- 空 eid、重复 eid、非法 priority 和非法尺寸被拒绝;
- schema migration 可处理现存脏数据;
- enum 和 API schema 一致;
File.size支持大文件。
M0 数据盘点和规则冻结
↓
M1 统一身份生成
↓
M2 移除 sort_order fallback
↓
M3 增加非外键的数据约束
↓
M4 应用层关系完整性和 reconciliation
↓
M5 统一 Media / Track composition 校验
↓
M6 统一 Track 格式和 metadata
↓
M7 统一 File 生命周期
↓
M8 清理历史字段和 enum
↓
M9 时间字段和状态机
↓
M10 索引优化
↓
M11 完整测试和生产验证
其中 M0 到 M3 属于身份正确性基础,未完成前不应继续扩展新的 Media/Track 变体类型。
- 先部署只读盘点和指标,不先改变写入行为。
- 修复历史空 eid 和重复身份,生成迁移报告。
- 部署身份生成逻辑,保证新数据不再产生空 eid。
- 部署唯一索引和检查约束。
- 移除 sort_order fallback。
- 部署 reconciliation 和 File purge 生命周期。
- 最后删除废弃列和旧兼容代码。
- 每一步都保留可查询的迁移统计和失败记录。
不允许:
- 在没有数据盘点的情况下直接创建唯一索引;
- 用随机值掩盖无法确定的外部身份;
- 保留 sort_order fallback 作为长期兼容路径;
- 使用数据库外键或级联删除解决问题;
- 通过删除有意义的字段来规避诊断或迁移失败。
本任务整体完成需要满足:
- 所有持久化 Media 都有非空且稳定的 eid;
- 手动上传不要求用户输入 external_id;
- 外部同步不再依赖 sort_order;
- 独立 Media 是受支持的正式状态;
- Media 身份范围和唯一性规则已落入数据库索引;
- 不使用物理外键,但应用层关系检查和 reconciliation 完整;
- Media / Track composition 规则覆盖所有写入入口;
- Track 格式字段拥有唯一 canonical 来源;
- Track/File 一对一生命周期和删除流程经过统一实现;
- 废弃 Media URL 字段已删除;
- enum、文档、TypeScript 和 migration 一致;
- 多 GB File size 可以正确保存;
- 核心身份、组合、删除、恢复、重试和并发场景都有集成测试;
- 相关文档已同步更新。