跳转到内容
快速开始

① 采集

使用 Provider

真实存在的 /api/v1/pulse 路由:填好某个 provider 的 OAuth 配置、用浏览器或表单连接账号、查看用户已连了什么,以及让 webhook 或拉取排程把数据送进来。

用户与厂商需要调用的接口全在 /api/v1/pulse 下。运维用的接口在 /api/v1/manage 下,鉴权用的是 sk 查询参数而不是 JWT。

路由鉴权用途
GET /api/v1/pulse/providersJWT 可选全部已注册的 provider;带 token 时把该用户的连接状态合并进去
GET /api/v1/pulse/user/providersJWT只看这个用户连了什么
POST /api/v1/pulse/user/providers/linkJWT发起或完成一次连接
GET /api/v1/pulse/{platform}/{provider}/callback厂商把浏览器送回来的落点
POST /api/v1/pulse/user/providers/unlinkJWT断开一个连接
POST /api/v1/pulse/user/providers/update-llm-accessJWT开关 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 通过 safe_read_cfg 读键,所以照常是三层解析:环境变量,然后 config.{env}.yaml,然后 config.yaml。这套层级关系以及 _SECRET / _KEY 结尾的值如何自动加密,见配置

Garmin 与 Whoop 的端点在随仓库发布的模板里已经填好,空着的只有三个跟你自己应用相关的值:

config.yaml
# Garmin Platform Configuration.
GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_token
GARMIN_AUTH_URL: https://connect.garmin.com/oauthConfirm/
GARMIN_ACCESS_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/access_token
GARMIN_API_BASE_URL: https://apis.garmin.com/wellness-api/rest
OAUTH_TEMP_TTL_SECONDS: 900
GARMIN_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_measurement
WHOOP_TOKEN_URL: https://api.prod.whoop.com/oauth/oauth2/token
WHOOP_AUTH_URL: https://api.prod.whoop.com/oauth/oauth2/auth
WHOOP_API_BASE_URL: https://api.prod.whoop.com/developer/v2
WHOOP_CLIENT_ID: ""
WHOOP_CLIENT_SECRET: ""
WHOOP_REDIRECT_URL: ""

自己的值写进 config.{env}.yaml,不要去改模板。Oura 在模板里没有任何一块配置,PostgreSQL provider 还需要它的功能开关,这些一起写在同一个文件里:

config.localdb.yaml
GARMIN_CLIENT_ID: your_garmin_consumer_key
GARMIN_CLIENT_SECRET: your_garmin_consumer_secret
GARMIN_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_garmin/callback
WHOOP_CLIENT_ID: your_whoop_client_id
WHOOP_CLIENT_SECRET: your_whoop_client_secret
WHOOP_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_whoop/callback
OURA_CLIENT_ID: your_oura_client_id
OURA_CLIENT_SECRET: your_oura_client_secret
OURA_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

GET /api/v1/pulse/providers 遍历注册表,因此它反映的正是启动时实际加载成功的 provider。JWT 是可选的:不带就只获得静态元数据,带上则路由会覆盖 status 并补上该用户的计数。

Terminal window
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)和 statusconnected / unconnected / unsupported)。另有一个 nocache 标记会转发给各平台的 get_providersowner_user_id 用来取另一个用户的 provider;共享权限校验不通过就会被拒。

connect_info_fields 是告诉客户端该渲染哪种流程的那个字段:null 表示把用户送去浏览器,是个列表就表示画出这张表单。只有 theta_pgsql 会返回列表。

所有连接都从同一条路由开始(POST /api/v1/pulse/user/providers/link),但它按 provider 的不同表现出两种相当不一样的行为。它的 auth_type 字段是一个枚举,只接受四个值:passwordoauth2tokencustomized

Garmin、Whoop、Oura 都是先返回一个 URL、在 callback 里才算完成。auth_typeoauth2;这三个 OAuth provider 都自己覆盖了 link(),构造授权 URL 时并不看这个字段,所以 Garmin 也没有 oauth1 这个值可以传:

Terminal window
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 对象:

Terminal window
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 到那里,并附上 codesuccessplatformproviderprovider_slug。否则它返回一小段 HTML:向 window.opener postMessage 一条 <PROVIDER>_OAUTH_COMPLETE 消息(例如 GARMIN_OAUTH_COMPLETE),然后自己关掉。没有重定向落点时,弹窗那套做法照样能运行通,靠的正是这一步。

两条路都落到同一处:按「一个用户一个 provider 一行」写进 health_user_provider。填哪些列由连接类型决定:PASSWORDusername + passwordOAUTH1access_token + access_token_secretOAUTH2access_token + refresh_token + expires_atCUSTOMIZED 填一个 connect_info JSON 对象。密钥类的值进库时加密,只在拉取需要时才解密。

重新连接是原子的:一条会改数据的 CTE 在同一条语句里把上一行有效记录软删除、并插入新行,因此失败不会留下「旧凭据删了、新凭据没写进去」的用户。这次写入还会强制 reconnect = 0,把之前的「需要重连」标记清掉。

这个标记就是坏连接的暴露方式。get_all_user_credentials_for_provider 只返回 reconnect = 0 的行,所以被标为需要重连的用户会被排程器跳过,而不是被无休止地重试;GET /api/v1/pulse/user/providers 也会把他们的状态报成 reconnect 而不是 connected

GET /api/v1/pulse/user/providers 只返回这个用户的连接:slug、状态和连接时间戳。只需确认某项连接状态时,用它更省。和 GET /api/v1/pulse/providers 不同,它不运行统计那一趟,所以那里的 record_count 一直是 0last_sync_at 一直是 null

agent 能不能读某个数据源,是一个独立的、按连接维度的开关:

Terminal window
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 的形式出现。

进来的路只有两条,某个 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 字段必须是已登记的指标名,具体见健康指标;写入之后的去向见数据流

Terminal window
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 时真正会用到的几个:

Terminal window
MANAGE="http://localhost:18080/api/v1/manage"
# what loaded, and what the scheduler thinks it is doing
curl "$MANAGE/pulse/providers/providers?sk=$SK"
curl "$MANAGE/theta/pull/status?sk=$SK"
curl "$MANAGE/theta/pull/config?sk=$SK"
# stop waiting for the timer
curl -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 into
curl "$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 with
curl "$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 会拒绝超过七天的时间范围。