mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
chore: remove docs unrelated to running the project
- Remove docs/PROJECT_DOCUMENTATION.md (internal refactoring spec) and docs/QR_LOGIN.md (implementation walkthrough); protocol reference lives in source comments (adapters/, api.py) - Keep docs/PRIVACY.md (data-flow audit backing the no-upload claim) - Update README/CHANGELOG links accordingly
This commit is contained in:
1 parent
45f1d2e701
commit
addd2849cc
4 files changed
+1
-986
No files matched your search
@@ -1,774 +0,0 @@
|
||||
# mi-fitness-mcp-cn 项目完整技术文档
|
||||
|
||||
> **文档目的**:作为重构/二次开发的唯一权威参考。内容基于源码逐行整理(基线 commit `7fcd069`),所有行为描述均与代码实际实现一一对应,不包含猜测。
|
||||
> **阅读对象**:执行重构的 AI 或工程师。重构约束与已知问题见第 15、16 章,**动手前必读**。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [项目定位与背景](#1-项目定位与背景)
|
||||
2. [快速事实卡](#2-快速事实卡)
|
||||
3. [总体架构](#3-总体架构)
|
||||
4. [目录结构与代码地图](#4-目录结构与代码地图)
|
||||
5. [配置层 config.py](#5-配置层)
|
||||
6. [凭据与认证 auth](#6-凭据与认证)
|
||||
7. [数据模型 models](#7-数据模型)
|
||||
8. [小米健康云协议(逆向核心)](#8-小米健康云协议)
|
||||
9. [适配器层 mi_fitness_cloud.py](#9-适配器层)
|
||||
10. [同步服务 sync_service.py](#10-同步服务)
|
||||
11. [存储层 storage](#11-存储层)
|
||||
12. [查询服务 query_service.py](#12-查询服务)
|
||||
13. [MCP Server server.py](#13-mcp-server)
|
||||
14. [CLI、辅助脚本与测试](#14-cli辅助脚本与测试)
|
||||
15. [已知问题清单(重构目标)](#15-已知问题清单)
|
||||
16. [重构方案建议](#16-重构方案建议)
|
||||
17. [本地环境备注](#17-本地环境备注)
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目定位与背景
|
||||
|
||||
**mi-fitness-mcp-cn** 是一个本地运行的 Python MCP(Model Context Protocol)Server,用于把用户自己的「小米运动健康」App 云端数据同步到本地 SQLite,并通过 MCP 工具供 AI 客户端(如 Claude Desktop)查询。
|
||||
|
||||
- 改造自 `kubulashvili/mi-fitness-mcp`(国际区),本 fork 的增量:
|
||||
1. 支持中国区云端(`--region cn`,API host `hlth.io.mi.com`,时区 UTC+8);
|
||||
2. 新增睡眠(`sleep`)与运动记录(`workouts`)同步;
|
||||
3. 额外逆向并接入 4 个数据 key:`resting_heart_rate`、`spo2`、`stress`、`abnormal_heart_beat`;
|
||||
4. CLI 输出与注释中文化。
|
||||
- **非官方项目**,接口全部为逆向所得,无任何官方保证,随时可能因小米改版而失效。
|
||||
- 仅用于读取分析用户**自己的**健康数据。MIT 协议。
|
||||
|
||||
## 2. 快速事实卡
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 语言 / 最低版本 | Python ≥ 3.11(用了 `datetime.UTC`) |
|
||||
| 核心依赖 | `mcp`(**必须 <2**,2.x 移除了 `Server.list_tools`)、`httpx`、`pydantic` ≥2、`platformdirs`、`keyring`、`click`(声明了但**未使用**)、`rich`(声明了但**未使用**) |
|
||||
| 构建 | hatchling;入口 `mi-fitness-mcp = mi_fitness_mcp.cli_mi_fitness:main` |
|
||||
| 源码规模 | src 约 3800 行 + 测试 370 行 + 独立探测脚本 268 行 |
|
||||
| 异步模型 | 全 asyncio(httpx.AsyncClient + MCP stdio);但 DB 层是**同步 sqlite3** |
|
||||
| 配置文件 | `<user_config_dir>/mi-fitness-mcp/config.json`(Windows: `C:\Users\<u>\AppData\Local\mi-fitness-mcp\mi-fitness-mcp\config.json`) |
|
||||
| 凭据存储 | OS keyring,服务名 `mi-fitness-mcp`,账号 `mi_fitness_auth_user_id` / `mi_fitness_auth_pass_token` |
|
||||
| 数据库 | `<user_data_dir>/mi-fitness-mcp/mi_fitness.db`(Windows 同上双目录),SQLite,8 张业务表 + 1 张同步状态表 |
|
||||
| 认证方式 | 小米账号 Cookie 中的 `userId` + `passToken`(用户手工从浏览器获取),换取 `serviceToken` + `ssecurity` |
|
||||
| 区域 | 已知 `ru, cn, de, i2, sg, us`;cn 用 `hlth.io.mi.com` + UTC+8,其余用 `{region}.hlth.io.mi.com` + UTC |
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ MCP 客户端 (AI) │
|
||||
└───────────────▲──────────────────────────────┘
|
||||
│ stdio (JSON-RPC)
|
||||
┌──────────────────────────────────────┴──────────────────────────────┐
|
||||
│ cli_mi_fitness.py ──► main.py (argparse CLI: serve/setup/doctor/sync)
|
||||
│ serve ─────────────► server.py (mcp.server.Server, 模块级单例)│
|
||||
│ │ list_tools / call_tool │
|
||||
│ sync ──┐ ▼ │
|
||||
│ │ ┌─────────────────────┐ ┌──────────────────┐ │
|
||||
│ └─────►│ SyncService │ │ QueryService │ │
|
||||
│ │ 分块/增量/去重 │ │ 聚合/过滤(内存) │ │
|
||||
│ └─────────┬───────────┘ └────────┬─────────┘ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ Database (sqlite3, 同步) │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
└─────────────▲───────────────────────────────────────────────────────┘
|
||||
│ AsyncIterator[Pydantic Model]
|
||||
│ ┌──────────┴───────────────────────┐
|
||||
│ │ MiFitnessCloudAdapter │
|
||||
│ │ 登录换取 serviceToken/ssecurity │
|
||||
│ │ RC4 加密 + SHA1 签名 │
|
||||
│ │ 分页拉取 + 解析为模型 │
|
||||
│ └──────────┬───────────────────────┘
|
||||
└─────────────┼───────────────────────────────────────────────────────┘
|
||||
│ HTTPS (加密表单)
|
||||
┌──────────┴──────────────────┐ ┌─────────────────────────┐
|
||||
│ account.xiaomi.com 登录 │ │ hlth.io.mi.com 数据接口 │
|
||||
└─────────────────────────────┘ └─────────────────────────┘
|
||||
```
|
||||
|
||||
分层职责一句话:
|
||||
|
||||
- **协议层(adapter 内嵌)**:登录、加密签名、分页、把云端 JSON 解析成 Pydantic 模型。
|
||||
- **服务层**:SyncService 负责「按 7 天分块拉取→落库→记录增量游标」;QueryService 负责从本地库读出后做内存聚合/过滤。
|
||||
- **接口层**:server.py 暴露 14 个 MCP 工具;main.py 暴露 4 个 CLI 子命令。
|
||||
- **存储层**:sqlite3 同步连接,UPSERT 写入,字符串日期过滤查询。
|
||||
|
||||
## 4. 目录结构与代码地图
|
||||
|
||||
```
|
||||
mi-fitness-mcp-cn/
|
||||
├── pyproject.toml # 构建/依赖/工具链(mcp>=1.0.0,<2 为本地修复,上游仍是 >=1.0.0)
|
||||
├── probe_mifitness.py # 独立交互式探测脚本:复制了一份登录+加密代码(268 行,代码重复)
|
||||
├── .env.example # 声明了 MI_FITNESS_USER_ID/PASS_TOKEN/REGION —— 但代码里【没有任何 env 加载逻辑】
|
||||
├── mcp-server-config.example.json
|
||||
├── docs/PROJECT_DOCUMENTATION.md # 本文档
|
||||
├── tests/ # pytest + pytest-asyncio(auto) + respx
|
||||
│ ├── test_config.py # 14 行:配置存取 roundtrip
|
||||
│ ├── test_mi_fitness_cloud.py# 102 行:适配器纯函数(解析/时区/分页辅助)
|
||||
│ ├── test_server_sync.py # 77 行:server 同步编排(mock adapter)
|
||||
│ ├── test_storage_query.py # 47 行:入库/查询
|
||||
│ └── test_sync_service.py # 130 行:分块、增量、异常→partial
|
||||
└── src/mi_fitness_mcp/
|
||||
├── __init__.py # 3 行
|
||||
├── cli_mi_fitness.py # 5 行:entry point 转发器 → main.main
|
||||
├── main.py # 193 行:argparse CLI(serve/setup/doctor/sync)⚠ 名字叫 main 实为 CLI
|
||||
├── server.py # 657 行:MCP Server(tools + handlers + 后台同步 + 生命周期)
|
||||
├── config.py # 76 行:Config 模型 + JSON 读写
|
||||
├── auth/__init__.py # 45 行:keyring 存取 userId/passToken
|
||||
├── models/__init__.py # 190 行:全部 Pydantic 模型
|
||||
├── adapters/
|
||||
│ ├── base.py # 116 行:DataAdapter 抽象基类(8 个 iter_* + 4 个基础方法)
|
||||
│ └── mi_fitness_cloud.py # 805 行:★核心★ 协议实现 + 8 类数据解析器
|
||||
├── services/
|
||||
│ ├── sync_service.py # 290 行:分块增量同步引擎
|
||||
│ └── query_service.py # 366 行:读侧聚合/过滤
|
||||
└── storage/__init__.py # 878 行:Database 类(建表/UPSERT/查询/覆盖统计)
|
||||
```
|
||||
|
||||
**模块依赖方向**(重构时保持或理顺):`cli → server/main`、`server → services + adapter + storage + auth + config`、`services → adapter + storage`、`adapter → models + base`、`storage → models`。无循环依赖。
|
||||
|
||||
## 5. 配置层
|
||||
|
||||
`Config`(pydantic BaseModel)字段全表,含**实际使用状态**:
|
||||
|
||||
| 字段 | 类型/默认 | 实际用途 | 状态 |
|
||||
|---|---|---|---|
|
||||
| `mode` | `Literal["mi_fitness_cloud","not_configured"]` = `not_configured` | 判定是否已配置 | 使用中 |
|
||||
| `region` | str = `"ru"` | API host 与时区选择 | 使用中;⚠ 默认值与 README 推荐的 `cn` 不一致 |
|
||||
| `timezone` | str = `"UTC"` | 仅 `get_profile` 返回值里出现 | **准死代码** |
|
||||
| `database_path` | Path = platformdirs | DB 位置 | 使用中 |
|
||||
| `logs_path` | Path = platformdirs | —— | **完全未使用**(无 logging 配置代码) |
|
||||
| `auto_sync_on_start` | bool = True | —— | **未使用**(server 启动明确不连接不同步) |
|
||||
| `stale_after_minutes` | int = 60 | —— | **未使用** |
|
||||
| `store_raw_payloads` | bool = True | —— | **未使用**(原始报文从未落库) |
|
||||
| `default_lookback_days` | 30 (1–3650) | 未给 start_date 时的回溯天数 | 使用中 |
|
||||
| `sync_chunk_days` | 7 (1–90) | 分块大小 | 使用中 |
|
||||
| `http_timeout_seconds` | 20.0 (0–120] | httpx 超时 | 使用中 |
|
||||
| `request_retries` | 3 (1–10] | 请求重试次数 | 使用中 |
|
||||
| `health_check_timeout_seconds` | 10.0 | 状态工具的健康检查 wait_for | 使用中 |
|
||||
| `sync_type_timeout_seconds` | 180.0 | 单类型同步 asyncio.wait_for 上限 | 使用中(仅 MCP 路径;CLI 路径无超时) |
|
||||
| `max_pages` | 200 (1–5000) | 单 key 分页安全上限 | 使用中 |
|
||||
|
||||
- 存取:`load_config()` 无文件则生成默认配置并**立即写盘**;`save_config()` 把 Path 序列化为字符串。
|
||||
- 路径:`platformdirs.user_config_dir("mi-fitness-mcp")` / `user_data_dir(...)` / `user_log_dir(...)`。Windows 实测为 `AppData\Local\mi-fitness-mcp\mi-fitness-mcp\`(appauthor 与 appname 双层目录)。
|
||||
- ⚠ `.env.example` 是孤儿文件:代码不读取任何环境变量。
|
||||
|
||||
## 6. 凭据与认证
|
||||
|
||||
`auth/__init__.py`,基于系统 keyring:
|
||||
|
||||
- `save_mi_fitness_token(user_id, pass_token)`:写两条密码项,服务名 `mi-fitness-mcp`,账号名 `mi_fitness_auth_user_id` / `mi_fitness_auth_pass_token`。
|
||||
- `load_mi_fitness_token()`:读取,任何异常吞掉并返回 `(None, None)`。
|
||||
- `delete_mi_fitness_token()`:删除。**没有任何 CLI/MCP 命令调用它**(无 logout 功能)。
|
||||
- README 提示:无系统 keyring 时可装 `keyrings.alt`(可能明文存盘)。
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
`models/__init__.py`。所有业务实体继承 `BaseEntity`:
|
||||
|
||||
| 公共字段 | 说明 |
|
||||
|---|---|
|
||||
| `id` | 确定性主键,格式 `mi_fitness_{类别}_{业务键}`(详见 §11 UPSERT) |
|
||||
| `provider` | 恒为 `mi_fitness` |
|
||||
| `source_type` | 恒为 `cloud_session` |
|
||||
| `source_record_id` | 云端记录 `time` 字符串,可空 |
|
||||
| `user_id` / `device_id` | device_id 恒为 None(云端未解析设备) |
|
||||
| `timezone` | 记录级时区名(`zone_name`),默认 UTC |
|
||||
| `collected_at` | 记录时间(带记录自身 offset) |
|
||||
| `created_at/updated_at` | 模型层默认 now(UTC) 去掉 tzinfo;**落库时并不写入这两列** |
|
||||
|
||||
业务实体:
|
||||
|
||||
| 模型 | 关键字段 | 备注 |
|
||||
|---|---|---|
|
||||
| `DailyActivity` | `date`(YYYY-MM-DD)、`steps`、`distance_m`、`active_kcal`;可选 `total_kcal/floors/active_minutes` | 云端只解析前 3+2 项 |
|
||||
| `SleepSession` | `sleep_id`、`start_at/end_at`、`duration_minutes`、`time_asleep/time_awake_minutes`、`sleep_score`(0-100)、`is_nap`、`stages: list[SleepStage]` | stages 落库为 JSON 字符串 |
|
||||
| `SleepStage` | `stage ∈ {deep,light,rem,awake}`、`minutes` | state 映射见 §9.4 |
|
||||
| `Workout` | `workout_id`、`activity_type`、`start_at/end_at`、`duration_minutes`、距离/卡路里/平均最高心率/配速/步数 | 可选字段 0 值会被转 None |
|
||||
| `BodyMeasurement` | `timestamp`、`weight_kg`(必填>0)、bmi/体脂/肌肉/水分/骨量/内脏脂肪/基础代谢/代谢年龄 | |
|
||||
| `HeartRateSample` | `timestamp`、`bpm`、`sample_type ∈ {resting,active,passive,workout}` | `workout` 枚举**永远不会被赋值** |
|
||||
| `SpO2Sample` | `timestamp`、`spo2_pct`(0-100) | |
|
||||
| `StressSample` | `timestamp`、`stress_score`(0-100)、`level ∈ {low,medium,high}` | level 阈值 <30 low,<60 medium,否则 high |
|
||||
| `AbnormalHeartBeatEvent` | `event_id`、`start_at/end_at`、`duration_seconds` | |
|
||||
|
||||
响应/辅助模型:
|
||||
|
||||
- `QueryResponse`:所有 MCP 查询工具的统一信封 `{status, source, generated_at, timezone, data, error}`。
|
||||
- `ConnectionStatus`:连接状态工具用(代码里还手工 merge 了 `connection_state/region/last_health_check_at/last_connection_error/sync_in_progress` 等额外键)。
|
||||
- `SyncResult`、`UserProfile`、`DeviceInfo`、`DataCoverage`:**已定义但基本未使用**(SyncResult 的字段被 server 手工拼 dict;UserProfile 仅部分字段复用;DataCoverage.gaps_detected 从未计算)。
|
||||
|
||||
## 8. 小米健康云协议
|
||||
|
||||
> 本章是本项目最有价值的资产,重构时**协议行为必须保持逐字节兼容**,否则登录/签名会失效。
|
||||
|
||||
### 8.1 登录(passToken → serviceToken + ssecurity)
|
||||
|
||||
```
|
||||
GET https://account.xiaomi.com/pass/serviceLogin?_json=true&sid=miothealth
|
||||
Cookie: userId={user_id}; passToken={pass_token}
|
||||
```
|
||||
|
||||
- 响应体以 **`&&&START&&&`** 前缀开头,去掉前缀后为 JSON,关键字段:
|
||||
- `passToken`(新 token,登录成功后**回写覆盖** self.pass_token)
|
||||
- `userId`(回写,转 str)
|
||||
- `ssecurity`(base64,解码后保存为 bytes,用于后续签名)
|
||||
- `location`(重定向 URL)
|
||||
- 随后 `GET {location}`,收集响应的 `set-cookie`(取每条的 `k=v` 部分),拼接为会话 Cookie(含 `serviceToken` 等)。
|
||||
- ⚠ `httpx.AsyncClient(follow_redirects=False)`:location 是手工 GET 的;该响应的 cookie 不会自动进 cookie jar,而是拼成字符串 `self._cookies` 手工携带。
|
||||
|
||||
### 8.2 请求加密与签名(RC4 + 双签名)
|
||||
|
||||
对数据端点的每次 POST:
|
||||
|
||||
1. 明文表单:`data = json.dumps(payload, separators=(",", ":"))`(紧凑 JSON)。
|
||||
2. `nonce = os.urandom(8) + struct.pack(">I", unix_ts // 60)`(12 字节,后 4 字节是**分钟级**时间戳,大端)。
|
||||
3. `signed_nonce = sha256(ssecurity + nonce)`(32 字节原始摘要)。
|
||||
4. RC4 加密:以 `signed_nonce` 为 key,KSA 标准初始化后 **PRGA 先丢弃 1024 字节**,再异或明文。
|
||||
5. 签名函数 `_gen_signature(method, path, values, signed_nonce)`:
|
||||
`base = method + "&" + path + "&data=" + values["data"]`(若 values 里有 `rc4_hash__` 则追加 `&rc4_hash__=...`)+ `"&" + base64(signed_nonce)`;结果 `base64(sha1(base))`。
|
||||
6. 组装最终表单(全部再 urlencode):
|
||||
- `data`:RC4(明文 JSON)
|
||||
- `rc4_hash__`:对**明文**表单计算的签名
|
||||
- `signature`:对**密文**表单(含 data、rc4_hash__)计算的签名
|
||||
- `_nonce`:base64(nonce)
|
||||
7. `POST {base_url}{api_path}`,`Content-Type: application/x-www-form-urlencoded`,`Cookie: {会话cookie}`。
|
||||
8. 响应体是 **base64 文本**,用 `signed_nonce` RC4 解密得 JSON:`{code, message, result}`。
|
||||
- `code != 0` → 抛错;`code ∈ {401,403,-6,-10001}` 或 message 命中 auth 标记(`unauthorized / not logged in / session expired / invalid pass token / login required / ...`,大小写折叠匹配)→ 判定为认证失败。
|
||||
|
||||
### 8.3 端点与分页
|
||||
|
||||
| 端点 | 用途 | 请求 payload | 响应关键字段 |
|
||||
|---|---|---|---|
|
||||
| `POST /app/v1/data/get_fitness_data_by_time` | 健康数据(steps/calories/heart_rate/resting_heart_rate/weight/sleep/spo2/stress/abnormal_heart_beat) | `{start_time, end_time, key, next_key?}`(epoch 秒) | `data_list[]`、`has_more`、`next_key` |
|
||||
| `POST /app/v1/data/get_sport_records_by_time` | 运动记录 | `{start_time, end_time, limit: 50, next_key?}` | `sport_records[]`、`has_more`、`next_key` |
|
||||
|
||||
- host:region 为 `cn` 或空 → `https://hlth.io.mi.com`;否则 `https://{region}.hlth.io.mi.com`。
|
||||
- 分页:循环携带 `next_key` 直到 `has_more` 为假;用 `seen_keys` 集合检测游标成环(报 `pagination cursor loop detected`);页数超过 `max_pages`(默认 200)报 `pagination exceeded safety limit`。
|
||||
- 列表项公共字段:`time`(epoch 秒,记录时间)、`zone_offset`(秒)、`zone_name`(如 `Asia/Shanghai`)、`sid`(设备/来源 ID)、`value`(**JSON 字符串或 dict**)。
|
||||
- 时间换算:请求范围由「日期字符串 + 区域时区」决定——cn/空 → UTC+8,其他 → UTC;`end_date` 取到当天 `23:59:59`。
|
||||
|
||||
### 8.4 各数据 key 的载荷格式与字段映射
|
||||
|
||||
`value` 内字段 → 模型字段(括号内为备选字段名):
|
||||
|
||||
**steps / calories(daily_activity 两次请求)**
|
||||
- steps:`{steps, distance, calories}` → 按天累加 steps/distance_m/active_kcal(初值)
|
||||
- calories:`{calories}` → 按天累加后**整体覆盖**该日的 `active_kcal`
|
||||
- 日归属:以记录 `time` + `zone_offset` 格式化为 `YYYY-MM-DD`
|
||||
|
||||
**sleep**
|
||||
- 入睡:`bedtime`(备选 `device_bedtime`、`bed_timestamp`)
|
||||
- 醒来:`wake_up_time`(备选 `device_wake_up_time`、`out_bed_timestamp`、兜底 `time`)
|
||||
- `duration`(分钟;缺省用 end-start);清醒 `awake_duration`(备选 `sleep_awake_duration`);入睡分钟 = duration - awake
|
||||
- `score`(备选 `sleep_score`)→ sleep_score;`is_nap`
|
||||
- 分段 `items[]`:`{start_time, end_time, state}`,state 映射:`1→deep, 2→light, 3→light, 4→awake, 5→rem`,其他→light;分钟数=(end-start)//60,0 分钟丢弃
|
||||
- `sleep_id = f"{sid or user_id}_{item.time or int(睡眠结束ts)}"`
|
||||
|
||||
**heart_rate**
|
||||
- `{type, bpm}`:`type==0 → sample_type="passive"`,否则 `"active"`
|
||||
- **resting_heart_rate**(独立 key):`{date_time, bpm}` → `sample_type="resting"`;时间戳优先 `date_time`
|
||||
|
||||
**weight(body_measurements)**
|
||||
- `weight`;`bmi`;`body_fat_rate→body_fat_pct`;`muscle_rate→muscle_mass_kg`;`moisture_rate→water_pct`;`bone_mass`;`visceral_fat→visceral_fat_score`;`basal_metabolism→basal_metabolism_kcal`;`body_age→metabolic_age`
|
||||
- id:`mi_fitness_weight_{item.time}`
|
||||
|
||||
**workouts(sport 端点)**
|
||||
- `start_time`(兜底 `time`)、`end_time`(缺省 = start+duration 秒)、`duration`(秒)
|
||||
- `distance`、`calories`(备选 `total_cal`)、`avg_hrm`、`max_hrm`、`avg_pace`、`max_pace`、`steps`(备选 `total_steps`)、`sport_type`
|
||||
- `activity_type = item.category or item.key or sport_type or "workout"`
|
||||
- `workout_id = f"{sid or user_id}_{item.key or 'workout'}_{item.time or start_ts}"`
|
||||
|
||||
**spo2**:`{time, spo2 | value}`;timestamp 优先 value 内 `time`
|
||||
**stress**:`{time, stress | score | value}`;level 按分数分段
|
||||
**abnormal_heart_beat**:`{start_time, end_time}`;`event_id = f"{sid or user_id}_{int(start_ts)}"`
|
||||
|
||||
数值清洗:`_optional_float/_optional_int` 把 **0 值转 None**(缺省语义与“真实 0”被合并,见 §15-I6)。
|
||||
|
||||
### 8.5 错误处理与重试(_request 循环)
|
||||
|
||||
- 重试 `request_retries`(默认 3)次,指数退避 `min(4s, 0.5*2^attempt) + rand(0..0.1)`。
|
||||
- 可重试:httpx 超时/网络错误;HTTP 429、≥500。
|
||||
- 认证失败(401/403 或业务码/message 命中):`_connected=False`,若还有重试余量且持有凭据 → **用 passToken 重新登录**并继续重试;重新登录失败 → 立即终止。
|
||||
- 其他业务错误:立即终止。
|
||||
- 最终失败统一抛 `RuntimeError("Mi Fitness request failed: ...")`(认证错误原样抛 `MiFitnessAuthenticationError`)。
|
||||
- ⚠ 全部错误被包装后,上层无法再区分认证失败与普通失败(`MiFitnessAuthenticationError` 在 `_request` 末尾被重新抛出,这一条保留;但 `connect()` 会把一切折叠成 `False` + 字符串 last_error)。
|
||||
|
||||
### 8.6 区域发现与时区
|
||||
|
||||
- `_discover_region(preferred)`:对候选区域依次试拉 `weight/steps/heart_rate`(固定区间 2025-04-01~2025-05-31),有数据即选中。**当前永远不会被调用**(region 恒为非空,见 §9.1 注释)——准死代码。
|
||||
- `_request_timezone`:`cn`/空 → UTC+8;其余 → UTC。`Config.timezone` 字段不参与此逻辑。
|
||||
|
||||
## 9. 适配器层
|
||||
|
||||
`adapters/base.py`:`DataAdapter(ABC)` 抽象 4 个基础方法(`connect/is_connected/get_user_id/get_available_data_types`)+ 8 个 `iter_*`(同步或异步迭代器二选一,签名返回 `Iterator | AsyncIterator` 联合类型——重构时建议收敛为纯 async)。
|
||||
|
||||
`adapters/mi_fitness_cloud.py` 实现要点:
|
||||
|
||||
- **懒连接**:MCP 启动时只构造不连接(`server.main` 注释:stdio 必须快速可用);所有工具按需 `connect()`。`connect()` 有 `asyncio.Lock` 防并发,成功后 `_discover_data_types()` 返回**固定 8 类型列表**(不再探测,注释说明了原因:固定历史区间探测会静默漏数据)。
|
||||
- `health_check()`:真实调用 `_fetch_key("steps", today, today)`(1 天小请求验证认证+数据通路);瞬时网络失败**不会**使有效会话失效。
|
||||
- `iter_*` 的共同骨架:`if not connected or 没日期: return; yield`(空迭代器)→ 拉取 → 解析 yield。**注意 `return; yield` 写法**是为了把生成函数变为 async generator 的惯用法。
|
||||
- 分页辅助 `_fetch_key`(fitness)/ `_fetch_sport_records_by_time`(sport):见 §8.3。
|
||||
- `_record_datetime`:`datetime.fromtimestamp(item.time, tz=timezone(item.zone_offset))` —— 所有记录时间都带**记录自身**的时区偏移。
|
||||
- 每次 `connect()` 都会新建 `httpx.AsyncClient` 并重登录;`close()` 关闭并置 `_connected=False`。
|
||||
- 可调参数(由 server 从 Config 注入):`http_timeout / request_retries / max_pages`。
|
||||
|
||||
## 10. 同步服务
|
||||
|
||||
`services/sync_service.py`。`SyncService(adapter, db, default_lookback_days=30, chunk_days=7)`。
|
||||
|
||||
### 10.1 并发控制
|
||||
|
||||
- `sync_data_type()`:`if self._sync_active or self._sync_lock.locked(): raise RuntimeError` → 置 `_sync_active=True`(**无 await 的检查+置位**,在单事件循环内原子)→ `async with lock` 执行 → finally 复位。
|
||||
- server.py 另有全局 `sync_active` 布尔(拒绝并发 sync_data 工具调用)——**两把锁叠加**。
|
||||
|
||||
### 10.2 单类型同步算法 `_sync_data_type_unlocked`
|
||||
|
||||
1. 增量游标:非 force_full 时读 `sync_state.last_record_timestamp`(`datetime.fromisoformat` 后 `replace(tzinfo=None)` —— ⚠ 丢偏移量)。
|
||||
2. 日期缺省:`end_date` 默认今天(**本地时区 naive**);`start_date` 缺省 = 游标日期(若有)否则 `end - (lookback-1)` 天。
|
||||
3. 校验 `start ≤ end`,格式必须 `YYYY-MM-DD`。
|
||||
4. **分块循环**:以 `chunk_days`(7)切分区间,逐块调 `_sync_range`;单块异常 → 记录 chunk error 并**立即停止**后续块,返回 `status=partial`(若已有成功块)或 `error`。
|
||||
5. 汇总 `{status, data_type, added, updated, skipped, start_date, end_date, chunks[]}`;每个 chunk 记录 added/updated/skipped。
|
||||
6. `skipped` 恒为 0(计数器从未自增)。
|
||||
|
||||
### 10.3 `_sync_range`(单块落库)
|
||||
|
||||
- 按 `data_type` 分派到 8 个 `iter_*`,逐条 `db.insert_*`;返回 True 计 added,False 计 updated(**实际永远 True**,见 §15-I2)。
|
||||
- 逐条跟踪 `last_ts`(各类型用不同字段:activity 用 `collected_at`、sleep/workout/abnormal 用 `start_at`、其余用 `timestamp`)。
|
||||
- 块结束时 `db.update_sync_state(data_type, last_ts)`(仅当 last_ts 非空)。
|
||||
- 未知类型抛 `ValueError`。
|
||||
- `sync_data_type_sync()`:`asyncio.run` 包装的同步版本 —— **在已有事件循环内会炸**,当前无人调用(死代码)。
|
||||
|
||||
### 10.4 性能特征(实测)
|
||||
|
||||
- 8 个月全类型同步 ≈ 每类型 `ceil(天数/7)` 次端点调用(heart_rate 还要 ×2:active+resting keys),单线程串行,总耗时数分钟。首次全量 2026-01-01→08-30 实测约 5–9 分钟;昨天+今天增量 <10 秒。
|
||||
|
||||
## 11. 存储层
|
||||
|
||||
`storage/__init__.py`(878 行)。同步 sqlite3,`sqlite3.Row`,**每次操作独立连接**(`contextmanager` 打开/关闭),每条 INSERT 单独 `conn.commit()`。
|
||||
|
||||
### 11.1 表结构(8 业务表 + sync_state)
|
||||
|
||||
所有业务表公共列:`id TEXT PK, provider, source_type, source_record_id, user_id, device_id, timezone, collected_at, created_at, updated_at, …业务列`。时间值以 **ISO 字符串(带原 offset)** 存储。
|
||||
|
||||
| 表 | 业务列 | 业务 UNIQUE 约束 | UPSERT 冲突目标 |
|
||||
|---|---|---|---|
|
||||
| `daily_activity` | date, steps, distance_m, active_kcal, total_kcal, floors, active_minutes | `(user_id, date, device_id)` | `ON CONFLICT(id)` → 更新 steps/distance/active_kcal/timezone/collected_at 等 |
|
||||
| `sleep_sessions` | sleep_id, start_at, end_at, duration_minutes, time_asleep_minutes, time_awake_minutes, sleep_score, is_nap, stages(JSON TEXT) | `(user_id, sleep_id)` | `ON CONFLICT(user_id, sleep_id)` → 不更新 start_at/end_at(⚠ 后端变化不会修正时间) |
|
||||
| `workouts` | workout_id, activity_type, start_at, end_at, duration_minutes, distance_m, calories_kcal, avg/max_heart_rate_bpm, avg/max_pace_sec_per_km, total_steps | `(user_id, workout_id)` | `ON CONFLICT(user_id, workout_id)` → **不更新 activity_type/start_at/end_at/pace/steps** |
|
||||
| `body_measurements` | timestamp, weight_kg, bmi, body_fat_pct, muscle_mass_kg, water_pct, bone_mass_kg, visceral_fat_score, basal_metabolism_kcal, metabolic_age | `(user_id, timestamp, device_id)` | `ON CONFLICT(id)` → 只更新 weight/bmi/body_fat/muscle/water |
|
||||
| `heart_rate_samples` | timestamp, bpm, sample_type | `(user_id, timestamp, sample_type)` | `ON CONFLICT(id)` → 全字段更新;⚠ id=`mi_fitness_hr_{time}` 与业务约束不一致:同秒不同类型 → id 不同但撞业务 UNIQUE → `IntegrityError` |
|
||||
| `spo2_samples` | timestamp, spo2_pct | `(user_id, timestamp)` | `ON CONFLICT(id)` |
|
||||
| `stress_samples` | timestamp, stress_score, level | `(user_id, timestamp)` | `ON CONFLICT(id)` |
|
||||
| `abnormal_heart_beat_events` | event_id, start_at, end_at, duration_seconds | `(user_id, event_id)` | `ON CONFLICT(user_id, event_id)` |
|
||||
| `sync_state` | id PK, data_type UNIQUE, last_sync_at, last_record_timestamp, records_count | —— | `ON CONFLICT(data_type)`;⚠ `records_count` 永远是 0(从不更新) |
|
||||
|
||||
索引:8 个 `(user_id, 日期/时间列)` 索引。无迁移机制(仅 `CREATE TABLE IF NOT EXISTS`)。
|
||||
|
||||
### 11.2 关键语义(重构必须知道)
|
||||
|
||||
1. **insert 返回值不可信**:`cursor.rowcount > 0` 在 SQLite 的 `INSERT … ON CONFLICT DO UPDATE` 下插入和更新都返回 1 → added/updated 统计失真(实测重复同步显示「新增 242、更新 0」)。要区分需比较 `total_changes()` 或先查后写。
|
||||
2. **日期过滤是字符串比较**:查询用 `substr(时间列,1,10) >= ? AND <= ?`,比较的是 ISO 字符串前 10 位——即**记录自身时区的日期**。混合 `+08:00` 与 `Z` 记录时,同一“本地日”可能被切错;边界记录(23:xx / 00:xx)可能落入相邻日期桶。
|
||||
3. **created_at/updated_at 落库靠 SQLite 默认值**(CURRENT_TIMESTAMP,UTC),模型字段不传入。
|
||||
4. **阻塞风险**:所有 DB 调用是同步的,且被 async 服务直接调用(无 `to_thread`)——大查询会卡 MCP 事件循环。
|
||||
5. `get_data_coverage()`:对每表做 `MIN/MAX/COUNT(DISTINCT date)` 统计;daily 用 `date` 列,sleep/workouts/measurements/HR 用 `substr(...,1,10)`,spo2/stress/abnormal 用 `date(expr)`(三种风格并存)。
|
||||
|
||||
## 12. 查询服务
|
||||
|
||||
`services/query_service.py`。`QueryService(db, user_id)` —— 全部为**本地库读后内存加工**,不做 SQL 聚合。
|
||||
|
||||
| 方法 | 行为 |
|
||||
|---|---|
|
||||
| `get_daily_summaries(start, end)` | 拉区间内 daily_activity,按日期分组求和(steps/distance/active_kcal/total_kcal/floors/active_minutes) |
|
||||
| `get_metric_series(metric, start, end, granularity, aggregation)` | 基于 summaries 取单指标序列;`granularity=week/month` 时内存聚合(周以周一为起点);**metric 枚举里的 `weight_kg` 永远取不到值**(summaries 没有该字段) |
|
||||
| `get_sleep_sessions(..., include_naps)` | 过滤 nap;stages 从 JSON 解析;按 start_at 排序 |
|
||||
| `get_workouts(...)` | 内存过滤 activity_types(lower 比较)/min_duration/min_distance_km |
|
||||
| `get_body_measurements(...)` | 只输出非 None 指标;metrics 参数做字段白名单过滤 |
|
||||
| `get_heart_rate_samples(...)` | 内存过滤 sample_type;**limit 在 Python 侧切片**(全量拉出后截断,大区间低效) |
|
||||
| `get_spo2_samples / get_stress_samples / get_abnormal_heart_beat_events` | 同上模式(stress 支持 level 过滤) |
|
||||
| `get_data_coverage(data_types?)` | 转发 db 统计,可按类型过滤 |
|
||||
|
||||
## 13. MCP Server
|
||||
|
||||
`server.py`。**架构特征:模块级全局单例** `config/db/adapter/sync_service/query_service/sync_tasks/sync_active`,由 `main()` 初始化。
|
||||
|
||||
### 13.1 生命周期
|
||||
|
||||
- `main()`:加载配置 → 建 `Database`(建表)→ `mode==mi_fitness_cloud` 且 keyring 有凭据时构造 adapter 并注入 http_timeout/retries/max_pages → 构造两个 service → `stdio_server()` 启动。**启动时不 connect**(保证 MCP 握手不被网络阻塞)。
|
||||
- 退出 finally:取消所有后台 sync task(gather 吞异常)→ `adapter.close()`。
|
||||
- ⚠ `config` 为 None 或 mode=not_configured 时:连接类工具报错,查询类工具走空库(QueryService 依然创建)。
|
||||
|
||||
### 13.2 工具清单(14 个)
|
||||
|
||||
| 工具 | 必填参数 | 行为要点 |
|
||||
|---|---|---|
|
||||
| `get_connection_status` | — | 同步中→`is_connected()`;否则 `health_check()`(带 `health_check_timeout`)。合并 last_sync(8 类型里最新的 sync_state.last_sync_at)、available_types(连接成功用 adapter 的,否则从 sync_state 推断)、附加 `connection_state/region/last_health_check_at/last_connection_error/sync_in_progress` |
|
||||
| `sync_data` | — | `data_types[]`(缺省=8 类型全量)、`start_date/end_date`、`force_full_sync`、`background`。空数组报错;未知类型报错。**全局 sync_active 防重入** |
|
||||
| `get_sync_status` | `sync_id` | 查后台任务表(剔除 task 对象) |
|
||||
| `get_profile` | — | 静态拼装 `{user_id, timezone, devices:[]}` |
|
||||
| `get_daily_summary` | `date` 或 `start_date+end_date` | date 同时充当 start/end |
|
||||
| `query_metric_series` | `metric, start_date, end_date` | metric 枚举 `steps/distance_m/active_kcal/weight_kg`(weight_kg 死枚举);granularity `day/week/month`;aggregation `sum/avg/min/max/latest`(⚠ latest 在服务层落到 else 分支=**sum**) |
|
||||
| `query_heart_rate` | `start_date, end_date` | `sample_type` 枚举 `resting/active/passive/workout`;`limit` |
|
||||
| `query_body_measurements` | 同上 | `metrics[]` 白名单;`latest_only` 取最后一条 |
|
||||
| `query_sleep` | 同上 | `include_naps` 默认 True |
|
||||
| `query_workouts` | 同上 | `activity_types/min_duration/min_distance_km` |
|
||||
| `query_spo2` / `query_stress` / `query_abnormal_heart_beat` | 同上 | stress 支持 `level` 枚举过滤 |
|
||||
| `get_data_coverage` | — | `data_types[]` 可选过滤 |
|
||||
|
||||
完整 inputSchema JSON 见附录 A。响应统一为 `[TextContent(json.dumps(result))]`,异常捕获后返回 `{"status":"error","error":str}`(不抛给 MCP 层)。
|
||||
|
||||
### 13.3 后台同步(background=true)
|
||||
|
||||
- `_prune_sync_tasks()`:清理已完成任务至多保留 `MAX_SYNC_TASKS=100`。
|
||||
- `sync_id=uuid4`;task 表条目 `{sync_id, status: queued→running→ok/partial/error/cancelled, created_at, started_at, …结果}`。
|
||||
- 前台/后台共用 `_run_sync_data`:按需 `adapter.connect()` → 校验/展开 data_types → 逐类型 `asyncio.wait_for(sync_service.sync_data_type(...), sync_type_timeout_seconds)`(**超时只标记该类型 error,不中断其他类型**)→ 汇总 `status: ok/partial/error`。
|
||||
- 取消传播:`asyncio.CancelledError` 单独处理置 `cancelled`。
|
||||
- ⚠ 任务表是内存 dict:**MCP 进程重启即丢失**;`get_sync_status` 对未知 id 返回 error。
|
||||
|
||||
## 14. CLI、辅助脚本与测试
|
||||
|
||||
### 14.1 CLI(main.py;注意:文件名叫 main,职责是 CLI)
|
||||
|
||||
| 子命令 | 行为 |
|
||||
|---|---|
|
||||
| `serve`(或**无参数**) | `asyncio.run(server.main())` —— ⚠ 裸跑 `mi-fitness-mcp` 会直接启动 MCP server |
|
||||
| `setup` | `--mode mi_fitness_cloud --user-id --pass-token --region`(mode 未给时进入交互式 input 向导);保存 keyring + config.json。⚠ 无任何参数校验(非空校验仅在交互路径) |
|
||||
| `doctor` | 打印配置路径/加载配置/读凭据/真实 `connect()` 测连通(打印区域+8 类型)/检查 DB 文件存在性;配置缺失时 `sys.exit(1)` |
|
||||
| `sync` | `--type`(单类型)+ `--start-date/--end-date`(缺省 lookback 30 天);连接失败 exit(1);逐类型打印 `新增/更新` 条数;**没有分块超时,也没有 `--force-full` 参数** |
|
||||
|
||||
### 14.2 probe_mifitness.py
|
||||
|
||||
独立交互式诊断脚本(268 行):**复制粘贴**了一份登录/RC4/签名代码(与 adapter 重复),支持对任意 key/区间做原始请求打印,用于逆向调试。重构候选:改为调用 adapter 的导出函数。
|
||||
|
||||
### 14.3 测试(18 个,全部通过)
|
||||
|
||||
- `test_config.py`:配置 roundtrip。
|
||||
- `test_mi_fitness_cloud.py`:`_optional_*`、`_parse_value`(JSON 字符串与 dict 双形态)、`_record_datetime` zone_offset、分页游标环检测、`_is_authentication_error` 标记匹配等纯函数;respx mock HTTP。
|
||||
- `test_sync_service.py`:mock adapter 的分块/增量/partial 语义。
|
||||
- `test_storage_query.py`:入库+查询。
|
||||
- `test_server_sync.py`:server 层编排(mock adapter)。
|
||||
- 覆盖缺口:CLI 无测试;`_request` 加密路径无 golden-vector 测试;MCP 工具 handler 仅覆盖 sync 部分。
|
||||
|
||||
## 15. 已知问题清单
|
||||
|
||||
> 分级:**P0**=破坏正确性/可用性;**P1**=行为缺陷或明显技术债;**P2**=清理项。重构时按此核对。
|
||||
|
||||
**P0**
|
||||
|
||||
- **I1 `mcp` 依赖无上界**:`mcp>=1.0.0` 会拉到 2.x,`Server.list_tools` 不存在 → server 与测试收集直接崩(已在本地 pyproject 修为 `>=1.0.0,<2`,**上游未修**,重构时保留上界或迁移到新 API)。
|
||||
- **I2 added/updated 统计失真**:UPSERT 下 `cursor.rowcount>0` 恒真 → 永远报“新增”(§11.2-1),连带 `sync_state.records_count` 恒 0。
|
||||
- **I3 同步游标时区混用**:`last_record_ts` 存的是带 offset 的 ISO 串,续传时 `replace(tzinfo=None)` 直接剥掉 → 跨时区/夏令时场景增量起点可能偏移数小时(边界记录重复或遗漏)。
|
||||
- **I4 HR 唯一约束与 id 冲突目标不一致**:`UNIQUE(user_id, timestamp, sample_type)` vs `ON CONFLICT(id)`,同秒双类型样本会抛 `IntegrityError` 中断整块同步(sync_range 无 per-record 容错)。
|
||||
|
||||
**P1**
|
||||
|
||||
- **I5 事件循环阻塞**:同步 sqlite3 + 内存聚合直接在 async 上下文执行;大区间 heart_rate 查询(16k+ 行)会冻结 MCP 心跳/其他工具调用。
|
||||
- **I6 0 值被吞**:`_optional_float/int(0) → None`,把合法 0(如 0 步运动、0 kcal)当缺省丢弃。
|
||||
- **I7 日期过滤语义**:`substr(iso,1,10)` 字符串比较混合多 offset 记录(§11.2-2),查询/覆盖统计的“日”归属不稳定。
|
||||
- **I8 死枚举/死指标**:`query_metric_series.weight_kg` 永远无数据;`aggregation=latest` 落到 sum;`sample_type=workout` 永不产生;`skipped` 恒 0。
|
||||
- **I9 双重并发闸门**:server `sync_active` + SyncService 锁叠加;`sync_tasks` 内存态不可恢复。
|
||||
- **I10 协议层无测试保护**:RC4/签名/登录无 golden vectors,重构协议层极易悄悄破坏兼容性。
|
||||
- **I11 无迁移机制**:改表只能靠手工删库。
|
||||
- **I12 CLI 与 MCP 路径行为不一致**:CLI 无 per-type 超时/force_full;`serve` 是无参数默认行为(危险默认)。
|
||||
- **I13 每 chunk 单独 commit**:非原子块,中途断电留半块数据(靠 UPSERT 幂等兜底,但 sync_state 已前移时可能漏补)。
|
||||
|
||||
**P2**
|
||||
|
||||
- **I14 死代码/孤儿**:`logs_path、auto_sync_on_start、stale_after_minutes、store_raw_payloads、timezone` 配置;`delete_mi_fitness_token`(无 logout 命令);`SyncResult/UserProfile/DeviceInfo/DataCoverage(gaps_detected)` 模型;`_discover_region`;`sync_data_type_sync`;`click/rich` 依赖;`.env.example`(无 env 加载)。
|
||||
- **I15 代码重复**:probe_mifitness.py 复制协议代码;query_service 周/月聚合两份近似函数;server 里 14 个 handler 高度模板化。
|
||||
- **I16 可观测性**:logger 无 handler(日志不知去向);`last_error` 只进工具响应。
|
||||
- **I17 命名混乱**:`main.py` 实为 CLI;`server.py` 里 `main()` 实为 MCP 入口。
|
||||
- **I18 Config 默认 `region="ru"`** 与文档/实际用法(cn)不一致。
|
||||
- **I19 base.py 抽象签名** `Iterator | AsyncIterator` 联合类型增加调用方分支(`_iterate_records` 就是为兼容它存在)。
|
||||
|
||||
## 16. 重构方案建议
|
||||
|
||||
### 16.1 硬性约束(不可破坏的兼容契约)
|
||||
|
||||
1. **MCP 工具名与 inputSchema 保持不变**(14 个工具;客户端可能已固化调用方式)。响应 JSON 顶层字段保持兼容。
|
||||
2. **协议层逐字节兼容**:nonce 构造、RC4 drop-1024、双签名 base 串、登录前缀 `&&&START&&&`、分页参数——改协议必须先有 §16.4 的 golden vectors。
|
||||
3. **本地 DB 平滑升级**:老库文件必须能被新版本打开(或提供一次性迁移脚本),`sync_state` 游标不能丢。
|
||||
4. CLI 四个子命令与关键 flag 保持(可新增不可删除)。
|
||||
5. 保持 Python ≥3.11、`mcp>=1,<2` 上界。
|
||||
|
||||
### 16.2 目标架构(建议)
|
||||
|
||||
```
|
||||
src/mi_fitness_mcp/
|
||||
├── app.py # AppContext:配置、db、adapter、services 集中装配,替代 server 模块级全局
|
||||
├── cli/ # main.py 拆分:cli/{__main__,setup,doctor,sync}.py
|
||||
├── mcp_layer/
|
||||
│ ├── tools.py # 14 个工具的 schema 声明(数据驱动,替代 300 行 if-elif)
|
||||
│ └── handlers.py # 薄 handler:解析参数→调 service→包 QueryResponse
|
||||
├── protocol/ # ★从 adapter 抽出,纯函数化、可独立测试
|
||||
│ ├── xiaomi_auth.py # 登录(返回 serviceToken/ssecurity/cookies)
|
||||
│ ├── crypto.py # nonce/rc4/signature(golden vectors)
|
||||
│ └── client.py # XiaomiHealthClient:request/fetch_key/fetch_sport_records(重试/分页/错误分类)
|
||||
├── adapters/
|
||||
│ ├── base.py # 纯 AsyncIterator 抽象
|
||||
│ └── mi_fitness_cloud.py # 仅做 payload→model 映射
|
||||
├── services/
|
||||
│ ├── sync_service.py # 分块/增量/游标(tz-aware)
|
||||
│ └── query_service.py
|
||||
├── storage/
|
||||
│ ├── db.py # aiosqlite(或 run_in_executor)+ 事务化 chunk commit
|
||||
│ ├── schema.py # DDL + schema_version 迁移表
|
||||
│ └── repo.py # 按类型的仓储(UPSERT 用 total_changes 判定 added/updated)
|
||||
├── config.py # pydantic-settings:JSON + 真实 env/.env 支持(落地 .env.example)
|
||||
└── models/ # 现有模型 + 删除未用模型或接线
|
||||
```
|
||||
|
||||
### 16.3 优先修复映射(问题 → 方案)
|
||||
|
||||
| 问题 | 方案 |
|
||||
|---|---|
|
||||
| I2 | UPSERT 后用 `conn.total_changes()` 差值或 `RETURNING` 判定 inserted/updated;顺带维护 `records_count` |
|
||||
| I3 | 游标统一存 UTC(或带 offset 的完整 ISO),续传转 aware 再比较;默认日期一律 aware |
|
||||
| I4 | HR 的 UPSERT 冲突目标改为业务 UNIQUE(`user_id,timestamp,sample_type`),id 与约束二选一统一 |
|
||||
| I5 | storage 全链路 aiosqlite/线程池;查询下推 SQL(LIMIT/聚合) |
|
||||
| I7 | 日期列改为存 UTC + 原始 offset 两列,或查询时用 `unixepoch()` 换算到用户时区再比较 |
|
||||
| I6 | `_optional_*` 只把 None/空转 None,保留 0 |
|
||||
| I8 | 删 weight_kg 枚举或实现体重序列(join body_measurements);`latest` 聚合单独实现;HR workout 类型留待协议支持或删枚举 |
|
||||
| I9 | AppContext 单一同步闸门;后台任务可选落盘(sync_state 里记 running 状态) |
|
||||
| I13 | 每 chunk 一个事务(数据+游标同 commit) |
|
||||
| I11 | `schema_version` 表 + 线性迁移函数链 |
|
||||
|
||||
### 16.4 验收标准
|
||||
|
||||
1. `pytest` 通过,且新增:crypto golden vectors(固定 ssecurity/nonce → 固定密文/签名)、8 类 payload fixture 解析快照测试、分块边界(chunk=1 天/跨月)、UPSERT added/updated 正确性、时区边界(+08:00 与 Z 混合查询)。
|
||||
2. 手工验收序列(当前环境可直接跑):
|
||||
- `mi-fitness-mcp doctor` → 连接 ✅、8 类型齐全;
|
||||
- `sync --start-date <昨天> --end-date <今天>` → 与重构前逐表 rowcount/内容 diff 一致;
|
||||
- `serve` + MCP 客户端调用全部 14 工具成功;
|
||||
- 老库文件(本机 `AppData\Local\mi-fitness-mcp\mi-fitness-mcp\mi_fitness.db`,约 19k 行数据)可直接打开且覆盖统计正确。
|
||||
3. 行为不回退:8 个月全量同步耗时不显著劣化;单类型超时(180s)语义保留。
|
||||
|
||||
## 17. 开发环境备注
|
||||
|
||||
- 要求 Python ≥ 3.11;开发安装 `pip install -e '.[dev]'`(含 pytest/ruff/build)。
|
||||
- 跑测试:`python -m pytest -q`(18 passed,pytest-asyncio auto 模式)。
|
||||
- Lint:`python -m ruff check src tests`(CI 同款规则集,见 pyproject `[tool.ruff]`)。
|
||||
- 构建:`python -m build`(hatchling)。
|
||||
- 运行时产物均在系统用户目录(platformdirs),不在仓库内:config.json、SQLite 库、keyring 凭据;`.gitignore` 已排除 `.venv/ *.db .env` 等。
|
||||
|
||||
## 18. HTTP API 扩展(新增模块)
|
||||
|
||||
在 MCP 之外提供同能力的 REST 封装,供普通程序直接调用:
|
||||
|
||||
- 模块:`src/mi_fitness_mcp/api.py`(FastAPI + uvicorn,可选依赖组 `pip install -e '.[api]'`)。
|
||||
- 启动:`mi-fitness-mcp api [--host 127.0.0.1] [--port 8321]`(新 CLI 子命令;绑定非本机地址且未设 key 时打印警告)。
|
||||
- 鉴权:设置环境变量 `MI_FITNESS_API_KEY` 后,全部接口要求请求头 `X-API-Key`;未设置则不鉴权(仅建议 127.0.0.1 场景)。已全局启用 CORS。
|
||||
- 生命周期:lifespan 中装配 config/Database/adapter/SyncService/QueryService(与 `server.main` 相同的懒连接策略),退出时取消后台同步任务并关闭 adapter。查询端点为同步 `def`(FastAPI 线程池执行,不阻塞事件循环)。
|
||||
- 端点一览(均为 `/api` 前缀,响应信封 `{"status","count","data"}` 或同步结果对象):
|
||||
- `GET /` 服务信息;`GET /docs` Swagger UI
|
||||
- `GET /api/status?deep=true|false`(deep=true 真实健康检查)
|
||||
- `POST /api/sync` body `{data_types?, start_date?, end_date?, force_full_sync?, background?}`;前台等待返回汇总,`background=true` 返回 `{status:"accepted", sync_id}`
|
||||
- `GET /api/sync/{sync_id}` 后台任务状态(内存态,重启即失)
|
||||
- `GET /api/summary?start_date&end_date`
|
||||
- `GET /api/metric-series?metric&start_date&end_date&granularity&aggregation`(metric 限 steps/distance_m/active_kcal,已在 API 层校验)
|
||||
- `GET /api/heart-rate?start_date&end_date&sample_type&limit`
|
||||
- `GET /api/sleep?start_date&end_date&include_naps`
|
||||
- `GET /api/workouts?start_date&end_date&activity_types&min_duration&min_distance_km`
|
||||
- `GET /api/body-measurements?start_date&end_date&metrics&latest_only`
|
||||
- `GET /api/spo2` / `GET /api/stress?level` / `GET /api/abnormal-heart-beat`
|
||||
- `GET /api/coverage?data_types`
|
||||
- 错误约定:参数格式错→400,重复同步→409,未初始化→503,云连接失败→502,未知 sync_id→404,鉴权失败→401。
|
||||
- 已知取舍:`sync_tasks` 为内存态;查询与同步并发时 sqlite 写入可能偶发 busy(上游 I5/I13 修复后自然消除)。
|
||||
|
||||
## 19. API Key 体系与扫码登录(v0.2 新增)
|
||||
|
||||
### 19.1 鉴权模型(类大模型平台)
|
||||
|
||||
三级访问,实现在 `api.py` 的 `_gate`(入口闸门)+ `_resolve_context`(凭据解析):
|
||||
|
||||
1. **默认凭据**:不带头 = 使用本机配置(keyring)的凭据,仅建议 127.0.0.1。
|
||||
2. **静态 Key**:环境变量 `MI_FITNESS_API_KEY` 设置后强制全员带头;该值本身也始终有效(管理员通道)。
|
||||
3. **发放 Key**:`mif_sk_<40 hex>`,存主库 `api_keys` 表(key/label/user_id/pass_token/region/created_at/last_used_at/revoked)。带 Key 请求按 Key 绑定的小米凭据解析出**独立 UserContext**(adapter/sync/query 三服务按凭据缓存),实现多账号隔离;每次使用更新 last_used_at。
|
||||
|
||||
管理端点(keys/qr)鉴权:环境变量 `MI_FITNESS_ADMIN_KEY` + `X-Admin-Key` 头;未设置该变量时仅允许本机调用。
|
||||
|
||||
### 19.2 Key 管理端点
|
||||
|
||||
- `POST /api/auth/keys` body `{user_id, pass_token, region?, label?}` → **真实登录小米验证凭据后**发放 Key(失败 400 返回原因)。完整 Key 仅返回一次。
|
||||
- `GET /api/auth/keys` → 列表(Key 打码,如 `mif_sk_ab12cd…ef34`)。
|
||||
- `DELETE /api/auth/keys/{prefix}` → 按完整 Key 或唯一前缀吊销(软删 revoked=1)。
|
||||
|
||||
### 19.3 扫码登录(逆向自 account.xiaomi.com 通用 QR 流程)
|
||||
|
||||
流程与端点(参考 python-miio `cloud_qr.py`,MIT):
|
||||
|
||||
1. `GET https://account.xiaomi.com/longPolling/loginUrl`,参数**必须** `sid=xiaomiio` + `callback=https://sts.api.io.mi.com/sts`(其他组合返回 code=10025「Callback连接不合法」;`miothealth` 不被扫码接口接受)。响应剥 `&&&START&&&` 后含 `qr`(二维码图片 URL)、`lp`(长轮询 URL)、`loginUrl`(浏览器确认链接)、`timeout`(300 秒)。
|
||||
2. 用户用小米账号/米家 App 扫码确认,或浏览器打开 loginUrl 登录确认。
|
||||
3. `GET {lp}` 长轮询,确认后返回 `userId` + 账号级 `passToken`(passToken 与 sid 无关,可再用 serviceLogin 换 miothealth 会话)。
|
||||
4. 服务端校验凭据可登录后自动发放 API Key。
|
||||
|
||||
API 封装:`POST /api/auth/qr/start?region=cn`(返回 qr_token、PNG 地址、login_url)→ `GET /api/auth/qr/{token}.png` → `GET /api/auth/qr/poll?token=...`(waiting / confirmed{api_key,user_id} / expired)。qr_sessions 为内存态,重启失效。
|
||||
|
||||
### 19.4 Python Flask Web 仪表盘与反向代理(web.py / testpage)
|
||||
|
||||
基于 Python Flask 构建的现代化 Web 仪表盘与反向代理服务(无需 Rust 工具链,纯 Python 生态一键启动):
|
||||
- `GET /`:提供极富设计感的现代化中文健康数据仪表盘(全部数据端点 + 同步中心 + API Key 发放/列表/吊销 + 扫码二维码登录弹窗 + 多维图表 + 实时响应检视器)。
|
||||
- `/proxy/*`:反向代理到 Python FastAPI 后端,服务端自动中继 `X-API-Key` 与 `X-Admin-Key`(凭据与密钥不进浏览器),4xx/5xx 状态码与耗时(`X-Upstream-Time-Ms` 响应头)原样透传。
|
||||
- 启动命令:`mi-fitness-mcp web` 或 `python testpage/app.py`。
|
||||
- 环境变量:`BIND`(默认 127.0.0.1:8322)、`MI_FITNESS_API_URL`(默认 127.0.0.1:8321)、`MI_FITNESS_API_KEY`、`MI_FITNESS_ADMIN_KEY`。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:MCP 工具 inputSchema 全量 JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"get_connection_status": {"type": "object", "properties": {}},
|
||||
"sync_data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_types": {"type": "array", "items": {"type": "string"}},
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"force_full_sync": {"type": "boolean"},
|
||||
"background": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"get_sync_status": {
|
||||
"type": "object",
|
||||
"properties": {"sync_id": {"type": "string"}},
|
||||
"required": ["sync_id"]
|
||||
},
|
||||
"get_profile": {"type": "object", "properties": {}},
|
||||
"get_daily_summary": {
|
||||
"type": "object",
|
||||
"properties": {"date": {"type": "string"}, "start_date": {"type": "string"}, "end_date": {"type": "string"}}
|
||||
},
|
||||
"query_metric_series": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"metric": {"type": "string", "enum": ["steps", "distance_m", "active_kcal", "weight_kg"]},
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"granularity": {"type": "string", "enum": ["day", "week", "month"]},
|
||||
"aggregation": {"type": "string", "enum": ["sum", "avg", "min", "max", "latest"]}
|
||||
},
|
||||
"required": ["metric", "start_date", "end_date"]
|
||||
},
|
||||
"query_heart_rate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"sample_type": {"type": "string", "enum": ["resting", "active", "passive", "workout"]},
|
||||
"limit": {"type": "integer"}
|
||||
},
|
||||
"required": ["start_date", "end_date"]
|
||||
},
|
||||
"query_body_measurements": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"metrics": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["weight_kg", "bmi", "body_fat_pct", "muscle_mass_kg", "water_pct"]}
|
||||
},
|
||||
"latest_only": {"type": "boolean"}
|
||||
},
|
||||
"required": ["start_date", "end_date"]
|
||||
},
|
||||
"query_sleep": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"include_naps": {"type": "boolean"}
|
||||
},
|
||||
"required": ["start_date", "end_date"]
|
||||
},
|
||||
"query_workouts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"activity_types": {"type": "array", "items": {"type": "string"}},
|
||||
"min_duration": {"type": "integer"},
|
||||
"min_distance_km": {"type": "number"}
|
||||
},
|
||||
"required": ["start_date", "end_date"]
|
||||
},
|
||||
"query_spo2": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"limit": {"type": "integer"}
|
||||
},
|
||||
"required": ["start_date", "end_date"]
|
||||
},
|
||||
"query_stress": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"level": {"type": "string", "enum": ["low", "medium", "high"]},
|
||||
"limit": {"type": "integer"}
|
||||
},
|
||||
"required": ["start_date", "end_date"]
|
||||
},
|
||||
"query_abnormal_heart_beat": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"limit": {"type": "integer"}
|
||||
},
|
||||
"required": ["start_date", "end_date"]
|
||||
},
|
||||
"get_data_coverage": {
|
||||
"type": "object",
|
||||
"properties": {"data_types": {"type": "array", "items": {"type": "string"}}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 B:响应信封示例
|
||||
|
||||
查询类(`QueryResponse`):
|
||||
|
||||
```json
|
||||
{"status": "ok", "source": "cache", "generated_at": "2026-08-31T10:00:00", "timezone": "UTC", "data": {"samples": [{"timestamp": "2026-08-08T04:09:00+08:00", "bpm": 93, "sample_type": "passive"}], "count": 1}, "error": null}
|
||||
```
|
||||
|
||||
同步类(`_run_sync_data`):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok | partial | error",
|
||||
"sync_id": "uuid",
|
||||
"started_at": "...", "finished_at": "...",
|
||||
"records_added": 0, "records_updated": 0, "records_skipped": 0,
|
||||
"data_types_synced": ["daily_activity"],
|
||||
"results": [{"data_type": "daily_activity", "status": "ok", "added": 1, "updated": 0, "skipped": 0, "start_date": "...", "end_date": "...", "chunks": [{"start_date": "...", "end_date": "...", "status": "ok", "added": 1, "updated": 0, "skipped": 0}], "duration_seconds": 1.2}]
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 C:config.json 实例(本机实际生成)
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "mi_fitness_cloud",
|
||||
"region": "cn",
|
||||
"timezone": "UTC",
|
||||
"database_path": "C:\\Users\\<user>\\AppData\\Local\\mi-fitness-mcp\\mi-fitness-mcp\\mi_fitness.db",
|
||||
"logs_path": "C:\\Users\\<user>\\AppData\\Local\\mi-fitness-mcp\\mi-fitness-mcp\\logs\\mi_fitness.log",
|
||||
"auto_sync_on_start": true,
|
||||
"stale_after_minutes": 60,
|
||||
"store_raw_payloads": true,
|
||||
"default_lookback_days": 30,
|
||||
"sync_chunk_days": 7,
|
||||
"http_timeout_seconds": 20.0,
|
||||
"request_retries": 3,
|
||||
"health_check_timeout_seconds": 10.0,
|
||||
"sync_type_timeout_seconds": 180.0,
|
||||
"max_pages": 200
|
||||
}
|
||||
```
|
||||
@@ -1,209 +0,0 @@
|
||||
# 小米扫码登录实现方案
|
||||
|
||||
> 本文档只覆盖「扫码登录」这一部分:协议原理 → 服务端实现 → API 用法 → 安全与限制。
|
||||
> 代码位置:`src/mi_fitness_mcp/api.py`(`/api/auth/qr/*` 三个端点);测试页入口:`mi-fitness-api-test`(扫码面板)。
|
||||
> 协议来源:逆向小米账号通用扫码流程,参考 python-miio `cloud_qr.py`(MIT)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
用「手机扫码」替代「浏览器 F12 手抠 Cookie」来获取小米凭据 `userId + passToken`,扫码成功后服务端自动发放本平台的 API Key(`mif_sk_*`)。整个过程中小米凭据只出现在服务端,调用方全程只接触 API Key。
|
||||
|
||||
小米对第三方**密码登录**已加验证码,但**扫码流程不受影响**——这是选择扫码方案的核心原因。
|
||||
|
||||
## 2. 小米扫码协议(逆向所得)
|
||||
|
||||
整个协议只有两个云端端点,全部是 GET,无需任何预置凭据:
|
||||
|
||||
```
|
||||
手机App 我们的服务端 小米账号服务
|
||||
│ │ │
|
||||
│ │ ① GET /longPolling/loginUrl │
|
||||
│ │◄──────────────────────────────│
|
||||
│ │ 返回 qr / lp / loginUrl/ticket│
|
||||
│ │ │
|
||||
│ ② 用户扫码→点确认 │ │
|
||||
│◄───────────────────────│ (二维码 = 带 ticket 的URL) │
|
||||
│──── 确认登录 ──────────────────────────────────────────►│
|
||||
│ │ │
|
||||
│ │ ③ GET {lp} 长轮询(挂起) │
|
||||
│ │◄──────────────────────────────│
|
||||
│ │ 返回 userId + passToken │
|
||||
```
|
||||
|
||||
### 2.1 端点一:生成登录会话
|
||||
|
||||
```
|
||||
GET https://account.xiaomi.com/longPolling/loginUrl
|
||||
```
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| `sid` | `xiaomiio` | **必须是这个值**,换 `miothealth` 会返回 10025 错误 |
|
||||
| `callback` | `https://sts.api.io.mi.com/sts` | **必须是这个值**,其他值同样 10025 |
|
||||
| `qs` | `%3Fsid%3Dxiaomiio%26_json%3Dtrue` | 即 `?sid=xiaomiio&_json=true` 的 URL 编码 |
|
||||
| `_qrsize` | `480` | 二维码图片尺寸 |
|
||||
| `_hasLogo` | `false` | 二维码中央不放 logo |
|
||||
| `_locale` | `zh_CN` | 界面语言 |
|
||||
| `_dc` | 当前毫秒时间戳 | 防缓存 |
|
||||
|
||||
响应体以固定前缀 `&&&START&&&` 开头(小米账号接口的通用 guard),剥掉后是 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"qr": "https://account.xiaomi.com/pass/qr/login?ticket=lp_xxxx",
|
||||
"loginUrl": "https://c3.account.xiaomi.com/longPolling/login?ticket=lp_xxxx",
|
||||
"lp": "https://c3.lp.account.xiaomi.com/lp/s?k=lp_xxxx",
|
||||
"timeout": "300"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `qr` | 二维码图片的 URL(内容就是它自己,服务端需代抓 PNG) |
|
||||
| `loginUrl` | 电脑端兜底:浏览器打开它 → 登录账号 → 点确认,效果等同扫码 |
|
||||
| `lp` | 长轮询地址,用来等"用户已确认"的结果 |
|
||||
| `timeout` | 本次登录会话有效期(秒),一般 300 |
|
||||
|
||||
三个 URL 共享同一个一次性 `ticket`(`lp_` 前缀),ticket 就是"这一次登录会话"的身份。
|
||||
|
||||
### 2.2 端点二:长轮询确认结果
|
||||
|
||||
```
|
||||
GET {lp}
|
||||
```
|
||||
|
||||
- 用户**尚未确认**:请求挂起(长轮询),十几秒后超时返回,继续轮询即可。
|
||||
- 用户**已确认**:立即返回 HTTP 200,剥 `&&&START&&&` 前缀后:
|
||||
|
||||
```json
|
||||
{"code": 0, "userId": 1234567890, "passToken": "V1:..."}
|
||||
```
|
||||
|
||||
`userId` + `passToken` 与浏览器 Cookie 中手抠到的完全同种,且是**账号级**凭据(不绑定 xiaomiio 服务)。
|
||||
|
||||
### 2.3 关键坑(已修复并验证)
|
||||
|
||||
| 坑 | 现象 | 结论 |
|
||||
|---|---|---|
|
||||
| sid 不能用 `miothealth` | 返回 `{"code":10025,"desc":"Callback连接不合法"}` | 扫码入口只认 `sid=xiaomiio` + `callback=https://sts.api.io.mi.com/sts` 这一个组合 |
|
||||
| passToken 是否绑服务 | 担心 xiaomiio 的 token 不能用于运动健康 | **不绑定**。拿到后走 `serviceLogin?sid=miothealth` 可正常换运动健康会话 |
|
||||
| 响应前缀 | 直接 `json.loads` 报错 | 所有账号接口都要先剥 `&&&START&&&` |
|
||||
| 长轮询挂起 | 超时/网络异常频繁 | 属正常节奏,捕获后继续轮询,不算失败 |
|
||||
|
||||
## 3. 服务端实现(api.py)
|
||||
|
||||
小米的流程是"一次长轮询阻塞到底",直接搬到 HTTP API 会让请求挂几分钟。实现上改造成**提交-轮询**模式,拆成三个端点:
|
||||
|
||||
### 3.1 状态机
|
||||
|
||||
```
|
||||
POST /start 生成会话
|
||||
│
|
||||
▼
|
||||
[waiting] ──poll 时调 lp 且收到 code=0+userId+passToken──► [confirmed](含 api_key)
|
||||
│ │
|
||||
└─── 超过 timeout+10 秒 ──► [expired] 之后 poll 永远返回 confirmed 结果
|
||||
```
|
||||
|
||||
### 3.2 端点一:`POST /api/auth/qr/start?region=cn`
|
||||
|
||||
服务端向小米请求登录会话,把 `lp` / `loginUrl` / 二维码 PNG / 过期时间 / region 存入内存字典 `qr_sessions`(key 为 uuid `qr_token`),对外返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"qr_token": "b5ca13315c1f4a62805a47916c82404a",
|
||||
"qr_image": "/api/auth/qr/b5ca13315c1f4a62805a47916c82404a.png",
|
||||
"login_url": "https://c3.account.xiaomi.com/longPolling/login?ticket=lp_...",
|
||||
"expires_in": 300,
|
||||
"instruction": "用小米运动健康/小米账号 App 扫码,或在浏览器打开 login_url 确认;然后轮询 /api/auth/qr/poll?token=..."
|
||||
}
|
||||
```
|
||||
|
||||
二维码 PNG 由服务端代抓并缓存进会话,浏览器拿到的是现成图片,不直连小米。
|
||||
|
||||
### 3.3 端点二:`GET /api/auth/qr/{token}.png`
|
||||
|
||||
返回会话中缓存的 PNG(480×480,实测约 15KB),前端 `<img src>` 直接显示。token 不存在返回 404。
|
||||
|
||||
### 3.4 端点三:`GET /api/auth/qr/poll?token=xxx`
|
||||
|
||||
每次调用最多阻塞约 15 秒(lp 单次挂起上限),无异常时返回三种状态之一:
|
||||
|
||||
| 返回 | 含义 | 后续动作 |
|
||||
|---|---|---|
|
||||
| `{"status":"waiting"}` | 未确认/网络抖动 | 前端隔几秒再调 |
|
||||
| `{"status":"confirmed","api_key":"mif_sk_...","user_id":"..."}` | 登录成功,Key 已发放 | 前端存下 Key,此后带头调用 |
|
||||
| `{"status":"expired"}` | 超 5 分钟未确认 | 重新调 start 生成新码 |
|
||||
|
||||
**confirmed 时服务端做的三件事**(全部在返回前完成):
|
||||
|
||||
1. 从 lp 结果提取 `userId` / `passToken`;
|
||||
2. 用这套凭据真实构造 `MiFitnessCloudAdapter` 并 `connect()` 一次——验证 passToken 能换到运动健康的 serviceToken,避免发放无效 Key;验证失败则返回 `confirmed` 但 `api_key: null` 并附说明;
|
||||
3. 凭据写入主库 `api_keys` 表(key / user_id / pass_token / region / created_at),发放 `mif_sk_<40位hex>`,完整 Key 仅此一次返回。
|
||||
|
||||
### 3.5 设计取舍
|
||||
|
||||
| 决策 | 理由 |
|
||||
|---|---|
|
||||
| 长轮询放进 poll 而不是 start | start 里阻塞等扫码会让 HTTP 请求挂 5 分钟,网关/浏览器易超时;改为前端每几秒调一次 poll,单次最多 15 秒 |
|
||||
| `qr_sessions` 纯内存 | 存的是未完成登录的临时 ticket,天然短命,重启失效无副作用(重新生成即可),不值得持久化 |
|
||||
| passToken 不进前端 | 扫码结果由服务端直接落库换 Key,浏览器全程只见 `mif_sk_*`,与 Key 体系安全模型一致 |
|
||||
| 发 Key 前真实验证 | 防止把"扫码成功但服务受限"的凭据发出去,问题在发放时刻暴露而非首次调用时 |
|
||||
| poll 的 confirmed 常驻内存 | 让前端可以重复查询结果;代价是进程重启后确认记录丢失(重新扫码即可) |
|
||||
|
||||
## 4. 凭据的后续链路(扫码之外)
|
||||
|
||||
扫码只解决"凭据从哪来"。拿到 `userId + passToken` 后的流程与手动配置完全一致:
|
||||
|
||||
```
|
||||
passToken ──GET serviceLogin?sid=miothealth──► ssecurity(签名密钥) + serviceToken(会话Cookie)
|
||||
│
|
||||
└──► 健康数据接口(RC4 加密 + SHA1 签名,见主文档第 8 章)
|
||||
```
|
||||
|
||||
即:扫码、`mi-fitness-mcp setup` 手动配置、`POST /api/auth/keys` 发放,三条路最终汇合到同一套凭据模型,扫码登录对 sync/query 全部端点透明生效。
|
||||
|
||||
## 5. 使用方法
|
||||
|
||||
### 5.1 测试页(推荐)
|
||||
|
||||
打开 `http://127.0.0.1:8322/` → 「扫码登录」面板 → 点「生成登录二维码」→ 手机小米账号/米家 App 扫码并确认 → 回页面点「轮询扫码状态」→ confirmed 后 Key 自动填入右上角请求头。
|
||||
|
||||
### 5.2 纯 API 调用
|
||||
|
||||
```bash
|
||||
# 1. 生成二维码
|
||||
curl -X POST "http://127.0.0.1:8321/api/auth/qr/start?region=cn"
|
||||
# → {"qr_token":"...","qr_image":"/api/auth/qr/....png","login_url":"https://...","expires_in":300}
|
||||
|
||||
# 2. 展示二维码:浏览器打开 http://127.0.0.1:8321/api/auth/qr/{qr_token}.png
|
||||
# 或让用户直接打开返回的 login_url 在网页上确认
|
||||
|
||||
# 3. 轮询(未确认返回 waiting,可循环调用)
|
||||
curl "http://127.0.0.1:8321/api/auth/qr/poll?token={qr_token}"
|
||||
# → {"status":"confirmed","api_key":"mif_sk_...","user_id":"..."}
|
||||
```
|
||||
|
||||
### 5.3 鉴权要求
|
||||
|
||||
三个 `/api/auth/qr/*` 端点都受管理鉴权保护:
|
||||
|
||||
- 设置了环境变量 `MI_FITNESS_ADMIN_KEY`:必须带 `X-Admin-Key` 头;
|
||||
- 未设置:仅允许 `127.0.0.1` 本机调用,非本机返回 403。
|
||||
|
||||
## 6. 安全说明
|
||||
|
||||
- **passToken 是长期凭据**:等同登录态,泄露后应退出并重新登录小米账号使其失效。本方案中它只在服务端内存与 `api_keys` 表中出现一次。
|
||||
- **扫码 = 把账号登录到你的服务器**:`/api/auth/qr/start` 绝不能无鉴权暴露在局域网/公网。
|
||||
- **ticket 一次性且 5 分钟过期**:小米侧控制,无法延长;过期重新生成即可。
|
||||
- **内存态会话**:服务重启丢失所有未完成的扫码会话,无持久化泄露面。
|
||||
|
||||
## 7. 已验证与已知边界
|
||||
|
||||
| 状态 | 项 |
|
||||
|---|---|
|
||||
| ✅ 已验证 | 会话生成、二维码 PNG 有效(480×480)、poll 未扫码返回 waiting、10025 问题修复、Key 发放→使用→吊销全链路 |
|
||||
| ⏳ 需人工 | 用手机实际扫码走完 confirmed 全流程(扫码登录的本意,无法程序化替代) |
|
||||
| 已知限制 | 二维码 5 分钟过期;confirmed 记录仅存内存;协议为逆向所得,小米改版(如 ticket 格式变化)会静默失效,需重新抓包对照 |
|
||||
Reference in new issue
Block a user