---
url: /docs/api-docs/v1/device/honor.md
---
# honor —— 荣耀健康（v1）

> 前缀：`/api/v1/device/`（URL 与 V1 平行）
> 代码：`src/app/honor/interface/api/honor_routes.py` + `application/usecase/honor_*.py`
> 鉴权：两路由均**公开**（V1 `config.public: true` — 荣耀授权页在外部浏览器/webview 打开，无法带 token）

## M1 · POST /api/v1/device/honor-register —— 发起荣耀授权

生成会话（10min TTL 一次性）→ 返回荣耀 OAuth 授权 URL。

**请求 body**（全可选 —— 画像透传，用于回调后补资料）：

| 字段 | 类型 | 必填 | 默认 | 校验规则 |
|---|---|---|---|---|
| `userId` | string | ⬜ | — | 仅 `^[a-z0-9]{20,30}$` 才写会话（精确绑定 — demo 场景） |
| `nickname` | string | ⬜ | `荣耀用户` | trim 后空则用默认 |
| `gender` | string | ⬜ | `unknown` | 白名单 `male` / `female` / `unknown`，否则 `unknown` |
| `age` | int | ⬜ | — | 5~120 越界置 None |
| `height` | int | ⬜ | — | 50~300 越界置 None |
| `weight` | int | ⬜ | — | 10~500 越界置 None |
| `redirectScheme` | string | ⬜ | — | **安全白名单：仅 `muhe://` 或 `https://`**（防开放重定向） |

**响应 data**：

| 字段 | 类型 | 说明 |
|---|---|---|
| `sessionKey` | string | 会话标识（服务端 10min TTL） |
| `authUrl` | string | 荣耀授权页 URL（携带 state=sessionKey） |
| `requireProfileCompletion` | bool | 恒 `true`（回调只解出 5 字段，几乎必走补资料） |

**错误码**：

| code | HTTP | 场景 |
|---|---|---|
| `40401005` | **200** | HONOR\_CLIENT\_ID 未配置（V1 `{code:500}` 兼容锚点） |
| `40401001` | 400 | 参数非法 |

## M2 · GET /api/v1/device/honor-callback —— 荣耀 OAuth 回调

**Query**：`code`（授权码）+ `state`（sessionKey）

**流程**：会话校验（一次性消费 — **换 token 成功后才消费**，失败可重试）→ code 换 token + `getTokenInfo` 取权威 openId → 用户 upsert（`openid='honor:<openId>'` or session.userId 精确绑定）→ DeviceBinding upsert（`vendor='honor_oauth'`, status=active）→ 结果分流。

**结果分流**：

| 条件 | 响应 |
|---|---|
| 会话带 `redirectScheme`（App 场景） | **302** deep link：`{scheme}?status=success|error&msg=…`（expo-web-browser 自动关闭） |
| 否则（Web 场景） | **HTML** 结果页（✅/❌ + 文案 + 2s 自动关窗 + `postMessage` 通知父窗口 + `AndroidBridge.onAuthResult`） |

**错误分支文案**（⚠️ 回调路径不抛 JSON 错误码 —— 一律渲染为 HTML/302 的 `msg`）：

| 场景 | msg |
|---|---|
| 缺 code/state | 缺少参数提示 |
| 会话过期/缺失（10min TTL） | 「会话已过期（10 分钟），请重新填写信息授权」 |
| code 换 token 失败 | 「Token 交换失败，请检查 Client Secret 或重试」 |
| session.userId 精确绑定目标用户不存在 | 「session.userId=… 不存在」 |

**注意**：session.userId 精确绑定分支更新 nickname/age/height/weight（**不更新 gender** — V1 语义）。

## action：honorAdoptBinding —— 绑定切换

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

把后台已有用户的荣耀授权切换给当前 APP 用户：

| 场景 | 响应 data |
|---|---|
| 当前用户已有 honor\_oauth 绑定 | `{ "adopted": false, "alreadyBound": true, "binding": {id, status, lastSyncAt} }` |
| 成功切换（取最近同步的他人绑定改挂） | `{ "adopted": true, "fromNickname": "…", "binding": {id, status, lastSyncAt} }` |

**错误码**：`40401009`（404）暂无后台荣耀授权可切换。

## 数据通道（三层窗口）

| 接口 | 窗口 | 约束 |
|---|---|---|
| `sportStat` | **7 天**（API 硬约束 — 超窗永久丢数据） | STEPS / CALORIES / STRENGTH / DISTANCE |
| `sampleSets` | 180 天历史，**每次 ≤7 天分页** | 单次只能一种 dataType（10050）：HEART\_RATE / WEIGHT / STRESS / OXYGEN\_MAX |
| `healthRecord` | 30 天 | 睡眠 |

**认证（每请求 7 参数）**：`accessToken` + `secretData`（`HmacSHA256(secretId, openId)` → Base64）+ `openId` + `appId` + `startTime` / `endTime` / `dataTypes`。

**落库映射**：sportStat → DeviceDailyActivity（`vendor='honor'`）；sampleSets → HealthMetricRecord + BodyCompositionRecord（体重）；healthRecord → HealthSleepRecord。

**时间口径**：东八区切日（容器 UTC 直接 toISOString 会偏一天）；唯一例外 `strengthData` 用 UTC 切日（V1 历史行为保持）。

## 双层同步 job（授权有效性状态机）

**beat 触发器**：

| 模式 | 频率 | 范围 |
|---|---|---|
| `hourly` | 1h 高频 | sportStat + 心率（sampleSets HEART\_RATE） |
| `daily` | 24h 全量兜底 | sportStat 7 天窗必保 + sampleSets 30 天 + healthRecord 30 天 |

**token 状态机**（token JSON 内嵌 `expiresAt` / `failCount` — 零迁移）：

| 事件 | 动作 |
|---|---|
| 未临期（>30min） | 直接复用（抗频控） |
| 临期（≤30min）才 refresh | 成功 → 回写新 token + failCount 清零 |
| refresh invalid（400/401） | binding `status='expired'` + 站内信引导重绑（不再空转） |
| refresh network 抖动 | failCount+1 回写；**连续 3 次**才判失效（防单次误杀） |
| refresh unconfigured | 仅告警（环境问题不判失效，旧 token 继续用） |

## honor 错误码（40401xxx）

| code | 语义 | HTTP |
|---|---|---|
| `40401001` | 请求参数非法 | 400 |
| `40401005` | HONOR\_CLIENT\_ID 未配置 | **200**（V1 兼容） |
| `40401006` | M1 会话过期/缺失（10min 一次性） | 400 |
| `40401007` | 精确绑定目标用户不存在 | 404 |
| `40401008` | code 换 token 失败 | 400 |
| `40401009` | 无后台荣耀授权可切换 | 404 |

## 配置（env）

`HONOR_CLIENT_ID` / `HONOR_CLIENT_SECRET` / `HONOR_APP_ID` / `HONOR_SECRET_ID` / `HONOR_REDIRECT_URI` / `HONOR_API_BASE`（可选覆盖）；`V2_ENV=production` 切 prod base。

## 实现参考

* 模块结构：`src/app/honor/CLAUDE.md`
* 协议知识（scope / 7 参数 / 实测键名坑）：`src/app/honor/domain/services/honor_protocol.py`
