未知项野外指南 · 实现之前

可调整的计划

不要一份按执行顺序排列的计划,而是让它按改动每一部分的可能性来排序——值得你关注的决策浮到最前面,机械性工作沉到最底下。

提示词
Write an implementation plan for annotation export as HTML, but lead with the decisions I'm most likely to tweak: data model changes, new type interfaces, and anything user-facing. Bury the mechanical refactoring at the bottom — I trust you on that part.

中文大意:为“标注导出”功能写一份 HTML 格式的实现计划,但把我最可能改动的决策放在最前面:数据模型变更、新的类型接口,以及所有面向用户的部分。把机械性的重构埋到最底下——那部分我信得过你。

Claude 生成的结果

实现计划:标注导出

Acme · 将一次审阅的标注导出为可分享的 PDF 或 CSV · 分支 feat/annotation-export

工作量约 2.5 人日
涉及文件13 个(3 个新建)
风险低到中
迁移1 个,纯新增
↑ 调整 ↓ 信任
这份计划按“被调整的可能性”排序,而不是执行顺序。从上往下读 = 最值得你关注的排在最前。A 部分集中了所有需要判断取舍的决策;B 部分是构建顺序;C 部分是机械性工作,完全可以跳过不看。
A

你多半想改的决策

调整可能性:高

有三处我做了判断、而你可能不同意。每个被标记的选择都附有我考虑过的备选方案——点击切换对比。

A1 · 数据模型 — 新增 annotation_exports

annotation_exports 新增
iduuid pk
review_iduuid → reviews
requested_byuuid → users
statusenum(queued, rendering, ready, failed)
optionsjsonb
snapshot ①jsonb
① 不设 snapshot 列——渲染器在渲染时实时 JOIN annotations
artifact_url ②text null
rendered_attimestamptz null
② 不存产物文件——GET /exports/:id/download 按需实时渲染
created_attimestamptz
选择 ① 快照 vs. 实时联表
计划的选择 — 反规范化快照
在请求导出时,把标注内容复制进 snapshot jsonb。导出是这次审阅在那一刻的记录:之后的编辑、解决与删除都不会改写历史。
  • 即使审阅被归档,导出依然有效。
  • 渲染器只读一行——不会在 annotations + annotation_replies 之间产生 N+1 查询。

代价:重度审阅约 40 KB/行;相对实时讨论串,快照可能过期。

备选方案 — 渲染时实时联表
不做快照;生成文件时由渲染器实时联表 annotations。导出始终反映当前状态。
  • 没有重复数据,也不存在过期问题。
  • 重新下载同一份导出可能得到不同的文件——用于审计时会让人意外。

适用场景:导出是工作文档,而不是存档记录。回我一句即可:“use live join.”(改用实时联表)

选择 ② 存储产物 vs. 按需渲染
计划的选择 — 只渲染一次,存入 blob 存储
Worker 把 PDF/CSV 渲染一次并写入 artifact_url。下载只是一次签名 URL 重定向——成本低、可缓存,还能分享给没有 Acme 账号的审阅者。

代价:需要管理 blob 生命周期;我会加一个 30 天 TTL 的清理任务(见 C 部分第 6 项)。

备选方案 — 每次下载都重新渲染
不存文件:GET /exports/:id/download 流式返回一次全新渲染。零存储,但大型审阅每次下载约需 2–4 秒,也没有可分享的公开链接。

适用场景:存储或合规要求让保留副本变得麻烦。

A2 · 新的类型接口

// src/types/exports.ts (new)
export interface ExportRequest {
reviewId: ReviewId;
format: 'pdf' | 'csv';1
range: 'all' | 'unresolved' | TimecodeRange;2
includeReplies: boolean; // default true
includeDrawings: boolean; // default false3
}
 
