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

@@ -128,6 +128,17 @@ CREATE TABLE IF NOT EXISTS personal_records (
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Coordination for work that must happen once per interval regardless of how
-- many gunicorn workers are running. A worker claims a job by writing its
-- row, and the others see a fresh claim and stand down. Without this the
-- hourly sync would fire once per worker.
CREATE TABLE IF NOT EXISTS job_locks (
name VARCHAR(64) PRIMARY KEY,
holder VARCHAR(64),
claimed_at DATETIME,
last_run_at DATETIME
);
-- Rendezvous for the interactive MFA login.
-- garth asks for the code through a *blocking* callback, so the login parks in
-- a background thread while the code arrives in a separate HTTP request that
@@ -334,14 +345,25 @@ def _migrate(cur):
# --- Public API -------------------------------------------------------------
def _statements(schema):
"""Split a schema script into statements.
Comments are stripped first: splitting the raw text on ';' would cut a
comment that happens to contain one in half and hand the remainder to the
database as SQL.
"""
lines = [ln for ln in schema.splitlines() if not ln.strip().startswith("--")]
for stmt in "\n".join(lines).split(";"):
stmt = stmt.strip()
if stmt:
yield stmt
def init_db():
conn = _connect()
try:
cur = conn.cursor()
for stmt in SCHEMA.split(";"):
stmt = stmt.strip()
if not stmt:
continue
for stmt in _statements(SCHEMA):
cur.execute(_adapt_sql(stmt))
_migrate(cur)
finally: