Appearance
xiaomi —— 小米运动健康(v1)
前缀:
/api/v1/device/(URL 与 V1 平行) 代码:src/app/xiaomi/interface/api/xiaomi_routes.py+application/usecase/xiaomi_*.py鉴权:callback / webhook 均公开(OAuth 重定向落点 + 官方推送无用户态;webhook 以验签鉴权) 官方文档:dev.mi.com/xiaomihyperos/documentation/detail?pId=2333(服务端接入指南)/ 2328(云服务接口)/ 2329(数据类型) 注册地址(申请清单逐字一致):回调…/api/v1/device/xiaomi-callback· 数据变更回调…/api/v1/device/xiaomi-webhook
发起授权 · POST /api/v1/device/vendors/xiaomi/auth-url
通用授权中心端点(registry 分发 → XiaomiVendor.cz_start_auth)——与 /vendors 页「去连接」按钮同源。
请求 body:{}(无需参数;userId 取自登录态 Bearer)
响应 data:
| 字段 | 类型 | 说明 |
|---|---|---|
authUrl | string | https://account.xiaomi.com/oauth2/authorize?client_id=…&response_type=code&scope_provider=healthkit&scope=…&state=… |
sessionKey | string | 会话标识(服务端 10min TTL 一次性 — callback 经 state 反查本地用户) |
requireProfileCompletion | bool | 恒 false(小米 token 响应自带 openId — 无需补资料) |
错误码:
| code | HTTP | 场景 |
|---|---|---|
40403006 | 400 | XIAOMI_CLIENT_ID/SECRET 未配置(msg「该设备厂家正在审核中,敬请期待」+ data.vendor='xiaomi') |
40403002 | 401 | 未登录 |
40403004 | 404 | vendor 不存在 |
四态:未配置 →
reviewing(授权页灰色「正在审核中」);配置就绪 →available。审批通过 = 配 env(XIAOMI_CLIENT_ID / XIAOMI_CLIENT_SECRET),零代码变更。
M2 · GET /api/v1/device/xiaomi-callback —— OAuth 回调
Query:code(授权码 — 10 分钟单次)+ state(sessionKey);可选 error
流程:会话校验(一次性消费)→ POST account.xiaomi.com/oauth2/token(form-urlencoded;&&&START&&& 前缀剥离)→ openId → DeviceBinding upsert(vendor='xiaomi', vendor_user_id=<openId>, status=active, token JSON {accessToken, refreshToken, expiresAt, openId, unionId})→ 消费会话 → HTML 结果页(✅/❌ + 2s 自动关窗)。
绑定成功后 fire-and-forget(失败静默不阻塞绑定):订阅登记(POST /healthkit/subscribe/change 五事件)+ 首拉(采样 30 天 + dailies 7 天 + 睡眠记录 30 天 + 运动概要 5 天)。
HTML 文案分支(回调路径不抛 JSON 错误码):缺 code/state → 「缺少 code/state 参数」;会话过期 → 「会话已过期,请重新发起授权」;未配置 → 「该设备厂家正在审核中,敬请期待」;换 token 失败 → 「换取 token 失败:…」。
订阅推送 · POST /api/v1/device/xiaomi-webhook
Header:x-notification-signature = hex(HMAC-SHA256(openId_eventType_subType_eventTime, AppSecret))(官方逐字)
Body(单事件对象):{openId, eventTime, eventType, subType, metaData: [{metaKey, metaValue}]}
事件分发(官方 7.3 表):
| eventType / subType | 处理 |
|---|---|
USER_EVENT / CANCEL_ALL | DeviceBinding → expired(用户在小米侧解绑 — 引导重绑) |
USER_EVENT / CHANGE_SCOPE | 记录日志(无表操作) |
FITNESS_DATA_EVENT / FITNESS_AGGREGATED_DATA_EVENT / FITNESS_RECORD_DATA_EVENT / SPORT_RECORD_DATA_EVENT(UPDATE) | 反查用户 → 增量拉取(metaData startTime/endTime 窗口,clamp [1,30] 天) |
| 未知类别 | skipped(不炸整批) |
响应(成功恒 HTTP 200 — 官方 FAQ Q4):
json
{"code": "0", "msg": "ok", "data": {"handled": true, "eventCount": 1, "skipped": 0}}错误码:
| code | HTTP | 场景 |
|---|---|---|
40404001 | 401 | 验签失败(fail-loud — 公开端点不静默采信) |
40404002 | 400 | 通知体非法(非 JSON / 缺事件字段) |
数据同步语义(job / vendor — 对外可观测)
| 端点族 | 窗口 | 落库 |
|---|---|---|
| dailies 聚合 | ≤7 天(硬约束) | DeviceDailyActivity(step/calories_kcal/active_min)+ SleepSummaryRecord(全天睡眠报告) |
| 采样 dataPoints | ≤30 天 | HealthMetricRecord(HR/SPO2/STRESS/VO2MAX/REST_HR — 东八区日均)+ BodyCompositionRecord(体重) |
| healthRecords(睡眠) | ≤30 天 | HealthSleepRecord(三维幂等:user+calendarDate+sleepStartGMT) |
| sportRecords/all | 5 天必保 | DeviceRawEvent 暂存(sportId 幂等 — 消费管线后续棒) |
每日 job:Celery beat daily(cz_xiaomi_sync_daily);token 临期(<30min)先 refresh 回写。推送为主通道,job 兜底。
解绑
POST /api/v1/device action unbind(vendor='xiaomi')→ 远端 open.account.xiaomi.com/user/unbindOAuth2Grained 尽力撤销(code=0 唯一成功标志)→ 本地绑定删除(远端失败不阻塞 — fail-safe)。