Files
GarminHealthLab/backend/db.py
ericwyuan af0604bce4 fix(garmin): 两步验证账号同步报 EOFError,改用令牌登录
现象:网页触发同步报 "EOF when reading a line"。

原因:garth 的默认 MFA 提示是 input(),向 stdin 索取验证码。
gunicorn worker 没有 stdin,于是抛出 EOFError——错误信息本身
完全没提到 MFA,看不出该做什么。

方案:把"输验证码"和"日常同步"拆开。
- 新增 garmin_tokens 表存 garth 令牌(Client.dumps/loads 序列化)
- garmin_login.py:在终端里跑一次,可正常输入验证码,
  成功后令牌存库
- _connect() 优先加载令牌并 refresh_oauth2(),命中则完全跳过登录,
  既不需要密码也不需要验证码(令牌有效期约一年)
- 无令牌且密码登录撞上 MFA 时,抛 MFARequired 并给出具体该执行
  哪条命令,而不是把 EOFError 原样抛给用户

接口:
- GET /api/garmin/auth-status 返回是否已有令牌
- /api/garmin/sync 在已有令牌时不再强制要求密码

前端:
- 有令牌时隐藏密码输入框,提示无需密码
- 同步返回 mfaRequired 时,展示需要在 NAS 上执行的具体命令
- 同步请求超时放宽到 180s(一周的天数 + 运动是多次上游调用)
- 成功消息补上运动记录条数

tests (test_garmin_sync.py 新增 12 条,共 35):
- 令牌存取、覆盖不累积、按用户隔离
- 有令牌时绝不调用 login()
- MFA 的 EOFError 转成带操作指引的 MFARequired
- 普通 401 不会被误标成 mfaRequired
- 无令牌且无密码时给出明确拒绝

NAS 真机: 252 passed

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 19:57:40 +08:00

258 lines
6.9 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)
);
-- 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)