背景:命令行方案要求用户在电脑前开交互式终端,实际不可行。 改为在网页里完成 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>
275 lines
7.6 KiB
Python
275 lines
7.6 KiB
Python
"""
|
|
Pluggable data layer for Garmin Health Lab.
|
|
|
|
Supports both SQLite (stdlib, local dev) and MariaDB (PyMySQL, NAS production)
|
|
through a single unified API:
|
|
|
|
init_db() -> create tables if missing
|
|
execute(sql, params) -> INSERT/UPDATE/DELETE, returns {id, changes}
|
|
query_one(sql, params) -> one row as dict or None
|
|
query_all(sql, params) -> list of row dicts
|
|
|
|
Both backends accept `?` placeholders; the SQL is translated to `%s` for
|
|
MariaDB automatically. Upserts must use backend-specific SQL (see services).
|
|
"""
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
import queue
|
|
import datetime
|
|
|
|
from config import (
|
|
DB_TYPE,
|
|
SQLITE_PATH,
|
|
MARIADB_SOCKET,
|
|
MARIADB_HOST,
|
|
MARIADB_PORT,
|
|
MARIADB_USER,
|
|
MARIADB_PASSWORD,
|
|
MARIADB_DATABASE,
|
|
)
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id VARCHAR(64) PRIMARY KEY,
|
|
email VARCHAR(255) NOT NULL UNIQUE,
|
|
garmin_email VARCHAR(255) NOT NULL,
|
|
garmin_password_hash TEXT NOT NULL,
|
|
jwt_token TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS health_data (
|
|
id VARCHAR(64) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
date DATE NOT NULL,
|
|
steps INT,
|
|
heart_rate INT,
|
|
heart_rate_variability DOUBLE,
|
|
blood_pressure_systolic INT,
|
|
blood_pressure_diastolic INT,
|
|
sleep_duration INT,
|
|
sleep_quality DOUBLE,
|
|
stress INT,
|
|
calories_burned DOUBLE,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, date),
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS activities (
|
|
id VARCHAR(64) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
activity_type VARCHAR(255) NOT NULL,
|
|
start_time DATETIME NOT NULL,
|
|
end_time DATETIME NOT NULL,
|
|
duration INT,
|
|
distance DOUBLE,
|
|
calories DOUBLE,
|
|
heart_rate_average INT,
|
|
heart_rate_max INT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sync_status (
|
|
user_id VARCHAR(64) PRIMARY KEY,
|
|
last_sync_time DATETIME,
|
|
status VARCHAR(32) DEFAULT 'idle',
|
|
last_error TEXT,
|
|
records_synced INT DEFAULT 0,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- Garmin OAuth tokens, obtained once through an interactive login.
|
|
-- Garmin accounts with two-factor auth cannot be logged into unattended: the
|
|
-- library asks for an MFA code on stdin, which a gunicorn worker does not
|
|
-- have. Storing the resulting tokens lets every later sync skip the login
|
|
-- entirely (they stay valid for roughly a year).
|
|
CREATE TABLE IF NOT EXISTS garmin_tokens (
|
|
user_id VARCHAR(64) PRIMARY KEY,
|
|
token TEXT NOT NULL,
|
|
garmin_email VARCHAR(255),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- 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
|
|
-- may land on a different gunicorn worker. The handoff therefore goes through
|
|
-- the database rather than process memory.
|
|
-- Holds no password: that stays in the waiting thread's memory only.
|
|
CREATE TABLE IF NOT EXISTS garmin_mfa_sessions (
|
|
id VARCHAR(64) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
status VARCHAR(32) NOT NULL,
|
|
code VARCHAR(16),
|
|
error TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- One cached LLM answer per user. Generating one takes minutes against a
|
|
-- large reasoning model, which is far too slow to sit in a page load, so the
|
|
-- result is stored and reused until the underlying data changes.
|
|
-- `fingerprint` identifies the health data the advice was derived from.
|
|
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
|
user_id VARCHAR(64) PRIMARY KEY,
|
|
fingerprint VARCHAR(64) NOT NULL,
|
|
model VARCHAR(64),
|
|
upstream VARCHAR(64),
|
|
days INT,
|
|
payload TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
"""
|
|
|
|
# --- MariaDB pool (lazy) ----------------------------------------------------
|
|
_mariadb_pool = None
|
|
_pool_lock = threading.Lock()
|
|
|
|
|
|
def _new_mariadb_conn():
|
|
import pymysql
|
|
from pymysql.cursors import DictCursor
|
|
|
|
kwargs = dict(
|
|
user=MARIADB_USER,
|
|
password=MARIADB_PASSWORD,
|
|
database=MARIADB_DATABASE,
|
|
charset="utf8mb4",
|
|
autocommit=True,
|
|
cursorclass=DictCursor,
|
|
connect_timeout=10,
|
|
)
|
|
if MARIADB_SOCKET:
|
|
kwargs["unix_socket"] = MARIADB_SOCKET
|
|
else:
|
|
kwargs["host"] = MARIADB_HOST
|
|
kwargs["port"] = MARIADB_PORT
|
|
return pymysql.connect(**kwargs)
|
|
|
|
|
|
def _mariadb_acquire():
|
|
global _mariadb_pool
|
|
if _mariadb_pool is None:
|
|
with _pool_lock:
|
|
if _mariadb_pool is None:
|
|
_mariadb_pool = queue.Queue(maxsize=10)
|
|
for _ in range(10):
|
|
_mariadb_pool.put(_new_mariadb_conn())
|
|
try:
|
|
return _mariadb_pool.get(block=False)
|
|
except queue.Empty:
|
|
return _new_mariadb_conn()
|
|
|
|
|
|
def _mariadb_release(conn):
|
|
try:
|
|
conn.ping(reconnect=False)
|
|
_mariadb_pool.put(conn)
|
|
except Exception:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# --- SQLite connection ------------------------------------------------------
|
|
def _sqlite_connect():
|
|
data_dir = os.path.dirname(SQLITE_PATH)
|
|
if data_dir and not os.path.exists(data_dir):
|
|
os.makedirs(data_dir, exist_ok=True)
|
|
conn = sqlite3.connect(SQLITE_PATH, isolation_level=None)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
return conn
|
|
|
|
|
|
def _connect():
|
|
if DB_TYPE == "mariadb":
|
|
return _mariadb_acquire()
|
|
return _sqlite_connect()
|
|
|
|
|
|
def _disconnect(conn):
|
|
if DB_TYPE == "mariadb":
|
|
_mariadb_release(conn)
|
|
else:
|
|
conn.close()
|
|
|
|
|
|
def _adapt_sql(sql):
|
|
# pymysql uses %s placeholders; sqlite3 uses ?. Business code writes ?.
|
|
return sql.replace("?", "%s") if DB_TYPE == "mariadb" else sql
|
|
|
|
|
|
def _serialize(value):
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (datetime.datetime, datetime.date)):
|
|
return value.isoformat()
|
|
return value
|
|
|
|
|
|
def _row_to_dict(row):
|
|
if row is None:
|
|
return None
|
|
if isinstance(row, dict):
|
|
return {k: _serialize(v) for k, v in row.items()}
|
|
return {k: _serialize(row[k]) for k in row.keys()}
|
|
|
|
|
|
# --- Public API -------------------------------------------------------------
|
|
def init_db():
|
|
conn = _connect()
|
|
try:
|
|
cur = conn.cursor()
|
|
for stmt in SCHEMA.split(";"):
|
|
stmt = stmt.strip()
|
|
if not stmt:
|
|
continue
|
|
cur.execute(_adapt_sql(stmt))
|
|
finally:
|
|
_disconnect(conn)
|
|
|
|
|
|
def execute(sql, params=None):
|
|
params = params or []
|
|
conn = _connect()
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(_adapt_sql(sql), params)
|
|
return {"id": cur.lastrowid, "changes": cur.rowcount}
|
|
finally:
|
|
_disconnect(conn)
|
|
|
|
|
|
def query_one(sql, params=None):
|
|
params = params or []
|
|
conn = _connect()
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(_adapt_sql(sql), params)
|
|
return _row_to_dict(cur.fetchone())
|
|
finally:
|
|
_disconnect(conn)
|
|
|
|
|
|
def query_all(sql, params=None):
|
|
params = params or []
|
|
conn = _connect()
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute(_adapt_sql(sql), params)
|
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
|
finally:
|
|
_disconnect(conn)
|