用户买了 Pro 却激活不了?为「网络不通」:一次授权系统改造复盘

0 阅读13分钟

上一篇提到,我们的 Obsidain 插件做了多平台发布功能,支持一键发 20+ 个平台。

免费用户每天享有 3 个平台的额度(加上微信,是 4 个平台),更多平台发布则是 Pro 功能。用户需要购买授权码,在浏览器扩展里输入,校验通过后才能解锁。

然后问题就来了。有一个国内用户反馈:

我不开 VPN 的时候,授权做不了。

我心里一沉。

这套逻辑在我的开发环境跑得稳稳的,海外网络也正常,开着代理也没问题。服务部署在 Cloudflare 上,配了自定义域名,用了 CDN 边缘节点。按理说大部分时候都能正常走通。

这不只是一个「换个服务器」能解决的。它牵扯到:

  • License 校验怎么做多端点故障转移
  • 国内备用节点该不该持有授权数据和签名私钥
  • 云函数和主服务之间怎么代理
  • ICP 备案、CORS、SSL 证书、DNS 验证,这些容易踩的坑
  • 以及测试中意外发现的一个设计问题:「清除本机授权」其实没有释放服务端名额

这篇文章就是这次改造的全过程记录。

一开始的架构:简单但脆弱

Pro 授权服务一开始放在海外 Worker 上。

核心校验流程不复杂。

  1. 用户在浏览器扩展里输入授权码
  2. 扩展生成本机设备 ID
  3. 扩展请求验证接口
  4. 服务端从 KV 存储查询这张授权码
  5. 如果有效、设备数没超限,就把当前设备 ID 写入已绑设备列表
  6. 服务端返回一个 payload,用私钥签名
  7. 扩展用内置公钥验签
  8. 只有验签通过,扩展才相信这个授权结果

这里最关键的设计是,客户端不只看 HTTP 状态码,也不只看 JSON 里的 valid: true,而是必须验证签名。

也就是说,中间即使有代理、有网关、有 CDN,只要它们没有私钥,就不能伪造授权结果。

这也是后面我们敢做国内备用网关的基础。

image.png|400

问题的真实面貌

服务放在 Cloudflare 上,配了自定义域名,用了 CDN 边缘计算。大部分用户在大部分时间里都能正常走通授权。

但问题在于,它不是绝对的「通」或「不通」,而是概率性的。

Cloudflare 在国内没有直连节点(企业版除外),国内用户的请求会路由到香港、新加坡等周边节点。大部分情况下没问题,但偶尔某个用户被分配到的节点刚好不太稳定,或者某个时段的路由出现了波动。

还有另一种情况:有些用户开着 Clash、Surge 这类代理工具,代理规则把我们的域名错误分流了,或者走了 Fake IP,反而导致连接超时。不开代理时能连上,开了代理反而连不上。

这类问题的核心难点在于:它不是每次都复现,也不是所有用户都遇到。你可能试了十次都正常,但用户偏偏就是那一次不行。

对用户来说,体验很糟糕。

  • 他已经付了钱
  • 他按提示输入了授权码
  • 结果扩展告诉他网络失败
  • 他不知道是自己网络问题、授权码问题,还是产品坏了

对客服来说也很麻烦。

  • 你得问用户有没有开代理
  • 是不是用了 Clash、Surge
  • 让他切手机热点试一下
  • 让他再试一次
  • 还要判断是不是授权码本身有问题

这类概率性的问题,如果不在产品层面做容错,最后都会变成客服的负担。

第一个判断:备用节点不做第二套授权

最直觉的方案是,在国内也部署一套 License 服务。

但我不想这么做。

原因很简单。License 服务不是普通 API。它有授权数据,有管理接口,有签名私钥,有吊销、解绑、查询等运营能力。

如果国内和海外各有一套完整服务,就会马上引入这些问题。

  • 两边数据怎么同步?
  • 用户在一个节点激活,另一个节点是否马上知道?
  • 设备数怎么避免并发冲突?
  • 私钥是否要复制到国内?
  • 管理接口是否也要暴露到备用节点?
  • 如果两个节点返回不一致,客户端信谁?

对于一个小产品,这个复杂度太高了。

所以最后的原则是,国内节点只做备用网关,不做第二套授权系统。

主服务仍然只有一个。国内备用域名只负责把国内用户的请求转发到主服务。

它不保存授权数据,不持有签名私钥,不代理后台管理接口。

客户端改造:多端点 Failover

