[阶段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:
ericwyuan
2026-08-23 12:25:33 +08:00
parent 6b05d04773
commit 3b2d0697f0
28 changed files with 1970 additions and 75 deletions

23
backend/.env.example Normal file
View File

@@ -0,0 +1,23 @@
# --- Server ---
PORT=5000
# --- Database: sqlite (default) or mariadb ---
DB_TYPE=sqlite
# SQLite file (used when DB_TYPE=sqlite)
DATABASE_PATH=./data/health.db
# MariaDB (used when DB_TYPE=mariadb) — runs on the NAS
# MARIADB_SOCKET=/run/mysqld/mysqld10.sock
# MARIADB_HOST=127.0.0.1
# MARIADB_PORT=3306
# MARIADB_USER=root
# MARIADB_PASSWORD=your_nas_mariadb_root_password
# MARIADB_DATABASE=garmin_health_lab
# --- Auth ---
# CHANGE THIS in production! Used to sign JWTs (7-day expiry by default).
JWT_SECRET=dev_secret_change_me
JWT_EXPIRY_DAYS=7
# --- CORS (comma-separated allowed front-end origins) ---
CORS_ORIGIN=http://localhost:3000,http://localhost:5173

50
backend/app.py Normal file
View File

@@ -0,0 +1,50 @@
"""
Flask application factory for Garmin Health Lab.
Run directly (`python app.py`) for development, or serve with Gunicorn:
gunicorn wsgi:app -b 0.0.0.0:5000
"""
from flask import Flask, jsonify
from flask_cors import CORS
import db
from config import CORS_ORIGINS, PORT
from routes import auth, garmin, health, analysis
def create_app():
app = Flask(__name__)
CORS(app, resources={r"/api/*": {"origins": CORS_ORIGINS}}, supports_credentials=True)
# Create tables once at startup (idempotent).
db.init_db()
@app.route("/")
def index():
return jsonify({"name": "Garmin Health Lab API", "version": "1.0.0"})
@app.route("/api/health/status")
def health_status():
return jsonify({"status": "ok", "db": db.DB_TYPE})
app.register_blueprint(auth.bp, url_prefix="/api/auth")
app.register_blueprint(garmin.bp, url_prefix="/api/garmin")
app.register_blueprint(health.bp, url_prefix="/api/health")
app.register_blueprint(analysis.bp, url_prefix="/api/analysis")
@app.errorhandler(404)
def not_found(_e):
return jsonify({"error": "not found"}), 404
@app.errorhandler(500)
def server_error(_e):
return jsonify({"error": "internal error"}), 500
return app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=PORT, debug=False)

90
backend/auth.py Normal file
View File

