NAS 局域网 IP 因重启被 DHCP 换过两次,磁盘/网络稳定性都不如已经跑着好几个 生产服务的甲骨文机器。整体搬迁:应用 + 数据库都搬走,NAS 只保留 Gitea(这个 仓库的源码托管,未动)。 ## 迁移过程(已核对无损) - MariaDB:NAS 导出(10.11 源库,处理了只有新版本才有的 `/*M!999999` 注释) → 导入甲骨文 MariaDB 10.3.39,**20 张表逐条精确 COUNT(*) 比对完全一致** - 冻结 NAS(停服务)后又 dump 一次核对,确认期间零数据差异,才继续删库 - NAS `garmin_health_lab` 已 DROP DATABASE,备份在本地 `~/Desktop/Work/backups/garmin_health_lab_nas_backup_20260912.sql.gz` - 应用部署到 `/opt/garmin-health-lab`,systemd 单元(`ubuntu` 用户,非 root),和这台机器上的 ai-gateway/auth-hub 同一套约定 - 公网:`https://garmin.zichuan.xyz`,DNS + Caddy 反代 + 自动 TLS,替代原来 `NAS frpc → 甲骨文:8124` 那条隧道(已从 NAS 的 frpc.toml 精确删除对应段, 其它转发未动,改完逐条复检过没打断) - auth-hub 回调地址换成新域名,NAS/旧端口那几条历史回调已清掉 - AI 网关配置改本地回环(网关现在同机了),触发真实生成验证过 ## 一个当场拦下来的风险 甲骨文部署完默认开着自动同步。迁移窗口期两边并行跑时,若两边的调度器同时去 刷新 Garmin 令牌,会撞上按账号计算的 SSO 限流(`GarminHealthLab` 仓库 2026-09-03 那次事故的根因,那次修复花了一整天)。确认账号级 auto_sync 设置 本来是关的、这次算侥幸没撞上——不是设计上的保险,所以迁移期间显式在甲骨文这边 加了 `AUTO_SYNC=false`,直接在运行进程里验证过生效,确认 NAS 已冻结、数据无 缺口后才打开。 ## 文档 / 脚本同步 CLAUDE.md 明确写过"部署位置会变,排障前先查、不要凭记忆"——这次是第二次踩中 同一类问题(上次是"NAS 有没有生产环境"判断错),所以把 CLAUDE.md / PROGRESS.md / README.md / docs/* 里的部署事实全部更新,NAS 时代的 `deploy/` 脚本加废弃 说明保留参考、不删除,新增 `deploy/push_oracle.sh`(当场跑通一次真实部署)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
12 KiB
Markdown
174 lines
12 KiB
Markdown
# 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,用 **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`,默认);生产 MariaDB(甲骨文云主机
|
||
本机,`garmin_health_lab` 库,连接配置见该机 `/opt/garmin-health-lab/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/ # push_oracle.sh 现役;其余(S99garmin/start/stop/
|
||
│ # push/deploy)是 NAS 时代脚本,已废弃保留参考
|
||
├── 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 pytest(446+ 项)
|
||
npm run typecheck
|
||
npm run build # client/react-scripts build → client/build/
|
||
|
||
# 冒烟测试(后端单测入口)
|
||
cd backend && .venv/bin/python tests/smoke.py
|
||
```
|
||
|
||
## 部署(生产 = 甲骨文云主机,2026-09-12 起;此前是 NAS,已下线)
|
||
|
||
- **位置**:`129.146.26.249` `/opt/garmin-health-lab`,**`ubuntu` 用户**跑
|
||
gunicorn `127.0.0.1:5500`(2 workers / 4 threads / --timeout 300),
|
||
systemd 单元 `garmin-health-lab.service`(`enabled`,随机器开机自启,
|
||
`Restart=always`)。和这台机器上其它服务(ai-gateway / auth-hub / fam-edge)
|
||
同一套约定:`/opt/<项目>` 下独立部署 + 独立 venv + Caddy 按子域名反代。
|
||
- **公网**:`https://garmin.zichuan.xyz` → Caddy → `127.0.0.1:5500`。
|
||
DNS(腾讯云 DNSPod,A 记录)与 TLS(Caddy 自动签发)都已配好。
|
||
~~旧的 `http://129.146.26.249:8124`~~ 已下线(走 NAS frpc 转发,隧道已拆)。
|
||
- **一键部署**:本地 `./deploy/push_oracle.sh`(tar 经 ssh key 认证同步
|
||
backend + 清空重推 client/build + pip install + systemctl restart,
|
||
**校验 main PID 变化 + health 200**)。前置:先 `npm run build`。
|
||
`deploy/push.sh` / `start.sh` / `stop.sh` / `S99garmin.sh` / `deploy.sh`
|
||
是 NAS 时代的脚本,已废弃保留仅供参考。
|
||
- **DB**:甲骨文本机 MariaDB 10.3.39(`127.0.0.1:3306`),独立账号
|
||
`garmin`(**注意**:`garmin@localhost` 与 `garmin@127.0.0.1` 是两个不同账号,
|
||
必须同密码建两份,否则 TCP 连接用的是另一个密码),库 `garmin_health_lab`。
|
||
和其它项目(`chat_relay`、`zhongyuan`)共用同一个 MariaDB 实例,各自独立库
|
||
独立账号。**这台机器磁盘 96% 已满**,改动前留意剩余空间。
|
||
- **AI 网关**:`AI_GATEWAY_BASE_URL` 现在是**本地回环** `http://127.0.0.1:5100/v1`
|
||
(不再经 Caddy/公网 —— 网关和这个服务同机了)。
|
||
- **auth-hub**:client `996aLPw4T5gl-rYZ`;回调只保留
|
||
`https://garmin.zichuan.xyz/auth/callback` 一条(NAS/旧公网端口那几条已用
|
||
`manage_clients remove-redirect-uri` 清掉)。改注册用
|
||
`/opt/auth-hub` 下 `PYTHONPATH=/opt/auth-hub/src venv/bin/python -m
|
||
auth_hub.manage_clients`。
|
||
- **NAS 现状**:`garmin_health_lab` 库已 `DROP DATABASE`(备份在本地
|
||
`~/Desktop/Work/backups/garmin_health_lab_nas_backup_20260912.sql.gz`,
|
||
20 张表逐条精确计数核对过一致后才删的),`S99garmin.sh` 开机项已移除,
|
||
frpc 配置里 `garmin-health` 转发段已删(`gitea`/`wordpress`/`fam-core`/
|
||
`nexusai` 那几条没动——**Gitea 还在 NAS 上,这个仓库的 git remote 仍然指
|
||
向它**,这次迁移只搬了应用和数据,没搬源码托管)。
|
||
- **部署/排障前**:先读 `PROGRESS.md` 与 `deploy/` 脚本确认事实(曾经凭旧记忆
|
||
断言"无线上环境"而误判,后来又把"生产在 NAS"当成默认事实——**两次都错在
|
||
没有先查,部署位置是会变的**)。
|
||
|
||
## 关键约定与坑(写代码/改样式前看)
|
||
|
||
1. **日期一律本地日历日期**,别用 `toISOString()`(UTC,UTC+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 排查一整天的根因就是这个。
|
||
**窗口内每次尝试都会延长封锁**(实测:一次同步把截止时间从 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。
|
||
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 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 刷屏,commit 617c832 已修)。
|
||
- 双后端 SQL 注意:**MariaDB 的 `trigger` 是保留字,SQLite 却允许它做列名**——本地
|
||
pytest 全绿也不代表生产可用(首次部署 `sync_history` 即 1064 起服务失败)。跨库建表
|
||
列名避免 `trigger`,用 `trigger_kind` 之类替代(commit 5e6fc01)。
|