511 lines
16 KiB
Python
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)
|