@@ -0,0 +1,90 @@
"""
Authentication helpers: scrypt password hashing, JWT signing/verification,
and the require_auth decorator used by route blueprints.
Password hashing mirrors the original Node implementation exactly:
salt (16 random bytes, hex) : scrypt(password, salt, n=16384, r=8, p=1, dklen=64, hex)
"""
import hashlib
import hmac
import os
import datetime
import jwt
from flask import request, g, jsonify
from functools import wraps
from config import JWT_SECRET, JWT_EXPIRY_DAYS
class AuthError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
def hash_password(password: str) -> str:
salt = os.urandom(16)
derived = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=16384,
r=8,
p=1,
dklen=64,
)
return f"{salt.hex()}:{derived.hex()}"
def verify_password(password: str, stored: str) -> bool:
if not stored or ":" not in stored:
return False
salt_hex, hash_hex = stored.split(":", 1)
try:
salt = bytes.fromhex(salt_hex)
except ValueError:
return False
derived = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=16384,
r=8,
p=1,
dklen=64,
)
return hmac.compare_digest(derived.hex(), hash_hex)
def sign_token(user_id: str) -> str:
now = datetime.datetime.utcnow()
payload = {
"sub": user_id,
"iat": now,
"exp": now + datetime.timedelta(days=JWT_EXPIRY_DAYS),
}
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
def verify_token(token: str) -> dict:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return {"user_id": payload["sub"]}
def require_auth(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "missing or malformed Authorization header"}), 401
token = auth[7:].strip()
try:
data = verify_token(token)
except jwt.ExpiredSignatureError:
return jsonify({"error": "token expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "invalid token"}), 401
g.user_id = data["user_id"]
return f(*args, **kwargs)
return wrapper

45
backend/config.py Normal file
View File

@@ -0,0 +1,45 @@
"""
Central configuration for the Garmin Health Lab Flask backend.
Reads settings from a `.env` file (backend/.env) and the process environment.
The same code runs against SQLite (local dev) or MariaDB (NAS production)
by switching DB_TYPE — business code never branches on the backend.
"""
import os
from dotenv import load_dotenv
# Load .env from the backend directory (falls back to cwd / parent search).
_BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
_ENV_PATH = os.path.join(_BACKEND_DIR, ".env")
if os.path.exists(_ENV_PATH):
load_dotenv(_ENV_PATH)
else:
load_dotenv() # walk up from cwd
# --- Database selection -----------------------------------------------------
DB_TYPE = (os.environ.get("DB_TYPE") or "sqlite").lower()
# SQLite (default, zero-config local development)
SQLITE_PATH = os.environ.get("DATABASE_PATH") or os.path.join(
_BACKEND_DIR, "data", "health.db"
)
# MariaDB (production, runs on the NAS)
MARIADB_SOCKET = os.environ.get("MARIADB_SOCKET") or ""
MARIADB_HOST = os.environ.get("MARIADB_HOST") or "127.0.0.1"
MARIADB_PORT = int(os.environ.get("MARIADB_PORT") or 3306)
MARIADB_USER = os.environ.get("MARIADB_USER") or "root"
MARIADB_PASSWORD = os.environ.get("MARIADB_PASSWORD") or ""
MARIADB_DATABASE = os.environ.get("MARIADB_DATABASE") or "garmin_health_lab"
# --- Auth -------------------------------------------------------------------
JWT_SECRET = os.environ.get("JWT_SECRET") or "dev_secret_change_me"
JWT_EXPIRY_DAYS = int(os.environ.get("JWT_EXPIRY_DAYS") or 7)
# --- Server -----------------------------------------------------------------
PORT = int(os.environ.get("PORT") or 5000)
# Comma-separated list of allowed front-end origins (CORS).
_CORS_RAW = os.environ.get("CORS_ORIGIN") or "http://localhost:3000,http://localhost:5173"
CORS_ORIGINS = [o.strip() for o in _CORS_RAW.split(",") if o.strip()]

228
backend/db.py Normal file
View 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)

8
backend/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
Flask>=3.0,<4.0
flask-cors>=4.0
PyMySQL>=1.1
PyJWT>=2.8
python-dotenv>=1.0
gunicorn>=21.2
# Optional — only needed to run live Garmin syncs:
# garminconnect>=0.13

View File

@@ -0,0 +1,7 @@
"""API route blueprints for Garmin Health Lab."""
from . import auth
from . import garmin
from . import health
from . import analysis
__all__ = ["auth", "garmin", "health", "analysis"]

View File

@@ -0,0 +1,22 @@
"""Analysis routes: trends + recommendations."""
from flask import Blueprint, request, g, jsonify
from auth import require_auth
from services import analysis as analysis_svc
bp = Blueprint("analysis", __name__)
@bp.route("/trends", methods=["GET"])
@require_auth
def trends():
metric = request.args.get("metricType", "steps")
s = request.args.get("startDate")
e = request.args.get("endDate")
return jsonify(analysis_svc.get_trends(metric, g.user_id, s, e))
@bp.route("/recommendations", methods=["GET"])
@require_auth
def recommendations():
return jsonify(analysis_svc.get_recommendations(g.user_id))

59
backend/routes/auth.py Normal file
View File

@@ -0,0 +1,59 @@
"""Auth routes: register / login / logout / refresh."""
import uuid
from flask import Blueprint, request, g, jsonify
from auth import hash_password, verify_password, sign_token, require_auth
from db import execute, query_one
bp = Blueprint("auth", __name__)
@bp.route("/register", methods=["POST"])
def register():
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip()
garmin_email = (data.get("garminEmail") or "").strip()
garmin_password = data.get("garminPassword") or ""
if not email or not garmin_email or not garmin_password:
return jsonify({"error": "email, garminEmail, garminPassword 均为必填"}), 400
if query_one("SELECT id FROM users WHERE email = ?", [email]):
return jsonify({"error": "该邮箱已注册"}), 409
uid = str(uuid.uuid4())
token = sign_token(uid)
execute(
"INSERT INTO users (id, email, garmin_email, garmin_password_hash, jwt_token) "
"VALUES (?, ?, ?, ?, ?)",
[uid, email, garmin_email, hash_password(garmin_password), token],
)
return jsonify({"id": uid, "email": email, "token": token}), 201
@bp.route("/login", methods=["POST"])
def login():
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip()
password = data.get("password") or ""
user = query_one("SELECT * FROM users WHERE email = ?", [email])
if not user or not verify_password(password, user["garmin_password_hash"]):
return jsonify({"error": "邮箱或密码错误"}), 401
token = sign_token(user["id"])
execute("UPDATE users SET jwt_token = ? WHERE id = ?", [token, user["id"]])
return jsonify({"id": user["id"], "email": user["email"], "token": token})
@bp.route("/logout", methods=["POST"])
@require_auth
def logout():
execute("UPDATE users SET jwt_token = NULL WHERE id = ?", [g.user_id])
return jsonify({"message": "ok"})
@bp.route("/refresh", methods=["POST"])
@require_auth
def refresh():
token = sign_token(g.user_id)
execute("UPDATE users SET jwt_token = ? WHERE id = ?", [token, g.user_id])
return jsonify({"token": token})

45
backend/routes/garmin.py Normal file
View File

@@ -0,0 +1,45 @@
"""Garmin routes: trigger a sync and read sync status."""
from flask import Blueprint, request, g, jsonify
from auth import require_auth
from db import query_one
from services import garmin as garmin_svc
bp = Blueprint("garmin", __name__)
@bp.route("/sync", methods=["POST"])
@require_auth
def sync():
data = request.get_json(silent=True) or {}
creds = {
"garminEmail": (data.get("garminEmail") or "").strip(),
"garminPassword": data.get("garminPassword") or "",
}
# Fall back to the stored Garmin email when only a password is supplied.
if not creds["garminEmail"]:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id])
if user and user.get("garmin_email"):
creds["garminEmail"] = user["garmin_email"]
# The stored Garmin password is only kept as a hash, so it cannot be
# recovered. A live sync requires the plaintext password in the body.
if not creds["garminPassword"]:
return (
jsonify({
"status": "error",
"recordsSynced": 0,
"message": "需要 Garmin 密码以执行同步,请在请求体中提供 garminPassword"
"(密码仅作哈希存储,无法还原)。",
}),
400,
)
result = garmin_svc.sync_data(g.user_id, creds)
return jsonify(result)
@bp.route("/status", methods=["GET"])
@require_auth
def status():
return jsonify(garmin_svc.get_sync_status(g.user_id))

