后端实现: - 创建 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>
151 lines
5.9 KiB
Python
151 lines
5.9 KiB
Python
"""
|
|
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,
|
|
}
|