Files
GarminHealthLab/CLAUDE.md
ericwyuan 9d6ebbe422 ops: 生产从 NAS 整体迁移到甲骨文云主机
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>
2026-09-12 23:53:15 +08:00

174 lines
12 KiB
Markdown
Raw Permalink 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`,默认);生产 MariaDB甲骨文云主机
本机,`garmin_health_lab` 库,连接配置见该机 `/opt/garmin-health-lab/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/ # 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 pytest446+ 项)
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腾讯云 DNSPodA 记录)与 TLSCaddy 自动签发)都已配好。
~~旧的 `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()`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 排查一整天的根因就是这个。
**窗口内每次尝试都会延长封锁**(实测:一次同步把截止时间从 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 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