光在云上加一个备用网关还不够,客户端也要知道怎么用它。

image.png|400

我们把扩展端的校验逻辑改成多端点请求。

默认按顺序尝试,只要任意一个返回合法的签名 payload,就认为授权成功。

这里有一个重要细节:不是「谁先返回 HTTP 200 就信谁」,而是「谁返回的签名能通过验签,就信谁」。

这让备用网关的安全边界很清晰。

  • 云函数可以转发响应
  • 云函数不能伪造响应
  • 如果云函数返回了错误格式,客户端不会把它当成授权成功
  • 如果云函数被错误配置到别的上游,客户端验签也不会通过

我们还给国内网络错误加了一段更明确的提示。大意是:

如果你开启了 Clash、Surge 等代理,请尝试关闭后直连,或切换到手机热点重试。

这个提示不那么优雅,但它非常实用。因为很多国内用户的问题并不是「完全没有网络」,而是代理规则、DNS、证书、Fake IP 等混在一起导致的。

选择云函数:只做极简反向代理

国内备用网关最后选的是云函数。

代码并不复杂,核心逻辑就是,

  1. 读取请求方法和路径
  2. 检查路径是否在允许列表里
  3. 构造上游 URL
  4. 带超时请求主服务
  5. 原样返回响应
  6. 加上必要的 CORS 头

我们只允许这些路由。

  • 健康检查
  • 授权校验
  • 当前设备解绑

不允许任何管理接口,后台管理能力继续只走主服务。

环境变量也尽量精简,就两个:上游地址和超时时间。

这里后来踩了一个小坑。环境变量的 URL 里多写了一个 =,导致云函数报「无效 URL」。这个错误看起来像上游不可用,但根因其实只是配错了 URL。

踩坑合集

Node.js 版本

云函数创建时会让你选择 Node.js 版本。建议选 18 或以上,因为自带 fetch,可以少引入依赖,函数包更简单。

CORS 的 Expose-Headers 不能为空

云函数配 CORS 时有一个很容易踩的坑。如果开启 CORS 但某些字段留空,控制台可能报参数错误。

我们最后的 CORS 配置很简单,Allow-Origin: *,对我们的场景足够了。License 校验不依赖 Cookie,也不需要浏览器携带凭证。

上海地域需要 ICP 备案

一开始我们尝试用上海地域。

函数默认 URL 可以访问,自定义域名也能配置。但关掉 VPN 测试时,出现了非常典型的问题。有时连接重置,有时返回类似拦截页面。

根因是,中国大陆地域提供公网自定义域名服务时,域名需要 ICP 备案。

这件事在控制台上不一定会早早拦住你。有时你能配一部分,看起来也能成功,但最后访问时还是被拦。

对于还没有做国内备案的小产品来说,上海地域不适合做备用网关。最后改成了香港地域。

免费 SSL 证书的 DNS 验证

腾讯云绑定自定义域名并开启 HTTPS 时,需要证书。免费证书有效期 90 天,后续要关注续期。

DNS 验证时,如果你的 DNS 托管在 Cloudflare,要在两边配合。有一个经验是:本地 dig 能查到 TXT 记录,不代表腾讯云控制台马上验证完成。可能还会显示「验证中」,需要等几分钟。这个时候不要急着反复删除重建证书。

CNAME 要不要开代理

license-cn 这个备用域名需要 CNAME 到云函数域名。在 Cloudflare 里添加 CNAME 时,有一个选择:是否走 Cloudflare 代理。

我更倾向于先用 DNS only。因为做这个备用网关,就是为了给国内用户绕开一部分 Cloudflare 的访问问题。如果备用域名又走 Cloudflare proxy,就把问绕回来了。

更清晰的链路是:国内用户 → 备用域名 → 香港云函数 → 主服务海外 Worker。云函数访问主服务,通常比用户本机直连更稳定。

测试顺序

这次我觉得最有价值的调试顺序是:

  1. 先测云函数默认 URL。如果默认 URL 都不通,先检查函数本身,不要急着怀疑 DNS 和证书
  2. 用假 key 测试转发。返回「未找到授权码」其实是好结果,说明请求到了云函数、云函数成功转发、主服务正常执行业务逻辑、响应原路返回
  3. 再测自定义域名,确认 DNS 解析正确,HTTPS 正常

我们最后拿到的健康检查响应大概是这样的。这说明备用域名已经能走通。

测试中暴露的另一个问题

