Appearance
模块手册 · 设备线(device-line)
收录 5 个模块的权威开发手册(源自各模块
CLAUDE.md,随仓库同步)。
本文目录
- device
- honor
- zepp
- xiaomi
- suunto
1. device 模块
源:
src/app/device/CLAUDE.md(随仓库同步)
src/app/device/
device 共享域 — 厂商健康数据统一抽象(IDeviceVendor 框架 + 四态授权中心),V2.0.15 落地。
模块职责
device 是所有设备厂商(honor / zepp / xiaomi / 未来 9 家)的共享域(对齐 V1 modules/devices/ 设计稿):
- 统一抽象:
IDeviceVendor五方法 Protocol(domain 层纯契约)+ 注册中心 registry - 共享实体:DeviceBinding / DeviceDailyActivity / HealthMetric / Sleep(SleepSummary) / BodyComposition / RawEvent
- 共享读写:
POST /api/v1/deviceaction 协议(myBindings / unbind / 数据查询 ×3) - 授权中心:
GET /vendors四态状态机(bound / available / reviewing / error)+ 通用发起授权 - 依赖方向:honor / zepp 模块依赖 device 域(vendor 实现 → device.domain),device 绝不反向依赖厂商模块(厂商 action 经 extra_actions 装配期注入)
DDD 4 层结构
src/app/device/
├── domain/ # 零 web 依赖 — 厂商「词汇表」
│ ├── vendor.py # IDeviceVendor Protocol(5 方法 + cz_meta)
│ │ # VendorMeta / VendorCapabilities / VendorAuthInput|Result /
│ │ # VendorCallbackInput|Result / SyncOptions / VendorSyncResult /
│ │ # VendorHealthStatus(全部 frozen dataclass 契约)
│ ├── entities/
│ │ ├── device_binding.py # DeviceBinding + VENDOR_* / STATUS_ACTIVE|EXPIRED + cz_token_json
│ │ ├── device_daily_activity.py / health_metric.py / sleep.py / body_composition.py / raw_event.py
│ ├── exceptions.py # DeviceErrorCode (40403xxx) + cz_device_error_to_http_status
│ └── services/ # (空 — 共享域无域服务)
│
├── application/
│ ├── ports/
│ │ ├── repositories.py # 8 个 Port:Binding / DailyActivity / HealthMetric / Sleep /
│ │ │ # SleepSummary / BodyComposition / RawEvent(Protocol)
│ │ └── services.py # SessionStorePort / NotificationPort / AccessTokenDecoderPort
│ ├── usecase/
│ │ ├── device_queries.py # 5 use case(myBindings / unbind / listDeviceDailyActivity /
│ │ │ # myMetrics / mySleep)
│ │ └── vendor_center.py # ListVendorCenterUseCase(四态映射 — 「授权中心状态机」对拍)
│ ├── vendor_registry.py # VendorRegistry(register / get / list / list_with_capability /
│ │ # check_all_health — 单厂商异常隔离为 ok:false)
│ └── planned_vendor.py # PlannedVendor(9 家预登记占位 — 授权尝试 → VENDOR_REVIEWING)
│
├── infrastructure/persistence/
│ └── inmemory_device_repositories.py # 9 个 InMemory 实现(含 SessionStore / Notification 适配)
│
└── interface/
├── api/device_actions.py # cz_build_device_actions_router(action 协议)
│ # cz_build_device_vendors_router(授权中心两端点)
└── handlers.py # cz_register_device_exception_handlersIDeviceVendor 框架(五方法)
| 方法 | 语义 | honor 映射 | zepp 映射 |
|---|---|---|---|
cz_start_auth(input) | M1 发起授权 | HonorRegisterUseCase | ZeppAuthUrlUseCase(三端) |
cz_handle_callback(input) | M2 回调换 token + 落绑定 | HonorCallbackUseCase | ZeppCallbackUseCase |
cz_sync_data(user_id, options) | 数据同步(显式窗口 + mode) | 三接口(sportStat/sampleSets/healthRecord) | Legacy 拉取(29 天) |
cz_unbind(user_id) | 解绑 | 绑定仓储直删 | 绑定仓储直删 |
cz_health_check() | 健康检查 | cz_is_configured()(配置就绪=服务就绪) | 同左 |
cz_meta(property):VendorMeta— id / displayName{zh,en} / logoUrl / authType(oauth|ble|import|sync)/ dataWindowDays / capabilities / planned- Protocol
runtime_checkable(registry 装配校验 + 测试 isinstance 断言) - zepp 的 webhook 纯 push 通道不经 IDeviceVendor(事件驱动独立于轮询语义 — sync_data 仅覆盖 legacy)
授权中心四态状态机
映射顺序(vendor_center.py:bound > available > error > reviewing):
| 条件 | status | 前端 |
|---|---|---|
| 用户有 active 绑定 | bound | 绿色「已连接」 |
configured && health.ok | available | 「去连接」 |
configured && !health.ok | error | 「暂时不可用」 |
!configured && planned | reviewing | 灰色「正在审核中」 |
!configured && !planned | 不出现(过滤) | — |
- 占位厂家(9 家:apple_health / garmin_cn / garmin_intl / coros / huawei / suunto / oppo / polar / werun)全部操作抛
40403006 VENDOR_REVIEWING(msg「该设备厂家正在审核中,敬请期待」+data.vendor);xiaomi 已由真实实现接管(S3 — planned 动态:未配置=审核中 / 配置就绪=available) - 接入完成一家 = registry 覆盖注册(同 id 替换,端点契约不变)
对外接口
| 端点 | 说明 | 鉴权 |
|---|---|---|
POST /api/v1/device | action 协议:myBindings / unbind / listDeviceDailyActivity / myMetrics / mySleep + 注入 action(honorAdoptBinding / zeppAuthUrl) | Bearer |
GET /api/v1/device/vendors | 授权中心厂家清单(meta + health + 四态 status) | Bearer |
POST /api/v1/device/vendors/{vendor_id}/auth-url | 通用发起授权(registry 分发) | Bearer |
关键设计约束
- 共享表收敛:DeviceBinding 等 6 张表从 V1 全厂商共享表收敛到本域(honor / zepp / 未来厂商 + admin / user.deletion 共用)
- token 载体:
access_token_enc是 JSON 字符串(非真加密 — V1 兼容),camelCase 键(accessToken/refreshToken/openId/expiresAt/failCount/zeppUserId) - vendor 命名:绑定渠道
honor_oauth(数据表honor)/zepp(同标识) - lastSyncAt 语义:最近「数据同步成功」时刻 — token refresh 不刷
- 依赖注入:
VendorRegistry装配于 main.py(honor + zepp + xiaomi + 9 占位 = 12 平台),app.state.vendor_registry暴露
测试与质量
| 文件 | 范围 |
|---|---|
tests/integration/test_device_honor_zepp.py | 设备线 E2E(459 行 — 共享 action + 厂商路由 + webhook) |
tests/unit/device/test_device_queries.py | 5 查询 use case |
tests/unit/device/test_vendor_registry.py / test_vendor_center.py | registry + 四态映射 |
tests/unit/device/test_vendor_celery_tasks.py | 厂商定时任务装配 |
FAQ
Q1: 新厂商接入怎么做(如小米)? A: ① 新建 src/app/<vendor>/ DDD 4 层;② application 层实现 IDeviceVendor(五方法薄适配,复用既有 use case);③ main.py registry cz_register(同 id 替换占位 — 授权中心自动从 reviewing 变 available);④ 删除 planned_vendor.py 中对应占位 spec。参考 src/app/honor/application/vendor.py。
Q2: 为什么 vendor 实现放 application 层而不是 domain 层? A: IDeviceVendor(domain)是纯契约;实现要编排 use case + 仓储(application 依赖方向),落在各厂商 application 层天然合规(实现 → device.domain)。
Q3: 为什么预登记占位厂家不是「不返回」? A: 产品语义(2026-09-16):C 端授权页列出全部目标平台,未对接完成的显示「该设备厂家正在审核中」——不允许"点击无反应"或暴露技术错误。占位 vendor 的 health_check 恒 configured=false → 授权中心映射 reviewing。
相关文件
src/app/device/ ← 本文件所在模块
├── domain/{vendor.py, entities/ 6 实体, exceptions.py}
├── application/{usecase/{device_queries,vendor_center}.py, vendor_registry.py, planned_vendor.py, ports/}
├── infrastructure/persistence/inmemory_device_repositories.py(9 实现)
└── interface/{api/device_actions.py, handlers.py}
docs/internal-wiki/architecture/device-cloud-integration-research.md ← 12 平台矩阵 + 四态需求
docs/api-docs/v1/device/ ← 设备线对外契约变更记录 (Changelog)
| 日期 (Asia/Shanghai) | 版本 | 变更 |
|---|---|---|
| 2026-09-16 | 1.0.0 | 模块 CLAUDE.md 首版 — IDeviceVendor + registry + 四态授权中心 + 共享 action |
| 2026-09-16 | — | V2.0.15:设备共享域 + 框架落地(edceefa)/ 四态状态机 + 占位厂家(2043ffb) |
2. honor 模块
源:
src/app/honor/CLAUDE.md(随仓库同步)
src/app/honor/
honor 模块 — 荣耀健康数据云迁移(M1/M2 + 三接口同步 + 双层 job),V2.0.15 落地。
模块职责
honor 承接荣耀运动健康云 API 对接(对齐 V1 server/src/modules/honor/,生产实测踩坑全继承):
- M1/M2 OAuth:
POST /api/v1/device/honor-register(发起)+GET /honor-callback(回调,公开路由) - 数据同步:三接口(sportStat / sampleSets / healthRecord)→ device 共享 4 表
- 绑定切换:
honorAdoptBindingaction(后台已有授权改挂当前用户) - 双层 job:Celery beat hourly(高频)+ daily(全量兜底)+ token 有效性状态机
- 厂商框架:
HonorVendor实现IDeviceVendor(薄适配 — 不复制协议逻辑) - 错误码区段 40401xxx;鉴权:两路由均公开(荣耀授权页在外部浏览器打开)
DDD 4 层结构
src/app/honor/
├── domain/
│ ├── services/
│ │ ├── honor_protocol.py # ⭐ 荣耀协议知识(V1 生产实测产物逐条继承):
│ │ │ # scope 11 项 URL 串 / 三接口路径 / 窗口常量 /
│ │ │ # 时间映射(东八区切日)/ 实测响应键名映射
│ │ ├── honor_signature.py # secretData 签名:HmacSHA256(secretId, openId) → Base64
│ │ └── honor_token_policy.py # token 状态机策略(临期 30min / FAIL_LIMIT=3 / 通知模板)
│ └── exceptions.py # HonorErrorCode (40401xxx) + cz_honor_error_to_http_status
│
├── application/
│ ├── ports/services.py # HonorApiPort / HonorUserPort
│ ├── usecase/
│ │ ├── honor_register.py # M1 发起授权(会话 10min TTL 一次性 + 画像钳制)
│ │ ├── honor_callback.py # M2 回调(换 token → getTokenInfo 权威 openId → 绑定 upsert)
│ │ ├── honor_adopt.py # honorAdoptBinding(绑定切换)
│ │ ├── honor_sync.py # 三接口落库映射 + SyncAllHonor(hourly/daily 模式)
│ │ └── honor_sync_job.py # 双层 job 状态机(临期刷 token + 失效判定 + 通知)
│ ├── vendor.py # HonorVendor(IDeviceVendor 实现)
│ └── factories/create_honor_usecases.py
│
├── infrastructure/
│ ├── adapters/honor_api_client.py # httpx 客户端(prod/test base 可切;MockTransport 可测)
│ ├── adapters/inmemory_honor_user.py
│ └── tasks.py # Celery:cz_honor_sync_hourly / cz_honor_sync_daily
│
└── interface/
├── api/honor_routes.py # 双路由 + HTML 结果页渲染 + APP deep link 302 分流
└── handlers.py # cz_register_honor_exception_handlers荣耀协议要点(全部是 V1 生产实测踩坑产物)
每请求 7 参数认证
accessToken + secretData(HmacSHA256(secretId, openId) → Base64)+ openId + appId + startTime / endTime / dataTypes(全 POST + JSON body)。
三接口 + 窗口硬约束
| 接口 | 窗口 | 硬约束 |
|---|---|---|
sportStat | 7 天 | 超窗永久丢数据 → daily 兜底必保;dataTypes: STEPS/CALORIES/STRENGTH/DISTANCE |
sampleSets | 180 天历史 | 单次只能一种 dataType(10050);每次 ≤7 天 → 分页;HEART_RATE/WEIGHT/STRESS/OXYGEN_MAX |
healthRecord | 30 天 | 睡眠 |
实测键名(曾踩坑 — 勿"纠正")
| 项 | 实测正确键 | 错误键(曾恒空) |
|---|---|---|
| 步数 | stepData[].steps(复数) | step |
| 卡路里 | calorieData(无 s)[].calorieTotal | caloriesData |
| 距离 | distanceData[].distanceTotal | — |
| 心率值 | detail.dynamicHeartRate | — |
| 体重值 | detail.weightKilo | — |
| 压力值 | detail.score | stress(曾恒跳过) |
| VO2Max | detail.maxVO2 | vo2Max(曾恒跳过) |
时间口径
东八区切日(容器 UTC 直接 toISOString 会偏一天);唯一例外 strengthData 用 UTC 切日(V1 历史行为 bit 兼容)。 endTime 不能大于当前时间(10032)→ min(当日 23:59:59, now-1s)。
10011 破案链路
token 响应不含 open_id 字段 → 必须经 getTokenInfo(open_id=OPENID) 取权威 openId; sub / union_id 是开发者维度联合标识,数据桥不认。
双层同步 job(状态机)
beat hourly → 全量 honor_oauth active 绑定遍历 → 临期(≤30min)才 refresh → 同步(mode=hourly)
beat daily → 同链路 mode=daily(sportStat 7 天窗必保 + sampleSets 30 天 + healthRecord 30 天)授权有效性状态机(token JSON 内嵌 expiresAt / failCount — 零迁移):
| refresh 结果 | 动作 |
|---|---|
| 成功 | 回写新 token(openId 不变 + expiresAt + failCount 清零) |
| invalid(400/401) | binding status='expired' + 站内信「授权已失效,请到我的-设备重新绑定」 |
| network 抖动 | failCount+1 回写;连续 3 次才判失效(防单次误杀) |
| unconfigured | 仅告警(环境问题不判失效,旧 token 继续) |
对外接口
| 端点 | 鉴权 | 说明 |
|---|---|---|
POST /api/v1/device/honor-register | 公开 | M1:会话 + authUrl(画像钳制 / redirectScheme 白名单) |
GET /api/v1/device/honor-callback | 公开 | M2:换 token + 绑定;结果 302 deep link 或 HTML(2s 关窗 + postMessage) |
POST /api/v1/device action honorAdoptBinding | Bearer | 绑定切换(幂等 / 无来源 404) |
测试与质量
| 文件 | 范围 |
|---|---|
tests/unit/honor/test_honor_client.py | API 客户端(httpx.MockTransport 零真实网络) |
tests/unit/honor/test_honor_domain.py | protocol / signature / token_policy 纯函数 |
tests/unit/honor/test_honor_sync_mapping.py | 三接口 → 4 表落库映射(含实测键名) |
tests/unit/honor/test_honor_sync_job.py | 双层 job 状态机(invalid/network/unconfigured 分支) |
tests/unit/honor/test_honor_routes.py | M1/M2 路由(HTML/302 分流) |
tests/unit/honor/test_honor_vendor.py | IDeviceVendor 五方法适配 |
tests/integration/test_device_honor_zepp.py | 设备线 E2E |
FAQ
Q1: 为什么 sportStat 只有 7 天窗口还要 hourly 也拉? A: 超窗永久丢数据 → 双保险:hourly 高频近实时 + daily 兜底 7 天全量。只要 daily 连续失败才可能丢数据(需告警)。
Q2: 为什么 callback 里"换 token 成功后才消费会话"? A: V1 语义 — 失败可重试(会话 10min 内有效);提前消费会让一次网络抖动直接作废整个授权流程。
Q3: Celery worker 用 InMemory 不是每次重启丢数据吗? A: dev 语义如此(worker 是独立进程,InMemory 进程内隔离);生产经 cz_build_honor_sync_job() 注入真库仓储后行为等价。
相关文件
src/app/honor/ ← 本文件所在模块
├── domain/{services/{honor_protocol,honor_signature,honor_token_policy}.py, exceptions.py}
├── application/{usecase/ 5 文件, vendor.py, ports/services.py, factories/}
├── infrastructure/{adapters/{honor_api_client,inmemory_honor_user}.py, tasks.py}
└── interface/{api/honor_routes.py, handlers.py}
tests/unit/honor/(7 文件)· tests/integration/test_device_honor_zepp.py
docs/api-docs/v1/device/honor.md ← 对外契约 + 状态机变更记录 (Changelog)
| 日期 (Asia/Shanghai) | 版本 | 变更 |
|---|---|---|
| 2026-09-16 | 1.0.0 | 模块 CLAUDE.md 首版 — 三接口 + 7 参数 + 双层 job 状态机 |
| 2026-09-16 | — | V2.0.15:V1 荣耀云迁移落地(e5b54b7 — client + sync + M1/M2 + 双层 job) |
3. zepp 模块
源:
src/app/zepp/CLAUDE.md(随仓库同步)
src/app/zepp/
zepp 模块 — Zepp 健康数据云迁移(三端 OAuth + webhook 六事件 + legacy 直拉 + 每日 job),V2.0.15 落地。
模块职责
zepp 承接 Zepp 开放平台对接(对齐 V1 server/src/modules/device/ 中 zepp 相关源码,生产实测坑全继承):
- 三端 OAuth:web / android / ios 各一套凭据(console.zepp.com 三应用)
- webhook 六事件:官方推送(含密文双模解析)→ device 共享 6 表
- Legacy 直拉:明文通道近 29 天全量(backfill 推送实测密文 → legacy 保当下可达)
- 每日 job:Celery beat daily(legacy 29 天幂等 upsert + token 临期刷新)
- 厂商框架:
ZeppVendor实现IDeviceVendor - 错误码区段 40402xxx;鉴权:auth-url 需登录,callback / webhook 公开
DDD 4 层结构
src/app/zepp/
├── domain/
│ ├── services/
│ │ ├── zepp_webhook_codec.py # ⭐ 密文编解码(双层 JSON + base64(AES-128-ECB) 双模兼容)
│ │ └── zepp_units.py # 单位换算(克→kg / js_round / finite_or_none)
│ └── exceptions.py # ZeppErrorCode (40402xxx) + cz_zepp_error_to_http_status
│
├── application/
│ ├── ports/services.py # ZeppApiPort + ZeppPlatform(Literal) + cz_normalize_platform
│ │ # 端点常量:ZEPP_AUTHORIZE_URL / TOKEN / REFRESH / API_BASE
│ ├── usecase/
│ │ ├── zepp_auth.py # auth-url(路由版抛 UNCONFIGURED / action 版返 configured:false)
│ │ ├── zepp_callback.py # 回调(会话反查 platform → 发起端凭据 exchange → 绑定 upsert)
│ │ ├── zepp_webhook.py # 六事件分发 + 落库规则(单条失败不阻塞整批)
│ │ ├── zepp_legacy_sync.py # Legacy 拉取(sleep + activities → 3 表 + 指标)
│ │ ├── zepp_fit_pull.py # activityFiles ping 消费(FIT 下载 + 暂存 — FitIngestPort)
│ │ └── zepp_sync_job.py # 每日同步(临期刷新 + legacy 全量)
│ ├── vendor.py # ZeppVendor(IDeviceVendor 实现)
│ └── factories/create_zepp_usecases.py
│
├── infrastructure/
│ ├── adapters/zepp_api_client.py # httpx 客户端 + ZeppConfig(env 三端凭据)
│ ├── adapters/inmemory_fit_ingest.py
│ └── tasks.py # Celery:cz_zepp_sync_daily
│
└── interface/
├── api/zepp_routes.py # 三路由 + fire-and-forget 调度(_cz_spawn 守卫)+ HTML 结果页
└── handlers.py # cz_register_zepp_exception_handlers三端 OAuth(web / android / ios)
| 项 | 约定 |
|---|---|
| 凭据 | ZEPP_CLIENT_ID/SECRET(web 主)/ ZEPP_ANDROID_* / ZEPP_IOS_* |
| 平台归一 | cz_normalize_platform:android/ios 白名单识别,其余 → web |
| 会话 | {userId, platform} 存 10min TTL(callback 必须用发起端凭据 exchange) |
| authorize 坑 | 必须带 token=["access","refresh"](URL 编码)—— 不带则无 refresh_token |
| token 交换 | form-urlencoded(非 JSON);token 1h 短命必须刷新链路 |
| refresh | 独立端点 /v2/oauth2/refresh_token + Bearer 头(失败回退 token 端点点位) |
| redirect_uri | https://api.qingmulife.cn/api/v1/device/zepp-callback |
未配置语义:路由版 zepp-auth-url 抛 40402005 UNCONFIGURED(HTTP 200 — V1 {code:500} 兼容锚点); action 版 zeppAuthUrl 不抛错 → {url: "", configured: false}。
webhook 六事件(POST /zepp-webhook)
响应恒 204(解析失败吞错 — 防官方重试风暴);空 body → 415 + 40402010。
密文双模(2026-08-27 破案):官方文档只写明文双层 JSON,实测 body 为 base64(AES-128-ECB(双层JSON))(key = base64decode(ZEPP_CLIENT_SECRET),须 16 字节)。 解析链:明文直传 → 失败再走解密分支;双层 = string[] 逐条 JSON.parse(单层必炸)。
| 事件 | 落库 | 幂等键 |
|---|---|---|
authorizationChange / Delete | DeviceBinding → expired | — |
sportChange | DeviceRawEvent 暂存 + 批后防抖 legacy 近 3 天增量 | track_id |
activityFiles(ping-pull) | DeviceRawEvent 暂存 + FIT 拉取(fire-and-forget) | summaryId |
sleeps | HealthSleepRecord(同日多段:午睡+夜睡) | (user, calendarDate, startGMT) |
hrv | HealthMetricRecord HRV(同日重推=修正) | — |
dailies | DeviceDailyActivity + REST_HR 指标 | — |
bodyComps | BodyCompositionRecord(克→kg) | (user, timestamp) |
fire-and-forget 调度(_cz_spawn + _background_tasks 强引用防 GC — RUF006): 绑定后回填(backfill 29 天 + legacy 直拉 29 天)/ FIT 拉取 / sportChange 增量(整批一次)。
Legacy 拉取 + 每日 job
GET /users/-/sleep|activities(Bearer +interval=daily必填坑)→ sleep/activity 双 200- 窗口:近 29 天(clamp [1, 29] — 压官方 30 天上限内);睡眠时长单位分钟 ×60 转秒
- 落库:SleepSummaryRecord upsert(+ rhr/sleepHrv → REST_HR/HRV 指标);DeviceDailyActivity upsert;收尾
lastSyncAt刷新 - 每日 job:beat daily → legacy 29 天幂等 upsert;token 临期(<30min)先 refresh 回写(失败降级旧 token — 简化版状态机,不做 failCount)
- FIT 拉取现状:下载 + 暂存(
FitIngestPort);FIT 解析链未迁移(V2 差异记录在迁移报告)
对外接口
| 端点 | 鉴权 | 说明 |
|---|---|---|
POST /api/v1/device/zepp-auth-url | Bearer | 生成授权 URL(三端凭据 + 会话) |
GET /api/v1/device/zepp-callback | 公开 | 回调 → HTML 结果页(2s 关窗) |
POST /api/v1/device/zepp-webhook | 公开 | 六事件接收(text/plain)→ 204 |
POST /api/v1/device action zeppAuthUrl | Bearer | action 版(未配置返 configured:false) |
测试与质量
| 文件 | 范围 |
|---|---|
tests/unit/zepp/test_zepp_codec.py | 密文双模 + 双层 JSON 解析 |
tests/unit/zepp/test_zepp_webhook.py | 六事件分发 + 幂等规则 |
tests/unit/zepp/test_zepp_client.py | API 客户端(MockTransport);stub_zepp_api.py 桩 |
tests/unit/zepp/test_zepp_legacy_sync.py / test_zepp_sync_job.py | legacy + 每日 job |
tests/unit/zepp/test_zepp_fit_pull.py / test_zepp_routes.py / test_zepp_vendor.py | FIT / 路由 / 厂商适配 |
tests/integration/test_device_honor_zepp.py | 设备线 E2E |
FAQ
Q1: webhook 为什么永远返 204 连解析失败也是? A: V1 语义 — 官方有重试机制,返错误码会引发重试风暴;解析失败仅日志(_log.warning),已消费事件由幂等键兜底。
Q2: 为什么 backfill 之后还要 legacy 直拉? A: 实测 backfill 触发的 webhook 推送是密文 + huama-signature RSA 头(解析链不通),legacy 明文通道(Bearer 即可)保住"当下可达";两条链互补。
Q3: sleep 为什么用三维幂等键? A: 同日可能多段睡眠(午睡 + 夜睡)—— (user, calendarDate, startGMT) 三维;start 缺失时退化为同日查重。
相关文件
src/app/zepp/ ← 本文件所在模块
├── domain/{services/{zepp_webhook_codec,zepp_units}.py, exceptions.py}
├── application/{usecase/ 6 文件, vendor.py, ports/services.py, factories/}
├── infrastructure/{adapters/{zepp_api_client,inmemory_fit_ingest}.py, tasks.py}
└── interface/{api/zepp_routes.py, handlers.py}
tests/unit/zepp/(9 文件 + stub)· tests/integration/test_device_honor_zepp.py
docs/api-docs/v1/device/zepp.md ← 对外契约 + 六事件表变更记录 (Changelog)
| 日期 (Asia/Shanghai) | 版本 | 变更 |
|---|---|---|
| 2026-09-16 | 1.0.0 | 模块 CLAUDE.md 首版 — 三端 OAuth + 六事件 + 密文双模 + legacy + 每日 job |
| 2026-09-16 | — | V2.0.15:V1 Zepp 云迁移落地(55fa1c4)+ 全模块装配 |
4. xiaomi 模块
源:
src/app/xiaomi/CLAUDE.md(随仓库同步)
src/app/xiaomi/
xiaomi 模块 — 小米运动健康云 API 接入(OAuth + 五接口族 + 订阅推送 + 每日 job),S3 落地(第四个 IDeviceVendor 实现)。
模块职责
xiaomi 承接小米运动健康开放平台对接(官方文档 dev.mi.com/xiaomihyperos pId=2333/2328/2329,2026-09-11 版逐条对拍):
- OAuth 授权码模式:
account.xiaomi.com/oauth2/{authorize,token,refresh/token}(&&&START&&&前缀剥离;code 10min 单次;refresh ≤5000/s) - 每请求鉴权:
Authorization: Bearer+X-Client-ID+X-Client-Nonce(12B b64url:2×随机 uint32 大端 + 分钟时间戳大端) - 五接口族:采样(dataSets/dataPoints)/ 聚合(dailies/goalSummary)/ 健康记录(healthRecords,睡眠含 subData)/ 运动(sportRecords/sportDetails/sportGoals)/ 设备+个人信息(heathkit/deviceList ⚠️ 官方拼写 / deviceInfo / profile/userInfo)
- 订阅推送:
x-notification-signature= hex(HMAC-SHA256(openId_eventType_subType_eventTime, AppSecret));五事件族分发(S2 统一入口cz_handle_webhook) - 每日 job:Celery beat daily(推送为主通道,job 兜底 — 采样 30 天分段 + dailies 7 天 + 运动概要 5 天必保;token 临期 <30min 先刷)
- 厂商框架:
XiaomiVendor实现IDeviceVendorv2 七方法 - 错误码区段 40404xxx;鉴权:callback / webhook 公开(OAuth 重定向落点 + 官方推送无用户态)
DDD 4 层结构
src/app/xiaomi/
├── domain/
│ ├── services/xiaomi_protocol.py # ⭐ 协议知识(纯函数):nonce 生成(12B 字节结构)/
│ │ # 验签 message+HMAC / &&&START&&& 剥离 / DataPointValue 提取 /
│ │ # 东八区切日 / sleep_state 枚举 / dataType 常量与窗口硬约束
│ └── exceptions.py # XiaomiErrorCode (40404xxx) + cz_xiaomi_error_to_http_status
│
├── application/
│ ├── ports/services.py # XiaomiApiPort(OAuth + 五接口族 + 订阅 + 解绑)+ XiaomiTokenResult
│ │ # 端点常量 + DEFAULT_SCOPES(14 项申请清单)
│ ├── usecase/
│ │ ├── xiaomi_auth.py # M1 授权 URL(未配置 → 40403006 VENDOR_REVIEWING 审核中文案)
│ │ ├── xiaomi_callback.py # M2 回调(换 token → openId → 绑定 upsert vendor='xiaomi')
│ │ ├── xiaomi_sync.py # 五接口族拉取 → device 共享 6 表(逐族失败隔离)
│ │ ├── xiaomi_webhook.py # 验签 + 五事件族分发(CANCEL_ALL → expired;数据事件 → 增量提示)
│ │ └── xiaomi_sync_job.py # 每日 job(临期刷新 + 全量兜底)
│ ├── vendor.py # XiaomiVendor(IDeviceVendor v2 七方法;planned 动态)
│ ├── background.py # fire-and-forget 调度(RUF006 引用集)
│ └── factories/create_xiaomi_usecases.py
│
├── infrastructure/
│ ├── adapters/xiaomi_api_client.py # httpx 客户端 + XiaomiConfig(XIAOMI_CLIENT_ID/SECRET)
│ └── tasks.py # Celery:cz_xiaomi_sync_daily
│
└── interface/
├── api/xiaomi_routes.py # GET /xiaomi-callback(HTML)+ POST /xiaomi-webhook(registry 分发)
└── handlers.py # cz_register_xiaomi_exception_handlers官方协议要点(实现锚点)
每请求鉴权头(官方「公共传入参数」表)
| 头 | 规则 |
|---|---|
Authorization | Bearer ${access_token} |
X-Client-ID | AppId(XIAOMI_CLIENT_ID) |
X-Client-Nonce | 12 byte → base64url:[0:4] 随机 uint32 大端 + [4:8] 随机 uint32 大端 + [8:12] unix/60 大端 |
X-Trace-Id | 可选(官方建议)— 未给出自动生成 uuid4 |
窗口硬约束(FAQ Q6)
| 接口族 | 窗口 | 备注 |
|---|---|---|
| dataSets / dataPoints / healthRecords / sportRecords | ≤30 天 | 采样参数为毫秒 |
| dailies / goalSummary | ≤7 天 | yyyyMMdd + zoneName=Asia/Shanghai |
| dailies dataTypes | ≤10 类 · latestDataPoint ≤5 类 | — |
订阅事件(官方 7.3 表)
| 事件类别 | subType | 处理 |
|---|---|---|
USER_EVENT | CANCEL_ALL | binding → expired(用户在小米侧解绑 — 引导重绑) |
USER_EVENT | CHANGE_SCOPE | 记录日志(无表操作) |
FITNESS_DATA_EVENT / FITNESS_AGGREGATED_DATA_EVENT / FITNESS_RECORD_DATA_EVENT / SPORT_RECORD_DATA_EVENT | UPDATE | 反查用户 → 增量拉取(metaData startTime/endTime 窗口) |
验签失败 → XiaomiError(40404001) → 401(fail-loud);通知体非法 → 40404002 → 400。
落库映射(device 共享域)
| 小米数据 | 目标表 | 幂等键 / 口径 |
|---|---|---|
| dailies:steps.total / calories.total / exercise_intensity.statistics | DeviceDailyActivity(vendor='xiaomi') | (user, vendor, date);东八区 group.startTime 切日 |
| dailies:sleep.statistic(全天睡眠报告) | SleepSummaryRecord | (user, date);分钟 ×60 转秒 |
| 采样:heart_rate/spo2/stress/vo2_max/resting_hr | HealthMetricRecord(HR/SPO2/STRESS/VO2MAX/REST_HR) | (user, metricType, calendarDate);当日均值 JS round |
| 采样:weight.instantaneous | BodyCompositionRecord | (user, timestamp, source='api');weight/bmi/body_fat/muscle/bone/water |
| 健康记录:com.xiaomi.sleep.health | HealthSleepRecord | (user, calendarDate, sleepStartGMT) 三维(zepp 范式) |
| 运动记录:sportRecords/all | DeviceRawEvent(type='sportRecord') | external_id=sportId;消费管线后续棒 |
四态衔接(授权中心)
XiaomiVendor.cz_meta.planned 动态:未配置(厂家审核中)→ planned=True → 显示「正在审核中」; 配置就绪(配 env)→ planned=False → available。审批通过零代码变更 — 配 env 即转正。
对外接口
| 端点 | 鉴权 | 说明 |
|---|---|---|
POST /api/v1/device/vendors/xiaomi/auth-url | Bearer | 通用发起授权(registry 分发 → XiaomiVendor.cz_start_auth);未配置 → 40403006 |
GET /api/v1/device/xiaomi-callback | 公开 | OAuth 回调(HTML 结果页 2s 关窗;绑定成功 → 订阅登记 + 首拉 fire-and-forget) |
POST /api/v1/device/xiaomi-webhook | 公开(验签) | 订阅推送(registry → cz_handle_webhook);成功恒 200 信封(官方 FAQ Q4) |
关键设计约束
- 官方拼写坑:deviceList 路径为
/heathkit/deviceList(官方原文笔误 — 勿「纠正」) - scope_provider:官方正文拼写
heakthkit为文档笔误,实际值healthkit - token 载体:
access_token_encJSON(camelCase){accessToken, refreshToken, expiresAt(ms), openId, unionId} - token 响应 openId(camel)/ 刷新响应 open_id(snake) — 双键兼容
- 解绑:远端
unbindOAuth2Grained(code=0 唯一成功标志,尽力而为)+ 本地删除(fail-safe 用户意图优先)
测试与质量
| 文件 | 范围 |
|---|---|
tests/unit/xiaomi/test_xiaomi_protocol.py | nonce 字节结构 / 验签 / 前缀剥离 / 字段提取 / 切日 |
tests/unit/xiaomi/test_xiaomi_client.py | httpx.MockTransport:OAuth 全链 + 各接口解析 + 分页 + 解绑 |
tests/unit/xiaomi/test_xiaomi_webhook.py | 验签(正确+错误)/ 五事件族分发 / 增量窗口 |
tests/unit/xiaomi/test_xiaomi_sync.py | 五接口族 → 6 表映射 + 逐族失败隔离 |
tests/unit/xiaomi/test_xiaomi_sync_job.py | 临期刷新 / 降级 / 多绑定隔离 |
tests/unit/xiaomi/test_xiaomi_vendor.py | 七方法适配 + health_check 语义 + webhook 调度 |
tests/unit/xiaomi/test_xiaomi_routes.py | callback HTML / webhook 200/401/400(独立 app) |
tests/integration/test_device_xiaomi.py | 真装配:未配 env → reviewing;配 mock env → available + 验签 E2E |
FAQ
Q1: 为什么 cz_meta.planned 是动态的? A: 产品语义(授权页显示「正在审核中」):配置就绪前即实际「未对接完成」状态;配置 env 后自动转正(审批通过 → 配密钥,零代码变更)。四态映射由 device 域 _cz_status_of 统一裁决。
Q2: 为什么 callback / webhook 路由走 registry 分发而不直接调 use case? A: S2 Protocol v2 统一面(零 vendor 专属胶水 — 与 zepp 同款);路由只做 HTTP 壳,验签/分发/调度全部内聚 XiaomiVendor.cz_handle_webhook。
Q3: 为什么 refresh 失败还继续同步? A: 简化版状态机(zepp job 语义)— 网络抖动不误杀授权;真失效(CANCEL_ALL 推送 / 持续失败)由推送与人工重绑兜底。honor 的 failCount 状态机在本棒未移植(行为差异已记录)。
相关文件
src/app/xiaomi/ ← 本文件所在模块
├── domain/{services/xiaomi_protocol.py, exceptions.py}
├── application/{usecase/ 5 文件, vendor.py, background.py, ports/, factories/}
├── infrastructure/{adapters/xiaomi_api_client.py, tasks.py}
└── interface/{api/xiaomi_routes.py, handlers.py}
docs/internal-wiki/architecture/device-cloud-integration-research.md ← 12 平台矩阵 + 小米要点
docs/internal-wiki/operations/xiaomi-open-platform-application.md ← 申请清单(env 配置来源)
docs/internal-wiki/architecture/idevice-vendor-v2-plan.md ← S3 本棒(Protocol v2 七方法)变更记录 (Changelog)
| 日期 (Asia/Shanghai) | 版本 | 变更 |
|---|---|---|
| 2026-09-16 | 1.0.0 | S3 小米棒:OAuth + 五接口族 + 订阅推送 + 每日 job + XiaomiVendor(第四实现);planned_vendor 移除 xiaomi 占位 |
5. suunto 模块
源:
src/app/suunto/CLAUDE.md(随仓库同步)
src/app/suunto/
Suunto(颂拓)健康数据云接入 — IDeviceVendor 框架第三个真实厂商实现(OAuth + APIM 双 header + webhook + 每日 job)。
模块职责
对接 Suunto Cloud API(apizone.suunto.com 订阅制,Azure APIM 门户): 用户 OAuth 授权 → 拉取运动记录(workout)→ webhook 增量通知 → 数据落共享域(DeviceRawEvent 暂存)。
DDD 4 层结构
src/app/suunto/
├── domain/
│ ├── exceptions.py # 40417xxx(INVALID_SIGNATURE→401 / NOTIFICATION_INVALID→400)
│ └── services/suunto_protocol.py # 纯函数:JWT user claim 解析 / HMAC 验签 / 事件常量 / 窗口口径
├── application/
│ ├── ports/services.py # SuuntoApiPort(+ 端点常量)
│ ├── usecase/ # suunto_auth / suunto_callback / suunto_sync /
│ │ # suunto_webhook / suunto_sync_job(5 use case)
│ ├── vendor.py # SuuntoVendor(IDeviceVendor v2 七方法实现)
│ ├── background.py # fire-and-forget 调度
│ └── factories/create_suunto_usecases.py
├── infrastructure/
│ ├── adapters/suunto_api_client.py # httpx + Basic Auth + Bearer 双 header
│ └── tasks.py # cz_suunto_sync_daily(Celery beat)
└── interface/
├── api/suunto_routes.py # callback(HTML 承接)+ webhook(registry 统一分发)
└── handlers.py # 异常 → 业务信封对外接口
| 路由 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
/api/v1/device/suunto-callback | GET | 公开(state 校验) | OAuth 回调 → 换 token → 落 DeviceBinding |
/api/v1/device/{vendor_id}/webhook | POST | HMAC-SHA256 验签(raw body) | 官方推送入口(registry 统一分发 → cz_handle_webhook) |
Vendor 能力(SuuntoVendor,注册于 device registry;未配 env → 授权中心 reviewing): cz_meta / cz_start_auth / cz_handle_callback / cz_sync_data / cz_unbind / cz_health_check / cz_handle_webhook(cz_import_file → NotSupported)。
云 API 要点(官方 how-to 逐字对拍)
| 维度 | 内容 |
|---|---|
| OAuth | cloudapi-oauth.suunto.com/oauth/authorize → code → POST /oauth/token(Basic Auth CLIENT_ID:SECRET,form)→ {access_token(JWT), refresh_token, expires_in: 86400, scope: "workout"} |
| JWT | access_token 即 JWT;user claim = Suunto 用户名 → DeviceBinding.vendor_user_id 与 webhook 匹配共用此键 |
| API 鉴权 | 每请求双 header:Authorization: Bearer <JWT> + Ocp-Apim-Subscription-Key: <订阅 key> |
| 数据 | GET cloudapi.suunto.com/v2/workouts(since/until 毫秒 + limit≤100 + offset 分页)+ GET /v2/workouts/{key} 详情 |
| 配额 | Developer API 10 calls/min + 200 calls/week → 详情仅对新 workout 拉取(配额友好策略) |
| Webhook | WORKOUT_CREATED(→ 增量拉取 fire-and-forget)/ ROUTE_CREATED(登记忽略);验签 fail-loud 401;2s ack 超时(use case 只验签分发,不内联拉取) |
env 配置(三键齐备才 configured → 授权中心 available)
SUUNTO_CLIENT_ID / SUUNTO_CLIENT_SECRET / SUUNTO_SUBSCRIPTION_KEY
SUUNTO_WEBHOOK_SECRET # 缺省取 client_secret同步策略
- webhook 主通道:WORKOUT_CREATED → 增量拉取
- Celery beat daily 兜底(
cz_suunto_sync_daily):30 天窗口 + token 临期(<30min)refresh 回写 DeviceBinding
测试
- 单测 91 + 集成 6(
tests/unit/suunto/+ 集成):OAuth Basic Auth 断言 / JWT claim(中文/非法输入宽容)/ 双 header / 验签复算 fail-closed / 分发六路径 / 同步幂等 + 分页 / job 临期降级 / registry 装配 - 未配 env → 授权中心 reviewing(
40403006友好文案);mock env → available
变更记录
| 日期 | 内容 |
|---|---|
| 2026-09-16 | 首版:S5 接入(OAuth + APIM 双 header + webhook + 每日 job — IDeviceVendor 第三实现) |