设置 (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>
173 lines
5.9 KiB
Python
173 lines
5.9 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 Garmin holds for one activity: stats, laps, zones, series.
|
|
|
|
The first open goes out to Garmin and takes a few seconds; after that it
|
|
is served from the stored copy. `?refresh=1` forces a refetch.
|
|
"""
|
|
if not garmin_svc.has_token(g.user_id):
|
|
return jsonify({"error": "尚未绑定 Garmin 账号"}), 400
|
|
|
|
refresh = request.args.get("refresh") in ("1", "true", "yes")
|
|
try:
|
|
return jsonify(
|
|
garmin_svc.get_activity_detail(g.user_id, activity_id, refresh=refresh)
|
|
)
|
|
except garmin_svc.MFARequired as e:
|
|
return jsonify({"error": str(e)}), 401
|
|
except Exception as e: # noqa: BLE001 - surfaced to the user verbatim
|
|
return jsonify({"error": garmin_svc.describe(e)}), 502
|