在做国内备用网关测试时,我们用一个旧测试授权码验证。结果遇到了「设备数超限」。

这说明服务端认为这张 key 已经绑定了太多设备。

但从用户视角看,他可能已经在旧浏览器里点过「清除本机授权」。

问题就出在这里。当时扩展里的「清除本机授权」只是清掉本地缓存,并没有通知服务端释放当前设备 ID。

也就是说。

  • 用户本地看起来已经退出 Pro
  • 服务端仍然保留旧设备
  • 下次换电脑、换浏览器、重装扩展,就可能触发设备数超限

这是一个很典型的产品语义问题。按钮叫「清除本机授权」,用户自然会理解为「这台设备不再占用授权名额」。但如果工程实现只是删除本地 cache,那就和用户理解不一致。

image.png|400

解绑能力重新设计

当时有一个看似简单的方案,用户只输入授权码,那就允许他用授权码清空所有设备。

我没有这么做。

原因是授权码本身不适合作为「踢掉所有设备」的唯一凭证。它会在很多地方出现:购买邮件、客服沟通、用户截图、剪贴板。

如果只要拿到授权码就可以清空所有绑定设备,那任何看到 key 的人都可以反复把正版用户踢下线。

所以最后把解绑能力分成三种。

image.png|400

1. 当前设备解绑(新增)

请求包含授权码 + 当前设备 ID。只能释放当前设备,不能清空别人的。适合用户在已激活的设备上点击清除授权。

2. 用户自助全量解绑(保留)

授权码 + 邮箱。因为要清空全部设备,所以需要邮箱作为第二个验证因子。适合旧设备已不可用、设备 ID 已拿不到的场景。

3. 作者后台强制解绑(保留)

可以指定某个设备 ID 解绑,也可以清空全部。需要管理员权限。适合客服处理遗留问题。

解绑的语义也做了统一:远端解绑成功后才清本地缓存;远端失败时不清本地,并提示稍后重试。这让产品语义更一致了。

最终架构

浏览器扩展
  → 优先请求主服务域名
  → 如果网络失败,再请求备用域名
  → 备用域名由香港云函数转发到主服务
  → 主服务查询存储,返回签名 payload
  → 扩展端验签
  → 验签通过才解锁 Pro

客户端默认两个端点,只要其中一个返回合法签名就算成功。

image.png|400

备用网关只放行健康检查、授权校验、当前设备解绑三个接口,不代理管理后台。

几个小建议

如果你也在做类似的授权系统,我会建议:

1. 客户端不要只依赖一个端点。 尤其你的用户包含国内用户时,一个海外端点不够稳。但多端点不是简单地「多个 URL 随机请求」,要想清楚谁是主数据源、谁能签名、客户端信任什么。

2. 备用节点尽量不要持有核心私钥。 如果只是为了解决网络连通性,备用节点最好只是代理。

3. 一定要做响应签名。 HTTPS 保护传输过程,签名保护的是业务结果。引入代理和多节点后,签名会让客户端的信任模型简单很多。

4. 不要把管理接口放进备用网关。 能不暴露就不暴露。

5. 错误提示要写给真实用户看。 用户不需要知道 network_error 这个字段名,他需要知道是网络问题还是授权码问题,要不要切换网络,要不要联系客服。

6. 清除本机授权要真的释放服务端名额。 否则用户会以为自己已经解绑,服务端却还在占用名额。

7. 大陆云服务自定义域名要提前考虑 ICP。 如果没有备案,尽量不要把关键路径押在中国大陆地域的自定义域名上。


这次改造后,国内用户不开 VPN 时多了一个可用的授权备用路径。客户端从单端点变成了多端点自动切换。授权安全模型没有变,仍然依赖主服务的签名。国内备用网关不保存授权数据,不持有私钥,不代理管理接口。

扩展里的「清除本机授权」变成了真正释放当前设备名额。

从代码量看,这不算一个大功能。但从产品体验看,它补上了一个非常关键的可靠性缺口。

用户不会因为「我不开 VPN 所以买了也用不了」而卡在第一步。客服也不会每次都靠手动排查网络和解绑设备来救火。

最大的感受是:小产品的基础设施问题,往往不是「用哪个云服务」这么简单。更重要的是先想清楚边界。哪个服务是权威数据源?哪个服务可以签名?哪个只是代理?客户端到底信什么?失败时用户看到什么?

这些问题想清楚后,技术实现反而不复杂。

不是最豪华的架构,但足够让一个独立产品在真实的网络条件下活下来。