Appearance
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 各一套凭据):
- 前端按端调
POST /api/v1/device/zepp-auth-url传入platform - 服务端用发起端凭据生成授权 URL,并把
{userId, platform}写入会话(callback 换 token 必须用发起端凭据) 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 语义):
- backfill 近 29 天(activities + sleeps 走官方 backfill 通道)
- 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 差异记录在迁移报告)