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
|
241ae0d6a3
|
feat(ai): 每个数据页面都有 AI 解读,靠一条带优先级的生产者/消费者队列
原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外
的 10 个页面都有:健康、睡眠、运动、趋势、每日、身体成分、成绩预测、
身体年龄、挑战赛、运动详情。
不是给每个页面写一套,而是一个通用管线:
- services/scopes.py:一个页面一个 context builder,返回同一个信封。
context["highlights"] 是已经算好的白话事实——模型负责解读它们,模型不
可用时规则引擎原样渲染。两者引用同一批数字,所以降级读起来不像换了个 App。
没数据的页面返回 None,宁可不出卡片,也不让模型对着空表格发挥。
- coach.scope_messages / parse_scope_insight:一套提示词吃所有页面,页面
的差异全在 context 里,加页面 = 加一个 builder。
- 前端 <AiPanel scope="…">:一个组件渲染所有页面,轮询逻辑抽成
lib/insight.ts 的 usePolledInsight,晨报卡也改用它。
## 队列
一次生成 40 秒到 4.5 分钟,所以什么都不能在请求里生成。页面只负责入队,
worker 负责消费(services/jobs.py)。
优先级才是用队列而不是后台线程的理由:同步完成后 prefetch 把所有页面按
背景优先级排进去,可能要跑半小时;而用户一打开某个页面,那个页面的任务
立刻提到队首、下一个就跑。你在看什么,队列就在算什么。
队列放在数据库而不是内存里,因为 gunicorn 有两个 worker:任务带 holder
声明后回读确认,和 scheduler.py 抢 tick 是同一套做法。id 由
user+kind+subject 推导,所以每几秒一次的轮询是幂等的入队,不会每几秒堆一
个任务。
## 网关中断时踩到的两个坑(当场修了)
写完正好赶上 oracle 那台机器不通,于是看到:
- 三次失败后任务被永久标 failed,网关恢复了也不会重试——一次瞬时中断就把
那个页面的解读判了死刑,直到它的数据碰巧变化。加了冷却期,过期后重置
尝试次数再排一次。
- 队列已经放弃了,页面还在 pending 转圈,要转满 8 分钟才停。meta.pending
现在跟着队列状态走,并把失败原因带给卡片。
顺带把 BAND_SOURCES 从 routes/settings.py 下沉到 services/insights.py:
教练要拿它做参照,而 services 不该反向依赖 routes。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 15:17:17 +08:00 |
|
ericwyuan
|
526ece7d14
|
refactor(sync): 手动同步与自动同步彻底分开
「历史范围」原本放在设置页,却只对同步页的一个按钮起作用;而同步页最
显眼的主按钮「同步最新数据」写死 2 天,根本不看这个设置。选了「全部
历史」再点主按钮,表现就是应用无视你 —— 这正是反复出现的「只同步下来
两天」。
现在两条链路各管各的:
* 自动同步:只在设置页配置(开关 + 频率),窗口固定 SYNC_DAYS,不再
读 history_days。措辞也改成「拉取最近几天」,不再暗示会补历史。
* 手动同步:范围就在同步页当场选,紧挨着用它的按钮,并标出每个范围的
实际代价(自上次同步 / 7 天 / … / 全部历史约 730 天、20-40 分钟)。
两个按钮合成一个「开始同步」,写死 2 天的那个删掉。
history_days 保留为「上次手动选的范围」,只有同步页读它;默认值改成
-1(自上次同步),对日常使用是正确的起点。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-09-01 09:20:52 +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
|
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
|
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
|
12ef5ca06b
|
feat(sync): 每小时后台自动同步 + 手动拉取最新接口
- services/scheduler.py:通过 job_locks 表跨 worker 抢占,
gunicorn 多进程下一个周期只跑一次;claim 超时 30 分钟自动释放,
避免 worker 中途挂掉把任务永久卡死
- POST /api/garmin/sync-latest:同步执行,窗口 clamp 到 1..7 天
- GET /api/garmin/auto-sync:返回上次/下次运行时间
- db.py:注释里的分号会被 SCHEMA.split(";") 截断,改为先剥注释再切分
19 项调度器测试,全量 324 项通过
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-24 00:17:04 +08:00 |
|