diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..4b1cb2c --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,72 @@ +# Garmin Health Lab — 项目进度 + +## 已完成 + +### 部署 +- [x] 后端从 Node.js 重构为 Python/Flask +- [x] 数据层可插拔:开发用 SQLite,生产用 MariaDB +- [x] Gunicorn 生产服务器配置(2 workers / 4 threads) +- [x] 部署到本地 NAS(`/volume1/web/garmin-health-lab`),端口 8124 +- [x] frp 隧道配置,通过甲骨文公网 IP 外网访问(`http://129.146.26.249:8124`) +- [x] 开机自启脚本(`deploy/S99garmin.sh`) +- [x] 甲骨文 iptables 放行 8124 端口 + +### 认证 +- [x] auth-hub 统一登录接入(OAuth2 / OIDC) +- [x] 新建 NAS 专用 client(`client_id: 996aLPw4T5gl-rYZ`) +- [x] 注册 NAS 回调地址 `http://192.168.50.64:8124/auth/callback` 和公网地址 `http://129.146.26.249:8124/auth/callback` +- [x] JWT 令牌签发与验证 +- [x] Garmin OAuth 令牌授权(密码不入库,令牌约一年有效) + +### 后端功能 +- [x] Garmin 数据同步核心逻辑(`services/garmin.py`) +- [x] 后台自动同步调度器(`services/scheduler.py`) +- [x] 用户设置:身高/体重/出生日期/性别/单位/同步频率/历史范围(`services/settings.py`) +- [x] 健康数据分析服务(`services/analysis.py`) +- [x] 身体年龄计算(`services/fitness_age.py`) +- [x] 运动详情与全天曲线同步(`services/extras.py`) +- [x] 可插拔数据层(`db.py`,支持 SQLite ↔ MariaDB 切换) +- [x] 集中配置管理(`config.py`,从 `.env` 读取) +- [x] 自动同步调度器读取用户 `history_days` 设置(修复前固定 2 天) + +### 前端功能 +- [x] 仪表板:健康数据概览 +- [x] 趋势分析:数据可视化与趋势图(Recharts) +- [x] 数据同步页:同步最新数据 / 同步历史 / 补齐详细数据 +- [x] 同步进度轮询与状态展示 +- [x] 设置页:个人资料、单位、自动同步、历史范围 +- [x] 评分依据:每个评级分段的公开参考值来源 +- [x] 数据绑定页:Garmin 邮箱+密码输入 +- [x] 历史范围选项:自上次同步 / 全部历史 / N 天 / N 年 + +### 同步逻辑修复 +- [x] 自动同步调度器认用户设置的 `history_days`,不再固定 2 天 +- [x] 前端 `0`(全部历史)不再被 `||` 吞掉,改为 `??` 处理 +- [x] 后端路由和 `sync_data` 中 `0` 不再被当成 falsy 回退默认值 +- [x] `sync_data` 中 `0` → 730 天(全部历史=最大范围) +- [x] 调度器 `0` → 730 天转换 +- [x] 「已同步天数」显示数据库实际总天数(`totalDays`),而非上次同步记录数 + +### 数据库 +- [x] MariaDB 数据库创建(`garmin_health_lab`) +- [x] 完整表结构:`health_data` / `daily_series` / `activities` / `activity_details` / `users` / `user_settings` / `garmin_tokens` / `sync_status` 等 +- [x] 257 天健康数据已同步(2025-12-19 ~ 2026-09-01) + +## 待办 + +### 功能完善 +- [ ] 仪表板数据可视化组件完善 +- [ ] 健康建议 / AI 解读功能 +- [ ] 数据分析报告生成 +- [ ] 多用户支持完善 + +### 运维 +- [ ] 监控与告警 +- [ ] 日志轮转与清理 +- [ ] 数据库备份策略 +- [ ] HTTPS 证书配置(Let's Encrypt) + +### 文档 +- [x] ARCHITECTURE.md 需更新(当前仍为 Node.js 架构描述) +- [x] DEVELOPMENT.md 需更新(当前仍为 Node.js 开发指南) +- [x] REQUIREMENTS.md 需更新 \ No newline at end of file diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py index 002e644..56d079b 100644 --- a/backend/routes/garmin.py +++ b/backend/routes/garmin.py @@ -45,7 +45,14 @@ def sync(): days = request.get_json(silent=True).get("days") if request.is_json else None try: - days = max(1, min(int(days), 730)) if days else None + if days is not None: + days = int(days) + if days == -1: + days = -1 # 自上次同步(增量) + elif days == 0: + days = 730 # 全部历史 → 最大范围 + else: + days = max(1, min(days, 730)) except (TypeError, ValueError): days = None diff --git a/backend/services/garmin.py b/backend/services/garmin.py index d840a74..30dc8ef 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -54,17 +54,24 @@ def _set_sync_status(user_id, status, now, **fields): def get_sync_status(user_id): row = query_one("SELECT * FROM sync_status WHERE user_id = ?", [user_id]) + # Count distinct days actually stored in the database for this user. + count = query_one( + "SELECT COUNT(DISTINCT date) AS cnt FROM health_data WHERE user_id = ?", [user_id] + ) + total_days = count["cnt"] if count else 0 if not row: return { "status": "idle", "lastSyncTime": None, "recordsSynced": 0, + "totalDays": total_days, "lastError": None, } return { "status": row["status"], "lastSyncTime": row["last_sync_time"], "recordsSynced": row["records_synced"], + "totalDays": total_days, "lastError": row["last_error"], "progressCurrent": row.get("progress_current"), "progressTotal": row.get("progress_total"), @@ -360,6 +367,10 @@ def _connect(creds, user_id=None): Garmin = _import_garmin() client = Garmin(is_cn=_is_cn()) + # Garmin SSO can be slow from some networks; the default 10s timeout in + # the underlying garth library is too tight for the initial login. + client.garth.configure(timeout=30) + token = load_token(user_id) if user_id else None if token: client.garth.loads(token) @@ -937,7 +948,7 @@ def start_sync(user_id, creds, days=None): Progress lands in sync_status, which the UI polls; a full backfill runs far longer than any sensible HTTP timeout. """ - days = days or DEFAULT_SYNC_DAYS + days = DEFAULT_SYNC_DAYS if days is None else days now = datetime.datetime.utcnow().isoformat(timespec="seconds") until, msg = _rate_limit_block(user_id) if until: @@ -968,7 +979,9 @@ def sync_data(user_id, creds, days=None, client=None): `client` exists so tests can inject a stub instead of reaching Garmin. """ - days = days or DEFAULT_SYNC_DAYS + days = DEFAULT_SYNC_DAYS if days is None else days + if days == 0: + days = 730 # 全部历史 → 最大范围 now = datetime.datetime.utcnow().isoformat(timespec="seconds") until, msg = _rate_limit_block(user_id) if until: @@ -1004,6 +1017,23 @@ def sync_data(user_id, creds, days=None, client=None): } today = datetime.date.today() + + # -1 means "incremental sync": pick up from the latest date already in the + # local database rather than pulling a fixed window. + if days == -1: + row = query_one("SELECT MAX(date) FROM health_daily WHERE user_id = ?", (user_id,)) + latest = row[0] if row and row[0] else None + if latest is None: + days = DEFAULT_SYNC_DAYS # first sync → fall back to default window + else: + days = max(1, (today - datetime.date.fromisoformat(latest)).days) + # Rewrite the progress total now that we know the real day count. + _set_sync_status( + user_id, "syncing", now, + records_synced=0, progress_current=0, progress_total=days, + stage="连接 Garmin", + ) + start_date = (today - datetime.timedelta(days=days - 1)).isoformat() days_synced = 0 diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py index 2bef9b5..192d92a 100644 --- a/backend/services/scheduler.py +++ b/backend/services/scheduler.py @@ -127,8 +127,11 @@ def sync_all_accounts(days=None, respect_schedule=False): `respect_schedule` is what the background loop passes: it skips accounts that have auto-sync off or that were synced recently enough. A direct call (a manual "sync everything") leaves it False and syncs unconditionally. + + When `days` is not provided, each account's saved `history_days` from + `user_settings` is used (the user's 历史范围 picker), falling back to + `SYNC_DAYS`. """ - days = days or SYNC_DAYS rows = query_all("SELECT user_id FROM garmin_tokens") results = [] for row in rows: @@ -144,6 +147,16 @@ def sync_all_accounts(days=None, respect_schedule=False): results.append({"user": uid, "status": "skipped", "reason": "not due"}) continue + # Resolve the sync window: prefer the user's saved history_days, + # then the caller override, then the global default. + if days is None: + s = query_one("SELECT history_days FROM user_settings WHERE user_id = ?", (uid,)) + user_days = s[0] if s and s[0] is not None else None + if user_days == 0: + user_days = 730 # 全部历史 → 最大范围 + d = user_days if user_days is not None else SYNC_DAYS + else: + d = days # Never poke Garmin while it is rate-limiting us — that is exactly # what keeps the limit alive. Respect the persisted cooldown and sit # this tick out. @@ -154,7 +167,7 @@ def sync_all_accounts(days=None, respect_schedule=False): "retryAfterSeconds": int((blocked - _now()).total_seconds()), }) continue - out = garmin_svc.sync_data(uid, {}, days=days) + out = garmin_svc.sync_data(uid, {}, days=d) results.append({"user": uid, "status": out.get("status"), "records": out.get("recordsSynced")}) except Exception as e: # noqa: BLE001 - one account must not stop the rest diff --git a/backend/services/settings.py b/backend/services/settings.py index 12b1c51..50138ef 100644 --- a/backend/services/settings.py +++ b/backend/services/settings.py @@ -37,7 +37,7 @@ SEXES = ("male", "female", "other") UNITS = ("metric", "imperial") # Offered in the UI as a picker; anything else is snapped to the nearest. INTERVALS = (30, 60, 180, 360, 720, 1440) -HISTORY = (7, 30, 90, 180, 365, 730, 0) +HISTORY = (7, 30, 90, 180, 365, 730, 0, -1) CAMEL = { "height_cm": "heightCm", diff --git a/backend/static/asset-manifest.json b/backend/static/asset-manifest.json new file mode 100644 index 0000000..b480b25 --- /dev/null +++ b/backend/static/asset-manifest.json @@ -0,0 +1,16 @@ +{ + "files": { + "main.css": "/static/css/main.a0945a9e.css", + "main.js": "/static/js/main.3efa2734.js", + "static/media/Framework7Icons-Regular.ttf": "/static/media/Framework7Icons-Regular.4b8a7d10ca32f3125696.ttf", + "static/media/Framework7Icons-Regular.woff": "/static/media/Framework7Icons-Regular.286bd8fcebb566a45853.woff", + "static/media/Framework7Icons-Regular.woff2": "/static/media/Framework7Icons-Regular.852655880420bcb785bd.woff2", + "index.html": "/index.html", + "main.a0945a9e.css.map": "/static/css/main.a0945a9e.css.map", + "main.3efa2734.js.map": "/static/js/main.3efa2734.js.map" + }, + "entrypoints": [ + "static/css/main.a0945a9e.css", + "static/js/main.3efa2734.js" + ] +} \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html new file mode 100644 index 0000000..e3280d1 --- /dev/null +++ b/backend/static/index.html @@ -0,0 +1 @@ +