① 采集
使用 Provider
真实存在的 /api/v1/pulse 路由:填好某个 provider 的 OAuth 配置、用浏览器或表单连接账号、查看用户已连了什么,以及让 webhook 或拉取排程把数据送进来。
HTTP 接口
Section titled “HTTP 接口”用户与厂商需要调用的接口全在 /api/v1/pulse 下。运维用的接口在 /api/v1/manage 下,鉴权用的是 sk 查询参数而不是 JWT。
| 路由 | 鉴权 | 用途 |
|---|---|---|
GET /api/v1/pulse/providers | JWT 可选 | 全部已注册的 provider;带 token 时把该用户的连接状态合并进去 |
GET /api/v1/pulse/user/providers | JWT | 只看这个用户连了什么 |
POST /api/v1/pulse/user/providers/link | JWT | 发起或完成一次连接 |
GET /api/v1/pulse/{platform}/{provider}/callback | 无 | 厂商把浏览器送回来的落点 |
POST /api/v1/pulse/user/providers/unlink | JWT | 断开一个连接 |
POST /api/v1/pulse/user/providers/update-llm-access | JWT | 开关 agent 能否读这个数据源 |
POST /api/v1/pulse/{platform}/{provider}/webhook | 无 | 厂商推送,provider 写在路径里 |
POST /api/v1/pulse/{platform}/webhook | 无 | 厂商推送,provider 从请求体里推断 |
GET /api/v1/pulse/providers/indicators | 无 | 设备厂商可以上报的指标名与单位 |
POST /api/v1/pulse/{platform}/token | 无 | 用设备厂商自己的 user id + certification 换一个 Mirobody token |
/api/v1/pulse 下的每个响应都用同一个信封:成功是 {"code": 0, "msg": "ok", "data": {…}},失败是 {"code": …, "msg": "…"}。注意失败也回 HTTP 200 加一个非零 code;要看请求体,不要看状态行。管理路由的成功形状相同,但报错用的是 {"code": …, "detail": "…"}。
配置 provider 的凭据
Section titled “配置 provider 的凭据”Provider 通过 safe_read_cfg 读键,所以照常是三层解析:环境变量,然后 config.{env}.yaml,然后 config.yaml。这套层级关系以及 _SECRET / _KEY 结尾的值如何自动加密,见配置。
Garmin 与 Whoop 的端点在随仓库发布的模板里已经填好,空着的只有三个跟你自己应用相关的值:
# Garmin Platform Configuration.GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_tokenGARMIN_AUTH_URL: https://connect.garmin.com/oauthConfirm/GARMIN_ACCESS_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/access_tokenGARMIN_API_BASE_URL: https://apis.garmin.com/wellness-api/restOAUTH_TEMP_TTL_SECONDS: 900GARMIN_CLIENT_ID: ""GARMIN_CLIENT_SECRET: ""GARMIN_REDIRECT_URL: ""
# Whoop Platform Configuration.WHOOP_SCOPES: offline read:recovery read:sleep read:cycles read:profile read:workout read:body_measurementWHOOP_TOKEN_URL: https://api.prod.whoop.com/oauth/oauth2/tokenWHOOP_AUTH_URL: https://api.prod.whoop.com/oauth/oauth2/authWHOOP_API_BASE_URL: https://api.prod.whoop.com/developer/v2WHOOP_CLIENT_ID: ""WHOOP_CLIENT_SECRET: ""WHOOP_REDIRECT_URL: ""自己的值写进 config.{env}.yaml,不要去改模板。Oura 在模板里没有任何一块配置,PostgreSQL provider 还需要它的功能开关,这些一起写在同一个文件里:
GARMIN_CLIENT_ID: your_garmin_consumer_keyGARMIN_CLIENT_SECRET: your_garmin_consumer_secretGARMIN_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_garmin/callback
WHOOP_CLIENT_ID: your_whoop_client_idWHOOP_CLIENT_SECRET: your_whoop_client_secretWHOOP_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_whoop/callback
OURA_CLIENT_ID: your_oura_client_idOURA_CLIENT_SECRET: your_oura_client_secretOURA_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_oura/callback
ENABLE_PGSQL_DEVICE: "1"BACKEND_SERVER_SK: a_long_random_string这一块里有三件事值得讲明白:
- redirect URL 必须是对应那个 provider 的 callback 路由。 没有任何代码替你推导它:Garmin 把
GARMIN_REDIRECT_URL原样交给 Garmin 当oauth_callback,而源码注释里写的是这个 callback 由/api/v1/pulse/{platform}/{provider}/callback负责。同一个 URL 也要在厂商的开发者后台注册一遍。 OAUTH_TEMP_TTL_SECONDS(默认 900)给这次往返设了上限。 从「生成授权 URL」到「浏览器回来」这段时间里,token secret 和用户 id 以这个 TTL 存在 Redis 里。用户把厂商的授权页开着超过 15 分钟,就得重来一次。BACKEND_SERVER_SK就是sk查询参数要比对的那个值,而它不在随仓库发布的模板里。没配它,每一条/api/v1/manage/*都回500 Server configuration error: management key not configured。
列出可连接的数据源
Section titled “列出可连接的数据源”GET /api/v1/pulse/providers 遍历注册表,因此它反映的正是启动时实际加载成功的 provider。JWT 是可选的:不带就只获得静态元数据,带上则路由会覆盖 status 并补上该用户的计数。
curl "http://localhost:18080/api/v1/pulse/providers" \ -H "Authorization: Bearer $JWT"排序是已连接优先,然后未连接,然后不支持的;未连接那一组里另有一张固定优先级表,把 Whoop 和 Garmin 排到靠前的位置。每一项长这样:
{ "slug": "theta_garmin", "name": "Garmin Connect", "description": "Garmin fitness and health data integration via OAuth", "logo": "https://static.thetahealth.ai/res/garmin.png", "supported": true, "auth_type": "oauth1", "status": "available", "platform": "theta", "connected_at": null, "last_sync_at": null, "record_count": 0, "allow_llm_access": false, "connect_info_fields": null}两个可选查询参数可以收窄结果:platform(只要这个平台的 provider)和 status(connected / unconnected / unsupported)。另有一个 nocache 标记会转发给各平台的 get_providers;owner_user_id 用来取另一个用户的 provider;共享权限校验不通过就会被拒。
connect_info_fields 是告诉客户端该渲染哪种流程的那个字段:null 表示把用户送去浏览器,是个列表就表示画出这张表单。只有 theta_pgsql 会返回列表。
连接一个账号
Section titled “连接一个账号”所有连接都从同一条路由开始(POST /api/v1/pulse/user/providers/link),但它按 provider 的不同表现出两种相当不一样的行为。它的 auth_type 字段是一个枚举,只接受四个值:password、oauth2、token、customized。
Garmin、Whoop、Oura 都是先返回一个 URL、在 callback 里才算完成。auth_type 传 oauth2;这三个 OAuth provider 都自己覆盖了 link(),构造授权 URL 时并不看这个字段,所以 Garmin 也没有 oauth1 这个值可以传:
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/link" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{ "provider_slug": "theta_garmin", "platform": "theta", "auth_type": "oauth2", "return_url": "https://your-app/settings/devices" }'响应里带着 data.link_web_url。打开它、让用户授权,厂商就会重定向到你配置的 callback。
CUSTOMIZED 的 provider 在这一个请求里就完成校验并落库(不经浏览器,也没有 callback)。按 provider 声明的 connect_info_fields 传对应的 connect_info 对象:
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/link" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{ "provider_slug": "theta_pgsql", "platform": "theta", "auth_type": "customized", "connect_info": { "username": "readonly", "password": "secret", "host": "pg", "port": "5432", "database": "analytics" } }'provider 的 _validate_credentials_v2 先运行,所以密码错了或 host 不可达会让请求直接失败,而不是把一个坏连接存下来。PASSWORD 型的 provider 会用 auth_type: "password" 配顶层的 username / password(已发布的 provider 里没有走这条路的)。
platform 传错也不影响:任何以 theta_ 开头的 provider_slug 在分发之前都会被改道到 theta 平台。
索取授权 URL
POST /api/v1/pulse/user/providers/link。provider 把本次往返需要的状态存进 Redis,键是 OAuth token(OAuth 1.0a)或 state 值(OAuth 2.0),然后返回 link_web_url。
把用户送过去
在浏览器或弹窗里打开 link_web_url。用户是在厂商那边认证,不是在你的部署。
厂商回调
GET /api/v1/pulse/{platform}/{provider}/callback。它刻意不鉴权:发起调用的是厂商的重定向,不是你的用户。身份是特意从 Redis 里取回的,所以查询串里伪造 user_id 是没用的。
callback 有两种回法
如果 return_url 在这趟往返里活了下来,它就 302 到那里,并附上 code、success、platform、provider 和 provider_slug。否则它返回一小段 HTML:向 window.opener postMessage 一条 <PROVIDER>_OAUTH_COMPLETE 消息(例如 GARMIN_OAUTH_COMPLETE),然后自己关掉。没有重定向落点时,弹窗那套做法照样能运行通,靠的正是这一步。
凭据的存储位置
Section titled “凭据的存储位置”两条路都落到同一处:按「一个用户一个 provider 一行」写进 health_user_provider。填哪些列由连接类型决定:PASSWORD 填 username + password,OAUTH1 填 access_token + access_token_secret,OAUTH2 填 access_token + refresh_token + expires_at,CUSTOMIZED 填一个 connect_info JSON 对象。密钥类的值进库时加密,只在拉取需要时才解密。
重新连接是原子的:一条会改数据的 CTE 在同一条语句里把上一行有效记录软删除、并插入新行,因此失败不会留下「旧凭据删了、新凭据没写进去」的用户。这次写入还会强制 reconnect = 0,把之前的「需要重连」标记清掉。
这个标记就是坏连接的暴露方式。get_all_user_credentials_for_provider 只返回 reconnect = 0 的行,所以被标为需要重连的用户会被排程器跳过,而不是被无休止地重试;GET /api/v1/pulse/user/providers 也会把他们的状态报成 reconnect 而不是 connected。
查看与限制已连接的账号
Section titled “查看与限制已连接的账号”GET /api/v1/pulse/user/providers 只返回这个用户的连接:slug、状态和连接时间戳。只需确认某项连接状态时,用它更省。和 GET /api/v1/pulse/providers 不同,它不运行统计那一趟,所以那里的 record_count 一直是 0、last_sync_at 一直是 null。
agent 能不能读某个数据源,是一个独立的、按连接维度的开关:
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/update-llm-access" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{"provider_slug": "theta_garmin", "platform": "theta", "llm_access": false}'连接时 llm_access 被置为 1,所以刚连上的数据源默认是可读的;这条路由就是用户把它关掉的方式。它在 provider 列表里以 allow_llm_access 的形式出现。
数据的到达方式
Section titled “数据的到达方式”进来的路只有两条,某个 provider 走哪条由它自己决定(节奏表见 Pulse Provider 体系)。
Webhook。 推送型的厂商 POST 到 POST /api/v1/pulse/{platform}/{provider}/webhook;Garmin 就是 /api/v1/pulse/providers/theta_garmin/webhook。把 provider 写在路径里是可靠的那种写法。更短的 POST /api/v1/pulse/{platform}/webhook 则从请求体里推断 provider:顶层的 source 字符串,或者 data.source.slug。两条路由都不鉴权,也都把请求头里的 Svix-Id 当幂等键用,没有这个头时退化成一个时间戳字符串。
定时拉取。 声明需要排程的 provider 会获得一个任务:取出所有已连接用户的凭据,逐个用户向厂商取数。Whoop 和 Oura 走这条;Garmin 不走。
两条路的落点相同:先是 provider 的 save_raw_data_to_db,然后 format_data_v2,然后是写记录的那一步。产出的每条记录的 type 字段必须是已登记的指标名,具体见健康指标;写入之后的去向见数据流。
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/unlink" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{"provider_slug": "theta_garmin", "platform": "theta"}'基类实现是把那一行软删除。provider 可以覆盖 unlink 来顺便通知厂商,Garmin 就是这么做的;而且它把厂商侧调用失败当成错误,即使本地那一行总会被删除,所以这里的 500 仍然可能意味着「本地已断开,厂商没被通知到」。
已经入库的记录不会因为解绑而删除。解绑只是让新数据不再进来,它不是一个数据删除接口。
管理路由带 ?sk=<BACKEND_SERVER_SK>,不带 JWT。接入一个 provider 时真正会用到的几个:
MANAGE="http://localhost:18080/api/v1/manage"
# what loaded, and what the scheduler thinks it is doingcurl "$MANAGE/pulse/providers/providers?sk=$SK"curl "$MANAGE/theta/pull/status?sk=$SK"curl "$MANAGE/theta/pull/config?sk=$SK"
# stop waiting for the timercurl -X POST "$MANAGE/theta/pull/trigger?sk=$SK" \ -H "Content-Type: application/json" \ -d '{"provider_slug": "theta_oura", "force": true}'
# what arrived, and what one payload formats intocurl "$MANAGE/pulse/providers/webhooks?sk=$SK&provider=theta_garmin&page=1&page_size=20"curl "$MANAGE/pulse/providers/check_format?sk=$SK&provider=theta_garmin&id=123"
# what a user ended up withcurl "$MANAGE/pulse/user-data-sources?sk=$SK&user_id=505"curl "$MANAGE/pulse/user-indicators?sk=$SK&user_id=505"curl "$MANAGE/pulse/user-health-data?sk=$SK&user_id=505&start_date=2026-08-01&end_date=2026-08-05"记录缺失时第一个该用的是 check_format:它按 id 重新载入一条已存的原始负载,在它上面运行一遍 provider 的 format_data_v2,然后把原始数据和归一结果并排返回;于是你不用重做一遍厂商往返,就能判断问题出在接收还是映射。POST /api/v1/manage/theta/pull/start 和 /stop 控制排程器本身,而 user-health-data 会拒绝超过七天的时间范围。
有哪些数据源、各自需要什么
上面这些流程的一份完整实现
写一个属于你自己的数据源
用夹具校验你的映射