2250 字
11 分钟
一句话需求到凌晨上线:给自己写个位置追踪服务,顺便被现实上了四课

凌晨一点多,我给 Claude 发了一句话:新增服务,location tracking,我会写个 app 每小时、或者位置显著变化的时候,把经纬度 POST 上来,你去把服务和文档写了。

就这一句。然后我去干别的了。

这是我那个自建 API 平台(代号 Project Hail Mary,之前在烤肉流水线那篇里写过)上的第 N 个服务。平台的底子已经很厚了:FastAPI + SQLite,systemd 管进程,Caddy 管路由,push 到 main 就自动部署。按理说加个服务是流水线作业。

事实证明,流水线作业也能踩出四个值得记下来的坑。

先说顺利的部分。Claude 起手没有直接写代码,而是先放了六个并行的侦察 agent 出去,把仓库里现成的范式翻了个底朝天:最小服务骨架长什么样、人类页面的鉴权怎么做的、SDK 的集成契约、部署清单的校验规则、文档惯例、数据库和迁移的写法。六份报告回来之后才动手,测试先行,六十八个测试一次全绿。

然后是第一课。

代码写完不算完,它又放了五十多个 agent 做对抗审查——五个不同视角的「找茬」agent 并行挖问题,每个发现再由三个独立「法官」从复现、设计意图、实际影响三个角度投票,多数票才算确认。听起来很铺张,但这一轮抓出来的东西让我冒汗:地图页的 fetch 写的是绝对路径 /api/...,本地测试全绿,推上生产页面会直接挂死。

原因特别有意思。平台上的服务有两种挂法:path 挂载(网关域名下的一个路径,代理时把前缀剥掉)和 subdomain 挂载(独享一个子域)。已有的带页面的服务是 subdomain 挂载,页面里写绝对路径没有任何问题。新服务照着它抄,但挂的是 path——浏览器解析 /api/... 时直接回到了网关根上,那里只有一个空空荡荡的 fallthrough。同一个根因还有两个变体:换 cookie 之后的 303 跳转写的是 Location: /,会把人扔到网关首页;cookie 没限定 Path,等于把这个服务的读凭证挂在整栋楼的大门口,浏览器会把它捎带给同域名下的每一个兄弟服务。

测试为什么抓不到?因为测试直接打 ASGI 应用,根本没有「前缀被剥掉」这一层。本地和生产之间隔着的不是代码,是环境的形状。这句话后面还会再应验一次。

修完上线,全链路验证通过,我拿到一个 token 开始配手机。然后我看了一眼接入文档,回了一句:

「不对,PAT 不是干这个的。」

这是第二课,也是我自己出的题。Claude 最初的设计让手机 app 拿我的 PAT(个人访问令牌)上报。PAT 是什么?是我在整个平台上的完整身份,能读能写能管理。把它塞进一个常驻手机后台、每小时自动发请求的快捷指令里,等于把家里的万能钥匙串挂在车钥匙上。正确做法平台里其实有现成先例:墨水屏服务的设备端用的就是专用投递 token——只能投递,别的什么都干不了。照搬:新发一个 write-only 的 ingest token,只能写入点位,不能读轨迹、不能删数据。泄漏了最坏结果是有人往我地图上塞假点,旋转一下就完事。

设备端凭证的原则就一句话:给设备的永远是最小权限的专用钥匙,而不是你本人的身份。

第三课来自一条报错。我配好快捷指令跑了一下,给 Claude 甩了四个字:400 bad request。

它去翻服务日志——没有 400。翻网关日志——也没有 400。但有一条 405,方法不允许,UA 写着 BackgroundShortcutRunner。原来我的快捷指令 POST 到了地图页的地址上,还把 token 拼在 URL 里。改对地址之后,又来一发 422,这次的报错体特别诚实:

"input": "device_name=...&timestamp=2026-06-11T15%3A16%3A07-04%3A00&lat=40.69..."

iOS 快捷指令的「获取 URL 内容」选了表单模式,发出来的是 application/x-www-form-urlencoded,字段名还是它自己的方言:timestamp 不是 tsalt 不是 altitude_m,外加一个 device_name。而 FastAPI 的类型化 Body 只认 JSON。

