我某个项目里的每一次语义搜索都在花钱:一次 embedding、一次 reranking 调用、一次文本合成。每个请求几分钱。乘上一个跑循环的机器人,就不是几分钱了。第一个版本什么都不要求注册,因为为了搜番剧还要注册账号听起来很荒谬。现在听起来还是荒谬。我照做不误。
理由不是营销,也不是邮件列表。而是没有身份就没有东西可以计数。
不识别就放行的代价
我搭过一个 fake-door 验证用的架子,同时跑五个落地页,在动手做任何东西之前先测兴趣。唯一需要保护的是后台面板,所以我加了登录失败多次就封 IP。够了,我当时想。
不够。客户端 IP 来自 x-forwarded-for,而这个头是一个 客户端, 代理1, 代理2… 的列表,每个代理往右边追加,但没人清理已经写在里面的东西。取第一个值,就是取客户端想写什么就是什么。任何人发 x-forwarded-for: 1.2.3.4 就能绕开自己的封禁,顺便还能写上别人的 IP 把别人封掉。
我的修法是数跳数,而不是信左边:
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map((s) => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
前面有一个代理时,真实客户端是最后一个值:那是代理写的,客户端碰不到。它能工作。但看看我刚做了什么:我引入了一个依赖部署网络拓扑的环境变量,只为了能近似地识别某个人。如果我明天在前面加一个 CDN,这个数字就变了,封禁系统会悄无声息地弄错。它不会报错:它只是弄错。
为什么 IP 当不了身份
除了在错误的代理后面可以被伪造,IP 作为一个键还有一些无法修补的毛病。同一间办公室的四个人共用一个 IP。一个人走出楼门,手机的 IP 就变了。移动网络在巨大的地址块上做 NAT:封一个运营商的 IP 就是封掉一整个街区。
你想从标识符那里得到的,是同一个人稳定、不同的人不同。IP 一条都不满足。user_id 在构造上就两条都满足。
只有知道谁是谁才能做的事
在那个搜索里,我最后做了三个层级,而三个层级都有有限的上限:
visitor: { semanticSearchLimit: 10, historyLimit: 0 }
registered: { semanticSearchLimit: 30, historyLimit: 10 }
premium: { semanticSearchLimit: 200, historyLimit: null }
我在那个类型上留的注释就是全部的判断依据:永远有限;没有任何层级豁免于搜索配额。premium 拿到的是最大的数字,不是无限。historyLimit 倒是允许 null 表示"无限"。搜索的那个不行,因为每一次搜索都是一张账单。
访客仍然存在,用一个 cookie 给他一个匿名的键。但续期窗口因人而异:注册用户按 UTC 的自然月续期,premium 按他开通的周年日续期。这就要求存一个 premiumAnchorAt,而要存任何东西就得有一行属于某个人的记录。没有账号,就没有可锚定的周年日。
按请求计费的应用的情况
当每个动作都在花真钱时,配额就不再是防御,而是记账。而记账会以很无聊的方式出错。
在那个搜索里,两次一模一样的请求被计了两次。解决办法是一次预留,哈希的是请求的内容而不是它的文本:归一化后的 query、limit、explicit 标志和排序过的过滤条件。那个类型上的注释说明了为什么顺序重要——同一组条件换个顺序选出来,还是同一次搜索——以及为什么过滤条件要包含进去:同样的文本换一个题材就是另一个请求,把它们合并会返回错误的结果。这个预留返回 reserved、duplicate、in_progress 或 exhausted。四个状态都存在,因为四个都发生过。
在一个转录 API 里,按字符计费一开始是转录文本加上处理后的文本除以十。等到量出真实成本,那一行变成了这样:
transcriptionText.length + Math.floor(processedText.length * 0.25)
从 /10 到 * 0.25:贵了两倍半。价格错了 2.5 倍,而好几个月里我一直在自己贴这个差价却不知道,因为我没有能让我察觉到的度量。
在一个图像生成的 SaaS 里,bug 是反过来的:每日额度存在,但前置检查只看 subCredits。有每日额度的用户账上有额度却收到 402。一个 dailyCredits + subCredits 就修好了。这些全都是用户那一行上的列。没有那一行,就没有 bug 可修,因为根本没有东西可计数。
当身份也不够用时
有账号不等于身份有效。在一个用 API key 的服务里,key 那一行会活得比所有者的账号状态更久:下线流程里清掉了计费记录,却没人碰 key 的 isActive。key 仍然能通过认证。修法是在 authenticateApiKey 内部重新校验所有者仍然有计费记录,而不是相信那个标志位:
if (!ownerBilling) {
return { authenticated: false, error: "API key owner is no longer active" };
}
它放在那里而不是放在每个 endpoint 里,恰恰是为了让任何 endpoint 都不必记得这件事。
什么时候我确实允许免注册进入
当这个动作不花钱也不积累状态时:看一个条目、浏览一个目录、加载一个落地页。另外留一个配额很短的访客层级,让人可以先试再决定。注册前给十次搜索是诚实的摩擦;零次是一张敞开的账单。
问题不是登录烦不烦人。它烦人。问题是当有人越线时,你打算在 WHERE 里写什么。
