[阶段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

13
.gitignore vendored
View File

@@ -27,3 +27,16 @@ logs/
# WorkBuddy tool state (not project code) # WorkBuddy tool state (not project code)
.workbuddy/ .workbuddy/
# --- Python / Flask backend ---
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
venv/
.venv/
backend/venv/
backend/data/
*.sqlite
*.sqlite3
# backend/.env is already ignored by the .env rule above

View File

@@ -19,30 +19,33 @@
- Axios (API 请求) - Axios (API 请求)
### 后端 ### 后端
- Node.js + Express - Python 3.10+ + Flask
- TypeScript - Gunicorn生产运行
- SQLite3 (本地数据存储) - 可插拔数据层SQLite本地开发/ MariaDB生产运行于 NAS经 PyMySQL
- garmin-connect (Garmin API 集成) - JWT 鉴权 + scrypt 密码哈希
- garminconnect可选Garmin API 集成)
> 注:原 Node/TypeScript 后端保留在 `server/`(仅 service 逻辑骨架);
> 当前可运行实现为 `backend/` 下的 Python/Flask 单体服务。
## 📁 项目结构 ## 📁 项目结构
``` ```
GarminHealthLab/ GarminHealthLab/
├── client/ # 前端应用 ├── client/ # 前端应用 (React 18 + TypeScript)
── src/ ── src/services/api.ts # API 客户端(含 JWT 拦截器)
│ │ ├── components/ # React 组件 ├── backend/ # 后端应用 (Python + Flask) —— 当前可运行实现
│ ├── pages/ # 页面 │ ├── app.py # 应用工厂 / 路由装配
│ ├── services/ # API 服务 │ ├── wsgi.py # Gunicorn 入口
│ └── types/ # TypeScript 类型定义 ├── config.py # 配置(读 .env
── package.json ── db.py # 可插拔数据层 (SQLite / MariaDB)
├── server/ # 后端应用 │ ├── auth.py # scrypt + JWT + require_auth
│ ├── src/ │ ├── services/ # 业务逻辑 (health / analysis / garmin)
│ ├── routes/ # API 路由 │ ├── routes/ # 蓝图 (auth / garmin / health / analysis)
│ ├── services/ # 业务逻辑 │ ├── tests/smoke.py # 冒烟测试
│ ├── models/ # 数据模型 │ ├── requirements.txt
│ ├── middleware/ # 中间件 └── .env.example
└── utils/ # 工具函数 ├── server/ # 原 Node/TS 后端(仅 service 骨架,未接入路由)
│ └── package.json
├── docs/ # 文档 ├── docs/ # 文档
├── package.json # 工作空间根配置 ├── package.json # 工作空间根配置
└── README.md └── README.md
@@ -57,24 +60,33 @@ GarminHealthLab/
### 安装依赖 ### 安装依赖
前端React
```bash ```bash
npm install npm install
``` ```
后端Python/Flask建议在虚拟环境中安装
```bash
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
```
### 配置环境变量 ### 配置环境变量
创建 `server/.env` 文件: 复制 `backend/.env.example``backend/.env` 并填入真实值(`.env` 已被
`.gitignore` 忽略,不会进入版本库):
```env ```env
PORT=5000 PORT=5000
NODE_ENV=development DB_TYPE=sqlite # 本地开发用 sqlite生产切 mariadb
GARMIN_CONNECT_USER=your_garmin_email
GARMIN_CONNECT_PASSWORD=your_garmin_password
DATABASE_PATH=./data/health.db DATABASE_PATH=./data/health.db
JWT_SECRET=your_jwt_secret_here JWT_SECRET=your_jwt_secret_here # 生产务必更换
CORS_ORIGIN=http://localhost:3000 CORS_ORIGIN=http://localhost:3000,http://localhost:5173
``` ```
> 原 `server/.env` 的 Node 配置已弃用,请改用 `backend/.env`。
### 数据库SQLite / MariaDB 可插拔 ### 数据库SQLite / MariaDB 可插拔
数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动** 数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动**
@@ -82,7 +94,7 @@ CORS_ORIGIN=http://localhost:3000
- **SQLite默认本地开发**:零配置,由 `DATABASE_PATH` 指定文件位置。 - **SQLite默认本地开发**:零配置,由 `DATABASE_PATH` 指定文件位置。
- **MariaDB生产运行在 NAS 上)**:经 `mysql2` 连接,优先走 socket`MARIADB_SOCKET`),也可用 `MARIADB_HOST` + `MARIADB_PORT` - **MariaDB生产运行在 NAS 上)**:经 `mysql2` 连接,优先走 socket`MARIADB_SOCKET`),也可用 `MARIADB_HOST` + `MARIADB_PORT`
生产使用 NAS 上的独立库 `garmin_health_lab`(与 `sentinel_home_ai` 隔离)。完整配置见 `server/.env.example` 生产使用 NAS 上的独立库 `garmin_health_lab`(与 `sentinel_home_ai` 隔离)。完整配置见 `backend/.env.example`
```env ```env
DB_TYPE=mariadb DB_TYPE=mariadb
@@ -92,16 +104,32 @@ MARIADB_PASSWORD=your_nas_mariadb_root_password
MARIADB_DATABASE=garmin_health_lab MARIADB_DATABASE=garmin_health_lab
``` ```
> 复制 `server/.env.example` 为 `server/.env` 并填入真实值;`.env` 已被 `.gitignore` 忽略,不会进入版本库。 > 复制 `backend/.env.example` 为 `backend/.env` 并填入真实值;`.env` 已被 `.gitignore` 忽略,不会进入版本库。
### 启动开发服务器 ### 启动开发服务器
后端Flask端口 5000
```bash
cd backend
source venv/bin/activate
python app.py
# 或生产方式gunicorn wsgi:app -b 0.0.0.0:5000
```
前端Vite/React端口 3000 或 5173
```bash ```bash
npm run dev npm run dev
``` ```
- 前端: http://localhost:3000 - 前端: http://localhost:3000
- 后端: http://localhost:5000 - 后端 API: http://localhost:5000/api
### 冒烟测试
```bash
cd backend
python tests/smoke.py
```
## 📚 API 文档 ## 📚 API 文档

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()

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout'; import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import DataSync from './pages/DataSync'; import DataSync from './pages/DataSync';
import Analysis from './pages/Analysis'; import Analysis from './pages/Analysis';
@@ -10,15 +12,26 @@ import Settings from './pages/Settings';
function App() { function App() {
return ( return (
<Router> <Router>
<Layout> <Routes>
<Routes> <Route path="/login" element={<Login />} />
<Route path="/" element={<Dashboard />} />
<Route path="/sync" element={<DataSync />} /> <Route
<Route path="/analysis" element={<Analysis />} /> path="*"
<Route path="/recommendations" element={<Recommendations />} /> element={
<Route path="/settings" element={<Settings />} /> <ProtectedRoute>
</Routes> <Layout>
</Layout> <Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sync" element={<DataSync />} />
<Route path="/analysis" element={<Analysis />} />
<Route path="/recommendations" element={<Recommendations />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</Router> </Router>
); );
} }

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
interface ProtectedRouteProps {
children: React.ReactNode;
}
function ProtectedRoute({ children }: ProtectedRouteProps) {
const token = localStorage.getItem('ghl_token');
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export default ProtectedRoute;

156
client/src/pages/Login.css Normal file
View File

@@ -0,0 +1,156 @@
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem;
}
.login-card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
padding: 2rem;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
font-size: 1.8rem;
margin: 0 0 0.5rem 0;
color: #333;
}
.login-header p {
color: #999;
font-size: 0.9rem;
margin: 0;
}
.login-tabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border-bottom: 2px solid #f0f0f0;
}
.tab-button {
flex: 1;
padding: 0.75rem;
border: none;
background: none;
color: #999;
font-size: 1rem;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
margin-bottom: -2px;
}
.tab-button:hover {
color: #667eea;
}
.tab-button.active {
color: #667eea;
border-bottom-color: #667eea;
}
.error-message {
background-color: #fee;
border: 1px solid #fcc;
color: #c33;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 0.9rem;
}
.form-group input {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
transition: all 0.3s ease;
font-family: inherit;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.submit-button {
padding: 0.75rem 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 0.5rem;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.submit-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
@media (max-width: 480px) {
.login-card {
padding: 1.5rem;
}
.login-header h1 {
font-size: 1.5rem;
}
}

238
client/src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,238 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../services/api';
import './Login.css';
type TabType = 'login' | 'register';
function Login() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabType>('login');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
// Login form
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
// Register form
const [regEmail, setRegEmail] = useState('');
const [regGarminEmail, setRegGarminEmail] = useState('');
const [regPassword, setRegPassword] = useState('');
const [regConfirmPassword, setRegConfirmPassword] = useState('');
const validateEmail = (email: string): boolean => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const validatePassword = (password: string): boolean => {
return password.length >= 6;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(loginEmail)) {
setError('Please enter a valid email');
return;
}
if (!validatePassword(loginPassword)) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const response = await apiClient.login(loginEmail, loginPassword);
const { token } = response.data.data;
// Store token
apiClient.setSession(token);
// Redirect to dashboard
navigate('/');
} catch (err: any) {
const message = err.response?.data?.error?.message || 'Login failed';
setError(message);
} finally {
setLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!validateEmail(regEmail)) {
setError('Please enter a valid email');
return;
}
if (!validateEmail(regGarminEmail)) {
setError('Please enter a valid Garmin email');
return;
}
if (!validatePassword(regPassword)) {
setError('Password must be at least 6 characters');
return;
}
if (regPassword !== regConfirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
try {
const response = await apiClient.register(regEmail, regGarminEmail, regPassword);
const { token } = response.data.data;
// Store token
apiClient.setSession(token);
// Redirect to dashboard
navigate('/');
} catch (err: any) {
const message = err.response?.data?.error?.message || 'Registration failed';
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>🏃 Garmin Health Lab</h1>
<p></p>
</div>
<div className="login-tabs">
<button
className={`tab-button ${activeTab === 'login' ? 'active' : ''}`}
onClick={() => {
setActiveTab('login');
setError('');
}}
>
</button>
<button
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
onClick={() => {
setActiveTab('register');
setError('');
}}
>
</button>
</div>
{error && <div className="error-message">{error}</div>}
{activeTab === 'login' && (
<form onSubmit={handleLogin} className="login-form">
<div className="form-group">
<label htmlFor="login-email"></label>
<input
id="login-email"
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="example@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="login-password"></label>
<input
id="login-password"
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<button type="submit" className="submit-button" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
)}
{activeTab === 'register' && (
<form onSubmit={handleRegister} className="login-form">
<div className="form-group">
<label htmlFor="reg-email"></label>
<input
id="reg-email"
type="email"
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
placeholder="example@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-garmin-email">Garmin </label>
<input
id="reg-garmin-email"
type="email"
value={regGarminEmail}
onChange={(e) => setRegGarminEmail(e.target.value)}
placeholder="garmin@example.com"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-password"></label>
<input
id="reg-password"
type="password"
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="reg-confirm-password"></label>
<input
id="reg-confirm-password"
type="password"
value={regConfirmPassword}
onChange={(e) => setRegConfirmPassword(e.target.value)}
placeholder="••••••••"
required
disabled={loading}
/>
</div>
<button type="submit" className="submit-button" disabled={loading}>
{loading ? '注册中...' : '注册'}
</button>
</form>
)}
</div>
</div>
);
}
export default Login;

View File

@@ -1,6 +1,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const TOKEN_KEY = 'ghl_token';
class ApiClient { class ApiClient {
private client: AxiosInstance; private client: AxiosInstance;
@@ -12,9 +13,34 @@ class ApiClient {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
// Attach the saved JWT to every request.
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// On 401, drop the stored session so the UI can redirect to login.
this.client.interceptors.response.use(
(resp) => resp,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY);
}
return Promise.reject(error);
}
);
}
// --- Auth ---
register(email: string, garminEmail: string, garminPassword: string) {
return this.client.post('/auth/register', { email, garminEmail, garminPassword });
} }
// Auth endpoints
login(email: string, password: string) { login(email: string, password: string) {
return this.client.post('/auth/login', { email, password }); return this.client.post('/auth/login', { email, password });
} }
@@ -23,50 +49,55 @@ class ApiClient {
return this.client.post('/auth/logout'); return this.client.post('/auth/logout');
} }
// Garmin endpoints refresh() {
syncGarminData() { return this.client.post('/auth/refresh');
return this.client.post('/garmin/sync'); }
// Persist the JWT returned by register/login.
setSession(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
clearSession() {
localStorage.removeItem(TOKEN_KEY);
}
// --- Garmin ---
syncGarminData(garminEmail?: string, garminPassword?: string) {
const body =
garminEmail || garminPassword ? { garminEmail, garminPassword } : {};
return this.client.post('/garmin/sync', body);
} }
getGarminSyncStatus() { getGarminSyncStatus() {
return this.client.get('/garmin/status'); return this.client.get('/garmin/status');
} }
// Health endpoints // --- Health ---
getHealthSummary(startDate?: string, endDate?: string) { getHealthSummary(startDate?: string, endDate?: string) {
return this.client.get('/health/summary', { return this.client.get('/health/summary', { params: { startDate, endDate } });
params: { startDate, endDate }
});
} }
getStepsData(startDate?: string, endDate?: string) { getStepsData(startDate?: string, endDate?: string) {
return this.client.get('/health/steps', { return this.client.get('/health/steps', { params: { startDate, endDate } });
params: { startDate, endDate }
});
} }
getHeartRateData(startDate?: string, endDate?: string) { getHeartRateData(startDate?: string, endDate?: string) {
return this.client.get('/health/heart-rate', { return this.client.get('/health/heart-rate', { params: { startDate, endDate } });
params: { startDate, endDate }
});
} }
getSleepData(startDate?: string, endDate?: string) { getSleepData(startDate?: string, endDate?: string) {
return this.client.get('/health/sleep', { return this.client.get('/health/sleep', { params: { startDate, endDate } });
params: { startDate, endDate }
});
} }
getActivities(startDate?: string, endDate?: string) { getActivities(startDate?: string, endDate?: string) {
return this.client.get('/health/activities', { return this.client.get('/health/activities', { params: { startDate, endDate } });
params: { startDate, endDate }
});
} }
// Analysis endpoints // --- Analysis ---
getTrends(metricType?: string) { getTrends(metricType?: string, startDate?: string, endDate?: string) {
return this.client.get('/analysis/trends', { return this.client.get('/analysis/trends', {
params: { metricType } params: { metricType, startDate, endDate },
}); });
} }

