---
url: /docs/modules/device-line.md
---
# 模块手册 · 设备线（device-line）

> 收录 **5** 个模块的权威开发手册（源自各模块 `CLAUDE.md`，随仓库同步）。

## 本文目录

* 1. device
* 2. honor
* 3. zepp
* 4. xiaomi
* 5. 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/device` action 协议（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_handlers
```

### IDeviceVendor 框架（五方法）

| 方法 | 语义 | 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 替换，端点契约不变）

### 对外接口

详见 [API Docs → device](/api-docs/v1/device/)。

| 端点 | 说明 | 鉴权 |
|---|---|---|
| `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 表
* **绑定切换**：`honorAdoptBinding` action（后台已有授权改挂当前用户）
* **双层 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 继续） |

### 对外接口

详见 [API Docs → device/honor](/api-docs/v1/device/honor)。

| 端点 | 鉴权 | 说明 |
|---|---|---|
| `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 差异记录在迁移报告）

### 对外接口

详见 [API Docs → device/zepp](/api-docs/v1/device/zepp)。

| 端点 | 鉴权 | 说明 |
|---|---|---|
| `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` 实现 `IDeviceVendor` v2 七方法
* 错误码区段 **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_enc` JSON（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 第三实现）|
