方案报告 v5 · TTL 可见化 / 元数据永久保留 / 全平台统计

每块统一四段:现状 → 改成什么 → 界面 mock(PC 1440×900 / 移动 390×844,真实像素等比缩放)→ 代价与风险。全部 CSS/SVG 绘制,无截图、无外链。

不进本期 v0.1.4 · 本轮只出报告,代码一行未动

① TTL 可见化(发送列表 / 接收列表都能看到剩余时间)

现状

  • 服务端字段 transfers.expires_at timestamptz NOT NULL(migrations/0001_init.sql:41),创建时写 now + ttl_seconds;ttl_seconds 默认与上限都是 86400,客户端不可改。
  • 过期后:内容/状态端点返回 410 TRANSFER_EXPIRED;终结态另有 410 TRANSFER_CONSUMED / 410 TRANSFER_REVOKED;tombstone 被清理后变 404 TRANSFER_NOT_FOUND。
  • 用户完全看不到这些:列表里只有发送方/大小/块数,过期与否要等点下载才知道。

改成什么

  • 发送列表与接收列表每项右侧加一枚 TTL 胶囊:23 小时后过期 / 42 分钟后过期 / 已过期。
  • 已过期项:整行灰化 + 文件名删除线 + 胶囊 已过期,无法取回,下载按钮置灰(不可点)。
  • 文案统一成「N 小时后过期」/「N 分钟后过期」;≥1 天用「N 天后过期」(例:2 天后过期)。不写「剩 N 小时」——那个说法与上传下载的「预计剩余时间」混淆。
  • 临近过期(≤1 小时):胶囊转警示色 + 图标由时钟换成沙漏;不闪烁、不做循环动画、不加进度条(进度条会让人误以为是传输进度)。
  • 倒计时口径(唯一正确做法):
// 用服务端时间基准算一次 offset,再用单调时钟推进
const serverNow = Date.parse(resp.headers.get('Date')) // 或 /v1/config 的 server_time
const offset = serverNow - performance.now()
const remaining = Date.parse(item.expires_at) - (performance.now() + offset)
// ❌ 禁止:Date.parse(expires_at) - Date.now()  —— 设备时间可能差几分钟到几小时
// 不轮询:本地每 30s 用 performance.now() 重算一次显示即可
硬规则:禁止用 Date.now() 直接减 expires_at;倒计时不轮询服务器,只在本地按单调时钟推进,页面重新可见(visibilitychange)时用最近一次响应头重算 offset。

界面 mock

PC · 1440×900(右栏接收列表,三态并排)
雁书
🔒
AWQW
阿雁
(中栏拖入区,见 v4)
3 个文件待接收
阿龟 · 季度对账.xlsx
2.5 MB · 23 小时后过期
23 小时后过期
阿龟 · 报价单.docx
128 KB · 2 天后过期
2 天后过期
阿雁 · 产品视频.mp4
820 MB · 42 分钟后过期
42 分钟后过期
律师 · 合同扫描.pdf
6.1 MB · 已过期
已过期,无法取回
移动 · 390×844(列表 + 三态)
雁书
⟳
3 个文件待接收
阿龟 · 季度对账.xlsx
2.5 MB · 23 小时后过期
23 小时后过期
阿雁 · 产品视频.mp4
820 MB · 42 分钟后过期
42 分钟
律师 · 合同扫描.pdf
已过期
已过期
已过期项:整行灰化 + 删除线 + 下载按钮置灰

代价与风险

  • 纯前端 + 已有字段,不需要改协议;列表项多一个 expires_at 字段的读取(服务端列表接口已返回)。
  • 时钟漂移已用服务端基准 + 单调时钟规避;断网/后台休眠后回来重算一次,不累积误差。
  • 风险:用户可能把「23 小时后过期」当承诺——文案只写事实(服务端到期时间),不写「保证可下载」。
  • 工作量估计:列表三态 + 倒计时 hook + 测试,0.5 天。

② 元数据永久保留(tombstone 不按时间删,但三字段 30 天后 redact)

现状

  • spec.TombstoneTTL = 7 天(server/internal/spec/api.go:202):终结态行 7 天后被 DeleteTerminal 清掉,于是「历史传输记录」最多只能看到 7 天。
  • tombstone 里没有 filename / MIME / 明文 / 密钥 / 密文块(表里没有任何文件名或 MIME 列;密文只在 blob 目录且终结即删)。

改成什么

  • tombstone 永久保留(不再按时间删行)——「永久保留不泄露内容」成立,因为存的只有时间、大小、状态、码与计数。
  • 但三个关联性字段在终结后 +30 天置 NULL(保留行,不删行):
字段为什么不能永久处置
uploader_ip可识别信息:能还原「某 IP 何时给谁发过文件」+30 天 SET NULL
prelude_sm3内容派生指纹:可判断「同一文件是否传过」+30 天 SET NULL
upload_token_sm3凭据哈希,终结后无任何逻辑读取,只是暴露面+30 天 SET NULL
时间 / 大小 / 块数 / 状态 / 码 / 计数统计与审计所需,无内容语义永久保留

