跳转到内容
快速开始

基于 Mirobody 进行开发

Garmin Provider 示例

随包发布的 Garmin provider 做了哪些决定:OAuth 1.0a 的两阶段连接、为什么它不注册拉取任务、webhook 负载如何去重,以及把字段映射成数据而不是分支。

Garmin 集成是随包发布的四个 provider 里最完整的一个,写自己的 provider 之前值得先读它:它是唯一的 OAUTH1 数据源、唯一主动关掉定时拉取的,也是唯一在解绑时回调厂商的。

info 每次调用都重新求值,且完全不碰外部资源 —— 列出 provider 必须既不花一次网络往返,也不需要凭据。其中四个字段承载的是行为:

  • slugtheta_garmin)是路由键:它出现在 webhook 路径、callback 路径、凭据行里,也是拉取任务的身份。
  • auth_typeOAUTH1,决定 callback 读的是 oauth_token + oauth_verifier 而不是 code + state
  • status 只是个默认值:路由会按每个用户的实际情况替换掉它。
  • connect_info_fields 缺席,客户端正是靠这一点知道该打开浏览器,而不是画一张表单。

配置上,四个端点 URL 与 TTL 在模板里都已填好,空着的是 GARMIN_CLIENT_IDGARMIN_CLIENT_SECRETGARMIN_REDIRECT_URL。工厂方法用前两个卡住整个 provider:没都配上就返回 None,于是没配好的部署是「没有 Garmin provider」,而不是「有一个坏的」。

OAuth 1.0a 需要在两次互不共享会话的 HTTP 往返之间携带一个 secret,所以握手状态寄放在 Redis 里。

  1. 第一阶段:索取 request token 向 Garmin 取一个 request token,请求按 OAuth 1.0a 签名
  2. token secret 与用户 id 暂存 写入 Redis,TTL 取 OAUTH_TEMP_TTL_SECONDS(默认 900 秒)
  3. 用户在浏览器里授权 授权 URL 作为 link_web_url 返回给客户端
  4. Garmin 重定向到 callback GET /api/v1/pulse/providers/theta_garmin/callback?oauth_token=…&oauth_verifier=…
  5. 第二阶段:换永久令牌 换到 access token 与 token secret,临时状态读完即删
  6. 凭据落库,并顺手回补 存下凭据,再异步拉最近 7 天
一次 link 请求、一次厂商重定向,以及把两者串起来的那份临时状态。

第二阶段是安全相关的决定所在。callback 路由不鉴权:发起调用的是 Garmin 的重定向,不是你的用户。所以除了那两个 OAuth 参数,查询串里的任何其它参数都不被信任,用户身份是从 Redis 里取回来的,而临时状态读完立刻删除,交接只能用一次。

还有一件事只有此刻能做:连接过程中顺手向 Garmin 问一次「这个用户在你那边的 id 是什么」,把它存进这一行。后面每一次 webhook 都依赖它当初被抓下来。

默认每个 provider 都会获得一个定时拉取。Garmin 明确关掉它,于是没有任何定时器会去轮询 Garmin —— 数据由厂商 webhook 推送过来。拉取只发生一次:连接成功之后的那次回补。

对上传窗口有限制的厂商 API 要注意分片:Garmin 把单次查询窗口限制在 24 小时,所以跨多天的回补会被拆成每天一次调用再把结果拼起来。

webhook 送来的每一项带的是 Garmin 自己的用户 id,这对你的数据库毫无意义。所以身份在任何写入之前先解析完毕:

webhook 项里的 userId
凭据行里存下的厂商侧 id 一次批量查询,不是逐项查
那一行的用户
Mirobody 侧身份 从这里开始使用
任意一项的 summaryId
msg_id 所有项都没有时退化成时间戳
身份在写入之前解析完毕;映射不到的用户会带 warning 丢掉,而不是猜一个。

带注销通知的负载走另一个出口:它照常入库,然后把对应用户的凭据行删除 —— 这是 Garmin 告诉你某个用户在他们那边取消了授权的方式 —— 这一项随后被跳过,不再往下交给格式化。

Garmin 的映射是数据,不是代码:一张按数据类型分键的表描述每种负载的时间戳从哪来、哪些扁平字段以什么单位变成哪个指标、哪些嵌套数组是时间序列,以及之后要不要跑一个派生处理器。表由一个很短的通用解释器执行,因为知识都在表里。

决定一个扁平字段全部行为的三个键是 indicatorconverterunit。指标名从来不是字符串字面量,而是对 registry 的引用,所以 registry 里改个名字会立刻报错,而不是在数据库里留下一个悄悄错掉的 typeconverter 是单位在源头对齐的地方:Garmin 上报的活动时长单位是秒,写出的记录是分钟。

缺失的字段被无声跳过;存在但转不了的字段计一次跳过并记日志。没有任何情况会让整批中断:每种数据类型的失败被收集起来随结果一起返回,GET /api/v1/manage/pulse/providers/check_format 正是靠它诊断「映射只成了一半」。

时间序列有两种形状,由配置指明:一种是样本数组,每个样本带值与时间偏移(心率采样);另一种是整个对象即 offset → value(HRV、呼吸率)。两种情况下样本时间戳都是该项的基准时间加上偏移,因此一份日汇总负载会展开成几百条各自带时间戳的 record。

有些指标在 Garmin 的负载里根本不存在,必须算出来 —— 这就是派生处理器的用途:睡眠那个用两个原始字段派生出三个指标,dailies 那个把活动消耗与基础代谢消耗相加。

值得注意的是刻意不映射的那些。Garmin 有一个时长字段与某个标准指标形状相同、语义并不一致,直接映射会产出一个看起来合理、实际错误的数,所以它没有被列进映射表。另有两个睡眠派生指标也没有在这里计算:它们需要把心率采样与睡眠时间窗求交,而 Garmin 每种数据类型各发一个 webhook,没有任何一次调用同时获得这两样 —— 于是它们被推迟给聚合器,而不是用不完整的数据凑一个出来。

基类的解绑把凭据行软删除就结束了。Garmin 还要求调用一次注销接口,而它的错误语义需要注意:本地那一行在每条路径上都会被删除(包括厂商侧调用失败时),但厂商侧失败之后仍然会抛错。所以解绑报错的含义是「本地已断开,Garmin 可能还在推」,而不是「什么都没发生」。凭据行本来就不存在时,会被当作已解绑并返回成功。

  • 把映射写成数据,别写成分支。 一张按数据类型分键的表加一个短解释器,于是「加一种数据类型」只需加一条表项,不需要新方法。
  • 引用指标 registry,永远别写字符串。 这让 type 字段保持诚实。
  • 明确地在推送与拉取之间做选择。 关掉排程 + 连接后一次性回补,就是推送型数据源的全部套路。
  • 身份从自己的存储里取回,不要从重定向里取。 callback 不鉴权,把暂存状态当作用户身份的唯一可信来源,正是这件事安全的原因。
  • 语义不一致的字段就拒绝映射。 改用派生指标,而不是产出一个看起来合理但错误的值。

源码在 mirobody/pulse/providers/ 下的 mirobody_garmin_connect/