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
|
c83340742c
|
[阶段4.1] AI 健康建议 - 多模型可切换 + 大上下文 + 失败兜底
services/ai.py:
- 模型目录(catalog)按短 id 索引,业务代码不感知厂商
gemini-flash (Google, 1M 上下文)
llama-70b / qwen-72b / deepseek-r1 (NVIDIA NIM, 128k)
仅注册纯文本模型,不含视觉模型
- 两个 provider: GeminiProvider、OpenAICompatProvider
(后者兼容 NVIDIA NIM / Ollama / vLLM)
- 大上下文: 每日指标序列化为 CSV 而非 JSON,同样的数据 token 数约为
1/4,一整年历史仍远小于最小的 128k 窗口;按 AI_DAY_BUDGET 截断
- 兜底链: 首选模型超时/报错/返回无法解析的文本时自动降级到下一个,
meta.fallbackFrom 记录降级路径
- 响应解析容忍 markdown 代码块包裹和 JSON 前的多余句子
services/analysis.py:
- get_ai_recommendations(): 所有模型都失败时回落到规则引擎,
端点始终 200,meta.source 区分 ai / rules
routes/analysis.py:
- GET /api/analysis/models 列出模型及各自是否已配置密钥
- GET /api/analysis/ai-recommendations?model=&days=
tests/test_ai.py (59 通过, 全程 mock 不联网):
- prompt: 大预算截断保留最新的天、缺失指标不写成 "None"、
一年数据估算 token 数上界
- 解析: 代码块包裹/前置句子/单对象/非法 priority/空建议 等 7 种畸形输入
- provider: 超时、HTTP 4xx/5xx、响应结构异常均转为 AIError;
未配置密钥时不发出任何请求
- 兜底: gemini 超时后 llama 接管、首个成功则不再调用第二个
- 端点: /models 不泄漏 API key;无密钥时仍返回 200 + 规则建议
密钥一律从环境变量读取,.env.example 只留空占位符。
全量: 161 passed, 1 skipped
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
|
2026-08-23 12:38: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 |
|