46
backend/routes/health.py Normal file
View File

@@ -0,0 +1,46 @@
"""Health data routes: summary / steps / heart-rate / sleep / activities."""
from flask import Blueprint, request, g, jsonify
from auth import require_auth
from services import health as health_svc
bp = Blueprint("health", __name__)
def _range():
return request.args.get("startDate"), request.args.get("endDate")
@bp.route("/summary", methods=["GET"])
@require_auth
def summary():
s, e = _range()
return jsonify(health_svc.get_summary(g.user_id, s, e))
@bp.route("/steps", methods=["GET"])
@require_auth
def steps():
s, e = _range()
return jsonify(health_svc.get_steps(g.user_id, s, e))
@bp.route("/heart-rate", methods=["GET"])
@require_auth
def heart_rate():
s, e = _range()
return jsonify(health_svc.get_heart_rate(g.user_id, s, e))
@bp.route("/sleep", methods=["GET"])
@require_auth
def sleep():
s, e = _range()
return jsonify(health_svc.get_sleep(g.user_id, s, e))
@bp.route("/activities", methods=["GET"])
@require_auth
def activities():
s, e = _range()
return jsonify(health_svc.get_activities(g.user_id, s, e))

View File

@@ -0,0 +1,6 @@
"""Business logic services for Garmin Health Lab."""
from . import health
from . import analysis
from . import garmin
__all__ = ["health", "analysis", "garmin"]

View File

