Port NomadCNA city_details/content/routes seeding into PocketBase and surface full destination pages with related meetups and discussions. Co-authored-by: Cursor <cursoragent@cursor.com>
384 lines
10 KiB
Python
384 lines
10 KiB
Python
"""Auth + favorites + plans — PocketBase backed (nomad_accounts / nomad_sessions)."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import secrets
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from app.services.pb_client import pb, pb_quote
|
|
from app.services.pb_repo import safe_create, safe_first, safe_update, use_pb
|
|
|
|
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "user_store.json"
|
|
|
|
# In-memory fallback when PocketBase unavailable (dev only)
|
|
_users: dict[str, dict] = {}
|
|
_sessions: dict[str, str] = {}
|
|
_favorites: dict[str, list[str]] = {}
|
|
_plans: dict[str, dict] = {}
|
|
|
|
|
|
def _hash_password(password: str) -> str:
|
|
return hashlib.sha256(password.encode()).hexdigest()
|
|
|
|
|
|
def _empty_plan() -> dict[str, Any]:
|
|
return {
|
|
"items": [],
|
|
"meta": {
|
|
"title": "我的旅居计划",
|
|
"startMonth": "",
|
|
"monthlyBudget": 0,
|
|
"checklist": {},
|
|
},
|
|
"updated_at": 0,
|
|
}
|
|
|
|
|
|
def _user_profile(user: dict) -> dict:
|
|
return {
|
|
"id": user["id"],
|
|
"email": user["email"],
|
|
"name": user["name"],
|
|
"avatar": user.get("avatar", "🧑💻"),
|
|
}
|
|
|
|
|
|
def _account_from_pb(row: dict) -> dict:
|
|
return {
|
|
"pb_id": row["id"],
|
|
"id": row.get("legacyUserId") or row["id"],
|
|
"email": row.get("email", ""),
|
|
"password_hash": row.get("passwordHash", ""),
|
|
"name": row.get("name", ""),
|
|
"avatar": row.get("avatar", "🧑💻"),
|
|
"favorites": row.get("favorites") or [],
|
|
"plan": row.get("plan") or _empty_plan(),
|
|
}
|
|
|
|
|
|
def _migrate_json_to_pb() -> None:
|
|
if not STORE_PATH.exists() or not use_pb():
|
|
return
|
|
try:
|
|
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return
|
|
if pb.list_records("nomad_accounts", per_page=1).get("totalItems", 0) > 0:
|
|
return
|
|
for email, user in (data.get("users") or {}).items():
|
|
uid = user.get("id", secrets.token_hex(8))
|
|
pb.create_record(
|
|
"nomad_accounts",
|
|
{
|
|
"email": email,
|
|
"passwordHash": user.get("password_hash", ""),
|
|
"name": user.get("name", ""),
|
|
"avatar": user.get("avatar", "🧑💻"),
|
|
"legacyUserId": uid,
|
|
"favorites": data.get("favorites", {}).get(uid, []),
|
|
"plan": data.get("plans", {}).get(uid, _empty_plan()),
|
|
},
|
|
)
|
|
for token, uid in (data.get("sessions") or {}).items():
|
|
pb.create_record(
|
|
"nomad_sessions",
|
|
{"token": token, "userId": uid, "expiresAt": int(time.time()) + 86400 * 30},
|
|
)
|
|
|
|
|
|
def _restore_json() -> bool:
|
|
if not STORE_PATH.exists():
|
|
return False
|
|
try:
|
|
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
global _users, _sessions, _favorites, _plans
|
|
_users = data.get("users") or {}
|
|
_sessions = data.get("sessions") or {}
|
|
_favorites = data.get("favorites") or {}
|
|
_plans = data.get("plans") or {}
|
|
return True
|
|
|
|
|
|
def _persist_json() -> None:
|
|
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
STORE_PATH.write_text(
|
|
json.dumps({
|
|
"users": _users,
|
|
"sessions": _sessions,
|
|
"favorites": _favorites,
|
|
"plans": _plans,
|
|
}, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _get_account_by_email(email: str) -> dict | None:
|
|
row = safe_first("nomad_accounts", filter=f"email={pb_quote(email)}")
|
|
return _account_from_pb(row) if row else None
|
|
|
|
|
|
def _get_account_by_id(user_id: str) -> dict | None:
|
|
row = safe_first("nomad_accounts", filter=f"legacyUserId={pb_quote(user_id)}")
|
|
if not row:
|
|
row = safe_first("nomad_accounts", filter=f"id={pb_quote(user_id)}")
|
|
return _account_from_pb(row) if row else None
|
|
|
|
|
|
def _create_session(user_id: str) -> str:
|
|
token = secrets.token_urlsafe(32)
|
|
if use_pb():
|
|
safe_create(
|
|
"nomad_sessions",
|
|
{"token": token, "userId": user_id, "expiresAt": int(time.time()) + 86400 * 30},
|
|
)
|
|
else:
|
|
_sessions[token] = user_id
|
|
_persist_json()
|
|
return token
|
|
|
|
|
|
def _issue_login(user: dict) -> dict[str, Any]:
|
|
token = _create_session(user["id"])
|
|
return {"token": token, "user": _user_profile(user)}
|
|
|
|
|
|
def register_user(email: str, password: str, name: str) -> dict[str, Any] | None:
|
|
if use_pb():
|
|
_migrate_json_to_pb()
|
|
if _get_account_by_email(email):
|
|
return None
|
|
uid = secrets.token_hex(8)
|
|
row = safe_create(
|
|
"nomad_accounts",
|
|
{
|
|
"email": email,
|
|
"passwordHash": _hash_password(password),
|
|
"name": name,
|
|
"avatar": "🧑💻",
|
|
"legacyUserId": uid,
|
|
"favorites": [],
|
|
"plan": _empty_plan(),
|
|
},
|
|
)
|
|
if not row:
|
|
return None
|
|
return _issue_login(_account_from_pb(row))
|
|
|
|
if email in _users:
|
|
return None
|
|
uid = secrets.token_hex(8)
|
|
_users[email] = {
|
|
"id": uid,
|
|
"email": email,
|
|
"password_hash": _hash_password(password),
|
|
"name": name,
|
|
"avatar": "🧑💻",
|
|
}
|
|
_favorites[uid] = []
|
|
_plans[uid] = _empty_plan()
|
|
_persist_json()
|
|
return _issue_login(_users[email])
|
|
|
|
|
|
def login_user(email: str, password: str) -> dict[str, Any] | None:
|
|
if use_pb():
|
|
user = _get_account_by_email(email)
|
|
if not user or user["password_hash"] != _hash_password(password):
|
|
return None
|
|
return _issue_login(user)
|
|
|
|
user = _users.get(email)
|
|
if not user or user["password_hash"] != _hash_password(password):
|
|
return None
|
|
return _issue_login(user)
|
|
|
|
|
|
def login_google(email: str, name: str) -> dict[str, Any] | None:
|
|
if use_pb():
|
|
user = _get_account_by_email(email)
|
|
if not user:
|
|
uid = secrets.token_hex(8)
|
|
row = safe_create(
|
|
"nomad_accounts",
|
|
{
|
|
"email": email,
|
|
"passwordHash": _hash_password(secrets.token_hex(16)),
|
|
"name": name or email.split("@")[0],
|
|
"avatar": "🌐",
|
|
"legacyUserId": uid,
|
|
"favorites": [],
|
|
"plan": _empty_plan(),
|
|
},
|
|
)
|
|
if not row:
|
|
return None
|
|
user = _account_from_pb(row)
|
|
return _issue_login(user)
|
|
|
|
if email not in _users:
|
|
register_user(email, secrets.token_hex(16), name or email.split("@")[0])
|
|
return _issue_login(_users[email])
|
|
|
|
|
|
def login_demo() -> dict[str, Any] | None:
|
|
email = "demo@nomadro.com"
|
|
if use_pb():
|
|
user = _get_account_by_email(email)
|
|
if not user:
|
|
return register_user(email, "demo123", "演示用户")
|
|
return _issue_login(user)
|
|
|
|
if email not in _users:
|
|
return register_user(email, "demo123", "演示用户")
|
|
return _issue_login(_users[email])
|
|
|
|
|
|
def get_user_by_token(token: str) -> dict | None:
|
|
if use_pb():
|
|
row = safe_first("nomad_sessions", filter=f"token={pb_quote(token)}")
|
|
if not row:
|
|
return None
|
|
exp = int(row.get("expiresAt") or 0)
|
|
if exp and exp < int(time.time()):
|
|
return None
|
|
user = _get_account_by_id(row.get("userId", ""))
|
|
return _user_profile(user) if user else None
|
|
|
|
uid = _sessions.get(token)
|
|
if not uid and _restore_json():
|
|
uid = _sessions.get(token)
|
|
if not uid:
|
|
return None
|
|
for user in _users.values():
|
|
if user["id"] == uid:
|
|
return _user_profile(user)
|
|
return None
|
|
|
|
|
|
def _token_user_id(token: str) -> str | None:
|
|
user = get_user_by_token(token)
|
|
return user["id"] if user else None
|
|
|
|
|
|
def get_favorites(token: str) -> list[str]:
|
|
uid = _token_user_id(token)
|
|
if not uid:
|
|
return []
|
|
if use_pb():
|
|
user = _get_account_by_id(uid)
|
|
return list(user.get("favorites") or []) if user else []
|
|
return list(_favorites.get(uid, []))
|
|
|
|
|
|
def toggle_favorite(token: str, slug: str) -> list[str]:
|
|
uid = _token_user_id(token)
|
|
if not uid:
|
|
return []
|
|
if use_pb():
|
|
user = _get_account_by_id(uid)
|
|
if not user:
|
|
return []
|
|
favs = list(user.get("favorites") or [])
|
|
if slug in favs:
|
|
favs.remove(slug)
|
|
else:
|
|
favs.append(slug)
|
|
safe_update("nomad_accounts", user["pb_id"], {"favorites": favs})
|
|
return favs
|
|
|
|
favs = _favorites.setdefault(uid, [])
|
|
if slug in favs:
|
|
favs.remove(slug)
|
|
else:
|
|
favs.append(slug)
|
|
_persist_json()
|
|
return list(favs)
|
|
|
|
|
|
def get_plan(token: str) -> dict[str, Any] | None:
|
|
uid = _token_user_id(token)
|
|
if not uid:
|
|
return None
|
|
if use_pb():
|
|
user = _get_account_by_id(uid)
|
|
if not user:
|
|
return None
|
|
plan = user.get("plan") or _empty_plan()
|
|
return {
|
|
"items": plan.get("items") or [],
|
|
"meta": plan.get("meta") or _empty_plan()["meta"],
|
|
"updated_at": int(plan.get("updated_at") or 0),
|
|
}
|
|
|
|
plan = _plans.get(uid) or _empty_plan()
|
|
return {
|
|
"items": plan.get("items") or [],
|
|
"meta": plan.get("meta") or _empty_plan()["meta"],
|
|
"updated_at": int(plan.get("updated_at") or 0),
|
|
}
|
|
|
|
|
|
def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None) -> dict[str, Any] | None:
|
|
uid = _token_user_id(token)
|
|
if not uid:
|
|
return None
|
|
ts = int(updated_at or time.time() * 1000)
|
|
payload = {"items": items, "meta": meta, "updated_at": ts}
|
|
if use_pb():
|
|
user = _get_account_by_id(uid)
|
|
if not user:
|
|
return None
|
|
safe_update("nomad_accounts", user["pb_id"], {"plan": payload})
|
|
return get_plan(token)
|
|
|
|
_plans[uid] = payload
|
|
_persist_json()
|
|
return get_plan(token)
|
|
|
|
|
|
def ensure_user_by_email(email: str, name: str = "") -> dict[str, Any]:
|
|
"""Create or resume a session for meetup checkout flows (NomadCNA ensure-user)."""
|
|
display = (name or email.split("@")[0]).strip() or "游民"
|
|
if use_pb():
|
|
_migrate_json_to_pb()
|
|
user = _get_account_by_email(email)
|
|
is_new = False
|
|
if not user:
|
|
created = register_user(email, secrets.token_urlsafe(12), display)
|
|
if not created:
|
|
raise RuntimeError("unable to create user")
|
|
return {**created, "is_new": True}
|
|
return {**_issue_login(user), "is_new": is_new}
|
|
|
|
if email not in _users:
|
|
created = register_user(email, secrets.token_urlsafe(12), display)
|
|
if not created:
|
|
raise RuntimeError("unable to create user")
|
|
return {**created, "is_new": True}
|
|
return {**_issue_login(_users[email]), "is_new": False}
|
|
|
|
|
|
def find_user_public_by_email(email: str) -> dict | None:
|
|
if use_pb():
|
|
user = _get_account_by_email(email)
|
|
return _user_profile(user) if user else None
|
|
user = _users.get(email)
|
|
return _user_profile(user) if user else None
|
|
|
|
|
|
# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
|
|
if not use_pb():
|
|
if not _restore_json():
|
|
login_demo()
|
|
elif "demo@nomadro.com" not in _users:
|
|
login_demo()
|
|
else:
|
|
_migrate_json_to_pb()
|
|
if not _get_account_by_email("demo@nomadro.com"):
|
|
register_user("demo@nomadro.com", "demo123", "演示用户")
|