Files
GarminHealthLab/backend/routes/garmin.py
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

191 lines
6.4 KiB
Python

"""Garmin routes: trigger a sync and read sync status."""
from flask import Blueprint, request, g, jsonify
from auth import require_auth
from db import query_one
from services import garmin as garmin_svc
from services import garmin_auth
from services import scheduler
bp = Blueprint("garmin", __name__)
@bp.route("/sync", methods=["POST"])
@require_auth
def sync():
data = request.get_json(silent=True) or {}
creds = {
"garminEmail": (data.get("garminEmail") or "").strip(),
"garminPassword": data.get("garminPassword") or "",
}
# Fall back to the stored Garmin email when only a password is supplied.
if not creds["garminEmail"]:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id])
if user and user.get("garmin_email"):
creds["garminEmail"] = user["garmin_email"]
# With stored OAuth tokens no password is needed at all. Without them the
# plaintext password must come in the body, because only a hash is kept.
if not creds["garminPassword"] and not garmin_svc.has_token(g.user_id):
return (
jsonify({
"status": "error",
"recordsSynced": 0,
"message": "需要 Garmin 密码以执行同步,请在请求体中提供 garminPassword"
"(密码仅作哈希存储,无法还原)。",
}),
400,
)
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
except (TypeError, ValueError):
days = None
# Always run in the background: even a week takes ~20s, and a full
# backfill runs for many minutes. Progress is polled via /status.
result = garmin_svc.start_sync(g.user_id, creds, days)
return jsonify(result), 202
@bp.route("/auth-status", methods=["GET"])
@require_auth
def auth_status():
"""Whether a stored token exists, so the UI knows to ask for a password."""
return jsonify({"hasToken": garmin_svc.has_token(g.user_id)})
@bp.route("/login", methods=["POST"])
@require_auth
def login():
"""Begin an interactive Garmin login.
Returns immediately with a session id; the login continues in the
background and parks if Garmin asks for a two-factor code. Poll
/login-status and post the code to /mfa.
"""
data = request.get_json(silent=True) or {}
password = data.get("garminPassword") or ""
if not password:
return jsonify({"error": "请提供 Garmin 密码"}), 400
garmin_email = (data.get("garminEmail") or "").strip()
if not garmin_email:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id])
garmin_email = (user or {}).get("garmin_email") or ""
if not garmin_email:
return jsonify({"error": "缺少 Garmin 邮箱"}), 400
session_id = garmin_auth.start_login(g.user_id, garmin_email, password)
return jsonify({"session": session_id, "status": "starting"}), 202
@bp.route("/login-status", methods=["GET"])
@require_auth
def login_status():
session_id = request.args.get("session") or ""
row = garmin_auth.get_session(session_id, g.user_id)
if not row:
return jsonify({"error": "登录会话不存在或已过期"}), 404
return jsonify({
"session": row["id"],
"status": row["status"],
"error": row["error"],
})
@bp.route("/mfa", methods=["POST"])
@require_auth
def submit_mfa():
data = request.get_json(silent=True) or {}
session_id = (data.get("session") or "").strip()
code = (data.get("code") or "").strip()
if not session_id or not code:
return jsonify({"error": "session 与 code 均为必填"}), 400
ok, message = garmin_auth.submit_code(session_id, g.user_id, code)
return jsonify({"ok": ok, "message": message}), (200 if ok else 400)
@bp.route("/login", methods=["DELETE"])
@require_auth
def cancel_login():
session_id = request.args.get("session") or ""
garmin_auth.cancel(session_id, g.user_id)
return jsonify({"ok": True})
@bp.route("/status", methods=["GET"])
@require_auth
def status():
return jsonify(garmin_svc.get_sync_status(g.user_id))
@bp.route("/sync-latest", methods=["POST"])
@require_auth
def sync_latest():
"""Pull just the last couple of days.
Separate from /sync because it is fast enough to wait for (a few seconds
rather than minutes), so the UI can report the result directly instead of
handing back a job to poll.
"""
if not garmin_svc.has_token(g.user_id):
return jsonify({"error": "尚未绑定 Garmin 账号"}), 400
days = request.get_json(silent=True) or {}
try:
window = max(1, min(int(days.get("days", scheduler.SYNC_DAYS)), 7))
except (TypeError, ValueError):
window = scheduler.SYNC_DAYS
return jsonify(garmin_svc.sync_data(g.user_id, {}, days=window))
@bp.route("/auto-sync", methods=["GET"])
@require_auth
def auto_sync_status():
"""When the scheduler last ran, and when this account is next due."""
return jsonify(scheduler.status(g.user_id))
@bp.route("/activities/<activity_id>/detail", methods=["GET"])
@require_auth
def activity_detail(activity_id):
"""Everything stored for one activity: stats, laps, zones, series.
A local read. Detail is fetched during the sync rather than when the user
taps an activity — seven Garmin calls on the critical path of a tap was
slow on a good connection and a timeout on a bad one.
"""
detail = garmin_svc.read_activity_detail(g.user_id, activity_id)
if detail is None:
return jsonify({
"error": "这条运动的详细数据还没同步到本机,去「同步」页拉一次即可。",
"needsSync": True,
}), 404
return jsonify(detail)
@bp.route("/sync-details", methods=["POST"])
@require_auth
def sync_details():
"""Backfill details for activities already stored without one."""
if not garmin_svc.has_token(g.user_id):
return jsonify({"error": "尚未绑定 Garmin 账号"}), 400
body = request.get_json(silent=True) or {}
try:
limit = int(body["limit"]) if body.get("limit") else None
except (TypeError, ValueError):
limit = None
return jsonify(garmin_svc.start_detail_sync(g.user_id, limit)), 202
@bp.route("/sync-details", methods=["GET"])
@require_auth
def sync_details_status():
return jsonify(garmin_svc.detail_sync_status(g.user_id))