[阶段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:
6
backend/services/__init__.py
Normal file
6
backend/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Business logic services for Garmin Health Lab."""
|
||||
from . import health
|
||||
from . import analysis
|
||||
from . import garmin
|
||||
|
||||
__all__ = ["health", "analysis", "garmin"]
|
||||
119
backend/services/analysis.py
Normal file
119
backend/services/analysis.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Analysis service: metric trends + a rule-based recommendation engine.
|
||||
|
||||
Replicates the original Node AnalysisService logic. Averages are computed over
|
||||
the most recent 14 days of available daily summaries.
|
||||
"""
|
||||
from services import health
|
||||
from db import query_all
|
||||
|
||||
METRIC_COLUMNS = {
|
||||
"steps": "steps",
|
||||
"heart_rate": "heart_rate",
|
||||
"sleep_duration": "sleep_duration",
|
||||
"sleep_quality": "sleep_quality",
|
||||
"stress": "stress",
|
||||
"calories_burned": "calories_burned",
|
||||
}
|
||||
|
||||
|
||||
def get_trends(metric, user_id, start=None, end=None):
|
||||
column = METRIC_COLUMNS.get(metric, "steps")
|
||||
params = [user_id]
|
||||
sql = "WHERE user_id = ?"
|
||||
if start:
|
||||
sql += " AND date >= ?"
|
||||
params.append(start)
|
||||
if end:
|
||||
sql += " AND date <= ?"
|
||||
params.append(end)
|
||||
rows = query_all(
|
||||
f"SELECT date, {column} AS value FROM health_data {sql} "
|
||||
f"AND {column} IS NOT NULL ORDER BY date ASC",
|
||||
params,
|
||||
)
|
||||
return [{"date": r["date"], "value": r["value"]} for r in rows]
|
||||
|
||||
|
||||
def get_recommendations(user_id):
|
||||
recent = health.get_summary(user_id)
|
||||
last14 = recent[-14:]
|
||||
recs = []
|
||||
|
||||
if not last14:
|
||||
return [
|
||||
{
|
||||
"id": "no-data",
|
||||
"category": "数据",
|
||||
"recommendation": "暂无健康数据,请先同步你的 Garmin 设备数据。",
|
||||
"priority": "low",
|
||||
"basedOn": [],
|
||||
}
|
||||
]
|
||||
|
||||
avg = lambda key: sum((r.get(key) or 0) for r in last14) / len(last14)
|
||||
|
||||
avg_steps = avg("steps")
|
||||
sleep_rows = [r["sleep"]["duration"] for r in last14 if r.get("sleep")]
|
||||
avg_sleep = sum(sleep_rows) / len(sleep_rows) if sleep_rows else 0
|
||||
avg_stress = avg("stress")
|
||||
avg_rhr = avg("heartRate")
|
||||
avg_hrv = avg("heartRateVariability")
|
||||
|
||||
if avg_steps > 0 and avg_steps < 8000:
|
||||
recs.append({
|
||||
"id": "steps",
|
||||
"category": "运动",
|
||||
"recommendation": f"近 {len(last14)} 天日均步数约 {round(avg_steps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。",
|
||||
"priority": "medium",
|
||||
"basedOn": ["steps"],
|
||||
})
|
||||
|
||||
if avg_sleep > 0 and avg_sleep < 7:
|
||||
recs.append({
|
||||
"id": "sleep",
|
||||
"category": "睡眠",
|
||||
"recommendation": f"日均睡眠约 {avg_sleep:.1f} 小时,偏少。建议固定就寝时间,目标 7-8 小时。",
|
||||
"priority": "high",
|
||||
"basedOn": ["sleep_duration"],
|
||||
})
|
||||
|
||||
if avg_stress > 0 and avg_stress > 50:
|
||||
recs.append({
|
||||
"id": "stress",
|
||||
"category": "压力",
|
||||
"recommendation": f"平均压力指数 {round(avg_stress)} 偏高,建议安排放松活动(冥想/散步)。",
|
||||
"priority": "high",
|
||||
"basedOn": ["stress"],
|
||||
})
|
||||
|
||||
if avg_rhr > 0 and avg_rhr > 65:
|
||||
recs.append({
|
||||
"id": "rhr",
|
||||
"category": "心肺",
|
||||
"recommendation": f"静息心率约 {round(avg_rhr)} bpm 偏高,规律有氧运动有助于改善心肺功能。",
|
||||
"priority": "medium",
|
||||
"basedOn": ["heart_rate"],
|
||||
})
|
||||
|
||||
if avg_hrv > 0 and avg_hrv < 40:
|
||||
recs.append({
|
||||
"id": "hrv",
|
||||
"category": "恢复",
|
||||
"recommendation": f"心率变异性(HRV)约 {round(avg_hrv)} ms 偏低,注意恢复与休息,避免过度训练。",
|
||||
"priority": "low",
|
||||
"basedOn": ["heart_rate_variability"],
|
||||
})
|
||||
|
||||
if not recs:
|
||||
recs.append({
|
||||
"id": "good",
|
||||
"category": "状态",
|
||||
"recommendation": "近期各项指标良好,保持当前作息与运动习惯即可。",
|
||||
"priority": "low",
|
||||
"basedOn": [],
|
||||
})
|
||||
|
||||
order = {"high": 0, "medium": 1, "low": 2}
|
||||
recs.sort(key=lambda r: order[r["priority"]])
|
||||
return recs
|
||||
150
backend/services/garmin.py
Normal file
150
backend/services/garmin.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Garmin sync service.
|
||||
|
||||
Pulls up to 7 days of daily summaries + activities through the `garminconnect`
|
||||
library and upserts them. The library and real Garmin credentials are required
|
||||
to actually run a sync; without them the endpoint reports a clear error instead
|
||||
of crashing (mirrors the original Node behaviour).
|
||||
|
||||
Garmin credentials: the app only stores a scrypt *hash* of the Garmin password
|
||||
(so it cannot be recovered), therefore a live sync needs the plaintext
|
||||
garminEmail/garminPassword supplied in the request body.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from db import execute, query_one, query_all
|
||||
from config import DB_TYPE
|
||||
from services import health
|
||||
|
||||
|
||||
def _set_sync_status(user_id, status, now, **fields):
|
||||
cols = ["user_id", "status", "last_sync_time"] + list(fields.keys())
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
if DB_TYPE == "mariadb":
|
||||
updates = ", ".join(
|
||||
f"{c}=VALUES({c})" for c in cols if c != "user_id"
|
||||
)
|
||||
sql = (
|
||||
f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON DUPLICATE KEY UPDATE {updates}"
|
||||
)
|
||||
else:
|
||||
updates = ", ".join(
|
||||
f"{c}=excluded.{c}" for c in cols if c != "user_id"
|
||||
)
|
||||
sql = (
|
||||
f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT(user_id) DO UPDATE SET {updates}"
|
||||
)
|
||||
params = [user_id, status, now] + list(fields.values())
|
||||
execute(sql, params)
|
||||
|
||||
|
||||
def get_sync_status(user_id):
|
||||
row = query_one("SELECT * FROM sync_status WHERE user_id = ?", [user_id])
|
||||
if not row:
|
||||
return {
|
||||
"status": "idle",
|
||||
"lastSyncTime": None,
|
||||
"recordsSynced": 0,
|
||||
"lastError": None,
|
||||
}
|
||||
return {
|
||||
"status": row["status"],
|
||||
"lastSyncTime": row["last_sync_time"],
|
||||
"recordsSynced": row["records_synced"],
|
||||
"lastError": row["last_error"],
|
||||
}
|
||||
|
||||
|
||||
def sync_data(user_id, creds):
|
||||
now = datetime.datetime.utcnow().isoformat()
|
||||
_set_sync_status(user_id, "syncing", now, records_synced=0)
|
||||
|
||||
try:
|
||||
try:
|
||||
from garminconnect import Garmin
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"GARMIN_LIB_MISSING: 请先运行 `pip install garminconnect` 以启用同步"
|
||||
)
|
||||
|
||||
client = Garmin(email=creds["garminEmail"], password=creds["garminPassword"])
|
||||
client.login()
|
||||
|
||||
records_synced = 0
|
||||
for i in range(7):
|
||||
d = datetime.datetime.utcnow() - datetime.timedelta(days=i)
|
||||
date_str = d.strftime("%Y-%m-%d")
|
||||
try:
|
||||
daily = client.get_user_summary(date_str)
|
||||
if daily:
|
||||
sleep_sec = (daily.get("sleep") or {}).get("sleepingSeconds") or daily.get(
|
||||
"sleepingSeconds"
|
||||
)
|
||||
health.upsert_health_daily(
|
||||
user_id,
|
||||
{
|
||||
"date": date_str,
|
||||
"steps": daily.get("steps"),
|
||||
"heartRate": daily.get("restingHeartRate")
|
||||
or daily.get("averageHeartRate"),
|
||||
"heartRateVariability": daily.get("hrv")
|
||||
or daily.get("heartRateVariability"),
|
||||
"sleepDuration": round(sleep_sec / 3600, 1) if sleep_sec else None,
|
||||
"sleepQuality": (daily.get("sleep") or {}).get("sleepQuality"),
|
||||
"stress": (daily.get("stress") or {}).get("average")
|
||||
or daily.get("averageStress"),
|
||||
"caloriesBurned": (daily.get("calories") or {}).get("total")
|
||||
or daily.get("totalCalories"),
|
||||
},
|
||||
)
|
||||
records_synced += 1
|
||||
|
||||
activities = client.get_activities(date_str) or []
|
||||
for a in activities or []:
|
||||
start = a.get("startTimeLocal") or a.get("startTime")
|
||||
start_ms = start and datetime.datetime.strptime(
|
||||
start, "%Y-%m-%dT%H:%M:%S" if "T" in (start or "") else "%Y-%m-%d %H:%M:%S"
|
||||
).timestamp() if start else None
|
||||
health.insert_activity(
|
||||
user_id,
|
||||
{
|
||||
"activityType": (a.get("activityType") or {}).get("typeKey")
|
||||
or a.get("type")
|
||||
or "unknown",
|
||||
"startTime": start,
|
||||
"endTime": (
|
||||
start
|
||||
if start_ms is None or not a.get("duration")
|
||||
else datetime.datetime.utcfromtimestamp(
|
||||
start_ms + (a.get("duration") or 0)
|
||||
).isoformat()
|
||||
),
|
||||
"duration": a.get("duration"),
|
||||
"distance": a.get("distance"),
|
||||
"calories": a.get("calories"),
|
||||
"heartRateAverage": a.get("averageHR"),
|
||||
"heartRateMax": a.get("maxHR"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
# skip a single bad day and continue
|
||||
continue
|
||||
|
||||
_set_sync_status(user_id, "idle", now, records_synced=records_synced)
|
||||
return {
|
||||
"status": "success",
|
||||
"recordsSynced": records_synced,
|
||||
"message": f"同步完成,新增/更新 {records_synced} 天数据",
|
||||
"lastSyncTime": now,
|
||||
}
|
||||
except Exception as e:
|
||||
message = str(e)
|
||||
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
|
||||
return {
|
||||
"status": "error",
|
||||
"recordsSynced": 0,
|
||||
"message": message,
|
||||
"lastSyncTime": now,
|
||||
}
|
||||
153
backend/services/health.py
Normal file
153
backend/services/health.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Health data service: read endpoints + upsert helpers used by the Garmin sync.
|
||||
|
||||
Mirrors the original Node HealthService, including the camelCase JSON mapping.
|
||||
Upserts use backend-specific SQL because SQLite does not support
|
||||
`ON DUPLICATE KEY UPDATE` (it uses `ON CONFLICT ... DO UPDATE`).
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from db import execute, query_one, query_all
|
||||
from config import DB_TYPE
|
||||
|
||||
|
||||
def _range_sql(user_id, start=None, end=None):
|
||||
params = [user_id]
|
||||
sql = "WHERE user_id = ?"
|
||||
if start:
|
||||
sql += " AND date >= ?"
|
||||
params.append(start)
|
||||
if end:
|
||||
sql += " AND date <= ?"
|
||||
params.append(end)
|
||||
return sql, params
|
||||
|
||||
|
||||
def get_summary(user_id, start=None, end=None):
|
||||
sql, params = _range_sql(user_id, start, end)
|
||||
rows = query_all(
|
||||
"SELECT date, steps, heart_rate, heart_rate_variability, "
|
||||
"sleep_duration, sleep_quality, stress, calories_burned "
|
||||
f"FROM health_data {sql} ORDER BY date ASC",
|
||||
params,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"date": r["date"],
|
||||
"steps": r["steps"],
|
||||
"heartRate": r["heart_rate"],
|
||||
"heartRateVariability": r["heart_rate_variability"],
|
||||
"sleep": (
|
||||
{"duration": r["sleep_duration"], "quality": r["sleep_quality"]}
|
||||
if r["sleep_duration"] is not None
|
||||
else None
|
||||
),
|
||||
"stress": r["stress"],
|
||||
"caloriesBurned": r["calories_burned"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_steps(user_id, start=None, end=None):
|
||||
sql, params = _range_sql(user_id, start, end)
|
||||
rows = query_all(
|
||||
f"SELECT date, steps FROM health_data {sql} AND steps IS NOT NULL ORDER BY date ASC",
|
||||
params,
|
||||
)
|
||||
return [{"date": r["date"], "steps": r["steps"]} for r in rows]
|
||||
|
||||
|
||||
def get_heart_rate(user_id, start=None, end=None):
|
||||
sql, params = _range_sql(user_id, start, end)
|
||||
rows = query_all(
|
||||
f"SELECT date, heart_rate, heart_rate_variability FROM health_data {sql} "
|
||||
"AND heart_rate IS NOT NULL ORDER BY date ASC",
|
||||
params,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"date": r["date"],
|
||||
"heartRate": r["heart_rate"],
|
||||
"heartRateVariability": r["heart_rate_variability"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_sleep(user_id, start=None, end=None):
|
||||
sql, params = _range_sql(user_id, start, end)
|
||||
rows = query_all(
|
||||
f"SELECT date, sleep_duration, sleep_quality FROM health_data {sql} "
|
||||
"AND sleep_duration IS NOT NULL ORDER BY date ASC",
|
||||
params,
|
||||
)
|
||||
return [
|
||||
{"date": r["date"], "duration": r["sleep_duration"], "quality": r["sleep_quality"]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_activities(user_id, start=None, end=None):
|
||||
sql, params = _range_sql(user_id, start, end)
|
||||
rows = query_all(
|
||||
"SELECT id, activity_type, start_time, end_time, duration, distance, "
|
||||
"calories, heart_rate_average, heart_rate_max "
|
||||
f"FROM activities {sql} ORDER BY start_time DESC",
|
||||
params,
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def upsert_health_daily(user_id, record):
|
||||
hid = f"{user_id}-{record['date']}"
|
||||
cols = [
|
||||
"id", "user_id", "date", "steps", "heart_rate",
|
||||
"heart_rate_variability", "blood_pressure_systolic",
|
||||
"blood_pressure_diastolic", "sleep_duration", "sleep_quality",
|
||||
"stress", "calories_burned",
|
||||
]
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
vals = [
|
||||
hid, user_id, record.get("date"), record.get("steps"),
|
||||
record.get("heartRate"), record.get("heartRateVariability"),
|
||||
record.get("bloodPressureSystolic"), record.get("bloodPressureDiastolic"),
|
||||
record.get("sleepDuration"), record.get("sleepQuality"),
|
||||
record.get("stress"), record.get("caloriesBurned"),
|
||||
]
|
||||
if DB_TYPE == "mariadb":
|
||||
update_cols = [c for c in cols if c not in ("id", "user_id")]
|
||||
updates = ", ".join([f"{c}=VALUES({c})" for c in update_cols])
|
||||
sql = (
|
||||
f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON DUPLICATE KEY UPDATE {updates}, updated_at=CURRENT_TIMESTAMP"
|
||||
)
|
||||
else:
|
||||
update_cols = [c for c in cols if c not in ("id", "user_id")]
|
||||
updates = ", ".join([f"{c}=excluded.{c}" for c in update_cols])
|
||||
sql = (
|
||||
f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT(user_id, date) DO UPDATE SET {updates}, updated_at=CURRENT_TIMESTAMP"
|
||||
)
|
||||
execute(sql, vals)
|
||||
return hid
|
||||
|
||||
|
||||
def insert_activity(user_id, activity):
|
||||
aid = str(uuid.uuid4())
|
||||
cols = [
|
||||
"id", "user_id", "activity_type", "start_time", "end_time",
|
||||
"duration", "distance", "calories", "heart_rate_average", "heart_rate_max",
|
||||
]
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
vals = [
|
||||
aid, user_id, activity.get("activityType"), activity.get("startTime"),
|
||||
activity.get("endTime"), activity.get("duration"), activity.get("distance"),
|
||||
activity.get("calories"), activity.get("heartRateAverage"),
|
||||
activity.get("heartRateMax"),
|
||||
]
|
||||
execute(
|
||||
f"INSERT INTO activities ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
vals,
|
||||
)
|
||||
return aid
|
||||
Reference in New Issue
Block a user