10
package-lock.json generated
View File

@@ -6395,6 +6395,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bfj": { "node_modules/bfj": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz",
@@ -21586,6 +21595,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"axios": "^1.5.0", "axios": "^1.5.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",

View File

@@ -11,22 +11,23 @@
"lint": "eslint src --ext .ts" "lint": "eslint src --ext .ts"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "axios": "^1.5.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.6.0", "mysql2": "^3.6.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"jsonwebtoken": "^9.0.2",
"axios": "^1.5.0",
"ts-node": "^10.9.1" "ts-node": "^10.9.1"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.17",
"@types/cors": "^2.8.13", "@types/cors": "^2.8.13",
"@types/node": "^20.3.1", "@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2", "@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.3.1",
"@types/sqlite3": "^3.1.8", "@types/sqlite3": "^3.1.8",
"typescript": "^5.1.3", "tsx": "^3.12.7",
"tsx": "^3.12.7" "typescript": "^5.1.3"
} }
} }

View File

@@ -0,0 +1,65 @@
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../services/AuthService';
import { AppError } from './errorHandler';
export interface AuthRequest extends Request {
userId?: string;
userEmail?: string;
}
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
error: {
status: 401,
message: 'Missing authorization header',
},
});
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(401).json({
error: {
status: 401,
message: 'Invalid authorization header format',
},
});
}
const token = parts[1];
try {
const payload = verifyToken(token);
req.userId = payload.userId;
next();
} catch (error: any) {
return res.status(401).json({
error: {
status: 401,
message: 'Invalid or expired token',
},
});
}
}
export function optionalAuthMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (authHeader) {
const parts = authHeader.split(' ');
if (parts.length === 2 && parts[0] === 'Bearer') {
const token = parts[1];
try {
const payload = verifyToken(token);
req.userId = payload.userId;
} catch (error) {
// Silently fail - continue without authentication
}
}
}
next();
}