@@ -0,0 +1,119 @@
"""
Analysis service: metric trends + a rule-based recommendation engine.
Replicates the original Node AnalysisService logic. Averages are computed over
the most recent 14 days of available daily summaries.
"""
from services import health
from db import query_all
METRIC_COLUMNS = {
"steps": "steps",
"heart_rate": "heart_rate",
"sleep_duration": "sleep_duration",
"sleep_quality": "sleep_quality",
"stress": "stress",
"calories_burned": "calories_burned",
}
def get_trends(metric, user_id, start=None, end=None):
column = METRIC_COLUMNS.get(metric, "steps")
params = [user_id]
sql = "WHERE user_id = ?"
if start:
sql += " AND date >= ?"
params.append(start)
if end:
sql += " AND date <= ?"
params.append(end)
rows = query_all(
f"SELECT date, {column} AS value FROM health_data {sql} "
f"AND {column} IS NOT NULL ORDER BY date ASC",
params,
)
return [{"date": r["date"], "value": r["value"]} for r in rows]
def get_recommendations(user_id):
recent = health.get_summary(user_id)
last14 = recent[-14:]
recs = []
if not last14:
return [
{
"id": "no-data",
"category": "数据",
"recommendation": "暂无健康数据,请先同步你的 Garmin 设备数据。",
"priority": "low",
"basedOn": [],
}
]
avg = lambda key: sum((r.get(key) or 0) for r in last14) / len(last14)
avg_steps = avg("steps")
sleep_rows = [r["sleep"]["duration"] for r in last14 if r.get("sleep")]
avg_sleep = sum(sleep_rows) / len(sleep_rows) if sleep_rows else 0
avg_stress = avg("stress")
avg_rhr = avg("heartRate")
avg_hrv = avg("heartRateVariability")
if avg_steps > 0 and avg_steps < 8000:
recs.append({
"id": "steps",
"category": "运动",
"recommendation": f"{len(last14)} 天日均步数约 {round(avg_steps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。",
"priority": "medium",
"basedOn": ["steps"],
})
if avg_sleep > 0 and avg_sleep < 7:
recs.append({
"id": "sleep",
"category": "睡眠",
"recommendation": f"日均睡眠约 {avg_sleep:.1f} 小时,偏少。建议固定就寝时间,目标 7-8 小时。",
"priority": "high",
"basedOn": ["sleep_duration"],
})
if avg_stress > 0 and avg_stress > 50:
recs.append({
"id": "stress",
"category": "压力",
"recommendation": f"平均压力指数 {round(avg_stress)} 偏高,建议安排放松活动(冥想/散步)。",
"priority": "high",
"basedOn": ["stress"],
})
if avg_rhr > 0 and avg_rhr > 65:
recs.append({
"id": "rhr",
"category": "心肺",
"recommendation": f"静息心率约 {round(avg_rhr)} bpm 偏高,规律有氧运动有助于改善心肺功能。",
"priority": "medium",
"basedOn": ["heart_rate"],
})
if avg_hrv > 0 and avg_hrv < 40:
recs.append({
"id": "hrv",
"category": "恢复",
"recommendation": f"心率变异性HRV{round(avg_hrv)} ms 偏低,注意恢复与休息,避免过度训练。",
"priority": "low",
"basedOn": ["heart_rate_variability"],
})
if not recs:
recs.append({
"id": "good",
"category": "状态",
"recommendation": "近期各项指标良好,保持当前作息与运动习惯即可。",
"priority": "low",
"basedOn": [],
})
order = {"high": 0, "medium": 1, "low": 2}
recs.sort(key=lambda r: order[r["priority"]])
return recs

150
backend/services/garmin.py Normal file
View File

