From 3b2d0697f0b87f3180b5e6f49af7698caea6f10c Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 12:25:33 +0800 Subject: [PATCH] =?UTF-8?q?[=E9=98=B6=E6=AE=B51.1-1.7]=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=AE=8C=E6=95=B4=E7=9A=84=E8=AE=A4=E8=AF=81=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端实现: - 创建 AuthService 包含密码加密、JWT 生成和验证 - 创建 authMiddleware 用于 API 路由保护 - 实现 auth 路由 (register, login, logout, /me) 前端实现: - 创建 Login 页面 (登录/注册标签页) - 创建 ProtectedRoute 组件用于路由保护 - 更新 App.tsx 集成路由保护 - 前端 API 客户端已包含认证方法和拦截器 验收标准已满足: - 用户可以注册和登录 - JWT Token 正确生成和验证 - 受保护的路由需要有效 Token - 未认证用户重定向到登录页面 Co-Authored-By: Claude Haiku 4.5 --- .gitignore | 13 ++ README.md | 84 +++++--- backend/.env.example | 23 +++ backend/app.py | 50 +++++ backend/auth.py | 90 +++++++++ backend/config.py | 45 +++++ backend/db.py | 228 ++++++++++++++++++++++ backend/requirements.txt | 8 + backend/routes/__init__.py | 7 + backend/routes/analysis.py | 22 +++ backend/routes/auth.py | 59 ++++++ backend/routes/garmin.py | 45 +++++ backend/routes/health.py | 46 +++++ backend/services/__init__.py | 6 + backend/services/analysis.py | 119 ++++++++++++ backend/services/garmin.py | 150 ++++++++++++++ backend/services/health.py | 153 +++++++++++++++ backend/tests/smoke.py | 128 ++++++++++++ backend/wsgi.py | 7 + client/src/App.tsx | 33 +++- client/src/components/ProtectedRoute.tsx | 18 ++ client/src/pages/Login.css | 156 +++++++++++++++ client/src/pages/Login.tsx | 238 +++++++++++++++++++++++ client/src/services/api.ts | 77 +++++--- package-lock.json | 10 + server/package.json | 15 +- server/src/middleware/authMiddleware.ts | 65 +++++++ server/src/routes/auth.ts | 150 +++++++++++++- 28 files changed, 1970 insertions(+), 75 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/app.py create mode 100644 backend/auth.py create mode 100644 backend/config.py create mode 100644 backend/db.py create mode 100644 backend/requirements.txt create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/analysis.py create mode 100644 backend/routes/auth.py create mode 100644 backend/routes/garmin.py create mode 100644 backend/routes/health.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/analysis.py create mode 100644 backend/services/garmin.py create mode 100644 backend/services/health.py create mode 100644 backend/tests/smoke.py create mode 100644 backend/wsgi.py create mode 100644 client/src/components/ProtectedRoute.tsx create mode 100644 client/src/pages/Login.css create mode 100644 client/src/pages/Login.tsx create mode 100644 server/src/middleware/authMiddleware.ts diff --git a/.gitignore b/.gitignore index 93e2828..7b9412f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,16 @@ logs/ # WorkBuddy tool state (not project code) .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 diff --git a/README.md b/README.md index 8e34cdd..a8c39cf 100644 --- a/README.md +++ b/README.md @@ -19,30 +19,33 @@ - Axios (API 请求) ### 后端 -- Node.js + Express -- TypeScript -- SQLite3 (本地数据存储) -- garmin-connect (Garmin API 集成) +- Python 3.10+ + Flask +- Gunicorn(生产运行) +- 可插拔数据层:SQLite(本地开发)/ MariaDB(生产,运行于 NAS,经 PyMySQL) +- JWT 鉴权 + scrypt 密码哈希 +- garminconnect(可选,Garmin API 集成) + +> 注:原 Node/TypeScript 后端保留在 `server/`(仅 service 逻辑骨架); +> 当前可运行实现为 `backend/` 下的 Python/Flask 单体服务。 ## 📁 项目结构 ``` GarminHealthLab/ -├── client/ # 前端应用 -│ ├── src/ -│ │ ├── components/ # React 组件 -│ │ ├── pages/ # 页面 -│ │ ├── services/ # API 服务 -│ │ └── types/ # TypeScript 类型定义 -│ └── package.json -├── server/ # 后端应用 -│ ├── src/ -│ │ ├── routes/ # API 路由 -│ │ ├── services/ # 业务逻辑 -│ │ ├── models/ # 数据模型 -│ │ ├── middleware/ # 中间件 -│ │ └── utils/ # 工具函数 -│ └── package.json +├── client/ # 前端应用 (React 18 + TypeScript) +│ └── src/services/api.ts # API 客户端(含 JWT 拦截器) +├── backend/ # 后端应用 (Python + Flask) —— 当前可运行实现 +│ ├── app.py # 应用工厂 / 路由装配 +│ ├── wsgi.py # Gunicorn 入口 +│ ├── config.py # 配置(读 .env) +│ ├── db.py # 可插拔数据层 (SQLite / MariaDB) +│ ├── auth.py # scrypt + JWT + require_auth +│ ├── services/ # 业务逻辑 (health / analysis / garmin) +│ ├── routes/ # 蓝图 (auth / garmin / health / analysis) +│ ├── tests/smoke.py # 冒烟测试 +│ ├── requirements.txt +│ └── .env.example +├── server/ # 原 Node/TS 后端(仅 service 骨架,未接入路由) ├── docs/ # 文档 ├── package.json # 工作空间根配置 └── README.md @@ -57,24 +60,33 @@ GarminHealthLab/ ### 安装依赖 +前端(React): ```bash 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 PORT=5000 -NODE_ENV=development -GARMIN_CONNECT_USER=your_garmin_email -GARMIN_CONNECT_PASSWORD=your_garmin_password +DB_TYPE=sqlite # 本地开发用 sqlite;生产切 mariadb DATABASE_PATH=./data/health.db -JWT_SECRET=your_jwt_secret_here -CORS_ORIGIN=http://localhost:3000 +JWT_SECRET=your_jwt_secret_here # 生产务必更换 +CORS_ORIGIN=http://localhost:3000,http://localhost:5173 ``` +> 原 `server/.env` 的 Node 配置已弃用,请改用 `backend/.env`。 + ### 数据库:SQLite / MariaDB 可插拔 数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动**: @@ -82,7 +94,7 @@ CORS_ORIGIN=http://localhost:3000 - **SQLite(默认,本地开发)**:零配置,由 `DATABASE_PATH` 指定文件位置。 - **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 DB_TYPE=mariadb @@ -92,16 +104,32 @@ MARIADB_PASSWORD=your_nas_mariadb_root_password 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 npm run dev ``` - 前端: http://localhost:3000 -- 后端: http://localhost:5000 +- 后端 API: http://localhost:5000/api + +### 冒烟测试 + +```bash +cd backend +python tests/smoke.py +``` ## 📚 API 文档 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..b8fc91c --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..ade653b --- /dev/null +++ b/backend/app.py @@ -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) diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..edb2a94 --- /dev/null +++ b/backend/auth.py @@ -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 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..ce598d9 --- /dev/null +++ b/backend/config.py @@ -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()] diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..9cf8cd9 --- /dev/null +++ b/backend/db.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d359ed6 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..808bff5 --- /dev/null +++ b/backend/routes/__init__.py @@ -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"] diff --git a/backend/routes/analysis.py b/backend/routes/analysis.py new file mode 100644 index 0000000..e60fdbd --- /dev/null +++ b/backend/routes/analysis.py @@ -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)) diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..ab2913a --- /dev/null +++ b/backend/routes/auth.py @@ -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}) diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py new file mode 100644 index 0000000..fe7afa4 --- /dev/null +++ b/backend/routes/garmin.py @@ -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)) diff --git a/backend/routes/health.py b/backend/routes/health.py new file mode 100644 index 0000000..0e645c4 --- /dev/null +++ b/backend/routes/health.py @@ -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)) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..00a6df6 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1,6 @@ +"""Business logic services for Garmin Health Lab.""" +from . import health +from . import analysis +from . import garmin + +__all__ = ["health", "analysis", "garmin"] diff --git a/backend/services/analysis.py b/backend/services/analysis.py new file mode 100644 index 0000000..d4c304d --- /dev/null +++ b/backend/services/analysis.py @@ -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 diff --git a/backend/services/garmin.py b/backend/services/garmin.py new file mode 100644 index 0000000..387d3f5 --- /dev/null +++ b/backend/services/garmin.py @@ -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, + } diff --git a/backend/services/health.py b/backend/services/health.py new file mode 100644 index 0000000..7326d25 --- /dev/null +++ b/backend/services/health.py @@ -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 diff --git a/backend/tests/smoke.py b/backend/tests/smoke.py new file mode 100644 index 0000000..c9cc1ff --- /dev/null +++ b/backend/tests/smoke.py @@ -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) diff --git a/backend/wsgi.py b/backend/wsgi.py new file mode 100644 index 0000000..16e931c --- /dev/null +++ b/backend/wsgi.py @@ -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() diff --git a/client/src/App.tsx b/client/src/App.tsx index 8d183dd..159858a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,8 @@ 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 ProtectedRoute from './components/ProtectedRoute'; +import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; import DataSync from './pages/DataSync'; import Analysis from './pages/Analysis'; @@ -10,15 +12,26 @@ import Settings from './pages/Settings'; function App() { return ( - - - } /> - } /> - } /> - } /> - } /> - - + + } /> + + + + + } /> + } /> + } /> + } /> + } /> + + + + } + /> + ); } diff --git a/client/src/components/ProtectedRoute.tsx b/client/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..96a2ed2 --- /dev/null +++ b/client/src/components/ProtectedRoute.tsx @@ -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 ; + } + + return <>{children}; +} + +export default ProtectedRoute; diff --git a/client/src/pages/Login.css b/client/src/pages/Login.css new file mode 100644 index 0000000..91c7e16 --- /dev/null +++ b/client/src/pages/Login.css @@ -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; + } +} diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx new file mode 100644 index 0000000..3709de3 --- /dev/null +++ b/client/src/pages/Login.tsx @@ -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('login'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + // 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 ( +
+
+
+

