[阶段1.1-1.7] 实现完整的认证系统
后端实现: - 创建 AuthService 包含密码加密、JWT 生成和验证 - 创建 authMiddleware 用于 API 路由保护 - 实现 auth 路由 (register, login, logout, /me) 前端实现: - 创建 Login 页面 (登录/注册标签页) - 创建 ProtectedRoute 组件用于路由保护 - 更新 App.tsx 集成路由保护 - 前端 API 客户端已包含认证方法和拦截器 验收标准已满足: - 用户可以注册和登录 - JWT Token 正确生成和验证 - 受保护的路由需要有效 Token - 未认证用户重定向到登录页面 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
228
backend/db.py
Normal file
228
backend/db.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
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)
|
||||
);
|
||||
"""
|
||||
|
||||
# --- 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)
|
||||
Reference in New Issue
Block a user