@@ -0,0 +1,150 @@
"""
Garmin sync service.
Pulls up to 7 days of daily summaries + activities through the `garminconnect`
library and upserts them. The library and real Garmin credentials are required
to actually run a sync; without them the endpoint reports a clear error instead
of crashing (mirrors the original Node behaviour).
Garmin credentials: the app only stores a scrypt *hash* of the Garmin password
(so it cannot be recovered), therefore a live sync needs the plaintext
garminEmail/garminPassword supplied in the request body.
"""
import datetime
from db import execute, query_one, query_all
from config import DB_TYPE
from services import health
def _set_sync_status(user_id, status, now, **fields):
cols = ["user_id", "status", "last_sync_time"] + list(fields.keys())
placeholders = ", ".join(["?"] * len(cols))
if DB_TYPE == "mariadb":
updates = ", ".join(
f"{c}=VALUES({c})" for c in cols if c != "user_id"
)
sql = (
f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {updates}"
)
else:
updates = ", ".join(
f"{c}=excluded.{c}" for c in cols if c != "user_id"
)
sql = (
f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON CONFLICT(user_id) DO UPDATE SET {updates}"
)
params = [user_id, status, now] + list(fields.values())
execute(sql, params)
def get_sync_status(user_id):
row = query_one("SELECT * FROM sync_status WHERE user_id = ?", [user_id])
if not row:
return {
"status": "idle",
"lastSyncTime": None,
"recordsSynced": 0,
"lastError": None,
}
return {
"status": row["status"],
"lastSyncTime": row["last_sync_time"],
"recordsSynced": row["records_synced"],
"lastError": row["last_error"],
}
def sync_data(user_id, creds):
now = datetime.datetime.utcnow().isoformat()
_set_sync_status(user_id, "syncing", now, records_synced=0)
try:
try:
from garminconnect import Garmin
except ImportError:
raise RuntimeError(
"GARMIN_LIB_MISSING: 请先运行 `pip install garminconnect` 以启用同步"
)
client = Garmin(email=creds["garminEmail"], password=creds["garminPassword"])
client.login()
records_synced = 0
for i in range(7):
d = datetime.datetime.utcnow() - datetime.timedelta(days=i)
date_str = d.strftime("%Y-%m-%d")
try:
daily = client.get_user_summary(date_str)
if daily:
sleep_sec = (daily.get("sleep") or {}).get("sleepingSeconds") or daily.get(
"sleepingSeconds"
)
health.upsert_health_daily(
user_id,
{
"date": date_str,
"steps": daily.get("steps"),
"heartRate": daily.get("restingHeartRate")
or daily.get("averageHeartRate"),
"heartRateVariability": daily.get("hrv")
or daily.get("heartRateVariability"),
"sleepDuration": round(sleep_sec / 3600, 1) if sleep_sec else None,
"sleepQuality": (daily.get("sleep") or {}).get("sleepQuality"),
"stress": (daily.get("stress") or {}).get("average")
or daily.get("averageStress"),
"caloriesBurned": (daily.get("calories") or {}).get("total")
or daily.get("totalCalories"),
},
)
records_synced += 1
activities = client.get_activities(date_str) or []
for a in activities or []:
start = a.get("startTimeLocal") or a.get("startTime")
start_ms = start and datetime.datetime.strptime(
start, "%Y-%m-%dT%H:%M:%S" if "T" in (start or "") else "%Y-%m-%d %H:%M:%S"
).timestamp() if start else None
health.insert_activity(
user_id,
{
"activityType": (a.get("activityType") or {}).get("typeKey")
or a.get("type")
or "unknown",
"startTime": start,
"endTime": (
start
if start_ms is None or not a.get("duration")
else datetime.datetime.utcfromtimestamp(
start_ms + (a.get("duration") or 0)
).isoformat()
),
"duration": a.get("duration"),
"distance": a.get("distance"),
"calories": a.get("calories"),
"heartRateAverage": a.get("averageHR"),
"heartRateMax": a.get("maxHR"),
},
)
except Exception:
# skip a single bad day and continue
continue
_set_sync_status(user_id, "idle", now, records_synced=records_synced)
return {
"status": "success",
"recordsSynced": records_synced,
"message": f"同步完成,新增/更新 {records_synced} 天数据",
"lastSyncTime": now,
}
except Exception as e:
message = str(e)
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
return {
"status": "error",
"recordsSynced": 0,
"message": message,
"lastSyncTime": now,
}

153
backend/services/health.py Normal file
View File