这里有个选择:让我去快捷指令里把请求体改成 JSON、字段逐个重命名,还是让服务端宽容一点?我们选了后者——服务是为设备服务的,不是反过来。ingest 端点改成手动解析请求体,JSON 和表单都收,空表单值当缺省;字段加了一层别名(timestamp/latitude/longitude/alt/accuracy/speed/heading/device_name 统统认识)。顺带一个冷知识:Starlette 的 request.form() 即使只解析 urlencoded、完全不碰 multipart,也必须装 python-multipart,不装就在运行时给你一个异常。

中间还有一段插曲让我把两个 token 都换了。排查 405 的时候,网关日志里赫然躺着完整的 token——因为它被拼在 ?key= 里,而 URL 是要进 access log 的。这本来是「浏览器一次性换 cookie」设计的已知代价,但当它真的躺在日志里的时候,该旋转还是得立刻旋转。凭证进了不该进的地方,不要心存侥幸,换掉永远是最便宜的选项。

第四课最冤,也最值得记。表单兼容的代码连测试八十八个全绿,推上去,生产开始 500。

日志说:table locations has no column named device。新代码要的列,数据库里没有——迁移文件没跟着部署上去。可文件明明就在仓库目录里。

查下来是个 git 冷知识。当时仓库里还有另一个并行的 Claude 会话在干活(它往暂存区里 stage 了自己的文件),为了不把别人的半成品卷进我的提交,用了带路径的提交:git commit <路径> -m ...。这个用法有个安静的脾气:**它只提交已跟踪文件的修改,路径范围内的 untracked 新文件会被一声不吭地排除。**新写的迁移 SQL 恰好是个新文件,于是留在了本地。本地测试用的是工作区文件,照样全绿;生产拿到的是 git 里的内容,新代码配旧表结构,五百了三分钟。

「本地全绿,生产挂死」,一晚上第二次,两次的本质一模一样:你的测试环境和生产环境不是同一个形状。第一次差在反向代理的前缀剥离,第二次差在 git 的提交边界。测试覆盖了代码,覆盖不了形状。

补一笔部署链路的惊魂。这个平台 push 到 main 就触发 webhook 自动部署,但今晚发现了它的两个暗病:部署器是同步处理 webhook 的,部署一旦超过 Cloudflare 的代理超时(默认两分钟),CF 就掐线,GitHub 的投递记录里留下一排 504(其实是 CF 自家的 524 被归一成了标准码)——好在请求实际已经送达,部署照跑;更阴的是部署进行中再来一个 webhook 会被文件锁直接丢弃,没有队列,没有重试。我那次 push 的投递甚至压根没出现在 GitHub 的记录里。救法也都验证过了:gh api 翻投递记录、对丢失的那条手动 redeliver(顺手把隔壁会话被吞的部署也救了回来)、实在没有投递记录就推一个真实文件改动重新触发——空提交没用,部署器是按改动文件算账的。

凌晨开工,下午收尾。最后的状态:服务挂在独立子域上(中途我又改了一次主意:网关域名只放纯 API,带界面的服务一律独立子域——架构洁癖,但洁癖值得坚持),手机每小时和位置突变时静默上报,地图页一条轨迹从家划到公司再划回来。

回头看这一晚,代码本身反而是最不值得写的部分——CRUD 加一张地图,没什么花头。值钱的是四课:挂载形状会让全绿的测试说谎;设备端凭证永远最小化;客户端发什么格式是现实不是选择题;git commit 带路径时记得看一眼 ??。还有一个更大的感受:仓库里同时有三四个 agent 会话在并行干活,改不同的服务、发版本、审计文档,git 的暂存区是大家共享的——这种「多人协作」里的每一个坑,以前是人和人之间踩,现在是 agent 和 agent 之间踩。坑还是那些坑,只是踩的速度快多了。

睡了。手机在兜里每小时替我打一个点。

一句话需求到凌晨上线:给自己写个位置追踪服务,顺便被现实上了四课
https://blog.lishuyu.app/posts/位置追踪服务上线记/
作者
猫猫魔女
发布于
2026-06-11
许可协议
CC BY-NC-SA 4.0