---
url: /docs/api-docs/v1/device/zepp.md
---
# zepp —— Zepp 健康（v1）

> 前缀：`/api/v1/device/`（URL 与 V1 平行）
> 代码：`src/app/zepp/interface/api/zepp_routes.py` + `application/usecase/zepp_*.py`
> 鉴权：`zepp-auth-url` 需 Bearer；`zepp-callback` / `zepp-webhook` **公开**（官方回调，无 token）

## 三端 OAuth（web / android / ios）

Zepp 在 console.zepp.com 注册**三个应用**（web/android/ios 各一套凭据）：

1. 前端按端调 `POST /api/v1/device/zepp-auth-url` 传入 `platform`
2. 服务端用**发起端凭据**生成授权 URL，并把 `{userId, platform}` 写入会话（callback 换 token **必须用发起端凭据**）
3. `GET /zepp-callback` 按会话记录的平台完成 exchange

### POST /api/v1/device/zepp-auth-url —— 生成授权 URL

**请求 body**：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `platform` | string | null | ⬜ | `web`（默认）| `android` | `ios`（白名单外归一为 `web`） |

**响应 data**：`{ "url": "<授权页>", "platform": "<归一后平台>" }`

**错误码**：

| code | HTTP | 场景 |
|---|---|---|
| `40402005` | **200** | ZEPP\_CLIENT\_ID/SECRET 未配置（V1 `{code:500}` 兼容锚点；「console.zepp.com 三应用待批」） |
| `40402002` | 401 | 未登录 |

### GET /api/v1/device/zepp-callback —— OAuth 回调

**Query**：`code` / `state` / `error`

**流程**：`error` 参数 → 失败 → 会话反查 `{userId, platform}` → 用发起端凭据 exchange token → `zeppUserId`（token 响应自带，无则调 profile 补）→ DeviceBinding upsert（`vendor='zepp'`）→ 消费会话 → **HTML 结果页**（✅/❌ + 2s 自动关窗）。

**绑定成功后的两个 fire-and-forget 动作**（失败静默不阻塞绑定 — V1 语义）：

1. backfill 近 29 天（activities + sleeps 走官方 backfill 通道）
2. Legacy 直拉近 29 天（明文通道兜底 — 实测 backfill 推送为密文，legacy 保证当下可达）

**错误分支**：一律 HTML 渲染（`msg` 内文案）——「授权失败：…」/「缺少 code/state 参数」/「会话已过期，请重新发起授权」/「换取 token 失败：…」。

## POST /api/v1/device/zepp-webhook —— 事件推送（六事件）

**Content-Type：`text/plain`**（官方约定）；**响应恒 204**（解析失败也吞错 — 防官方重试风暴）。

**密文双模**（2026-08-27 破案）：官方文档只写明文双层 JSON，实测 body 为
`base64(AES-128-ECB(双层JSON))`（key = base64decode(CLIENT\_SECRET)，必须 16 字节）——
解析器双模兼容：明文直传 / 密文 base64。

**双层 JSON**：先 `JSON.parse` 得 `string[]`，再逐条 `JSON.parse`（单层解析必炸）。

**空 body** → `40402010` + HTTP **415**（`Content-Type 须为 text/plain`）。

### 六事件分发与落库

| 事件 | 落库 | 幂等键 |
|---|---|---|
| `authorizationChange` / `authorizationDelete` | DeviceBinding `status='expired'` | — |
| `sportChange` | 暂存 DeviceRawEvent + **批后防抖**触发 legacy 近 3 天增量（整批一次） | track\_id |
| `activityFiles`（ping-pull） | 暂存 DeviceRawEvent + fire-and-forget 拉 FIT | summaryId |
| `sleeps`（push） | HealthSleepRecord | (user, calendarDate, startGMT) 三维 — 同日多段（午睡+夜睡） |
| `hrv`（push） | HealthMetricRecord `metricType=HRV`（同日重推=修正更新） | — |
| `dailies`（push） | DeviceDailyActivity（`vendor='zepp'`）+ REST\_HR 指标 | — |
| `bodyComps`（push） | BodyCompositionRecord（克→kg 换算） | (user, timestamp) |

单条失败不阻塞整批（skipped++，官方重试机制兜底 — V1 语义）。

## action：zeppAuthUrl —— 生成授权 URL（action 版）

`{ "action": "zeppAuthUrl", "payload": { "platform"? } }`（`POST /api/v1/device`，需 Bearer）

与独立路由的差异：**未配置不抛错** → `{ "url": "", "configured": false }`；platform 恒 `web`。

## Legacy 拉取通道（数据同步）

`GET /users/-/sleep` + `GET /users/-/activities`（Bearer token，明文通道，`interval=daily` 必填）：

| 项 | 口径 |
|---|---|
| 窗口 | 近 **29 天**（clamp \[1, 29] — 压官方 30 天上限内） |
| 睡眠字段 | `date / deepSleepTime / shallowSleepTime / wakeTime / rem / sleepScore / start / stop / rhr / sleepHrv`（时长单位分钟 ×60 转秒） |
| 活动字段 | `date / steps / distance（米）/ calories` |
| 落库 | sleep → SleepSummaryRecord upsert + rhr/sleepHrv → REST\_HR/HRV 指标；activity → DeviceDailyActivity upsert |
| 收尾 | `lastSyncAt` 刷新（授权中心绑定卡「最近同步时间」） |

## 每日 job

**beat：daily**（24h Legacy 全量 29 天幂等 upsert — 与 honor daily 同款节奏）。
sportChange 密文链已通（运动变更 → 增量拉取），但睡眠/静息心率无推送不更新 —— 每日全量保新鲜度。

**token 刷新**：1h 短命 token 临期（<30min）先 refresh 换新回写 DeviceBinding（「临期才刷」抗风控）；refresh 失败降级旧 token（简化版 — 不做 failCount 计数，V1 zepp job 语义如此）。

## zepp 错误码（40402xxx）

| code | 语义 | HTTP |
|---|---|---|
| `40402001` | 请求参数非法 | 400 |
| `40402002` | 未登录 / token 无效 | 401 |
| `40402005` | ZEPP\_CLIENT\_ID/SECRET 未配置 | **200**（V1 兼容） |
| `40402006` | OAuth 会话过期/缺失 | 400 |
| `40402010` | webhook body 缺失 | **415**（V1 兼容） |

## 配置（env）

`ZEPP_CLIENT_ID` / `ZEPP_CLIENT_SECRET`（web 主凭据）+ android / ios 三端凭据；webhook 解密 key = `ZEPP_CLIENT_SECRET`。

## 实现参考

* 模块结构：`src/app/zepp/CLAUDE.md`
* 密文编解码：`src/app/zepp/domain/services/zepp_webhook_codec.py`
* FIT 拉取现状：下载 + 暂存（`FitIngestPort`），FIT 解析链未迁移（V2 差异记录在迁移报告）
