Files
GarminHealthLab/CLAUDE.md
ericwyuan 682936b0b6 fix(garmin): 刷新出来的令牌从来没写回库,于是每次连接都重换一次
「又被限流了」的根因找到了,不是请求量,是令牌。

`_connect` 里 `refresh_oauth2()` 换来的新 OAuth2 令牌只活在进程内存里——
`save_token` 只在绑定账号时调用过一次。于是每次客户端缓存过期(15 分钟)、
每个 gunicorn worker、每次部署重启,都从库里读回**同一个已过期的令牌**,
然后再做一次真实 SSO 换令牌。而 SSO 端点是按账号限流最狠的那个,社区报告能
封 48 小时(garth #217、python-garminconnect #337)。我今天为了部署重启了
八次服务,每次都清掉缓存。

- `refresh_oauth2()` 成功后 `_persist_token()` 写回。拆出这个函数是因为它和
  `save_token` 想要的正好相反:重新绑定要作废现有会话,持久化刷新结果必须
  保住刚刚产出它的那个会话
- 写回时不带 garmin_email,否则 upsert 会把绑定邮箱刷成 NULL,数据同步页会
  忘记绑的是哪个账号
- 刷新加进程内锁,并在拿到锁后重读一次库:另一个线程刚换过就直接用它的,
  不再自己去换一次
- 五条测试盯住这个不变量,包括「冷缓存不该再换一次」(这条如果回归,就是同一
  个 bug 再来一遍)

顺带把数据端点也节流了——那是另外一半问题,不是这次的病因,但一天历史要 9 次
调用,730 天全历史 6600 个请求全速打出去,不该指望佳明一直容忍:

- services/garmin_throttle.py:代理包住 client,所有调用(含以后新加的)都经
  同一个收口,按间隔排队并计数
- 0.5s 是查过的:garmin-data-export 默认 0.15s、garmin-connect-scraper 默认
  3s、官方合作方 API 100 次/分钟(0.6s)。依据写在文件顶部
- 单次同步 1200 个请求预算,跑满就干净收尾、下次接着跑(已存的天数本来就跳过)
- 运动详情每次最多补 40 条——新账号几百条,不限量就是一次性打光预算

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:24:48 +08:00

149 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Garmin Health Lab — 项目指南
佳明Garmin健康数据分析平台同步 Garmin Connect 数据,做多维度健康分析、
可视化与 AI 解读(晨报 / 趋势归因 / Copilot
> **阅读顺序**:先读本文件(协作约定与现状速览)→ [PROGRESS.md](PROGRESS.md)
> (功能与部署进度,**排查前必读**)→ [README.md](README.md)API 与使用)→
> [docs/](docs/)(架构 / 开发 / 需求)。判断"部署在哪里、跑没跑、什么版本"时
> 一律以 `PROGRESS.md` 和 `deploy/` 脚本为准,**不要凭记忆断言**。
## 技术栈现状2026-09
- **后端**Python 3.10+ / Flask应用工厂Gunicorn 生产运行。原 Node/Express
后端早已重写为 Python仓库中**不存在 `server/` 目录**。
- **前端**React 18 + TypeScript用 **CRAreact-scripts**构建UI 组件库为
**Framework7 9**`framework7-react`iOS 主题)+ Recharts 图表。根目录
`package.json` 是 npm workspaces`client`)。
- **数据库**:可插拔数据层(`backend/db.py``DB_TYPE` 切换——
本地开发 SQLite`backend/data/health.db`,默认);生产 NAS MariaDB
`garmin_health_lab` 库,连接配置见 NAS 上 `backend/.env`)。
- **认证****auth-hub SSO**OAuth2/OIDC`services/auth_hub_client.py`
本地邮箱密码注册/登录已移除;应用内 JWT 由后端签发。
- **AI**:自建 ai-gatewayOpenAI 兼容,`https://ai.zichuan.xyz/v1`)为唯一上游
NVIDIA 推理模型,单次生成可达 2~5 分钟);生产 `AI_MODEL_CHAIN=gateway`
## 目录结构
```
GarminHealthLab/
├── backend/ # Flask 后端(当前唯一后端)
│ ├── app.py # 应用工厂:装配蓝图 + 静态 UI + scheduler/jobs
│ ├── wsgi.py # Gunicorn 入口
│ ├── config.py # 集中配置(读 backend/.env
│ ├── auth.py # JWT 签发/校验 + require_auth
│ ├── db.py # 可插拔数据层SQLite ↔ MariaDB+ 幂等迁移
│ ├── routes/ # API 蓝图blueprint
│ │ ├── auth.py # /api/auth auth-hub SSO: callback / logout / refresh / auth-hub/start
│ │ ├── garmin.py # /api/garmin 绑定/同步/MFA/退出/活动详情/同步详情
│ │ ├── health.py # /api/health 摘要/睡眠/心率/运动/身体成分/血氧/设备/挑战等
│ │ ├── analysis.py # /api/analysis 趋势/建议/briefing/trend-insight/copilot/insight
│ │ └── settings.py # /api/settings 个人资料/单位/同步频率/评分依据
│ ├── services/ # 业务逻辑(纯函数/无 Flask 依赖,可单测)
│ │ ├── garmin.py # Garmin API 同步核心(限流退避 24h 写 DB
│ │ ├── garmin_auth.py # Garmin OAuth 登录 + MFA 会话
│ │ ├── garmin_extras.py # 身体成分/血压/血氧/设备/徽章等扩展数据
│ │ ├── scheduler.py # 后台自动同步DB 锁,多 worker 只跑一次)
│ │ ├── ai.py / coach.py / insights.py / jobs.py # AI 教练 + 生产者/消费者队列
│ │ ├── analysis.py / health.py / fitness_age.py / settings.py
│ │ └── auth_hub_client.py / scopes.py
│ ├── data/ # SQLite本地开发git 忽略)
│ ├── static/ # 前端构建产物(生产由 Flask 同端口提供)
│ ├── tests/ # pytest 测试446+ 项)
│ └── .env.example # 环境变量样例(真实 .env 不提交)
├── client/ # React 前端CRA + Framework7
│ ├── src/pages/ # 今日/健康/趋势/运动/设置/每日/睡眠/指标详情等页面
│ ├── src/components/ # AI 晨报 / Copilot 浮窗 / 趋势归因 / Screen 骨架
│ ├── src/lib/ # day.ts本地日期/ metrics.ts指标注册表
│ ├── src/features.ts # 功能开关FEATURES.ai
│ └── .env.production # REACT_APP_API_URL=/api同源防 Network Error
├── deploy/ # NAS 部署脚本族S99garmin/start/stop/push/deploy
├── docs/ # 架构 / 开发 / 需求文档
├── PROGRESS.md # 进度与部署事实(排查必读)
└── README.md # 项目说明 + API 文档
```
## 常用命令
```bash
# 后端依赖(首次):在 backend/ 下建 .venv 并装 requirements-dev.txt
npm run setup:backend
# 同时起前后端开发backend:5000 由 BACKEND_PORT 固定client CRA 代理到 5000
npm run dev
# 单独起后端 / 前端 / 测试 / 类型检查 / 构建
npm run dev:backend
npm run dev:client
npm run test # backend/.venv/bin/python -m pytest446+ 项)
npm run typecheck
npm run build # client/react-scripts build → client/build/
# 冒烟测试(后端单测入口)
cd backend && .venv/bin/python tests/smoke.py
```
## 部署(生产 = NAS端口 8124
- **位置**NAS `192.168.50.64` `/volume1/web/garmin-health-lab`root 用户跑
gunicorn `0.0.0.0:8124`2 workers / 4 threads / --timeout 300开机自启走
DSM 任务调度器执行 `deploy/S99garmin.sh`
- **一键部署**:本地 `./deploy/push.sh`tar 经 ssh 同步 backend + deploy +
清空重推 client/build + sudo 重启,**校验 gunicorn pid 变化 + health 200**)。
前置:先 `npm run build`
- **公网**NAS frpc → 甲骨文 `http://129.146.26.249:8124`frp 重连需几秒)。
- **DB**NAS MariaDB 10.11root 经 socket `/run/mysqld/mysqld10.sock`(或
TCP 127.0.0.1:3306`garmin_health_lab`。10.11 dump 含 `/*M!999999`
注释、旧客户端会报错,且须 `--default-character-set=utf8mb4` 防中文丢失。
- **auth-hub**client `996aLPw4T5gl-rYZ`;回调注册了 LAN
`http://192.168.50.64:8124/auth/callback` 与公网
`http://129.146.26.249:8124/auth/callback` 两个地址。
- **部署/排障前**:先读 `PROGRESS.md``deploy/` 脚本确认事实(曾经凭旧记忆
断言"无线上环境"而误判)。服务以 root 运行:重启用 sudo日志
`logs/error.log``logs/access.log` 是 root 所有。
## 关键约定与坑(写代码/改样式前看)
1. **日期一律本地日历日期**,别用 `toISOString()`UTCUTC+8 每天前 8 小时
查的是昨天)。前端统一走 `client/src/lib/day.ts`
2. **阈值全部来自公开参考值AI 只解读不定阈值**(设计原则,勿破)。
3. **Framework7 全局 `button { width: 100% }`** 会把任何 `<button>` 拉满父容器
——自定义组件里的按钮要显式 `width: auto`Copilot 浮窗 ✕/发送按钮曾因此
错位,见 `Copilot.css` 注释。F7 `.navbar .left/.right` 的 frosted pill 也
需在 `f7theme.css` 覆盖。
4. **同步限流**:真正把账号打进 429 的是 **SSO 换令牌端点**,不是数据端点。
`refresh_oauth2()` 刷出来的新令牌**必须写回 DB**`_persist_token`)——不写回
的话每次客户端缓存过期15 分钟)、每个 gunicorn worker、每次部署重启都会
从库里读回同一个过期令牌再换一次,而这个端点按账号限流、能封几小时。
2026-09-03 排查一整天的根因就是这个。
数据端点另有节流:所有调用经 `services/garmin_throttle.py` 的代理,默认间隔
0.5s + 单次预算 1200 次(依据见该文件顶部)。
429 退避 24h`rate_limited_until`**DB 为准**(多 worker 内存不一致会卡死
守卫),且只作**信息**不作闸门;`_is_rate_limited` 沿异常 `__cause__` 链识别
RetryError 里的 429。
5. **AI 生成慢**:网关上游推理模型单次 2~5 分钟,晨报走后台生成 + 轮询,
接口超时/流式不可靠见 `services/ai.py` 的非流式回退;生产队列并发
`AI_JOB_CONCURRENCY=2`(共享网关,太高会把别家打成 502
6. **迁移幂等**`db.py``init_db()` 会被多 worker 并发调用,所有
`ALTER/CREATE` 必须幂等(`CREATE TABLE IF NOT EXISTS` + 先查列再加列)。
7. **不要提交**`.env`、构建产物 `client/build`、SQLite 数据、`.workbuddy/`
`.claude/`(已在 .gitignore。改代码后同步 `backend/static/` 用 deploy 流程。
8. **一任务一 commit**,完成即推送 NAS Giteaorigin 就是 Gitea
## 测试
- pytest446+ 项)覆盖:设置吸附/校验、身体年龄、运动详情列存解析、Garmin
同步入库与只读本地保证、AI 缓存/队列、调度器等。改后端先跑
`npm run test`(或 `cd backend && .venv/bin/python -m pytest`)。
- 前端无单测框架,用 Chrome headless + CDP 截图做界面回归(见每日日志)。
## 下一步 / 已知问题
-`PROGRESS.md` 的待办区与 `docs/REQUIREMENTS.md`
- 双后端 SQL 注意:**MariaDB 的 CAST 不支持 TEXT 目标类型**SQLite 支持)——写跨库
查询不要用 `CAST(x AS TEXT)`,两侧都是字符串时直接比较即可(曾导致 refill_backlog
活动分支在 NAS 上 1064 刷屏commit 617c832 已修)。
- 双后端 SQL 注意:**MariaDB 的 `trigger` 是保留字SQLite 却允许它做列名**——本地
pytest 全绿也不代表生产可用(首次部署 `sync_history` 即 1064 起服务失败)。跨库建表
列名避免 `trigger`,用 `trigger_kind` 之类替代commit 5e6fc01