10 KiB
10 KiB
Garmin Health Lab — 项目指南
佳明(Garmin)健康数据分析平台:同步 Garmin Connect 数据,做多维度健康分析、 可视化与 AI 解读(晨报 / 趋势归因 / Copilot)。
阅读顺序:先读本文件(协作约定与现状速览)→ PROGRESS.md (功能与部署进度,排查前必读)→ README.md(API 与使用)→ docs/(架构 / 开发 / 需求)。判断"部署在哪里、跑没跑、什么版本"时 一律以
PROGRESS.md和deploy/脚本为准,不要凭记忆断言。
技术栈(现状,2026-09)
- 后端:Python 3.10+ / Flask(应用工厂),Gunicorn 生产运行。原 Node/Express
后端早已重写为 Python,仓库中不存在
server/目录。 - 前端:React 18 + TypeScript,用 **CRA(react-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-gateway(OpenAI 兼容,
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 文档
常用命令
# 后端依赖(首次):在 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 pytest(446+ 项)
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 用户跑 gunicorn0.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.11,root 经 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;回调注册了 LANhttp://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 所有。
关键约定与坑(写代码/改样式前看)
- 日期一律本地日历日期,别用
toISOString()(UTC,UTC+8 每天前 8 小时 查的是昨天)。前端统一走client/src/lib/day.ts。 - 阈值全部来自公开参考值,AI 只解读不定阈值(设计原则,勿破)。
- Framework7 全局
button { width: 100% }会把任何<button>拉满父容器 ——自定义组件里的按钮要显式width: auto(Copilot 浮窗 ✕/发送按钮曾因此 错位,见Copilot.css注释)。F7.navbar .left/.right的 frosted pill 也 需在f7theme.css覆盖。 - 同步限流:真正把账号打进 429 的是 SSO 换令牌端点,不是数据端点。
refresh_oauth2()刷出来的新令牌必须写回 DB(_persist_token)——不写回 的话,每次客户端缓存过期(15 分钟)、每个 gunicorn worker、每次部署重启,都会 从库里读回同一个过期令牌再换一次,而这个端点按账号限流、能封几小时。 2026-09-03 排查一整天的根因就是这个。 窗口内每次尝试都会延长封锁(实测:一次同步把截止时间从 00:41 推到 15:26)。 所以sync_status.rate_limit_source区分sso/data:只有 sso 冷却会拒绝 动作,且只拒绝「刷新令牌」和「重新绑定」这两个真的会打登录端点的操作——库里 令牌没过期就照常同步。重新输账号密码换新令牌解决不了:garth.login()是 同一个端点、更重的流程,限流按账号计,换设备换网络都绕不开。用户可用 force 推翻我们自己猜的 24 小时。 数据端点另有节流:所有调用经services/garmin_throttle.py的代理,默认间隔 0.5s + 单次预算 1200 次(依据见该文件顶部)。 429 退避 24h,rate_limited_until以 DB 为准(多 worker 内存不一致会卡死 守卫),且只作信息不作闸门;_is_rate_limited沿异常__cause__链识别 RetryError 里的 429。 - AI 生成慢:网关上游推理模型单次 2~5 分钟,晨报走后台生成 + 轮询,
接口超时/流式不可靠见
services/ai.py的非流式回退;生产队列并发AI_JOB_CONCURRENCY=2(共享网关,太高会把别家打成 502)。 - 迁移幂等:
db.py的init_db()会被多 worker 并发调用,所有ALTER/CREATE必须幂等(CREATE TABLE IF NOT EXISTS+ 先查列再加列)。 - 不要提交:
.env、构建产物client/build、SQLite 数据、.workbuddy/、.claude/(已在 .gitignore)。改代码后同步backend/static/用 deploy 流程。 - 一任务一 commit,完成即推送 NAS Gitea(origin 就是 Gitea)。
测试
- pytest(446+ 项)覆盖:设置吸附/校验、身体年龄、运动详情列存解析、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 刷屏,commit617c832已修)。 - 双后端 SQL 注意:MariaDB 的
trigger是保留字,SQLite 却允许它做列名——本地 pytest 全绿也不代表生产可用(首次部署sync_history即 1064 起服务失败)。跨库建表 列名避免trigger,用trigger_kind之类替代(commit 5e6fc01)。