清理挂在现有 sweeper:redactAfter = terminal_at + 30d,按批 LIMIT 200,WHERE ... IS NOT NULL 幂等。

存储增长(实测整行 255 B,含 4 个索引约 0.5 KB/行):

日增传输一年行数一年增量(含索引)
1 千36.5 万≈ 0.18 GB
1 万365 万≈ 1.8 GB
10 万3650 万≈ 18 GB

界面 mock(记录永久保留 → 列表能看到「更早的记录」)

PC · 1440×900(最近传输:含 3 个月前 + 已过期)
雁书
🔒
AWQW
阿雁
(中栏拖入区,见 v4)
最近传输
季度对账.xlsx
2.5 MB · 今天 12:04
完成
产品视频.mp4
820 MB · 昨天
完成
合同扫描.pdf
6.1 MB · 9 月 2 日
已过期
报价单.docx
128 KB · 3 个月前
完成
记录永久保留;内容早已删除,只有「何时、多大、什么结果」
移动 · 390×844(历史记录 + 过期灰化)
雁书
⟳
最近传输
季度对账.xlsx
2.5 MB · 今天
完成
合同扫描.pdf
6.1 MB · 9 月 2 日
已过期
报价单.docx
128 KB · 3 个月前
完成

代价与风险

  • 改动小:去掉按时间删行 + 新增一档 redact 任务(复用 sweeper)。0.5 天(含迁移与测试)。
  • 风险 1:永久保留让「传输社交图」(谁给谁发过)长期可查——即便 redact 了 IP,sender_code/recipient_code 仍在;建议在报告里明确「码会轮换,历史码不指向具体人」,若仍担心可加「列表只展示 90 天,统计永久」。
  • 风险 2:表会一直长——建议明细转 transfers_archive 轻表(只留时间/大小/状态),主表保持可查;聚合另存计数器(见 ③)。

②b 速度与 ETA(传输卡片上沿数字行)

现状

  • 进度区只有百分比与「上传块 3/12」这类句子,没有速度、没有剩余时间。
  • 取回 1 GiB 实测约 458 秒,其中大部分时间花在端侧解密(顺序逐块喂内核),网络并不慢——但界面没区分,用户会以为是“网慢”。

改成什么

  • 位置:传输卡片上沿数字行——12.4 MB/s · 剩 1 分 20 秒(速度在左、ETA 在右),紧跟 已传 320 MB / 1.02 GB。
  • 口径分开:上传 = 已加密并发出的字节/秒;下载 = 已接收并解密的字节/秒。
  • 下载必须显示两段状态:下载中 62% 与 解密中 18% 分开(本项目解密是瓶颈,不能把慢算成网络慢)。
  • 平滑算法:≥2 秒采样窗口 + EWMA(α≈0.3),或 4 秒滑动中位数;刷新 ≤2 次/秒;ETA = 剩余字节 / 平滑速度,floor 到秒。
  • 显示规则:<10 秒 → 「即将完成」;<60 秒 → 「剩 45 秒」;<1 小时 → 「剩 12 分」;≥1 小时 → 「剩 1 小时以上」。
  • 异常:速度 <10 KB/s 持续 3 秒 → 「网络较慢」;3 秒无字节进展 → 「连接停滞,重试中」,ETA 暂时隐藏(不许数字乱跳)。
  • 不许出现:假的匀速动画;每次刷新重排宽度的数字(等宽字体 + 固定宽度);用 setInterval 堆定时器(用 rAF 节流)。
  • 移动端:同一套数字,但只保留「速度 · 剩 X」一行,字号 ≥14px。
// 平滑 + 节流(示意)
let ewma = 0, last = performance.now(), lastBytes = 0, lastPaint = 0
function onTick(bytes) {                      // 由 rAF 驱动
  const now = performance.now()
  const dt = (now - last) / 1000
  if (dt < 0.5) return                        // ≤2 次/秒
  const inst = (bytes - lastBytes) / dt       // B/s
  ewma = ewma ? ewma * 0.7 + inst * 0.3 : inst
  last = now; lastBytes = bytes
  const eta = ewma > 0 ? Math.floor((total - bytes) / ewma) : null
  paint(formatSpeed(ewma), formatEta(eta))    // 等宽数字,固定宽度
}

界面 mock

PC · 1440×900(上传中 / 下载两段状态)
雁书
✕
AWQW
阿雁
12.4 MB/s
剩 1 分 20 秒
已传 320 MB / 1.02 GB
8.6 MB/s
剩 4 分
下载中 62% · 解密中 18%
最近传输
季度对账.xlsx
2.5 MB · 今天
完成
移动 · 390×844(只留「速度 · 剩 X」一行,≥14px)
雁书
✕
12.4 MB/s·剩 1 分 20 秒
已传 320 MB / 1.02 GB
8.6 MB/s·剩 4 分
下载中 62% · 解密中 18%
网络较慢·ETA 已隐藏
已传 320 MB / 1.02 GB

