08-15,outcometick.com 正式上线。此前客户通过 Telegram 机器人找我手动开通、交付;上线后可以自己下单、拿 API key,再按文件下载。这里记一下 08-14 到 08-16 做的事和踩过的坑。
先把归档搬到 R2
每天的归档原来只放在一个私有 GitHub 仓库的 Release 里,客户拿不到。要做自助下载,而且流量不经过我的服务器,前提是数据在对象存储上有一份副本,所以第一步是写了个 mirror-to-r2.mjs 把归档镜像到 Cloudflare R2。
对象 key 按内容寻址:objects/<sha256>。某天的数据可能重新导出,这也是修数据的方式。如果用可读 key,文件会被原地覆盖,客户手里尚未过期的预签名 URL 就会下载到一份 sha256 对不上的文件。内容寻址也合并了 1010 条跨天重复内容。dry-run 的结果是 68 天、11922 个唯一对象、25.07GB。
每天另写一份 catalog/<date>.json 当索引,API 靠它把「日期 + 文件名」解析成 sha256。写入顺序是对象全部落地之后才写索引,否则 API 会签出一个还不存在的对象。
第一次实际运行时,GitHub 偶尔给资产下载返回 401,同一个资产马上用 curl 请求却是 302。token 有效,限额也只用了 6/5000。我的并发工具当时是 fail-fast,一个偶发拒绝会让一万多个对象的任务整体中止。我改成按对象容错,结束时打印失败分布,并以非 0 退出。只要某一天有对象失败,当天 catalog 就不发布,重跑补齐后再发,避免索引引用尚未上传的对象。
禁止交付给客户的路径统一由 scope.mjs 导出的 isCustomerBlockedPath 判断,镜像和下载门禁共用,并加了交叉一致性测试,避免两边黑名单不一致而放出不该卖的文件。
Stripe 与 API key
支付有两条通道:Stripe 刷卡,和稳定币转账。客户付款之后拿到一把 API key,凭它调 /v1/files 列文件、再拿预签名链接直接从 R2 下。
key 的格式是 <key_id>.<secret>,secret 只在创建时返回一次,库里只存 sha256,比对用常数时间。这意味着读库也拿不到一把能用的 key,代价是明文只有一次交付机会,页面上那一次没接住就没了。
Stripe webhook 的验签我没引入 SDK,自己写了大约 30 行 HMAC,代价是必须测透,写了 17 个用例覆盖伪造、篡改、重放、密钥轮换、body 被重新序列化、畸形头等情况。事件表用 event_id 当主键,吸收 Stripe 的重投。
复审时发现 webhook 的处理有问题:只要没有抛异常就回 200,但有几种结果代表客户已经付款,我却找不到对应档位:价格没写进 subscription_plans、档位被停用、session 没带 customer。这些状态先写进事件表再落账。Stripe 收到 2xx 后不会重投;以后即使补好配置,同一事件重投也会在「重复事件」处短路,订阅无法开通。
修法是把结果分成两类。终局的(已应用、忽略、live/test 模式不符)幂等短路;可重放的(未知价格、档位停用、缺 customer、待付款)允许在之后的重投里按当时的配置重新判定,handler 回 503 让 Stripe 继续重投。Stripe 会退避重投大约三天,这三天就是我发现问题、补好配置的窗口。验证是起真实 API 加 docker 里的 Postgres 实测:未知价格回 503,补上配置后同一事件重投即 applied。
同一轮还修了一个 fail-open:是 live 还是 test 模式,原来是从 STRIPE_SECRET_KEY 的前缀推出来的,缺 key 时静默当成 test。而 webhook 只要有 webhook secret 就能启用,一个只配了 webhook secret 的生产部署会把真实付款全部判成模式不符并永久吃掉。现在模式判断不出来就拒绝启动。
pm2 下服务静默不启动
08-15 凌晨部署 API 时,pm2 显示 online,进程和内存正常,但端口没监听,也没有日志。
原因是我用 process.argv[1] 判断「这个文件是不是被直接执行的」(常见的 isMain 写法)。pm2 fork 模式下 argv[1] 是 pm2 自己的 ProcessContainerFork.js,于是 isMain 判为 false,主逻辑没有运行。改成优先读 pm2 导出的 pm_exec_path,读不到再回落 argv[1]。这个仓库以前用 argv 判断进程身份时也遇到过类似问题,pm2 下不能直接沿用这个判断。
定价接口也改了:原来会把空结果缓存 5 分钟,档位还没写进去、Stripe 临时失败,都可能让定价页空上五分钟。现在只缓存非空结果。
价格和覆盖度不写死
网站用 Next.js,页面价格从 Stripe 取,保证标价和实际扣款数一致。覆盖天数从 R2 镜像目录取,在客户端拉取。服务端渲染会把数字写进构建产物,部署后很长时间仍会显示旧值。
上线当天我把三条资金路径都用真钱走了一遍:Stripe live 付款到拿到 key 大约 5 秒,下载下来的文件 sha256 一致;稳定币转账到自动开通 9 秒。
一开始订阅后端放在采集仓库里,网站只负责展示和发起支付。上线当天我把它搬进网站仓库,前后端同仓,采集仓库里那份从未启用过的副本也删了。门禁和订阅库这两个模块还有一部分与导出和运营机器人共用,这部分要两边同步。
滚动窗口锚在哪一天
上线第二天改了一个门禁上的问题。最便宜的档位是「滚动 30 天」,下界原来是从今天往回算的。但交付比采集滞后,数据要等归档完才能下。这样窗口顶端被滞后顶住,底端却每天照常前移,买 30 天的人同时持有的只有 27 到 29 天。部署那天实测线上最新归档日是 08-13,而当天是 08-16,滞后是 3 天,不是我以为的 2 天。如果当初按「滞后 2 天」硬编码去补偿,就直接补错了。
我把锚点改成「最新已归档日」,rolling-N 就是 N 个归档日。两端都是闭区间,下界为 anchor - (N-1)。一开始我想保留原来的 anchor - N,觉得这样能维持既有行为。复审时才发现:原来有滞后时实际给 29 天,换锚点后这么写会变成 31 天,净多 2 天。
另一件同源的事是到期。不续费的客户,订阅最后两天的数据要在 key 过期之后才落档,他永远拿不到。所以加了 2 天宽限:到期后 key 还能下载,但上限钉在到期那天,只能补拿付费期内的日子,之后归档的东西没买过就拿不到。宽限期内 rolling 的锚也要夹到到期日,不然每过一天宽限,最后那个付费窗口就少一天。
宽限我做成了 resolveApiKey 的参数,而不是它的默认行为。同一个函数还被下单接口拿来当所有权证明用,写在函数内部等于把「这把 key 能证明你是谁」也延长了两天。
还有一处是读不到归档列表时的处理。第一版是退回用今天当锚,结果就是给付费客户回 403,告诉他「你没买这天」。对付费客户来说这是更糟的错误,所以改成 rolling 档位直接回 503;其他档位的范围和归档末端无关,不受影响。
interval=5m 把结算流筛没了
08-16 有客户反馈,/v1/files?interval=5m 会把 prices、twap30s、twap60s 整个筛掉,他没法在一次请求里拿到「5m 市场的数据加上全部结算流」。
原因是 SQL 里 WHERE interval='5m' 撞 NULL 的老陷阱。结算流是连续的,没有周期,classifyPath 给的是 interval:null,而匹配逻辑写的是 got != null && alts.includes(got),「不适用」被当成了「不匹配」。
我没有让 interval=5m 顺带返回无周期的文件。筛选只收窄、不放宽,只要 5m K 线的人不该白下几十 MB 结算流,而且放宽之后就再也没法表达「只要有周期的」。最后加了一个哨兵值 none,写成 interval=5m,none 就是「5m 加全部无周期」,老查询的结果一个都没变。
第一版我还把 none 加进了 /v1/meta 返回的 intervals 数组里,复审时发现这会破坏兼容性:这个数组一直只放真实值,客户端会拿它建枚举、当 duration 解析、遍历着去拉数据。最后改成单独的 filterTokens: { noValue: 'none', appliesTo: [...] } 字段。
暂时无法处理的付款状态现在回 503,等待重投。
outcometick 工程笔记(7/21)