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

12 KiB
Raw Permalink Blame History

Garmin Health Lab — 项目指南

佳明Garmin健康数据分析平台同步 Garmin Connect 数据,做多维度健康分析、 可视化与 AI 解读(晨报 / 趋势归因 / Copilot

阅读顺序:先读本文件(协作约定与现状速览)→ PROGRESS.md (功能与部署进度,排查前必读)→ README.mdAPI 与使用)→ docs/(架构 / 开发 / 需求)。判断"部署在哪里、跑没跑、什么版本"时 一律以 PROGRESS.mddeploy/ 脚本为准,不要凭记忆断言

技术栈现状2026-09

  • 后端Python 3.10+ / Flask应用工厂Gunicorn 生产运行。原 Node/Express 后端早已重写为 Python仓库中不存在 server/ 目录
  • 前端React 18 + TypeScript用 **CRAreact-scripts**构建UI 组件库为 Framework7 9framework7-reactiOS 主题)+ Recharts 图表。根目录 package.json 是 npm workspacesclient)。
  • 数据库:可插拔数据层(backend/db.pyDB_TYPE 切换—— 本地开发 SQLitebackend/data/health.db,默认);生产 MariaDB甲骨文云主机 本机,garmin_health_lab 库,连接配置见该机 /opt/garmin-health-lab/backend/.env)。
  • 认证auth-hub SSOOAuth2/OIDCservices/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 文档

常用命令

# 后端依赖(首次):在 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-labubuntu 用户跑 gunicorn 127.0.0.1:55002 workers / 4 threads / --timeout 300 systemd 单元 garmin-health-lab.serviceenabled,随机器开机自启, 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.shtar 经 ssh key 认证同步 backend + 清空重推 client/build + pip install + systemctl restart 校验 main PID 变化 + health 200)。前置:先 npm run builddeploy/push.sh / start.sh / stop.sh / S99garmin.sh / deploy.sh 是 NAS 时代的脚本,已废弃保留仅供参考。
  • DB:甲骨文本机 MariaDB 10.3.39127.0.0.1:3306),独立账号 garmin注意garmin@localhostgarmin@127.0.0.1 是两个不同账号, 必须同密码建两份,否则 TCP 连接用的是另一个密码),库 garmin_health_lab。 和其它项目(chat_relayzhongyuan)共用同一个 MariaDB 实例,各自独立库 独立账号。这台机器磁盘 96% 已满,改动前留意剩余空间。
  • AI 网关AI_GATEWAY_BASE_URL 现在是本地回环 http://127.0.0.1:5100/v1 (不再经 Caddy/公网 —— 网关和这个服务同机了)。
  • auth-hubclient 996aLPw4T5gl-rYZ;回调只保留 https://garmin.zichuan.xyz/auth/callback 一条NAS/旧公网端口那几条已用 manage_clients remove-redirect-uri 清掉)。改注册用 /opt/auth-hubPYTHONPATH=/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.mddeploy/ 脚本确认事实(曾经凭旧记忆 断言"无线上环境"而误判,后来又把"生产在 NAS"当成默认事实——两次都错在 没有先查,部署位置是会变的)。

关键约定与坑(写代码/改样式前看)

  1. 日期一律本地日历日期,别用 toISOString()UTCUTC+8 每天前 8 小时 查的是昨天)。前端统一走 client/src/lib/day.ts
  2. 阈值全部来自公开参考值AI 只解读不定阈值(设计原则,勿破)。
  3. Framework7 全局 button { width: 100% } 会把任何 <button> 拉满父容器 ——自定义组件里的按钮要显式 width: autoCopilot 浮窗 ✕/发送按钮曾因此 错位,见 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 退避 24hrate_limited_untilDB 为准(多 worker 内存不一致会卡死 守卫),且只作信息不作闸门;_is_rate_limited 沿异常 __cause__ 链识别 RetryError 里的 429。
  5. AI 生成慢:网关上游推理模型单次 2~5 分钟,晨报走后台生成 + 轮询, 接口超时/流式不可靠见 services/ai.py 的非流式回退;生产队列并发 AI_JOB_CONCURRENCY=2(共享网关,太高会把别家打成 502
  6. 迁移幂等db.pyinit_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