[阶段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:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user