---
url: /docs/api-docs/v1/device/xiaomi.md
---
# 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）。
