nomadweb/backend/app/services/auth.py
eric bdf0516197 Fix dating match gate and expand seed data for usable demos.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:18:25 -05:00

511 lines
16 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 update_profile(token: str, *, name: str | None = None, avatar: str | None = None) -> dict | 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
patch: dict[str, Any] = {}
if name is not None and name.strip():
patch["name"] = name.strip()[:80]
if avatar is not None and avatar.strip():
patch["avatar"] = avatar.strip()[:500]
if patch:
safe_update("nomad_accounts", user["pb_id"], patch)
user = {**user, **patch}
return _user_profile(user)
for _email, user in list(_users.items()):
if user.get("id") == uid:
if name is not None and name.strip():
user["name"] = name.strip()[:80]
if avatar is not None and avatar.strip():
user["avatar"] = avatar.strip()[:500]
_persist_json()
return _user_profile(user)
return None
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
_DEMO_SEED_USERS = [
("demo@nomadro.com", "demo123", "演示用户", "🧑‍💻"),
("xiaolin@nomadro.com", "demo123", "小林", "🧳"),
("marco@nomadro.com", "demo123", "Marco", "💻"),
("yuki@nomadro.com", "demo123", "Yuki", "🎨"),
("alex@nomadro.com", "demo123", "Alex", "🚀"),
("sara@nomadro.com", "demo123", "Sara", "☕"),
("ken@nomadro.com", "demo123", "Ken", "📊"),
("lina@nomadro.com", "demo123", "Lina", "📷"),
("devon@nomadro.com", "demo123", "Devon", "🖥️"),
("sofia@nomadro.com", "demo123", "Sofia", "🌺"),
("omar@nomadro.com", "demo123", "Omar", "🏙️"),
("nina@nomadro.com", "demo123", "Nina", "🍷"),
]
_DEMO_FAVORITES = {
"演示用户": ["chiangmai", "lisbon", "bali"],
"小林": ["chiangmai", "dali", "bali"],
"Marco": ["lisbon", "barcelona", "berlin"],
"Yuki": ["bali", "seoul", "tokyo"],
"Alex": ["mexico", "medellin", "lisbon"],
"Sara": ["barcelona", "lisbon", "medellin"],
"Ken": ["tokyo", "seoul", "berlin"],
"Lina": ["dali", "chiangmai", "bali"],
"Devon": ["berlin", "tbilisi", "lisbon"],
"Sofia": ["medellin", "mexico", "barcelona"],
"Omar": ["dubai", "lisbon", "tbilisi"],
"Nina": ["tbilisi", "berlin", "dubai"],
}
_DEMO_PLANS = {
"演示用户": {
"items": [
{"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 2, "note": "先稳住节奏"},
{"slug": "bali", "name": "巴厘岛", "country": "印尼", "emoji": "🏝️", "cost": 4500, "months": 1, "note": ""},
{"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 2, "note": "评估 D7"},
],
"meta": {"title": "演示旅居计划", "startMonth": "2026-10", "monthlyBudget": 8000, "checklist": {"visa": True, "sim": True}},
"updated_at": 1,
},
"小林": {
"items": [
{"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 3, "note": ""},
{"slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️", "cost": 4200, "months": 1, "note": "慢创作"},
],
"meta": {"title": "东南亚慢旅", "startMonth": "2026-09", "monthlyBudget": 4500, "checklist": {}},
"updated_at": 1,
},
"Marco": {
"items": [
{"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 4, "note": ""},
{"slug": "barcelona", "name": "巴塞罗那", "country": "西班牙", "emoji": "🏖️", "cost": 10500, "months": 1, "note": ""},
],
"meta": {"title": "欧洲基地", "startMonth": "2026-08", "monthlyBudget": 12000, "checklist": {"visa": True}},
"updated_at": 1,
},
"Sofia": {
"items": [
{"slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺", "cost": 5200, "months": 2, "note": ""},
{"slug": "mexico", "name": "墨西哥城", "country": "墨西哥", "emoji": "🌃", "cost": 6500, "months": 1, "note": ""},
],
"meta": {"title": "拉美春城", "startMonth": "2026-10", "monthlyBudget": 5500, "checklist": {}},
"updated_at": 1,
},
"Devon": {
"items": [
{"slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷", "cost": 4800, "months": 3, "note": ""},
{"slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨", "cost": 9800, "months": 1, "note": ""},
],
"meta": {"title": "税务友好试住", "startMonth": "2026-11", "monthlyBudget": 6000, "checklist": {}},
"updated_at": 1,
},
}
def _seed_demo_users() -> None:
for email, password, name, avatar in _DEMO_SEED_USERS:
if email not in _users:
created = register_user(email, password, name)
if not created:
continue
uid = _users[email]["id"]
_users[email]["avatar"] = avatar
favs = _DEMO_FAVORITES.get(name)
if favs and not (_favorites.get(uid) or []):
_favorites[uid] = list(favs)
plan = _DEMO_PLANS.get(name)
if plan and not ((_plans.get(uid) or {}).get("items") or []):
_plans[uid] = plan
_persist_json()
# 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()
_seed_demo_users()
else:
_migrate_json_to_pb()
if not _get_account_by_email("demo@nomadro.com"):
register_user("demo@nomadro.com", "demo123", "演示用户")
# Best-effort: ensure a few named demo accounts exist in PocketBase too
for email, password, name, _avatar in _DEMO_SEED_USERS[:6]:
if not _get_account_by_email(email):
register_user(email, password, name)