export interface AnnotationSnapshot {
id: AnnotationId;
timecode: string; // "00:04:12.08"
author: { id: UserId; name: string };
body: string;
state: 'open' | 'resolved';4
replies: ReplySnapshot[];
}
1先从 PDF + CSV 起步。以后要加 'xlsx''srt',只需多一个联合类型成员 + 一个渲染器。
2TimecodeRange 让制片人可以只导出某一场戏的批注。如果没人提过这个需求,砍掉它能省约半天。
3涂鸦(画面标记)会栅格化进 PDF。默认关闭,因为它会让渲染时间变成三倍。
4导出时有意把内部的 5 态状态机压成 2 态。如果审阅者需要看到 'wont_fix',请告诉我。

A3 · UX 流程

第 1 步
导出按钮
审阅工具栏溢出菜单里的新条目,紧挨着“复制分享链接”。
第 2 步
选项弹窗
格式、范围、是否含回复、是否含涂鸦。复用设置页的 ModalForm
第 3 步
本计划最薄弱的一环
后台任务 + toast 提示
导出在 worker 中渲染;用户看到一条“完成后会通知你”的 toast,可以直接离开页面。
第 4 步
完成通知
铃铛通知 + 下载链接;同时列在审阅 → 导出(Exports)标签页下。

第 3 步为什么薄弱:大多数导出会在 3 秒内完成,“发完即忘”的 toast 可能显得小题大做——但一个带涂鸦、有 400 条标注的审阅需要约 20 秒,阻塞等待又太久。混合方案(原地最多等 4 秒,超时再转为通知)体验更好,但要多花约半天。由你决定。

B

实施顺序

执行顺序

我实际动手时会采用的顺序。每一步落地时 CI 都保持绿色;在第 5 步打开开关之前,用户看不到任何变化。

  1. 1迁移 + 模型。 创建 annotation_exports,接好 Sequelize 模型、factory 和 fixtures。0.5 天
  2. 2类型 + 请求校验。 A2 里的全部内容,外加 POST /api/reviews/:id/exports 上的 zod schema。0.25 天
  3. 3渲染器。 先做 CSV(很简单),再通过现有的 @acme/render 服务做 PDF。1 天
  4. 4Worker 任务 + blob 上传。 排入 exports.render 队列;重试 3 次后置为 status = failed0.25 天
  5. 5UI:菜单项、弹窗、toast、导出标签页。 藏在功能开关 export_annotations 后面。0.5 天
  6. 6测试、文档,向 Acme 团队灰度放开开关。0.25 天
C

机械性工作(放心交给我)

调整可能性:低

这个功能需要的重构和底层接线。这里没有需要判断取舍的地方——特意折叠起来。

枯燥但必要 8 项任务 · 共约 0.5 天,已计入上面的估时
  • AnnotationSerializerapi/annotations.ts 抽到 lib/serializers/,让导出 worker 可以复用。 纯移动,行为不变。
  • 完成 CommentMarkerAnnotationMarker 的重命名——还有 3 个文件在引用已废弃的别名。
  • annotation_exports 加进 fixture 加载器和 CI 数据库重置脚本。
  • workers/index.ts 里注册 exports.render,并把它加入死信告警列表。
  • flags.yaml 里创建功能开关 export_annotations,默认关闭。
  • jobs/cleanup.ts 里为过期导出 blob 加一个每晚运行的 TTL 清理(仅当选择 ② 保持原样时需要)。
  • openapi.yaml 里补充两个新端点并重新生成客户端。
  • 把时间码格式化辅助函数从 player/utils.ts 挪到 lib/time.ts——PDF 渲染器需要它,而且不应从播放器 bundle 引入。

可以调整的三件事

回复这几句话的杠杆率最高。复制一条,改一改,发给我——我会据此修订计划。(回复原文保留英文,便于直接发送。)

“Switch Choice ① to live join — exports here are working docs, not records.”中文大意:把选择 ① 改成实时联表——这里的导出是工作文档,不是存档记录。
“Cut TimecodeRange from ExportRequest; nobody has asked for per-scene export.”中文大意:从 ExportRequest 里砍掉 TimecodeRange;没人提过按场次导出的需求。
“Do the hybrid for step 3: wait up to 4s inline, then fall back to notify.”中文大意:第 3 步采用混合方案:原地最多等 4 秒,超时转为通知。