ericwyuan
c57c930949
feat(ai): AI 教练 —— 晨间简报、运动处方、趋势归因与 Copilot
...
数值全部在服务端算好再交给模型,模型只做解读。让模型从 CSV 里自己推
z 分数,它算错的次数足以让简报引用图表反驳它的数字。
- services/insights.py:z 分数(28 天个人基线,且**排除当天**——用一个
值参与算出来的均值去衡量它自己,会把真实离群点摊平)、13 个月趋势斜率
(按序数日期最小二乘,手表放充电器上一周不会压缩 x 轴)、近 7 天活动量
对比。
- services/coach.py:三套提示词 + 回复解析,每套都配一个规则引擎版本。
网关一次生成要几分钟,上游被限流时给一个朴素的答案,好过给一张空卡片。
- services/ai.py:多轮 chat()、SSE stream()、complete()/stream_chat(),
以及 extract_json()——上游是推理模型,可见输出以思维链开头,所以从末尾
倒着找最后一个配平的 JSON(字符串感知,扛得住引号里的 } 和转义引号)。
- 接口 briefing / trend-insight / copilot(SSE),缓存表 ai_insights。
- 前端:今日页晨报卡(后台生成 + 轮询升级)、全局 Copilot 浮窗、指标详情
页归因面板。features.ai 打开。
实测(对着自建 ai-gateway):晨报一次 273 秒,缓存命中 18 毫秒——所以简报
绝不能同步阻塞首屏。网关的流式通道比阻塞通道更不可靠:同一条提示词流式
139 秒后返回「所有模型均不可用」,阻塞则成功,因此 stream_chat() 在流式零
输出时对同一模型退回非流式重试。Copilot 实测 TTFB 9ms、全程 40 秒。
顺带修两处:refresh 原来只跳过缓存读、不删行,导致「重新生成」后的轮询读
到旧行、看到 cached 就停了,用户一直盯着他刚要求替换掉的那段字;基线零方差
时原来返回 z=0.0,把「和每一条观测都不同」标成「完全正常」,改为 z=null。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com >
2026-09-01 13:57:35 +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
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
564aaef6c1
fix(auth): 登录页不停刷新、无法输入
...
未登录时五个 Tab 视图会同时挂载各自的 Screen,每个都判定「没登录」,
于是每个都对**当前**视图发一次 navigate('/login/', {reloadAll: true})。
每次 reloadAll 又让其他几个重新挂载,再各发一次——登录表单被持续拆掉重建,
根本打不出字。关掉页面过渡动画后这个循环变紧,症状才明显起来。
判断登录与否是外壳的职责,不是每个页面各自抢着跳转:
- App 持有会话状态:没有会话就只渲染一个 /login/ 视图,连 Tab 栏都不出;
有会话才渲染五个 Tab。
- Screen 不再做任何跳转。
- setSession / clearSession 派发 ghl:auth 事件。storage 事件只在**其他**
标签页触发,同标签页的登录登出需要自己的信号。
- 登录成功后不再手动 navigate:外壳会换掉整个视图,从一个正要被卸载的
视图里发起路由是在和它抢。
实测:登出后登录页只有 1 个视图、无 Tab 栏,输入的内容 2.5 秒后仍在,
且输入框还是同一个 DOM 节点(没有重挂);恢复会话后自动切回 5 个 Tab。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com >
2026-08-24 09:08:02 +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
b0e0799a97
feat(ui): 卡片可点进详情 + 身体年龄 + 运动详情 + 指标选择弹窗
...
需求 3.5 / 3.6 / 4.4 / 4.5 / 4.8 / 4.9
- 删掉导航栏右上角那两个 Link,就是它们渲染成半透明椭圆的。
睡眠入口改为点健康页的睡眠卡片,每日入口移进趋势页内容里。
- 新增 lib/metrics.ts 作为唯一的指标注册表。label/unit/取值函数原本在
今日、健康、趋势各写一份,改一处要改三处,也就有三次写不一致的机会。
- /metric/:id/ 指标详情:大数值 + 参考区间 + 7/30/90/365 趋势图 +
平均最高最低达标天数 + 这个指标是什么 + 评分依据(取自后端,不在前端另写一份)
- /activity/:id/ 运动详情:概览/数据/分段/图表,数据分组照搬手表的排法,
心率区间用单色顺序色阶(区间是有序刻度,不是分类,不能用分类色)
- /body-age/ 身体年龄:逐步展示 VO₂max 基准与各项修正,以及每步的出处
- 趋势页 15 个 chip 占满一屏且像张表单,改成弹窗选择,页面上只留一行摘要
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com >
2026-08-24 00:39:33 +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
ad88ec7e41
[阶段7] 前端重做:31 项指标全部露出,配色经色盲校验
...
原来仪表板只有 4 张卡片和 4 张图,31 项指标里绝大多数没有出口。
信息架构重组为 7 个页面(原 5 个):
- 今日 按活动 / 心率压力 / 睡眠呼吸三组展示 16 项指标
- 趋势 15 组指标可切换,5 档时间窗口(最长一年)
- 睡眠 新增。分期堆叠图 + 夜间血氧/呼吸/压力
- 成就 新增。奖励徽章按年份分组、个人纪录、运动记录
- 建议 / 同步 / 设置 保持
配色(依据 dataviz 规范,用校验器实测而非目测):
- 采用验证过的分类色板并按既定 slot 顺序取色 —— 顺序本身就是
色盲安全机制,不是审美选择,因此只取不循环
- 浅色 worst adjacent CVD ΔE 9.1 / 常视觉 22.9
深色 worst adjacent CVD ΔE 8.4 / 常视觉 19.8,两档全部通过
- 浅色表面下 aqua 2.74:1、yellow 2.11:1 低于 3:1,按规范提供
"relief":每张图都带表格视图,且图例始终与文字标签同现
- 深色不是自动反色,是同色相针对深色表面重新取阶并单独校验
- 状态色(good/warning/serious/critical)保留专用,绝不当作
第 N 个系列色;且始终图标 + 文字同现,不靠颜色单独表意
图表规范:
- 单一 y 轴,绝不双轴;量纲不同的指标拆成不同图
- 2px 线宽、4px 圆角柱端锚定基线、堆叠段间 2px 表面色间隙、
悬停标记 2px 表面色描边
- 两系列以上必有图例,单系列不加(标题已经点明)
- 折线 connectNulls,设备漏记的日子不把线打断
- 数值文字一律用文字色令牌,不染系列色
其他:
- 主题切换(自动/浅色/深色),选择写入 localStorage 并标在 <html>
- 导航改为顶部横向,移动端可横滑
- 表格、徽章、空状态等组件统一到设计令牌
验证方式: DOM 审计确认 2px 线宽、圆角为 A 4,4 弧、堆叠段
2px 间隙、图例数量与系列数匹配、两档主题令牌各自解析到校验过的色值。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com >
2026-08-23 21:17:08 +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
bb774c332b
[阶段5.2] 两步验证改到网页端完成,手机上即可绑定 Garmin
...
背景:命令行方案要求用户在电脑前开交互式终端,实际不可行。
改为在网页里完成 MFA,手机也能操作。
难点:garth 索取验证码走的是 *阻塞回调*,0.4.46 没有
"发起登录 -> 返回句柄 -> 稍后续接" 的接口,登录必须一直挂着。
而 gunicorn 跑多个 worker,验证码请求不一定落到挂着登录的那个 worker。
方案:登录跑在后台线程里,停在 prompt_mfa 内轮询数据库;
浏览器用另一个请求把验证码写进同一行。**汇合点是数据库而非进程内存**,
所以哪个 worker 收到验证码都能送达。
- 新增 garmin_mfa_sessions 表(不存密码,密码只活在等待线程的内存里)
- services/garmin_auth.py:start_login / submit_code / cancel
状态机 starting -> awaiting_code -> finishing -> done|failed
- 超时 5 分钟自动放弃,会话 1 小时后清理
- 会话按 user_id 校验,他人拿到 session id 也读不到、提交不了
接口:
- POST /api/garmin/login 发起登录,202 返回 session
- GET /api/garmin/login-status 轮询状态
- POST /api/garmin/mfa 提交验证码
- DELETE /api/garmin/login 取消
前端 DataSync 改为三步:
- 未绑定 -> 输密码「绑定 Garmin 账号」
- 需要验证码 -> 弹出 6 位验证码输入框(inputMode=numeric、
autoComplete=one-time-code,手机可直接从短信自动填充)
- 已绑定 -> 只剩「立即同步」,不再要密码
tests/test_garmin_mfa.py (20 通过):
- stub 的 prompt_mfa 按 garth 的真实方式同步阻塞调用
- 关键用例:验证码直接写进数据库行也能被挂起的线程取到
(模拟验证码落到另一个 worker)
- 无 MFA 的账号不经验证码直接完成
- 验证码错误 / 密码错误 / 等待超时 各自失败并给出原因
- 取消后挂起线程立即释放,不空转到超时
- 密码不出现在会话行里
- 跨用户读取和提交均被拒
全量: 271 passed
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com >
2026-08-23 20:11:45 +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
5f07dad019
[阶段5] 部署到 NAS + frp 公网映射,并加注册锁
...
部署 (NAS 192.168.50.64):
- MariaDB 建库 garmin_health_lab,5 张表由 init_db 建好
- Python 3.8.15 venv;NAS 无 gcc,依赖全部走纯 Python 轮子
- gunicorn 2 worker × 4 线程,--timeout 300(AI 生成耗时可达数分钟)
- start.sh / stop.sh,可重复执行;日志落 logs/
- 在 NAS 真机 + 真实 MariaDB 上跑通全部测试:205 passed
app.py / config.py:
- STATIC_DIR 存在时由同一个 Flask 进程托管 React 构建产物,
部署即单端口单进程,不需要额外反代
- 404 处理区分 /api 前缀:API 仍返回 JSON,其余回退到 index.html,
这样 /settings 这类前端路由刷新后不会 404
安全 - 注册锁 (ALLOW_REGISTRATION):
- 服务要挂到公网,而原本 /register 完全开放,任何人都能注册进来
读取健康数据
- 默认策略 auto:仅在尚无任何账号时开放,注册完第一个即自动关闭
- 另支持 true / false 显式覆盖;按请求读取,改配置无需重启
- 新增 GET /auth/registration-status,前端据此隐藏注册标签页
frp 公网映射:
- 复用 NAS 上已有的 frpc (/etc/frp/frpc.toml),追加 garmin 隧道
NAS:8123 -> 甲骨文:8123(改前已按既有惯例备份 .bak.<时间戳>)
- 经 S99frpc.sh restart 生效,原有 4 条隧道均正常恢复
tests/test_registration_policy.py (13 通过):
- auto 策略下第一个账号放行、第二个 403 且不落库
- true/false 显式覆盖,大小写不敏感
- 策略按请求读取而非 import 时冻结
- 关闭注册不影响登录;status 端点无需鉴权
公网实测: 页面、SPA 路由、鉴权 401、注册锁 403 均符合预期。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com >
2026-08-23 18:49:05 +08:00
ericwyuan
acc6a2474b
[阶段4.4] AI 建议结果缓存 - 页面不再阻塞等待 160 秒
...
网关首选的推理模型一次生成约 160 秒,每次打开建议页都重跑不可用。
结果落库缓存,页面读缓存,用户想要新的再手动触发。
db.py:
- 新增 ai_recommendations 表,每用户一行(重新生成是替换不是累积)
- fingerprint 列记录这条建议是基于哪份数据算出来的
services/analysis.py:
- _fingerprint() 对全部每日指标 + 运动条数取 sha256,任何一次同步
新增或修正了数值都会让摘要变化,从而使缓存失效
- TTL 默认 24 小时(AI_CACHE_TTL_HOURS 可调)
- 指定 model 参数时绕过缓存:点名某个模型意味着想要那个模型的答案
- 降级到规则引擎的结果不写缓存,避免把兜底答案当成 AI 结果存下来
- 缓存写入失败只打日志,不影响本次请求返回
routes: ?refresh=1 强制重新生成
前端:
- "重新生成" 按钮走 refresh,并提示需要 1-3 分钟、可以离开本页
- meta 栏显示是否为缓存结果及生成时间,以及网关的上游厂商
- axios 该请求超时放宽到 240s(冷生成远超默认超时)
tests/test_ai_cache.py (20 通过):
- 第二次调用不再打模型
- 新增一天数据 / 修正某天数值 / 新增一条运动记录,三种情况都失效
- TTL 边界两侧各一条(刚过期重算、未过期沿用)
- 缓存按用户隔离,A 的结果不会答给 B
- payload 损坏时重新生成而不是抛异常
- 规则兜底结果和无数据用户都不落缓存
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com >
2026-08-23 17:55:12 +08:00
ericwyuan
8616a13525
[阶段4.2] 前端对接 Flask API + 建议/分析/设置页面,移除废弃的 Node 后端
...
选型确认为 Python Flask 后,删除 server/ 整套 Node 实现,
根 package.json 改为只管理 client workspace,
npm run dev 同时拉起 Flask 与 React。
fix: 前端读取响应的方式与后端不符
- Flask 返回裸数组/对象,而前端读的是 response.data.data
(Node 那套 {success,data} 包装),登录后拿到 undefined 直接崩
- api.ts 重写为返回 response.data,并补齐全部端点的 TypeScript 类型
- 401 拦截器改为清除会话并跳转 /login,而不是留在空白页
fix: 同步页缺少 Garmin 密码输入
- 后端只存密码哈希、无法还原,/garmin/sync 要求请求体带明文密码,
原页面没有该输入框,点同步必然 400
- DataSync 增加密码字段,请求结束后立即清空,并说明为何每次都要输入
新增页面:
- Recommendations: 模型下拉切换(未配置密钥的模型置灰),
展示本次由哪个模型作答、是否发生了降级、分析了多少天数据;
按优先级配色,底部附免责声明
- Analysis: 6 个指标 × 4 个时间窗口,展示均值/中位数/极值/
前后半段差值,并绘制趋势图
- Settings: 模型清单与配置状态、数据隐私说明、退出登录
Dashboard:
- 修正响应结构,数据扁平化后再传给图表
(原先给 dataKey 传了函数,与 Chart 的 string 类型不符)
- 无数据时引导用户去同步,而不是显示一堆空图表
Chart:
- 按指标而非按行判断是否为空,某天缺某项指标不再让整张图判空
- 折线图 connectNulls,避免设备漏记的日子把线断成碎片
验证: 前端 tsc --noEmit 通过;后端 161 passed / 1 skipped;
实机启动 Flask 后 register/login/models/ai-recommendations 均正常,
未配置密钥时正确降级到规则引擎。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com >
2026-08-23 12:43:52 +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
ericwyuan
d73405decb
Initial commit: Set up Garmin Health Lab project structure
...
- Initialize monorepo with root workspace configuration
- Set up Express.js backend with TypeScript
- Set up React 18 frontend with TypeScript
- Create database schema with SQLite
- Implement project architecture and documentation
- Add development and deployment guidelines
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com >
2026-08-23 11:11:29 +08:00