Compare commits
18 Commits
6b05d04773
...
9e5e77755e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e5e77755e | ||
|
|
a17d6dde9b | ||
|
|
6a5cfa7806 | ||
|
|
ad88ec7e41 | ||
|
|
cbbff61082 | ||
|
|
bb774c332b | ||
|
|
af0604bce4 | ||
|
|
6de7562cd8 | ||
|
|
5f07dad019 | ||
|
|
acc6a2474b | ||
|
|
8882bf44a4 | ||
|
|
8616a13525 | ||
|
|
c83340742c | ||
|
|
8e37e5a551 | ||
|
|
a71c5438ce | ||
|
|
0177758e1f | ||
|
|
637347082a | ||
|
|
3b2d0697f0 |
@@ -2,9 +2,9 @@
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"name": "client",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"runtimeArgs": ["start", "--workspace=client"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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
|
||||
|
||||
87
README.md
87
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,21 +104,38 @@ 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 文档
|
||||
|
||||
### 认证
|
||||
- `POST /api/auth/login` - 用户登录
|
||||
- `POST /api/auth/register` - 用户注册(email, garminEmail, garminPassword)
|
||||
- `POST /api/auth/login` - 用户登录(email, password)
|
||||
- `POST /api/auth/logout` - 用户登出
|
||||
- `POST /api/auth/refresh` - 刷新 Token
|
||||
|
||||
|
||||
59
backend/.env.example
Normal file
59
backend/.env.example
Normal file
@@ -0,0 +1,59 @@
|
||||
# --- Server ---
|
||||
# BACKEND_PORT takes precedence over PORT. Prefer it: many tools inject PORT
|
||||
# for the frontend, and Flask would otherwise take the React dev server's port.
|
||||
BACKEND_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
|
||||
|
||||
# --- AI models (text-only, large context) ---
|
||||
# Put REAL keys in backend/.env — that file is gitignored. Never commit keys.
|
||||
# Any model whose credentials are absent is skipped automatically.
|
||||
|
||||
# Self-hosted AI gateway (model id "gateway"). OpenAI-compatible; it fans out
|
||||
# over nvidia/gemini/ollama itself and rotates several Gemini keys, so it
|
||||
# absorbs single-vendor quota limits. Reached directly, bypassing any local
|
||||
# HTTP proxy. NOTE: its NVIDIA upstream is a large reasoning model — replies
|
||||
# can take 2-3 minutes, so set AI_TIMEOUT_SECONDS accordingly.
|
||||
AI_GATEWAY_BASE_URL=http://129.146.203.203:5100/v1
|
||||
AI_GATEWAY_TOKEN=
|
||||
AI_GATEWAY_MODEL=ai-gateway-auto
|
||||
|
||||
# Google AI Studio -> "gemini-flash". Free-tier quota is small; 429s are common.
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# NVIDIA NIM -> "llama-70b", "nemotron-49b", "mistral-large".
|
||||
# Model ids come from that account's live GET /v1/models — do not guess them.
|
||||
NVIDIA_API_KEY=
|
||||
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||
|
||||
# Preference order. The first configured model answers; if it fails or times
|
||||
# out, the next is tried. Read per request, so changes need no restart.
|
||||
AI_MODEL_CHAIN=gateway,gemini-flash,llama-70b
|
||||
|
||||
# Max days of history sent (CSV-encoded). Trimmed further per model so the
|
||||
# payload always fits that model's own context window.
|
||||
AI_DAY_BUDGET=365
|
||||
|
||||
AI_TIMEOUT_SECONDS=180
|
||||
# Output cap. Reasoning models spend part of it thinking before they answer;
|
||||
# entries that need more declare their own budget in services/ai.py.
|
||||
AI_MAX_TOKENS=1024
|
||||
70
backend/app.py
Normal file
70
backend/app.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
import os
|
||||
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
from flask_cors import CORS
|
||||
|
||||
import db
|
||||
from config import CORS_ORIGINS, PORT, STATIC_DIR
|
||||
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()
|
||||
|
||||
# In production the built React app is served by this same process, so the
|
||||
# deployment is a single port with no reverse proxy to configure. In
|
||||
# development STATIC_DIR does not exist and the CRA dev server serves the
|
||||
# UI instead — hence the guard rather than an unconditional route.
|
||||
has_ui = bool(STATIC_DIR) and os.path.isfile(os.path.join(STATIC_DIR, "index.html"))
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
if has_ui:
|
||||
return send_from_directory(STATIC_DIR, "index.html")
|
||||
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):
|
||||
# API paths always answer in JSON. Everything else falls through to the
|
||||
# SPA so client-side routes (/settings, /recommendations, ...) survive a
|
||||
# page reload instead of 404-ing.
|
||||
from flask import request
|
||||
|
||||
if has_ui and not request.path.startswith("/api/"):
|
||||
asset = request.path.lstrip("/")
|
||||
if asset and os.path.isfile(os.path.join(STATIC_DIR, asset)):
|
||||
return send_from_directory(STATIC_DIR, asset)
|
||||
return send_from_directory(STATIC_DIR, "index.html")
|
||||
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)
|
||||
117
backend/auth.py
Normal file
117
backend/auth.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Authentication helpers: password hashing, JWT signing/verification,
|
||||
and the require_auth decorator used by route blueprints.
|
||||
|
||||
Hashes are stored in a self-describing format so the algorithm can be
|
||||
migrated later without invalidating existing rows:
|
||||
|
||||
pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>
|
||||
|
||||
PBKDF2-HMAC-SHA256 is used because it is available in every CPython build.
|
||||
`hashlib.scrypt` is NOT: builds linked against LibreSSL (notably the system
|
||||
Python on macOS) omit it, which made registration fail with a 500.
|
||||
Legacy `<salt_hex>:<hash_hex>` scrypt hashes are still verified when the
|
||||
running interpreter supports scrypt.
|
||||
"""
|
||||
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
|
||||
|
||||
PBKDF2_ITERATIONS = 200_000
|
||||
PBKDF2_PREFIX = "pbkdf2_sha256"
|
||||
_SALT_BYTES = 16
|
||||
_DK_LEN = 64
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, code, message):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _pbkdf2(password: str, salt: bytes, iterations: int) -> str:
|
||||
return hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, iterations, dklen=_DK_LEN
|
||||
).hex()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(_SALT_BYTES)
|
||||
derived = _pbkdf2(password, salt, PBKDF2_ITERATIONS)
|
||||
return f"{PBKDF2_PREFIX}${PBKDF2_ITERATIONS}${salt.hex()}${derived}"
|
||||
|
||||
|
||||
def _verify_legacy_scrypt(password: str, stored: str) -> bool:
|
||||
"""Verify a pre-migration `salt_hex:hash_hex` scrypt hash, if supported."""
|
||||
if not hasattr(hashlib, "scrypt"):
|
||||
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=_DK_LEN
|
||||
).hex()
|
||||
return hmac.compare_digest(derived, hash_hex)
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
if not stored:
|
||||
return False
|
||||
|
||||
if stored.startswith(PBKDF2_PREFIX + "$"):
|
||||
try:
|
||||
_, iterations, salt_hex, hash_hex = stored.split("$", 3)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
derived = _pbkdf2(password, salt, int(iterations))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return hmac.compare_digest(derived, hash_hex)
|
||||
|
||||
if ":" in stored:
|
||||
return _verify_legacy_scrypt(password, stored)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
62
backend/config.py
Normal file
62
backend/config.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
# Who may create an account.
|
||||
# "auto" - only while no user exists yet (first-run setup, then closed).
|
||||
# "true" - always open.
|
||||
# "false" - never; accounts must be created out of band.
|
||||
# "auto" is the default because this deployment is reachable from the public
|
||||
# internet, where an open registration endpoint would let anyone create an
|
||||
# account and start pulling health data.
|
||||
ALLOW_REGISTRATION = (os.environ.get("ALLOW_REGISTRATION") or "auto").lower()
|
||||
|
||||
# --- Static UI --------------------------------------------------------------
|
||||
# Directory holding the built React app. When set and populated, the Flask
|
||||
# process serves the UI too, so a deployment is one port and one service.
|
||||
STATIC_DIR = os.environ.get("STATIC_DIR") or os.path.join(_BACKEND_DIR, "static")
|
||||
|
||||
# --- Server -----------------------------------------------------------------
|
||||
# BACKEND_PORT wins over PORT: `PORT` is set by many dev tools and PaaS
|
||||
# runtimes for the *frontend*, and letting it through made Flask seize the
|
||||
# React dev server's port during `npm run dev`.
|
||||
PORT = int(os.environ.get("BACKEND_PORT") or 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()]
|
||||
381
backend/db.py
Normal file
381
backend/db.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
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)
|
||||
);
|
||||
|
||||
-- Garmin OAuth tokens, obtained once through an interactive login.
|
||||
-- Garmin accounts with two-factor auth cannot be logged into unattended: the
|
||||
-- library asks for an MFA code on stdin, which a gunicorn worker does not
|
||||
-- have. Storing the resulting tokens lets every later sync skip the login
|
||||
-- entirely (they stay valid for roughly a year).
|
||||
CREATE TABLE IF NOT EXISTS garmin_tokens (
|
||||
user_id VARCHAR(64) PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
garmin_email VARCHAR(255),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Badges earned on Garmin Connect ("奖励"). Keyed by Garmin's own badge id so
|
||||
-- a re-sync updates rather than duplicates.
|
||||
CREATE TABLE IF NOT EXISTS badges (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
badge_key VARCHAR(128),
|
||||
name VARCHAR(255),
|
||||
category_id INT,
|
||||
difficulty_id INT,
|
||||
earned_date DATETIME,
|
||||
earned_count INT,
|
||||
points INT,
|
||||
PRIMARY KEY (user_id, id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Personal records (个人纪录), e.g. fastest 5k, longest run.
|
||||
CREATE TABLE IF NOT EXISTS personal_records (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
type_id INT,
|
||||
activity_id VARCHAR(64),
|
||||
activity_name VARCHAR(255),
|
||||
activity_type VARCHAR(64),
|
||||
value DOUBLE,
|
||||
achieved_at DATETIME,
|
||||
PRIMARY KEY (user_id, id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Rendezvous for the interactive MFA login.
|
||||
-- garth asks for the code through a *blocking* callback, so the login parks in
|
||||
-- a background thread while the code arrives in a separate HTTP request that
|
||||
-- may land on a different gunicorn worker. The handoff therefore goes through
|
||||
-- the database rather than process memory.
|
||||
-- Holds no password: that stays in the waiting thread's memory only.
|
||||
CREATE TABLE IF NOT EXISTS garmin_mfa_sessions (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
code VARCHAR(16),
|
||||
error TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- One cached LLM answer per user. Generating one takes minutes against a
|
||||
-- large reasoning model, which is far too slow to sit in a page load, so the
|
||||
-- result is stored and reused until the underlying data changes.
|
||||
-- `fingerprint` identifies the health data the advice was derived from.
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
user_id VARCHAR(64) PRIMARY KEY,
|
||||
fingerprint VARCHAR(64) NOT NULL,
|
||||
model VARCHAR(64),
|
||||
upstream VARCHAR(64),
|
||||
days INT,
|
||||
payload TEXT NOT NULL,
|
||||
created_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()}
|
||||
|
||||
|
||||
# Columns added after the first release. `CREATE TABLE IF NOT EXISTS` does
|
||||
# nothing to a table that already exists, so new metrics need an explicit
|
||||
# additive migration or they silently never appear in production.
|
||||
MIGRATIONS = {
|
||||
"sync_status": [
|
||||
# A full backfill runs for many minutes, so the UI needs to show how
|
||||
# far along it is rather than an indefinite spinner.
|
||||
("progress_current", "INT"),
|
||||
("progress_total", "INT"),
|
||||
("started_at", "DATETIME"),
|
||||
],
|
||||
"health_data": [
|
||||
# activity / energy
|
||||
("distance_meters", "DOUBLE"),
|
||||
("active_calories", "DOUBLE"),
|
||||
("bmr_calories", "DOUBLE"),
|
||||
("floors_ascended", "DOUBLE"),
|
||||
("floors_descended", "DOUBLE"),
|
||||
("intensity_minutes", "INT"),
|
||||
("step_goal", "INT"),
|
||||
("sedentary_seconds", "INT"),
|
||||
("active_seconds", "INT"),
|
||||
# heart / stress
|
||||
("heart_rate_max", "INT"),
|
||||
("heart_rate_min", "INT"),
|
||||
("stress_max", "INT"),
|
||||
# body battery
|
||||
("body_battery_high", "INT"),
|
||||
("body_battery_low", "INT"),
|
||||
("body_battery_charged", "INT"),
|
||||
("body_battery_drained", "INT"),
|
||||
# breathing / blood oxygen
|
||||
("spo2_avg", "DOUBLE"),
|
||||
("spo2_min", "INT"),
|
||||
("respiration_avg", "DOUBLE"),
|
||||
("respiration_min", "DOUBLE"),
|
||||
("respiration_max", "DOUBLE"),
|
||||
# sleep detail
|
||||
("sleep_deep_seconds", "INT"),
|
||||
("sleep_light_seconds", "INT"),
|
||||
("sleep_rem_seconds", "INT"),
|
||||
("sleep_awake_seconds", "INT"),
|
||||
("sleep_spo2_avg", "DOUBLE"),
|
||||
("sleep_respiration_avg", "DOUBLE"),
|
||||
("sleep_stress_avg", "DOUBLE"),
|
||||
# training
|
||||
("training_readiness", "INT"),
|
||||
("vo2max", "DOUBLE"),
|
||||
("endurance_score", "INT"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _existing_columns(cur, table):
|
||||
if DB_TYPE == "mariadb":
|
||||
cur.execute(
|
||||
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
|
||||
[table],
|
||||
)
|
||||
return {r["COLUMN_NAME"] if isinstance(r, dict) else r[0] for r in cur.fetchall()}
|
||||
cur.execute(f"PRAGMA table_info({table})")
|
||||
return {row[1] for row in cur.fetchall()}
|
||||
|
||||
|
||||
def _migrate(cur):
|
||||
for table, columns in MIGRATIONS.items():
|
||||
present = _existing_columns(cur, table)
|
||||
for name, coltype in columns:
|
||||
if name in present:
|
||||
continue
|
||||
# SQLite has no "ADD COLUMN IF NOT EXISTS"; the membership check
|
||||
# above is what keeps this idempotent on both backends.
|
||||
cur.execute(f"ALTER TABLE {table} ADD COLUMN {name} {coltype}")
|
||||
|
||||
|
||||
# --- 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))
|
||||
_migrate(cur)
|
||||
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)
|
||||
109
backend/garmin_login.py
Normal file
109
backend/garmin_login.py
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
One-time interactive Garmin login.
|
||||
|
||||
Garmin accounts with two-factor auth cannot be logged into by the web service:
|
||||
the library asks for the MFA code on stdin, and a gunicorn worker has none —
|
||||
the attempt fails with "EOFError: EOF when reading a line".
|
||||
|
||||
This script does that login in a terminal, where a code can actually be typed,
|
||||
and stores the resulting OAuth tokens in the database. Every later sync loads
|
||||
those tokens and skips the login entirely. They stay valid for roughly a year;
|
||||
re-run this when a sync starts reporting an expired session.
|
||||
|
||||
Usage (on the NAS):
|
||||
cd ~/apps/garmin-health-lab/backend
|
||||
.venv/bin/python garmin_login.py
|
||||
|
||||
Add --email to pick an account when more than one is registered.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
import db
|
||||
from services import garmin as garmin_svc
|
||||
|
||||
|
||||
def pick_user(email=None):
|
||||
if email:
|
||||
row = db.query_one("SELECT id, email, garmin_email FROM users WHERE email = ?",
|
||||
[email])
|
||||
if not row:
|
||||
sys.exit(f"找不到账号: {email}")
|
||||
return row
|
||||
|
||||
rows = db.query_all("SELECT id, email, garmin_email FROM users ORDER BY created_at")
|
||||
if not rows:
|
||||
sys.exit("数据库里还没有账号,请先在网页上注册。")
|
||||
if len(rows) == 1:
|
||||
return rows[0]
|
||||
|
||||
print("有多个账号,请选择:")
|
||||
for i, r in enumerate(rows, 1):
|
||||
print(f" {i}) {r['email']} (Garmin: {r['garmin_email']})")
|
||||
choice = input("序号: ").strip()
|
||||
try:
|
||||
return rows[int(choice) - 1]
|
||||
except (ValueError, IndexError):
|
||||
sys.exit("选择无效")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="一次性 Garmin 登录,保存令牌")
|
||||
parser.add_argument("--email", help="要绑定的本站账号邮箱")
|
||||
parser.add_argument("--garmin-email", help="Garmin 账号邮箱(默认用注册时填的)")
|
||||
args = parser.parse_args()
|
||||
|
||||
db.init_db()
|
||||
user = pick_user(args.email)
|
||||
garmin_email = args.garmin_email or user["garmin_email"]
|
||||
|
||||
print(f"本站账号 : {user['email']}")
|
||||
print(f"Garmin : {garmin_email}")
|
||||
print()
|
||||
|
||||
if garmin_svc.has_token(user["id"]):
|
||||
if input("已存在登录令牌,要覆盖吗?[y/N] ").strip().lower() != "y":
|
||||
return
|
||||
|
||||
password = getpass.getpass("Garmin 密码: ")
|
||||
if not password:
|
||||
sys.exit("密码不能为空")
|
||||
|
||||
Garmin = garmin_svc._import_garmin()
|
||||
client = Garmin(email=garmin_email, password=password, is_cn=garmin_svc._is_cn())
|
||||
|
||||
def ask_mfa():
|
||||
# garth's built-in prompt is a bare English input() that is easy to
|
||||
# miss in the surrounding output, so this replaces it with something
|
||||
# unmistakable.
|
||||
print("\n" + "=" * 52)
|
||||
print(" 账号开启了两步验证,请查收短信/邮件中的验证码")
|
||||
print("=" * 52)
|
||||
while True:
|
||||
code = input(" 验证码(6 位数字): ").strip()
|
||||
if code:
|
||||
return code
|
||||
print(" 验证码不能为空,请重新输入。")
|
||||
|
||||
print("\n正在登录……")
|
||||
try:
|
||||
# Call garth directly rather than Garmin.login(): only this path lets
|
||||
# the MFA prompt be replaced. The two lines afterwards are what
|
||||
# Garmin.login() would otherwise populate.
|
||||
client.garth.login(garmin_email, password, prompt_mfa=ask_mfa)
|
||||
client.display_name = client.garth.profile["displayName"]
|
||||
client.full_name = client.garth.profile["fullName"]
|
||||
except Exception as e:
|
||||
sys.exit(f"\n登录失败: {type(e).__name__}: {e}")
|
||||
|
||||
garmin_svc.save_token(user["id"], client.garth.dumps(), garmin_email)
|
||||
|
||||
print(f"\n登录成功:{client.display_name}")
|
||||
print("令牌已保存到数据库,之后网页上的同步不再需要密码或验证码。")
|
||||
print("令牌大约一年后过期,届时重跑本脚本即可。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
backend/pytest.ini
Normal file
8
backend/pytest.ini
Normal file
@@ -0,0 +1,8 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
addopts = -q --strict-markers
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
ignore::UserWarning
|
||||
4
backend/requirements-dev.txt
Normal file
4
backend/requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
|
||||
pytest>=7.4
|
||||
pytest-cov>=4.1
|
||||
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
|
||||
requests>=2.31
|
||||
garminconnect>=0.2.8
|
||||
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"]
|
||||
49
backend/routes/analysis.py
Normal file
49
backend/routes/analysis.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Analysis routes: trends + recommendations."""
|
||||
from flask import Blueprint, request, g, jsonify
|
||||
|
||||
from auth import require_auth
|
||||
from services import analysis as analysis_svc
|
||||
from services import ai as ai_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))
|
||||
|
||||
|
||||
@bp.route("/models", methods=["GET"])
|
||||
@require_auth
|
||||
def models():
|
||||
"""Available LLMs and whether each one has credentials configured."""
|
||||
return jsonify(ai_svc.list_models())
|
||||
|
||||
|
||||
@bp.route("/ai-recommendations", methods=["GET"])
|
||||
@require_auth
|
||||
def ai_recommendations():
|
||||
"""LLM recommendations. `?model=` picks one; omit it to use the chain.
|
||||
|
||||
Served from cache unless `?refresh=1` or an explicit `model` is given —
|
||||
a fresh generation can take minutes against a large reasoning model.
|
||||
|
||||
Always 200: when no model succeeds the rule engine answers instead, and
|
||||
meta.source says which produced the result.
|
||||
"""
|
||||
model = request.args.get("model") or None
|
||||
days = request.args.get("days", type=int)
|
||||
refresh = request.args.get("refresh") in ("1", "true", "yes")
|
||||
return jsonify(
|
||||
analysis_svc.get_ai_recommendations(g.user_id, model, days, refresh)
|
||||
)
|
||||
88
backend/routes/auth.py
Normal file
88
backend/routes/auth.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Auth routes: register / login / logout / refresh."""
|
||||
import uuid
|
||||
|
||||
from flask import Blueprint, request, g, jsonify
|
||||
|
||||
import os
|
||||
|
||||
from auth import hash_password, verify_password, sign_token, require_auth
|
||||
import config
|
||||
from db import execute, query_one
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
def registration_open():
|
||||
"""Whether an account may be created right now.
|
||||
|
||||
Under the default "auto" policy the endpoint closes as soon as the first
|
||||
account exists, so a publicly reachable deployment cannot be signed up to
|
||||
by strangers.
|
||||
"""
|
||||
# Read at call time, not import time, so the policy can be changed without
|
||||
# a restart and so tests are not bound to whatever .env held at startup.
|
||||
policy = (os.environ.get("ALLOW_REGISTRATION") or config.ALLOW_REGISTRATION).lower()
|
||||
if policy == "true":
|
||||
return True
|
||||
if policy == "false":
|
||||
return False
|
||||
return query_one("SELECT id FROM users LIMIT 1") is None
|
||||
|
||||
|
||||
@bp.route("/registration-status", methods=["GET"])
|
||||
def registration_status():
|
||||
"""Lets the UI hide the sign-up tab when registration is closed."""
|
||||
return jsonify({"open": registration_open()})
|
||||
|
||||
|
||||
@bp.route("/register", methods=["POST"])
|
||||
def register():
|
||||
if not registration_open():
|
||||
return jsonify({"error": "注册已关闭:本实例已有账号"}), 403
|
||||
|
||||
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})
|
||||
121
backend/routes/garmin.py
Normal file
121
backend/routes/garmin.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""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
|
||||
from services import garmin_auth
|
||||
|
||||
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"]
|
||||
|
||||
# With stored OAuth tokens no password is needed at all. Without them the
|
||||
# plaintext password must come in the body, because only a hash is kept.
|
||||
if not creds["garminPassword"] and not garmin_svc.has_token(g.user_id):
|
||||
return (
|
||||
jsonify({
|
||||
"status": "error",
|
||||
"recordsSynced": 0,
|
||||
"message": "需要 Garmin 密码以执行同步,请在请求体中提供 garminPassword"
|
||||
"(密码仅作哈希存储,无法还原)。",
|
||||
}),
|
||||
400,
|
||||
)
|
||||
|
||||
days = request.get_json(silent=True).get("days") if request.is_json else None
|
||||
try:
|
||||
days = max(1, min(int(days), 730)) if days else None
|
||||
except (TypeError, ValueError):
|
||||
days = None
|
||||
|
||||
# Always run in the background: even a week takes ~20s, and a full
|
||||
# backfill runs for many minutes. Progress is polled via /status.
|
||||
result = garmin_svc.start_sync(g.user_id, creds, days)
|
||||
return jsonify(result), 202
|
||||
|
||||
|
||||
@bp.route("/auth-status", methods=["GET"])
|
||||
@require_auth
|
||||
def auth_status():
|
||||
"""Whether a stored token exists, so the UI knows to ask for a password."""
|
||||
return jsonify({"hasToken": garmin_svc.has_token(g.user_id)})
|
||||
|
||||
|
||||
@bp.route("/login", methods=["POST"])
|
||||
@require_auth
|
||||
def login():
|
||||
"""Begin an interactive Garmin login.
|
||||
|
||||
Returns immediately with a session id; the login continues in the
|
||||
background and parks if Garmin asks for a two-factor code. Poll
|
||||
/login-status and post the code to /mfa.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
password = data.get("garminPassword") or ""
|
||||
if not password:
|
||||
return jsonify({"error": "请提供 Garmin 密码"}), 400
|
||||
|
||||
garmin_email = (data.get("garminEmail") or "").strip()
|
||||
if not garmin_email:
|
||||
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id])
|
||||
garmin_email = (user or {}).get("garmin_email") or ""
|
||||
if not garmin_email:
|
||||
return jsonify({"error": "缺少 Garmin 邮箱"}), 400
|
||||
|
||||
session_id = garmin_auth.start_login(g.user_id, garmin_email, password)
|
||||
return jsonify({"session": session_id, "status": "starting"}), 202
|
||||
|
||||
|
||||
@bp.route("/login-status", methods=["GET"])
|
||||
@require_auth
|
||||
def login_status():
|
||||
session_id = request.args.get("session") or ""
|
||||
row = garmin_auth.get_session(session_id, g.user_id)
|
||||
if not row:
|
||||
return jsonify({"error": "登录会话不存在或已过期"}), 404
|
||||
return jsonify({
|
||||
"session": row["id"],
|
||||
"status": row["status"],
|
||||
"error": row["error"],
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/mfa", methods=["POST"])
|
||||
@require_auth
|
||||
def submit_mfa():
|
||||
data = request.get_json(silent=True) or {}
|
||||
session_id = (data.get("session") or "").strip()
|
||||
code = (data.get("code") or "").strip()
|
||||
if not session_id or not code:
|
||||
return jsonify({"error": "session 与 code 均为必填"}), 400
|
||||
|
||||
ok, message = garmin_auth.submit_code(session_id, g.user_id, code)
|
||||
return jsonify({"ok": ok, "message": message}), (200 if ok else 400)
|
||||
|
||||
|
||||
@bp.route("/login", methods=["DELETE"])
|
||||
@require_auth
|
||||
def cancel_login():
|
||||
session_id = request.args.get("session") or ""
|
||||
garmin_auth.cancel(session_id, g.user_id)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.route("/status", methods=["GET"])
|
||||
@require_auth
|
||||
def status():
|
||||
return jsonify(garmin_svc.get_sync_status(g.user_id))
|
||||
59
backend/routes/health.py
Normal file
59
backend/routes/health.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""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))
|
||||
|
||||
|
||||
@bp.route("/badges", methods=["GET"])
|
||||
@require_auth
|
||||
def badges():
|
||||
"""Earned badges (奖励), most recent first."""
|
||||
return jsonify(health_svc.get_badges(g.user_id))
|
||||
|
||||
|
||||
@bp.route("/personal-records", methods=["GET"])
|
||||
@require_auth
|
||||
def personal_records():
|
||||
return jsonify(health_svc.get_personal_records(g.user_id))
|
||||
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"]
|
||||
551
backend/services/ai.py
Normal file
551
backend/services/ai.py
Normal file
@@ -0,0 +1,551 @@
|
||||
"""
|
||||
Multi-provider LLM layer for health recommendations.
|
||||
|
||||
Design goals
|
||||
------------
|
||||
* **Switchable models** — every model lives in a catalog keyed by a short id
|
||||
("gemini-flash", "llama-70b", ...). Callers pass an id; nothing else in the
|
||||
codebase knows which vendor is behind it.
|
||||
* **Large context** — daily metrics are serialised as compact CSV rather than
|
||||
JSON, so a year of data costs a few thousand tokens instead of tens of
|
||||
thousands. Each model declares its own window and the payload is trimmed to
|
||||
fit the smallest of (model window, configured day budget).
|
||||
* **Fallback** — if the preferred model errors or times out, the next healthy
|
||||
model in the chain is tried before giving up. This mirrors the behaviour the
|
||||
NAS deployment already relies on (Gemini primary, NVIDIA secondary).
|
||||
|
||||
Only text-in/text-out models are supported; no vision models are registered.
|
||||
API keys are read from the environment — never hardcode them.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
# Tunables are read per call rather than captured at import: module-level
|
||||
# constants freeze whatever the environment held when the module first loaded,
|
||||
# which both hides live config changes and leaks a developer's .env into tests.
|
||||
FALLBACK_TIMEOUT = 60.0
|
||||
FALLBACK_DAY_BUDGET = 365
|
||||
|
||||
|
||||
def default_timeout():
|
||||
return float(os.environ.get("AI_TIMEOUT_SECONDS") or FALLBACK_TIMEOUT)
|
||||
|
||||
|
||||
def default_day_budget():
|
||||
"""Max days of history to put in a prompt, before per-model trimming."""
|
||||
return int(os.environ.get("AI_DAY_BUDGET") or FALLBACK_DAY_BUDGET)
|
||||
|
||||
|
||||
# Output cap. Deliberately modest: a long generation is what blows past an
|
||||
# upstream's own timeout (the self-hosted gateway allows its adapters only
|
||||
# 30-45s), and the reply here is a short JSON list, not an essay.
|
||||
FALLBACK_MAX_TOKENS = 1024
|
||||
|
||||
|
||||
def default_max_tokens():
|
||||
return int(os.environ.get("AI_MAX_TOKENS") or FALLBACK_MAX_TOKENS)
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"你是一名严谨的健康数据分析助手,负责解读用户的可穿戴设备(Garmin)数据。\n"
|
||||
"要求:\n"
|
||||
"1. 只依据给出的数据得出结论,数据不足时明确说明,不要编造数值。\n"
|
||||
"2. 指出趋势、异常和相互关联(例如睡眠不足与静息心率升高的关系)。\n"
|
||||
"3. 给出具体、可执行的建议,而不是泛泛而谈。\n"
|
||||
"4. 你不是医生,不做诊断;发现明显异常时建议用户咨询专业医师。\n"
|
||||
"5. 用简体中文回答。\n\n"
|
||||
"输出严格为 JSON 数组,最多 5 条,每条 recommendation 不超过 120 字,\n"
|
||||
"每个元素形如:\n"
|
||||
'{"category": "睡眠", "recommendation": "……", "priority": "high|medium|low", '
|
||||
'"basedOn": ["sleep_duration"]}\n'
|
||||
"不要输出 JSON 以外的任何文字,不要用 markdown 代码块包裹。"
|
||||
)
|
||||
|
||||
|
||||
class AIError(Exception):
|
||||
"""Raised when a provider cannot produce a completion."""
|
||||
|
||||
|
||||
class Completion:
|
||||
"""A model reply plus, where the endpoint reports it, the upstream that
|
||||
actually served the request.
|
||||
|
||||
The self-hosted gateway multiplexes over nvidia/gemini/ollama and names
|
||||
the winner in its response, so `upstream` is what makes a gateway-side
|
||||
failover visible to the UI instead of silently invisible.
|
||||
"""
|
||||
|
||||
__slots__ = ("text", "upstream")
|
||||
|
||||
def __init__(self, text, upstream=None):
|
||||
self.text = text
|
||||
self.upstream = upstream
|
||||
|
||||
|
||||
# --- providers --------------------------------------------------------------
|
||||
class Provider:
|
||||
"""Base class. Subclasses turn a prompt into text.
|
||||
|
||||
`use_proxy` decides whether HTTP(S)_PROXY / ALL_PROXY from the environment
|
||||
apply. It matters because the two kinds of endpoint want opposite answers:
|
||||
overseas vendors (Gemini, NVIDIA) may only be reachable *through* a local
|
||||
proxy, while a self-hosted box on a public IP is reachable directly and
|
||||
breaks if forced through one.
|
||||
"""
|
||||
|
||||
name = "base"
|
||||
|
||||
def __init__(
|
||||
self, model_id, context_window, api_key_env, use_proxy=True, max_tokens=None
|
||||
):
|
||||
self.model_id = model_id
|
||||
self.context_window = context_window
|
||||
self.api_key_env = api_key_env
|
||||
self.use_proxy = use_proxy
|
||||
self._max_tokens = max_tokens
|
||||
|
||||
@property
|
||||
def max_tokens(self):
|
||||
"""Output cap for this endpoint.
|
||||
|
||||
Reasoning models emit a chain-of-thought *before* the answer, so a cap
|
||||
sized for the answer alone gets spent on the thinking and truncates
|
||||
before any JSON appears. Those endpoints therefore declare a larger
|
||||
budget than the default.
|
||||
"""
|
||||
return self._max_tokens or default_max_tokens()
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
return os.environ.get(self.api_key_env) or ""
|
||||
|
||||
def is_configured(self):
|
||||
return bool(self.api_key)
|
||||
|
||||
def _session(self):
|
||||
session = requests.Session()
|
||||
# trust_env=False also drops netrc/CA-bundle env lookups, which is the
|
||||
# intent here: talk to the host directly, exactly as configured.
|
||||
session.trust_env = self.use_proxy
|
||||
return session
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class GeminiProvider(Provider):
|
||||
"""Google AI Studio (generativelanguage.googleapis.com)."""
|
||||
|
||||
name = "gemini"
|
||||
BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
if not self.is_configured():
|
||||
raise AIError(f"{self.api_key_env} 未配置")
|
||||
timeout = timeout or default_timeout()
|
||||
url = f"{self.BASE}/{self.model_id}:generateContent"
|
||||
payload = {
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {
|
||||
"temperature": 0.4,
|
||||
"maxOutputTokens": self.max_tokens,
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = self._session().post(
|
||||
url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-goog-api-key": self.api_key,
|
||||
},
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise AIError(f"gemini 请求失败: {e}") from e
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise AIError(f"gemini HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
try:
|
||||
body = resp.json()
|
||||
parts = body["candidates"][0]["content"]["parts"]
|
||||
return Completion("".join(p.get("text", "") for p in parts))
|
||||
except (ValueError, KeyError, IndexError) as e:
|
||||
raise AIError(f"gemini 响应格式异常: {e}") from e
|
||||
|
||||
|
||||
class OpenAICompatProvider(Provider):
|
||||
"""Any endpoint speaking the OpenAI chat-completions schema (NVIDIA NIM,
|
||||
Ollama, vLLM, ...).
|
||||
|
||||
`requires_key=False` covers self-hosted runtimes such as Ollama, which
|
||||
authenticate by network reachability rather than by a token. Those are
|
||||
opt-in: they count as configured only once their base URL is set, so an
|
||||
unset OLLAMA_BASE_URL keeps the entry out of the fallback chain.
|
||||
"""
|
||||
|
||||
name = "openai-compat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_id,
|
||||
context_window,
|
||||
base_url_env,
|
||||
default_base_url="",
|
||||
api_key_env=None,
|
||||
requires_key=True,
|
||||
use_proxy=True,
|
||||
max_tokens=None,
|
||||
):
|
||||
super().__init__(
|
||||
model_id, context_window, api_key_env or "", use_proxy, max_tokens
|
||||
)
|
||||
self.base_url_env = base_url_env
|
||||
self.default_base_url = default_base_url
|
||||
self.requires_key = requires_key
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
return os.environ.get(self.base_url_env) or self.default_base_url
|
||||
|
||||
def is_configured(self):
|
||||
if not self.base_url:
|
||||
return False
|
||||
return bool(self.api_key) if self.requires_key else True
|
||||
|
||||
def generate(self, prompt, timeout=None):
|
||||
if not self.is_configured():
|
||||
raise AIError(
|
||||
f"{self.api_key_env} 未配置" if self.requires_key
|
||||
else f"{self.base_url_env} 未配置"
|
||||
)
|
||||
timeout = timeout or default_timeout()
|
||||
url = f"{self.base_url.rstrip('/')}/chat/completions"
|
||||
payload = {
|
||||
"model": self.model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.4,
|
||||
"max_tokens": self.max_tokens,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
try:
|
||||
resp = self._session().post(
|
||||
url, headers=headers, json=payload, timeout=timeout
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise AIError(f"{self.model_id} 请求失败: {e}") from e
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise AIError(f"{self.model_id} HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
try:
|
||||
body = resp.json()
|
||||
# `provider` is a gateway extension, absent from stock OpenAI
|
||||
# responses — hence the .get rather than an index.
|
||||
return Completion(
|
||||
body["choices"][0]["message"]["content"], body.get("provider")
|
||||
)
|
||||
except (ValueError, KeyError, IndexError) as e:
|
||||
raise AIError(f"{self.model_id} 响应格式异常: {e}") from e
|
||||
|
||||
|
||||
# --- catalog ----------------------------------------------------------------
|
||||
NVIDIA_BASE = "https://integrate.api.nvidia.com/v1"
|
||||
|
||||
|
||||
def _nvidia(model_id, context_window):
|
||||
return OpenAICompatProvider(
|
||||
model_id=model_id,
|
||||
context_window=context_window,
|
||||
api_key_env="NVIDIA_API_KEY",
|
||||
base_url_env="NVIDIA_BASE_URL",
|
||||
default_base_url=NVIDIA_BASE,
|
||||
)
|
||||
|
||||
|
||||
def _build_catalog():
|
||||
"""Model id -> Provider. Text-only models with large context windows.
|
||||
|
||||
The NVIDIA model strings below were taken from that account's live
|
||||
`GET /v1/models` listing. Do not guess them: ids that merely look
|
||||
plausible (`qwen/qwen2.5-72b-instruct`, `deepseek-ai/deepseek-r1`)
|
||||
return HTTP 404 from this endpoint.
|
||||
"""
|
||||
return {
|
||||
# Preferred entry: the self-hosted gateway on the Oracle box. It
|
||||
# multiplexes over nvidia/gemini/ollama behind one OpenAI-compatible
|
||||
# endpoint and rotates several Gemini keys, so it absorbs the quota
|
||||
# and timeout failures that a single upstream hits on its own. Its
|
||||
# reply names the upstream that served the request.
|
||||
"gateway": OpenAICompatProvider(
|
||||
model_id=os.environ.get("AI_GATEWAY_MODEL") or "ai-gateway-auto",
|
||||
context_window=128_000,
|
||||
api_key_env="AI_GATEWAY_TOKEN",
|
||||
base_url_env="AI_GATEWAY_BASE_URL",
|
||||
# Self-hosted and directly reachable: a local proxy would only
|
||||
# add a hop that times out.
|
||||
use_proxy=False,
|
||||
# Its primary upstream is a reasoning model that thinks out loud
|
||||
# before answering; at the default cap the trace consumed the whole
|
||||
# budget and the reply was truncated before the JSON began.
|
||||
max_tokens=3000,
|
||||
),
|
||||
# Direct upstreams, for pinning one vendor or for running without the
|
||||
# gateway. These need their own keys in this app's .env.
|
||||
"gemini-flash": GeminiProvider(
|
||||
model_id="gemini-flash-latest",
|
||||
context_window=1_000_000,
|
||||
api_key_env="GEMINI_API_KEY",
|
||||
),
|
||||
"llama-70b": _nvidia("meta/llama-3.3-70b-instruct", 128_000),
|
||||
"nemotron-49b": _nvidia("nvidia/llama-3.3-nemotron-super-49b-v1.5", 128_000),
|
||||
"mistral-large": _nvidia("mistralai/mistral-large-2-instruct", 128_000),
|
||||
}
|
||||
|
||||
|
||||
CATALOG = _build_catalog()
|
||||
|
||||
# Preference order used when no model is requested, and for fallback.
|
||||
FALLBACK_CHAIN = "gateway,gemini-flash,llama-70b"
|
||||
|
||||
|
||||
def default_chain():
|
||||
"""Preference order, read from the environment on every call.
|
||||
|
||||
Deliberately not a module-level constant: it is read at request time so a
|
||||
changed AI_MODEL_CHAIN takes effect without a restart, and so tests can
|
||||
set it without reaching into module internals.
|
||||
"""
|
||||
raw = os.environ.get("AI_MODEL_CHAIN") or FALLBACK_CHAIN
|
||||
return [m.strip() for m in raw.split(",") if m.strip()]
|
||||
|
||||
|
||||
def list_models():
|
||||
"""Catalog entries plus whether each one currently has credentials."""
|
||||
chain = default_chain()
|
||||
head = chain[0] if chain else None
|
||||
return [
|
||||
{
|
||||
"id": mid,
|
||||
"model": p.model_id,
|
||||
"provider": p.name,
|
||||
"contextWindow": p.context_window,
|
||||
"configured": p.is_configured(),
|
||||
"default": mid == head,
|
||||
}
|
||||
for mid, p in CATALOG.items()
|
||||
]
|
||||
|
||||
|
||||
def resolve_chain(preferred=None):
|
||||
"""Ordered list of model ids to attempt, configured ones only."""
|
||||
chain = []
|
||||
if preferred:
|
||||
if preferred not in CATALOG:
|
||||
raise AIError(f"未知模型: {preferred}")
|
||||
chain.append(preferred)
|
||||
for mid in default_chain():
|
||||
if mid in CATALOG and mid not in chain:
|
||||
chain.append(mid)
|
||||
configured = [m for m in chain if CATALOG[m].is_configured()]
|
||||
if not configured:
|
||||
raise AIError(
|
||||
"没有可用的模型:请在 backend/.env 中配置 GEMINI_API_KEY 或 NVIDIA_API_KEY"
|
||||
)
|
||||
return configured
|
||||
|
||||
|
||||
# --- prompt construction ----------------------------------------------------
|
||||
# Kept deliberately short: every extra column multiplies by the number of
|
||||
# days sent, and the column names double as the vocabulary the model cites
|
||||
# back in `basedOn`.
|
||||
_CSV_COLUMNS = [
|
||||
("date", "date"),
|
||||
("steps", "steps"),
|
||||
("distanceMeters", "dist_m"),
|
||||
("heartRate", "rest_hr"),
|
||||
("heartRateMax", "max_hr"),
|
||||
("heartRateVariability", "hrv"),
|
||||
("stress", "stress"),
|
||||
("stressMax", "stress_max"),
|
||||
("bodyBatteryHigh", "bb_high"),
|
||||
("bodyBatteryLow", "bb_low"),
|
||||
("spo2Avg", "spo2"),
|
||||
("respirationAvg", "resp"),
|
||||
("intensityMinutes", "intensity_min"),
|
||||
("caloriesBurned", "kcal"),
|
||||
("activeCalories", "active_kcal"),
|
||||
("floorsAscended", "floors"),
|
||||
("trainingReadiness", "readiness"),
|
||||
("enduranceScore", "endurance"),
|
||||
]
|
||||
|
||||
|
||||
def build_prompt(summary, activities=None, day_budget=None):
|
||||
"""Render health history as a compact CSV prompt.
|
||||
|
||||
CSV rather than JSON: roughly 4x fewer tokens for the same numbers, which
|
||||
is what makes a full year of history practical to send.
|
||||
"""
|
||||
day_budget = day_budget if day_budget is not None else default_day_budget()
|
||||
rows = summary[-day_budget:] if day_budget else summary
|
||||
header = (
|
||||
",".join(label for _, label in _CSV_COLUMNS)
|
||||
+ ",sleep_h,sleep_q,sleep_deep_s,sleep_rem_s,sleep_awake_s"
|
||||
)
|
||||
lines = [header]
|
||||
for r in rows:
|
||||
cells = []
|
||||
for key, _ in _CSV_COLUMNS:
|
||||
value = r.get(key)
|
||||
cells.append("" if value is None else str(value))
|
||||
sleep = r.get("sleep") or {}
|
||||
for key in ("duration", "quality", "deepSeconds", "remSeconds", "awakeSeconds"):
|
||||
value = sleep.get(key)
|
||||
cells.append("" if value is None else str(value))
|
||||
lines.append(",".join(cells))
|
||||
|
||||
sections = [
|
||||
SYSTEM_PROMPT,
|
||||
f"\n## 每日健康数据(共 {len(rows)} 天,CSV)\n" + "\n".join(lines),
|
||||
]
|
||||
|
||||
if activities:
|
||||
act_lines = ["type,start,duration_s,distance_km,kcal,avg_hr,max_hr"]
|
||||
for a in activities[:200]:
|
||||
act_lines.append(
|
||||
",".join(
|
||||
str(a.get(k) if a.get(k) is not None else "")
|
||||
for k in (
|
||||
"activity_type", "start_time", "duration",
|
||||
"distance", "calories", "heart_rate_average",
|
||||
"heart_rate_max",
|
||||
)
|
||||
)
|
||||
)
|
||||
sections.append(
|
||||
f"\n## 运动记录(共 {min(len(activities), 200)} 条,CSV)\n"
|
||||
+ "\n".join(act_lines)
|
||||
)
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
# --- response parsing -------------------------------------------------------
|
||||
_VALID_PRIORITIES = {"high", "medium", "low"}
|
||||
_FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def parse_recommendations(text):
|
||||
"""Coerce a model reply into the same shape the rule engine returns.
|
||||
|
||||
Models routinely wrap JSON in markdown fences or add a sentence before it,
|
||||
despite instructions, so both are tolerated here.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
raise AIError("模型返回空响应")
|
||||
|
||||
cleaned = _FENCE.sub("", text).strip()
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
except ValueError:
|
||||
start, end = cleaned.find("["), cleaned.rfind("]")
|
||||
if start == -1 or end <= start:
|
||||
raise AIError(f"模型未返回 JSON 数组: {text[:200]}")
|
||||
try:
|
||||
data = json.loads(cleaned[start : end + 1])
|
||||
except ValueError as e:
|
||||
raise AIError(f"模型返回的 JSON 无法解析: {e}") from e
|
||||
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
if not isinstance(data, list):
|
||||
raise AIError("模型返回的不是 JSON 数组")
|
||||
|
||||
recs = []
|
||||
for i, item in enumerate(data):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text_value = (item.get("recommendation") or "").strip()
|
||||
if not text_value:
|
||||
continue
|
||||
priority = str(item.get("priority", "medium")).lower()
|
||||
if priority not in _VALID_PRIORITIES:
|
||||
priority = "medium"
|
||||
based_on = item.get("basedOn")
|
||||
if not isinstance(based_on, list):
|
||||
based_on = []
|
||||
recs.append(
|
||||
{
|
||||
"id": f"ai-{i}",
|
||||
"category": (item.get("category") or "综合").strip(),
|
||||
"recommendation": text_value,
|
||||
"priority": priority,
|
||||
"basedOn": [str(b) for b in based_on],
|
||||
"source": "ai",
|
||||
}
|
||||
)
|
||||
|
||||
if not recs:
|
||||
raise AIError("模型未返回任何有效建议")
|
||||
|
||||
order = {"high": 0, "medium": 1, "low": 2}
|
||||
recs.sort(key=lambda r: order[r["priority"]])
|
||||
return recs
|
||||
|
||||
|
||||
# --- entry point ------------------------------------------------------------
|
||||
# One CSV day is ~40 characters ≈ 10 tokens. Half the window is left for the
|
||||
# system prompt, the activity table and the model's own answer.
|
||||
_TOKENS_PER_DAY = 10
|
||||
_WINDOW_UTILISATION = 0.5
|
||||
|
||||
|
||||
def max_days_for(provider, day_budget=None):
|
||||
"""How many days of history fit in this model's context window.
|
||||
|
||||
Models in the chain have windows that differ by more than an order of
|
||||
magnitude (32k for a local Ollama vs 1M for Gemini), so the payload has to
|
||||
be sized per model — a prompt that fits Gemini would overflow Ollama.
|
||||
"""
|
||||
day_budget = day_budget if day_budget is not None else default_day_budget()
|
||||
fits = int(provider.context_window * _WINDOW_UTILISATION / _TOKENS_PER_DAY)
|
||||
return max(1, min(day_budget, fits)) if day_budget else max(1, fits)
|
||||
|
||||
|
||||
def generate(summary, activities=None, preferred_model=None, day_budget=None):
|
||||
"""Ask the first healthy model in the chain for recommendations.
|
||||
|
||||
Returns (recommendations, meta). `meta` records which model answered, how
|
||||
much history it actually saw, and every model that failed on the way —
|
||||
the failures are kept even on success so a silent degradation to a weaker
|
||||
model is still visible.
|
||||
"""
|
||||
chain = resolve_chain(preferred_model)
|
||||
errors = []
|
||||
|
||||
for model_id in chain:
|
||||
provider = CATALOG[model_id]
|
||||
days = max_days_for(provider, day_budget)
|
||||
prompt = build_prompt(summary, activities, days)
|
||||
try:
|
||||
completion = provider.generate(prompt)
|
||||
recs = parse_recommendations(completion.text)
|
||||
return recs, {
|
||||
"model": model_id,
|
||||
"provider": provider.name,
|
||||
"upstream": completion.upstream,
|
||||
"days": min(len(summary), days),
|
||||
"fallbackFrom": [e["model"] for e in errors],
|
||||
"errors": errors,
|
||||
}
|
||||
except AIError as e:
|
||||
errors.append({"model": model_id, "error": str(e)})
|
||||
|
||||
detail = "; ".join(f"{e['model']}: {e['error']}" for e in errors)
|
||||
raise AIError(f"所有模型均失败 -> {detail}")
|
||||
258
backend/services/analysis.py
Normal file
258
backend/services/analysis.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
|
||||
from services import health
|
||||
from services import ai as ai_svc
|
||||
from db import query_all, query_one, execute
|
||||
from config import DB_TYPE
|
||||
|
||||
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
|
||||
|
||||
|
||||
CACHE_TTL_HOURS = int(os.environ.get("AI_CACHE_TTL_HOURS") or 24)
|
||||
|
||||
|
||||
def _fingerprint(summary, activities):
|
||||
"""Identify the data a cached answer was derived from.
|
||||
|
||||
Cheap and order-independent: the day count, the newest and oldest dates,
|
||||
and every metric value. Any sync that adds or corrects a value changes the
|
||||
digest, which is what expires the cache.
|
||||
"""
|
||||
parts = [str(len(summary)), str(len(activities))]
|
||||
for row in summary:
|
||||
parts.append(
|
||||
"|".join(
|
||||
str(row.get(k))
|
||||
for k in ("date", "steps", "heartRate", "heartRateVariability",
|
||||
"stress", "caloriesBurned")
|
||||
)
|
||||
)
|
||||
sleep = row.get("sleep") or {}
|
||||
parts.append(f"{sleep.get('duration')}/{sleep.get('quality')}")
|
||||
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()[:64]
|
||||
|
||||
|
||||
def _read_cache(user_id, fingerprint):
|
||||
row = query_one(
|
||||
"SELECT * FROM ai_recommendations WHERE user_id = ?", [user_id]
|
||||
)
|
||||
if not row or row["fingerprint"] != fingerprint:
|
||||
return None
|
||||
|
||||
created = row.get("created_at")
|
||||
if created:
|
||||
try:
|
||||
ts = datetime.datetime.fromisoformat(str(created).replace(" ", "T"))
|
||||
age = datetime.datetime.utcnow() - ts
|
||||
if age > datetime.timedelta(hours=CACHE_TTL_HOURS):
|
||||
return None
|
||||
except ValueError:
|
||||
# An unparseable timestamp should not permanently poison the cache.
|
||||
return None
|
||||
|
||||
try:
|
||||
recs = json.loads(row["payload"])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"recommendations": recs,
|
||||
"meta": {
|
||||
"source": "ai",
|
||||
"model": row["model"],
|
||||
"upstream": row["upstream"],
|
||||
"days": row["days"],
|
||||
"cached": True,
|
||||
"generatedAt": created,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write_cache(user_id, fingerprint, recs, meta):
|
||||
cols = ["user_id", "fingerprint", "model", "upstream", "days", "payload",
|
||||
"created_at"]
|
||||
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 ai_recommendations ({', '.join(cols)}) "
|
||||
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}"
|
||||
)
|
||||
else:
|
||||
updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "user_id")
|
||||
sql = (
|
||||
f"INSERT INTO ai_recommendations ({', '.join(cols)}) "
|
||||
f"VALUES ({placeholders}) ON CONFLICT(user_id) DO UPDATE SET {updates}"
|
||||
)
|
||||
execute(sql, [
|
||||
user_id, fingerprint, meta.get("model"), meta.get("upstream"),
|
||||
meta.get("days"), json.dumps(recs, ensure_ascii=False),
|
||||
datetime.datetime.utcnow().isoformat(timespec="seconds"),
|
||||
])
|
||||
|
||||
|
||||
def get_ai_recommendations(user_id, model=None, days=None, refresh=False):
|
||||
"""LLM recommendations over the user's history, cached.
|
||||
|
||||
A generation costs minutes against a large reasoning model, so a stored
|
||||
answer is reused until the health data changes (or the TTL lapses).
|
||||
`refresh=True` and an explicit `model` both bypass the cache — asking for
|
||||
a specific model means wanting that model's answer, not a stored one.
|
||||
|
||||
Falls back to the rule engine when every model fails, so the endpoint
|
||||
always returns something useful; `meta.source` tells the two apart.
|
||||
"""
|
||||
summary = health.get_summary(user_id)
|
||||
if not summary:
|
||||
return {
|
||||
"recommendations": get_recommendations(user_id),
|
||||
"meta": {"model": None, "source": "rules", "reason": "无健康数据"},
|
||||
}
|
||||
|
||||
activities = health.get_activities(user_id)
|
||||
fingerprint = _fingerprint(summary, activities)
|
||||
|
||||
if not refresh and not model:
|
||||
cached = _read_cache(user_id, fingerprint)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
budget = days or ai_svc.default_day_budget()
|
||||
try:
|
||||
recs, meta = ai_svc.generate(
|
||||
summary, activities, preferred_model=model, day_budget=budget
|
||||
)
|
||||
except ai_svc.AIError as e:
|
||||
return {
|
||||
"recommendations": get_recommendations(user_id),
|
||||
"meta": {"model": None, "source": "rules", "reason": str(e)},
|
||||
}
|
||||
|
||||
try:
|
||||
_write_cache(user_id, fingerprint, recs, meta)
|
||||
except Exception as e: # noqa: BLE001 - a cache write must never fail the request
|
||||
print(f"[analysis] failed to cache recommendations: {e}")
|
||||
|
||||
return {"recommendations": recs, "meta": {**meta, "source": "ai", "cached": False}}
|
||||
|
||||
|
||||
def clear_ai_cache(user_id):
|
||||
execute("DELETE FROM ai_recommendations WHERE user_id = ?", [user_id])
|
||||
543
backend/services/garmin.py
Normal file
543
backend/services/garmin.py
Normal file
@@ -0,0 +1,543 @@
|
||||
"""
|
||||
Garmin sync service.
|
||||
|
||||
Pulls 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.
|
||||
|
||||
Garmin credentials: the app only stores a scrypt/PBKDF2 *hash* of the Garmin
|
||||
password (so it cannot be recovered), therefore a live sync needs the
|
||||
plaintext garminEmail/garminPassword supplied in the request body.
|
||||
|
||||
On the library's API — these were verified against garminconnect 0.2.8:
|
||||
* get_user_summary(cdate) -> one day of daily totals
|
||||
* get_sleep_data(cdate) -> sleep, NOT part of the summary
|
||||
* get_hrv_data(cdate) -> HRV, also separate
|
||||
* get_activities_by_date(start, end) -> activities in a date range
|
||||
* get_activities(start, limit) -> PAGINATION, not dates
|
||||
The last two are easy to confuse: `get_activities` takes an offset and a count,
|
||||
so passing it a date silently asks for activity number "2026-08-23".
|
||||
"""
|
||||
import datetime
|
||||
import os
|
||||
import threading
|
||||
|
||||
from db import execute, query_one
|
||||
from config import DB_TYPE
|
||||
from services import health
|
||||
|
||||
# How many days back a sync reaches.
|
||||
DEFAULT_SYNC_DAYS = int(os.environ.get("GARMIN_SYNC_DAYS") or 7)
|
||||
|
||||
|
||||
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}"
|
||||
)
|
||||
execute(sql, [user_id, status, now] + list(fields.values()))
|
||||
|
||||
|
||||
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"],
|
||||
"progressCurrent": row.get("progress_current"),
|
||||
"progressTotal": row.get("progress_total"),
|
||||
"startedAt": row.get("started_at"),
|
||||
}
|
||||
|
||||
|
||||
class MFARequired(RuntimeError):
|
||||
"""Raised when a password login needs a code this process cannot obtain."""
|
||||
|
||||
|
||||
# garth sends a browser User-Agent, which its SSO flow needs. The data API
|
||||
# treats that same UA as a browser hitting it directly and answers every
|
||||
# request with HTTP 200 and an empty array — no error, just no data. The
|
||||
# official app's UA (and in fact any non-browser one) returns real data, so
|
||||
# the header is swapped after login, before any API call.
|
||||
API_USER_AGENT = "com.garmin.android.apps.connectmobile"
|
||||
|
||||
|
||||
def _use_api_user_agent(client):
|
||||
try:
|
||||
client.garth.sess.headers["User-Agent"] = API_USER_AGENT
|
||||
except AttributeError:
|
||||
pass # a stubbed client in tests has no session
|
||||
|
||||
|
||||
def _is_cn():
|
||||
# Selects Garmin's China service, a separate backend with separate
|
||||
# accounts. This project tracks an international account.
|
||||
return (os.environ.get("GARMIN_IS_CN") or "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _import_garmin():
|
||||
try:
|
||||
from garminconnect import Garmin
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"GARMIN_LIB_MISSING: 请先运行 `pip install garminconnect` 以启用同步"
|
||||
)
|
||||
return Garmin
|
||||
|
||||
|
||||
def load_token(user_id):
|
||||
row = query_one("SELECT token FROM garmin_tokens WHERE user_id = ?", [user_id])
|
||||
return row["token"] if row else None
|
||||
|
||||
|
||||
def save_token(user_id, token, garmin_email=None):
|
||||
cols = ["user_id", "token", "garmin_email", "updated_at"]
|
||||
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 garmin_tokens ({', '.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 garmin_tokens ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT(user_id) DO UPDATE SET {updates}")
|
||||
execute(sql, [user_id, token, garmin_email,
|
||||
datetime.datetime.utcnow().isoformat(timespec="seconds")])
|
||||
|
||||
|
||||
def has_token(user_id):
|
||||
return load_token(user_id) is not None
|
||||
|
||||
|
||||
def _connect(creds, user_id=None):
|
||||
"""Obtain a logged-in Garmin client.
|
||||
|
||||
Prefers stored OAuth tokens: an account with two-factor auth cannot be
|
||||
logged into from a web worker, because the library asks for the code on
|
||||
stdin and there is none (the failure surfaces as
|
||||
"EOFError: EOF when reading a line"). Tokens are minted once by
|
||||
`garmin_login.py`, which runs in a terminal where a code can be typed.
|
||||
"""
|
||||
Garmin = _import_garmin()
|
||||
client = Garmin(is_cn=_is_cn())
|
||||
|
||||
token = load_token(user_id) if user_id else None
|
||||
if token:
|
||||
client.garth.loads(token)
|
||||
_use_api_user_agent(client)
|
||||
# Proves the token still works, and refreshes it if near expiry.
|
||||
client.garth.refresh_oauth2()
|
||||
# garminconnect builds most of its URLs from display_name, so leaving
|
||||
# it unset sends every request to ".../None".
|
||||
client.display_name = client.garth.profile["displayName"]
|
||||
return client
|
||||
|
||||
if not creds.get("garminPassword"):
|
||||
raise RuntimeError("缺少 Garmin 密码,且未找到已保存的登录令牌")
|
||||
|
||||
client.username = creds["garminEmail"]
|
||||
client.password = creds["garminPassword"]
|
||||
try:
|
||||
client.login()
|
||||
except EOFError as e:
|
||||
# garth's default MFA prompt calls input(); under gunicorn stdin is
|
||||
# closed, so it raises EOFError rather than anything descriptive.
|
||||
raise MFARequired(
|
||||
"该 Garmin 账号开启了两步验证。请在「数据同步」页面用密码重新绑定,"
|
||||
"系统会提示你输入验证码。"
|
||||
) from e
|
||||
_use_api_user_agent(client)
|
||||
return client
|
||||
|
||||
|
||||
def describe(e):
|
||||
"""A message that is never empty.
|
||||
|
||||
Some exceptions carry no text at all — a bare `assert` raises
|
||||
AssertionError with str(e) == "" — and storing that produced a failed
|
||||
sync whose recorded reason was blank, which is undiagnosable.
|
||||
"""
|
||||
text = str(e).strip()
|
||||
return f"{type(e).__name__}: {text}" if text else type(e).__name__
|
||||
|
||||
|
||||
def _num(*values):
|
||||
"""First value that is a usable number."""
|
||||
for v in values:
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _safe(fn, default=None):
|
||||
"""Call an optional endpoint; a metric the device does not record must not
|
||||
abort the whole day."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _first(seq):
|
||||
return seq[0] if isinstance(seq, list) and seq else {}
|
||||
|
||||
|
||||
def _to_datetime(*values):
|
||||
"""Normalise Garmin's several timestamp shapes into an ISO string.
|
||||
|
||||
The same payload mixes ISO strings ("2019-10-13T10:10:12.0") with epoch
|
||||
milliseconds (1570961412000); handing the latter to a DATETIME column is
|
||||
rejected outright, so a personal record whose only timestamp was numeric
|
||||
failed the whole batch.
|
||||
"""
|
||||
for v in values:
|
||||
if v is None or v == "":
|
||||
continue
|
||||
if isinstance(v, str):
|
||||
return v[:26]
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||
# Values past ~1e11 are milliseconds, below that seconds.
|
||||
seconds = v / 1000 if v > 1e11 else v
|
||||
try:
|
||||
return datetime.datetime.utcfromtimestamp(seconds).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
except (ValueError, OverflowError, OSError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _extract_daily(client, date_str):
|
||||
"""Everything Garmin exposes for one day.
|
||||
|
||||
The daily summary is the bulk of it, but sleep, HRV, training readiness
|
||||
and endurance each live behind their own endpoint — none of them appear in
|
||||
get_user_summary. Each is fetched defensively so a metric this device does
|
||||
not record leaves a NULL instead of failing the day.
|
||||
"""
|
||||
s = client.get_user_summary(date_str) or {}
|
||||
|
||||
sleep_dto = (_safe(lambda: client.get_sleep_data(date_str)) or {}).get(
|
||||
"dailySleepDTO"
|
||||
) or {}
|
||||
scores = sleep_dto.get("sleepScores") if isinstance(
|
||||
sleep_dto.get("sleepScores"), dict
|
||||
) else {}
|
||||
sleep_seconds = _num(sleep_dto.get("sleepTimeSeconds"))
|
||||
|
||||
hrv_summary = (_safe(lambda: client.get_hrv_data(date_str)) or {}).get(
|
||||
"hrvSummary"
|
||||
) or {}
|
||||
|
||||
readiness = _first(_safe(lambda: client.get_training_readiness(date_str), []))
|
||||
training = _safe(lambda: client.get_training_status(date_str), {}) or {}
|
||||
vo2 = (training.get("mostRecentVO2Max") or {}).get("generic") or {}
|
||||
endurance = _safe(lambda: client.get_endurance_score(date_str), {}) or {}
|
||||
|
||||
def secs(key):
|
||||
return _num(sleep_dto.get(key))
|
||||
|
||||
return {
|
||||
"date": date_str,
|
||||
# --- activity / energy ---
|
||||
"steps": _num(s.get("totalSteps")),
|
||||
"stepGoal": _num(s.get("dailyStepGoal")),
|
||||
"distanceMeters": _num(s.get("totalDistanceMeters")),
|
||||
"caloriesBurned": _num(s.get("totalKilocalories")),
|
||||
"activeCalories": _num(s.get("activeKilocalories")),
|
||||
"bmrCalories": _num(s.get("bmrKilocalories")),
|
||||
"floorsAscended": _num(s.get("floorsAscended")),
|
||||
"floorsDescended": _num(s.get("floorsDescended")),
|
||||
"intensityMinutes": (
|
||||
(_num(s.get("moderateIntensityMinutes")) or 0)
|
||||
+ (_num(s.get("vigorousIntensityMinutes")) or 0)
|
||||
) or None,
|
||||
"sedentarySeconds": _num(s.get("sedentarySeconds")),
|
||||
"activeSeconds": _num(s.get("activeSeconds")),
|
||||
# --- heart / stress ---
|
||||
"heartRate": _num(s.get("restingHeartRate"), s.get("averageHeartRate")),
|
||||
"heartRateMax": _num(s.get("maxHeartRate")),
|
||||
"heartRateMin": _num(s.get("minHeartRate")),
|
||||
"heartRateVariability": _num(
|
||||
hrv_summary.get("lastNightAvg"), hrv_summary.get("weeklyAvg")
|
||||
),
|
||||
"stress": _num(s.get("averageStressLevel")),
|
||||
"stressMax": _num(s.get("maxStressLevel")),
|
||||
# --- body battery ---
|
||||
"bodyBatteryHigh": _num(s.get("bodyBatteryHighestValue")),
|
||||
"bodyBatteryLow": _num(s.get("bodyBatteryLowestValue")),
|
||||
"bodyBatteryCharged": _num(s.get("bodyBatteryChargedValue")),
|
||||
"bodyBatteryDrained": _num(s.get("bodyBatteryDrainedValue")),
|
||||
# --- breathing / blood oxygen ---
|
||||
"spo2Avg": _num(s.get("averageSpo2")),
|
||||
"spo2Min": _num(s.get("lowestSpo2")),
|
||||
"respirationAvg": _num(
|
||||
s.get("avgWakingRespirationValue"), s.get("latestRespirationValue")
|
||||
),
|
||||
"respirationMin": _num(s.get("lowestRespirationValue")),
|
||||
"respirationMax": _num(s.get("highestRespirationValue")),
|
||||
# --- sleep ---
|
||||
"sleepDuration": round(sleep_seconds / 3600, 1) if sleep_seconds else None,
|
||||
"sleepQuality": _num((scores.get("overall") or {}).get("value")),
|
||||
"sleepDeepSeconds": secs("deepSleepSeconds"),
|
||||
"sleepLightSeconds": secs("lightSleepSeconds"),
|
||||
"sleepRemSeconds": secs("remSleepSeconds"),
|
||||
"sleepAwakeSeconds": secs("awakeSleepSeconds"),
|
||||
"sleepSpo2Avg": secs("averageSpO2Value"),
|
||||
"sleepRespirationAvg": secs("averageRespirationValue"),
|
||||
"sleepStressAvg": secs("avgSleepStress"),
|
||||
# --- training ---
|
||||
"trainingReadiness": _num(readiness.get("score")),
|
||||
"vo2max": _num(vo2.get("vo2MaxValue")),
|
||||
"enduranceScore": _num(endurance.get("overallScore")),
|
||||
}
|
||||
|
||||
|
||||
def sync_badges(client, user_id):
|
||||
"""Earned badges. Keyed by Garmin's badge id, so re-syncing updates."""
|
||||
badges = _safe(lambda: client.get_earned_badges(), []) or []
|
||||
stored = 0
|
||||
for b in badges:
|
||||
bid = b.get("badgeId")
|
||||
if bid is None:
|
||||
continue
|
||||
health.upsert_badge(user_id, {
|
||||
"id": str(bid),
|
||||
"badgeKey": b.get("badgeKey"),
|
||||
"name": b.get("badgeName"),
|
||||
"categoryId": _num(b.get("badgeCategoryId")),
|
||||
"difficultyId": _num(b.get("badgeDifficultyId")),
|
||||
"earnedDate": _to_datetime(b.get("badgeEarnedDate")),
|
||||
"earnedCount": _num(b.get("badgeEarnedNumber")),
|
||||
"points": _num(b.get("badgePoints")),
|
||||
})
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def sync_personal_records(client, user_id):
|
||||
records = _safe(lambda: client.get_personal_record(), []) or []
|
||||
stored = 0
|
||||
for r in records:
|
||||
rid = r.get("id")
|
||||
if rid is None:
|
||||
continue
|
||||
health.upsert_personal_record(user_id, {
|
||||
"id": str(rid),
|
||||
"typeId": _num(r.get("typeId")),
|
||||
"activityId": r.get("activityId"),
|
||||
"activityName": r.get("activityName"),
|
||||
"activityType": r.get("activityType"),
|
||||
"value": _num(r.get("value")),
|
||||
# Prefer the pre-formatted strings; the bare fields are epoch ms.
|
||||
"achievedAt": _to_datetime(
|
||||
r.get("prStartTimeLocalFormatted"),
|
||||
r.get("prStartTimeGmtFormatted"),
|
||||
r.get("activityStartDateTimeLocalFormatted"),
|
||||
r.get("prStartTimeLocal"),
|
||||
r.get("prStartTimeGmt"),
|
||||
),
|
||||
})
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
def _activity_end(start, duration_seconds):
|
||||
if not start or not duration_seconds:
|
||||
return start
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
|
||||
try:
|
||||
dt = datetime.datetime.strptime(start[:26], fmt)
|
||||
return (dt + datetime.timedelta(seconds=duration_seconds)).isoformat()
|
||||
except ValueError:
|
||||
continue
|
||||
return start
|
||||
|
||||
|
||||
def _sync_activities(client, user_id, start_date, end_date):
|
||||
"""Fetch the window's activities in one call and store the new ones."""
|
||||
activities = client.get_activities_by_date(start_date, end_date) or []
|
||||
stored = 0
|
||||
for a in activities:
|
||||
start = a.get("startTimeLocal") or a.get("startTime")
|
||||
activity_type = (
|
||||
(a.get("activityType") or {}).get("typeKey")
|
||||
if isinstance(a.get("activityType"), dict)
|
||||
else a.get("activityType")
|
||||
) or "unknown"
|
||||
duration = _num(a.get("duration"))
|
||||
|
||||
# Garmin activity ids are stable, so re-syncing a window must not
|
||||
# duplicate what is already stored.
|
||||
garmin_id = a.get("activityId")
|
||||
if garmin_id is not None:
|
||||
existing = query_one(
|
||||
"SELECT id FROM activities WHERE user_id = ? AND id = ?",
|
||||
[user_id, str(garmin_id)],
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
health.insert_activity(
|
||||
user_id,
|
||||
{
|
||||
"id": str(garmin_id) if garmin_id is not None else None,
|
||||
"activityType": activity_type,
|
||||
"startTime": start,
|
||||
"endTime": _activity_end(start, duration),
|
||||
"duration": duration,
|
||||
"distance": _num(a.get("distance")),
|
||||
"calories": _num(a.get("calories")),
|
||||
"heartRateAverage": _num(a.get("averageHR")),
|
||||
"heartRateMax": _num(a.get("maxHR")),
|
||||
},
|
||||
)
|
||||
stored += 1
|
||||
return stored
|
||||
|
||||
|
||||
# Above this many days a sync is long enough that the caller must not block
|
||||
# on it — a year takes roughly 20 minutes at ~3s per day.
|
||||
BACKGROUND_THRESHOLD_DAYS = 14
|
||||
|
||||
|
||||
def start_sync(user_id, creds, days=None):
|
||||
"""Run a sync in the background and return immediately.
|
||||
|
||||
Progress lands in sync_status, which the UI polls; a full backfill runs
|
||||
far longer than any sensible HTTP timeout.
|
||||
"""
|
||||
days = days or DEFAULT_SYNC_DAYS
|
||||
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
|
||||
_set_sync_status(
|
||||
user_id, "syncing", now,
|
||||
records_synced=0, progress_current=0, progress_total=days,
|
||||
started_at=now, last_error=None,
|
||||
)
|
||||
thread = threading.Thread(
|
||||
target=sync_data, args=(user_id, creds, days), daemon=True
|
||||
)
|
||||
thread.start()
|
||||
return {"status": "syncing", "days": days}
|
||||
|
||||
|
||||
def sync_data(user_id, creds, days=None, client=None):
|
||||
"""Pull the last `days` days from Garmin Connect into the local database.
|
||||
|
||||
`client` exists so tests can inject a stub instead of reaching Garmin.
|
||||
"""
|
||||
days = days or DEFAULT_SYNC_DAYS
|
||||
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
|
||||
_set_sync_status(
|
||||
user_id, "syncing", now,
|
||||
records_synced=0, progress_current=0, progress_total=days,
|
||||
)
|
||||
|
||||
try:
|
||||
client = client or _connect(creds, user_id)
|
||||
except Exception as e:
|
||||
message = describe(e)
|
||||
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
|
||||
return {
|
||||
"status": "error",
|
||||
"recordsSynced": 0,
|
||||
"message": message,
|
||||
"mfaRequired": isinstance(e, MFARequired),
|
||||
"lastSyncTime": now,
|
||||
}
|
||||
|
||||
today = datetime.date.today()
|
||||
start_date = (today - datetime.timedelta(days=days - 1)).isoformat()
|
||||
|
||||
days_synced = 0
|
||||
day_errors = []
|
||||
for i in range(days):
|
||||
date_str = (today - datetime.timedelta(days=i)).isoformat()
|
||||
try:
|
||||
record = _extract_daily(client, date_str)
|
||||
except Exception as e:
|
||||
day_errors.append(f"{date_str}: {describe(e)}")
|
||||
continue
|
||||
# A day Garmin has no data for comes back all-None; storing it would
|
||||
# create an empty row that the metric endpoints then have to filter.
|
||||
if any(record[k] is not None for k in record if k != "date"):
|
||||
health.upsert_health_daily(user_id, record)
|
||||
days_synced += 1
|
||||
|
||||
# Reported every few days rather than every day: the write is cheap
|
||||
# but not free, and the UI polls on a 2s cadence anyway.
|
||||
if (i + 1) % 5 == 0 or i + 1 == days:
|
||||
_set_sync_status(
|
||||
user_id, "syncing", now,
|
||||
records_synced=days_synced, progress_current=i + 1,
|
||||
progress_total=days,
|
||||
)
|
||||
|
||||
activities_synced = 0
|
||||
try:
|
||||
activities_synced = _sync_activities(
|
||||
client, user_id, start_date, today.isoformat()
|
||||
)
|
||||
except Exception as e:
|
||||
day_errors.append(f"activities: {describe(e)}")
|
||||
|
||||
# Badges and personal records are account-wide rather than per-day, so
|
||||
# they are fetched once per sync rather than inside the day loop.
|
||||
badges_synced = 0
|
||||
records_synced_pr = 0
|
||||
try:
|
||||
badges_synced = sync_badges(client, user_id)
|
||||
except Exception as e:
|
||||
day_errors.append(f"badges: {describe(e)}")
|
||||
try:
|
||||
records_synced_pr = sync_personal_records(client, user_id)
|
||||
except Exception as e:
|
||||
day_errors.append(f"personal_records: {describe(e)}")
|
||||
|
||||
# Every single day failing means something systemic (expired session,
|
||||
# API change) — reporting that as a clean success would hide it.
|
||||
if days_synced == 0 and len(day_errors) >= days:
|
||||
message = "; ".join(day_errors[:3])
|
||||
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
|
||||
return {"status": "error", "recordsSynced": 0,
|
||||
"message": f"同步失败:{message}", "lastSyncTime": now}
|
||||
|
||||
_set_sync_status(
|
||||
user_id, "idle", now, records_synced=days_synced,
|
||||
progress_current=days, progress_total=days,
|
||||
last_error="; ".join(day_errors[:3]) if day_errors else None,
|
||||
)
|
||||
message = (
|
||||
f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录、"
|
||||
f"{badges_synced} 个奖励、{records_synced_pr} 项个人纪录"
|
||||
)
|
||||
if day_errors:
|
||||
message += f"({len(day_errors)} 项跳过)"
|
||||
return {
|
||||
"status": "success",
|
||||
"recordsSynced": days_synced,
|
||||
"activitiesSynced": activities_synced,
|
||||
"badgesSynced": badges_synced,
|
||||
"personalRecordsSynced": records_synced_pr,
|
||||
"message": message,
|
||||
"lastSyncTime": now,
|
||||
}
|
||||
160
backend/services/garmin_auth.py
Normal file
160
backend/services/garmin_auth.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Interactive Garmin login with two-factor auth, driven from the web UI.
|
||||
|
||||
The problem this solves: garth asks for the MFA code through a *blocking*
|
||||
callback in the middle of `sso.login`. There is no "start login, return a
|
||||
handle, resume later" API in garth 0.4.46, so the login has to stay alive
|
||||
while the code is fetched.
|
||||
|
||||
Shape of the solution:
|
||||
* the login runs in a background thread and parks inside `prompt_mfa`
|
||||
* the browser posts the code in a separate request
|
||||
* the two meet through a row in `garmin_mfa_sessions`, not process memory,
|
||||
because gunicorn runs several workers and the code request will not
|
||||
reliably land on the worker holding the parked login
|
||||
|
||||
The Garmin password never leaves the waiting thread — it is not stored.
|
||||
|
||||
Statuses:
|
||||
starting - thread launched, login not yet at the MFA step
|
||||
awaiting_code - parked in prompt_mfa, waiting for the browser
|
||||
finishing - code received, completing the login
|
||||
done - tokens saved
|
||||
failed - see `error`
|
||||
"""
|
||||
import datetime
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from config import DB_TYPE
|
||||
from db import execute, query_one
|
||||
from services import garmin as garmin_svc
|
||||
|
||||
# How long the parked login waits for a code before giving up. Garmin codes
|
||||
# expire in 30 minutes, but holding a thread that long is wasteful; the user
|
||||
# can simply start again.
|
||||
CODE_WAIT_SECONDS = 300
|
||||
POLL_INTERVAL_SECONDS = 2
|
||||
|
||||
# Sessions older than this are cleared out whenever a new one starts.
|
||||
SESSION_TTL_MINUTES = 60
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.datetime.utcnow().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _set(session_id, status, **fields):
|
||||
sets = ["status = ?", "updated_at = ?"]
|
||||
params = [status, _now()]
|
||||
for k, v in fields.items():
|
||||
sets.append(f"{k} = ?")
|
||||
params.append(v)
|
||||
params.append(session_id)
|
||||
execute(f"UPDATE garmin_mfa_sessions SET {', '.join(sets)} WHERE id = ?", params)
|
||||
|
||||
|
||||
def get_session(session_id, user_id=None):
|
||||
row = query_one("SELECT * FROM garmin_mfa_sessions WHERE id = ?", [session_id])
|
||||
if not row:
|
||||
return None
|
||||
# A session id from one account must never address another's login.
|
||||
if user_id and row["user_id"] != user_id:
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def _cleanup(user_id):
|
||||
cutoff = (
|
||||
datetime.datetime.utcnow() - datetime.timedelta(minutes=SESSION_TTL_MINUTES)
|
||||
).isoformat(timespec="seconds")
|
||||
execute(
|
||||
"DELETE FROM garmin_mfa_sessions WHERE user_id = ? AND created_at < ?",
|
||||
[user_id, cutoff],
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_code(session_id):
|
||||
"""Block until the browser posts a code. Runs inside garth's prompt_mfa."""
|
||||
_set(session_id, "awaiting_code")
|
||||
deadline = time.time() + CODE_WAIT_SECONDS
|
||||
while time.time() < deadline:
|
||||
row = query_one(
|
||||
"SELECT code FROM garmin_mfa_sessions WHERE id = ?", [session_id]
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError("登录会话已被取消")
|
||||
if row["code"]:
|
||||
_set(session_id, "finishing")
|
||||
return row["code"]
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
raise TimeoutError("等待验证码超时,请重新发起登录")
|
||||
|
||||
|
||||
def _run_login(session_id, user_id, garmin_email, password, is_cn, import_garmin):
|
||||
try:
|
||||
Garmin = import_garmin()
|
||||
client = Garmin(is_cn=is_cn)
|
||||
# Calling garth directly is what allows the MFA prompt to be replaced;
|
||||
# Garmin.login() hardcodes the stdin one.
|
||||
client.garth.login(
|
||||
garmin_email, password, prompt_mfa=lambda: _wait_for_code(session_id)
|
||||
)
|
||||
garmin_svc.save_token(user_id, client.garth.dumps(), garmin_email)
|
||||
_set(session_id, "done", code=None)
|
||||
except Exception as e: # noqa: BLE001 - surfaced to the user via the row
|
||||
_set(session_id, "failed", error=f"{type(e).__name__}: {e}"[:500], code=None)
|
||||
|
||||
|
||||
def start_login(user_id, garmin_email, password, import_garmin=None, is_cn=None):
|
||||
"""Kick off a login in the background. Returns the session id."""
|
||||
_cleanup(user_id)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
execute(
|
||||
"INSERT INTO garmin_mfa_sessions (id, user_id, status, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
[session_id, user_id, "starting", _now(), _now()],
|
||||
)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_login,
|
||||
args=(
|
||||
session_id,
|
||||
user_id,
|
||||
garmin_email,
|
||||
password,
|
||||
garmin_svc._is_cn() if is_cn is None else is_cn,
|
||||
import_garmin or garmin_svc._import_garmin,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return session_id
|
||||
|
||||
|
||||
def submit_code(session_id, user_id, code):
|
||||
"""Hand a code to the parked login. Returns (ok, message)."""
|
||||
row = get_session(session_id, user_id)
|
||||
if not row:
|
||||
return False, "登录会话不存在或已过期"
|
||||
if row["status"] == "done":
|
||||
return False, "登录已完成"
|
||||
if row["status"] == "failed":
|
||||
return False, row["error"] or "登录已失败,请重新发起"
|
||||
if row["status"] not in ("awaiting_code", "starting"):
|
||||
return False, f"当前状态无法提交验证码({row['status']})"
|
||||
|
||||
execute(
|
||||
"UPDATE garmin_mfa_sessions SET code = ?, updated_at = ? WHERE id = ?",
|
||||
[str(code).strip(), _now(), session_id],
|
||||
)
|
||||
return True, "验证码已提交"
|
||||
|
||||
|
||||
def cancel(session_id, user_id):
|
||||
execute(
|
||||
"DELETE FROM garmin_mfa_sessions WHERE id = ? AND user_id = ?",
|
||||
[session_id, user_id],
|
||||
)
|
||||
243
backend/services/health.py
Normal file
243
backend/services/health.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
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):
|
||||
"""Every stored metric for each day, in the camelCase the UI and the AI
|
||||
prompt consume."""
|
||||
sql, params = _range_sql(user_id, start, end)
|
||||
columns = ", ".join(HEALTH_COLUMNS)
|
||||
rows = query_all(
|
||||
f"SELECT date, {columns} FROM health_data {sql} ORDER BY date ASC", params
|
||||
)
|
||||
|
||||
out = []
|
||||
for r in rows:
|
||||
day = {"date": r["date"]}
|
||||
for column, key in HEALTH_COLUMNS.items():
|
||||
day[key] = r.get(column)
|
||||
# Sleep stays nested for backwards compatibility with the UI and the
|
||||
# existing recommendation rules.
|
||||
day["sleep"] = (
|
||||
{
|
||||
"duration": r.get("sleep_duration"),
|
||||
"quality": r.get("sleep_quality"),
|
||||
"deepSeconds": r.get("sleep_deep_seconds"),
|
||||
"lightSeconds": r.get("sleep_light_seconds"),
|
||||
"remSeconds": r.get("sleep_rem_seconds"),
|
||||
"awakeSeconds": r.get("sleep_awake_seconds"),
|
||||
}
|
||||
if r.get("sleep_duration") is not None
|
||||
else None
|
||||
)
|
||||
out.append(day)
|
||||
return out
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Column name -> key in the record dict produced by the Garmin extractor.
|
||||
# Keeping the mapping in one place means adding a metric touches this table
|
||||
# and the extractor, and nothing else.
|
||||
HEALTH_COLUMNS = {
|
||||
"steps": "steps",
|
||||
"step_goal": "stepGoal",
|
||||
"distance_meters": "distanceMeters",
|
||||
"calories_burned": "caloriesBurned",
|
||||
"active_calories": "activeCalories",
|
||||
"bmr_calories": "bmrCalories",
|
||||
"floors_ascended": "floorsAscended",
|
||||
"floors_descended": "floorsDescended",
|
||||
"intensity_minutes": "intensityMinutes",
|
||||
"sedentary_seconds": "sedentarySeconds",
|
||||
"active_seconds": "activeSeconds",
|
||||
"heart_rate": "heartRate",
|
||||
"heart_rate_max": "heartRateMax",
|
||||
"heart_rate_min": "heartRateMin",
|
||||
"heart_rate_variability": "heartRateVariability",
|
||||
"stress": "stress",
|
||||
"stress_max": "stressMax",
|
||||
"body_battery_high": "bodyBatteryHigh",
|
||||
"body_battery_low": "bodyBatteryLow",
|
||||
"body_battery_charged": "bodyBatteryCharged",
|
||||
"body_battery_drained": "bodyBatteryDrained",
|
||||
"spo2_avg": "spo2Avg",
|
||||
"spo2_min": "spo2Min",
|
||||
"respiration_avg": "respirationAvg",
|
||||
"respiration_min": "respirationMin",
|
||||
"respiration_max": "respirationMax",
|
||||
"sleep_duration": "sleepDuration",
|
||||
"sleep_quality": "sleepQuality",
|
||||
"sleep_deep_seconds": "sleepDeepSeconds",
|
||||
"sleep_light_seconds": "sleepLightSeconds",
|
||||
"sleep_rem_seconds": "sleepRemSeconds",
|
||||
"sleep_awake_seconds": "sleepAwakeSeconds",
|
||||
"sleep_spo2_avg": "sleepSpo2Avg",
|
||||
"sleep_respiration_avg": "sleepRespirationAvg",
|
||||
"sleep_stress_avg": "sleepStressAvg",
|
||||
"training_readiness": "trainingReadiness",
|
||||
"vo2max": "vo2max",
|
||||
"endurance_score": "enduranceScore",
|
||||
"blood_pressure_systolic": "bloodPressureSystolic",
|
||||
"blood_pressure_diastolic": "bloodPressureDiastolic",
|
||||
}
|
||||
|
||||
|
||||
def _upsert(table, key_cols, cols, values):
|
||||
"""INSERT ... ON CONFLICT/DUPLICATE UPDATE, written for both backends."""
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
updatable = [c for c in cols if c not in key_cols]
|
||||
if DB_TYPE == "mariadb":
|
||||
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
|
||||
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON DUPLICATE KEY UPDATE {updates}")
|
||||
else:
|
||||
conflict = ", ".join(key_cols)
|
||||
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
|
||||
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT({conflict}) DO UPDATE SET {updates}")
|
||||
execute(sql, values)
|
||||
|
||||
|
||||
def upsert_health_daily(user_id, record):
|
||||
hid = f"{user_id}-{record['date']}"
|
||||
cols = ["id", "user_id", "date"] + list(HEALTH_COLUMNS)
|
||||
values = [hid, user_id, record.get("date")] + [
|
||||
record.get(key) for key in HEALTH_COLUMNS.values()
|
||||
]
|
||||
_upsert("health_data", ("user_id", "date"), cols, values)
|
||||
return hid
|
||||
|
||||
|
||||
def upsert_badge(user_id, badge):
|
||||
cols = ["id", "user_id", "badge_key", "name", "category_id",
|
||||
"difficulty_id", "earned_date", "earned_count", "points"]
|
||||
values = [
|
||||
badge["id"], user_id, badge.get("badgeKey"), badge.get("name"),
|
||||
badge.get("categoryId"), badge.get("difficultyId"),
|
||||
badge.get("earnedDate"), badge.get("earnedCount"), badge.get("points"),
|
||||
]
|
||||
_upsert("badges", ("user_id", "id"), cols, values)
|
||||
return badge["id"]
|
||||
|
||||
|
||||
def upsert_personal_record(user_id, record):
|
||||
cols = ["id", "user_id", "type_id", "activity_id", "activity_name",
|
||||
"activity_type", "value", "achieved_at"]
|
||||
values = [
|
||||
record["id"], user_id, record.get("typeId"), record.get("activityId"),
|
||||
record.get("activityName"), record.get("activityType"),
|
||||
record.get("value"), record.get("achievedAt"),
|
||||
]
|
||||
_upsert("personal_records", ("user_id", "id"), cols, values)
|
||||
return record["id"]
|
||||
|
||||
|
||||
def get_badges(user_id):
|
||||
return query_all(
|
||||
"SELECT id, badge_key, name, category_id, difficulty_id, earned_date, "
|
||||
"earned_count, points FROM badges WHERE user_id = ? "
|
||||
"ORDER BY earned_date DESC",
|
||||
[user_id],
|
||||
)
|
||||
|
||||
|
||||
def get_personal_records(user_id):
|
||||
return query_all(
|
||||
"SELECT id, type_id, activity_id, activity_name, activity_type, value, "
|
||||
"achieved_at FROM personal_records WHERE user_id = ? "
|
||||
"ORDER BY achieved_at DESC",
|
||||
[user_id],
|
||||
)
|
||||
|
||||
|
||||
def insert_activity(user_id, activity):
|
||||
# Prefer Garmin's own activity id when the caller has one: it is stable
|
||||
# across syncs, which is what lets a re-synced window skip what is already
|
||||
# stored instead of inserting it again.
|
||||
aid = str(activity.get("id") or 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
|
||||
126
backend/tests/conftest.py
Normal file
126
backend/tests/conftest.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Shared pytest fixtures.
|
||||
|
||||
Environment must be configured BEFORE `config` is imported, because config.py
|
||||
reads os.environ at import time. Each test then gets its own SQLite file so
|
||||
tests never share state.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
os.environ.setdefault("DB_TYPE", "sqlite")
|
||||
os.environ.setdefault("JWT_SECRET", "test_secret_at_least_32_bytes_long_ok")
|
||||
os.environ.setdefault("CORS_ORIGIN", "http://localhost:3000")
|
||||
# Point at a throwaway path; the db_path fixture overrides it per test.
|
||||
os.environ.setdefault(
|
||||
"DATABASE_PATH", os.path.join(tempfile.mkdtemp(), "bootstrap.db")
|
||||
)
|
||||
|
||||
import db as db_module # noqa: E402
|
||||
from app import create_app # noqa: E402
|
||||
|
||||
# config.py calls load_dotenv() at import, so backend/.env leaks into the test
|
||||
# process — a developer's real AI_MODEL_CHAIN or API keys would silently change
|
||||
# what the suite exercises (and could bill real API calls). Clear them here;
|
||||
# individual tests opt back in through the `keys` / `gateway` fixtures.
|
||||
_AI_ENV_VARS = (
|
||||
"AI_MODEL_CHAIN",
|
||||
"AI_DAY_BUDGET",
|
||||
"AI_TIMEOUT_SECONDS",
|
||||
"GEMINI_API_KEY",
|
||||
"NVIDIA_API_KEY",
|
||||
"NVIDIA_BASE_URL",
|
||||
"AI_GATEWAY_TOKEN",
|
||||
"AI_GATEWAY_BASE_URL",
|
||||
"AI_GATEWAY_MODEL",
|
||||
"OLLAMA_BASE_URL",
|
||||
"OLLAMA_MODEL",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_ai_env(monkeypatch):
|
||||
for var in _AI_ENV_VARS:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
# Most tests need to create users freely; the production default closes
|
||||
# registration once one account exists. test_registration_policy.py clears
|
||||
# this to exercise the real default.
|
||||
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path, monkeypatch):
|
||||
"""A freshly initialized, isolated SQLite database for one test."""
|
||||
path = str(tmp_path / "test.db")
|
||||
monkeypatch.setattr(db_module, "SQLITE_PATH", path)
|
||||
db_module.init_db()
|
||||
return db_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(db):
|
||||
application = create_app()
|
||||
application.config.update(TESTING=True)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(client):
|
||||
"""A registered user: returns {id, email, token, password}."""
|
||||
password = "secret123"
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "tester@example.com",
|
||||
"garminEmail": "gm@example.com",
|
||||
"garminPassword": password,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.get_data(as_text=True)
|
||||
body = resp.get_json()
|
||||
return {**body, "password": password}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth(user):
|
||||
"""Authorization headers for the registered user."""
|
||||
return {"Authorization": f"Bearer {user['token']}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_health(db, user):
|
||||
"""Insert daily health rows. Returns the inserted records."""
|
||||
|
||||
def _seed(records):
|
||||
for r in records:
|
||||
db.execute(
|
||||
"INSERT INTO health_data (id, user_id, date, steps, heart_rate, "
|
||||
"heart_rate_variability, sleep_duration, sleep_quality, stress, "
|
||||
"calories_burned) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
[
|
||||
f"{user['id']}-{r['date']}",
|
||||
user["id"],
|
||||
r["date"],
|
||||
r.get("steps"),
|
||||
r.get("heart_rate"),
|
||||
r.get("hrv"),
|
||||
r.get("sleep_duration"),
|
||||
r.get("sleep_quality"),
|
||||
r.get("stress"),
|
||||
r.get("calories"),
|
||||
],
|
||||
)
|
||||
return records
|
||||
|
||||
return _seed
|
||||
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)
|
||||
679
backend/tests/test_ai.py
Normal file
679
backend/tests/test_ai.py
Normal file
@@ -0,0 +1,679 @@
|
||||
"""
|
||||
Unit tests for the multi-provider LLM layer.
|
||||
|
||||
Every HTTP call is mocked — the suite never touches the network and never
|
||||
needs a real API key.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from services import ai as ai_svc
|
||||
from services import analysis as analysis_svc
|
||||
|
||||
|
||||
VALID_REPLY = json.dumps(
|
||||
[
|
||||
{"category": "睡眠", "recommendation": "固定就寝时间,目标 7-8 小时。",
|
||||
"priority": "high", "basedOn": ["sleep_duration"]},
|
||||
{"category": "运动", "recommendation": "每天增加 20 分钟快走。",
|
||||
"priority": "medium", "basedOn": ["steps"]},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
SUMMARY = [
|
||||
{"date": "2026-08-20", "steps": 6500, "heartRate": 70,
|
||||
"heartRateVariability": 45, "stress": 55, "caloriesBurned": 260,
|
||||
"sleep": {"duration": 6, "quality": 80}},
|
||||
{"date": "2026-08-21", "steps": 9000, "heartRate": 62,
|
||||
"heartRateVariability": 46, "stress": 40, "caloriesBurned": 360,
|
||||
"sleep": {"duration": 8, "quality": 79}},
|
||||
]
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text or json.dumps(payload or {})
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json")
|
||||
return self._payload
|
||||
|
||||
|
||||
def gemini_payload(text):
|
||||
return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
|
||||
|
||||
|
||||
def openai_payload(text):
|
||||
return {"choices": [{"message": {"content": text}}]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keys(monkeypatch):
|
||||
"""Direct vendor keys configured; the gateway stays out of the chain."""
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-gemini-key")
|
||||
monkeypatch.setenv("NVIDIA_API_KEY", "test-nvidia-key")
|
||||
monkeypatch.delenv("AI_GATEWAY_TOKEN", raising=False)
|
||||
monkeypatch.delenv("AI_GATEWAY_BASE_URL", raising=False)
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_keys(monkeypatch):
|
||||
for var in (
|
||||
"GEMINI_API_KEY", "NVIDIA_API_KEY",
|
||||
"AI_GATEWAY_TOKEN", "AI_GATEWAY_BASE_URL",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway(monkeypatch):
|
||||
"""Only the self-hosted gateway is configured."""
|
||||
monkeypatch.setenv("AI_GATEWAY_TOKEN", "test-gateway-token")
|
||||
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gw.test:5100/v1")
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("NVIDIA_API_KEY", raising=False)
|
||||
return True
|
||||
|
||||
|
||||
# --- prompt construction ----------------------------------------------------
|
||||
class TestBuildPrompt:
|
||||
def test_includes_every_day_as_a_csv_row(self):
|
||||
prompt = ai_svc.build_prompt(SUMMARY)
|
||||
assert "2026-08-20" in prompt and "2026-08-21" in prompt
|
||||
assert "共 2 天" in prompt
|
||||
|
||||
def test_uses_csv_not_json(self):
|
||||
"""CSV keeps a year of history affordable; JSON would not."""
|
||||
prompt = ai_svc.build_prompt(SUMMARY)
|
||||
assert "6500,45" in prompt.replace(" ", "") or "6500" in prompt
|
||||
assert '"steps":' not in prompt
|
||||
|
||||
def test_missing_metrics_become_empty_cells_not_the_word_none(self):
|
||||
prompt = ai_svc.build_prompt([{"date": "2026-08-20", "steps": None}])
|
||||
assert "None" not in prompt
|
||||
|
||||
def test_sleep_is_flattened_into_columns(self):
|
||||
prompt = ai_svc.build_prompt(SUMMARY)
|
||||
assert "sleep_h,sleep_q" in prompt
|
||||
|
||||
def test_day_budget_trims_to_the_most_recent_days(self):
|
||||
many = [{"date": f"2026-01-{d:02d}", "steps": d} for d in range(1, 32)]
|
||||
prompt = ai_svc.build_prompt(many, day_budget=5)
|
||||
assert "共 5 天" in prompt
|
||||
assert "2026-01-31" in prompt, "must keep the newest days"
|
||||
assert "2026-01-01" not in prompt, "must drop the oldest days"
|
||||
|
||||
def test_activities_included_when_supplied(self):
|
||||
prompt = ai_svc.build_prompt(
|
||||
SUMMARY, [{"activity_type": "running", "distance": 5.0}]
|
||||
)
|
||||
assert "running" in prompt
|
||||
|
||||
def test_activities_capped(self):
|
||||
acts = [{"activity_type": f"run{i}"} for i in range(500)]
|
||||
prompt = ai_svc.build_prompt(SUMMARY, acts)
|
||||
assert "共 200 条" in prompt
|
||||
|
||||
def test_prompt_forbids_fabricating_numbers(self):
|
||||
assert "不要编造" in ai_svc.build_prompt(SUMMARY)
|
||||
|
||||
def test_prompt_disclaims_medical_advice(self):
|
||||
assert "不是医生" in ai_svc.build_prompt(SUMMARY)
|
||||
|
||||
def test_a_year_of_data_stays_compact(self):
|
||||
year = [
|
||||
{"date": f"2026-{m:02d}-{d:02d}", "steps": 8000, "heartRate": 60,
|
||||
"sleep": {"duration": 7, "quality": 80}}
|
||||
for m in range(1, 13) for d in range(1, 29)
|
||||
]
|
||||
prompt = ai_svc.build_prompt(year)
|
||||
# ~4 chars/token: a year must stay far under even the smallest window.
|
||||
assert len(prompt) / 4 < 50_000
|
||||
|
||||
|
||||
# --- response parsing -------------------------------------------------------
|
||||
class TestParseRecommendations:
|
||||
def test_plain_json_array(self):
|
||||
recs = ai_svc.parse_recommendations(VALID_REPLY)
|
||||
assert len(recs) == 2
|
||||
assert recs[0]["category"] == "睡眠"
|
||||
|
||||
def test_markdown_fenced_json(self):
|
||||
recs = ai_svc.parse_recommendations(f"```json\n{VALID_REPLY}\n```")
|
||||
assert len(recs) == 2
|
||||
|
||||
def test_json_with_a_preamble_sentence(self):
|
||||
recs = ai_svc.parse_recommendations(f"好的,分析结果如下:\n{VALID_REPLY}")
|
||||
assert len(recs) == 2
|
||||
|
||||
def test_single_object_is_wrapped(self):
|
||||
recs = ai_svc.parse_recommendations(
|
||||
'{"category":"睡眠","recommendation":"早点睡","priority":"high"}'
|
||||
)
|
||||
assert len(recs) == 1
|
||||
|
||||
def test_results_are_sorted_by_priority(self):
|
||||
reply = json.dumps([
|
||||
{"category": "a", "recommendation": "low one", "priority": "low"},
|
||||
{"category": "b", "recommendation": "high one", "priority": "high"},
|
||||
{"category": "c", "recommendation": "medium one", "priority": "medium"},
|
||||
])
|
||||
assert [r["priority"] for r in ai_svc.parse_recommendations(reply)] == [
|
||||
"high", "medium", "low"
|
||||
]
|
||||
|
||||
def test_invalid_priority_defaults_to_medium(self):
|
||||
reply = json.dumps([
|
||||
{"category": "a", "recommendation": "x", "priority": "URGENT!!"}
|
||||
])
|
||||
assert ai_svc.parse_recommendations(reply)[0]["priority"] == "medium"
|
||||
|
||||
def test_entries_without_recommendation_text_are_dropped(self):
|
||||
reply = json.dumps([
|
||||
{"category": "a", "recommendation": ""},
|
||||
{"category": "b", "recommendation": "keep me"},
|
||||
])
|
||||
recs = ai_svc.parse_recommendations(reply)
|
||||
assert len(recs) == 1 and recs[0]["recommendation"] == "keep me"
|
||||
|
||||
def test_non_list_based_on_is_normalised(self):
|
||||
reply = json.dumps([
|
||||
{"category": "a", "recommendation": "x", "basedOn": "steps"}
|
||||
])
|
||||
assert ai_svc.parse_recommendations(reply)[0]["basedOn"] == []
|
||||
|
||||
def test_results_are_tagged_as_ai_generated(self):
|
||||
assert all(r["source"] == "ai" for r in ai_svc.parse_recommendations(VALID_REPLY))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reply", ["", " ", "抱歉,我无法回答。", "[", "null", "[]", "[1,2,3]"]
|
||||
)
|
||||
def test_unusable_replies_raise_aierror(self, reply):
|
||||
with pytest.raises(ai_svc.AIError):
|
||||
ai_svc.parse_recommendations(reply)
|
||||
|
||||
|
||||
# --- providers --------------------------------------------------------------
|
||||
class TestGeminiProvider:
|
||||
def test_successful_call(self, keys, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["headers"] = kwargs.get("headers", {})
|
||||
captured["json"] = kwargs.get("json")
|
||||
return FakeResponse(200, gemini_payload("hello"))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
out = ai_svc.CATALOG["gemini-flash"].generate("prompt text")
|
||||
|
||||
assert out.text == "hello"
|
||||
assert "gemini-flash-latest:generateContent" in captured["url"]
|
||||
assert captured["headers"]["X-goog-api-key"] == "test-gemini-key"
|
||||
assert captured["json"]["contents"][0]["parts"][0]["text"] == "prompt text"
|
||||
|
||||
def test_http_error_becomes_aierror(self, keys, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
requests.Session, "post", lambda *a, **k: FakeResponse(429, text="rate limited")
|
||||
)
|
||||
with pytest.raises(ai_svc.AIError, match="429"):
|
||||
ai_svc.CATALOG["gemini-flash"].generate("p")
|
||||
|
||||
def test_timeout_becomes_aierror(self, keys, monkeypatch):
|
||||
def boom(self, *a, **k):
|
||||
raise requests.Timeout("timed out")
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", boom)
|
||||
with pytest.raises(ai_svc.AIError, match="请求失败"):
|
||||
ai_svc.CATALOG["gemini-flash"].generate("p")
|
||||
|
||||
def test_unexpected_shape_becomes_aierror(self, keys, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
requests.Session, "post", lambda *a, **k: FakeResponse(200, {"unexpected": True})
|
||||
)
|
||||
with pytest.raises(ai_svc.AIError, match="响应格式异常"):
|
||||
ai_svc.CATALOG["gemini-flash"].generate("p")
|
||||
|
||||
def test_missing_key_raises_before_any_request(self, no_keys, monkeypatch):
|
||||
def boom(self, *a, **k):
|
||||
raise AssertionError("must not issue a request without a key")
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", boom)
|
||||
with pytest.raises(ai_svc.AIError, match="GEMINI_API_KEY"):
|
||||
ai_svc.CATALOG["gemini-flash"].generate("p")
|
||||
|
||||
|
||||
class TestOpenAICompatProvider:
|
||||
def test_successful_call(self, keys, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["headers"] = kwargs.get("headers", {})
|
||||
captured["json"] = kwargs.get("json")
|
||||
return FakeResponse(200, openai_payload("hi"))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
out = ai_svc.CATALOG["llama-70b"].generate("prompt text")
|
||||
|
||||
assert out.text == "hi"
|
||||
assert out.upstream is None, "stock OpenAI replies carry no provider field"
|
||||
assert captured["url"].endswith("/chat/completions")
|
||||
assert captured["headers"]["Authorization"] == "Bearer test-nvidia-key"
|
||||
assert captured["json"]["model"] == "meta/llama-3.3-70b-instruct"
|
||||
|
||||
def test_http_error_becomes_aierror(self, keys, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
requests.Session, "post", lambda *a, **k: FakeResponse(500, text="boom")
|
||||
)
|
||||
with pytest.raises(ai_svc.AIError, match="500"):
|
||||
ai_svc.CATALOG["llama-70b"].generate("p")
|
||||
|
||||
|
||||
class TestGatewayProvider:
|
||||
"""The self-hosted gateway: OpenAI-compatible, plus a `provider` field
|
||||
naming whichever upstream actually served the request."""
|
||||
|
||||
def test_reports_the_upstream_that_answered(self, gateway, monkeypatch):
|
||||
payload = {**openai_payload("hi"), "provider": "nvidia"}
|
||||
monkeypatch.setattr(requests.Session, "post", lambda *a, **k: FakeResponse(200, payload))
|
||||
out = ai_svc.CATALOG["gateway"].generate("p")
|
||||
assert out.text == "hi"
|
||||
assert out.upstream == "nvidia"
|
||||
|
||||
def test_targets_the_configured_base_url(self, gateway, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["headers"] = kwargs.get("headers", {})
|
||||
captured["model"] = (kwargs.get("json") or {}).get("model")
|
||||
return FakeResponse(200, openai_payload("hi"))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
ai_svc.CATALOG["gateway"].generate("p")
|
||||
|
||||
assert captured["url"] == "http://gw.test:5100/v1/chat/completions"
|
||||
assert captured["headers"]["Authorization"] == "Bearer test-gateway-token"
|
||||
assert captured["model"] == "ai-gateway-auto"
|
||||
|
||||
def test_upstream_surfaces_in_generate_meta(self, gateway, monkeypatch):
|
||||
payload = {**openai_payload(VALID_REPLY), "provider": "gemini"}
|
||||
monkeypatch.setattr(requests.Session, "post", lambda *a, **k: FakeResponse(200, payload))
|
||||
_, meta = ai_svc.generate(SUMMARY)
|
||||
assert meta["model"] == "gateway"
|
||||
assert meta["upstream"] == "gemini"
|
||||
|
||||
def test_gateway_401_falls_through(self, monkeypatch):
|
||||
"""A stale gateway token must not strand the request."""
|
||||
monkeypatch.setenv("AI_GATEWAY_TOKEN", "expired")
|
||||
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gw.test:5100/v1")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "k")
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
if "gw.test" in url:
|
||||
return FakeResponse(401, text="unauthorized")
|
||||
return FakeResponse(200, gemini_payload(VALID_REPLY))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
_, meta = ai_svc.generate(SUMMARY)
|
||||
assert meta["model"] == "gemini-flash"
|
||||
assert meta["fallbackFrom"] == ["gateway"]
|
||||
|
||||
|
||||
# --- proxy handling ---------------------------------------------------------
|
||||
class TestProxyPolicy:
|
||||
"""Overseas vendors may only be reachable through a local proxy, while a
|
||||
self-hosted box on a public IP breaks when forced through one — so the two
|
||||
must not share a policy."""
|
||||
|
||||
def test_hosted_vendors_honour_environment_proxies(self):
|
||||
assert ai_svc.CATALOG["gemini-flash"].use_proxy is True
|
||||
assert ai_svc.CATALOG["llama-70b"].use_proxy is True
|
||||
|
||||
def test_self_hosted_gateway_bypasses_proxies(self):
|
||||
assert ai_svc.CATALOG["gateway"].use_proxy is False
|
||||
|
||||
def test_session_trust_env_follows_the_flag(self):
|
||||
"""Regression: requests picked up ALL_PROXY and routed the gateway
|
||||
call through a local proxy, which timed out after 120s."""
|
||||
assert ai_svc.CATALOG["gateway"]._session().trust_env is False
|
||||
assert ai_svc.CATALOG["gemini-flash"]._session().trust_env is True
|
||||
|
||||
|
||||
# --- output budget ----------------------------------------------------------
|
||||
class TestMaxTokens:
|
||||
def test_default_applies_to_ordinary_models(self):
|
||||
assert ai_svc.CATALOG["gemini-flash"].max_tokens == ai_svc.FALLBACK_MAX_TOKENS
|
||||
|
||||
def test_reasoning_endpoint_declares_a_larger_budget(self):
|
||||
"""Regression: the gateway's primary upstream thinks out loud before
|
||||
answering; at the default cap the trace consumed the whole budget and
|
||||
the reply was truncated before any JSON appeared."""
|
||||
assert ai_svc.CATALOG["gateway"].max_tokens > ai_svc.FALLBACK_MAX_TOKENS
|
||||
|
||||
def test_env_overrides_the_default_but_not_an_explicit_budget(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_MAX_TOKENS", "77")
|
||||
assert ai_svc.CATALOG["gemini-flash"].max_tokens == 77
|
||||
assert ai_svc.CATALOG["gateway"].max_tokens == 3000
|
||||
|
||||
def test_budget_reaches_the_openai_payload(self, gateway, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
seen["max_tokens"] = kwargs["json"]["max_tokens"]
|
||||
return FakeResponse(200, openai_payload("hi"))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
ai_svc.CATALOG["gateway"].generate("p")
|
||||
assert seen["max_tokens"] == 3000
|
||||
|
||||
def test_budget_reaches_the_gemini_payload(self, keys, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
seen["cap"] = kwargs["json"]["generationConfig"]["maxOutputTokens"]
|
||||
return FakeResponse(200, gemini_payload("hi"))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
ai_svc.CATALOG["gemini-flash"].generate("p")
|
||||
assert seen["cap"] == ai_svc.FALLBACK_MAX_TOKENS
|
||||
|
||||
|
||||
# --- lazily-read configuration ----------------------------------------------
|
||||
class TestLazyConfig:
|
||||
"""Regression: these were module-level constants, so they froze whatever
|
||||
the environment held at import — hiding config changes and letting a
|
||||
developer's .env leak into the test run."""
|
||||
|
||||
def test_chain_reflects_the_current_environment(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_MODEL_CHAIN", "llama-70b,gemini-flash")
|
||||
assert ai_svc.default_chain() == ["llama-70b", "gemini-flash"]
|
||||
monkeypatch.setenv("AI_MODEL_CHAIN", "gateway")
|
||||
assert ai_svc.default_chain() == ["gateway"]
|
||||
|
||||
def test_timeout_reflects_the_current_environment(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_TIMEOUT_SECONDS", "7")
|
||||
assert ai_svc.default_timeout() == 7.0
|
||||
|
||||
def test_day_budget_reflects_the_current_environment(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_DAY_BUDGET", "42")
|
||||
assert ai_svc.default_day_budget() == 42
|
||||
|
||||
def test_defaults_apply_when_unset(self):
|
||||
assert ai_svc.default_timeout() == ai_svc.FALLBACK_TIMEOUT
|
||||
assert ai_svc.default_day_budget() == ai_svc.FALLBACK_DAY_BUDGET
|
||||
assert ai_svc.default_chain()[0] == "gateway"
|
||||
|
||||
def test_default_flag_tracks_the_chain_head(self, monkeypatch, keys):
|
||||
monkeypatch.setenv("AI_MODEL_CHAIN", "llama-70b,gemini-flash")
|
||||
by_id = {m["id"]: m for m in ai_svc.list_models()}
|
||||
assert by_id["llama-70b"]["default"] is True
|
||||
assert by_id["gemini-flash"]["default"] is False
|
||||
|
||||
|
||||
# --- context sizing ---------------------------------------------------------
|
||||
class TestPerModelSizing:
|
||||
"""Chain members' windows differ by >30x, so the payload is sized per
|
||||
model rather than once for the whole chain."""
|
||||
|
||||
def test_small_window_gets_fewer_days_than_a_large_one(self):
|
||||
# A budget above what 128k can hold, so the window is what binds.
|
||||
budget = 100_000
|
||||
small = ai_svc.max_days_for(ai_svc.CATALOG["llama-70b"], budget) # 128k
|
||||
large = ai_svc.max_days_for(ai_svc.CATALOG["gemini-flash"], budget) # 1M
|
||||
assert small < large
|
||||
|
||||
def test_budget_binds_when_it_is_the_tighter_limit(self):
|
||||
"""At the default 365-day budget every model gets the same 365 days —
|
||||
no window in the catalog is small enough to bind first."""
|
||||
budget = ai_svc.default_day_budget()
|
||||
days = {
|
||||
mid: ai_svc.max_days_for(p, budget) for mid, p in ai_svc.CATALOG.items()
|
||||
}
|
||||
assert set(days.values()) == {budget}
|
||||
|
||||
def test_never_exceeds_the_configured_budget(self):
|
||||
assert ai_svc.max_days_for(ai_svc.CATALOG["gemini-flash"], day_budget=30) == 30
|
||||
|
||||
def test_always_allows_at_least_one_day(self):
|
||||
tiny = ai_svc.OpenAICompatProvider(
|
||||
model_id="tiny", context_window=10,
|
||||
base_url_env="X", default_base_url="http://x", requires_key=False,
|
||||
)
|
||||
assert ai_svc.max_days_for(tiny) >= 1
|
||||
|
||||
def test_each_model_gets_a_prompt_sized_for_itself(self, keys, monkeypatch):
|
||||
"""Regression: one prompt was built for the whole chain, so a payload
|
||||
sized for Gemini's 1M window was also sent to 128k models.
|
||||
|
||||
Needs more days than the 128k window holds (~6.4k) for the trimming to
|
||||
bite, hence the deliberately oversized history.
|
||||
"""
|
||||
history = [{"date": "2026-01-01", "steps": 8000} for _ in range(8000)]
|
||||
sizes = {}
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
if "generativelanguage" in url:
|
||||
sizes["gemini"] = len(kwargs["json"]["contents"][0]["parts"][0]["text"])
|
||||
raise requests.Timeout("force fallback")
|
||||
sizes["nvidia"] = len(kwargs["json"]["messages"][0]["content"])
|
||||
return FakeResponse(200, openai_payload(VALID_REPLY))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
ai_svc.generate(history, day_budget=100_000)
|
||||
|
||||
assert sizes["nvidia"] < sizes["gemini"], (
|
||||
"the 128k model must receive a smaller prompt than the 1M model"
|
||||
)
|
||||
|
||||
|
||||
# --- catalog & chain --------------------------------------------------------
|
||||
class TestCatalog:
|
||||
def test_all_models_listed(self, keys):
|
||||
assert {m["id"] for m in ai_svc.list_models()} == {
|
||||
"gateway", "gemini-flash", "llama-70b", "nemotron-49b", "mistral-large"
|
||||
}
|
||||
|
||||
def test_configured_flag_tracks_the_environment(self, no_keys, monkeypatch):
|
||||
assert all(not m["configured"] for m in ai_svc.list_models())
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "k")
|
||||
by_id = {m["id"]: m for m in ai_svc.list_models()}
|
||||
assert by_id["gemini-flash"]["configured"] is True
|
||||
assert by_id["llama-70b"]["configured"] is False
|
||||
|
||||
def test_every_model_declares_a_large_window(self):
|
||||
assert all(m["contextWindow"] >= 128_000 for m in ai_svc.list_models())
|
||||
|
||||
def test_no_vision_models_registered(self):
|
||||
assert not any("vision" in m["model"] for m in ai_svc.list_models())
|
||||
|
||||
|
||||
class TestResolveChain:
|
||||
def test_preferred_model_goes_first(self, keys):
|
||||
assert ai_svc.resolve_chain("nemotron-49b")[0] == "nemotron-49b"
|
||||
|
||||
def test_chain_has_no_duplicates(self, keys):
|
||||
chain = ai_svc.resolve_chain("gemini-flash")
|
||||
assert len(chain) == len(set(chain))
|
||||
|
||||
def test_unconfigured_models_are_skipped(self, no_keys, monkeypatch):
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "k")
|
||||
assert ai_svc.resolve_chain() == ["gemini-flash"]
|
||||
|
||||
def test_unknown_model_raises(self, keys):
|
||||
with pytest.raises(ai_svc.AIError, match="未知模型"):
|
||||
ai_svc.resolve_chain("gpt-nonexistent")
|
||||
|
||||
def test_no_credentials_raises_with_actionable_message(self, no_keys):
|
||||
with pytest.raises(ai_svc.AIError, match="GEMINI_API_KEY"):
|
||||
ai_svc.resolve_chain()
|
||||
|
||||
def test_gateway_needs_both_token_and_base_url(self, no_keys, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_TOKEN", "t")
|
||||
assert ai_svc.CATALOG["gateway"].is_configured() is False
|
||||
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gw.test:5100/v1")
|
||||
assert ai_svc.CATALOG["gateway"].is_configured() is True
|
||||
|
||||
|
||||
# --- generate + fallback ----------------------------------------------------
|
||||
class TestGenerate:
|
||||
def test_returns_recommendations_and_meta(self, keys, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
requests.Session, "post", lambda *a, **k: FakeResponse(200, gemini_payload(VALID_REPLY))
|
||||
)
|
||||
recs, meta = ai_svc.generate(SUMMARY)
|
||||
assert len(recs) == 2
|
||||
assert meta["model"] == "gemini-flash"
|
||||
assert meta["days"] == 2
|
||||
assert meta["fallbackFrom"] == []
|
||||
|
||||
def test_falls_back_to_the_next_model(self, keys, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
calls.append(url)
|
||||
if "generativelanguage" in url:
|
||||
raise requests.Timeout("gemini down")
|
||||
return FakeResponse(200, openai_payload(VALID_REPLY))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
recs, meta = ai_svc.generate(SUMMARY)
|
||||
|
||||
assert len(recs) == 2
|
||||
assert meta["model"] == "llama-70b"
|
||||
assert meta["fallbackFrom"] == ["gemini-flash"]
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_falls_back_when_a_model_returns_unparseable_text(self, keys, monkeypatch):
|
||||
def fake_post(self, url, **kwargs):
|
||||
if "generativelanguage" in url:
|
||||
return FakeResponse(200, gemini_payload("抱歉,我帮不了你。"))
|
||||
return FakeResponse(200, openai_payload(VALID_REPLY))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
_, meta = ai_svc.generate(SUMMARY)
|
||||
assert meta["model"] == "llama-70b"
|
||||
|
||||
def test_raises_when_every_model_fails(self, keys, monkeypatch):
|
||||
def boom(self, *a, **k):
|
||||
raise requests.Timeout("all down")
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", boom)
|
||||
with pytest.raises(ai_svc.AIError, match="所有模型均失败"):
|
||||
ai_svc.generate(SUMMARY)
|
||||
|
||||
def test_preferred_model_is_honoured(self, keys, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
seen["model"] = (kwargs.get("json") or {}).get("model")
|
||||
return FakeResponse(200, openai_payload(VALID_REPLY))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
_, meta = ai_svc.generate(SUMMARY, preferred_model="nemotron-49b")
|
||||
assert meta["model"] == "nemotron-49b"
|
||||
assert seen["model"] == "nvidia/llama-3.3-nemotron-super-49b-v1.5"
|
||||
|
||||
def test_no_second_call_after_the_first_succeeds(self, keys, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
calls.append(url)
|
||||
return FakeResponse(200, gemini_payload(VALID_REPLY))
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
ai_svc.generate(SUMMARY)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# --- service + endpoint integration -----------------------------------------
|
||||
class TestAiRecommendationsService:
|
||||
def test_uses_the_rule_engine_when_there_is_no_data(self, db, user, keys):
|
||||
out = analysis_svc.get_ai_recommendations(user["id"])
|
||||
assert out["meta"]["source"] == "rules"
|
||||
assert out["recommendations"][0]["id"] == "no-data"
|
||||
|
||||
def test_returns_ai_results_when_a_model_answers(
|
||||
self, seed_health, user, keys, monkeypatch
|
||||
):
|
||||
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
||||
monkeypatch.setattr(
|
||||
requests.Session, "post", lambda *a, **k: FakeResponse(200, gemini_payload(VALID_REPLY))
|
||||
)
|
||||
out = analysis_svc.get_ai_recommendations(user["id"])
|
||||
assert out["meta"]["source"] == "ai"
|
||||
assert len(out["recommendations"]) == 2
|
||||
|
||||
def test_degrades_to_rules_when_all_models_fail(
|
||||
self, seed_health, user, keys, monkeypatch
|
||||
):
|
||||
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
||||
|
||||
def boom(self, *a, **k):
|
||||
raise requests.Timeout("down")
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", boom)
|
||||
out = analysis_svc.get_ai_recommendations(user["id"])
|
||||
assert out["meta"]["source"] == "rules"
|
||||
assert "所有模型均失败" in out["meta"]["reason"]
|
||||
assert out["recommendations"], "must still return rule-based advice"
|
||||
|
||||
def test_degrades_to_rules_when_no_key_is_configured(
|
||||
self, seed_health, user, no_keys
|
||||
):
|
||||
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
||||
out = analysis_svc.get_ai_recommendations(user["id"])
|
||||
assert out["meta"]["source"] == "rules"
|
||||
assert "GEMINI_API_KEY" in out["meta"]["reason"]
|
||||
|
||||
|
||||
class TestEndpoints:
|
||||
def test_models_requires_auth(self, client):
|
||||
assert client.get("/api/analysis/models").status_code == 401
|
||||
|
||||
def test_ai_recommendations_requires_auth(self, client):
|
||||
assert client.get("/api/analysis/ai-recommendations").status_code == 401
|
||||
|
||||
def test_models_endpoint_lists_catalog(self, client, auth, keys):
|
||||
r = client.get("/api/analysis/models", headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert {m["id"] for m in r.get_json()} >= {"gemini-flash", "llama-70b"}
|
||||
|
||||
def test_models_endpoint_never_leaks_api_keys(self, client, auth, keys):
|
||||
body = client.get("/api/analysis/models", headers=auth).get_data(as_text=True)
|
||||
assert "test-gemini-key" not in body
|
||||
assert "test-nvidia-key" not in body
|
||||
|
||||
def test_ai_endpoint_returns_200_even_with_no_models(self, client, auth, no_keys):
|
||||
r = client.get("/api/analysis/ai-recommendations", headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["meta"]["source"] == "rules"
|
||||
|
||||
def test_ai_endpoint_passes_model_param_through(
|
||||
self, client, auth, seed_health, keys, monkeypatch
|
||||
):
|
||||
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
||||
monkeypatch.setattr(
|
||||
requests.Session, "post", lambda *a, **k: FakeResponse(200, openai_payload(VALID_REPLY))
|
||||
)
|
||||
r = client.get(
|
||||
"/api/analysis/ai-recommendations?model=nemotron-49b", headers=auth
|
||||
)
|
||||
assert r.get_json()["meta"]["model"] == "nemotron-49b"
|
||||
|
||||
def test_unknown_model_param_degrades_to_rules(
|
||||
self, client, auth, seed_health, keys
|
||||
):
|
||||
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
||||
r = client.get("/api/analysis/ai-recommendations?model=bogus", headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["meta"]["source"] == "rules"
|
||||
240
backend/tests/test_ai_cache.py
Normal file
240
backend/tests/test_ai_cache.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Unit tests for the AI recommendation cache.
|
||||
|
||||
A generation costs minutes against a large reasoning model, so the result is
|
||||
stored and reused. These tests pin when it is reused and — more importantly —
|
||||
when it must not be.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from services import analysis as analysis_svc
|
||||
from services import health as health_svc
|
||||
|
||||
|
||||
VALID_REPLY = json.dumps(
|
||||
[{"category": "睡眠", "recommendation": "早点睡。", "priority": "high",
|
||||
"basedOn": ["sleep_duration"]}],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text or json.dumps(payload or {})
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json")
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keys(monkeypatch):
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def counting_llm(monkeypatch):
|
||||
"""Mock the LLM and count how many times it is actually called."""
|
||||
calls = []
|
||||
|
||||
def fake_post(self, url, **kwargs):
|
||||
calls.append(url)
|
||||
return FakeResponse(
|
||||
200, {"candidates": [{"content": {"parts": [{"text": VALID_REPLY}]}}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", fake_post)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded(seed_health, user):
|
||||
seed_health([{"date": "2026-08-20", "steps": 5000, "sleep_duration": 6}])
|
||||
return user
|
||||
|
||||
|
||||
class TestCacheHit:
|
||||
def test_first_call_reaches_the_model(self, seeded, keys, counting_llm):
|
||||
out = analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert out["meta"]["source"] == "ai"
|
||||
assert out["meta"]["cached"] is False
|
||||
assert len(counting_llm) == 1
|
||||
|
||||
def test_second_call_is_served_from_cache(self, seeded, keys, counting_llm):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
out = analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
|
||||
assert len(counting_llm) == 1, "the model must not be called twice"
|
||||
assert out["meta"]["cached"] is True
|
||||
assert out["meta"]["source"] == "ai"
|
||||
|
||||
def test_cached_result_matches_the_generated_one(self, seeded, keys, counting_llm):
|
||||
first = analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
second = analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert first["recommendations"] == second["recommendations"]
|
||||
|
||||
def test_cache_records_which_model_answered(self, seeded, keys, counting_llm):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
out = analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert out["meta"]["model"] == "gemini-flash"
|
||||
|
||||
def test_cache_reports_when_it_was_generated(self, seeded, keys, counting_llm):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
out = analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert out["meta"]["generatedAt"]
|
||||
|
||||
|
||||
class TestCacheInvalidation:
|
||||
def test_new_health_data_invalidates(self, seeded, keys, counting_llm):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
health_svc.upsert_health_daily(
|
||||
seeded["id"], {"date": "2026-08-21", "steps": 9000}
|
||||
)
|
||||
out = analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
|
||||
assert len(counting_llm) == 2, "a new day of data must trigger a regeneration"
|
||||
assert out["meta"]["cached"] is False
|
||||
|
||||
def test_corrected_value_invalidates(self, seeded, keys, counting_llm):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
# Same date, different step count — a re-sync correcting a value.
|
||||
health_svc.upsert_health_daily(
|
||||
seeded["id"], {"date": "2026-08-20", "steps": 12345, "sleepDuration": 6}
|
||||
)
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert len(counting_llm) == 2
|
||||
|
||||
def test_new_activity_invalidates(self, seeded, keys, counting_llm):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
health_svc.insert_activity(
|
||||
seeded["id"],
|
||||
{"activityType": "running", "startTime": "2026-08-20T07:00:00",
|
||||
"endTime": "2026-08-20T07:30:00"},
|
||||
)
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert len(counting_llm) == 2
|
||||
|
||||
def test_refresh_bypasses_the_cache(self, seeded, keys, counting_llm):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
out = analysis_svc.get_ai_recommendations(seeded["id"], refresh=True)
|
||||
assert len(counting_llm) == 2
|
||||
assert out["meta"]["cached"] is False
|
||||
|
||||
def test_explicit_model_bypasses_the_cache(self, seeded, keys, counting_llm):
|
||||
"""Asking for a named model means wanting that model's answer."""
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
analysis_svc.get_ai_recommendations(seeded["id"], model="gemini-flash")
|
||||
assert len(counting_llm) == 2
|
||||
|
||||
def test_expired_entry_is_regenerated(self, seeded, keys, counting_llm, db):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
stale = (
|
||||
datetime.datetime.utcnow()
|
||||
- datetime.timedelta(hours=analysis_svc.CACHE_TTL_HOURS + 1)
|
||||
).isoformat(timespec="seconds")
|
||||
db.execute(
|
||||
"UPDATE ai_recommendations SET created_at = ? WHERE user_id = ?",
|
||||
[stale, seeded["id"]],
|
||||
)
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert len(counting_llm) == 2
|
||||
|
||||
def test_entry_just_inside_the_ttl_is_kept(self, seeded, keys, counting_llm, db):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
fresh = (
|
||||
datetime.datetime.utcnow()
|
||||
- datetime.timedelta(hours=analysis_svc.CACHE_TTL_HOURS - 1)
|
||||
).isoformat(timespec="seconds")
|
||||
db.execute(
|
||||
"UPDATE ai_recommendations SET created_at = ? WHERE user_id = ?",
|
||||
[fresh, seeded["id"]],
|
||||
)
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert len(counting_llm) == 1
|
||||
|
||||
def test_clear_cache_forces_regeneration(self, seeded, keys, counting_llm):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
analysis_svc.clear_ai_cache(seeded["id"])
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert len(counting_llm) == 2
|
||||
|
||||
|
||||
class TestIsolationAndRobustness:
|
||||
def test_cache_is_per_user(self, seeded, keys, counting_llm, db, client):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
|
||||
other = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "other@example.com", "garminEmail": "o@example.com",
|
||||
"garminPassword": "pw123456"},
|
||||
).get_json()
|
||||
health_svc.upsert_health_daily(
|
||||
other["id"], {"date": "2026-08-20", "steps": 5000, "sleepDuration": 6}
|
||||
)
|
||||
|
||||
analysis_svc.get_ai_recommendations(other["id"])
|
||||
assert len(counting_llm) == 2, "one user's cache must not answer another's"
|
||||
|
||||
def test_only_one_row_per_user(self, seeded, keys, counting_llm, db):
|
||||
for _ in range(3):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"], refresh=True)
|
||||
rows = db.query_all(
|
||||
"SELECT * FROM ai_recommendations WHERE user_id = ?", [seeded["id"]]
|
||||
)
|
||||
assert len(rows) == 1, "regeneration must replace, not accumulate"
|
||||
|
||||
def test_corrupt_payload_regenerates_instead_of_raising(
|
||||
self, seeded, keys, counting_llm, db
|
||||
):
|
||||
analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
db.execute(
|
||||
"UPDATE ai_recommendations SET payload = ? WHERE user_id = ?",
|
||||
["not json", seeded["id"]],
|
||||
)
|
||||
out = analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
assert len(counting_llm) == 2
|
||||
assert out["recommendations"]
|
||||
|
||||
def test_rule_fallback_is_not_cached(self, seeded, monkeypatch, db):
|
||||
"""A degraded answer must not be stored as if it were the AI's."""
|
||||
def boom(self, *a, **k):
|
||||
raise requests.Timeout("down")
|
||||
|
||||
monkeypatch.setattr(requests.Session, "post", boom)
|
||||
out = analysis_svc.get_ai_recommendations(seeded["id"])
|
||||
|
||||
assert out["meta"]["source"] == "rules"
|
||||
assert db.query_one(
|
||||
"SELECT * FROM ai_recommendations WHERE user_id = ?", [seeded["id"]]
|
||||
) is None
|
||||
|
||||
def test_no_data_user_is_not_cached(self, user, keys, counting_llm, db):
|
||||
analysis_svc.get_ai_recommendations(user["id"])
|
||||
assert len(counting_llm) == 0
|
||||
assert db.query_one(
|
||||
"SELECT * FROM ai_recommendations WHERE user_id = ?", [user["id"]]
|
||||
) is None
|
||||
|
||||
|
||||
class TestEndpoint:
|
||||
def test_second_request_is_cached(self, client, auth, seeded, keys, counting_llm):
|
||||
client.get("/api/analysis/ai-recommendations", headers=auth)
|
||||
r = client.get("/api/analysis/ai-recommendations", headers=auth)
|
||||
assert r.get_json()["meta"]["cached"] is True
|
||||
assert len(counting_llm) == 1
|
||||
|
||||
def test_refresh_param_forces_regeneration(
|
||||
self, client, auth, seeded, keys, counting_llm
|
||||
):
|
||||
client.get("/api/analysis/ai-recommendations", headers=auth)
|
||||
r = client.get("/api/analysis/ai-recommendations?refresh=1", headers=auth)
|
||||
assert r.get_json()["meta"]["cached"] is False
|
||||
assert len(counting_llm) == 2
|
||||
213
backend/tests/test_analysis.py
Normal file
213
backend/tests/test_analysis.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Unit tests for the trends query and the rule-based recommendation engine."""
|
||||
import pytest
|
||||
|
||||
from services import analysis as analysis_svc
|
||||
|
||||
|
||||
def ids(recs):
|
||||
return {r["id"] for r in recs}
|
||||
|
||||
|
||||
def days(n, **metrics):
|
||||
"""n consecutive days that all carry the same metric values."""
|
||||
return [
|
||||
{"date": f"2026-08-{d:02d}", **metrics} for d in range(1, n + 1)
|
||||
]
|
||||
|
||||
|
||||
# --- trends -----------------------------------------------------------------
|
||||
class TestTrends:
|
||||
def test_empty_without_data(self, db, user):
|
||||
assert analysis_svc.get_trends("steps", user["id"]) == []
|
||||
|
||||
def test_returns_date_value_pairs(self, seed_health, user):
|
||||
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
||||
assert analysis_svc.get_trends("steps", user["id"]) == [
|
||||
{"date": "2026-08-20", "value": 5000}
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metric,column_value",
|
||||
[
|
||||
("steps", {"steps": 5000}),
|
||||
("heart_rate", {"heart_rate": 60}),
|
||||
("sleep_duration", {"sleep_duration": 7}),
|
||||
("stress", {"stress": 30}),
|
||||
("calories_burned", {"calories": 250}),
|
||||
],
|
||||
)
|
||||
def test_each_supported_metric(self, seed_health, user, metric, column_value):
|
||||
seed_health([{"date": "2026-08-20", **column_value}])
|
||||
rows = analysis_svc.get_trends(metric, user["id"])
|
||||
assert len(rows) == 1 and rows[0]["value"] is not None
|
||||
|
||||
def test_unknown_metric_falls_back_to_steps(self, seed_health, user):
|
||||
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
||||
assert analysis_svc.get_trends("bogus", user["id"]) == [
|
||||
{"date": "2026-08-20", "value": 5000}
|
||||
]
|
||||
|
||||
def test_unknown_metric_cannot_inject_sql(self, seed_health, user):
|
||||
"""The metric name indexes a whitelist; it is never interpolated raw."""
|
||||
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
||||
rows = analysis_svc.get_trends(
|
||||
"steps FROM health_data; DROP TABLE health_data --", user["id"]
|
||||
)
|
||||
assert rows == [{"date": "2026-08-20", "value": 5000}]
|
||||
|
||||
def test_nulls_excluded(self, seed_health, user):
|
||||
seed_health([
|
||||
{"date": "2026-08-20", "steps": 5000},
|
||||
{"date": "2026-08-21"},
|
||||
])
|
||||
assert len(analysis_svc.get_trends("steps", user["id"])) == 1
|
||||
|
||||
def test_date_range_filter(self, seed_health, user):
|
||||
seed_health([
|
||||
{"date": "2026-08-20", "steps": 1},
|
||||
{"date": "2026-08-21", "steps": 2},
|
||||
{"date": "2026-08-22", "steps": 3},
|
||||
])
|
||||
rows = analysis_svc.get_trends(
|
||||
"steps", user["id"], "2026-08-21", "2026-08-21"
|
||||
)
|
||||
assert rows == [{"date": "2026-08-21", "value": 2}]
|
||||
|
||||
|
||||
# --- recommendations --------------------------------------------------------
|
||||
class TestNoData:
|
||||
def test_returns_a_single_guidance_item(self, db, user):
|
||||
recs = analysis_svc.get_recommendations(user["id"])
|
||||
assert len(recs) == 1
|
||||
assert recs[0]["id"] == "no-data"
|
||||
assert recs[0]["priority"] == "low"
|
||||
|
||||
|
||||
class TestStepsRule:
|
||||
def test_fires_below_8000(self, seed_health, user):
|
||||
seed_health(days(5, steps=6000))
|
||||
assert "steps" in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
def test_silent_at_or_above_8000(self, seed_health, user):
|
||||
seed_health(days(5, steps=8000))
|
||||
assert "steps" not in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
def test_priority_is_medium(self, seed_health, user):
|
||||
seed_health(days(5, steps=6000))
|
||||
rec = next(
|
||||
r for r in analysis_svc.get_recommendations(user["id"]) if r["id"] == "steps"
|
||||
)
|
||||
assert rec["priority"] == "medium"
|
||||
assert rec["basedOn"] == ["steps"]
|
||||
|
||||
def test_averages_across_days_not_per_day(self, seed_health, user):
|
||||
# 4000 and 12000 average to 8000 -> rule must NOT fire.
|
||||
seed_health([
|
||||
{"date": "2026-08-01", "steps": 4000},
|
||||
{"date": "2026-08-02", "steps": 12000},
|
||||
])
|
||||
assert "steps" not in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
|
||||
class TestSleepRule:
|
||||
def test_fires_below_7_hours(self, seed_health, user):
|
||||
seed_health(days(5, sleep_duration=6))
|
||||
assert "sleep" in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
def test_silent_at_7_hours(self, seed_health, user):
|
||||
seed_health(days(5, sleep_duration=7))
|
||||
assert "sleep" not in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
def test_priority_is_high(self, seed_health, user):
|
||||
seed_health(days(5, sleep_duration=5))
|
||||
rec = next(
|
||||
r for r in analysis_svc.get_recommendations(user["id"]) if r["id"] == "sleep"
|
||||
)
|
||||
assert rec["priority"] == "high"
|
||||
|
||||
def test_days_without_sleep_do_not_drag_the_average_down(self, seed_health, user):
|
||||
"""Nights with no sleep record must be excluded, not counted as zero."""
|
||||
seed_health([
|
||||
{"date": "2026-08-01", "sleep_duration": 8},
|
||||
{"date": "2026-08-02", "steps": 5000}, # no sleep recorded
|
||||
])
|
||||
assert "sleep" not in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
|
||||
class TestStressRule:
|
||||
def test_fires_above_50(self, seed_health, user):
|
||||
seed_health(days(5, stress=60))
|
||||
assert "stress" in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
def test_silent_at_50(self, seed_health, user):
|
||||
seed_health(days(5, stress=50))
|
||||
assert "stress" not in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
|
||||
class TestRestingHeartRateRule:
|
||||
def test_fires_above_65(self, seed_health, user):
|
||||
seed_health(days(5, heart_rate=70))
|
||||
assert "rhr" in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
def test_silent_at_65(self, seed_health, user):
|
||||
seed_health(days(5, heart_rate=65))
|
||||
assert "rhr" not in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
|
||||
class TestHrvRule:
|
||||
def test_fires_below_40(self, seed_health, user):
|
||||
seed_health(days(5, hrv=30))
|
||||
assert "hrv" in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
def test_silent_at_40(self, seed_health, user):
|
||||
seed_health(days(5, hrv=40))
|
||||
assert "hrv" not in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
|
||||
class TestHealthyUser:
|
||||
def test_all_good_returns_the_positive_message(self, seed_health, user):
|
||||
seed_health(days(5, steps=10000, sleep_duration=8, stress=30,
|
||||
heart_rate=55, hrv=60))
|
||||
recs = analysis_svc.get_recommendations(user["id"])
|
||||
assert ids(recs) == {"good"}
|
||||
|
||||
|
||||
class TestOrderingAndWindow:
|
||||
def test_sorted_high_medium_low(self, seed_health, user):
|
||||
seed_health(days(5, steps=6000, sleep_duration=5, hrv=30))
|
||||
order = {"high": 0, "medium": 1, "low": 2}
|
||||
priorities = [r["priority"] for r in analysis_svc.get_recommendations(user["id"])]
|
||||
assert priorities == sorted(priorities, key=lambda p: order[p])
|
||||
assert priorities[0] == "high"
|
||||
|
||||
def test_only_the_last_14_days_count(self, seed_health, user):
|
||||
"""20 lazy days then 14 active ones: the old days must not pull it down."""
|
||||
old = [{"date": f"2026-07-{d:02d}", "steps": 1000} for d in range(1, 21)]
|
||||
recent = [{"date": f"2026-08-{d:02d}", "steps": 12000} for d in range(1, 15)]
|
||||
seed_health(old + recent)
|
||||
assert "steps" not in ids(analysis_svc.get_recommendations(user["id"]))
|
||||
|
||||
def test_multiple_rules_can_fire_together(self, seed_health, user):
|
||||
seed_health(days(5, steps=5000, sleep_duration=5, stress=70, heart_rate=75))
|
||||
assert {"steps", "sleep", "stress", "rhr"} <= ids(
|
||||
analysis_svc.get_recommendations(user["id"])
|
||||
)
|
||||
|
||||
|
||||
class TestEndpoints:
|
||||
def test_trends_requires_auth(self, client):
|
||||
assert client.get("/api/analysis/trends").status_code == 401
|
||||
|
||||
def test_recommendations_requires_auth(self, client):
|
||||
assert client.get("/api/analysis/recommendations").status_code == 401
|
||||
|
||||
def test_trends_endpoint(self, client, auth, seed_health):
|
||||
seed_health(days(3, steps=5000))
|
||||
r = client.get("/api/analysis/trends?metricType=steps", headers=auth)
|
||||
assert r.status_code == 200 and len(r.get_json()) == 3
|
||||
|
||||
def test_recommendations_endpoint(self, client, auth, seed_health):
|
||||
seed_health(days(3, steps=5000))
|
||||
r = client.get("/api/analysis/recommendations", headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert "steps" in {x["id"] for x in r.get_json()}
|
||||
233
backend/tests/test_auth.py
Normal file
233
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Unit tests for password hashing, JWT handling, and the auth endpoints."""
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
import auth
|
||||
from config import JWT_SECRET
|
||||
|
||||
|
||||
# --- password hashing -------------------------------------------------------
|
||||
class TestPasswordHashing:
|
||||
def test_hash_is_verifiable(self):
|
||||
stored = auth.hash_password("correct horse")
|
||||
assert auth.verify_password("correct horse", stored) is True
|
||||
|
||||
def test_wrong_password_rejected(self):
|
||||
stored = auth.hash_password("correct horse")
|
||||
assert auth.verify_password("wrong horse", stored) is False
|
||||
|
||||
def test_salt_makes_hashes_unique(self):
|
||||
a = auth.hash_password("same")
|
||||
b = auth.hash_password("same")
|
||||
assert a != b, "identical passwords must not produce identical hashes"
|
||||
|
||||
def test_hash_format_is_self_describing(self):
|
||||
stored = auth.hash_password("pw")
|
||||
prefix, iterations, salt, digest = stored.split("$")
|
||||
assert prefix == auth.PBKDF2_PREFIX
|
||||
assert int(iterations) == auth.PBKDF2_ITERATIONS
|
||||
assert len(bytes.fromhex(salt)) == 16
|
||||
assert len(bytes.fromhex(digest)) == 64
|
||||
|
||||
def test_password_never_appears_in_hash(self):
|
||||
stored = auth.hash_password("supersecret")
|
||||
assert "supersecret" not in stored
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stored",
|
||||
["", None, "garbage", "pbkdf2_sha256$notanint$aa$bb", "nothex:nothex"],
|
||||
)
|
||||
def test_malformed_hashes_rejected_not_raised(self, stored):
|
||||
assert auth.verify_password("anything", stored) is False
|
||||
|
||||
def test_hashing_does_not_require_scrypt(self, monkeypatch):
|
||||
"""Regression: macOS system Python (LibreSSL) has no hashlib.scrypt.
|
||||
|
||||
Registration used to raise AttributeError -> HTTP 500 on those builds.
|
||||
"""
|
||||
monkeypatch.delattr(hashlib, "scrypt", raising=False)
|
||||
stored = auth.hash_password("pw")
|
||||
assert auth.verify_password("pw", stored) is True
|
||||
|
||||
def test_unicode_password(self):
|
||||
stored = auth.hash_password("密码🔒")
|
||||
assert auth.verify_password("密码🔒", stored) is True
|
||||
assert auth.verify_password("密码", stored) is False
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(hashlib, "scrypt"), reason="interpreter built without scrypt"
|
||||
)
|
||||
def test_legacy_scrypt_hash_still_verifies(self):
|
||||
salt = os.urandom(16)
|
||||
digest = hashlib.scrypt(
|
||||
b"legacy", salt=salt, n=16384, r=8, p=1, dklen=64
|
||||
).hex()
|
||||
assert auth.verify_password("legacy", f"{salt.hex()}:{digest}") is True
|
||||
assert auth.verify_password("nope", f"{salt.hex()}:{digest}") is False
|
||||
|
||||
|
||||
# --- JWT --------------------------------------------------------------------
|
||||
class TestTokens:
|
||||
def test_sign_and_verify_roundtrip(self):
|
||||
token = auth.sign_token("user-123")
|
||||
assert auth.verify_token(token) == {"user_id": "user-123"}
|
||||
|
||||
def test_expired_token_rejected(self):
|
||||
past = datetime.datetime.utcnow() - datetime.timedelta(days=1)
|
||||
token = jwt.encode(
|
||||
{"sub": "u", "iat": past, "exp": past}, JWT_SECRET, algorithm="HS256"
|
||||
)
|
||||
with pytest.raises(jwt.ExpiredSignatureError):
|
||||
auth.verify_token(token)
|
||||
|
||||
def test_token_signed_with_other_secret_rejected(self):
|
||||
token = jwt.encode({"sub": "u"}, "a-different-secret", algorithm="HS256")
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
auth.verify_token(token)
|
||||
|
||||
def test_tampered_token_rejected(self):
|
||||
token = auth.sign_token("user-123")
|
||||
head, payload, sig = token.split(".")
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
auth.verify_token(f"{head}.{payload}.{sig[:-2]}xx")
|
||||
|
||||
|
||||
# --- register ---------------------------------------------------------------
|
||||
class TestRegister:
|
||||
def test_returns_201_and_token(self, client):
|
||||
r = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "a@example.com",
|
||||
"garminEmail": "g@example.com",
|
||||
"garminPassword": "pw123456",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.get_json()
|
||||
assert body["email"] == "a@example.com"
|
||||
assert auth.verify_token(body["token"])["user_id"] == body["id"]
|
||||
|
||||
def test_duplicate_email_conflicts(self, client, user):
|
||||
r = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": user["email"],
|
||||
"garminEmail": "other@example.com",
|
||||
"garminPassword": "pw123456",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"email": "a@example.com"},
|
||||
{"email": "a@example.com", "garminEmail": "g@example.com"},
|
||||
{"email": "", "garminEmail": "g@example.com", "garminPassword": "x"},
|
||||
],
|
||||
)
|
||||
def test_missing_fields_rejected(self, client, payload):
|
||||
assert client.post("/api/auth/register", json=payload).status_code == 400
|
||||
|
||||
def test_password_stored_only_as_hash(self, client, db):
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": "h@example.com",
|
||||
"garminEmail": "g@example.com",
|
||||
"garminPassword": "plaintext-secret",
|
||||
},
|
||||
)
|
||||
row = db.query_one(
|
||||
"SELECT garmin_password_hash FROM users WHERE email = ?", ["h@example.com"]
|
||||
)
|
||||
assert "plaintext-secret" not in row["garmin_password_hash"]
|
||||
assert auth.verify_password("plaintext-secret", row["garmin_password_hash"])
|
||||
|
||||
|
||||
# --- login ------------------------------------------------------------------
|
||||
class TestLogin:
|
||||
def test_valid_credentials(self, client, user):
|
||||
r = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": user["email"], "password": user["password"]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["id"] == user["id"]
|
||||
|
||||
def test_wrong_password(self, client, user):
|
||||
r = client.post(
|
||||
"/api/auth/login", json={"email": user["email"], "password": "nope"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_unknown_email(self, client):
|
||||
r = client.post(
|
||||
"/api/auth/login", json={"email": "ghost@example.com", "password": "pw"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_error_does_not_reveal_which_field_was_wrong(self, client, user):
|
||||
unknown = client.post(
|
||||
"/api/auth/login", json={"email": "ghost@example.com", "password": "pw"}
|
||||
).get_json()
|
||||
bad_pw = client.post(
|
||||
"/api/auth/login", json={"email": user["email"], "password": "nope"}
|
||||
).get_json()
|
||||
assert unknown == bad_pw, "responses must not distinguish the two cases"
|
||||
|
||||
|
||||
# --- require_auth -----------------------------------------------------------
|
||||
class TestRequireAuth:
|
||||
def test_missing_header(self, client):
|
||||
assert client.get("/api/health/summary").status_code == 401
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header",
|
||||
["", "Bearer", "Token abc", "bearer abc", "Bearer not.a.jwt"],
|
||||
)
|
||||
def test_malformed_headers(self, client, header):
|
||||
r = client.get("/api/health/summary", headers={"Authorization": header})
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_expired_token_gets_401_not_500(self, client):
|
||||
past = datetime.datetime.utcnow() - datetime.timedelta(days=1)
|
||||
token = jwt.encode(
|
||||
{"sub": "u", "iat": past, "exp": past}, JWT_SECRET, algorithm="HS256"
|
||||
)
|
||||
r = client.get(
|
||||
"/api/health/summary", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert "expired" in r.get_json()["error"]
|
||||
|
||||
def test_valid_token_passes(self, client, auth):
|
||||
assert client.get("/api/health/summary", headers=auth).status_code == 200
|
||||
|
||||
|
||||
class TestLogoutAndRefresh:
|
||||
def test_logout_clears_stored_token(self, client, auth, user, db):
|
||||
assert client.post("/api/auth/logout", headers=auth).status_code == 200
|
||||
row = db.query_one("SELECT jwt_token FROM users WHERE id = ?", [user["id"]])
|
||||
assert row["jwt_token"] is None
|
||||
|
||||
def test_refresh_returns_usable_token(self, client, auth):
|
||||
r = client.post("/api/auth/refresh", headers=auth)
|
||||
assert r.status_code == 200
|
||||
new_token = r.get_json()["token"]
|
||||
assert (
|
||||
client.get(
|
||||
"/api/health/summary",
|
||||
headers={"Authorization": f"Bearer {new_token}"},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
def test_refresh_requires_auth(self, client):
|
||||
assert client.post("/api/auth/refresh").status_code == 401
|
||||
282
backend/tests/test_garmin_mfa.py
Normal file
282
backend/tests/test_garmin_mfa.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Unit tests for the web-driven two-factor Garmin login.
|
||||
|
||||
No network and no real library: a stub Garmin/garth pair stands in, and its
|
||||
`prompt_mfa` callback is invoked exactly the way garth invokes it — blocking,
|
||||
mid-login — because that blocking behaviour is the whole reason this flow
|
||||
needs a background thread and a database rendezvous.
|
||||
"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from services import garmin as garmin_svc
|
||||
from services import garmin_auth
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=8.0, interval=0.05):
|
||||
"""Poll until the background thread reaches the expected state."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(interval)
|
||||
return None
|
||||
|
||||
|
||||
def status_of(session_id):
|
||||
row = garmin_auth.get_session(session_id)
|
||||
return row["status"] if row else None
|
||||
|
||||
|
||||
def wait_status(session_id, *wanted, timeout=8.0):
|
||||
return wait_for(lambda: status_of(session_id) in wanted and status_of(session_id),
|
||||
timeout=timeout)
|
||||
|
||||
|
||||
class StubGarth:
|
||||
profile = {"displayName": "Tester", "fullName": "Test User"}
|
||||
|
||||
def __init__(self, needs_mfa=True, accept_code="123456", fail_login=False):
|
||||
self.needs_mfa = needs_mfa
|
||||
self.accept_code = accept_code
|
||||
self.fail_login = fail_login
|
||||
self.seen_code = None
|
||||
self.logged_in_with = None
|
||||
|
||||
def login(self, email, password, prompt_mfa=None):
|
||||
self.logged_in_with = (email, password)
|
||||
if self.fail_login:
|
||||
raise RuntimeError("401 Unauthorized")
|
||||
if self.needs_mfa:
|
||||
# garth calls this synchronously, in the middle of the login.
|
||||
self.seen_code = prompt_mfa()
|
||||
if self.seen_code != self.accept_code:
|
||||
raise RuntimeError("验证码错误")
|
||||
|
||||
def dumps(self):
|
||||
return "token-blob"
|
||||
|
||||
|
||||
class StubGarmin:
|
||||
"""Class factory: `make()` returns something usable as `Garmin`."""
|
||||
|
||||
last = None
|
||||
|
||||
@classmethod
|
||||
def make(cls, **garth_kwargs):
|
||||
def factory(is_cn=False, **_):
|
||||
instance = cls()
|
||||
instance.garth = StubGarth(**garth_kwargs)
|
||||
cls.last = instance
|
||||
return instance
|
||||
return lambda: factory
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_polling(monkeypatch):
|
||||
"""Keep the rendezvous poll short so tests stay quick."""
|
||||
monkeypatch.setattr(garmin_auth, "POLL_INTERVAL_SECONDS", 0.05)
|
||||
monkeypatch.setattr(garmin_auth, "CODE_WAIT_SECONDS", 5)
|
||||
|
||||
|
||||
class TestMfaFlow:
|
||||
def test_login_parks_waiting_for_a_code(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
assert wait_status(sid, "awaiting_code") == "awaiting_code"
|
||||
|
||||
def test_submitting_the_code_completes_the_login(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
wait_status(sid, "awaiting_code")
|
||||
|
||||
ok, _ = garmin_auth.submit_code(sid, user["id"], "123456")
|
||||
assert ok is True
|
||||
assert wait_status(sid, "done", "failed") == "done"
|
||||
|
||||
def test_token_is_saved_on_success(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
wait_status(sid, "awaiting_code")
|
||||
garmin_auth.submit_code(sid, user["id"], "123456")
|
||||
wait_status(sid, "done", "failed")
|
||||
|
||||
assert garmin_svc.has_token(user["id"]) is True
|
||||
assert garmin_svc.load_token(user["id"]) == "token-blob"
|
||||
|
||||
def test_the_code_reaches_garth(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
wait_status(sid, "awaiting_code")
|
||||
garmin_auth.submit_code(sid, user["id"], "123456")
|
||||
wait_status(sid, "done", "failed")
|
||||
|
||||
assert StubGarmin.last.garth.seen_code == "123456"
|
||||
|
||||
def test_account_without_mfa_completes_without_a_code(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw",
|
||||
import_garmin=StubGarmin.make(needs_mfa=False),
|
||||
)
|
||||
assert wait_status(sid, "done", "failed") == "done"
|
||||
assert garmin_svc.has_token(user["id"]) is True
|
||||
|
||||
def test_wrong_code_fails_with_a_reason(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
wait_status(sid, "awaiting_code")
|
||||
garmin_auth.submit_code(sid, user["id"], "000000")
|
||||
assert wait_status(sid, "done", "failed") == "failed"
|
||||
|
||||
assert "验证码错误" in garmin_auth.get_session(sid)["error"]
|
||||
assert garmin_svc.has_token(user["id"]) is False
|
||||
|
||||
def test_bad_password_fails_before_any_code(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "wrong",
|
||||
import_garmin=StubGarmin.make(fail_login=True),
|
||||
)
|
||||
assert wait_status(sid, "failed") == "failed"
|
||||
assert "401" in garmin_auth.get_session(sid)["error"]
|
||||
|
||||
def test_timeout_when_no_code_arrives(self, db, user, monkeypatch):
|
||||
monkeypatch.setattr(garmin_auth, "CODE_WAIT_SECONDS", 0.2)
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
assert wait_status(sid, "failed") == "failed"
|
||||
assert "超时" in garmin_auth.get_session(sid)["error"]
|
||||
|
||||
|
||||
class TestRendezvousIsNotInMemory:
|
||||
"""The handoff must survive the code arriving on a different worker, so it
|
||||
goes through the database rather than process memory."""
|
||||
|
||||
def test_code_written_directly_to_the_row_is_picked_up(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
wait_status(sid, "awaiting_code")
|
||||
|
||||
# Exactly what another worker's request would do: write the row.
|
||||
db.execute(
|
||||
"UPDATE garmin_mfa_sessions SET code = ? WHERE id = ?", ["123456", sid]
|
||||
)
|
||||
assert wait_status(sid, "done", "failed") == "done"
|
||||
|
||||
def test_cancelling_releases_the_parked_thread(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
wait_status(sid, "awaiting_code")
|
||||
garmin_auth.cancel(sid, user["id"])
|
||||
|
||||
# The row is gone, so the waiting thread must stop rather than spin
|
||||
# until its timeout.
|
||||
assert wait_for(lambda: garmin_auth.get_session(sid) is None)
|
||||
|
||||
|
||||
class TestSessionIsolation:
|
||||
def test_another_users_session_is_not_readable(self, db, user, client):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
other = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "o@example.com", "garminEmail": "og@example.com",
|
||||
"garminPassword": "pw123456"},
|
||||
).get_json()
|
||||
|
||||
assert garmin_auth.get_session(sid, other["id"]) is None
|
||||
|
||||
def test_another_user_cannot_submit_a_code(self, db, user, client):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
|
||||
)
|
||||
wait_status(sid, "awaiting_code")
|
||||
other = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "o2@example.com", "garminEmail": "og2@example.com",
|
||||
"garminPassword": "pw123456"},
|
||||
).get_json()
|
||||
|
||||
ok, _ = garmin_auth.submit_code(sid, other["id"], "123456")
|
||||
assert ok is False
|
||||
|
||||
def test_unknown_session_is_refused(self, db, user):
|
||||
ok, msg = garmin_auth.submit_code("no-such-session", user["id"], "123456")
|
||||
assert ok is False
|
||||
assert "不存在" in msg
|
||||
|
||||
|
||||
class TestPasswordHandling:
|
||||
def test_password_is_never_written_to_the_session_row(self, db, user):
|
||||
sid = garmin_auth.start_login(
|
||||
user["id"], "hunter2@example.com", "SuperSecret123",
|
||||
import_garmin=StubGarmin.make(),
|
||||
)
|
||||
wait_status(sid, "awaiting_code")
|
||||
|
||||
row = garmin_auth.get_session(sid)
|
||||
assert "SuperSecret123" not in str(dict(row))
|
||||
|
||||
|
||||
class TestEndpoints:
|
||||
def test_all_require_auth(self, client):
|
||||
assert client.post("/api/garmin/login", json={}).status_code == 401
|
||||
assert client.get("/api/garmin/login-status?session=x").status_code == 401
|
||||
assert client.post("/api/garmin/mfa", json={}).status_code == 401
|
||||
|
||||
def test_login_requires_a_password(self, client, auth):
|
||||
r = client.post("/api/garmin/login", headers=auth, json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_login_returns_a_session(self, client, auth, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
garmin_auth, "start_login", lambda *a, **k: "session-123"
|
||||
)
|
||||
r = client.post(
|
||||
"/api/garmin/login", headers=auth, json={"garminPassword": "pw"}
|
||||
)
|
||||
assert r.status_code == 202
|
||||
assert r.get_json()["session"] == "session-123"
|
||||
|
||||
def test_status_of_unknown_session_is_404(self, client, auth):
|
||||
assert client.get(
|
||||
"/api/garmin/login-status?session=nope", headers=auth
|
||||
).status_code == 404
|
||||
|
||||
def test_mfa_requires_both_fields(self, client, auth):
|
||||
assert client.post(
|
||||
"/api/garmin/mfa", headers=auth, json={"session": "x"}
|
||||
).status_code == 400
|
||||
|
||||
def test_full_flow_through_http(self, client, auth, user, db, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
garmin_svc, "_import_garmin", StubGarmin.make()
|
||||
)
|
||||
r = client.post(
|
||||
"/api/garmin/login", headers=auth, json={"garminPassword": "pw"}
|
||||
)
|
||||
sid = r.get_json()["session"]
|
||||
|
||||
assert wait_status(sid, "awaiting_code") == "awaiting_code"
|
||||
assert client.get(
|
||||
f"/api/garmin/login-status?session={sid}", headers=auth
|
||||
).get_json()["status"] == "awaiting_code"
|
||||
|
||||
r = client.post(
|
||||
"/api/garmin/mfa", headers=auth, json={"session": sid, "code": "123456"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
assert wait_status(sid, "done", "failed") == "done"
|
||||
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
|
||||
"hasToken"] is True
|
||||
604
backend/tests/test_garmin_sync.py
Normal file
604
backend/tests/test_garmin_sync.py
Normal file
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
Unit tests for the Garmin sync service.
|
||||
|
||||
A stub client stands in for `garminconnect`, so the suite runs without the
|
||||
library, without credentials and without touching Garmin.
|
||||
|
||||
The stub mirrors the real 0.2.8 API shapes on purpose — the original code
|
||||
called `get_activities(date)` when that method actually takes `(start, limit)`
|
||||
pagination arguments, a mistake that only surfaces once something exercises it.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from services import garmin as garmin_svc
|
||||
from services import health as health_svc
|
||||
|
||||
|
||||
def day(offset=0):
|
||||
return (datetime.date.today() - datetime.timedelta(days=offset)).isoformat()
|
||||
|
||||
|
||||
def summary(steps=8000, rhr=60, stress=40, kcal=2200):
|
||||
return {
|
||||
"totalSteps": steps,
|
||||
"restingHeartRate": rhr,
|
||||
"averageStressLevel": stress,
|
||||
"totalKilocalories": kcal,
|
||||
}
|
||||
|
||||
|
||||
def sleep(hours=7.5, score=82):
|
||||
return {
|
||||
"dailySleepDTO": {
|
||||
"sleepTimeSeconds": int(hours * 3600),
|
||||
"sleepScores": {"overall": {"value": score}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def hrv(value=48):
|
||||
return {"hrvSummary": {"lastNightAvg": value}}
|
||||
|
||||
|
||||
def activity(activity_id=1001, type_key="running", duration=1800):
|
||||
return {
|
||||
"activityId": activity_id,
|
||||
"activityType": {"typeKey": type_key},
|
||||
"startTimeLocal": f"{day()}T07:00:00",
|
||||
"duration": duration,
|
||||
"distance": 5000.0,
|
||||
"calories": 320.0,
|
||||
"averageHR": 145,
|
||||
"maxHR": 168,
|
||||
}
|
||||
|
||||
|
||||
class StubClient:
|
||||
"""Stands in for garminconnect.Garmin, recording how it was called."""
|
||||
|
||||
def __init__(self, summaries=None, sleeps=None, hrvs=None, activities=None,
|
||||
fail_days=(), fail_activities=False):
|
||||
self._summaries = summaries if summaries is not None else {}
|
||||
self._sleeps = sleeps if sleeps is not None else {}
|
||||
self._hrvs = hrvs if hrvs is not None else {}
|
||||
self._activities = activities if activities is not None else []
|
||||
self._fail_days = set(fail_days)
|
||||
self._fail_activities = fail_activities
|
||||
self.calls = []
|
||||
|
||||
def get_user_summary(self, cdate):
|
||||
self.calls.append(("summary", cdate))
|
||||
if cdate in self._fail_days:
|
||||
raise RuntimeError(f"upstream error for {cdate}")
|
||||
return self._summaries.get(cdate, summary())
|
||||
|
||||
def get_sleep_data(self, cdate):
|
||||
self.calls.append(("sleep", cdate))
|
||||
return self._sleeps.get(cdate, sleep())
|
||||
|
||||
def get_hrv_data(self, cdate):
|
||||
self.calls.append(("hrv", cdate))
|
||||
return self._hrvs.get(cdate, hrv())
|
||||
|
||||
def get_activities_by_date(self, startdate, enddate, activitytype=None):
|
||||
self.calls.append(("activities", startdate, enddate))
|
||||
if self._fail_activities:
|
||||
raise RuntimeError("activities endpoint down")
|
||||
return self._activities
|
||||
|
||||
|
||||
CREDS = {"garminEmail": "g@example.com", "garminPassword": "pw"}
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
def test_reports_success(self, db, user):
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient())
|
||||
assert out["status"] == "success"
|
||||
assert out["recordsSynced"] == 3
|
||||
|
||||
def test_stores_the_days(self, db, user):
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient())
|
||||
rows = health_svc.get_summary(user["id"])
|
||||
assert len(rows) == 3
|
||||
|
||||
def test_maps_every_metric(self, db, user):
|
||||
client = StubClient(
|
||||
summaries={day(): summary(steps=9500, rhr=57, stress=33, kcal=2450)},
|
||||
sleeps={day(): sleep(hours=8.0, score=91)},
|
||||
hrvs={day(): hrv(52)},
|
||||
)
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
|
||||
row = health_svc.get_summary(user["id"])[0]
|
||||
|
||||
assert row["steps"] == 9500
|
||||
assert row["heartRate"] == 57
|
||||
assert row["stress"] == 33
|
||||
assert row["caloriesBurned"] == 2450
|
||||
assert row["heartRateVariability"] == 52
|
||||
assert row["sleep"]["duration"] == 8.0
|
||||
assert row["sleep"]["quality"] == 91
|
||||
|
||||
def test_sleep_and_hrv_come_from_their_own_endpoints(self, db, user):
|
||||
"""Regression: both live outside get_user_summary. Reading only the
|
||||
summary recorded every night as having no sleep data."""
|
||||
client = StubClient()
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
|
||||
|
||||
kinds = {c[0] for c in client.calls}
|
||||
assert "sleep" in kinds
|
||||
assert "hrv" in kinds
|
||||
|
||||
def test_seconds_are_converted_to_hours(self, db, user):
|
||||
client = StubClient(sleeps={day(): sleep(hours=6.5)})
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
|
||||
assert health_svc.get_summary(user["id"])[0]["sleep"]["duration"] == 6.5
|
||||
|
||||
|
||||
class TestActivities:
|
||||
def test_fetched_by_date_range_in_one_call(self, db, user):
|
||||
"""Regression: the old code called get_activities(date), but that
|
||||
method takes (start, limit) pagination arguments, not a date."""
|
||||
client = StubClient(activities=[activity()])
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=7, client=client)
|
||||
|
||||
activity_calls = [c for c in client.calls if c[0] == "activities"]
|
||||
assert len(activity_calls) == 1, "one range call, not one call per day"
|
||||
_, start, end = activity_calls[0]
|
||||
assert start == day(6) and end == day(0)
|
||||
|
||||
def test_stored_with_fields_mapped(self, db, user):
|
||||
garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=1, client=StubClient(activities=[activity()])
|
||||
)
|
||||
rows = health_svc.get_activities(user["id"])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["activity_type"] == "running"
|
||||
assert rows[0]["heart_rate_average"] == 145
|
||||
|
||||
def test_count_is_reported(self, db, user):
|
||||
client = StubClient(activities=[activity(1), activity(2)])
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
|
||||
assert out["activitiesSynced"] == 2
|
||||
|
||||
def test_resync_does_not_duplicate(self, db, user):
|
||||
"""Garmin activity ids are stable, so a re-synced window must skip
|
||||
what is already stored."""
|
||||
client = StubClient(activities=[activity(1001)])
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
|
||||
|
||||
assert len(health_svc.get_activities(user["id"])) == 1
|
||||
assert out["activitiesSynced"] == 0
|
||||
|
||||
def test_end_time_derived_from_duration(self, db, user):
|
||||
client = StubClient(activities=[activity(duration=1800)])
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
|
||||
row = health_svc.get_activities(user["id"])[0]
|
||||
assert row["start_time"] != row["end_time"]
|
||||
|
||||
def test_failure_does_not_lose_the_daily_data(self, db, user):
|
||||
out = garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=2, client=StubClient(fail_activities=True)
|
||||
)
|
||||
assert out["status"] == "success"
|
||||
assert len(health_svc.get_summary(user["id"])) == 2
|
||||
|
||||
|
||||
class TestResync:
|
||||
def test_same_day_is_updated_not_duplicated(self, db, user):
|
||||
garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=1,
|
||||
client=StubClient(summaries={day(): summary(steps=5000)}),
|
||||
)
|
||||
garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=1,
|
||||
client=StubClient(summaries={day(): summary(steps=9000)}),
|
||||
)
|
||||
rows = health_svc.get_summary(user["id"])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["steps"] == 9000
|
||||
|
||||
|
||||
class TestPartialAndTotalFailure:
|
||||
def test_one_bad_day_is_skipped_not_fatal(self, db, user):
|
||||
client = StubClient(fail_days=[day(1)])
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=client)
|
||||
|
||||
assert out["status"] == "success"
|
||||
assert out["recordsSynced"] == 2
|
||||
assert "跳过" in out["message"]
|
||||
|
||||
def test_every_day_failing_is_reported_as_an_error(self, db, user):
|
||||
"""A systemic failure reported as a clean success would hide it."""
|
||||
client = StubClient(fail_days=[day(0), day(1), day(2)])
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=3, client=client)
|
||||
|
||||
assert out["status"] == "error"
|
||||
assert out["recordsSynced"] == 0
|
||||
|
||||
def test_login_failure_is_reported(self, db, user, monkeypatch):
|
||||
def boom(_creds, _uid=None):
|
||||
raise RuntimeError("401 Unauthorized")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_connect", boom)
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1)
|
||||
|
||||
assert out["status"] == "error"
|
||||
assert "401" in out["message"]
|
||||
|
||||
def test_days_without_data_are_not_stored(self, db, user):
|
||||
"""Garmin returns all-None for a day it has nothing for; an empty row
|
||||
would just have to be filtered back out by every read endpoint."""
|
||||
client = StubClient(
|
||||
summaries={day(): {}}, sleeps={day(): {}}, hrvs={day(): {}}
|
||||
)
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=client)
|
||||
assert out["recordsSynced"] == 0
|
||||
assert health_svc.get_summary(user["id"]) == []
|
||||
|
||||
|
||||
class TestSyncStatus:
|
||||
def test_idle_before_any_sync(self, db, user):
|
||||
assert garmin_svc.get_sync_status(user["id"])["status"] == "idle"
|
||||
|
||||
def test_success_leaves_status_idle(self, db, user):
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
status = garmin_svc.get_sync_status(user["id"])
|
||||
assert status["status"] == "idle"
|
||||
assert status["recordsSynced"] == 1
|
||||
assert status["lastSyncTime"]
|
||||
|
||||
def test_total_failure_leaves_status_error(self, db, user):
|
||||
garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=2, client=StubClient(fail_days=[day(0), day(1)])
|
||||
)
|
||||
status = garmin_svc.get_sync_status(user["id"])
|
||||
assert status["status"] == "error"
|
||||
assert status["lastError"]
|
||||
|
||||
def test_a_later_success_clears_the_error(self, db, user):
|
||||
garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=1, client=StubClient(fail_days=[day(0)])
|
||||
)
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||
status = garmin_svc.get_sync_status(user["id"])
|
||||
assert status["status"] == "idle"
|
||||
assert not status["lastError"]
|
||||
|
||||
|
||||
class TestEndpoint:
|
||||
def test_requires_auth(self, client):
|
||||
assert client.post("/api/garmin/sync", json={}).status_code == 401
|
||||
|
||||
def test_missing_password_is_refused_with_a_reason(self, client, auth):
|
||||
r = client.post("/api/garmin/sync", headers=auth, json={})
|
||||
assert r.status_code == 400
|
||||
assert "garminPassword" in r.get_json()["message"]
|
||||
|
||||
def test_status_endpoint(self, client, auth):
|
||||
r = client.get("/api/garmin/status", headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["status"] == "idle"
|
||||
|
||||
|
||||
class TestTokenStore:
|
||||
"""Tokens are what make unattended sync possible on an MFA-protected
|
||||
account: the web worker has no stdin to type a code into."""
|
||||
|
||||
def test_absent_before_any_login(self, db, user):
|
||||
assert garmin_svc.has_token(user["id"]) is False
|
||||
|
||||
def test_saved_token_round_trips(self, db, user):
|
||||
garmin_svc.save_token(user["id"], "token-blob", "g@example.com")
|
||||
assert garmin_svc.has_token(user["id"]) is True
|
||||
assert garmin_svc.load_token(user["id"]) == "token-blob"
|
||||
|
||||
def test_re_login_replaces_rather_than_accumulates(self, db, user):
|
||||
garmin_svc.save_token(user["id"], "first", "g@example.com")
|
||||
garmin_svc.save_token(user["id"], "second", "g@example.com")
|
||||
|
||||
rows = db.query_all(
|
||||
"SELECT * FROM garmin_tokens WHERE user_id = ?", [user["id"]]
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert garmin_svc.load_token(user["id"]) == "second"
|
||||
|
||||
def test_tokens_are_per_user(self, db, user, client):
|
||||
garmin_svc.save_token(user["id"], "mine", "g@example.com")
|
||||
other = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "o@example.com", "garminEmail": "og@example.com",
|
||||
"garminPassword": "pw123456"},
|
||||
).get_json()
|
||||
assert garmin_svc.has_token(other["id"]) is False
|
||||
|
||||
|
||||
class TestMfaHandling:
|
||||
def test_eof_from_the_mfa_prompt_becomes_an_actionable_error(
|
||||
self, db, user, monkeypatch
|
||||
):
|
||||
"""garth's default MFA prompt calls input(); with no stdin that raises
|
||||
a bare EOFError, which says nothing about what to do about it."""
|
||||
class StubGarth:
|
||||
def loads(self, s): pass
|
||||
def refresh_oauth2(self): pass
|
||||
|
||||
class StubGarmin:
|
||||
def __init__(self, *a, **k):
|
||||
self.garth = StubGarth()
|
||||
self.username = None
|
||||
self.password = None
|
||||
|
||||
def login(self, *a, **k):
|
||||
raise EOFError("EOF when reading a line")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
|
||||
with pytest.raises(garmin_svc.MFARequired, match="两步验证"):
|
||||
garmin_svc._connect(CREDS, user["id"])
|
||||
|
||||
def test_sync_flags_mfa_so_the_ui_can_explain(self, db, user, monkeypatch):
|
||||
def boom(_creds, _uid=None):
|
||||
raise garmin_svc.MFARequired("需要两步验证")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_connect", boom)
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1)
|
||||
|
||||
assert out["status"] == "error"
|
||||
assert out["mfaRequired"] is True
|
||||
|
||||
def test_ordinary_failures_are_not_flagged_as_mfa(self, db, user, monkeypatch):
|
||||
def boom(_creds, _uid=None):
|
||||
raise RuntimeError("401 Unauthorized")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_connect", boom)
|
||||
assert garmin_svc.sync_data(user["id"], CREDS, days=1)["mfaRequired"] is False
|
||||
|
||||
def test_stored_token_is_used_instead_of_logging_in(self, db, user, monkeypatch):
|
||||
garmin_svc.save_token(user["id"], "saved-blob", "g@example.com")
|
||||
loaded = {}
|
||||
|
||||
class StubGarth:
|
||||
profile = {"displayName": "Tester"}
|
||||
|
||||
def loads(self, s):
|
||||
loaded["blob"] = s
|
||||
|
||||
def refresh_oauth2(self):
|
||||
loaded["refreshed"] = True
|
||||
|
||||
class StubGarmin:
|
||||
def __init__(self, *a, **k):
|
||||
self.garth = StubGarth()
|
||||
|
||||
def login(self, *a, **k):
|
||||
raise AssertionError("must not log in when a token exists")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
|
||||
garmin_svc._connect({}, user["id"])
|
||||
|
||||
assert loaded["blob"] == "saved-blob"
|
||||
assert loaded["refreshed"] is True
|
||||
|
||||
def test_no_token_and_no_password_is_refused_clearly(self, db, user, monkeypatch):
|
||||
class StubGarmin:
|
||||
def __init__(self, *a, **k):
|
||||
self.garth = None
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
|
||||
with pytest.raises(RuntimeError, match="缺少 Garmin 密码"):
|
||||
garmin_svc._connect({}, user["id"])
|
||||
|
||||
|
||||
class TestAuthStatusEndpoint:
|
||||
def test_requires_auth(self, client):
|
||||
assert client.get("/api/garmin/auth-status").status_code == 401
|
||||
|
||||
def test_reports_false_then_true(self, client, auth, user, db):
|
||||
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
|
||||
"hasToken"] is False
|
||||
garmin_svc.save_token(user["id"], "blob", "g@example.com")
|
||||
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
|
||||
"hasToken"] is True
|
||||
|
||||
def test_sync_without_password_allowed_once_a_token_exists(
|
||||
self, client, auth, user, db
|
||||
):
|
||||
"""The password field exists only because no token is stored yet."""
|
||||
garmin_svc.save_token(user["id"], "blob", "g@example.com")
|
||||
r = client.post("/api/garmin/sync", headers=auth, json={})
|
||||
assert r.status_code != 400
|
||||
|
||||
|
||||
class TestApiUserAgent:
|
||||
"""Regression: garth keeps its browser User-Agent after login, and the
|
||||
Garmin data API answers that UA with HTTP 200 and an empty array — every
|
||||
endpoint silently returns nothing."""
|
||||
|
||||
def test_a_browser_user_agent_is_not_used_for_the_api(self):
|
||||
assert "Mozilla" not in garmin_svc.API_USER_AGENT
|
||||
assert "iPhone" not in garmin_svc.API_USER_AGENT
|
||||
|
||||
def test_header_is_swapped_after_loading_a_token(self, db, user):
|
||||
garmin_svc.save_token(user["id"], "blob", "g@example.com")
|
||||
headers = {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5)"}
|
||||
|
||||
class StubSess:
|
||||
def __init__(self): self.headers = headers
|
||||
|
||||
class StubGarth:
|
||||
profile = {"displayName": "Tester"}
|
||||
def __init__(self): self.sess = StubSess()
|
||||
def loads(self, s): pass
|
||||
def refresh_oauth2(self): pass
|
||||
|
||||
class StubGarmin:
|
||||
def __init__(self, *a, **k): self.garth = StubGarth()
|
||||
|
||||
monkey = pytest.MonkeyPatch()
|
||||
monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
|
||||
try:
|
||||
garmin_svc._connect({}, user["id"])
|
||||
finally:
|
||||
monkey.undo()
|
||||
|
||||
assert headers["User-Agent"] == garmin_svc.API_USER_AGENT
|
||||
|
||||
def test_swap_is_harmless_on_a_client_without_a_session(self):
|
||||
class Bare:
|
||||
garth = object()
|
||||
|
||||
garmin_svc._use_api_user_agent(Bare()) # must not raise
|
||||
|
||||
def test_display_name_is_populated(self, db, user):
|
||||
"""garminconnect builds URLs from display_name; unset sends every
|
||||
request to '.../None'."""
|
||||
class StubGarth:
|
||||
profile = {"displayName": "Tester"}
|
||||
sess = type("S", (), {"headers": {}})()
|
||||
def loads(self, s): pass
|
||||
def refresh_oauth2(self): pass
|
||||
|
||||
class StubGarmin:
|
||||
def __init__(self, *a, **k): self.garth = StubGarth()
|
||||
|
||||
garmin_svc.save_token(user["id"], "blob", "g@example.com")
|
||||
monkey = pytest.MonkeyPatch()
|
||||
monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
|
||||
try:
|
||||
client = garmin_svc._connect({}, user["id"])
|
||||
finally:
|
||||
monkey.undo()
|
||||
|
||||
assert client.display_name == "Tester"
|
||||
|
||||
|
||||
class TestErrorMessagesAreNeverEmpty:
|
||||
"""Regression: a bare `assert` inside garth raised AssertionError with an
|
||||
empty str(), which was stored as the sync's reason — a failed sync with a
|
||||
blank explanation cannot be diagnosed."""
|
||||
|
||||
def test_exception_without_text_still_describes_itself(self):
|
||||
assert garmin_svc.describe(AssertionError()) == "AssertionError"
|
||||
|
||||
def test_exception_with_text_keeps_it(self):
|
||||
assert "boom" in garmin_svc.describe(RuntimeError("boom"))
|
||||
assert "RuntimeError" in garmin_svc.describe(RuntimeError("boom"))
|
||||
|
||||
def test_whitespace_only_text_is_treated_as_empty(self):
|
||||
assert garmin_svc.describe(ValueError(" ")) == "ValueError"
|
||||
|
||||
def test_connect_failure_records_a_non_empty_reason(self, db, user, monkeypatch):
|
||||
def boom(_creds, _uid=None):
|
||||
raise AssertionError() # no message at all
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_connect", boom)
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1)
|
||||
|
||||
assert out["status"] == "error"
|
||||
assert out["message"].strip()
|
||||
assert garmin_svc.get_sync_status(user["id"])["lastError"].strip()
|
||||
|
||||
|
||||
class TestTimestampNormalisation:
|
||||
"""Regression: Garmin mixes ISO strings and epoch milliseconds in one
|
||||
payload. Writing the numeric form to a DATETIME column is rejected, which
|
||||
failed the entire personal-records batch."""
|
||||
|
||||
def test_iso_string_passes_through(self):
|
||||
assert garmin_svc._to_datetime("2019-10-13T10:10:12.0").startswith(
|
||||
"2019-10-13T10:10:12"
|
||||
)
|
||||
|
||||
def test_epoch_milliseconds_are_converted(self):
|
||||
assert garmin_svc._to_datetime(1570961412000).startswith("2019-10-13")
|
||||
|
||||
def test_epoch_seconds_are_converted(self):
|
||||
assert garmin_svc._to_datetime(1570961412).startswith("2019-10-13")
|
||||
|
||||
def test_first_usable_value_wins(self):
|
||||
assert garmin_svc._to_datetime(None, "", "2020-01-01T00:00:00") == (
|
||||
"2020-01-01T00:00:00"
|
||||
)
|
||||
|
||||
def test_all_empty_gives_none(self):
|
||||
assert garmin_svc._to_datetime(None, "") is None
|
||||
|
||||
def test_nonsense_value_does_not_raise(self):
|
||||
assert garmin_svc._to_datetime(float("inf")) is None
|
||||
|
||||
|
||||
class TestBackgroundSync:
|
||||
"""A full backfill runs for ~20 minutes at ~3s per day, so the request
|
||||
must not block on it and the UI needs progress rather than a spinner."""
|
||||
|
||||
def test_progress_is_reported_during_the_run(self, db, user):
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=10, client=StubClient())
|
||||
status = garmin_svc.get_sync_status(user["id"])
|
||||
assert status["progressTotal"] == 10
|
||||
assert status["progressCurrent"] == 10
|
||||
|
||||
def test_progress_total_matches_the_requested_window(self, db, user):
|
||||
garmin_svc.sync_data(user["id"], CREDS, days=3, client=StubClient())
|
||||
assert garmin_svc.get_sync_status(user["id"])["progressTotal"] == 3
|
||||
|
||||
def test_start_sync_returns_immediately(self, db, user, monkeypatch):
|
||||
import threading
|
||||
release = threading.Event()
|
||||
|
||||
def slow(uid, creds, days=None, client=None):
|
||||
release.wait(5)
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "sync_data", slow)
|
||||
out = garmin_svc.start_sync(user["id"], CREDS, days=365)
|
||||
|
||||
# Returns before the work finishes.
|
||||
assert out["status"] == "syncing"
|
||||
assert out["days"] == 365
|
||||
assert garmin_svc.get_sync_status(user["id"])["status"] == "syncing"
|
||||
release.set()
|
||||
|
||||
def test_start_sync_marks_total_before_any_work(self, db, user, monkeypatch):
|
||||
import threading
|
||||
release = threading.Event()
|
||||
monkeypatch.setattr(
|
||||
garmin_svc, "sync_data", lambda *a, **k: release.wait(5)
|
||||
)
|
||||
garmin_svc.start_sync(user["id"], CREDS, days=200)
|
||||
status = garmin_svc.get_sync_status(user["id"])
|
||||
assert status["progressTotal"] == 200
|
||||
assert status["progressCurrent"] == 0
|
||||
release.set()
|
||||
|
||||
def test_previous_error_is_cleared_when_a_new_sync_starts(
|
||||
self, db, user, monkeypatch
|
||||
):
|
||||
garmin_svc.sync_data(
|
||||
user["id"], CREDS, days=1, client=StubClient(fail_days=[day(0)])
|
||||
)
|
||||
assert garmin_svc.get_sync_status(user["id"])["lastError"]
|
||||
|
||||
import threading
|
||||
release = threading.Event()
|
||||
monkeypatch.setattr(garmin_svc, "sync_data", lambda *a, **k: release.wait(5))
|
||||
garmin_svc.start_sync(user["id"], CREDS, days=7)
|
||||
assert not garmin_svc.get_sync_status(user["id"])["lastError"]
|
||||
release.set()
|
||||
|
||||
def test_endpoint_returns_202_without_waiting(self, client, auth, user, db, monkeypatch):
|
||||
garmin_svc.save_token(user["id"], "blob", "g@example.com")
|
||||
monkeypatch.setattr(garmin_svc, "start_sync", lambda *a, **k: {"status": "syncing", "days": 30})
|
||||
r = client.post("/api/garmin/sync", headers=auth, json={"days": 30})
|
||||
assert r.status_code == 202
|
||||
assert r.get_json()["status"] == "syncing"
|
||||
|
||||
def test_days_is_clamped_to_a_sane_range(self, client, auth, user, db, monkeypatch):
|
||||
garmin_svc.save_token(user["id"], "blob", "g@example.com")
|
||||
seen = {}
|
||||
monkeypatch.setattr(
|
||||
garmin_svc, "start_sync",
|
||||
lambda uid, creds, days=None: seen.setdefault("days", days) or {"status": "syncing"},
|
||||
)
|
||||
client.post("/api/garmin/sync", headers=auth, json={"days": 99999})
|
||||
assert seen["days"] == 730
|
||||
256
backend/tests/test_health.py
Normal file
256
backend/tests/test_health.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Unit tests for the health data service and its endpoints."""
|
||||
import pytest
|
||||
|
||||
from services import health as health_svc
|
||||
|
||||
|
||||
DAYS = [
|
||||
{"date": "2026-08-20", "steps": 6500, "heart_rate": 70, "hrv": 45,
|
||||
"sleep_duration": 6, "sleep_quality": 80, "stress": 55, "calories": 260},
|
||||
{"date": "2026-08-21", "steps": 9000, "heart_rate": 62, "hrv": 46,
|
||||
"sleep_duration": 8, "sleep_quality": 79, "stress": 40, "calories": 360},
|
||||
{"date": "2026-08-22", "steps": 7500, "heart_rate": 68, "hrv": 47,
|
||||
"sleep_duration": 7, "sleep_quality": 78, "stress": 48, "calories": 300},
|
||||
]
|
||||
|
||||
|
||||
class TestGetSummary:
|
||||
def test_empty_for_new_user(self, db, user):
|
||||
assert health_svc.get_summary(user["id"]) == []
|
||||
|
||||
def test_returns_all_rows_ordered_by_date(self, seed_health, user):
|
||||
seed_health(DAYS)
|
||||
rows = health_svc.get_summary(user["id"])
|
||||
assert [r["date"] for r in rows] == [
|
||||
"2026-08-20", "2026-08-21", "2026-08-22"
|
||||
]
|
||||
|
||||
def test_maps_to_camel_case(self, seed_health, user):
|
||||
seed_health(DAYS)
|
||||
row = health_svc.get_summary(user["id"])[0]
|
||||
assert row["heartRate"] == 70
|
||||
assert row["heartRateVariability"] == 45
|
||||
assert row["caloriesBurned"] == 260
|
||||
# Sleep carries stage detail too; the core two must be right.
|
||||
assert row["sleep"]["duration"] == 6
|
||||
assert row["sleep"]["quality"] == 80
|
||||
|
||||
def test_sleep_is_none_when_absent(self, seed_health, user):
|
||||
seed_health([{"date": "2026-08-20", "steps": 100}])
|
||||
assert health_svc.get_summary(user["id"])[0]["sleep"] is None
|
||||
|
||||
def test_start_date_filter_is_inclusive(self, seed_health, user):
|
||||
seed_health(DAYS)
|
||||
rows = health_svc.get_summary(user["id"], start="2026-08-21")
|
||||
assert [r["date"] for r in rows] == ["2026-08-21", "2026-08-22"]
|
||||
|
||||
def test_end_date_filter_is_inclusive(self, seed_health, user):
|
||||
seed_health(DAYS)
|
||||
rows = health_svc.get_summary(user["id"], end="2026-08-21")
|
||||
assert [r["date"] for r in rows] == ["2026-08-20", "2026-08-21"]
|
||||
|
||||
def test_both_bounds(self, seed_health, user):
|
||||
seed_health(DAYS)
|
||||
rows = health_svc.get_summary(user["id"], "2026-08-21", "2026-08-21")
|
||||
assert len(rows) == 1
|
||||
|
||||
def test_range_with_no_matches(self, seed_health, user):
|
||||
seed_health(DAYS)
|
||||
assert health_svc.get_summary(user["id"], "2027-01-01") == []
|
||||
|
||||
def test_scoped_to_the_requesting_user(self, seed_health, user, db):
|
||||
seed_health(DAYS)
|
||||
db.execute(
|
||||
"INSERT INTO users (id, email, garmin_email, garmin_password_hash) "
|
||||
"VALUES (?,?,?,?)",
|
||||
["other-user", "other@example.com", "o@example.com", "x"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO health_data (id, user_id, date, steps) VALUES (?,?,?,?)",
|
||||
["other-1", "other-user", "2026-08-20", 99999],
|
||||
)
|
||||
rows = health_svc.get_summary(user["id"])
|
||||
assert all(r["steps"] != 99999 for r in rows)
|
||||
assert len(rows) == 3
|
||||
|
||||
|
||||
class TestMetricReads:
|
||||
"""Metric endpoints must drop rows where that metric is NULL."""
|
||||
|
||||
def test_steps_excludes_nulls(self, seed_health, user):
|
||||
seed_health(DAYS + [{"date": "2026-08-23", "heart_rate": 60}])
|
||||
rows = health_svc.get_steps(user["id"])
|
||||
assert len(rows) == 3
|
||||
assert all(r["steps"] is not None for r in rows)
|
||||
|
||||
def test_heart_rate_excludes_nulls(self, seed_health, user):
|
||||
seed_health(DAYS + [{"date": "2026-08-23", "steps": 100}])
|
||||
rows = health_svc.get_heart_rate(user["id"])
|
||||
assert len(rows) == 3
|
||||
|
||||
def test_sleep_excludes_nulls(self, seed_health, user):
|
||||
seed_health(DAYS + [{"date": "2026-08-23", "steps": 100}])
|
||||
rows = health_svc.get_sleep(user["id"])
|
||||
assert len(rows) == 3
|
||||
assert rows[0] == {"date": "2026-08-20", "duration": 6, "quality": 80}
|
||||
|
||||
|
||||
class TestUpsert:
|
||||
def test_insert_creates_row(self, db, user):
|
||||
health_svc.upsert_health_daily(
|
||||
user["id"], {"date": "2026-08-20", "steps": 5000}
|
||||
)
|
||||
rows = health_svc.get_summary(user["id"])
|
||||
assert len(rows) == 1 and rows[0]["steps"] == 5000
|
||||
|
||||
def test_second_upsert_updates_instead_of_duplicating(self, db, user):
|
||||
health_svc.upsert_health_daily(
|
||||
user["id"], {"date": "2026-08-20", "steps": 5000}
|
||||
)
|
||||
health_svc.upsert_health_daily(
|
||||
user["id"], {"date": "2026-08-20", "steps": 8000}
|
||||
)
|
||||
rows = health_svc.get_summary(user["id"])
|
||||
assert len(rows) == 1, "re-syncing a day must not duplicate it"
|
||||
assert rows[0]["steps"] == 8000
|
||||
|
||||
def test_upsert_is_deterministic_by_user_and_date(self, db, user):
|
||||
a = health_svc.upsert_health_daily(user["id"], {"date": "2026-08-20"})
|
||||
b = health_svc.upsert_health_daily(user["id"], {"date": "2026-08-20"})
|
||||
assert a == b
|
||||
|
||||
def test_missing_metrics_stored_as_null(self, db, user):
|
||||
health_svc.upsert_health_daily(
|
||||
user["id"], {"date": "2026-08-20", "steps": 100}
|
||||
)
|
||||
row = health_svc.get_summary(user["id"])[0]
|
||||
assert row["heartRate"] is None
|
||||
assert row["sleep"] is None
|
||||
|
||||
|
||||
class TestActivities:
|
||||
def test_insert_and_read_back(self, db, user):
|
||||
health_svc.insert_activity(
|
||||
user["id"],
|
||||
{
|
||||
"activityType": "running",
|
||||
"startTime": "2026-08-20T07:00:00",
|
||||
"endTime": "2026-08-20T07:30:00",
|
||||
"duration": 1800,
|
||||
"distance": 5.0,
|
||||
"calories": 320,
|
||||
"heartRateAverage": 140,
|
||||
"heartRateMax": 165,
|
||||
},
|
||||
)
|
||||
rows = health_svc.get_activities(user["id"])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["activity_type"] == "running"
|
||||
assert rows[0]["distance"] == 5.0
|
||||
|
||||
def test_ids_are_unique_per_insert(self, db, user):
|
||||
base = {
|
||||
"activityType": "running",
|
||||
"startTime": "2026-08-20T07:00:00",
|
||||
"endTime": "2026-08-20T07:30:00",
|
||||
}
|
||||
assert health_svc.insert_activity(user["id"], base) != health_svc.insert_activity(
|
||||
user["id"], base
|
||||
)
|
||||
|
||||
|
||||
class TestEndpoints:
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/api/health/summary", "/api/health/steps", "/api/health/heart-rate",
|
||||
"/api/health/sleep", "/api/health/activities"],
|
||||
)
|
||||
def test_require_authentication(self, client, path):
|
||||
assert client.get(path).status_code == 401
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/api/health/summary", "/api/health/steps", "/api/health/heart-rate",
|
||||
"/api/health/sleep", "/api/health/activities"],
|
||||
)
|
||||
def test_return_json_list_when_authenticated(self, client, auth, path):
|
||||
r = client.get(path, headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.get_json(), list)
|
||||
|
||||
def test_summary_reflects_seeded_data(self, client, auth, seed_health):
|
||||
seed_health(DAYS)
|
||||
body = client.get("/api/health/summary", headers=auth).get_json()
|
||||
assert len(body) == 3
|
||||
|
||||
def test_query_params_are_applied(self, client, auth, seed_health):
|
||||
seed_health(DAYS)
|
||||
body = client.get(
|
||||
"/api/health/summary?startDate=2026-08-22", headers=auth
|
||||
).get_json()
|
||||
assert len(body) == 1
|
||||
|
||||
|
||||
class TestBadgesAndRecords:
|
||||
"""Badges ("奖励") and personal records are account-wide, not per-day."""
|
||||
|
||||
BADGE = {
|
||||
"id": "1822", "badgeKey": "sleep_30_days", "name": "Sleep Savant",
|
||||
"categoryId": 3, "difficultyId": 2, "earnedDate": "2026-08-01T10:00:00",
|
||||
"earnedCount": 1, "points": 5,
|
||||
}
|
||||
|
||||
def test_badge_round_trips(self, db, user):
|
||||
health_svc.upsert_badge(user["id"], self.BADGE)
|
||||
rows = health_svc.get_badges(user["id"])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["name"] == "Sleep Savant"
|
||||
assert rows[0]["badge_key"] == "sleep_30_days"
|
||||
|
||||
def test_resync_updates_rather_than_duplicating(self, db, user):
|
||||
health_svc.upsert_badge(user["id"], self.BADGE)
|
||||
health_svc.upsert_badge(user["id"], {**self.BADGE, "earnedCount": 2})
|
||||
rows = health_svc.get_badges(user["id"])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["earned_count"] == 2
|
||||
|
||||
def test_badges_are_per_user(self, db, user, client):
|
||||
health_svc.upsert_badge(user["id"], self.BADGE)
|
||||
other = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "b@example.com", "garminEmail": "bg@example.com",
|
||||
"garminPassword": "pw123456"},
|
||||
).get_json()
|
||||
assert health_svc.get_badges(other["id"]) == []
|
||||
|
||||
def test_two_users_may_hold_the_same_badge_id(self, db, user, client):
|
||||
"""The key is (user, badge), so the same Garmin badge on two accounts
|
||||
must not collide."""
|
||||
other = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "c@example.com", "garminEmail": "cg@example.com",
|
||||
"garminPassword": "pw123456"},
|
||||
).get_json()
|
||||
health_svc.upsert_badge(user["id"], self.BADGE)
|
||||
health_svc.upsert_badge(other["id"], self.BADGE)
|
||||
assert len(health_svc.get_badges(user["id"])) == 1
|
||||
assert len(health_svc.get_badges(other["id"])) == 1
|
||||
|
||||
def test_personal_record_round_trips(self, db, user):
|
||||
health_svc.upsert_personal_record(user["id"], {
|
||||
"id": "2538883970", "typeId": 1, "activityId": "17446848459",
|
||||
"activityName": "晨跑", "activityType": "running",
|
||||
"value": 1234.5, "achievedAt": "2026-08-01T07:00:00",
|
||||
})
|
||||
rows = health_svc.get_personal_records(user["id"])
|
||||
assert len(rows) == 1 and rows[0]["activity_name"] == "晨跑"
|
||||
|
||||
def test_endpoints_require_auth(self, client):
|
||||
assert client.get("/api/health/badges").status_code == 401
|
||||
assert client.get("/api/health/personal-records").status_code == 401
|
||||
|
||||
def test_endpoints_return_lists(self, client, auth):
|
||||
assert isinstance(client.get("/api/health/badges", headers=auth).get_json(), list)
|
||||
assert isinstance(
|
||||
client.get("/api/health/personal-records", headers=auth).get_json(), list
|
||||
)
|
||||
87
backend/tests/test_registration_policy.py
Normal file
87
backend/tests/test_registration_policy.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Tests for who may create an account.
|
||||
|
||||
This matters because the deployment is reachable from the public internet: an
|
||||
unconditionally open /register would let a stranger sign up and start pulling
|
||||
health data.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
def signup(client, email="new@example.com"):
|
||||
return client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"garminEmail": "g@example.com",
|
||||
"garminPassword": "pw123456",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _default_policy(monkeypatch):
|
||||
monkeypatch.delenv("ALLOW_REGISTRATION", raising=False)
|
||||
|
||||
|
||||
class TestAutoPolicy:
|
||||
"""Default: open until the first account exists, then closed."""
|
||||
|
||||
def test_first_account_is_allowed(self, client, db):
|
||||
assert signup(client).status_code == 201
|
||||
|
||||
def test_second_account_is_refused(self, client, user):
|
||||
r = signup(client, "stranger@example.com")
|
||||
assert r.status_code == 403
|
||||
assert "注册已关闭" in r.get_json()["error"]
|
||||
|
||||
def test_refusal_does_not_create_the_account(self, client, user, db):
|
||||
signup(client, "stranger@example.com")
|
||||
assert db.query_one(
|
||||
"SELECT id FROM users WHERE email = ?", ["stranger@example.com"]
|
||||
) is None
|
||||
|
||||
def test_status_reports_open_before_any_signup(self, client, db):
|
||||
assert client.get("/api/auth/registration-status").get_json()["open"] is True
|
||||
|
||||
def test_status_reports_closed_afterwards(self, client, user):
|
||||
assert client.get("/api/auth/registration-status").get_json()["open"] is False
|
||||
|
||||
|
||||
class TestExplicitPolicies:
|
||||
def test_true_keeps_it_open_even_with_existing_users(self, client, user, monkeypatch):
|
||||
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
|
||||
assert signup(client, "second@example.com").status_code == 201
|
||||
|
||||
def test_false_closes_it_even_on_an_empty_instance(self, client, db, monkeypatch):
|
||||
monkeypatch.setenv("ALLOW_REGISTRATION", "false")
|
||||
assert signup(client).status_code == 403
|
||||
|
||||
def test_policy_is_read_per_request_not_at_import(self, client, db, monkeypatch):
|
||||
monkeypatch.setenv("ALLOW_REGISTRATION", "false")
|
||||
assert client.get("/api/auth/registration-status").get_json()["open"] is False
|
||||
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
|
||||
assert client.get("/api/auth/registration-status").get_json()["open"] is True
|
||||
|
||||
def test_value_is_case_insensitive(self, client, user, monkeypatch):
|
||||
monkeypatch.setenv("ALLOW_REGISTRATION", "TRUE")
|
||||
assert signup(client, "second@example.com").status_code == 201
|
||||
|
||||
|
||||
class TestUnaffectedBehaviour:
|
||||
def test_status_endpoint_needs_no_auth(self, client, db):
|
||||
"""The login page must be able to ask before anyone is signed in."""
|
||||
assert client.get("/api/auth/registration-status").status_code == 200
|
||||
|
||||
def test_closing_registration_does_not_block_login(self, client, user):
|
||||
r = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": user["email"], "password": user["password"]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_duplicate_email_still_reports_409_when_open(
|
||||
self, client, user, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("ALLOW_REGISTRATION", "true")
|
||||
assert signup(client, user["email"]).status_code == 409
|
||||
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()
|
||||
@@ -22,6 +22,7 @@
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } 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';
|
||||
import Trends from './pages/Trends';
|
||||
import Recommendations from './pages/Recommendations';
|
||||
import Settings from './pages/Settings';
|
||||
import Sleep from './pages/Sleep';
|
||||
import Achievements from './pages/Achievements';
|
||||
import { FEATURES } from './features';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<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>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/sync" element={<DataSync />} />
|
||||
<Route path="/trends" element={<Trends />} />
|
||||
<Route path="/sleep" element={<Sleep />} />
|
||||
<Route path="/achievements" element={<Achievements />} />
|
||||
{FEATURES.ai && (
|
||||
<Route path="/recommendations" element={<Recommendations />} />
|
||||
)}
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,129 +1,93 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
.header-inner {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 680;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.container {
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 7px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.88rem;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-left-color: #667eea;
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: #f0f0ff;
|
||||
border-left-color: #667eea;
|
||||
color: #667eea;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.2rem;
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 7px;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 1.75rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
position: static;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.header-inner {
|
||||
gap: 0.75rem;
|
||||
padding: 0 0.9rem;
|
||||
}
|
||||
.logo { font-size: 0.85rem; }
|
||||
.content { padding: 1.25rem 0.9rem 3rem; }
|
||||
}
|
||||
|
||||
@@ -1,56 +1,87 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { FEATURES } from '../features';
|
||||
import './Layout.css';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ path: '/', label: '今日' },
|
||||
{ path: '/trends', label: '趋势' },
|
||||
{ path: '/sleep', label: '睡眠' },
|
||||
{ path: '/achievements', label: '成就' },
|
||||
...(FEATURES.ai ? [{ path: '/recommendations', label: '建议' }] : []),
|
||||
{ path: '/sync', label: '同步' },
|
||||
{ path: '/settings', label: '设置' },
|
||||
];
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
/* Dark mode is a deliberate, validated palette rather than an inverted one, so
|
||||
the choice is stamped on <html> and the tokens swap in one place. */
|
||||
function useTheme(): [Theme, (t: Theme) => void] {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem('ghl_theme') as Theme) || 'system'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'system') root.removeAttribute('data-theme');
|
||||
else root.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('ghl_theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return [theme, setTheme];
|
||||
}
|
||||
|
||||
function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const [theme, setTheme] = useTheme();
|
||||
|
||||
const navigationItems = [
|
||||
{ path: '/', label: '仪表板', icon: '📊' },
|
||||
{ path: '/sync', label: '数据同步', icon: '🔄' },
|
||||
{ path: '/analysis', label: '数据分析', icon: '📈' },
|
||||
{ path: '/recommendations', label: '健康建议', icon: '💡' },
|
||||
{ path: '/settings', label: '设置', icon: '⚙️' },
|
||||
];
|
||||
const cycle = () =>
|
||||
setTheme(theme === 'system' ? 'light' : theme === 'light' ? 'dark' : 'system');
|
||||
|
||||
const themeLabel = { system: '跟随系统', light: '浅色', dark: '深色' }[theme];
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<header className="header">
|
||||
<div className="header-content">
|
||||
<h1 className="logo">🏃 Garmin Health Lab</h1>
|
||||
<p className="tagline">佳明健康数据分析平台</p>
|
||||
<div className="header-inner">
|
||||
<Link to="/" className="logo">Garmin Health Lab</Link>
|
||||
|
||||
<nav className="nav" aria-label="主导航">
|
||||
{NAV.map((item) => {
|
||||
const active =
|
||||
item.path === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`nav-link ${active ? 'active' : ''}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={cycle}
|
||||
title={`主题:${themeLabel}`}
|
||||
aria-label={`切换主题,当前${themeLabel}`}
|
||||
>
|
||||
{theme === 'dark' ? '深色' : theme === 'light' ? '浅色' : '自动'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container">
|
||||
<nav className="sidebar">
|
||||
<ul className="nav-list">
|
||||
{navigationItems.map(item => (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`nav-link ${location.pathname === item.path ? 'active' : ''}`}
|
||||
>
|
||||
<span className="icon">{item.icon}</span>
|
||||
<span className="label">{item.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main className="content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer className="footer">
|
||||
<p>© 2024 Garmin Health Lab. All rights reserved.</p>
|
||||
</footer>
|
||||
<main className="content">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
171
client/src/components/charts/Chart.css
Normal file
171
client/src/components/charts/Chart.css
Normal file
@@ -0,0 +1,171 @@
|
||||
.viz {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
margin: 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.viz-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.viz-head h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.viz-unit {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.viz-sub {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.viz-toggle {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.viz-toggle:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.viz-empty {
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
background: var(--surface-0);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.viz-foot {
|
||||
margin-top: 0.6rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Identity is never colour alone: the swatch always sits beside a text label. */
|
||||
.viz-swatch {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.4rem;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.viz-legend-item {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Tooltip ------------------------------------------------------------------ */
|
||||
.viz-tooltip {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
font-size: 0.8rem;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.viz-tooltip-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.viz-tooltip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.viz-tooltip-name {
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
/* Values wear text tokens, never the series colour. */
|
||||
.viz-tooltip-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Table view --------------------------------------------------------------- */
|
||||
.viz-table-wrap {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.viz-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.viz-table th,
|
||||
.viz-table td {
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viz-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.74rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.viz-table thead th:first-child,
|
||||
.viz-table tbody th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.viz-table tbody th {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.viz-table td {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
277
client/src/components/charts/Chart.tsx
Normal file
277
client/src/components/charts/Chart.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import {
|
||||
Area, AreaChart, Bar, BarChart, CartesianGrid, Legend, Line, LineChart,
|
||||
ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import './Chart.css';
|
||||
|
||||
export interface Series {
|
||||
key: string;
|
||||
label: string;
|
||||
/** 1-based slot in the categorical palette. Assigned in order, never cycled. */
|
||||
slot: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
unit?: string;
|
||||
/** Round displayed values to this many decimals. */
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
interface ChartProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
data: Array<Record<string, any>>;
|
||||
series: Series[];
|
||||
type: 'line' | 'bar' | 'stacked-bar' | 'area';
|
||||
xKey?: string;
|
||||
height?: number;
|
||||
/** Y-axis label; a chart has exactly one axis — never two scales. */
|
||||
unit?: string;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
const fmt = (value: any, decimals = 0) =>
|
||||
value == null
|
||||
? '—'
|
||||
: typeof value === 'number'
|
||||
? value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
: String(value);
|
||||
|
||||
function TooltipBox({ active, payload, label, series }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="viz-tooltip">
|
||||
<div className="viz-tooltip-label">{label}</div>
|
||||
{payload.map((entry: any) => {
|
||||
const s = series.find((x: Series) => x.key === entry.dataKey);
|
||||
return (
|
||||
<div key={entry.dataKey} className="viz-tooltip-row">
|
||||
<span
|
||||
className="viz-swatch"
|
||||
style={{ background: entry.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="viz-tooltip-name">{s?.label ?? entry.dataKey}</span>
|
||||
<span className="viz-tooltip-value">
|
||||
{fmt(entry.value, s?.decimals)}
|
||||
{s?.unit ? ` ${s.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chart({
|
||||
title, subtitle, data, series, type, xKey = 'date', height = 240, unit, footer,
|
||||
}: ChartProps) {
|
||||
// A table view is the relief for series whose colour falls below 3:1 on the
|
||||
// light surface, and doubles as the non-visual reading of any chart.
|
||||
const [showTable, setShowTable] = useState(false);
|
||||
|
||||
const present = series.filter((s) => data.some((row) => row[s.key] != null));
|
||||
if (present.length === 0) {
|
||||
return (
|
||||
<figure className="viz">
|
||||
<figcaption className="viz-head">
|
||||
<h4>{title}</h4>
|
||||
</figcaption>
|
||||
<div className="viz-empty">暂无数据</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
const color = (s: Series) => `var(--series-${s.slot})`;
|
||||
const axis = {
|
||||
stroke: 'var(--border-strong)',
|
||||
tick: { fill: 'var(--text-muted)', fontSize: 11 },
|
||||
tickLine: false,
|
||||
};
|
||||
const margin = { top: 8, right: 8, bottom: 0, left: -8 };
|
||||
|
||||
// A legend is mandatory from two series up; a single series is named by the
|
||||
// title, so a legend box would only repeat it.
|
||||
const legend =
|
||||
present.length > 1 ? (
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="left"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value: string) => {
|
||||
const s = present.find((x) => x.key === value);
|
||||
return <span className="viz-legend-item">{s?.label ?? value}</span>;
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const grid = <CartesianGrid stroke="var(--grid)" vertical={false} />;
|
||||
const tip = (
|
||||
<Tooltip
|
||||
content={<TooltipBox series={present} />}
|
||||
cursor={{ stroke: 'var(--border-strong)', strokeWidth: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const render = () => {
|
||||
if (type === 'bar' || type === 'stacked-bar') {
|
||||
const stacked = type === 'stacked-bar';
|
||||
return (
|
||||
<BarChart data={data} margin={margin} barCategoryGap="22%">
|
||||
{grid}
|
||||
<XAxis dataKey={xKey} {...axis} />
|
||||
<YAxis {...axis} width={48} />
|
||||
{tip}
|
||||
{legend}
|
||||
{present.map((s, i) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
name={s.key}
|
||||
fill={color(s)}
|
||||
stackId={stacked ? 'stack' : undefined}
|
||||
// 4px rounded data-end on the topmost segment only, so the shape
|
||||
// reads as one bar anchored to the baseline.
|
||||
radius={
|
||||
stacked
|
||||
? i === present.length - 1
|
||||
? [4, 4, 0, 0]
|
||||
: [0, 0, 0, 0]
|
||||
: [4, 4, 0, 0]
|
||||
}
|
||||
// A 2px gap in the surface colour separates adjacent fills.
|
||||
stroke="var(--surface-1)"
|
||||
strokeWidth={stacked ? 2 : 0}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'area') {
|
||||
return (
|
||||
<AreaChart data={data} margin={margin}>
|
||||
<defs>
|
||||
{present.map((s) => (
|
||||
<linearGradient key={s.key} id={`fill-${s.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color(s)} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={color(s)} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{grid}
|
||||
<XAxis dataKey={xKey} {...axis} />
|
||||
<YAxis {...axis} width={48} />
|
||||
{tip}
|
||||
{legend}
|
||||
{present.map((s) => (
|
||||
<Area
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.key}
|
||||
stroke={color(s)}
|
||||
strokeWidth={2}
|
||||
fill={`url(#fill-${s.key})`}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LineChart data={data} margin={margin}>
|
||||
{grid}
|
||||
<XAxis dataKey={xKey} {...axis} />
|
||||
<YAxis {...axis} width={48} domain={['auto', 'auto']} />
|
||||
{tip}
|
||||
{legend}
|
||||
{present.map((s) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.key}
|
||||
stroke={color(s)}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
// A 2px surface ring keeps overlapping markers readable.
|
||||
activeDot={{ r: 5, strokeWidth: 2, stroke: 'var(--surface-1)' }}
|
||||
// Days the device recorded nothing must not fragment the line.
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<figure className="viz">
|
||||
<figcaption className="viz-head">
|
||||
<div>
|
||||
<h4>
|
||||
{title}
|
||||
{unit && <span className="viz-unit"> ({unit})</span>}
|
||||
</h4>
|
||||
{subtitle && <p className="viz-sub">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="viz-toggle"
|
||||
onClick={() => setShowTable((v) => !v)}
|
||||
aria-pressed={showTable}
|
||||
>
|
||||
{showTable ? '看图表' : '看数据'}
|
||||
</button>
|
||||
</figcaption>
|
||||
|
||||
{showTable ? (
|
||||
<div className="viz-table-wrap">
|
||||
<table className="viz-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">日期</th>
|
||||
{present.map((s) => (
|
||||
<th key={s.key} scope="col">
|
||||
<span
|
||||
className="viz-swatch"
|
||||
style={{ background: color(s) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{s.label}
|
||||
{s.unit ? ` (${s.unit})` : ''}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...data].reverse().map((row, i) => (
|
||||
<tr key={`${row[xKey]}-${i}`}>
|
||||
<th scope="row">{row[xKey]}</th>
|
||||
{present.map((s) => (
|
||||
<td key={s.key}>{fmt(row[s.key], s.decimals)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{render()}
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
{footer && <div className="viz-foot">{footer}</div>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export default Chart;
|
||||
80
client/src/components/charts/StatTile.css
Normal file
80
client/src/components/charts/StatTile.css
Normal file
@@ -0,0 +1,80 @@
|
||||
.tile {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tile-value {
|
||||
font-size: 1.55rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.15;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tile-unit {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.tile-meter {
|
||||
height: 4px;
|
||||
background: var(--surface-0);
|
||||
border-radius: 999px;
|
||||
margin-top: 0.55rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile-meter-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tile-detail {
|
||||
margin-top: 0.45rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tile-status {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-good { color: var(--status-good); }
|
||||
.status-warning { color: var(--status-warning); }
|
||||
.status-serious { color: var(--status-serious); }
|
||||
.status-critical { color: var(--status-critical); }
|
||||
|
||||
.tile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.tile-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.tile-value {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
75
client/src/components/charts/StatTile.tsx
Normal file
75
client/src/components/charts/StatTile.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ReactNode } from 'react';
|
||||
import './StatTile.css';
|
||||
|
||||
export type Status = 'good' | 'warning' | 'serious' | 'critical';
|
||||
|
||||
/* Status is carried by an icon plus a label, never by colour alone — the
|
||||
light-surface status steps are deliberately below 3:1. */
|
||||
const STATUS_ICON: Record<Status, string> = {
|
||||
good: '●',
|
||||
warning: '▲',
|
||||
serious: '▲',
|
||||
critical: '■',
|
||||
};
|
||||
|
||||
interface StatTileProps {
|
||||
label: string;
|
||||
value: number | string | null | undefined;
|
||||
unit?: string;
|
||||
/** Secondary line: a goal, a range, a comparison. */
|
||||
detail?: ReactNode;
|
||||
status?: Status;
|
||||
statusLabel?: string;
|
||||
/** 0–1; draws a goal meter under the value. */
|
||||
progress?: number | null;
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
label, value, unit, detail, status, statusLabel, progress,
|
||||
}: StatTileProps) {
|
||||
const display =
|
||||
value == null
|
||||
? '—'
|
||||
: typeof value === 'number'
|
||||
? value.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
: value;
|
||||
|
||||
return (
|
||||
<div className="tile">
|
||||
<div className="tile-label">{label}</div>
|
||||
<div className="tile-value">
|
||||
{display}
|
||||
{unit && value != null && <span className="tile-unit">{unit}</span>}
|
||||
</div>
|
||||
|
||||
{progress != null && (
|
||||
<div
|
||||
className="tile-meter"
|
||||
role="meter"
|
||||
aria-valuenow={Math.round(progress * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${label}完成度`}
|
||||
>
|
||||
<div
|
||||
className="tile-meter-fill"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(detail || status) && (
|
||||
<div className="tile-detail">
|
||||
{status && (
|
||||
<span className={`tile-status status-${status}`}>
|
||||
<span aria-hidden="true">{STATUS_ICON[status]}</span> {statusLabel}
|
||||
</span>
|
||||
)}
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatTile;
|
||||
12
client/src/features.ts
Normal file
12
client/src/features.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Feature switches.
|
||||
*
|
||||
* `ai` is off while the recommendation module is being reworked: the pages and
|
||||
* the backend endpoints still exist, so turning it back on is a one-line
|
||||
* change rather than a rebuild. Nothing links to the route while it is off,
|
||||
* and the route itself is not registered — a hidden nav entry with a live URL
|
||||
* would still be reachable by typing it.
|
||||
*/
|
||||
export const FEATURES = {
|
||||
ai: false,
|
||||
};
|
||||
@@ -1,24 +1,14 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@import './theme.css';
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
html, body, #root { width: 100%; min-height: 100%; }
|
||||
|
||||
207
client/src/pages/Achievements.tsx
Normal file
207
client/src/pages/Achievements.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
apiClient, Activity, Badge, errorMessage, PersonalRecord,
|
||||
} from '../services/api';
|
||||
import StatTile from '../components/charts/StatTile';
|
||||
import './Pages.css';
|
||||
|
||||
type Tab = 'badges' | 'records' | 'activities';
|
||||
|
||||
const ACTIVITY_LABEL: Record<string, string> = {
|
||||
running: '跑步',
|
||||
cycling: '骑行',
|
||||
walking: '步行',
|
||||
hiking: '徒步',
|
||||
swimming: '游泳',
|
||||
table_tennis: '乒乓球',
|
||||
strength_training: '力量训练',
|
||||
indoor_cycling: '室内骑行',
|
||||
treadmill_running: '跑步机',
|
||||
fitness_equipment: '健身器械',
|
||||
};
|
||||
|
||||
const label = (key: string | null) =>
|
||||
key ? ACTIVITY_LABEL[key] ?? key.replace(/_/g, ' ') : '—';
|
||||
|
||||
const date = (value: string | null) => (value ? value.slice(0, 10) : '—');
|
||||
|
||||
function Achievements() {
|
||||
const [tab, setTab] = useState<Tab>('badges');
|
||||
const [badges, setBadges] = useState<Badge[]>([]);
|
||||
const [records, setRecords] = useState<PersonalRecord[]>([]);
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [b, r, a] = await Promise.all([
|
||||
apiClient.getBadges(),
|
||||
apiClient.getPersonalRecords(),
|
||||
apiClient.getActivities(),
|
||||
]);
|
||||
setBadges(b);
|
||||
setRecords(r);
|
||||
setActivities(a);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="page-loading">加载中…</div>;
|
||||
|
||||
// Badges cluster heavily by year, which is the only grouping that reads.
|
||||
const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => {
|
||||
const year = b.earned_date ? b.earned_date.slice(0, 4) : '未知';
|
||||
(acc[year] ||= []).push(b);
|
||||
return acc;
|
||||
}, {});
|
||||
const years = Object.keys(byYear).sort().reverse();
|
||||
|
||||
const totalPoints = badges.reduce((sum, b) => sum + (b.points ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>成就</h2>
|
||||
<p className="subtitle">奖励徽章、个人纪录与运动记录</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="tile-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<StatTile label="奖励徽章" value={badges.length} unit="个" />
|
||||
<StatTile
|
||||
label="徽章积分"
|
||||
value={totalPoints || null}
|
||||
detail={totalPoints ? undefined : '该账号未记录积分'}
|
||||
/>
|
||||
<StatTile label="个人纪录" value={records.length} unit="项" />
|
||||
<StatTile label="运动记录" value={activities.length} unit="条" />
|
||||
</div>
|
||||
|
||||
<div className="metric-tabs">
|
||||
{([
|
||||
['badges', `奖励 (${badges.length})`],
|
||||
['records', `个人纪录 (${records.length})`],
|
||||
['activities', `运动 (${activities.length})`],
|
||||
] as Array<[Tab, string]>).map(([id, text]) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`metric-tab ${tab === id ? 'active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'badges' && (
|
||||
badges.length === 0 ? (
|
||||
<p className="placeholder">还没有同步到徽章。</p>
|
||||
) : (
|
||||
years.map((year) => (
|
||||
<section className="section" key={year}>
|
||||
<h3 className="section-title">
|
||||
{year === '未知' ? '未知年份' : `${year} 年`}
|
||||
<span className="section-count">{byYear[year].length} 个</span>
|
||||
</h3>
|
||||
<div className="badge-grid">
|
||||
{byYear[year].map((b) => (
|
||||
<div className="badge" key={b.id}>
|
||||
<div className="badge-name">{b.name || b.badge_key}</div>
|
||||
<div className="badge-meta">
|
||||
{date(b.earned_date)}
|
||||
{b.earned_count && b.earned_count > 1 && (
|
||||
<span className="badge-count">×{b.earned_count}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'records' && (
|
||||
records.length === 0 ? (
|
||||
<p className="placeholder">还没有同步到个人纪录。</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">活动</th>
|
||||
<th scope="col">类型</th>
|
||||
<th scope="col">数值</th>
|
||||
<th scope="col">日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<th scope="row">{r.activity_name || '—'}</th>
|
||||
<td>{label(r.activity_type)}</td>
|
||||
<td className="num">
|
||||
{r.value != null ? r.value.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
}) : '—'}
|
||||
</td>
|
||||
<td>{date(r.achieved_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'activities' && (
|
||||
activities.length === 0 ? (
|
||||
<p className="placeholder">所选区间内没有运动记录。</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">时间</th>
|
||||
<th scope="col">类型</th>
|
||||
<th scope="col">时长</th>
|
||||
<th scope="col">距离</th>
|
||||
<th scope="col">消耗</th>
|
||||
<th scope="col">平均心率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activities.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<th scope="row">{a.start_time?.slice(0, 16).replace('T', ' ')}</th>
|
||||
<td>{label(a.activity_type)}</td>
|
||||
<td className="num">
|
||||
{a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'}
|
||||
</td>
|
||||
<td className="num">
|
||||
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
|
||||
</td>
|
||||
<td className="num">{a.calories != null ? `${Math.round(a.calories)}` : '—'}</td>
|
||||
<td className="num">{a.heart_rate_average ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Achievements;
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
import './Pages.css';
|
||||
|
||||
function Analysis() {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>数据分析</h2>
|
||||
<p className="placeholder">数据分析页面即将推出...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Analysis;
|
||||
@@ -1,33 +1,276 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { apiClient } from '../services/api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import StatTile, { Status } from '../components/charts/StatTile';
|
||||
import './Pages.css';
|
||||
|
||||
const DAYS = 30;
|
||||
|
||||
/** MM-DD keeps the axis readable at 30 points. */
|
||||
const short = (iso: string) => iso.slice(5);
|
||||
|
||||
function avg(values: Array<number | null | undefined>): number | null {
|
||||
const present = values.filter((v): v is number => v != null);
|
||||
if (!present.length) return null;
|
||||
return present.reduce((a, b) => a + b, 0) / present.length;
|
||||
}
|
||||
|
||||
/* Thresholds follow the same rules the recommendation engine uses, so the
|
||||
dashboard and the advice never disagree about what counts as low. */
|
||||
function sleepStatus(hours: number | null): [Status, string] | [] {
|
||||
if (hours == null) return [];
|
||||
if (hours < 6) return ['critical', '偏少'];
|
||||
if (hours < 7) return ['warning', '略少'];
|
||||
return ['good', '充足'];
|
||||
}
|
||||
|
||||
function rhrStatus(bpm: number | null): [Status, string] | [] {
|
||||
if (bpm == null) return [];
|
||||
if (bpm > 70) return ['serious', '偏高'];
|
||||
if (bpm > 65) return ['warning', '略高'];
|
||||
return ['good', '正常'];
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [summary, setSummary] = useState<any>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [days, setDays] = useState<HealthDay[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const load = async () => {
|
||||
try {
|
||||
// TODO: Fetch health summary data
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load summary:', error);
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (DAYS - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载数据失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="page-loading">加载中...</div>;
|
||||
if (loading) return <div className="page-loading">加载中…</div>;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>今日概览</h2>
|
||||
<div className="error-message">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (days.length === 0) {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>今日概览</h2>
|
||||
<div className="empty-state">
|
||||
<p>还没有任何健康数据。</p>
|
||||
<Link to="/sync" className="btn btn-primary">去同步 Garmin 数据</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const today = days[days.length - 1];
|
||||
const rows = days.map((d) => ({ ...d, date: short(d.date) }));
|
||||
|
||||
const avgSteps = avg(days.map((d) => d.steps));
|
||||
const avgSleep = avg(days.map((d) => d.sleepDuration));
|
||||
const avgRhr = avg(days.map((d) => d.heartRate));
|
||||
const avgHrv = avg(days.map((d) => d.heartRateVariability));
|
||||
|
||||
const [sleepTone, sleepWord] = sleepStatus(today.sleepDuration);
|
||||
const [rhrTone, rhrWord] = rhrStatus(today.heartRate);
|
||||
|
||||
const round = (v: number | null, d = 0) =>
|
||||
v == null ? null : Math.round(v * 10 ** d) / 10 ** d;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>健康仪表板</h2>
|
||||
<p className="placeholder">仪表板内容即将推出...</p>
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>今日概览</h2>
|
||||
<p className="subtitle">{today.date} · 近 {days.length} 天数据</p>
|
||||
</div>
|
||||
<Link to="/trends" className="btn btn-plain">查看全部趋势 →</Link>
|
||||
</header>
|
||||
|
||||
{/* Activity ---------------------------------------------------------- */}
|
||||
<section className="section">
|
||||
<h3 className="section-title">活动</h3>
|
||||
<div className="tile-grid">
|
||||
<StatTile
|
||||
label="步数"
|
||||
value={today.steps}
|
||||
detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined}
|
||||
progress={
|
||||
today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label="距离"
|
||||
value={round(today.distanceMeters != null ? today.distanceMeters / 1000 : null, 2)}
|
||||
unit="km"
|
||||
/>
|
||||
<StatTile label="爬楼" value={round(today.floorsAscended)} unit="层" />
|
||||
<StatTile
|
||||
label="强度分钟"
|
||||
value={today.intensityMinutes}
|
||||
unit="分钟"
|
||||
detail="中等以上强度"
|
||||
/>
|
||||
<StatTile
|
||||
label="总消耗"
|
||||
value={round(today.caloriesBurned)}
|
||||
unit="kcal"
|
||||
detail={
|
||||
today.activeCalories != null
|
||||
? `其中活动 ${Math.round(today.activeCalories)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label="久坐"
|
||||
value={round(
|
||||
today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null, 1
|
||||
)}
|
||||
unit="小时"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Heart & stress ---------------------------------------------------- */}
|
||||
<section className="section">
|
||||
<h3 className="section-title">心率与压力</h3>
|
||||
<div className="tile-grid">
|
||||
<StatTile
|
||||
label="静息心率"
|
||||
value={today.heartRate}
|
||||
unit="bpm"
|
||||
status={rhrTone}
|
||||
statusLabel={rhrWord}
|
||||
detail={avgRhr != null ? `30 日均 ${Math.round(avgRhr)}` : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label="心率区间"
|
||||
value={
|
||||
today.heartRateMin != null && today.heartRateMax != null
|
||||
? `${today.heartRateMin}–${today.heartRateMax}`
|
||||
: null
|
||||
}
|
||||
unit="bpm"
|
||||
/>
|
||||
<StatTile
|
||||
label="心率变异性"
|
||||
value={round(today.heartRateVariability)}
|
||||
unit="ms"
|
||||
detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label="平均压力"
|
||||
value={today.stress}
|
||||
detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label="身体电量"
|
||||
value={
|
||||
today.bodyBatteryLow != null && today.bodyBatteryHigh != null
|
||||
? `${today.bodyBatteryLow}–${today.bodyBatteryHigh}`
|
||||
: null
|
||||
}
|
||||
detail={
|
||||
today.bodyBatteryCharged != null
|
||||
? `充 ${today.bodyBatteryCharged} / 耗 ${today.bodyBatteryDrained}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatTile label="训练准备度" value={today.trainingReadiness} unit="/100" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sleep & breathing -------------------------------------------------- */}
|
||||
<section className="section">
|
||||
<h3 className="section-title">睡眠与呼吸</h3>
|
||||
<div className="tile-grid">
|
||||
<StatTile
|
||||
label="睡眠时长"
|
||||
value={today.sleepDuration}
|
||||
unit="小时"
|
||||
status={sleepTone}
|
||||
statusLabel={sleepWord}
|
||||
detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
|
||||
/>
|
||||
<StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" />
|
||||
<StatTile
|
||||
label="血氧"
|
||||
value={round(today.spo2Avg)}
|
||||
unit="%"
|
||||
detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label="呼吸频率"
|
||||
value={round(today.respirationAvg)}
|
||||
unit="次/分"
|
||||
detail={
|
||||
today.respirationMin != null && today.respirationMax != null
|
||||
? `${today.respirationMin}–${today.respirationMax}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="section-link">
|
||||
<Link to="/sleep">查看睡眠分期详情 →</Link>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Trends ------------------------------------------------------------- */}
|
||||
<section className="section">
|
||||
<h3 className="section-title">近 {days.length} 天趋势</h3>
|
||||
<div className="chart-grid">
|
||||
<Chart
|
||||
title="步数"
|
||||
subtitle={avgSteps != null ? `日均 ${Math.round(avgSteps).toLocaleString()} 步` : undefined}
|
||||
data={rows}
|
||||
type="bar"
|
||||
series={[{ key: 'steps', label: '步数', slot: 1, unit: '步' }]}
|
||||
/>
|
||||
<Chart
|
||||
title="心率"
|
||||
unit="bpm"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[
|
||||
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
|
||||
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
|
||||
]}
|
||||
/>
|
||||
<Chart
|
||||
title="睡眠时长"
|
||||
unit="小时"
|
||||
data={rows}
|
||||
type="area"
|
||||
series={[{ key: 'sleepDuration', label: '睡眠', slot: 1, unit: '小时', decimals: 1 }]}
|
||||
/>
|
||||
<Chart
|
||||
title="身体电量"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[
|
||||
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
|
||||
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
184
client/src/pages/DataSync.css
Normal file
184
client/src/pages/DataSync.css
Normal file
@@ -0,0 +1,184 @@
|
||||
.sync-container {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.status-card h3 {
|
||||
margin: 0 0 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: var(--surface-0);
|
||||
border-radius: 7px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 550;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Colour reinforces the status word already present in the markup; it never
|
||||
carries the meaning alone — the light-surface status steps are below 3:1. */
|
||||
.value.status-idle { color: var(--status-good); }
|
||||
.value.status-syncing { color: var(--accent); }
|
||||
.value.status-error { color: var(--status-critical); }
|
||||
|
||||
.status-item.error {
|
||||
background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, var(--status-critical) 25%, transparent);
|
||||
}
|
||||
|
||||
.status-item.error .value {
|
||||
color: var(--status-critical);
|
||||
font-weight: 400;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sync-actions {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.sync-choices {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mfa-card {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.code-input {
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: 0.32em;
|
||||
text-align: center;
|
||||
font-family: Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.mfa-buttons {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.progress-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--surface-0);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.info-box code {
|
||||
background: var(--surface-0);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.cmd {
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 7px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.8;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
font-family: Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.status-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.status-item .value { text-align: left; }
|
||||
.sync-choices .btn { flex: 1; }
|
||||
}
|
||||
@@ -1,18 +1,363 @@
|
||||
import React from 'react';
|
||||
import './Pages.css';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus,
|
||||
} from '../services/api';
|
||||
import './DataSync.css';
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
function DataSync() {
|
||||
const handleSync = async () => {
|
||||
// TODO: Implement data sync
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
const [hasToken, setHasToken] = useState<boolean | null>(null);
|
||||
|
||||
// Garmin login (only needed until a token is stored)
|
||||
const [password, setPassword] = useState('');
|
||||
const [session, setSession] = useState<string | null>(null);
|
||||
const [loginState, setLoginState] = useState<GarminLoginStatus | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
try {
|
||||
setSyncStatus(await apiClient.getGarminSyncStatus());
|
||||
} catch (err) {
|
||||
// A failed status poll should not blank the page.
|
||||
console.error('Failed to load sync status:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// A backfill outlives the page, so a reload must pick the progress back up.
|
||||
apiClient.getGarminSyncStatus().then((s) => {
|
||||
setSyncStatus(s);
|
||||
if (s.status === 'syncing') beginSyncPolling();
|
||||
}).catch(() => undefined);
|
||||
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
|
||||
return stopPolling;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// --- Garmin login -------------------------------------------------------
|
||||
const startLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setMessage('');
|
||||
if (!password) {
|
||||
setError('请输入 Garmin 密码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const sid = await apiClient.startGarminLogin(password);
|
||||
// The password is only ever needed for this one request.
|
||||
setPassword('');
|
||||
setSession(sid);
|
||||
setLoginState('starting');
|
||||
setCodeSubmitted(false);
|
||||
beginPolling(sid);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '登录失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const beginPolling = (sid: string) => {
|
||||
stopPolling();
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const { status, error: loginError } = await apiClient.getGarminLoginStatus(sid);
|
||||
setLoginState(status);
|
||||
|
||||
if (status === 'done') {
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
setHasToken(true);
|
||||
setMessage('Garmin 登录成功,之后同步不再需要密码或验证码。');
|
||||
} else if (status === 'failed') {
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
setCodeSubmitted(false);
|
||||
setError(loginError || '登录失败,请重试');
|
||||
}
|
||||
} catch (err: any) {
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
setError(errorMessage(err, '登录状态查询失败'));
|
||||
}
|
||||
}, POLL_MS);
|
||||
};
|
||||
|
||||
const submitCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!session || !code.trim()) return;
|
||||
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { ok, message: msg } = await apiClient.submitGarminMfa(session, code.trim());
|
||||
if (ok) {
|
||||
setCodeSubmitted(true);
|
||||
setCode('');
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '验证码提交失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelLogin = async () => {
|
||||
if (session) {
|
||||
try {
|
||||
await apiClient.cancelGarminLogin(session);
|
||||
} catch {
|
||||
// Cancelling is best-effort; the session expires on its own anyway.
|
||||
}
|
||||
}
|
||||
stopPolling();
|
||||
setSession(null);
|
||||
setLoginState(null);
|
||||
setCode('');
|
||||
setCodeSubmitted(false);
|
||||
};
|
||||
|
||||
// --- sync ---------------------------------------------------------------
|
||||
const handleSync = async (days: number) => {
|
||||
setError('');
|
||||
setMessage('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.syncGarminData(days);
|
||||
await loadSyncStatus();
|
||||
beginSyncPolling();
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '同步失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// The sync runs in the background, so the page follows it by polling
|
||||
// rather than by holding a request open for the whole backfill.
|
||||
const beginSyncPolling = () => {
|
||||
stopPolling();
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const s = await apiClient.getGarminSyncStatus();
|
||||
setSyncStatus(s);
|
||||
if (s.status !== 'syncing') {
|
||||
stopPolling();
|
||||
if (s.status === 'error') setError(s.lastError || '同步失败');
|
||||
else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
|
||||
}
|
||||
} catch {
|
||||
stopPolling();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
syncing: '正在同步…',
|
||||
error: '上次同步失败',
|
||||
};
|
||||
|
||||
const syncing = syncStatus?.status === 'syncing';
|
||||
const busy = loading || syncing;
|
||||
const awaitingCode = loginState === 'awaiting_code' || codeSubmitted;
|
||||
const current = syncStatus?.progressCurrent ?? 0;
|
||||
const total = syncStatus?.progressTotal ?? 0;
|
||||
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>数据同步</h2>
|
||||
<p className="placeholder">数据同步功能即将推出...</p>
|
||||
<button onClick={handleSync} className="btn btn-primary">
|
||||
同步 Garmin 数据
|
||||
</button>
|
||||
<p className="subtitle">从 Garmin Connect 拉取最近 7 天的健康数据</p>
|
||||
|
||||
<div className="sync-container">
|
||||
<section className="status-card">
|
||||
<h3>同步状态</h3>
|
||||
{syncStatus ? (
|
||||
<div className="status-info">
|
||||
<div className="status-item">
|
||||
<span className="label">状态</span>
|
||||
<span className={`value status-${syncStatus.status}`}>
|
||||
{statusLabel[syncStatus.status] ?? syncStatus.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className="label">最后同步</span>
|
||||
<span className="value">
|
||||
{parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
|
||||
?? '从未同步'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className="label">已同步天数</span>
|
||||
<span className="value">{syncStatus.recordsSynced}</span>
|
||||
</div>
|
||||
{syncStatus.lastError && (
|
||||
<div className="status-item error">
|
||||
<span className="label">错误</span>
|
||||
<span className="value">{syncStatus.lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="placeholder">加载中…</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Step 1 — link the Garmin account, once. */}
|
||||
{hasToken === false && !session && (
|
||||
<form className="sync-actions" onSubmit={startLogin}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="garmin-password">Garmin 密码</label>
|
||||
<input
|
||||
id="garmin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年),
|
||||
密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
|
||||
{loading ? '正在连接…' : '绑定 Garmin 账号'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Step 2 — the two-factor code. */}
|
||||
{session && (
|
||||
<section className="status-card mfa-card">
|
||||
{loginState === 'starting' && !codeSubmitted && (
|
||||
<p className="placeholder">正在连接 Garmin…</p>
|
||||
)}
|
||||
|
||||
{awaitingCode && (
|
||||
<form onSubmit={submitCode}>
|
||||
<h3>输入验证码</h3>
|
||||
<p className="field-hint" style={{ marginBottom: '1rem' }}>
|
||||
Garmin 已向你的手机或邮箱发送了 6 位验证码,请在下方填写。
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<input
|
||||
id="mfa-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={10}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="6 位数字"
|
||||
className="code-input"
|
||||
disabled={loading || codeSubmitted}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mfa-buttons">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading || codeSubmitted || !code.trim()}
|
||||
>
|
||||
{codeSubmitted ? '正在验证…' : '提交验证码'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loginState === 'finishing' && (
|
||||
<p className="placeholder">验证通过,正在完成登录…</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 3 — sync, once linked. */}
|
||||
{hasToken === true && (
|
||||
<section className="status-card">
|
||||
<h3>拉取数据</h3>
|
||||
<p className="field-hint" style={{ marginBottom: '0.9rem' }}>
|
||||
已绑定 Garmin 账号,同步无需密码。首次建议回补一段历史,
|
||||
之后日常只需拉最近 7 天。
|
||||
</p>
|
||||
|
||||
{syncing && total > 0 ? (
|
||||
<div className="progress-block">
|
||||
<div className="progress-head">
|
||||
<span>正在同步…</span>
|
||||
<span className="progress-count">{current} / {total} 天</span>
|
||||
</div>
|
||||
<div
|
||||
className="progress-bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="field-hint">
|
||||
在后台运行,可以离开本页。约每天 3 秒,
|
||||
{total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sync-choices">
|
||||
{[7, 30, 90, 365].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => handleSync(d)}
|
||||
className={`btn ${d === 7 ? 'btn-primary' : 'btn-plain'}`}
|
||||
disabled={busy}
|
||||
>
|
||||
{d === 365 ? '回补一年' : `最近 ${d} 天`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{message && <div className="success-message">{message}</div>}
|
||||
|
||||
<section className="info-box">
|
||||
<h4>关于数据同步</h4>
|
||||
<ul>
|
||||
<li>每次同步获取最近 7 天的每日汇总与运动记录</li>
|
||||
<li>同一天重复同步会更新原有记录,不会产生重复数据</li>
|
||||
<li>数据保存在本机数据库,不经过第三方服务</li>
|
||||
<li>Garmin 授权令牌约一年过期,届时重新绑定一次即可</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
100
client/src/pages/Login.css
Normal file
100
client/src/pages/Login.css
Normal file
@@ -0,0 +1,100 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--surface-0);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 2rem 1.85rem;
|
||||
animation: rise 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 680;
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.84rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 0.6rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 0.7rem 1rem;
|
||||
background: var(--accent-solid);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
margin-top: 0.35rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
238
client/src/pages/Login.tsx
Normal file
238
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiClient, errorMessage } 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>('');
|
||||
// Sign-up closes once an account exists, so the tab is hidden rather than
|
||||
// offering something the server will refuse.
|
||||
const [canRegister, setCanRegister] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.getRegistrationStatus()
|
||||
.then(setCanRegister)
|
||||
.catch(() => setCanRegister(false));
|
||||
}, []);
|
||||
|
||||
// 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 { token } = await apiClient.login(loginEmail, loginPassword);
|
||||
apiClient.setSession(token);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '登录失败'));
|
||||
} 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 { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
|
||||
apiClient.setSession(token);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '注册失败'));
|
||||
} 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>
|
||||
{canRegister && (
|
||||
<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' && canRegister && (
|
||||
<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,59 +1,351 @@
|
||||
.page {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page h2 {
|
||||
color: #333;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.8rem;
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 680;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.7rem;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
margin: 0.7rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.section-link a,
|
||||
.page a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.section-link a:hover,
|
||||
.page a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Filters sit in one row above the charts. */
|
||||
.range-tabs,
|
||||
.metric-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-tabs {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.range-tab,
|
||||
.metric-tab {
|
||||
padding: 0.34rem 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-1);
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.range-tab:hover,
|
||||
.metric-tab:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.range-tab.active,
|
||||
.metric-tab.active {
|
||||
background: var(--accent-solid);
|
||||
border-color: var(--accent-solid);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chart-grid.one-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
min-height: 240px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #999;
|
||||
font-size: 1.1rem;
|
||||
padding: 2rem;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
background: var(--surface-1);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: color-mix(in srgb, var(--status-critical) 10%, var(--surface-1));
|
||||
border: 1px solid color-mix(in srgb, var(--status-critical) 35%, transparent);
|
||||
color: var(--status-critical);
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: color-mix(in srgb, var(--status-good) 10%, var(--surface-1));
|
||||
border: 1px solid color-mix(in srgb, var(--status-good) 35%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* Buttons ------------------------------------------------------------------ */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
background: var(--accent-solid);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-plain {
|
||||
background: var(--surface-1);
|
||||
border-color: var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.84rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
}
|
||||
|
||||
.btn-plain:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Badges ------------------------------------------------------------------- */
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--series-4);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
.badge-name {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.badge-meta {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.badge-count {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Tables ------------------------------------------------------------------- */
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: auto;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table thead th {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.data-table tbody th {
|
||||
color: var(--text-primary);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.data-table td.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child th,
|
||||
.data-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Forms -------------------------------------------------------------------- */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chart-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.page-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
170
client/src/pages/Recommendations.css
Normal file
170
client/src/pages/Recommendations.css
Normal file
@@ -0,0 +1,170 @@
|
||||
.model-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.model-bar label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.model-bar select {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.model-bar select:disabled {
|
||||
background: var(--surface-0);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-bar {
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.meta-bar.ai {
|
||||
background: var(--accent-soft);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.meta-bar.rules {
|
||||
background: color-mix(in srgb, var(--status-warning) 12%, var(--surface-1));
|
||||
border-color: color-mix(in srgb, var(--status-warning) 35%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.meta-bar .fallback {
|
||||
color: var(--text-secondary);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 0.9rem 1rem;
|
||||
background: color-mix(in srgb, var(--status-warning) 12%, var(--surface-1));
|
||||
border: 1px solid color-mix(in srgb, var(--status-warning) 35%, transparent);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.notice code {
|
||||
background: var(--surface-0);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.rec-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.rec-card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.15rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Priority is spelled out in the badge text; the stripe is reinforcement. */
|
||||
.rec-card.priority-high { border-left-color: var(--status-critical); }
|
||||
.rec-card.priority-medium { border-left-color: var(--status-serious); }
|
||||
.rec-card.priority-low { border-left-color: var(--status-good); }
|
||||
|
||||
.rec-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.rec-category {
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.rec-priority {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.rec-priority.priority-high {
|
||||
background: color-mix(in srgb, var(--status-critical) 12%, transparent);
|
||||
color: var(--status-critical);
|
||||
border-color: color-mix(in srgb, var(--status-critical) 30%, transparent);
|
||||
}
|
||||
|
||||
.rec-priority.priority-medium {
|
||||
background: color-mix(in srgb, var(--status-serious) 14%, transparent);
|
||||
color: var(--status-serious);
|
||||
border-color: color-mix(in srgb, var(--status-serious) 35%, transparent);
|
||||
}
|
||||
|
||||
.rec-priority.priority-low {
|
||||
background: color-mix(in srgb, var(--status-good) 12%, transparent);
|
||||
color: var(--status-good);
|
||||
border-color: color-mix(in srgb, var(--status-good) 30%, transparent);
|
||||
}
|
||||
|
||||
.rec-body {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.85;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rec-based-on {
|
||||
margin-top: 0.65rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.model-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.model-bar select,
|
||||
.model-bar .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,159 @@
|
||||
import React from 'react';
|
||||
import './Pages.css';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
apiClient, errorMessage, AiRecommendations, ModelInfo, parseUtc,
|
||||
} from '../services/api';
|
||||
import './Recommendations.css';
|
||||
|
||||
const PRIORITY_LABEL: Record<string, string> = {
|
||||
high: '高',
|
||||
medium: '中',
|
||||
low: '低',
|
||||
};
|
||||
|
||||
function Recommendations() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
const [result, setResult] = useState<AiRecommendations | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
const load = useCallback(async (model?: string, refresh?: boolean) => {
|
||||
setLoading(true);
|
||||
setRegenerating(Boolean(refresh || model));
|
||||
setError('');
|
||||
try {
|
||||
setResult(await apiClient.getAiRecommendations(model || undefined, refresh));
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '获取建议失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRegenerating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const list = await apiClient.getModels();
|
||||
setModels(list);
|
||||
setSelected(list.find((m) => m.default && m.configured)?.id ?? '');
|
||||
} catch {
|
||||
// The model list is a convenience; recommendations still work without it.
|
||||
}
|
||||
load();
|
||||
};
|
||||
init();
|
||||
}, [load]);
|
||||
|
||||
const configured = models.filter((m) => m.configured);
|
||||
const meta = result?.meta;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>健康建议</h2>
|
||||
<p className="placeholder">健康建议页面即将推出...</p>
|
||||
<p className="subtitle">基于你的历史数据生成,可切换不同的大模型</p>
|
||||
|
||||
<div className="model-bar">
|
||||
<label htmlFor="model-select">模型</label>
|
||||
<select
|
||||
id="model-select"
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
setSelected(e.target.value);
|
||||
load(e.target.value);
|
||||
}}
|
||||
disabled={loading || configured.length === 0}
|
||||
>
|
||||
<option value="">自动(按优先级依次尝试)</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id} disabled={!m.configured}>
|
||||
{m.id} · {(m.contextWindow / 1000).toLocaleString()}k
|
||||
{m.configured ? '' : '(未配置密钥)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => load(selected, true)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '生成中…' : '重新生成'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{regenerating && (
|
||||
<div className="notice">
|
||||
正在请求大模型重新分析,通常需要 1-3 分钟(推理模型会先推演再作答)。
|
||||
期间可以离开本页,结果会被缓存下来。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configured.length === 0 && models.length > 0 && (
|
||||
<div className="notice">
|
||||
尚未配置任何模型密钥。在 <code>backend/.env</code> 中填入
|
||||
<code>GEMINI_API_KEY</code> 或 <code>NVIDIA_API_KEY</code> 即可启用 AI 建议;
|
||||
在此之前下面显示的是规则引擎的结果。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{meta && (
|
||||
<div className={`meta-bar ${meta.source}`}>
|
||||
{meta.source === 'ai' ? (
|
||||
<>
|
||||
<strong>AI 生成</strong> · 模型 {meta.model}
|
||||
{meta.upstream && `(上游 ${meta.upstream})`}
|
||||
{' · '}分析了 {meta.days} 天数据
|
||||
{meta.cached && (
|
||||
<span className="fallback">
|
||||
· 缓存结果
|
||||
{meta.generatedAt &&
|
||||
`,生成于 ${parseUtc(meta.generatedAt)?.toLocaleString('zh-CN')}`}
|
||||
</span>
|
||||
)}
|
||||
{meta.fallbackFrom && meta.fallbackFrom.length > 0 && (
|
||||
<span className="fallback">
|
||||
({meta.fallbackFrom.join('、')} 失败后自动切换)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>规则引擎</strong>
|
||||
{meta.reason && <span className="fallback">· {meta.reason}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !result && <div className="page-loading">正在分析…</div>}
|
||||
|
||||
<div className="rec-list">
|
||||
{result?.recommendations.map((rec) => (
|
||||
<article key={rec.id} className={`rec-card priority-${rec.priority}`}>
|
||||
<header className="rec-header">
|
||||
<span className="rec-category">{rec.category}</span>
|
||||
<span className={`rec-priority priority-${rec.priority}`}>
|
||||
{PRIORITY_LABEL[rec.priority] ?? rec.priority}
|
||||
</span>
|
||||
</header>
|
||||
<p className="rec-body">{rec.recommendation}</p>
|
||||
{rec.basedOn.length > 0 && (
|
||||
<footer className="rec-based-on">
|
||||
依据:{rec.basedOn.join('、')}
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="disclaimer">
|
||||
以上内容由数据分析生成,不构成医疗建议。如有健康问题请咨询专业医师。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
137
client/src/pages/Settings.css
Normal file
137
client/src/pages/Settings.css
Normal file
@@ -0,0 +1,137 @@
|
||||
.settings-section {
|
||||
margin-bottom: 2.25rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.55rem;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.settings-hint code,
|
||||
.model-table code {
|
||||
background: var(--surface-0);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.88em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.model-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.86rem;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-table th {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.model-table td {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.model-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* State is spelled out in the badge text; colour only reinforces it. */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.badge.ok {
|
||||
background: color-mix(in srgb, var(--status-good) 12%, transparent);
|
||||
color: var(--status-good);
|
||||
border-color: color-mix(in srgb, var(--status-good) 30%, transparent);
|
||||
}
|
||||
|
||||
.badge.off {
|
||||
background: var(--surface-0);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.badge-default {
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.95;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--surface-1);
|
||||
color: var(--status-critical);
|
||||
border: 1px solid color-mix(in srgb, var(--status-critical) 40%, transparent);
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-1));
|
||||
border-color: var(--status-critical);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.model-table {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.model-table th,
|
||||
.model-table td {
|
||||
padding: 0.45rem 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,99 @@
|
||||
import React from 'react';
|
||||
import './Pages.css';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiClient, ModelInfo } from '../services/api';
|
||||
import { FEATURES } from '../features';
|
||||
import './Settings.css';
|
||||
|
||||
function Settings() {
|
||||
const navigate = useNavigate();
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!FEATURES.ai) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
apiClient
|
||||
.getModels()
|
||||
.then(setModels)
|
||||
.catch(() => setModels([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await apiClient.logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>设置</h2>
|
||||
<p className="placeholder">设置页面即将推出...</p>
|
||||
|
||||
{FEATURES.ai && (
|
||||
<section className="settings-section">
|
||||
<h3>AI 模型</h3>
|
||||
<p className="settings-hint">
|
||||
模型清单与优先级由后端 <code>backend/.env</code> 决定。
|
||||
填入对应厂商的密钥后,模型会自动变为可用;
|
||||
<code>AI_MODEL_CHAIN</code> 控制自动模式下的尝试顺序。
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<p className="placeholder">加载中…</p>
|
||||
) : models.length === 0 ? (
|
||||
<p className="placeholder">无法获取模型列表。</p>
|
||||
) : (
|
||||
<table className="model-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>模型</th>
|
||||
<th>上下文</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<code>{m.id}</code>
|
||||
{m.default && <span className="badge-default">默认</span>}
|
||||
</td>
|
||||
<td className="model-name">{m.model}</td>
|
||||
<td>{(m.contextWindow / 1000).toLocaleString()}k</td>
|
||||
<td>
|
||||
<span className={`badge ${m.configured ? 'ok' : 'off'}`}>
|
||||
{m.configured ? '已配置' : '缺少密钥'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>数据与隐私</h3>
|
||||
<ul className="settings-list">
|
||||
<li>健康数据保存在自建数据库中,不上传第三方服务。</li>
|
||||
<li>
|
||||
Garmin 账号通过 OAuth 令牌授权,密码不会被保存;令牌约一年后过期,
|
||||
届时在「同步」页重新绑定一次即可。
|
||||
</li>
|
||||
<li>本站登录密码以 PBKDF2 加盐哈希存储,无法还原。</li>
|
||||
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>账户</h3>
|
||||
<button className="btn btn-danger" onClick={handleLogout}>
|
||||
退出登录
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
178
client/src/pages/Sleep.tsx
Normal file
178
client/src/pages/Sleep.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import StatTile from '../components/charts/StatTile';
|
||||
import './Pages.css';
|
||||
|
||||
const RANGES = [7, 14, 30, 90];
|
||||
const H = 3600;
|
||||
|
||||
function avg(values: Array<number | null | undefined>): number | null {
|
||||
const present = values.filter((v): v is number => v != null);
|
||||
return present.length ? present.reduce((a, b) => a + b, 0) / present.length : null;
|
||||
}
|
||||
|
||||
function Sleep() {
|
||||
const [days, setDays] = useState<HealthDay[]>([]);
|
||||
// 14 by default: the stacked chart needs bars wide enough to read the
|
||||
// thinnest stage and to give hover a ~24px hit target. Longer windows stay
|
||||
// available for the trend, where density matters less.
|
||||
const [range, setRange] = useState(14);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (range - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载睡眠数据失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [range]);
|
||||
|
||||
const nights = days.filter((d) => d.sleepDuration != null);
|
||||
|
||||
// Stage seconds are converted to hours here so the stacked bar and the
|
||||
// duration chart share one y-scale — a chart never carries two scales.
|
||||
const rows = nights.map((d) => ({
|
||||
date: d.date.slice(5),
|
||||
deep: d.sleep?.deepSeconds != null ? d.sleep.deepSeconds / H : null,
|
||||
light: d.sleep?.lightSeconds != null ? d.sleep.lightSeconds / H : null,
|
||||
rem: d.sleep?.remSeconds != null ? d.sleep.remSeconds / H : null,
|
||||
awake: d.sleep?.awakeSeconds != null ? d.sleep.awakeSeconds / H : null,
|
||||
quality: d.sleepQuality,
|
||||
spo2: d.sleepSpo2Avg,
|
||||
respiration: d.sleepRespirationAvg,
|
||||
stress: d.sleepStressAvg,
|
||||
}));
|
||||
|
||||
const avgDeep = avg(rows.map((r) => r.deep));
|
||||
const avgRem = avg(rows.map((r) => r.rem));
|
||||
const avgLight = avg(rows.map((r) => r.light));
|
||||
const avgAwake = avg(rows.map((r) => r.awake));
|
||||
const avgDuration = avg(nights.map((d) => d.sleepDuration));
|
||||
const avgQuality = avg(nights.map((d) => d.sleepQuality));
|
||||
|
||||
const totalStages = [avgDeep, avgLight, avgRem].reduce<number>(
|
||||
(sum, v) => sum + (v ?? 0), 0
|
||||
);
|
||||
const share = (v: number | null) =>
|
||||
v == null || totalStages === 0 ? undefined : `占 ${Math.round((v / totalStages) * 100)}%`;
|
||||
|
||||
const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>睡眠</h2>
|
||||
<p className="subtitle">分期、评分与夜间生理指标</p>
|
||||
</div>
|
||||
<div className="range-tabs">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={`range-tab ${r === range ? 'active' : ''}`}
|
||||
onClick={() => setRange(r)}
|
||||
>
|
||||
{r} 天
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{loading && <div className="page-loading">加载中…</div>}
|
||||
|
||||
{!loading && !error && nights.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>所选区间内没有睡眠记录。</p>
|
||||
<Link to="/sync" className="btn btn-primary">去同步数据</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && nights.length > 0 && (
|
||||
<>
|
||||
<section className="section">
|
||||
<h3 className="section-title">{nights.length} 晚平均</h3>
|
||||
<div className="tile-grid">
|
||||
<StatTile label="总时长" value={hrs(avgDuration)} unit="小时" />
|
||||
<StatTile label="睡眠评分" value={hrs(avgQuality, 0)} unit="/100" />
|
||||
<StatTile label="深睡" value={hrs(avgDeep)} unit="小时" detail={share(avgDeep)} />
|
||||
<StatTile label="浅睡" value={hrs(avgLight)} unit="小时" detail={share(avgLight)} />
|
||||
<StatTile label="REM" value={hrs(avgRem)} unit="小时" detail={share(avgRem)} />
|
||||
<StatTile label="夜间清醒" value={hrs(avgAwake)} unit="小时" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="chart-grid one-col">
|
||||
<Chart
|
||||
title="睡眠分期"
|
||||
unit="小时"
|
||||
subtitle="每晚各阶段时长堆叠;总高度即当晚睡眠总时长"
|
||||
data={rows}
|
||||
type="stacked-bar"
|
||||
height={280}
|
||||
series={[
|
||||
{ key: 'deep', label: '深睡', slot: 1, unit: '小时', decimals: 1 },
|
||||
{ key: 'light', label: '浅睡', slot: 2, unit: '小时', decimals: 1 },
|
||||
{ key: 'rem', label: 'REM', slot: 3, unit: '小时', decimals: 1 },
|
||||
{ key: 'awake', label: '清醒', slot: 4, unit: '小时', decimals: 1 },
|
||||
]}
|
||||
footer="成人参考:深睡约占 13–23%,REM 约占 20–25%。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="chart-grid">
|
||||
<Chart
|
||||
title="睡眠评分"
|
||||
unit="/100"
|
||||
data={rows}
|
||||
type="area"
|
||||
series={[{ key: 'quality', label: '评分', slot: 1 }]}
|
||||
/>
|
||||
<Chart
|
||||
title="夜间血氧"
|
||||
unit="%"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[{ key: 'spo2', label: '血氧', slot: 1, unit: '%', decimals: 1 }]}
|
||||
/>
|
||||
<Chart
|
||||
title="夜间呼吸频率"
|
||||
unit="次/分"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[
|
||||
{ key: 'respiration', label: '呼吸', slot: 1, unit: '次/分', decimals: 1 },
|
||||
]}
|
||||
/>
|
||||
<Chart
|
||||
title="睡眠压力"
|
||||
data={rows}
|
||||
type="line"
|
||||
series={[{ key: 'stress', label: '压力', slot: 1, decimals: 1 }]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sleep;
|
||||
280
client/src/pages/Trends.tsx
Normal file
280
client/src/pages/Trends.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import Chart, { Series } from '../components/charts/Chart';
|
||||
import StatTile from '../components/charts/StatTile';
|
||||
import './Pages.css';
|
||||
|
||||
const RANGES = [7, 14, 30, 90, 365];
|
||||
|
||||
/** Each group is one chart. Metrics only share a chart when they share a
|
||||
* scale and a unit — a chart never carries two y-scales. */
|
||||
interface MetricGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
unit?: string;
|
||||
type: 'line' | 'bar' | 'area';
|
||||
/** Which HealthDay fields to plot, in palette-slot order. */
|
||||
series: Series[];
|
||||
/** Optional transform, e.g. seconds to hours. */
|
||||
scale?: Record<string, number>;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const GROUPS: MetricGroup[] = [
|
||||
{
|
||||
id: 'steps', label: '步数', unit: '步', type: 'bar',
|
||||
series: [{ key: 'steps', label: '步数', slot: 1, unit: '步' }],
|
||||
},
|
||||
{
|
||||
id: 'distance', label: '距离', unit: 'km', type: 'bar',
|
||||
scale: { distanceMeters: 1 / 1000 },
|
||||
series: [{ key: 'distanceMeters', label: '距离', slot: 1, unit: 'km', decimals: 2 }],
|
||||
},
|
||||
{
|
||||
id: 'calories', label: '能量消耗', unit: 'kcal', type: 'bar',
|
||||
series: [
|
||||
{ key: 'bmrCalories', label: '基础代谢', slot: 1, unit: 'kcal' },
|
||||
{ key: 'activeCalories', label: '活动消耗', slot: 2, unit: 'kcal' },
|
||||
],
|
||||
note: '两者相加即当日总消耗。',
|
||||
},
|
||||
{
|
||||
id: 'heart', label: '心率', unit: 'bpm', type: 'line',
|
||||
series: [
|
||||
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
|
||||
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
|
||||
{ key: 'heartRateMin', label: '最低', slot: 3, unit: 'bpm' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hrv', label: '心率变异性', unit: 'ms', type: 'area',
|
||||
series: [{ key: 'heartRateVariability', label: 'HRV', slot: 1, unit: 'ms', decimals: 1 }],
|
||||
note: 'HRV 反映自主神经恢复情况,持续偏低常与压力或训练过量相关。',
|
||||
},
|
||||
{
|
||||
id: 'stress', label: '压力', type: 'line',
|
||||
series: [
|
||||
{ key: 'stress', label: '平均', slot: 1 },
|
||||
{ key: 'stressMax', label: '峰值', slot: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'battery', label: '身体电量', type: 'line',
|
||||
series: [
|
||||
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
|
||||
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sleep', label: '睡眠时长', unit: '小时', type: 'area',
|
||||
series: [{ key: 'sleepDuration', label: '时长', slot: 1, unit: '小时', decimals: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'spo2', label: '血氧', unit: '%', type: 'line',
|
||||
series: [
|
||||
{ key: 'spo2Avg', label: '平均', slot: 1, unit: '%', decimals: 1 },
|
||||
{ key: 'spo2Min', label: '最低', slot: 2, unit: '%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'respiration', label: '呼吸频率', unit: '次/分', type: 'line',
|
||||
series: [
|
||||
{ key: 'respirationAvg', label: '平均', slot: 1, unit: '次/分', decimals: 1 },
|
||||
{ key: 'respirationMax', label: '最高', slot: 2, unit: '次/分', decimals: 1 },
|
||||
{ key: 'respirationMin', label: '最低', slot: 3, unit: '次/分', decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'floors', label: '爬楼', unit: '层', type: 'bar',
|
||||
series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层', decimals: 0 }],
|
||||
},
|
||||
{
|
||||
id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
|
||||
series: [{ key: 'intensityMinutes', label: '强度分钟', slot: 1, unit: '分钟' }],
|
||||
},
|
||||
{
|
||||
id: 'sedentary', label: '久坐与活动时长', unit: '小时', type: 'bar',
|
||||
scale: { sedentarySeconds: 1 / 3600, activeSeconds: 1 / 3600 },
|
||||
series: [
|
||||
{ key: 'sedentarySeconds', label: '久坐', slot: 1, unit: '小时', decimals: 1 },
|
||||
{ key: 'activeSeconds', label: '活动', slot: 2, unit: '小时', decimals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'training', label: '训练准备度', unit: '/100', type: 'area',
|
||||
series: [{ key: 'trainingReadiness', label: '准备度', slot: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'endurance', label: '耐力分', type: 'area',
|
||||
series: [{ key: 'enduranceScore', label: '耐力分', slot: 1 }],
|
||||
},
|
||||
];
|
||||
|
||||
function stats(values: Array<number | null | undefined>) {
|
||||
const present = values.filter((v): v is number => v != null);
|
||||
if (!present.length) return null;
|
||||
const sorted = [...present].sort((a, b) => a - b);
|
||||
const mean = present.reduce((a, b) => a + b, 0) / present.length;
|
||||
const mid = Math.floor(present.length / 2);
|
||||
const firstHalf = present.slice(0, mid);
|
||||
const secondHalf = present.slice(mid);
|
||||
const delta =
|
||||
firstHalf.length && secondHalf.length
|
||||
? secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length -
|
||||
firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
|
||||
: 0;
|
||||
return {
|
||||
count: present.length,
|
||||
mean,
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
median: sorted[mid],
|
||||
delta,
|
||||
};
|
||||
}
|
||||
|
||||
function Trends() {
|
||||
const [days, setDays] = useState<HealthDay[]>([]);
|
||||
const [range, setRange] = useState(30);
|
||||
const [active, setActive] = useState(GROUPS[0].id);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - (range - 1) * 86400000);
|
||||
setDays(
|
||||
await apiClient.getHealthSummary(
|
||||
start.toISOString().slice(0, 10),
|
||||
end.toISOString().slice(0, 10)
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [range]);
|
||||
|
||||
const group = GROUPS.find((g) => g.id === active) ?? GROUPS[0];
|
||||
|
||||
/* Bars stop working long before a year fits: 365 of them across a typical
|
||||
chart leaves ~1.7px each, well under a readable mark or a usable hover
|
||||
target. Past this many points the same data is drawn as an area instead —
|
||||
a line handles density natively because its crosshair snaps to the
|
||||
nearest x rather than needing per-mark hit areas. */
|
||||
const DENSE_ABOVE = 90;
|
||||
const dense = days.length > DENSE_ABOVE;
|
||||
const renderType =
|
||||
dense && (group.type === 'bar') ? 'area' as const : group.type;
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
days.map((d) => {
|
||||
const row: Record<string, any> = { date: d.date.slice(5) };
|
||||
for (const s of group.series) {
|
||||
const raw = (d as any)[s.key];
|
||||
const factor = group.scale?.[s.key];
|
||||
row[s.key] = raw == null ? null : factor ? raw * factor : raw;
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
[days, group]
|
||||
);
|
||||
|
||||
const primary = group.series[0];
|
||||
const summary = stats(rows.map((r) => r[primary.key]));
|
||||
/* Precision by magnitude: "11,013.99 步" is both false precision and long
|
||||
enough to wrap its unit onto a second line. */
|
||||
const fmt = (v: number) => {
|
||||
const abs = Math.abs(v);
|
||||
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
|
||||
return v.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>趋势</h2>
|
||||
<p className="subtitle">全部 {GROUPS.length} 组指标</p>
|
||||
</div>
|
||||
<div className="range-tabs">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={`range-tab ${r === range ? 'active' : ''}`}
|
||||
onClick={() => setRange(r)}
|
||||
>
|
||||
{r === 365 ? '一年' : `${r} 天`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="metric-tabs">
|
||||
{GROUPS.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
className={`metric-tab ${g.id === active ? 'active' : ''}`}
|
||||
onClick={() => setActive(g.id)}
|
||||
>
|
||||
{g.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{loading && <div className="page-loading">加载中…</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{summary ? (
|
||||
<div className="tile-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
<StatTile label="平均" value={fmt(summary.mean)} unit={primary.unit} />
|
||||
<StatTile label="中位数" value={fmt(summary.median)} unit={primary.unit} />
|
||||
<StatTile label="最低" value={fmt(summary.min)} unit={primary.unit} />
|
||||
<StatTile label="最高" value={fmt(summary.max)} unit={primary.unit} />
|
||||
<StatTile
|
||||
label="后半段对比前半段"
|
||||
value={`${summary.delta >= 0 ? '+' : ''}${fmt(summary.delta)}`}
|
||||
unit={primary.unit}
|
||||
/>
|
||||
<StatTile label="有效天数" value={summary.count} unit="天" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="placeholder">该指标在所选区间内没有数据。</p>
|
||||
)}
|
||||
|
||||
<div className="chart-grid one-col">
|
||||
<Chart
|
||||
title={group.label}
|
||||
unit={group.unit}
|
||||
subtitle={
|
||||
dense && renderType !== group.type
|
||||
? `${days.length} 天数据:柱形在此密度下不可读,已改用面积图`
|
||||
: undefined
|
||||
}
|
||||
data={rows}
|
||||
type={renderType}
|
||||
series={group.series}
|
||||
height={320}
|
||||
footer={group.note}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Trends;
|
||||
@@ -1,6 +1,186 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||
const TOKEN_KEY = 'ghl_token';
|
||||
|
||||
// The Flask backend returns bare JSON (an array, or the object itself) and
|
||||
// signals failure with `{ error: "..." }` plus a non-2xx status. There is no
|
||||
// {success, data} envelope, so responses are read as `response.data` directly.
|
||||
export interface AuthResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface SleepDetail {
|
||||
duration: number;
|
||||
quality: number | null;
|
||||
deepSeconds: number | null;
|
||||
lightSeconds: number | null;
|
||||
remSeconds: number | null;
|
||||
awakeSeconds: number | null;
|
||||
}
|
||||
|
||||
/** One day, with every metric the sync stores. All are nullable: a device
|
||||
* that does not record a metric leaves it null rather than zero. */
|
||||
export interface HealthDay {
|
||||
date: string;
|
||||
steps: number | null;
|
||||
stepGoal: number | null;
|
||||
distanceMeters: number | null;
|
||||
caloriesBurned: number | null;
|
||||
activeCalories: number | null;
|
||||
bmrCalories: number | null;
|
||||
floorsAscended: number | null;
|
||||
floorsDescended: number | null;
|
||||
intensityMinutes: number | null;
|
||||
sedentarySeconds: number | null;
|
||||
activeSeconds: number | null;
|
||||
heartRate: number | null;
|
||||
heartRateMax: number | null;
|
||||
heartRateMin: number | null;
|
||||
heartRateVariability: number | null;
|
||||
stress: number | null;
|
||||
stressMax: number | null;
|
||||
bodyBatteryHigh: number | null;
|
||||
bodyBatteryLow: number | null;
|
||||
bodyBatteryCharged: number | null;
|
||||
bodyBatteryDrained: number | null;
|
||||
spo2Avg: number | null;
|
||||
spo2Min: number | null;
|
||||
respirationAvg: number | null;
|
||||
respirationMin: number | null;
|
||||
respirationMax: number | null;
|
||||
sleepDuration: number | null;
|
||||
sleepQuality: number | null;
|
||||
sleepSpo2Avg: number | null;
|
||||
sleepRespirationAvg: number | null;
|
||||
sleepStressAvg: number | null;
|
||||
trainingReadiness: number | null;
|
||||
vo2max: number | null;
|
||||
enduranceScore: number | null;
|
||||
sleep: SleepDetail | null;
|
||||
}
|
||||
|
||||
export interface Badge {
|
||||
id: string;
|
||||
badge_key: string | null;
|
||||
name: string | null;
|
||||
category_id: number | null;
|
||||
difficulty_id: number | null;
|
||||
earned_date: string | null;
|
||||
earned_count: number | null;
|
||||
points: number | null;
|
||||
}
|
||||
|
||||
export interface PersonalRecord {
|
||||
id: string;
|
||||
type_id: number | null;
|
||||
activity_id: string | null;
|
||||
activity_name: string | null;
|
||||
activity_type: string | null;
|
||||
value: number | null;
|
||||
achieved_at: string | null;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
activity_type: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
duration: number | null;
|
||||
distance: number | null;
|
||||
calories: number | null;
|
||||
heart_rate_average: number | null;
|
||||
heart_rate_max: number | null;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
status: 'idle' | 'syncing' | 'error';
|
||||
lastSyncTime: string | null;
|
||||
recordsSynced: number;
|
||||
lastError: string | null;
|
||||
/** Days completed / requested. A backfill runs for many minutes. */
|
||||
progressCurrent: number | null;
|
||||
progressTotal: number | null;
|
||||
startedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
status: 'success' | 'error';
|
||||
recordsSynced: number;
|
||||
activitiesSynced?: number;
|
||||
message: string;
|
||||
/** Set when the account has two-factor auth and no token is stored yet. */
|
||||
mfaRequired?: boolean;
|
||||
lastSyncTime: string;
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
category: string;
|
||||
recommendation: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
basedOn: string[];
|
||||
source?: 'ai';
|
||||
}
|
||||
|
||||
export interface AiRecommendations {
|
||||
recommendations: Recommendation[];
|
||||
meta: {
|
||||
source: 'ai' | 'rules';
|
||||
model: string | null;
|
||||
provider?: string;
|
||||
upstream?: string | null;
|
||||
days?: number;
|
||||
fallbackFrom?: string[];
|
||||
reason?: string;
|
||||
/** True when served from the stored answer rather than freshly generated. */
|
||||
cached?: boolean;
|
||||
generatedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type GarminLoginStatus =
|
||||
| 'starting'
|
||||
| 'awaiting_code'
|
||||
| 'finishing'
|
||||
| 'done'
|
||||
| 'failed';
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
contextWindow: number;
|
||||
configured: boolean;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a timestamp the backend wrote with `datetime.utcnow()` — i.e. UTC but
|
||||
* with no offset in the string. JavaScript reads such a value as *local* time,
|
||||
* which showed sync times eight hours in the past here.
|
||||
*/
|
||||
export function parseUtc(value: string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const normalised = value.replace(' ', 'T');
|
||||
const withZone = /[Zz]|[+-]\d{2}:?\d{2}$/.test(normalised)
|
||||
? normalised
|
||||
: `${normalised}Z`;
|
||||
const date = new Date(withZone);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/** Pull a human-readable message out of an axios error. */
|
||||
export function errorMessage(err: any, fallback = '请求失败'): string {
|
||||
return err?.response?.data?.error || err?.message || fallback;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
@@ -8,70 +188,212 @@ class ApiClient {
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
login(email: string, password: string) {
|
||||
return this.client.post('/auth/login', { email, password });
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this.client.post('/auth/logout');
|
||||
}
|
||||
|
||||
// Garmin endpoints
|
||||
syncGarminData() {
|
||||
return this.client.post('/garmin/sync');
|
||||
}
|
||||
|
||||
getGarminSyncStatus() {
|
||||
return this.client.get('/garmin/status');
|
||||
}
|
||||
|
||||
// Health endpoints
|
||||
getHealthSummary(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/summary', {
|
||||
params: { startDate, endDate }
|
||||
// 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 session and bounce to login, so an expired token does
|
||||
// not leave the user staring at empty pages.
|
||||
this.client.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getStepsData(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/steps', {
|
||||
params: { startDate, endDate }
|
||||
// --- session ---
|
||||
setSession(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
clearSession() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return Boolean(localStorage.getItem(TOKEN_KEY));
|
||||
}
|
||||
|
||||
// --- auth ---
|
||||
async register(email: string, garminEmail: string, garminPassword: string) {
|
||||
const { data } = await this.client.post<AuthResponse>('/auth/register', {
|
||||
email,
|
||||
garminEmail,
|
||||
garminPassword,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
getHeartRateData(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/heart-rate', {
|
||||
params: { startDate, endDate }
|
||||
/** Whether sign-up is currently permitted (closes after the first account). */
|
||||
async getRegistrationStatus() {
|
||||
const { data } = await this.client.get<{ open: boolean }>(
|
||||
'/auth/registration-status'
|
||||
);
|
||||
return data.open;
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const { data } = await this.client.post<AuthResponse>('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
getSleepData(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/sleep', {
|
||||
params: { startDate, endDate }
|
||||
async logout() {
|
||||
try {
|
||||
await this.client.post('/auth/logout');
|
||||
} finally {
|
||||
this.clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
// --- garmin ---
|
||||
/**
|
||||
* With a stored OAuth token no password is needed. Without one, the
|
||||
* plaintext password must be supplied because only a hash is kept — and an
|
||||
* MFA-protected account cannot log in this way at all (see garmin_login.py).
|
||||
*/
|
||||
/** Starts a sync in the background; poll getGarminSyncStatus for progress. */
|
||||
async syncGarminData(days?: number, garminPassword?: string) {
|
||||
const { data } = await this.client.post<{ status: string; days?: number }>(
|
||||
'/garmin/sync',
|
||||
{
|
||||
...(days ? { days } : {}),
|
||||
...(garminPassword ? { garminPassword } : {}),
|
||||
}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Whether a stored Garmin token exists (then sync needs no password). */
|
||||
async getGarminAuthStatus() {
|
||||
const { data } = await this.client.get<{ hasToken: boolean }>(
|
||||
'/garmin/auth-status'
|
||||
);
|
||||
return data.hasToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an interactive Garmin login. Returns a session id; the login runs
|
||||
* in the background and parks if Garmin asks for a two-factor code.
|
||||
*/
|
||||
async startGarminLogin(garminPassword: string, garminEmail?: string) {
|
||||
const { data } = await this.client.post<{ session: string }>('/garmin/login', {
|
||||
garminPassword,
|
||||
...(garminEmail ? { garminEmail } : {}),
|
||||
});
|
||||
return data.session;
|
||||
}
|
||||
|
||||
getActivities(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/activities', {
|
||||
params: { startDate, endDate }
|
||||
async getGarminLoginStatus(session: string) {
|
||||
const { data } = await this.client.get<{
|
||||
session: string;
|
||||
status: GarminLoginStatus;
|
||||
error: string | null;
|
||||
}>('/garmin/login-status', { params: { session } });
|
||||
return data;
|
||||
}
|
||||
|
||||
async submitGarminMfa(session: string, code: string) {
|
||||
const { data } = await this.client.post<{ ok: boolean; message: string }>(
|
||||
'/garmin/mfa',
|
||||
{ session, code }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async cancelGarminLogin(session: string) {
|
||||
await this.client.delete('/garmin/login', { params: { session } });
|
||||
}
|
||||
|
||||
async getGarminSyncStatus() {
|
||||
const { data } = await this.client.get<SyncStatus>('/garmin/status');
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- health ---
|
||||
private range(startDate?: string, endDate?: string) {
|
||||
return { params: { startDate, endDate } };
|
||||
}
|
||||
|
||||
async getHealthSummary(startDate?: string, endDate?: string) {
|
||||
const { data } = await this.client.get<HealthDay[]>(
|
||||
'/health/summary',
|
||||
this.range(startDate, endDate)
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getActivities(startDate?: string, endDate?: string) {
|
||||
const { data } = await this.client.get<Activity[]>(
|
||||
'/health/activities',
|
||||
this.range(startDate, endDate)
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getBadges() {
|
||||
const { data } = await this.client.get<Badge[]>('/health/badges');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getPersonalRecords() {
|
||||
const { data } = await this.client.get<PersonalRecord[]>(
|
||||
'/health/personal-records'
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- analysis ---
|
||||
async getTrends(metricType: string, startDate?: string, endDate?: string) {
|
||||
const { data } = await this.client.get<TrendPoint[]>('/analysis/trends', {
|
||||
params: { metricType, startDate, endDate },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// Analysis endpoints
|
||||
getTrends(metricType?: string) {
|
||||
return this.client.get('/analysis/trends', {
|
||||
params: { metricType }
|
||||
});
|
||||
async getRecommendations() {
|
||||
const { data } = await this.client.get<Recommendation[]>('/analysis/recommendations');
|
||||
return data;
|
||||
}
|
||||
|
||||
getRecommendations() {
|
||||
return this.client.get('/analysis/recommendations');
|
||||
async getModels() {
|
||||
const { data } = await this.client.get<ModelInfo[]>('/analysis/models');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Served from the stored answer unless `refresh` is set or a `model` is
|
||||
* named. A fresh generation can take minutes, so the caller should show a
|
||||
* long-running state for those two cases.
|
||||
*/
|
||||
async getAiRecommendations(model?: string, refresh?: boolean, days?: number) {
|
||||
const { data } = await this.client.get<AiRecommendations>(
|
||||
'/analysis/ai-recommendations',
|
||||
{
|
||||
params: { model, days, ...(refresh ? { refresh: 1 } : {}) },
|
||||
// A cold generation runs well past axios's default timeout.
|
||||
timeout: 240_000,
|
||||
}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
109
client/src/theme.css
Normal file
109
client/src/theme.css
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Design tokens.
|
||||
*
|
||||
* The series colours are the validated categorical palette, in its documented
|
||||
* slot order — the ordering is the colour-blind-safety mechanism, not a
|
||||
* cosmetic choice, so slots are assigned in order and never cycled.
|
||||
* Verified with the palette validator in both modes:
|
||||
* light worst adjacent CVD ΔE 9.1, normal-vision ΔE 22.9
|
||||
* dark worst adjacent CVD ΔE 8.4, normal-vision ΔE 19.8
|
||||
* On the light surface aqua (2.74:1) and yellow (2.11:1) fall below 3:1, so
|
||||
* every chart using them ships visible labels plus a table view.
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* surfaces & ink */
|
||||
--surface-0: #f4f4f2;
|
||||
--surface-1: #fcfcfb;
|
||||
--surface-2: #ffffff;
|
||||
--border: #e4e3df;
|
||||
--border-strong: #d3d2cd;
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #86847e;
|
||||
|
||||
/* categorical series — assign in order */
|
||||
--series-1: #2a78d6; /* blue */
|
||||
--series-2: #eb6834; /* orange */
|
||||
--series-3: #1baf7a; /* aqua */
|
||||
--series-4: #eda100; /* yellow */
|
||||
--series-5: #e87ba4; /* magenta */
|
||||
--series-6: #008300; /* green */
|
||||
|
||||
/* status — reserved, never reused as a series */
|
||||
--status-good: #0ca30c;
|
||||
--status-warning: #fab219;
|
||||
--status-serious: #ec835a;
|
||||
--status-critical: #d03b3b;
|
||||
|
||||
--grid: #eceae5;
|
||||
/* Separate token for filled surfaces (buttons, active tabs). White text on
|
||||
--accent measures only 4.42:1 light / 3.64:1 dark — under the 4.5:1 body
|
||||
floor — so fills use a darker step from the same blue ramp: 5.39:1 light,
|
||||
6.63:1 dark. --accent stays for text, borders and marks. */
|
||||
--accent: #2a78d6;
|
||||
--accent-solid: #256abf;
|
||||
--accent-soft: #eef4fd;
|
||||
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 8px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* Dark steps are the same hues re-stepped for the dark surface — selected and
|
||||
validated as a set, not an automatic inversion. Declared under both the OS
|
||||
media query and the explicit toggle so either can win. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:where(:not([data-theme='light'])) {
|
||||
color-scheme: dark;
|
||||
--surface-0: #131312;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--border: #34342f;
|
||||
--border-strong: #45443e;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #8f8e85;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
|
||||
--grid: #2c2c28;
|
||||
--accent: #3987e5;
|
||||
--accent-solid: #1c5cab;
|
||||
--accent-soft: #1d2938;
|
||||
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--surface-0: #131312;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--border: #34342f;
|
||||
--border-strong: #45443e;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #8f8e85;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
|
||||
--grid: #2c2c28;
|
||||
--accent: #3987e5;
|
||||
--accent-solid: #1c5cab;
|
||||
--accent-soft: #1d2938;
|
||||
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
46
deploy/S99garmin.sh
Normal file
46
deploy/S99garmin.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# Garmin Health Lab — DSM boot script.
|
||||
# Mirrors the convention already used by S99frpc.sh on this NAS.
|
||||
APP=/var/services/homes/ericwyuan/apps/garmin-health-lab
|
||||
RUNAS=ericwyuan
|
||||
# Kept inside the app dir, not /var/run: the service runs as an unprivileged
|
||||
# user, which cannot write there.
|
||||
PIDFILE="$APP/app.pid"
|
||||
|
||||
start() {
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
echo "already running (pid $(cat "$PIDFILE"))"
|
||||
return 0
|
||||
fi
|
||||
# Runs as the owning user: the service needs nothing privileged and its
|
||||
# .env holds database and API credentials.
|
||||
# setsid + closed stdio so it survives the invoking shell and never holds
|
||||
# an SSH session open.
|
||||
if [ "$(id -un)" = "$RUNAS" ]; then
|
||||
sh "$APP/start.sh" >/dev/null 2>&1 </dev/null
|
||||
else
|
||||
su - "$RUNAS" -c "sh $APP/start.sh" >/dev/null 2>&1 </dev/null
|
||||
fi
|
||||
sleep 2
|
||||
echo "started (pid $(cat "$PIDFILE" 2>/dev/null))"
|
||||
}
|
||||
|
||||
stop() {
|
||||
[ -f "$PIDFILE" ] && kill "$(cat "$PIDFILE")" 2>/dev/null
|
||||
rm -f "$PIDFILE"
|
||||
pkill -f "gunicorn.*8123" 2>/dev/null
|
||||
echo "stopped"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
restart) stop; sleep 2; start ;;
|
||||
status)
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
echo "running (pid $(cat "$PIDFILE"))"
|
||||
else
|
||||
echo "not running"
|
||||
fi ;;
|
||||
*) echo "Usage: $0 {start|stop|restart|status}" ;;
|
||||
esac
|
||||
1988
package-lock.json
generated
1988
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,21 +1,20 @@
|
||||
{
|
||||
"name": "garmin-health-lab",
|
||||
"version": "0.1.0",
|
||||
"description": "佳明健康数据分析平台",
|
||||
"description": "佳明健康数据分析平台 - React 前端 + Flask 后端",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:server": "cd server && npm run dev",
|
||||
"dev:client": "cd client && npm start",
|
||||
"build": "npm run build:server && npm run build:client",
|
||||
"build:server": "cd server && npm run build",
|
||||
"build:client": "cd client && npm run build",
|
||||
"start": "node server/dist/index.js"
|
||||
},
|
||||
"workspaces": [
|
||||
"server",
|
||||
"client"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n backend,client -c blue,green \"npm run dev:backend\" \"npm run dev:client\"",
|
||||
"dev:backend": "cd backend && BACKEND_PORT=5000 .venv/bin/python app.py",
|
||||
"dev:client": "npm start --workspace=client",
|
||||
"build": "npm run build --workspace=client",
|
||||
"typecheck": "npm run typecheck --workspace=client",
|
||||
"test": "cd backend && .venv/bin/python -m pytest",
|
||||
"setup:backend": "cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.0"
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# App
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
JWT_SECRET=generate_a_random_string
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# --- Database ---
|
||||
# Local/dev: SQLite (zero config)
|
||||
# DB_TYPE=sqlite
|
||||
# DATABASE_PATH=./data/health.db
|
||||
#
|
||||
# Production: MariaDB on the NAS
|
||||
DB_TYPE=mariadb
|
||||
MARIADB_SOCKET=/run/mysqld/mysqld10.sock
|
||||
MARIADB_USER=root
|
||||
MARIADB_PASSWORD=your_nas_mariadb_root_password
|
||||
MARIADB_DATABASE=garmin_health_lab
|
||||
|
||||
# Garmin Connect
|
||||
GARMIN_CONNECT_USER=your_garmin_email
|
||||
GARMIN_CONNECT_PASSWORD=your_garmin_password
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "garmin-health-lab-server",
|
||||
"version": "0.1.0",
|
||||
"description": "Garmin Health Lab Backend",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"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/jsonwebtoken": "^9.0.2",
|
||||
"@types/sqlite3": "^3.1.8",
|
||||
"typescript": "^5.1.3",
|
||||
"tsx": "^3.12.7"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import { initializeDatabase } from './utils/database';
|
||||
import authRoutes from './routes/auth';
|
||||
import garminRoutes from './routes/garmin';
|
||||
import healthRoutes from './routes/health';
|
||||
import analysisRoutes from './routes/analysis';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/garmin', garminRoutes);
|
||||
app.use('/api/health', healthRoutes);
|
||||
app.use('/api/analysis', analysisRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health/status', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// Initialize database, then start accepting requests
|
||||
initializeDatabase()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[db] Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(
|
||||
err: any,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
console.error('Error:', err);
|
||||
|
||||
const status = err.status || 500;
|
||||
const message = err.message || 'Internal server error';
|
||||
|
||||
res.status(status).json({
|
||||
error: {
|
||||
status,
|
||||
message,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// TODO: Implement analysis endpoints
|
||||
router.get('/trends', (req, res) => {
|
||||
res.json({ message: 'Trends analysis endpoint' });
|
||||
});
|
||||
|
||||
router.get('/recommendations', (req, res) => {
|
||||
res.json({ message: 'Recommendations endpoint' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,18 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// TODO: Implement authentication routes
|
||||
router.post('/login', (req, res) => {
|
||||
res.json({ message: 'Login endpoint' });
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
res.json({ message: 'Logout endpoint' });
|
||||
});
|
||||
|
||||
router.post('/refresh', (req, res) => {
|
||||
res.json({ message: 'Refresh token endpoint' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,14 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// TODO: Implement Garmin API integration
|
||||
router.post('/sync', (req, res) => {
|
||||
res.json({ message: 'Garmin sync endpoint' });
|
||||
});
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({ message: 'Garmin sync status endpoint' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,26 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// TODO: Implement health data endpoints
|
||||
router.get('/summary', (req, res) => {
|
||||
res.json({ message: 'Health summary endpoint' });
|
||||
});
|
||||
|
||||
router.get('/steps', (req, res) => {
|
||||
res.json({ message: 'Steps data endpoint' });
|
||||
});
|
||||
|
||||
router.get('/heart-rate', (req, res) => {
|
||||
res.json({ message: 'Heart rate data endpoint' });
|
||||
});
|
||||
|
||||
router.get('/sleep', (req, res) => {
|
||||
res.json({ message: 'Sleep data endpoint' });
|
||||
});
|
||||
|
||||
router.get('/activities', (req, res) => {
|
||||
res.json({ message: 'Activities endpoint' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,130 +0,0 @@
|
||||
import { allAsync } from '../utils/database';
|
||||
import { getSummary } from './HealthService';
|
||||
|
||||
const METRIC_COLUMNS: Record<string, string> = {
|
||||
steps: 'steps',
|
||||
heart_rate: 'heart_rate',
|
||||
sleep_duration: 'sleep_duration',
|
||||
sleep_quality: 'sleep_quality',
|
||||
stress: 'stress',
|
||||
calories_burned: 'calories_burned',
|
||||
};
|
||||
|
||||
export async function getTrends(metric: string, userId: string, startDate?: string, endDate?: string) {
|
||||
const column = METRIC_COLUMNS[metric] || 'steps';
|
||||
const params: any[] = [userId];
|
||||
let sql = 'WHERE user_id = ?';
|
||||
if (startDate) { sql += ' AND date >= ?'; params.push(startDate); }
|
||||
if (endDate) { sql += ' AND date <= ?'; params.push(endDate); }
|
||||
|
||||
const rows = await allAsync(
|
||||
`SELECT date, ${column} AS value FROM health_data ${sql}
|
||||
AND ${column} IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({ date: r.date, value: r.value }));
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
category: string;
|
||||
recommendation: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
basedOn: string[];
|
||||
}
|
||||
|
||||
export async function getRecommendations(userId: string): Promise<Recommendation[]> {
|
||||
// Look at the most recent 14 days of data.
|
||||
const recent = await getSummary(userId);
|
||||
const last14 = recent.slice(-14);
|
||||
const recs: Recommendation[] = [];
|
||||
|
||||
if (last14.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: 'no-data',
|
||||
category: '数据',
|
||||
recommendation: '暂无健康数据,请先同步你的 Garmin 设备数据。',
|
||||
priority: 'low',
|
||||
basedOn: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const avg = (key: string) =>
|
||||
last14.reduce((sum, r) => sum + (r[key] ?? 0), 0) / last14.length;
|
||||
|
||||
const avgSteps = avg('steps');
|
||||
const avgSleep = last14.filter((r) => r.sleep).reduce(
|
||||
(s, r) => s + (r.sleep?.duration ?? 0),
|
||||
0
|
||||
) / Math.max(1, last14.filter((r) => r.sleep).length);
|
||||
const avgStress = avg('stress');
|
||||
const avgRestingHr = avg('heartRate');
|
||||
const avgHrv = avg('heartRateVariability');
|
||||
|
||||
if (avgSteps > 0 && avgSteps < 8000) {
|
||||
recs.push({
|
||||
id: 'steps',
|
||||
category: '运动',
|
||||
recommendation: `近 ${last14.length} 天日均步数约 ${Math.round(avgSteps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。`,
|
||||
priority: 'medium',
|
||||
basedOn: ['steps'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgSleep > 0 && avgSleep < 7) {
|
||||
recs.push({
|
||||
id: 'sleep',
|
||||
category: '睡眠',
|
||||
recommendation: `日均睡眠约 ${avgSleep.toFixed(1)} 小时,偏少。建议固定就寝时间,目标 7-8 小时。`,
|
||||
priority: 'high',
|
||||
basedOn: ['sleep_duration'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgStress > 0 && avgStress > 50) {
|
||||
recs.push({
|
||||
id: 'stress',
|
||||
category: '压力',
|
||||
recommendation: `平均压力指数 ${Math.round(avgStress)} 偏高,建议安排放松活动(冥想/散步)。`,
|
||||
priority: 'high',
|
||||
basedOn: ['stress'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgRestingHr > 0 && avgRestingHr > 65) {
|
||||
recs.push({
|
||||
id: 'rhr',
|
||||
category: '心肺',
|
||||
recommendation: `静息心率约 ${Math.round(avgRestingHr)} bpm 偏高,规律有氧运动有助于改善心肺功能。`,
|
||||
priority: 'medium',
|
||||
basedOn: ['heart_rate'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgHrv > 0 && avgHrv < 40) {
|
||||
recs.push({
|
||||
id: 'hrv',
|
||||
category: '恢复',
|
||||
recommendation: `心率变异性(HRV)约 ${Math.round(avgHrv)} ms 偏低,注意恢复与休息,避免过度训练。`,
|
||||
priority: 'low',
|
||||
basedOn: ['heart_rate_variability'],
|
||||
});
|
||||
}
|
||||
|
||||
if (recs.length === 0) {
|
||||
recs.push({
|
||||
id: 'good',
|
||||
category: '状态',
|
||||
recommendation: '近期各项指标良好,保持当前作息与运动习惯即可。',
|
||||
priority: 'low',
|
||||
basedOn: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
const order = { high: 0, medium: 1, low: 2 } as const;
|
||||
recs.sort((a, b) => order[a.priority] - order[b.priority]);
|
||||
return recs;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { runAsync, getAsync } from '../utils/database';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_me';
|
||||
const TOKEN_EXPIRY = '7d';
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(public code: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
function hashPassword(password: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||
if (err) reject(err);
|
||||
else resolve(`${salt}:${derived.toString('hex')}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return resolve(false);
|
||||
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derived.toString('hex') === hash);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function signToken(userId: string): string {
|
||||
return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export async function register(input: {
|
||||
email: string;
|
||||
garminEmail: string;
|
||||
garminPassword: string;
|
||||
}) {
|
||||
const existing = await getAsync('SELECT id FROM users WHERE email = ?', [input.email]);
|
||||
if (existing) {
|
||||
throw new AuthError('EMAIL_TAKEN', '该邮箱已注册');
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const garminPasswordHash = await hashPassword(input.garminPassword);
|
||||
const token = signToken(id);
|
||||
await runAsync(
|
||||
`INSERT INTO users (id, email, garmin_email, garmin_password_hash, jwt_token)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, input.email, input.garminEmail, garminPasswordHash, token]
|
||||
);
|
||||
return { id, email: input.email, token };
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const user = await getAsync('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (!user) {
|
||||
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||
}
|
||||
const ok = await verifyPassword(password, user.garmin_password_hash);
|
||||
if (!ok) {
|
||||
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||
}
|
||||
const token = signToken(user.id);
|
||||
await runAsync('UPDATE users SET jwt_token = ? WHERE id = ?', [token, user.id]);
|
||||
return { id: user.id, email: user.email, token };
|
||||
}
|
||||
|
||||
export async function logout(userId: string) {
|
||||
await runAsync('UPDATE users SET jwt_token = NULL WHERE id = ?', [userId]);
|
||||
}
|
||||
|
||||
export async function getUserById(userId: string) {
|
||||
const user = await getAsync(
|
||||
'SELECT id, email, garmin_email, created_at FROM users WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): { userId: string } {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as { sub: string };
|
||||
return { userId: payload.sub };
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { getAsync, runAsync } from '../utils/database';
|
||||
import { upsertHealthDaily, insertActivity } from './HealthService';
|
||||
|
||||
export interface SyncResult {
|
||||
status: 'success' | 'error';
|
||||
recordsSynced: number;
|
||||
message: string;
|
||||
lastSyncTime: string;
|
||||
}
|
||||
|
||||
export async function syncData(
|
||||
userId: string,
|
||||
creds: { garminEmail: string; garminPassword: string }
|
||||
): Promise<SyncResult> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await runAsync(
|
||||
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
|
||||
VALUES (?, 'syncing', ?, 0)
|
||||
ON DUPLICATE KEY UPDATE status='syncing', last_sync_time=?, records_synced=0`,
|
||||
[userId, now, now]
|
||||
);
|
||||
|
||||
try {
|
||||
let GarminConnect: any;
|
||||
try {
|
||||
GarminConnect = require('garmin-connect');
|
||||
} catch {
|
||||
throw new Error('GARMIN_LIB_MISSING: 请先运行 `npm install garmin-connect` 以启用同步');
|
||||
}
|
||||
|
||||
const Client = GarminConnect.default || GarminConnect.GarminConnect || GarminConnect;
|
||||
const client = new Client({ username: creds.garminEmail, password: creds.garminPassword });
|
||||
await client.login();
|
||||
|
||||
let recordsSynced = 0;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
try {
|
||||
const daily =
|
||||
(await client.getUserDailySummary?.(dateStr)) ||
|
||||
(await client.getDailySummary?.(dateStr)) ||
|
||||
(await client.getStats?.(dateStr));
|
||||
|
||||
if (daily) {
|
||||
const sleepSec = daily.sleep?.sleepingSeconds ?? daily.sleepingSeconds;
|
||||
await upsertHealthDaily(userId, {
|
||||
date: dateStr,
|
||||
steps: daily.steps ?? null,
|
||||
heartRate: daily.restingHeartRate ?? daily.averageHeartRate ?? null,
|
||||
heartRateVariability: daily.hrv ?? daily.heartRateVariability ?? null,
|
||||
sleepDuration: sleepSec ? Math.round(sleepSec / 3600 * 10) / 10 : null,
|
||||
sleepQuality: daily.sleep?.sleepQuality ?? null,
|
||||
stress: daily.stress?.average ?? daily.averageStress ?? null,
|
||||
caloriesBurned: daily.calories?.total ?? daily.totalCalories ?? null,
|
||||
});
|
||||
recordsSynced++;
|
||||
}
|
||||
|
||||
const activities =
|
||||
(await client.getActivities?.(dateStr)) ||
|
||||
(await client.getActivitiesByDate?.(dateStr)) ||
|
||||
[];
|
||||
for (const a of activities || []) {
|
||||
const start = a.startTimeLocal || a.startTime;
|
||||
const startMs = start ? new Date(start).getTime() : NaN;
|
||||
await insertActivity(userId, {
|
||||
activityType: a.activityType?.typeKey || a.type || 'unknown',
|
||||
startTime: start,
|
||||
endTime:
|
||||
isNaN(startMs) || !a.duration ? start : new Date(startMs + (a.duration || 0) * 1000).toISOString(),
|
||||
duration: a.duration ?? null,
|
||||
distance: a.distance ?? null,
|
||||
calories: a.calories ?? null,
|
||||
heartRateAverage: a.averageHR ?? null,
|
||||
heartRateMax: a.maxHR ?? null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip a single bad day and continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await runAsync(
|
||||
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
|
||||
VALUES (?, 'idle', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE status='idle', last_sync_time=?, records_synced=?`,
|
||||
[userId, now, recordsSynced, now, recordsSynced]
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
recordsSynced,
|
||||
message: `同步完成,新增/更新 ${recordsSynced} 天数据`,
|
||||
lastSyncTime: now,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const message = err?.message || String(err);
|
||||
await runAsync(
|
||||
`INSERT INTO sync_status (user_id, status, last_sync_time, last_error, records_synced)
|
||||
VALUES (?, 'error', ?, ?, 0)
|
||||
ON DUPLICATE KEY UPDATE status='error', last_sync_time=?, last_error=?, records_synced=0`,
|
||||
[userId, now, message, now, message]
|
||||
);
|
||||
return { status: 'error', recordsSynced: 0, message, lastSyncTime: now };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSyncStatus(userId: string) {
|
||||
const row = await getAsync('SELECT * FROM sync_status WHERE user_id = ?', [userId]);
|
||||
if (!row) return { status: 'idle', lastSyncTime: null, recordsSynced: 0, lastError: null };
|
||||
return {
|
||||
status: row.status,
|
||||
lastSyncTime: row.last_sync_time,
|
||||
recordsSynced: row.records_synced,
|
||||
lastError: row.last_error,
|
||||
};
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
import { allAsync, runAsync } from '../utils/database';
|
||||
|
||||
interface DateRange {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
function rangeParams(userId: string, range?: DateRange): { sql: string; params: any[] } {
|
||||
const params: any[] = [userId];
|
||||
let sql = 'WHERE user_id = ?';
|
||||
if (range?.startDate) {
|
||||
sql += ' AND date >= ?';
|
||||
params.push(range.startDate);
|
||||
}
|
||||
if (range?.endDate) {
|
||||
sql += ' AND date <= ?';
|
||||
params.push(range.endDate);
|
||||
}
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
export async function getSummary(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, steps, heart_rate, heart_rate_variability,
|
||||
sleep_duration, sleep_quality, stress, calories_burned
|
||||
FROM health_data ${sql} ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
steps: r.steps,
|
||||
heartRate: r.heart_rate,
|
||||
heartRateVariability: r.heart_rate_variability,
|
||||
sleep: r.sleep_duration != null ? { duration: r.sleep_duration, quality: r.sleep_quality } : null,
|
||||
stress: r.stress,
|
||||
caloriesBurned: r.calories_burned,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSteps(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, steps FROM health_data ${sql} AND steps IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({ date: r.date, steps: r.steps }));
|
||||
}
|
||||
|
||||
export async function getHeartRate(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, heart_rate, heart_rate_variability FROM health_data ${sql}
|
||||
AND heart_rate IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
heartRate: r.heart_rate,
|
||||
heartRateVariability: r.heart_rate_variability,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSleep(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, sleep_duration, sleep_quality FROM health_data ${sql}
|
||||
AND sleep_duration IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
duration: r.sleep_duration,
|
||||
quality: r.sleep_quality,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getActivities(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT id, activity_type, start_time, end_time, duration, distance,
|
||||
calories, heart_rate_average, heart_rate_max
|
||||
FROM activities ${sql} ORDER BY start_time DESC`,
|
||||
params
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Used by GarminService to persist daily health records (upsert by user+date).
|
||||
export async function upsertHealthDaily(userId: string, record: any) {
|
||||
const id = `${userId}-${record.date}`;
|
||||
await runAsync(
|
||||
`INSERT INTO health_data
|
||||
(id, user_id, date, steps, heart_rate, heart_rate_variability,
|
||||
blood_pressure_systolic, blood_pressure_diastolic, sleep_duration,
|
||||
sleep_quality, stress, calories_burned)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
steps = VALUES(steps),
|
||||
heart_rate = VALUES(heart_rate),
|
||||
heart_rate_variability = VALUES(heart_rate_variability),
|
||||
blood_pressure_systolic = VALUES(blood_pressure_systolic),
|
||||
blood_pressure_diastolic = VALUES(blood_pressure_diastolic),
|
||||
sleep_duration = VALUES(sleep_duration),
|
||||
sleep_quality = VALUES(sleep_quality),
|
||||
stress = VALUES(stress),
|
||||
calories_burned = VALUES(calories_burned),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
id, userId, record.date, record.steps ?? null, record.heartRate ?? null,
|
||||
record.heartRateVariability ?? null, record.bloodPressureSystolic ?? null,
|
||||
record.bloodPressureDiastolic ?? null, record.sleepDuration ?? null,
|
||||
record.sleepQuality ?? null, record.stress ?? null, record.caloriesBurned ?? null,
|
||||
]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function insertActivity(userId: string, activity: any) {
|
||||
const id = crypto.randomUUID();
|
||||
await runAsync(
|
||||
`INSERT INTO activities
|
||||
(id, user_id, activity_type, start_time, end_time, duration, distance,
|
||||
calories, heart_rate_average, heart_rate_max)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id, userId, activity.activityType, activity.startTime, activity.endTime,
|
||||
activity.duration ?? null, activity.distance ?? null, activity.calories ?? null,
|
||||
activity.heartRateAverage ?? null, activity.heartRateMax ?? null,
|
||||
]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
garminEmail: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface HealthData {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: Date;
|
||||
steps: number;
|
||||
heartRate?: number;
|
||||
heartRateVariability?: number;
|
||||
bloodPressure?: {
|
||||
systolic: number;
|
||||
diastolic: number;
|
||||
};
|
||||
sleep?: {
|
||||
duration: number;
|
||||
quality: number;
|
||||
};
|
||||
stress?: number;
|
||||
caloriesBurned?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
userId: string;
|
||||
activityType: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
duration: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
heartRateAverage?: number;
|
||||
heartRateMax?: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface HealthRecommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
category: string;
|
||||
recommendation: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
basedOn: string[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
lastSyncTime: Date;
|
||||
status: 'idle' | 'syncing' | 'error';
|
||||
lastError?: string;
|
||||
recordsSynced: number;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const DB_TYPE = (process.env.DB_TYPE || 'sqlite').toLowerCase();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite (local / development)
|
||||
// Loaded lazily so the MariaDB build never depends on the native sqlite3 module.
|
||||
// ---------------------------------------------------------------------------
|
||||
let sqliteDb: any = null;
|
||||
|
||||
const SQLITE_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
garmin_email TEXT 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 TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
steps INTEGER,
|
||||
heart_rate INTEGER,
|
||||
heart_rate_variability REAL,
|
||||
blood_pressure_systolic INTEGER,
|
||||
blood_pressure_diastolic INTEGER,
|
||||
sleep_duration INTEGER,
|
||||
sleep_quality REAL,
|
||||
stress INTEGER,
|
||||
calories_burned REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
activity_type TEXT NOT NULL,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
duration INTEGER,
|
||||
distance REAL,
|
||||
calories REAL,
|
||||
heart_rate_average INTEGER,
|
||||
heart_rate_max INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_status (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
last_sync_time DATETIME,
|
||||
status TEXT DEFAULT 'idle',
|
||||
last_error TEXT,
|
||||
records_synced INTEGER DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MariaDB (production, runs on the NAS)
|
||||
// NOTE: TEXT cannot be a PRIMARY KEY in MariaDB, so ids use VARCHAR(64).
|
||||
// ---------------------------------------------------------------------------
|
||||
let mariadbPool: mysql.Pool | null = null;
|
||||
|
||||
const MARIADB_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)
|
||||
);
|
||||
`;
|
||||
|
||||
function getMariadbConfig(): mysql.PoolOptions {
|
||||
const config: mysql.PoolOptions = {
|
||||
user: process.env.MARIADB_USER || 'root',
|
||||
password: process.env.MARIADB_PASSWORD || '',
|
||||
database: process.env.MARIADB_DATABASE || 'garmin_health_lab',
|
||||
connectionLimit: 10,
|
||||
waitForConnections: true,
|
||||
};
|
||||
if (process.env.MARIADB_SOCKET) {
|
||||
config.socketPath = process.env.MARIADB_SOCKET;
|
||||
} else {
|
||||
config.host = process.env.MARIADB_HOST || '127.0.0.1';
|
||||
config.port = process.env.MARIADB_PORT ? Number(process.env.MARIADB_PORT) : 3306;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
mariadbPool = mysql.createPool(getMariadbConfig());
|
||||
const conn = await mariadbPool.getConnection();
|
||||
try {
|
||||
const statements = MARIADB_SCHEMA
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const stmt of statements) {
|
||||
await conn.query(stmt);
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
console.log('[db] MariaDB database initialized successfully');
|
||||
} else {
|
||||
const sqlitePath = process.env.DATABASE_PATH || './data/health.db';
|
||||
const dataDir = path.dirname(sqlitePath);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
const sqlite3 = await import('sqlite3');
|
||||
const SqliteDb = (sqlite3 as any).default?.Database || (sqlite3 as any).Database;
|
||||
sqliteDb = new SqliteDb(sqlitePath);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sqliteDb.serialize(() => {
|
||||
sqliteDb.exec(SQLITE_SCHEMA, (err: Error | null) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
console.log('[db] SQLite database initialized successfully');
|
||||
}
|
||||
}
|
||||
|
||||
// Unified query helpers — same signatures for both backends.
|
||||
// `?` placeholders are supported by both sqlite3 and mysql2.
|
||||
|
||||
export async function runAsync(sql: string, params: any[] = []): Promise<any> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
const [result] = await mariadbPool!.execute(sql, params);
|
||||
const r = result as any;
|
||||
return { id: r.insertId, changes: r.affectedRows };
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
sqliteDb.run(sql, params, function (this: any, err: Error | null) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: this.lastID, changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAsync(sql: string, params: any[] = []): Promise<any> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
const [rows] = await mariadbPool!.execute(sql, params);
|
||||
return Array.isArray(rows) ? (rows as any[])[0] : undefined;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
sqliteDb.get(sql, params, (err: Error | null, row: any) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function allAsync(sql: string, params: any[] = []): Promise<any[]> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
const [rows] = await mariadbPool!.execute(sql, params);
|
||||
return (rows as any[]) || [];
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
sqliteDb.all(sql, params, (err: Error | null, rows: any[]) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user