[阶段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:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
84
README.md
84
README.md
@@ -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
23
backend/.env.example
Normal 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
50
backend/app.py
Normal 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
90
backend/auth.py
Normal 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
45
backend/config.py
Normal 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
228
backend/db.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
Pluggable data layer for Garmin Health Lab.
|
||||||
|
|
||||||
|
Supports both SQLite (stdlib, local dev) and MariaDB (PyMySQL, NAS production)
|
||||||
|
through a single unified API:
|
||||||
|
|
||||||
|
init_db() -> create tables if missing
|
||||||
|
execute(sql, params) -> INSERT/UPDATE/DELETE, returns {id, changes}
|
||||||
|
query_one(sql, params) -> one row as dict or None
|
||||||
|
query_all(sql, params) -> list of row dicts
|
||||||
|
|
||||||
|
Both backends accept `?` placeholders; the SQL is translated to `%s` for
|
||||||
|
MariaDB automatically. Upserts must use backend-specific SQL (see services).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
DB_TYPE,
|
||||||
|
SQLITE_PATH,
|
||||||
|
MARIADB_SOCKET,
|
||||||
|
MARIADB_HOST,
|
||||||
|
MARIADB_PORT,
|
||||||
|
MARIADB_USER,
|
||||||
|
MARIADB_PASSWORD,
|
||||||
|
MARIADB_DATABASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
garmin_email VARCHAR(255) NOT NULL,
|
||||||
|
garmin_password_hash TEXT NOT NULL,
|
||||||
|
jwt_token TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS health_data (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
steps INT,
|
||||||
|
heart_rate INT,
|
||||||
|
heart_rate_variability DOUBLE,
|
||||||
|
blood_pressure_systolic INT,
|
||||||
|
blood_pressure_diastolic INT,
|
||||||
|
sleep_duration INT,
|
||||||
|
sleep_quality DOUBLE,
|
||||||
|
stress INT,
|
||||||
|
calories_burned DOUBLE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
activity_type VARCHAR(255) NOT NULL,
|
||||||
|
start_time DATETIME NOT NULL,
|
||||||
|
end_time DATETIME NOT NULL,
|
||||||
|
duration INT,
|
||||||
|
distance DOUBLE,
|
||||||
|
calories DOUBLE,
|
||||||
|
heart_rate_average INT,
|
||||||
|
heart_rate_max INT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_status (
|
||||||
|
user_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
last_sync_time DATETIME,
|
||||||
|
status VARCHAR(32) DEFAULT 'idle',
|
||||||
|
last_error TEXT,
|
||||||
|
records_synced INT DEFAULT 0,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- MariaDB pool (lazy) ----------------------------------------------------
|
||||||
|
_mariadb_pool = None
|
||||||
|
_pool_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _new_mariadb_conn():
|
||||||
|
import pymysql
|
||||||
|
from pymysql.cursors import DictCursor
|
||||||
|
|
||||||
|
kwargs = dict(
|
||||||
|
user=MARIADB_USER,
|
||||||
|
password=MARIADB_PASSWORD,
|
||||||
|
database=MARIADB_DATABASE,
|
||||||
|
charset="utf8mb4",
|
||||||
|
autocommit=True,
|
||||||
|
cursorclass=DictCursor,
|
||||||
|
connect_timeout=10,
|
||||||
|
)
|
||||||
|
if MARIADB_SOCKET:
|
||||||
|
kwargs["unix_socket"] = MARIADB_SOCKET
|
||||||
|
else:
|
||||||
|
kwargs["host"] = MARIADB_HOST
|
||||||
|
kwargs["port"] = MARIADB_PORT
|
||||||
|
return pymysql.connect(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _mariadb_acquire():
|
||||||
|
global _mariadb_pool
|
||||||
|
if _mariadb_pool is None:
|
||||||
|
with _pool_lock:
|
||||||
|
if _mariadb_pool is None:
|
||||||
|
_mariadb_pool = queue.Queue(maxsize=10)
|
||||||
|
for _ in range(10):
|
||||||
|
_mariadb_pool.put(_new_mariadb_conn())
|
||||||
|
try:
|
||||||
|
return _mariadb_pool.get(block=False)
|
||||||
|
except queue.Empty:
|
||||||
|
return _new_mariadb_conn()
|
||||||
|
|
||||||
|
|
||||||
|
def _mariadb_release(conn):
|
||||||
|
try:
|
||||||
|
conn.ping(reconnect=False)
|
||||||
|
_mariadb_pool.put(conn)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --- SQLite connection ------------------------------------------------------
|
||||||
|
def _sqlite_connect():
|
||||||
|
data_dir = os.path.dirname(SQLITE_PATH)
|
||||||
|
if data_dir and not os.path.exists(data_dir):
|
||||||
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(SQLITE_PATH, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _connect():
|
||||||
|
if DB_TYPE == "mariadb":
|
||||||
|
return _mariadb_acquire()
|
||||||
|
return _sqlite_connect()
|
||||||
|
|
||||||
|
|
||||||
|
def _disconnect(conn):
|
||||||
|
if DB_TYPE == "mariadb":
|
||||||
|
_mariadb_release(conn)
|
||||||
|
else:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _adapt_sql(sql):
|
||||||
|
# pymysql uses %s placeholders; sqlite3 uses ?. Business code writes ?.
|
||||||
|
return sql.replace("?", "%s") if DB_TYPE == "mariadb" else sql
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (datetime.datetime, datetime.date)):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row):
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if isinstance(row, dict):
|
||||||
|
return {k: _serialize(v) for k, v in row.items()}
|
||||||
|
return {k: _serialize(row[k]) for k in row.keys()}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Public API -------------------------------------------------------------
|
||||||
|
def init_db():
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
for stmt in SCHEMA.split(";"):
|
||||||
|
stmt = stmt.strip()
|
||||||
|
if not stmt:
|
||||||
|
continue
|
||||||
|
cur.execute(_adapt_sql(stmt))
|
||||||
|
finally:
|
||||||
|
_disconnect(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def execute(sql, params=None):
|
||||||
|
params = params or []
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(_adapt_sql(sql), params)
|
||||||
|
return {"id": cur.lastrowid, "changes": cur.rowcount}
|
||||||
|
finally:
|
||||||
|
_disconnect(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def query_one(sql, params=None):
|
||||||
|
params = params or []
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(_adapt_sql(sql), params)
|
||||||
|
return _row_to_dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
_disconnect(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def query_all(sql, params=None):
|
||||||
|
params = params or []
|
||||||
|
conn = _connect()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(_adapt_sql(sql), params)
|
||||||
|
return [_row_to_dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
_disconnect(conn)
|
||||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal 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
|
||||||
7
backend/routes/__init__.py
Normal file
7
backend/routes/__init__.py
Normal 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"]
|
||||||
22
backend/routes/analysis.py
Normal file
22
backend/routes/analysis.py
Normal 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
59
backend/routes/auth.py
Normal 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
45
backend/routes/garmin.py
Normal 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
46
backend/routes/health.py
Normal 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))
|
||||||
6
backend/services/__init__.py
Normal file
6
backend/services/__init__.py
Normal 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"]
|
||||||
119
backend/services/analysis.py
Normal file
119
backend/services/analysis.py
Normal 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
150
backend/services/garmin.py
Normal 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
153
backend/services/health.py
Normal 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
128
backend/tests/smoke.py
Normal 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
7
backend/wsgi.py
Normal 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()
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
client/src/components/ProtectedRoute.tsx
Normal file
18
client/src/components/ProtectedRoute.tsx
Normal 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
156
client/src/pages/Login.css
Normal 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
238
client/src/pages/Login.tsx
Normal 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;
|
||||||
@@ -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
10
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
server/src/middleware/authMiddleware.ts
Normal file
65
server/src/middleware/authMiddleware.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user