代价与风险

  • 纯前端:采样 + EWMA + rAF 节流,0.5 天(含单测:平滑、四档文案、停滞隐藏 ETA)。
  • 风险 1:速度会“看起来不涨”——因为解密是瓶颈;两段状态分开显示正是为了避免这个误解。
  • 风险 2:后台标签页 rAF 被暂停 → 回到前台先丢弃过期样本,避免一次巨大瞬时速度。

③ 全平台统计 + 翻牌器(累计文件数 / 累计总大小)

现状

  • 首页只有本机本次会话的三个数字(发送/接收/成功率),关掉页面就归零。
  • 没有全平台口径:/v1/stats 不存在,数据库也没有计数器表。

改成什么

  • 口径:只统计 state='CONSUMED'(接收方 ack 完成,即真的送达);EXPIRED/REVOKED 不计入。若产品想展示「创建量」,另给 created 计数,不与「传输数」混成一个数。
  • 实现:stats_counters(day date, transfers bigint, bytes bigint),ack 成功时同事务 INSERT ... ON CONFLICT (day) DO UPDATE;/v1/stats 只读当日行 + 汇总行(O(1)),进程内再缓存 60s。
  • 为什么不能首页实时全表扫:10 万/日 → 一年 3650 万终态行,count(*) + sum() 即使走索引也要扫全量,单次数百 ms 且随年限线性变差;首页按 10 QPS 估算就是每秒 10 次大范围扫描。
  • 缓存头(必须排除 CDN/反代,否则数字被冻住):Cache-Control: no-store + CDN-Cache-Control: no-store + Surrogate-Control: no-store;若要省源站可改 public, max-age=30, s-maxage=30,但「今日」会有 30s 滞后,需产品确认。
  • 可见性两档(我们没有登录体系):A 全匿名(推荐):公开 /v1/stats,返回累计 + 今日,代价半天;B 匿名只给今日、累计需管理凭据:要新开管理员凭据面 + 契约新增 ADMIN_* 错误码,1–2 天且多一个长期密钥,v1 不建议。
  • 防爬取舍:累计值可四舍五入到千/万再展示(如 1.28M / 42.8 TB),既做社交证明又不暴露精确量。

界面 mock(首页用 v4 翻牌器:等宽数字、360ms 翻转、不抖动)

PC · 1440×900(左栏统计区:累计 + 今日)
雁书
🔒
AWQW
阿雁
累计传输
1,284,930
累计总大小
42.8 TB
今日 12,480 次 · 1.9 TB
(中栏拖入区,见 v4)
最近传输
季度对账.xlsx
2.5 MB · 今天
完成
产品视频.mp4
820 MB · 昨天
完成
移动 · 390×844(码卡片下方一行统计)
雁书
🔒
AWQW
阿雁
累计次数
1.28M
累计大小
42.8TB
今日
12,480
最近传输
季度对账.xlsx
2.5 MB · 今天
完成

代价与风险

  • A 方案:1 个 handler + 1 张计数器表 + 4 个断言,半天;读 O(1),1 万 QPS 无压力;写与 ack 同频(10 万/日 ≈ 1.2 QPS)。
  • 风险 1:数字被抓取(无个人信息,仅聚合值)——用「四舍五入到千/万」缓解。
  • 风险 2:计数器与真实行可能漂移(漏加/重放)——ack 事务内累加 + 每日对账任务(count(*) 与计数器差 > 0.1% 报警)。
  • 风险 3:CDN 缓存把数字冻住——三个 no-store 头 + 部署文档写明「/v1/stats 排除缓存」。

要 spencer 拍的三件

1. 是否接受 redact 三字段(uploader_ip / prelude_sm3 / upload_token_sm3 终结 +30 天置 NULL) 永久保留行不删,但这三列到期变 NULL;内容本来就零留存。
建议:接受——内容零泄露、关联性也砍掉,代价只是一列变 NULL。
2. 统计口径:只计 CONSUMED,另给 created 分开显示 「送达才计」符合用户理解的“传了多少文件”;过期/撤回算进去会让数字虚高。
建议:只计 CONSUMED,created 单独一个数字,不混算。
3. 可见性:A 全匿名 / B 匿名只给今日、累计需管理凭据 我们没有登录体系,B 要新开一条管理员凭据面(env 里的 bearer + 常量时间比较 + 契约新增 ADMIN_*),并多一个长期密钥。
建议:A 全匿名(累计 + 今日都公开,数值四舍五入到千/万);B 在 v1 不值得。
4(新增). 速度 / ETA 是「本期就做」还是「随 v5 一起做」 它和 TTL 改的是同一张传输卡片:分两次做等于把同一组件改两遍、两次回归。
建议:随 v5 一起做(同一 PR、同一次组件回归;TTL 胶囊与速度行一起定稿)。
本轮边界:只出报告,不动仓库代码、不开分支/PR;实施排在 v0.1.4 发版之后另开分支。