View File

@@ -1,18 +1,154 @@
import express from 'express'; import express from 'express';
import { register, login, logout, getUserById } from '../services/AuthService';
import { authMiddleware, AuthRequest } from '../middleware/authMiddleware';
const router = express.Router(); const router = express.Router();
// TODO: Implement authentication routes /**
router.post('/login', (req, res) => { * POST /api/auth/register
res.json({ message: 'Login endpoint' }); * Register a new user
*/
router.post('/register', async (req, res, next) => {
try {
const { email, garminEmail, garminPassword } = req.body;
if (!email || !garminEmail || !garminPassword) {
return res.status(400).json({
error: {
status: 400,
message: 'Missing required fields: email, garminEmail, garminPassword',
},
});
}
const result = await register({ email, garminEmail, garminPassword });
res.status(201).json({
success: true,
data: {
userId: result.id,
email: result.email,
token: result.token,
},
});
} catch (error: any) {
if (error.code === 'EMAIL_TAKEN') {
return res.status(409).json({
error: {
status: 409,
message: error.message,
},
});
}
next(error);
}
}); });
router.post('/logout', (req, res) => { /**
res.json({ message: 'Logout endpoint' }); * POST /api/auth/login
* Login with email and password
*/
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
error: {
status: 400,
message: 'Missing required fields: email, password',
},
});
}
const result = await login(email, password);
res.json({
success: true,
data: {
userId: result.id,
email: result.email,
token: result.token,
},
});
} catch (error: any) {
if (error.code === 'INVALID_CREDENTIALS') {
return res.status(401).json({
error: {
status: 401,
message: error.message,
},
});
}
next(error);
}
}); });
router.post('/refresh', (req, res) => { /**
res.json({ message: 'Refresh token endpoint' }); * POST /api/auth/logout
* Logout (requires authentication)
*/
router.post('/logout', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
await logout(userId);
res.json({
success: true,
message: 'Logged out successfully',
});
} catch (error) {
next(error);
}
});
/**
* GET /api/auth/me
* Get current user info (requires authentication)
*/
router.get('/me', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({
error: {
status: 401,
message: 'User not authenticated',
},
});
}
const user = await getUserById(userId);
if (!user) {
return res.status(404).json({
error: {
status: 404,
message: 'User not found',
},
});
}
res.json({
success: true,
data: {
id: user.id,
email: user.email,
garminEmail: user.garmin_email,
createdAt: user.created_at,
},
});
} catch (error) {
next(error);
}
}); });
export default router; export default router;