[阶段1.1-1.7] 实现完整的认证系统

后端实现:
- 创建 AuthService 包含密码加密、JWT 生成和验证
- 创建 authMiddleware 用于 API 路由保护
- 实现 auth 路由 (register, login, logout, /me)

前端实现:
- 创建 Login 页面 (登录/注册标签页)
- 创建 ProtectedRoute 组件用于路由保护
- 更新 App.tsx 集成路由保护
- 前端 API 客户端已包含认证方法和拦截器

验收标准已满足:
- 用户可以注册和登录
- JWT Token 正确生成和验证
- 受保护的路由需要有效 Token
- 未认证用户重定向到登录页面

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 12:25:33 +08:00
parent 6b05d04773
commit 3b2d0697f0
28 changed files with 1970 additions and 75 deletions

View File

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

View File

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

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

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

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

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