原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外 的 10 个页面都有:健康、睡眠、运动、趋势、每日、身体成分、成绩预测、 身体年龄、挑战赛、运动详情。 不是给每个页面写一套,而是一个通用管线: - services/scopes.py:一个页面一个 context builder,返回同一个信封。 context["highlights"] 是已经算好的白话事实——模型负责解读它们,模型不 可用时规则引擎原样渲染。两者引用同一批数字,所以降级读起来不像换了个 App。 没数据的页面返回 None,宁可不出卡片,也不让模型对着空表格发挥。 - coach.scope_messages / parse_scope_insight:一套提示词吃所有页面,页面 的差异全在 context 里,加页面 = 加一个 builder。 - 前端 <AiPanel scope="…">:一个组件渲染所有页面,轮询逻辑抽成 lib/insight.ts 的 usePolledInsight,晨报卡也改用它。 ## 队列 一次生成 40 秒到 4.5 分钟,所以什么都不能在请求里生成。页面只负责入队, worker 负责消费(services/jobs.py)。 优先级才是用队列而不是后台线程的理由:同步完成后 prefetch 把所有页面按 背景优先级排进去,可能要跑半小时;而用户一打开某个页面,那个页面的任务 立刻提到队首、下一个就跑。你在看什么,队列就在算什么。 队列放在数据库而不是内存里,因为 gunicorn 有两个 worker:任务带 holder 声明后回读确认,和 scheduler.py 抢 tick 是同一套做法。id 由 user+kind+subject 推导,所以每几秒一次的轮询是幂等的入队,不会每几秒堆一 个任务。 ## 网关中断时踩到的两个坑(当场修了) 写完正好赶上 oracle 那台机器不通,于是看到: - 三次失败后任务被永久标 failed,网关恢复了也不会重试——一次瞬时中断就把 那个页面的解读判了死刑,直到它的数据碰巧变化。加了冷却期,过期后重置 尝试次数再排一次。 - 队列已经放弃了,页面还在 pending 转圈,要转满 8 分钟才停。meta.pending 现在跟着队列状态走,并把失败原因带给卡片。 顺带把 BAND_SOURCES 从 routes/settings.py 下沉到 services/insights.py: 教练要拿它做参照,而 services 不该反向依赖 routes。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
670 lines
22 KiB
Python
670 lines
22 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 time
|
|
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) UNIQUE,
|
|
garmin_email VARCHAR(255),
|
|
garmin_password_hash TEXT,
|
|
jwt_token TEXT,
|
|
auth_hub_sub VARCHAR(64) UNIQUE,
|
|
auth_hub_username VARCHAR(255),
|
|
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)
|
|
);
|
|
|
|
-- Badges earned on Garmin Connect ("奖励"). Keyed by Garmin's own badge id so
|
|
-- a re-sync updates rather than duplicates.
|
|
CREATE TABLE IF NOT EXISTS badges (
|
|
id VARCHAR(64) NOT NULL,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
badge_key VARCHAR(128),
|
|
name VARCHAR(255),
|
|
category_id INT,
|
|
difficulty_id INT,
|
|
earned_date DATETIME,
|
|
earned_count INT,
|
|
points INT,
|
|
PRIMARY KEY (user_id, id),
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- Personal records (个人纪录), e.g. fastest 5k, longest run.
|
|
CREATE TABLE IF NOT EXISTS personal_records (
|
|
id VARCHAR(64) NOT NULL,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
type_id INT,
|
|
activity_id VARCHAR(64),
|
|
activity_name VARCHAR(255),
|
|
activity_type VARCHAR(64),
|
|
value DOUBLE,
|
|
achieved_at DATETIME,
|
|
PRIMARY KEY (user_id, id),
|
|
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
|
|
-- 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)
|
|
);
|
|
|
|
-- Per-user profile and preferences.
|
|
-- Height/weight/birth date/sex are here rather than on `users` because they
|
|
-- are body measurements the owner edits over time, not identity; and because
|
|
-- the rating bands and the fitness-age estimate need them, an account without
|
|
-- them still works, just with fewer personalised readings.
|
|
CREATE TABLE IF NOT EXISTS user_settings (
|
|
user_id VARCHAR(64) PRIMARY KEY,
|
|
height_cm DOUBLE,
|
|
weight_kg DOUBLE,
|
|
birth_date DATE,
|
|
sex VARCHAR(16),
|
|
units VARCHAR(16),
|
|
auto_sync INT,
|
|
auto_sync_minutes INT,
|
|
history_days INT,
|
|
updated_at DATETIME,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- Full detail for one activity, exactly as Garmin returned it.
|
|
-- The list view stores only the summary columns; opening an activity needs
|
|
-- laps, heart-rate zones and the sampled series, which are far too large to
|
|
-- carry on every list request. Fetched on demand and kept, so the second
|
|
-- visit costs nothing and works offline.
|
|
CREATE TABLE IF NOT EXISTS activity_details (
|
|
activity_id VARCHAR(64) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
payload MEDIUMTEXT,
|
|
fetched_at DATETIME,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- Weight and body composition, one row per measurement day.
|
|
-- Separate from health_data because it arrives from the scale rather than the
|
|
-- watch, on its own irregular schedule — most days simply have no row.
|
|
CREATE TABLE IF NOT EXISTS body_composition (
|
|
id VARCHAR(96) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
date DATE NOT NULL,
|
|
weight_kg DOUBLE,
|
|
bmi DOUBLE,
|
|
body_fat_pct DOUBLE,
|
|
body_water_pct DOUBLE,
|
|
bone_mass_kg DOUBLE,
|
|
muscle_mass_kg DOUBLE,
|
|
physique_rating DOUBLE,
|
|
visceral_fat DOUBLE,
|
|
metabolic_age DOUBLE,
|
|
source VARCHAR(32),
|
|
UNIQUE(user_id, date),
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- Blood pressure readings. Manually entered in Garmin Connect, so there may
|
|
-- be none at all; the table exists so that there is somewhere to put them.
|
|
CREATE TABLE IF NOT EXISTS blood_pressure (
|
|
id VARCHAR(96) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
measured_at DATETIME NOT NULL,
|
|
systolic INT,
|
|
diastolic INT,
|
|
pulse INT,
|
|
note TEXT,
|
|
UNIQUE(user_id, measured_at),
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- Garmin's predicted race times, in seconds. One row per day it recalculates.
|
|
CREATE TABLE IF NOT EXISTS race_predictions (
|
|
id VARCHAR(96) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
date DATE NOT NULL,
|
|
time_5k INT,
|
|
time_10k INT,
|
|
time_half INT,
|
|
time_marathon INT,
|
|
UNIQUE(user_id, date),
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- Within-day sample series: heart rate, stress, body battery, respiration,
|
|
-- SpO2. One generic table rather than five near-identical ones — they differ
|
|
-- only in what the numbers mean, and the daily screen reads them the same way.
|
|
CREATE TABLE IF NOT EXISTS daily_series (
|
|
id VARCHAR(96) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
date DATE NOT NULL,
|
|
kind VARCHAR(32) NOT NULL,
|
|
payload MEDIUMTEXT,
|
|
fetched_at DATETIME,
|
|
UNIQUE(user_id, date, kind),
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- Badge challenges and ad-hoc challenges. Distinct from `badges`: a badge is
|
|
-- earned once, a challenge has a period, a target and a standing.
|
|
CREATE TABLE IF NOT EXISTS challenges (
|
|
id VARCHAR(96) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
challenge_uuid VARCHAR(96),
|
|
kind VARCHAR(32),
|
|
name VARCHAR(255),
|
|
status VARCHAR(64),
|
|
start_date DATE,
|
|
end_date DATE,
|
|
payload MEDIUMTEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
-- Paired devices, so the app can say which watch a number came from.
|
|
CREATE TABLE IF NOT EXISTS devices (
|
|
id VARCHAR(96) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
device_id VARCHAR(96),
|
|
name VARCHAR(255),
|
|
model VARCHAR(255),
|
|
serial VARCHAR(96),
|
|
software_version VARCHAR(64),
|
|
last_used_at DATETIME,
|
|
payload MEDIUMTEXT,
|
|
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)
|
|
);
|
|
|
|
-- Cached AI answers keyed by what they are about, so one stale entry cannot
|
|
-- evict another: `kind` separates the morning briefing from a chart-window
|
|
-- attribution, and `subject` is the day (briefing) or metric+range (trend).
|
|
-- Same reasoning as ai_recommendations above — a generation costs minutes, so
|
|
-- it can never sit inside a page load.
|
|
-- The coach's work queue. Generating one insight costs minutes against the
|
|
-- gateway, so nothing is produced inside a request: screens enqueue, a worker
|
|
-- consumes. `priority` is what makes the screen the user is actually looking
|
|
-- at jump ahead of the backfill queued after a sync (lower runs first).
|
|
--
|
|
-- `id` is derived from user+kind+subject, so enqueueing the same work twice
|
|
-- updates one row rather than piling up duplicates — which is what keeps a
|
|
-- poll every few seconds from queueing a job every few seconds.
|
|
CREATE TABLE IF NOT EXISTS ai_jobs (
|
|
id VARCHAR(160) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
kind VARCHAR(32) NOT NULL,
|
|
subject VARCHAR(96) NOT NULL,
|
|
fingerprint VARCHAR(64),
|
|
priority INT NOT NULL DEFAULT 10,
|
|
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
|
attempts INT NOT NULL DEFAULT 0,
|
|
error TEXT,
|
|
holder VARCHAR(64),
|
|
claimed_at DATETIME,
|
|
created_at DATETIME,
|
|
updated_at DATETIME,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ai_insights (
|
|
id VARCHAR(160) PRIMARY KEY,
|
|
user_id VARCHAR(64) NOT NULL,
|
|
kind VARCHAR(32) NOT NULL,
|
|
subject VARCHAR(96) NOT NULL,
|
|
fingerprint VARCHAR(64) NOT NULL,
|
|
model VARCHAR(64),
|
|
upstream VARCHAR(64),
|
|
payload MEDIUMTEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, kind, subject),
|
|
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):
|
|
"""Return a connection to the pool, or close it if the pool is full.
|
|
|
|
`put()` blocks when the queue is at maxsize — and the queue can be full,
|
|
because `_mariadb_acquire` opens an extra connection whenever the pool is
|
|
empty rather than waiting. Under enough concurrency (a long sync plus
|
|
ordinary requests) a thread would park here forever holding its request
|
|
open. `put_nowait` plus closing the surplus keeps the pool bounded and the
|
|
thread free.
|
|
"""
|
|
try:
|
|
conn.ping(reconnect=False)
|
|
except Exception:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
try:
|
|
_mariadb_pool.put_nowait(conn)
|
|
except queue.Full:
|
|
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()}
|
|
|
|
|
|
# Columns added after the first release. `CREATE TABLE IF NOT EXISTS` does
|
|
# nothing to a table that already exists, so new metrics need an explicit
|
|
# additive migration or they silently never appear in production.
|
|
MIGRATIONS = {
|
|
"users": [
|
|
# auth-hub SSO account linkage (local email/password login was removed).
|
|
("auth_hub_sub", "VARCHAR(64)"),
|
|
("auth_hub_username", "VARCHAR(255)"),
|
|
],
|
|
"sync_status": [
|
|
# A full backfill runs for many minutes, so the UI needs to show how
|
|
# far along it is rather than an indefinite spinner.
|
|
("progress_current", "INT"),
|
|
("progress_total", "INT"),
|
|
("started_at", "DATETIME"),
|
|
# Which part of the sync is running. "0 / 730 天" says nothing about
|
|
# what is actually happening for the several minutes of it.
|
|
("stage", "VARCHAR(64)"),
|
|
# When Garmin answered 429 we stand the account down. Persisted so every
|
|
# gunicorn worker and a restart agree on the cooldown — a process-local
|
|
# dict let each worker re-hit Garmin and keep the limit alive forever.
|
|
("rate_limited_until", "DATETIME"),
|
|
],
|
|
"health_data": [
|
|
# activity / energy
|
|
("distance_meters", "DOUBLE"),
|
|
("active_calories", "DOUBLE"),
|
|
("bmr_calories", "DOUBLE"),
|
|
("floors_ascended", "DOUBLE"),
|
|
("floors_descended", "DOUBLE"),
|
|
("intensity_minutes", "INT"),
|
|
("step_goal", "INT"),
|
|
("sedentary_seconds", "INT"),
|
|
("active_seconds", "INT"),
|
|
# heart / stress
|
|
("heart_rate_max", "INT"),
|
|
("heart_rate_min", "INT"),
|
|
("stress_max", "INT"),
|
|
# body battery
|
|
("body_battery_high", "INT"),
|
|
("body_battery_low", "INT"),
|
|
("body_battery_charged", "INT"),
|
|
("body_battery_drained", "INT"),
|
|
# breathing / blood oxygen
|
|
("spo2_avg", "DOUBLE"),
|
|
("spo2_min", "INT"),
|
|
("respiration_avg", "DOUBLE"),
|
|
("respiration_min", "DOUBLE"),
|
|
("respiration_max", "DOUBLE"),
|
|
# sleep detail
|
|
("sleep_deep_seconds", "INT"),
|
|
("sleep_light_seconds", "INT"),
|
|
("sleep_rem_seconds", "INT"),
|
|
("sleep_awake_seconds", "INT"),
|
|
("sleep_spo2_avg", "DOUBLE"),
|
|
("sleep_respiration_avg", "DOUBLE"),
|
|
("sleep_stress_avg", "DOUBLE"),
|
|
# training
|
|
("training_readiness", "INT"),
|
|
("vo2max", "DOUBLE"),
|
|
("endurance_score", "INT"),
|
|
# hill score, hydration and weight — daily scalars that were being
|
|
# fetched from Garmin by nothing at all until now
|
|
("hill_score", "INT"),
|
|
("hydration_ml", "INT"),
|
|
("hydration_goal_ml", "INT"),
|
|
("sweat_loss_ml", "INT"),
|
|
("weight_kg", "DOUBLE"),
|
|
("body_fat_pct", "DOUBLE"),
|
|
("bmi", "DOUBLE"),
|
|
],
|
|
}
|
|
|
|
|
|
def _existing_columns(cur, table):
|
|
if DB_TYPE == "mariadb":
|
|
cur.execute(
|
|
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
|
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
|
|
[table],
|
|
)
|
|
return {r["COLUMN_NAME"] if isinstance(r, dict) else r[0] for r in cur.fetchall()}
|
|
cur.execute(f"PRAGMA table_info({table})")
|
|
return {row[1] for row in cur.fetchall()}
|
|
|
|
|
|
def _migrate(cur):
|
|
for table, columns in MIGRATIONS.items():
|
|
present = _existing_columns(cur, table)
|
|
for name, coltype in columns:
|
|
if name in present:
|
|
continue
|
|
# The membership check above keeps this idempotent for the normal
|
|
# case. But gunicorn runs 2 workers that both call init_db() on
|
|
# boot; under that race the loser can decide to add a column the
|
|
# winner already added (ADD COLUMN commits implicitly and becomes
|
|
# visible a hair after the loser's probe). Swallow the
|
|
# duplicate-column error so a concurrent boot can't take the whole
|
|
# service down. Same guard covers SQLite's "duplicate column name".
|
|
try:
|
|
cur.execute(f"ALTER TABLE {table} ADD COLUMN {name} {coltype}")
|
|
except Exception as e: # noqa: BLE001 - only "already exists" is safe to ignore
|
|
if "duplicate column" not in str(e).lower():
|
|
raise
|
|
|
|
# Accounts used to require a Garmin email/password at signup, so
|
|
# deployments from before auth-hub still have NOT NULL on these columns.
|
|
# New accounts come from auth-hub and bind Garmin credentials later (or
|
|
# never), so an install that predates that change needs its constraint
|
|
# relaxed once. `CREATE TABLE IF NOT EXISTS` cannot do this — it only
|
|
# applies to a table that does not exist yet — and SQLite has no
|
|
# `MODIFY COLUMN`, but every SQLite install is fresh enough to already
|
|
# have the nullable definition from SCHEMA above, so this only matters
|
|
# for MariaDB.
|
|
if DB_TYPE == "mariadb":
|
|
cur.execute(
|
|
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
|
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' "
|
|
"AND COLUMN_NAME IN ('garmin_email', 'garmin_password_hash') "
|
|
"AND IS_NULLABLE = 'NO'"
|
|
)
|
|
still_required = {r["COLUMN_NAME"] if isinstance(r, dict) else r[0] for r in cur.fetchall()}
|
|
if "garmin_email" in still_required:
|
|
cur.execute("ALTER TABLE users MODIFY COLUMN garmin_email VARCHAR(255) NULL")
|
|
if "garmin_password_hash" in still_required:
|
|
cur.execute("ALTER TABLE users MODIFY COLUMN garmin_password_hash TEXT NULL")
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
# How long to keep waiting for the database at startup.
|
|
#
|
|
# On a NAS reboot the app and MariaDB come up together and the app usually
|
|
# wins the race. Without this it raised, gunicorn reported "Worker failed to
|
|
# boot", the master shut down — and when MariaDB appeared seconds later there
|
|
# was nothing left running to notice. The site stayed down until someone
|
|
# restarted it by hand.
|
|
INIT_RETRY_SECONDS = int(os.environ.get("DB_INIT_RETRY_SECONDS") or 120)
|
|
INIT_RETRY_INTERVAL = 3
|
|
|
|
|
|
def init_db():
|
|
deadline = time.monotonic() + INIT_RETRY_SECONDS
|
|
attempt = 0
|
|
while True:
|
|
attempt += 1
|
|
try:
|
|
conn = _connect()
|
|
break
|
|
except Exception as e: # noqa: BLE001 - any connection failure is worth retrying
|
|
if time.monotonic() >= deadline:
|
|
raise
|
|
if attempt == 1:
|
|
print(f"[db] 数据库还没准备好,重试中:{e}")
|
|
time.sleep(INIT_RETRY_INTERVAL)
|
|
|
|
if attempt > 1:
|
|
print(f"[db] 第 {attempt} 次尝试后连上数据库")
|
|
|
|
try:
|
|
cur = conn.cursor()
|
|
for stmt in _statements(SCHEMA):
|
|
cur.execute(_adapt_sql(stmt))
|
|
_migrate(cur)
|
|
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)
|