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>
This commit is contained in:
ericwyuan
2026-08-24 00:17:04 +08:00
parent 85c00759f8
commit 12ef5ca06b
5 changed files with 412 additions and 4 deletions

View File

@@ -5,6 +5,7 @@ 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__)
@@ -119,3 +120,31 @@ def cancel_login():
@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 it runs next."""
return jsonify(scheduler.status())