Appearance
honor —— 荣耀健康(v1)
前缀:
/api/v1/device/(URL 与 V1 平行) 代码:src/app/honor/interface/api/honor_routes.py+application/usecase/honor_*.py鉴权:两路由均公开(V1config.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 |
| 否则(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