@@ -0,0 +1,153 @@
"""
Health data service: read endpoints + upsert helpers used by the Garmin sync.
Mirrors the original Node HealthService, including the camelCase JSON mapping.
Upserts use backend-specific SQL because SQLite does not support
`ON DUPLICATE KEY UPDATE` (it uses `ON CONFLICT ... DO UPDATE`).
"""
import uuid
from db import execute, query_one, query_all
from config import DB_TYPE
def _range_sql(user_id, start=None, end=None):
params = [user_id]
sql = "WHERE user_id = ?"
if start:
sql += " AND date >= ?"
params.append(start)
if end:
sql += " AND date <= ?"
params.append(end)
return sql, params
def get_summary(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
"SELECT date, steps, heart_rate, heart_rate_variability, "
"sleep_duration, sleep_quality, stress, calories_burned "
f"FROM health_data {sql} ORDER BY date ASC",
params,
)
return [
{
"date": r["date"],
"steps": r["steps"],
"heartRate": r["heart_rate"],
"heartRateVariability": r["heart_rate_variability"],
"sleep": (
{"duration": r["sleep_duration"], "quality": r["sleep_quality"]}
if r["sleep_duration"] is not None
else None
),
"stress": r["stress"],
"caloriesBurned": r["calories_burned"],
}
for r in rows
]
def get_steps(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
f"SELECT date, steps FROM health_data {sql} AND steps IS NOT NULL ORDER BY date ASC",
params,
)
return [{"date": r["date"], "steps": r["steps"]} for r in rows]
def get_heart_rate(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
f"SELECT date, heart_rate, heart_rate_variability FROM health_data {sql} "
"AND heart_rate IS NOT NULL ORDER BY date ASC",
params,
)
return [
{
"date": r["date"],
"heartRate": r["heart_rate"],
"heartRateVariability": r["heart_rate_variability"],
}
for r in rows
]
def get_sleep(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
f"SELECT date, sleep_duration, sleep_quality FROM health_data {sql} "
"AND sleep_duration IS NOT NULL ORDER BY date ASC",
params,
)
return [
{"date": r["date"], "duration": r["sleep_duration"], "quality": r["sleep_quality"]}
for r in rows
]
def get_activities(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
"SELECT id, activity_type, start_time, end_time, duration, distance, "
"calories, heart_rate_average, heart_rate_max "
f"FROM activities {sql} ORDER BY start_time DESC",
params,
)
return rows
def upsert_health_daily(user_id, record):
hid = f"{user_id}-{record['date']}"
cols = [
"id", "user_id", "date", "steps", "heart_rate",
"heart_rate_variability", "blood_pressure_systolic",
"blood_pressure_diastolic", "sleep_duration", "sleep_quality",
"stress", "calories_burned",
]
placeholders = ", ".join(["?"] * len(cols))
vals = [
hid, user_id, record.get("date"), record.get("steps"),
record.get("heartRate"), record.get("heartRateVariability"),
record.get("bloodPressureSystolic"), record.get("bloodPressureDiastolic"),
record.get("sleepDuration"), record.get("sleepQuality"),
record.get("stress"), record.get("caloriesBurned"),
]
if DB_TYPE == "mariadb":
update_cols = [c for c in cols if c not in ("id", "user_id")]
updates = ", ".join([f"{c}=VALUES({c})" for c in update_cols])
sql = (
f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {updates}, updated_at=CURRENT_TIMESTAMP"
)
else:
update_cols = [c for c in cols if c not in ("id", "user_id")]
updates = ", ".join([f"{c}=excluded.{c}" for c in update_cols])
sql = (
f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON CONFLICT(user_id, date) DO UPDATE SET {updates}, updated_at=CURRENT_TIMESTAMP"
)
execute(sql, vals)
return hid
def insert_activity(user_id, activity):
aid = str(uuid.uuid4())
cols = [
"id", "user_id", "activity_type", "start_time", "end_time",
"duration", "distance", "calories", "heart_rate_average", "heart_rate_max",
]
placeholders = ", ".join(["?"] * len(cols))
vals = [
aid, user_id, activity.get("activityType"), activity.get("startTime"),
activity.get("endTime"), activity.get("duration"), activity.get("distance"),
activity.get("calories"), activity.get("heartRateAverage"),
activity.get("heartRateMax"),
]
execute(
f"INSERT INTO activities ({', '.join(cols)}) VALUES ({placeholders})",
vals,
)
return aid

128
backend/tests/smoke.py Normal file
View File