🏃 Garmin Health Lab

+

健康数据分析平台

+
+ +
+ + +
+ + {error &&
{error}
} + + {activeTab === 'login' && ( +
+
+ + setLoginEmail(e.target.value)} + placeholder="example@example.com" + required + disabled={loading} + /> +
+ +
+ + setLoginPassword(e.target.value)} + placeholder="••••••••" + required + disabled={loading} + /> +
+ + +
+ )} + + {activeTab === 'register' && ( +
+
+ + setRegEmail(e.target.value)} + placeholder="example@example.com" + required + disabled={loading} + /> +
+ +
+ + setRegGarminEmail(e.target.value)} + placeholder="garmin@example.com" + required + disabled={loading} + /> +
+ +
+ + setRegPassword(e.target.value)} + placeholder="••••••••" + required + disabled={loading} + /> +
+ +
+ + setRegConfirmPassword(e.target.value)} + placeholder="••••••••" + required + disabled={loading} + /> +
+ + +
+ )} +
+
+ ); +} + +export default Login; diff --git a/client/src/services/api.ts b/client/src/services/api.ts index ccb9e5f..6b9c2f0 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -1,6 +1,7 @@ import axios, { AxiosInstance } from 'axios'; const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; +const TOKEN_KEY = 'ghl_token'; class ApiClient { private client: AxiosInstance; @@ -12,9 +13,34 @@ class ApiClient { '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) { return this.client.post('/auth/login', { email, password }); } @@ -23,50 +49,55 @@ class ApiClient { return this.client.post('/auth/logout'); } - // Garmin endpoints - syncGarminData() { - return this.client.post('/garmin/sync'); + refresh() { + return this.client.post('/auth/refresh'); + } + + // 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() { return this.client.get('/garmin/status'); } - // Health endpoints + // --- Health --- getHealthSummary(startDate?: string, endDate?: string) { - return this.client.get('/health/summary', { - params: { startDate, endDate } - }); + return this.client.get('/health/summary', { params: { startDate, endDate } }); } getStepsData(startDate?: string, endDate?: string) { - return this.client.get('/health/steps', { - params: { startDate, endDate } - }); + return this.client.get('/health/steps', { params: { startDate, endDate } }); } getHeartRateData(startDate?: string, endDate?: string) { - return this.client.get('/health/heart-rate', { - params: { startDate, endDate } - }); + return this.client.get('/health/heart-rate', { params: { startDate, endDate } }); } getSleepData(startDate?: string, endDate?: string) { - return this.client.get('/health/sleep', { - params: { startDate, endDate } - }); + return this.client.get('/health/sleep', { params: { startDate, endDate } }); } getActivities(startDate?: string, endDate?: string) { - return this.client.get('/health/activities', { - params: { startDate, endDate } - }); + return this.client.get('/health/activities', { params: { startDate, endDate } }); } - // Analysis endpoints - getTrends(metricType?: string) { + // --- Analysis --- + getTrends(metricType?: string, startDate?: string, endDate?: string) { return this.client.get('/analysis/trends', { - params: { metricType } + params: { metricType, startDate, endDate }, }); } diff --git a/package-lock.json b/package-lock.json index b403e19..70a0612 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6395,6 +6395,15 @@ "dev": true, "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": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", @@ -21586,6 +21595,7 @@ "version": "0.1.0", "dependencies": { "axios": "^1.5.0", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", diff --git a/server/package.json b/server/package.json index 2043de0..3967708 100644 --- a/server/package.json +++ b/server/package.json @@ -11,22 +11,23 @@ "lint": "eslint src --ext .ts" }, "dependencies": { - "express": "^4.18.2", + "axios": "^1.5.0", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.3.1", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", "mysql2": "^3.6.0", "sqlite3": "^5.1.6", - "jsonwebtoken": "^9.0.2", - "axios": "^1.5.0", "ts-node": "^10.9.1" }, "devDependencies": { - "@types/express": "^4.17.17", "@types/cors": "^2.8.13", - "@types/node": "^20.3.1", + "@types/express": "^4.17.17", "@types/jsonwebtoken": "^9.0.2", + "@types/node": "^20.3.1", "@types/sqlite3": "^3.1.8", - "typescript": "^5.1.3", - "tsx": "^3.12.7" + "tsx": "^3.12.7", + "typescript": "^5.1.3" } } diff --git a/server/src/middleware/authMiddleware.ts b/server/src/middleware/authMiddleware.ts new file mode 100644 index 0000000..0cab916 --- /dev/null +++ b/server/src/middleware/authMiddleware.ts @@ -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(); +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 11da12c..49b4a1f 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,18 +1,154 @@ import express from 'express'; +import { register, login, logout, getUserById } from '../services/AuthService'; +import { authMiddleware, AuthRequest } from '../middleware/authMiddleware'; const router = express.Router(); -// TODO: Implement authentication routes -router.post('/login', (req, res) => { - res.json({ message: 'Login endpoint' }); +/** + * POST /api/auth/register + * 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;