ericwyuan
|
57c236ba16
|
fix: 连接阶段真实 429 以 rate_limited 状态呈现,而非泛化 error
sync_data 的 _connect 抛 RateLimited(真实 429,冷却已写入)原本落进
except Exception 返回 status=error,message 带 'RateLimited:' 前缀——前端
SettingsPage 只对 status=rate_limited 渲染'被限流'提示,导致放开本地拦截
后真实 429 的提示错位。
单独捕获 RateLimited:status=rate_limited + 干净 message + history 记录
rate_limited。补测试 test_a_real_429_at_connect_surfaces_as_rate_limited
(597 passed)
|
2026-09-03 07:18:14 +08:00 |
|
ericwyuan
|
e0e7b3cf53
|
fix: 本地限流估算不再拦截同步——一律真实请求 Garmin,仅真实 429 记录冷却并退避
此前 sync_data/start_sync/_connect/scheduler 四处会在请求前按本地
rate_limited_until 估算直接拒绝同步,用户看到'立即同步→被限流'实际是
本地拦截、零请求。若 Garmin 已恢复,陈旧估算会让账号一直闲置。
2026-09-02 起冷却只是信息不是闸门:
- start_sync / sync_data 开头移除本地拒绝,_connect 移除冷却提前抛错,
scheduler 不再跳过冷却中的账号
- 真实 429(refresh_oauth2 / 拉取中途)仍 _note_rate_limit 写 24h 冷却
并 stand down,中途退避分支保留用冷却算恢复时间
- 成功收尾 _clear_rate_limit 退休陈旧冷却,避免误导后续诊断
- 测试:blocked→仍会真实请求;stale cooldown→healthy connect 放行;
真实 429 mid-run 仍记 rate_limited;成功清除冷却(596 passed)
|
2026-09-03 07:06:12 +08:00 |
|
ericwyuan
|
5e6fc01f76
|
feat(sync-history): 新增同步结果查询(每次自动/手动/立即同步的记录)
后端:
- db.py SCHEMA 新增 sync_history 表(不可变,每次同步尝试一行) + 索引
- garmin.py: _log_sync_history() 在 sync_data 全部出口记录; trigger 区分
auto(调度器)/manual(同步页开始同步)/quick(设置页立即同步); start_sync
限流拦截分支同样留档; 写入失败只告警不影响同步
- scheduler.py 自动同步传 trigger="auto"; routes 新增 GET /garmin/sync-history
按时间倒序返回(默认 50 条,上限 200)
前端:
- api.ts 增加 SyncHistoryItem 类型 + getSyncHistory()
- 新页面 /sync-history/ 同步记录: 卡片列表, 状态(成功/失败/被限流)chip 配色,
时间本地化(今天/昨天/X月X日), 触发类型标签, 范围与耗时
- 同步页与设置页同步区块均加入口链接
|
2026-09-02 20:42:26 +08:00 |
|
ericwyuan
|
224144915c
|
fix(sync): 限流拦截不再把'上次同步'刷成现在 + 设置页新增立即同步按钮
- _set_sync_status 支持保留旧 last_sync_time;两处限流拦截(start_sync /
sync_data)改为传旧值——被拒的同步零请求发生,不能把 UI 的'上次同步'
拨快成'刚刚',掩盖数据早已停更的事实
- SettingsPage 同步区块新增'立即同步'行:走 /garmin/sync-latest(最近2天,
秒级阻塞返回),成功/限流/未绑定三种状态都有明确反馈;限流中后端零请求
拦截并回传恢复倒计时文案
|
2026-09-02 20:14:11 +08:00 |
|
ericwyuan
|
92ea33b800
|
feat(sync): 全部历史真的是全部,不再截断在两年
730 天是个凭空写死的上限。账号有七年数据的人选「全部历史」,拿到的是
最近两年,而且没有任何提示说剩下的被丢掉了。
* 全部历史现在一直回溯到账号最早的数据:连续 EMPTY_RUN_STOP(120) 天
完全没有内容就停,所以既不会截断,也不会去问手表存在之前的年份。
MAX_HISTORY_DAYS(3650) 只是兜底,可用环境变量覆盖。
* 已经存过的日期跳过(最近 3 天除外,它们还在写入中)。这让多年的
回填变成可续传的:撞上限流停下来,冷却过后再点一次就从断点继续,
而不是每次都从今天重新爬。
* 选择器补上 3 年 / 5 年。
* 前端把每个范围的实际代价写出来,并说明中断可续。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 09:30:03 +08:00 |
|
ericwyuan
|
41b7ae82e4
|
fix(sync): 「全部历史」真的拉全部历史,自动同步不再每次静默失败
四个独立的 bug 叠在一起,表现为「只同步两天、没有进度」:
* 前端 `...(days ? { days } : {})` 把 days=0 当成未传。「全部历史」
存的就是 0,请求体里根本没有 days,后端退回 7 天默认值。
* scheduler 用 `s[0]` 读 query_one 返回的 dict,抛 KeyError 后被
per-account 的 except 吞掉。只要用户存过一次设置,每 30 分钟的
自动同步就一次都没成功过——库里那 2 天全是手动点出来的。
* 增量同步查 `health_daily`(表其实叫 health_data),后台线程直接
死掉,状态永远卡在 syncing,进度条不动。
* UI 完全不看 /sync 的返回值,rate_limited 时按钮点了没反应;轮询
结束时又把 rate_limited 归进 else 分支报「同步完成」。
顺带:
* 日循环遇到 429 立即退避并保留已拉到的天数,而不是当成「跳过一天」
继续往下捶 700 天——这正是之前限流死循环的来源之一。
* 定时循环显式传 SYNC_DAYS。历史范围按 UI 文案只描述手动全量同步,
让半小时一次的 tick 重拉 730 天必然把限流撞得更深。
* 短同步逐天上报进度(原来每 5 天一次,7 天的同步全程停在 0)。
* /sync 路由重复解析 body,空 body 会 None.get 崩。
* 4 个 StubGarth 缺 configure(),7 个测试在此之前一直是红的。
新增 deploy/push.sh:NAS 只认密码,脚本开一个 ssh 复用连接,密码只
输一次,后面推送 / 重启 / 健康检查全走它。不碰 .env、.venv 和数据库。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 08:11:24 +08:00 |
|
ericwyuan
|
15fba8c25c
|
feat: NAS 部署 + auth-hub 认证 + 同步逻辑修复
部署:
- 后端 Python/Flask,数据层可插拔 SQLite/MariaDB
- NAS 部署路径 /volume1/web/garmin-health-lab,端口 8124
- Gunicorn 生产服务器 (2 workers / 4 threads)
- 开机自启脚本 deploy/S99garmin.sh
- frp 隧道甲骨文 8124 → NAS 8124,外网访问
- 前端构建产物纳入版本管理 (backend/static/)
认证:
- auth-hub OAuth2/OIDC 统一登录接入
- 新建 NAS 专用 client,注册内外网回调地址
- 前端 LoginPage 支持回调路由 /auth/callback
- 数据同步页增加 Garmin 邮箱输入框
同步逻辑修复:
- 自动同步调度器读取用户 history_days 设置,不再固定 2 天
- 前端 0(全部历史)不再被 || 吞掉,改为 ?? 处理
- 后端路由和 sync_data 中 0 不再被当成 falsy 回退默认值
- sync_data 和调度器中 0 → 730 天(全部历史=最大范围)
- 已同步天数显示数据库实际总天数 (totalDays)
- 历史范围新增「自上次同步」增量选项 (days=-1)
- 后端 settings.py HISTORY 添加 -1 值
项目文档:
- 创建 PROGRESS.md 跟踪项目进度
|
2026-09-01 07:39:09 +08:00 |
|
ericwyuan
|
9503fca370
|
feat(auth): 接入 auth-hub 统一登录,网页登录与 Garmin 同步彻底分离
网页身份改由 auth-hub 做 OAuth2 + PKCE 单点登录,本地邮箱/密码登录与注册整条链路删除
(routes/auth.py、auth.py 的密码哈希、config.py 的 ALLOW_REGISTRATION)。Garmin 账号绑定/
同步保持完全独立、可选:routes/garmin.py 不再直接查 users 表,Garmin 邮箱回退统一走新增
的 services/garmin.py::get_remembered_email()(优先读 garmin_tokens 当前绑定,兼容早期账号
落在 users.garmin_email 的历史值),彻底把「你是谁」和「你绑没绑 Garmin」两件事拆开。
- db.py: users 表新增 auth_hub_sub/auth_hub_username,MIGRATIONS 补上这两列(此前遗漏导致
已存在的生产 MariaDB 表永远不会自动加列);同时把历史遗留的 garmin_email/
garmin_password_hash NOT NULL 约束在线迁移为可空,因为新账号不再在注册时收集这些字段。
- routes/auth.py: 修掉 /callback 路由重复拼接 /api/auth 前缀导致 404 的 bug。
- client: LoginPage 去掉本地登录/注册标签页,只保留 auth-hub 统一登录;登录成功/失败后都
用 history.replaceState 清理地址栏,修掉 Framework7 browserHistory 读取
/auth/callback?code=... 导致「找不到页面」的问题。
- 新增 test_auth_hub_client.py 锁定 find_or_create_user 按 auth_hub_sub 幂等——生产上曾经因为
这个函数在没有该测试保护时被测试触发,误建过一个空账号,靠手工核对 health_data 计数才发现。
- 生产 auth-hub 侧另行为该项目注册了正式 client(未随本次提交变更,凭证只存在服务器 .env)。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
2026-08-31 23:12:17 +08:00 |
|
ericwyuan
|
7e51223eb9
|
fix(rate-limit): _is_rate_limited 识别 RetryError 的 429 形态,登录 429 真正触发自动退避
生产实测:登录时 garth 把 429 折进 RetryError('too many 429 error responses'),无 status_code、无 'rate limit' 字样,旧检测漏判 → 走 else 分支存原始报错、不调 _note_rate_limit,冷却永远不落库,用户可反复撞 Garmin。\n\n- garmin.py: _is_rate_limited 沿 __cause__ 链找 429 响应,并接受文本中的 '429' 标记。\n- garmin_auth.py: 退避文案 6h 改为 24h。\n- 生产:已补挂 24h 冷却(2026-08-30 10:00 北京),RetryError 形状验证识别为 429。
|
2026-08-29 10:00:30 +08:00 |
|
ericwyuan
|
30ae431ed9
|
fix(rate-limit): 退避 24h 且守卫以 DB 为准,终结限流死循环
根因:Garmin 限流窗口 >6h,旧 6h 退避每到期就再撞一次 429,永久循环无法退出;且 _note_rate_limit 的 DB 写入被 except:pass 静默吞掉,worker 内存揣着未来冷却时间、DB 停在旧值,守卫被陈旧内存卡死(用户冷却已过仍被拦)。\n\n- RATE_LIMIT_BACKOFF 6h->24h:一次 429 静默一天,真正超过 Garmin 窗口。\n- rate_limited_until() 改为 DB 为准(内存仅在无行时兜底):杜绝陈旧内存永久拦截。\n- _note_rate_limit 失败改为 logging 输出,不再静默吞错。\n- 生产已把冷却设到 2026-08-30 09:45 北京时间。
|
2026-08-29 09:45:49 +08:00 |
|
ericwyuan
|
ae126f90d4
|
fix(rate-limit): 退避延长至 6h 且登录路径同样遵守,打破 Garmin 限流死循环
根因:迁移后自动同步+旧 oauth2 刷新轰炸把 Garmin 账号撞进限流黑洞;退避原仅 60min 且登录路径完全不检查,导致每小时退避到期又撞一次 429、登录也直接连 Garmin 被 429,窗口永远退不出。
- garmin.py: RATE_LIMIT_BACKOFF 60min->6h;_note_rate_limit 改为固定 now+6h(不再短延期)。
- garmin_auth.py: _run_login 入口先查 rate_limited_until 拦截(不撞 Garmin),遇 429 也调 _note_rate_limit 延长退避。
- 已在生产把 sync_status.rate_limited_until 设到 now+6h,强制 Garmin 安静以让窗口真正关闭。
|
2026-08-28 17:18:37 +08:00 |
|
ericwyuan
|
bec45414a7
|
feat(garmin): 增加「退出 Garmin 账号」功能,删除已保存令牌以便重新登录
backend: services/garmin.py 新增 delete_token(),删除 garmin_tokens 行并 forget_client 丢弃缓存会话;routes/garmin.py 新增 POST /api/garmin/disconnect(require_auth),返回 ok + 提示文案。
frontend: api.ts 增加 disconnectGarmin();SyncPage 增加「退出 Garmin 账号」按钮(带二次确认弹窗);DataSync.css 增加危险色样式。
设计:仅删除 OAuth 令牌,保留 users.garmin_email,下次重登只需密码;已同步的健康数据不受影响。
|
2026-08-28 15:13:32 +08:00 |
|
ericwyuan
|
11869c6a8d
|
fix(sync): 限流退避持久化到 DB,调度器与手动同步均尊重退避期
db.py: 修复迁移非幂等——gunicorn 双 worker 并发 init_db 时后到者遇 Duplicate column 会让整个服务起不来,改为吞掉 duplicate column 错误。garmin.py/scheduler.py: 退避期写入 sync_status.rate_limited_until,自动同步与手动同步在退避期内跳过,不再反复撞击 Garmin 把限流窗口越撞越深。
|
2026-08-28 14:54:10 +08:00 |
|
ericwyuan
|
a4bedc263f
|
fix(sync): 限流检测漏掉了它真实的样子
上一版加了 429 识别,但点同步按钮报的还是 JSONDecodeError——因为检测
只看 `e.response.status_code` 和异常消息,而真实到达我们手里的异常两样
都不满足:garth 对着 429 调 .json(),抛出的 JSONDecodeError 里状态码已经
丢了,消息是无用的 "Expecting value: line 1 column 1"。
响应体「Rate limited」唯一幸存的地方是异常的 `doc` 属性,改为从那里取。
同时改正上一版的测试:它用 ValueError("Rate limited") 构造,那个形态现实中
根本不会出现,所以测试通过、检测却漏掉了每一个真实的限流。现在用真的
json.JSONDecodeError 构造,并先断言「光看消息不够」。
实测同步现在返回:
RateLimited: Garmin 暂时限制了请求频率…令牌本身没有失效
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-28 14:27:15 +08:00 |
|
ericwyuan
|
042c4d52be
|
fix(sync): 被 Garmin 限流时说人话,并停止把限流越撞越深
服务器上的数据停在 8-25,而同步状态是 error、卡在「连接 Garmin」,
记录的原因是 `JSONDecodeError: Expecting value: line 1 column 1`。
真相:Garmin 返回的是 HTTP 429,响应体是纯文本 "Rate limited"(12 字节),
garth 把它交给 json.loads,于是限流被伪装成了解析错误。令牌本身好好的,
oauth1 有效期到 2027-08-23,账号也没问题。
- 新增 RateLimited 异常并识别 429(按状态码或响应体),同步状态里显示
「Garmin 暂时限制了请求频率…令牌本身没有失效」,不再是一句解析报错。
- 命中限流后整个进程对该账号退避 30 分钟。重试正是把限流撞得更深的原因,
原来失败后每小时还接着试。
- 只在 oauth2 令牌确实过期时才 refresh_oauth2()。原先每次 _connect 都无条件
刷新一次,白白消耗配额——正是这个把账号一步步推到了 429。
排查过程中我一度误判:先以为是数据库迁移把令牌 base64 包了一层,
改了解码逻辑反而把能用的令牌弄坏(garth.loads 本来就要 base64 形式),
已还原。真正的定位靠拦截 requests.Session 打印出原始响应。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-28 14:20:43 +08:00 |
|
ericwyuan
|
6ad87115ab
|
fix: 返回键失效的根因 + 同步页重做 + 连接池会永久阻塞线程
返回键
- 根因是 Framework7 的页面过渡由动画事件驱动:push 时把 allowPageChange
置 false,等动画报告结束再恢复。这个报告不来,路由就永久卡住,之后每次
导航都被静默丢弃,back() 还会把上一页重建一份而不是弹出。
实测对照:navigate({animate:false}) 前后状态完全正确,带动画则必卡。
因此关掉页面过渡动画——导航同步完成,处处正确。动效改由内容承担
(卡片入场、hero 揭示、顶部进度条),这个取舍里正确性优先。
- Screen 的返回改为显式 handler,先清掉残留过渡状态再 back(),
不依赖路由自己的闸门。注意只清视图上的 router-transition 类:
页面自身的 page-previous 是 F7 判断「回到哪一页」的依据,
一并清掉会导致重建出一个重复的页面(中途踩过这个坑)。
同步页
- .btn 系列样式原本只定义在 pages/Pages.css,而那个文件只被一个没有路由的
遗留页面引用,所以真实页面上按钮全都退化成 Framework7 的默认样式——
就是你看到的三条链接。样式移进每个界面都会加载的 Screen.css。
- 主次分明:一个填充主按钮 + 两个带副标题的次按钮;补上「同步会取哪些数据」
说明,页面不再是一大片空白。
- 进度条显示当前阶段(每日数据 2026-08-01 / 运动详情 12/174 / 身体成分…),
原来只有「0 / 730 天」,几分钟里完全看不出在做什么。
后端
- MariaDB 连接池:_mariadb_release 用的是阻塞 put(),而队列 maxsize=10,
_mariadb_acquire 在池空时又会新建连接。并发超过 10 之后,归还的线程会
永久停在 put() 上,请求就此挂死。改为 put_nowait,多出来的连接直接关闭。
- 进程重启会带走同步线程却留下 status=syncing 的行,界面上是一个永远不动
的进度条,还拒绝开始新同步。启动时清理。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 08:41:48 +08:00 |
|
ericwyuan
|
f1319a6171
|
feat: 补齐 Garmin 未同步的数据,并各自配上界面
审计 57 个接口后,把账号里真有数据却从未入库的部分补上。全部走同步模块,
界面只读本地库。
新增数据
- 体重与身体成分(体脂率/肌肉量/体水分/骨量/内脏脂肪/代谢年龄)
- 血压(接口通,账号暂无记录)
- 跑步成绩预测(5 公里 / 10 公里 / 半马 / 全马)
- 爬坡分、饮水量、出汗量 → health_data 新增七列
- 全天曲线:心率 / 压力 / 身体电量 / 呼吸 / 血氧
- 挑战赛(徽章挑战与好友挑战,与一次性的徽章不同,有周期和进度)
- 已配对设备
新增界面
- /body/ 身体成分:体重大数字 + BMI 分级 + 体脂肌肉曲线 + 血压表格
- /race/ 成绩预测:四个距离的预测成绩与配速,以及预测随时间的变化
- /challenges/ 挑战赛:按类型筛选,有目标的显示进度条
- /devices/ 已配对设备
- 每日页新增「全天曲线」,这是存日内采样的主要目的
- 健康页新增「身体成分」分组与「更多」入口,运动页加挑战赛与成绩预测入口
同步开销
- 日内曲线每天五个请求,14 天以内的同步顺带拉,更长的历史交给后台
「补齐详细数据」,否则一年的同步会多出约 1800 个请求
- 原来的「补齐运动详情」扩展为统一的补齐任务,分阶段上报进度
日内采样抽稀到每天 240 点:手机图表分辨不出更多,只会把行撑大。
全量 446 项测试通过。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 04:16:33 +08:00 |
|
ericwyuan
|
34940cc387
|
feat(sync): 运动详情改为同步入库,详情页只读本地
按需回源是错的:点一次运动要等七个 Garmin 接口,网络好的时候慢,
网络差的时候直接超时(实测公网下 Network Error)。
- sync_data 顺带补齐缺详情的运动
- POST /api/garmin/sync-details 后台补齐存量,GET 查进度
- 详情页只读本地库;没有就提示去同步,不再回源
- 同步页新增「补齐运动详情」按钮,带进度
身体年龄:加入公开的阻尼系数
- 34 岁 VO₂max 46 原本算出 21 岁。不是算错,是方法本身会饱和:
人与人之间的 VO₂max 标准差约 7,而年龄每年只带来约 0.35 的衰减,
于是稍微能练的人都会撞到参考表最年轻一档。
- 按 50% 向实际年龄收拢,收敛范围 ±20 → ±12 岁,同一算例现在给 27 岁。
- 去掉「高于最年轻一档按 20 岁计」的硬地板,那是一道正好落在用户身上的悬崖。
- 界面同时显示未收拢的原始值,阻尼系数写进评分依据。
路由:为每个路径补无斜杠别名
- F7 写地址栏时去掉尾斜杠,于是 /daily/ 在地址栏是 /daily,
而那个地址匹配不到任何路由,刷新或分享就落到「找不到页面」。
布局:让页面结构上无法被撑宽
- 网格改用 minmax(min(210px,100%),1fr):裸的 minmax(210px,1fr) 允许
两列加起来超过窄屏宽度,第二张卡就被切掉在屏幕外。
- .ring-row 用 minmax(0,1fr),1fr 会以 min-content 兜底,一句长说明就能
把整行顶宽。
- .page-inner 加 overflow-x: clip。
- html/body 用 100dvh:手机浏览器把自己的地址栏盖在布局视口上,
100% 高的应用会把底部 Tab 栏顶到它们下面——对用户来说就是没有 Tab 栏。
测试:新增 122 项(设置 44、身体年龄 44、运动详情 42),全量 446 项通过。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 04:03:49 +08:00 |
|
ericwyuan
|
fa865ca8a6
|
fix: 实机验证发现的七个缺陷
部署与时区
- client/.env.production 写死 REACT_APP_API_URL=/api。之前没有这个文件,
构建靠命令行临时传参,一旦忘了就把开发默认值 localhost:5000 打进包里,
部署后整站 Network Error。
- 新增 lib/day.ts,所有日期改用本地日历日。原先用 toISOString() 取的是 UTC 日期,
在 UTC+8 每天前 8 小时都会少查一天——当天的数据佳明已经有了,应用却够不到。
界面
- 覆盖 Framework7 9 给 .navbar .left/.right 加的 frosted pill,
就是各页右上角和返回键旁边那个半透明椭圆。
- .metric-tab 显式 width:auto。F7 把每个 button 渲染成整宽块元素,
运动详情的四个 Tab 因此竖着堆成四行。
- 主要收益为 UNKNOWN 时不显示该区块,那是「没有结论」的哨兵值。
正确性
- 心率区间百分比改用整次运动时长作分母。原先除以「落在区间内的总时长」,
把低于区间 1 的时间挤掉了:44:06 的登山里区间 1 占 23:03,
手表显示 52%,我算成了 90%。现在对上了。
性能
- 按进程缓存已认证的 Garmin 会话(15 分钟 TTL)。实测 _connect 单次 11 秒,
而七个数据接口加起来才 4 秒——瓶颈全在每次重新认证。
冷启 16s → 热 7s → 命中缓存 0.8s,不再撞客户端超时。
- get_activity_details 的 maxchart 由 2000 降到 500,反正写入时抽稀到 300。
- 重新绑定账号时丢弃缓存会话。
删除前端重做前遗留的 5 个无引用页面文件。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 01:16:44 +08:00 |
|
ericwyuan
|
c70e7ced80
|
feat(api): 个人资料/单位/同步偏好 + 运动详情 + 身体年龄
设置 (services/settings.py, routes/settings.py)
- user_settings 表:身高/体重/出生日期/性别/单位/自动同步开关/同步频率/历史范围
- GET|PUT /api/settings,GET /api/settings/options(取值由后端给,前端不臆造)
- GET /api/settings/rating-basis:把每条参考区间的来源公开出来。
一个把数字标成「偏低」的区间是在下判断,用户有权看到依据。
运动详情 (services/garmin.py)
- GET /api/garmin/activities/<id>/detail:概览/分段/心率区间/天气/装备/采样曲线
- 首次打开回源 Garmin 并落库,之后走缓存;?refresh=1 强制刷新
- 采样点在写入时抽稀到 300,手机图表画不了更多,也免得整行撑大
身体年龄 (services/fitness_age.py)
- 0.2.8 版 garminconnect 没有 fitnessage 接口,改为本地按公开常模推算:
VO₂max 对应年龄为基准,静息心率与 BMI 做有上限的修正
- 返回每一步的中间值,界面照实展示,不做成一个不可追溯的分数
- 高于参考表最年轻一档时按 20 岁计——那里外推会得到「11 岁」这种结果
调度器改为每 5 分钟 tick,是否该同步按各账号自己的频率判断
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 00:26:26 +08:00 |
|
ericwyuan
|
6a5cfa7806
|
[阶段7.1] 同步改为后台任务 + 进度上报,支持回补历史
趋势页提供了"一年"档,但库里只有 7 天数据,那一档形同虚设。
实测每天约 2.84 秒(一天要打 5 个端点),回补一年需要约 28 分钟,
远超任何 HTTP 超时能等的时间。
- sync_status 新增 progress_current / progress_total / started_at
- start_sync() 起后台线程并立即返回,sync_data 每 5 天写一次进度
(写库便宜但不免费,而前端本来就是 2 秒一轮询)
- POST /api/garmin/sync 改为 202 立即返回,接受 days 参数并
夹在 1..730;进度经 GET /status 轮询
- 一次新同步会清掉上一次的错误,避免旧错误一直挂在界面上
前端:
- 同步页给出 7 / 30 / 90 / 365 天四个选项,日常与首次回补分开
- 进度条显示"第 N / 共 M 天"与预计耗时,并说明可以离开本页
- 页面挂载时若发现正在同步会接着轮询 —— 回补比页面存活时间长,
刷新后必须能接上进度
tests (+7, 共 299):
- start_sync 在工作完成前就返回,且返回前已把 total 写好
- 进度随同步推进,结束时等于总天数
- days 超范围被夹到 730
- 新同步清除上一次的错误
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-23 21:31:50 +08:00 |
|
ericwyuan
|
cbbff61082
|
[阶段6] 同步 Garmin 全量数据:31 项日指标 + 奖励 + 个人纪录
原来每天只存 7 个指标,而 get_user_summary 一次就返回 60+ 字段,
另有睡眠分期、训练准备度、耐力分等独立端点从未被调用。
db.py:
- health_data 新增 31 列(距离/活动卡路里/基础代谢/爬楼/强度分钟/
久坐时长/最高最低心率/最大压力/身体电量四项/血氧/呼吸/
睡眠深浅REM清醒分期/睡眠血氧/睡眠呼吸/睡眠压力/训练准备度/
VO2max/耐力分)
- 新增 badges 与 personal_records 两张表,均以 (user_id, garmin_id)
为主键,重复同步更新而非累积
- 新增增量迁移: CREATE TABLE IF NOT EXISTS 对已存在的表不生效,
新列必须显式 ALTER,否则生产库上永远不会出现。按列名比对后
逐个补齐,SQLite 与 MariaDB 都幂等
services/garmin.py:
- _extract_daily 改为汇总 user_summary + sleep + hrv +
training_readiness + training_status + endurance_score 五个端点
- 每个可选端点用 _safe 包裹:某项设备不记录时留 NULL,不影响当天其余数据
- 新增 sync_badges / sync_personal_records(账号级,每次同步取一次)
fix(garmin): 个人纪录整批写入失败
- Garmin 在同一份数据里混用 ISO 字符串和 Unix 毫秒时间戳,
prStartTimeGmt 是 1570961412000,写进 DATETIME 列被 MariaDB
以 1292 拒绝,导致 11 项个人纪录一条都没存进去
- 新增 _to_datetime 统一处理 ISO / 毫秒 / 秒三种形状,并优先取
Garmin 自己提供的 *Formatted 字段
services/ai.py:
- 送给模型的 CSV 从 7 列扩到 23 列,纳入身体电量、血氧、呼吸、
训练准备度、耐力分和睡眠分期
接口: GET /api/health/badges、/api/health/personal-records
tests (+13, 共 292):
- 徽章/纪录的往返、重复同步不累积、按用户隔离
- 两个用户可持有同一个 Garmin 徽章 id 而不冲突
- 时间戳三种形状的归一化及无效值不抛异常
NAS 实测: 7 天数据每天 31 项指标、65 个奖励、11 项个人纪录
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-23 20:48:49 +08:00 |
|
ericwyuan
|
af0604bce4
|
fix(garmin): 两步验证账号同步报 EOFError,改用令牌登录
现象:网页触发同步报 "EOF when reading a line"。
原因:garth 的默认 MFA 提示是 input(),向 stdin 索取验证码。
gunicorn worker 没有 stdin,于是抛出 EOFError——错误信息本身
完全没提到 MFA,看不出该做什么。
方案:把"输验证码"和"日常同步"拆开。
- 新增 garmin_tokens 表存 garth 令牌(Client.dumps/loads 序列化)
- garmin_login.py:在终端里跑一次,可正常输入验证码,
成功后令牌存库
- _connect() 优先加载令牌并 refresh_oauth2(),命中则完全跳过登录,
既不需要密码也不需要验证码(令牌有效期约一年)
- 无令牌且密码登录撞上 MFA 时,抛 MFARequired 并给出具体该执行
哪条命令,而不是把 EOFError 原样抛给用户
接口:
- GET /api/garmin/auth-status 返回是否已有令牌
- /api/garmin/sync 在已有令牌时不再强制要求密码
前端:
- 有令牌时隐藏密码输入框,提示无需密码
- 同步返回 mfaRequired 时,展示需要在 NAS 上执行的具体命令
- 同步请求超时放宽到 180s(一周的天数 + 运动是多次上游调用)
- 成功消息补上运动记录条数
tests (test_garmin_sync.py 新增 12 条,共 35):
- 令牌存取、覆盖不累积、按用户隔离
- 有令牌时绝不调用 login()
- MFA 的 EOFError 转成带操作指引的 MFARequired
- 普通 401 不会被误标成 mfaRequired
- 无令牌且无密码时给出明确拒绝
NAS 真机: 252 passed
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-23 19:57:40 +08:00 |
|
ericwyuan
|
6de7562cd8
|
[阶段5.1] 开机自启 + 接入真实 garminconnect,修复同步的三处 API 误用
fix(garmin): 同步代码调用的 garminconnect API 全是错的
装上库核对签名后发现(garminconnect 0.2.8),原代码从未被真正
执行过,三处调用都不成立:
1. get_activities(date) —— 该方法签名是 (start, limit),收的是
分页下标和条数,不是日期。传日期等于在问"第 2026-08-23 条运动",
而且少传一个参数必然 TypeError。
改为 get_activities_by_date(start, end),整个窗口一次取回,
顺带把原来"每天一次调用"减为一次。
2. 睡眠数据不在 get_user_summary 里,是独立的 get_sleep_data(cdate);
原代码从 summary 里读 sleep 字段,结果会把每一晚都记成无睡眠数据。
HRV 同理,走 get_hrv_data(cdate)。
3. 字段名不符:实际是 totalSteps / totalKilocalories /
averageStressLevel,原代码用的是 steps / calories.total /
stress.average。
其他改进:
- 运动记录改用 Garmin 自己的 activityId 作主键,重复同步同一窗口
不再产生重复行(原来每次同步都会把同一条运动再插一遍)
- 某天无数据时返回全 None,不再写入空行让读端点再过滤掉
- 原来 bare except 吞掉每天的异常,全部失败也报 success;
现在全窗口失败会如实返回 error 并记录原因
- 同步天数、是否走 Garmin 中国区改为环境变量可配
tests/test_garmin_sync.py (23 通过):
- 用 StubClient 模拟真实 0.2.8 的接口形状,无需库、凭证或网络
- 回归用例覆盖上述三处误用:活动必须按日期区间一次取回、
睡眠与 HRV 必须走各自端点
- 重复同步不产生重复的天和重复的运动记录
- 单天失败跳过、全窗口失败报错、登录失败如实上报
部署:
- NAS 安装 garminconnect(pydantic-core 有 cp38 x86_64 轮子,
无需 gcc)
- 新增 deploy/S99garmin.sh 开机自启脚本,与 NAS 上既有的
S99frpc.sh 同一套惯例;以 root 启动但降权到 ericwyuan 运行,
因为服务不需要特权而 .env 里有数据库和 API 凭证
- PID 文件放应用目录而非 /var/run(非特权用户写不了)
NAS 真机全量测试: 240 passed
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-23 19:05:19 +08:00 |
|
ericwyuan
|
3b2d0697f0
|
[阶段1.1-1.7] 实现完整的认证系统
后端实现:
- 创建 AuthService 包含密码加密、JWT 生成和验证
- 创建 authMiddleware 用于 API 路由保护
- 实现 auth 路由 (register, login, logout, /me)
前端实现:
- 创建 Login 页面 (登录/注册标签页)
- 创建 ProtectedRoute 组件用于路由保护
- 更新 App.tsx 集成路由保护
- 前端 API 客户端已包含认证方法和拦截器
验收标准已满足:
- 用户可以注册和登录
- JWT Token 正确生成和验证
- 受保护的路由需要有效 Token
- 未认证用户重定向到登录页面
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-23 12:25:33 +08:00 |
|