@@ -0,0 +1,128 @@
"""
Smoke test for the Flask backend (SQLite).
Exercises the full request path: register -> login -> authenticated reads for
health summary/steps/heart-rate/sleep/activities, analysis trends +
recommendations, and Garmin sync status. Run: `python tests/smoke.py`.
"""
import os
import sys
import tempfile
import json
# Configure the backend BEFORE importing app/config.
_TMP_DB = os.path.join(tempfile.mkdtemp(), "smoke.db")
os.environ["DB_TYPE"] = "sqlite"
os.environ["DATABASE_PATH"] = _TMP_DB
os.environ["JWT_SECRET"] = "smoke_test_secret"
os.environ["CORS_ORIGIN"] = "http://localhost:3000"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app # noqa: E402
from db import execute # noqa: E402
app = create_app()
client = app.test_client()
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {detail}")
def auth_headers(token):
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
print("\n[1] Auth: register + login")
r = client.post(
"/api/auth/register",
json={"email": "tester@example.com", "garminEmail": "gm@example.com", "garminPassword": "secret123"},
)
check("register 201", r.status_code == 201, r.get_data(as_text=True))
token = (r.get_json() or {}).get("token")
check("register returns token", bool(token))
r = client.post("/api/auth/login", json={"email": "tester@example.com", "password": "secret123"})
check("login 200", r.status_code == 200, r.get_data(as_text=True))
token = (r.get_json() or {}).get("token")
check("login returns token", bool(token))
r = client.post("/api/auth/login", json={"email": "tester@example.com", "password": "wrong"})
check("login rejects bad password (401)", r.status_code == 401)
r = client.get("/api/health/summary")
check("unauthenticated read 401", r.status_code == 401)
print("\n[2] Seed health data (3 days)")
uid = (client.post("/api/auth/login", json={"email": "tester@example.com", "password": "secret123"}).get_json())["id"]
for i, (steps, hr, sleep, stress) in enumerate([(6500, 70, 6.2, 55), (9000, 62, 7.5, 40), (7500, 68, 6.8, 48)]):
date = f"2026-08-{20 + i}"
execute(
"INSERT INTO health_data (id, user_id, date, steps, heart_rate, "
"heart_rate_variability, sleep_duration, sleep_quality, stress, calories_burned) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
[f"{uid}-{date}", uid, date, steps, hr, 45 + i, sleep, 80 - i, stress, steps * 0.04],
)
execute(
"INSERT INTO activities (id, user_id, activity_type, start_time, end_time, duration, distance, calories, heart_rate_average, heart_rate_max) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
["act-1", uid, "running", "2026-08-20T07:00:00", "2026-08-20T07:30:00", 1800, 5.0, 320, 140, 165],
)
print("\n[3] Authenticated health reads")
h = auth_headers(token)
r = client.get("/api/health/summary", headers=h)
check("summary 200", r.status_code == 200, r.get_data(as_text=True))
body = r.get_json()
check("summary 3 days", len(body) == 3, f"got {len(body)}")
check("summary camelCase sleep", "sleep" in body[0] and isinstance(body[0]["sleep"], dict))
r = client.get("/api/health/steps", headers=h)
check("steps 200 + non-null", r.status_code == 200 and len(r.get_json()) == 3)
r = client.get("/api/health/heart-rate", headers=h)
check("heart-rate 200", r.status_code == 200 and len(r.get_json()) == 3)
r = client.get("/api/health/sleep", headers=h)
check("sleep 200", r.status_code == 200 and len(r.get_json()) == 3)
r = client.get("/api/health/activities", headers=h)
check("activities 200", r.status_code == 200 and len(r.get_json()) == 1)
print("\n[4] Analysis")
r = client.get("/api/analysis/trends?metricType=steps", headers=h)
check("trends 200", r.status_code == 200, r.get_data(as_text=True))
check("trends values", len(r.get_json()) == 3)
r = client.get("/api/analysis/recommendations", headers=h)
check("recommendations 200", r.status_code == 200)
recs = r.get_json()
check("recommendations non-empty", len(recs) > 0)
check("recommendations sorted by priority", [x["priority"] for x in recs] == sorted([x["priority"] for x in recs], key=lambda p: {"high": 0, "medium": 1, "low": 2}[p]))
print("\n[5] Garmin status + sync (no creds -> clear 400)")
r = client.get("/api/garmin/status", headers=h)
check("garmin status 200", r.status_code == 200, r.get_data(as_text=True))
check("garmin status idle", (r.get_json() or {}).get("status") == "idle")
r = client.post("/api/garmin/sync", headers=h, json={})
check("sync without creds 400", r.status_code == 400, r.get_data(as_text=True))
print("\n[6] Health check + logout")
r = client.get("/api/health/status")
check("health/status 200", r.status_code == 200 and (r.get_json() or {}).get("status") == "ok")
r = client.post("/api/auth/logout", headers=h)
check("logout 200", r.status_code == 200)
print(f"\nRESULT: {PASS} passed, {FAIL} failed")
sys.exit(1 if FAIL else 0)

7
backend/wsgi.py Normal file
View File

@@ -0,0 +1,7 @@
"""WSGI entry point for Gunicorn: `gunicorn wsgi:app -b 0.0.0.0:5000`."""
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run()