每块统一四段:现状 → 改成什么 → 界面 mock(PC 1440×900 / 移动 390×844,真实像素等比缩放)→ 代价与风险。全部 CSS/SVG 绘制,无截图、无外链。
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。23 小时后过期 / 42 分钟后过期 / 已过期。已过期,无法取回,下载按钮置灰(不可点)。2 天后过期)。不写「剩 N 小时」——那个说法与上传下载的「预计剩余时间」混淆。// 用服务端时间基准算一次 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。expires_at 字段的读取(服务端列表接口已返回)。spec.TombstoneTTL = 7 天(server/internal/spec/api.go:202):终结态行 7 天后被 DeleteTerminal 清掉,于是「历史传输记录」最多只能看到 7 天。| 字段 | 为什么不能永久 | 处置 |
|---|---|---|
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 |
sender_code/recipient_code 仍在;建议在报告里明确「码会轮换,历史码不指向具体人」,若仍担心可加「列表只展示 90 天,统计永久」。transfers_archive 轻表(只留时间/大小/状态),主表保持可查;聚合另存计数器(见 ③)。12.4 MB/s · 剩 1 分 20 秒(速度在左、ETA 在右),紧跟 已传 320 MB / 1.02 GB。下载中 62% 与 解密中 18% 分开(本项目解密是瓶颈,不能把慢算成网络慢)。ETA = 剩余字节 / 平滑速度,floor 到秒。setInterval 堆定时器(用 rAF 节流)。// 平滑 + 节流(示意)
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)) // 等宽数字,固定宽度
}
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。count(*) + sum() 即使走索引也要扫全量,单次数百 ms 且随年限线性变差;首页按 10 QPS 估算就是每秒 10 次大范围扫描。Cache-Control: no-store + CDN-Cache-Control: no-store + Surrogate-Control: no-store;若要省源站可改 public, max-age=30, s-maxage=30,但「今日」会有 30s 滞后,需产品确认。/v1/stats,返回累计 + 今日,代价半天;B 匿名只给今日、累计需管理凭据:要新开管理员凭据面 + 契约新增 ADMIN_* 错误码,1–2 天且多一个长期密钥,v1 不建议。1.28M / 42.8 TB),既做社交证明又不暴露精确量。count(*) 与计数器差 > 0.1% 报警)。no-store 头 + 部署文档写明「/v1/stats 排除缓存」。uploader_ip / prelude_sm3 / upload_token_sm3 终结 +30 天置 NULL)
永久保留行不删,但这三列到期变 NULL;内容本来就零留存。
CONSUMED,另给 created 分开显示
「送达才计」符合用户理解的“传了多少文件”;过期/撤回算进去会让数字虚高。
created 单独一个数字,不混算。ADMIN_*),并多一个长期密钥。