Migrate auth/community/social to PocketBase and wire SeaweedFS S3 uploads.

Keep FastAPI as the only backend; add media upload, ntfy hook, and PB collection bootstrap for production.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 11:19:30 -05:00
parent 48408d3079
commit 96fa96c61e
20 changed files with 2450 additions and 799 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ frontend/.env.local
# Auth / plan user store (runtime) # Auth / plan user store (runtime)
backend/app/data/user_store.json backend/app/data/user_store.json
backend/app/data/social_store.json backend/app/data/social_store.json
backend/app/data/community_store.json
backend/app/data/platform_store.json backend/app/data/platform_store.json
# Production secrets (use deploy/nomadro-api.env.example) # Production secrets (use deploy/nomadro-api.env.example)

View File

@ -4,10 +4,31 @@
class Settings(BaseSettings): class Settings(BaseSettings):
pocketbase_url: str = "http://127.0.0.1:8090" pocketbase_url: str = "http://127.0.0.1:8090"
pocketbase_admin_email: str = "admin@nomadro.com" pocketbase_admin_email: str = "admin@nomadro.com"
pocketbase_admin_password: str = "admin123456" pocketbase_admin_password: str = "Xiao4669805"
api_host: str = "0.0.0.0" api_host: str = "0.0.0.0"
api_port: int = 8000 api_port: int = 8000
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000" cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
site_base_url: str = "https://nomadweb.nomadro.com"
# SeaweedFS S3 (s3.nomadro.com → 127.0.0.1:8333)
s3_enabled: bool = True
s3_endpoint: str = "http://127.0.0.1:8333"
s3_public_url: str = "https://s3.nomadro.com"
s3_access_key: str = "nomadweb"
s3_secret_key: str = ""
s3_bucket: str = "nomadweb"
s3_region: str = "us-east-1"
s3_upload_prefix: str = "nomadweb"
# ntfy (127.0.0.1:2586 on production server)
ntfy_enabled: bool = False
ntfy_url: str = "http://127.0.0.1:2586"
# Payment (from env on server)
dev_auto_pay: bool = False
payment_provider: str = "zpay"
mirotalk_url: str = "https://mirotalk.nomadro.com"
lounge_url: str = "https://lounge.nomadro.com"
@property @property
def cors_origin_list(self) -> list[str]: def cors_origin_list(self) -> list[str]:

View File

@ -2,7 +2,7 @@
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import api, auth, community, payment, platform, social from app.routers import api, auth, community, media, payment, platform, social
app = FastAPI( app = FastAPI(
title="nomadro API", title="nomadro API",
@ -24,6 +24,7 @@ app.include_router(social.router, prefix="/api/v1")
app.include_router(community.router, prefix="/api/v1") app.include_router(community.router, prefix="/api/v1")
app.include_router(payment.router, prefix="/api/v1") app.include_router(payment.router, prefix="/api/v1")
app.include_router(platform.router, prefix="/api/v1") app.include_router(platform.router, prefix="/api/v1")
app.include_router(media.router, prefix="/api/v1")
@app.get("/") @app.get("/")

View File

@ -0,0 +1,58 @@
"""Media upload — FastAPI only, stored on S3 (SeaweedFS)."""
from __future__ import annotations
from fastapi import APIRouter, File, Header, HTTPException, UploadFile
from app.services.auth import get_user_by_token
from app.services import s3_storage
from app.services.pb_client import pb
router = APIRouter(tags=["media"])
MAX_IMAGE = 10 * 1024 * 1024
MAX_VIDEO = 200 * 1024 * 1024
def _user(authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "未登录")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "未登录")
return user
@router.post("/upload-media")
async def upload_media(
file: UploadFile = File(...),
purpose: str = "general",
authorization: str | None = Header(None),
):
user = _user(authorization)
ctype = (file.content_type or "").lower()
max_size = MAX_VIDEO if ctype.startswith("video/") else MAX_IMAGE
result = await s3_storage.upload_file(file, purpose=purpose, max_size=max_size)
if pb.health_ok():
try:
pb.create_record(
"media_assets",
{
"userId": user["id"],
"url": result.url,
"filename": file.filename or "",
"contentType": result.content_type,
"size": result.size,
"objectKey": result.object_key,
},
)
except Exception:
pass
return {
"success": True,
"url": result.url,
"object_key": result.object_key,
"size": result.size,
"content_type": result.content_type,
}

View File

@ -1,4 +1,4 @@
"""Auth + favorites + synced user plans. File-backed so demo restarts keep data.""" """Auth + favorites + plans — PocketBase backed (nomad_accounts / nomad_sessions)."""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
@ -8,53 +8,22 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any 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" STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "user_store.json"
# In-memory fallback when PocketBase unavailable (dev only)
_users: dict[str, dict] = {} _users: dict[str, dict] = {}
_sessions: dict[str, str] = {} # token -> user_id _sessions: dict[str, str] = {}
_favorites: dict[str, list[str]] = {} # user_id -> [slugs] _favorites: dict[str, list[str]] = {}
_plans: dict[str, dict] = {} # user_id -> { items, meta, updated_at } _plans: dict[str, dict] = {}
def _hash_password(password: str) -> str: def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest() return hashlib.sha256(password.encode()).hexdigest()
def _persist() -> None:
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = {
"users": _users,
"sessions": _sessions,
"favorites": _favorites,
"plans": _plans,
}
STORE_PATH.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def _restore() -> 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 _user_profile(user: dict) -> dict:
return {
"id": user["id"],
"email": user["email"],
"name": user["name"],
"avatar": user.get("avatar", "🧑‍💻"),
}
def _empty_plan() -> dict[str, Any]: def _empty_plan() -> dict[str, Any]:
return { return {
"items": [], "items": [],
@ -68,7 +37,138 @@ def _empty_plan() -> dict[str, Any]:
} }
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: 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: if email in _users:
return None return None
uid = secrets.token_hex(8) uid = secrets.token_hex(8)
@ -81,69 +181,76 @@ def register_user(email: str, password: str, name: str) -> dict[str, Any] | None
} }
_favorites[uid] = [] _favorites[uid] = []
_plans[uid] = _empty_plan() _plans[uid] = _empty_plan()
token = secrets.token_urlsafe(32) _persist_json()
_sessions[token] = uid return _issue_login(_users[email])
_persist()
return {"token": token, "user": _user_profile(_users[email])}
def login_user(email: str, password: str) -> dict[str, Any] | None: 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) user = _users.get(email)
if not user or user["password_hash"] != _hash_password(password): if not user or user["password_hash"] != _hash_password(password):
return None return None
token = secrets.token_urlsafe(32) return _issue_login(user)
_sessions[token] = user["id"]
_persist()
return {"token": token, "user": _user_profile(user)}
def login_google(email: str, name: str) -> dict[str, Any] | None: 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: if email not in _users:
uid = secrets.token_hex(8) register_user(email, secrets.token_hex(16), name or email.split("@")[0])
_users[email] = { return _issue_login(_users[email])
"id": uid,
"email": email,
"password_hash": _hash_password(secrets.token_hex(16)),
"name": name or email.split("@")[0],
"avatar": "🌐",
"oauth": "google",
}
_favorites[uid] = []
_plans[uid] = _empty_plan()
user = _users[email]
token = secrets.token_urlsafe(32)
_sessions[token] = user["id"]
_persist()
return {"token": token, "user": _user_profile(user)}
def login_demo() -> dict[str, Any] | None: def login_demo() -> dict[str, Any] | None:
email = "demo@nomadro.com" 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: if email not in _users:
created = register_user(email, "demo123", "演示用户") return register_user(email, "demo123", "演示用户")
return created return _issue_login(_users[email])
user = _users[email]
token = secrets.token_urlsafe(32)
_sessions[token] = user["id"]
_persist()
return {"token": token, "user": _user_profile(user)}
def _reload_sessions() -> None:
if not STORE_PATH.exists():
return
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
global _sessions
_sessions.update(data.get("sessions") or {})
except (OSError, json.JSONDecodeError):
return
def get_user_by_token(token: str) -> dict | None: 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) uid = _sessions.get(token)
if not uid: if not uid and _restore_json():
_reload_sessions()
uid = _sessions.get(token) uid = _sessions.get(token)
if not uid: if not uid:
return None return None
@ -153,30 +260,61 @@ def get_user_by_token(token: str) -> dict | None:
return None 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]: def get_favorites(token: str) -> list[str]:
uid = _sessions.get(token) uid = _token_user_id(token)
if not uid: if not uid:
return [] return []
if use_pb():
user = _get_account_by_id(uid)
return list(user.get("favorites") or []) if user else []
return list(_favorites.get(uid, [])) return list(_favorites.get(uid, []))
def toggle_favorite(token: str, slug: str) -> list[str]: def toggle_favorite(token: str, slug: str) -> list[str]:
uid = _sessions.get(token) uid = _token_user_id(token)
if not uid: if not uid:
return [] 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, []) favs = _favorites.setdefault(uid, [])
if slug in favs: if slug in favs:
favs.remove(slug) favs.remove(slug)
else: else:
favs.append(slug) favs.append(slug)
_persist() _persist_json()
return list(favs) return list(favs)
def get_plan(token: str) -> dict[str, Any] | None: def get_plan(token: str) -> dict[str, Any] | None:
uid = _sessions.get(token) uid = _token_user_id(token)
if not uid: if not uid:
return None 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() plan = _plans.get(uid) or _empty_plan()
return { return {
"items": plan.get("items") or [], "items": plan.get("items") or [],
@ -186,21 +324,30 @@ def get_plan(token: str) -> dict[str, Any] | None:
def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None) -> dict[str, Any] | None: def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None) -> dict[str, Any] | None:
uid = _sessions.get(token) uid = _token_user_id(token)
if not uid: if not uid:
return None return None
ts = int(updated_at or time.time() * 1000) ts = int(updated_at or time.time() * 1000)
_plans[uid] = { payload = {"items": items, "meta": meta, "updated_at": ts}
"items": items, if use_pb():
"meta": meta, user = _get_account_by_id(uid)
"updated_at": ts, if not user:
} return None
_persist() safe_update("nomad_accounts", user["pb_id"], {"plan": payload})
return get_plan(token)
_plans[uid] = payload
_persist_json()
return get_plan(token) return get_plan(token)
# Boot: restore disk store or seed demo # Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
if not _restore(): if not use_pb():
login_demo() if not _restore_json():
elif "demo@nomadro.com" not in _users: login_demo()
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", "演示用户")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
"""ntfy push notifications (server: 127.0.0.1:2586)."""
from __future__ import annotations
import httpx
from app.config import settings
async def send_push(
topic: str,
title: str,
body: str,
*,
tags: list[str] | None = None,
click_url: str = "",
) -> bool:
if not settings.ntfy_enabled:
return False
base = settings.ntfy_url.rstrip("/")
url = f"{base}/{topic}"
headers: dict[str, str] = {"Title": title}
if click_url:
headers["Click"] = click_url
if tags:
headers["Tags"] = ",".join(tags)
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.post(url, content=body, headers=headers)
return r.status_code < 300
except Exception:
return False

View File

@ -0,0 +1,165 @@
"""PocketBase admin client — full CRUD for FastAPI services."""
from __future__ import annotations
from typing import Any
from urllib.parse import quote
import httpx
from app.config import settings
class PocketBaseError(RuntimeError):
pass
def pb_quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
class PocketBaseClient:
def __init__(self) -> None:
self._admin_token: str | None = None
self._client: httpx.Client | None = None
@property
def base_url(self) -> str:
return settings.pocketbase_url.rstrip("/")
def _http(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client(timeout=20.0, trust_env=False)
return self._client
def request(
self,
method: str,
path: str,
*,
token: str | None = None,
admin: bool = False,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
headers: dict[str, str] = {}
if admin:
token = self.admin_token()
if token:
headers["Authorization"] = token if token.startswith("Bearer ") else token
url = f"{self.base_url}{path}"
res = self._http().request(method, url, headers=headers, json=json, params=params)
if res.status_code >= 400:
try:
detail = res.json()
except Exception:
detail = res.text
raise PocketBaseError(f"PocketBase {res.status_code}: {detail}")
if not res.content:
return None
return res.json()
def admin_token(self) -> str:
if self._admin_token:
return self._admin_token
body = {
"identity": settings.pocketbase_admin_email,
"password": settings.pocketbase_admin_password,
}
last_error: Exception | None = None
for endpoint in (
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
):
try:
data = self.request("POST", endpoint, json=body)
token = (data or {}).get("token")
if token:
self._admin_token = token
return token
except Exception as exc:
last_error = exc
raise PocketBaseError(f"Unable to authenticate PocketBase admin: {last_error}")
def list_records(
self,
collection: str,
*,
filter: str | None = None,
sort: str | None = None,
page: int = 1,
per_page: int = 200,
admin: bool = True,
) -> dict[str, Any]:
params: dict[str, Any] = {"page": page, "perPage": per_page}
if filter:
params["filter"] = filter
if sort:
params["sort"] = sort
return self.request(
"GET",
f"/api/collections/{collection}/records",
admin=admin,
params=params,
)
def list_all(self, collection: str, *, filter: str | None = None, sort: str | None = None) -> list[dict[str, Any]]:
page = 1
items: list[dict[str, Any]] = []
while True:
data = self.list_records(collection, filter=filter, sort=sort, page=page)
batch = data.get("items") or []
items.extend(batch)
if page >= (data.get("totalPages") or 1):
break
page += 1
return items
def first_record(self, collection: str, *, filter: str, admin: bool = True) -> dict[str, Any] | None:
data = self.list_records(collection, filter=filter, per_page=1, admin=admin)
items = data.get("items") or []
return items[0] if items else None
def create_record(self, collection: str, payload: dict[str, Any]) -> dict[str, Any]:
return self.request(
"POST",
f"/api/collections/{collection}/records",
admin=True,
json=payload,
)
def update_record(self, collection: str, record_id: str, payload: dict[str, Any]) -> dict[str, Any]:
return self.request(
"PATCH",
f"/api/collections/{collection}/records/{record_id}",
admin=True,
json=payload,
)
def delete_record(self, collection: str, record_id: str) -> None:
self.request(
"DELETE",
f"/api/collections/{collection}/records/{record_id}",
admin=True,
)
def upsert_by_field(
self,
collection: str,
field: str,
value: str,
payload: dict[str, Any],
) -> dict[str, Any]:
existing = self.first_record(collection, filter=f"{field}={pb_quote(value)}")
if existing:
return self.update_record(collection, existing["id"], payload)
return self.create_record(collection, payload)
def health_ok(self) -> bool:
try:
res = self._http().get(f"{self.base_url}/api/health", timeout=2.0)
return res.status_code == 200
except Exception:
return False
pb = PocketBaseClient()

View File

@ -0,0 +1,80 @@
"""Shared PocketBase helpers for store modules."""
from __future__ import annotations
from typing import Any
from app.services.pb_client import PocketBaseError, pb, pb_quote
_pb_ready: bool | None = None
def use_pb() -> bool:
global _pb_ready
if _pb_ready is not None:
return _pb_ready
try:
if not pb.health_ok():
_pb_ready = False
return False
pb.admin_token()
_pb_ready = True
except Exception:
_pb_ready = False
return _pb_ready
def reset_pb_cache() -> None:
global _pb_ready
_pb_ready = None
def q(value: str) -> str:
return pb_quote(value)
def rid(record: dict[str, Any], legacy_field: str = "legacyId") -> str:
return str(record.get(legacy_field) or record.get("id") or "")
def pb_date(value: Any) -> str:
if not value:
return ""
s = str(value)
return s[:10] if len(s) >= 10 else s
def safe_list(collection: str, **kwargs: Any) -> list[dict[str, Any]]:
try:
return pb.list_all(collection, **kwargs)
except PocketBaseError:
return []
def safe_first(collection: str, *, filter: str) -> dict[str, Any] | None:
try:
return pb.first_record(collection, filter=filter)
except PocketBaseError:
return None
def safe_create(collection: str, payload: dict[str, Any]) -> dict[str, Any] | None:
try:
return pb.create_record(collection, payload)
except PocketBaseError:
return None
def safe_update(collection: str, record_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
try:
return pb.update_record(collection, record_id, payload)
except PocketBaseError:
return None
def find_by_legacy(collection: str, legacy_id: str) -> dict[str, Any] | None:
if not legacy_id:
return None
row = safe_first(collection, filter=f"legacyId={q(legacy_id)}")
if row:
return row
return safe_first(collection, filter=f"id={q(legacy_id)}")

View File

@ -1,5 +1,4 @@
"""Platform persistence: leads, submissions, volunteers, reports, newsletter.""" """Platform persistence — PocketBase backed."""
from __future__ import annotations from __future__ import annotations
import json import json
@ -7,124 +6,134 @@ import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json" from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb
_leads: list[dict] = [] _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json"
_submissions: list[dict] = [] _j: dict = {}
_volunteers: list[dict] = []
_reports: list[dict] = []
_newsletter: list[dict] = []
_notif_prefs: dict[str, dict] = {}
def _now() -> str: def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d") return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _persist() -> None: def _jload() -> dict:
STORE_PATH.parent.mkdir(parents=True, exist_ok=True) global _j
STORE_PATH.write_text(json.dumps({ if _j:
"leads": _leads, return _j
"submissions": _submissions, if _JSON_PATH.exists():
"volunteers": _volunteers, try:
"reports": _reports, _j = json.loads(_JSON_PATH.read_text(encoding="utf-8"))
"newsletter": _newsletter, except (OSError, json.JSONDecodeError):
"notif_prefs": _notif_prefs, _j = {}
}, ensure_ascii=False), encoding="utf-8") else:
_j = {}
return _j
def _restore() -> None: def _jsave() -> None:
global _leads, _submissions, _volunteers, _reports, _newsletter, _notif_prefs _JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
if not STORE_PATH.exists(): _JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8")
return
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
_leads = data.get("leads") or []
_submissions = data.get("submissions") or []
_volunteers = data.get("volunteers") or []
_reports = data.get("reports") or []
_newsletter = data.get("newsletter") or []
_notif_prefs = data.get("notif_prefs") or {}
_restore()
def add_service_lead(service_id: str, user_id: str | None, name: str, email: str, message: str) -> dict: def add_service_lead(service_id: str, user_id: str | None, name: str, email: str, message: str) -> dict:
item = { item = {
"id": secrets.token_hex(6), "serviceSlug": service_id,
"service_id": service_id, "userId": user_id or "",
"user_id": user_id or "",
"name": name,
"email": email, "email": email,
"message": message, "note": f"{name}: {message}"[:1000],
"created_at": _now(), "status": "open",
} }
_leads.append(item) if use_pb():
_persist() row = safe_create("service_leads", item)
return item return {"id": row["id"] if row else secrets.token_hex(6), **item}
_jload()
out = {"id": secrets.token_hex(6), "service_id": service_id, "name": name, "email": email, "message": message, "created_at": _now()}
_j.setdefault("leads", []).append(out)
_jsave()
return out
def add_content_submission(user_id: str | None, name: str, title: str, content_type: str, url: str, notes: str) -> dict: def add_content_submission(user_id: str | None, name: str, title: str, content_type: str, url: str, notes: str) -> dict:
item = { if use_pb():
"id": secrets.token_hex(6), row = safe_create(
"user_id": user_id or "", "content_submissions",
"name": name, {
"title": title, "title": title,
"content_type": content_type, "type": content_type,
"url": url, "mediaUrl": url,
"notes": notes, "submittedBy": user_id or "",
"status": "pending", "authorName": name,
"created_at": _now(), "description": notes[:2000],
} "reviewStatus": "pending",
_submissions.append(item) },
_persist() )
return item return {"id": row["id"] if row else secrets.token_hex(6), "title": title, "status": "pending"}
_jload()
out = {"id": secrets.token_hex(6), "title": title, "content_type": content_type, "url": url, "status": "pending", "created_at": _now()}
_j.setdefault("submissions", []).append(out)
_jsave()
return out
def add_volunteer(city_slug: str, user_id: str | None, name: str, email: str, message: str) -> dict: def add_volunteer(city_slug: str, user_id: str | None, name: str, email: str, message: str) -> dict:
item = { if use_pb():
"id": secrets.token_hex(6), row = safe_create(
"city_slug": city_slug, "volunteer_applications",
"user_id": user_id or "", {
"name": name, "citySlug": city_slug,
"email": email, "userId": user_id or "",
"message": message, "applicantName": name,
"created_at": _now(), "email": email,
} "motivation": message[:2000],
_volunteers.append(item) "status": "pending",
_persist() },
return item )
return {"id": row["id"] if row else secrets.token_hex(6), "city_slug": city_slug}
_jload()
out = {"id": secrets.token_hex(6), "city_slug": city_slug, "name": name, "email": email, "created_at": _now()}
_j.setdefault("volunteers", []).append(out)
_jsave()
return out
def add_report(reporter_id: str | None, target_type: str, target_id: str, reason: str) -> dict: def add_report(reporter_id: str | None, target_type: str, target_id: str, reason: str) -> dict:
item = { if use_pb():
"id": secrets.token_hex(6), row = safe_create(
"reporter_id": reporter_id or "", "reports",
"target_type": target_type, {
"target_id": target_id, "reporterId": reporter_id or "",
"reason": reason, "targetType": target_type,
"status": "open", "targetId": target_id,
"created_at": _now(), "reason": reason[:2000],
} "status": "open",
_reports.append(item) },
_persist() )
return item return {"id": row["id"] if row else secrets.token_hex(6), "status": "open"}
_jload()
out = {"id": secrets.token_hex(6), "target_type": target_type, "target_id": target_id, "status": "open", "created_at": _now()}
_j.setdefault("reports", []).append(out)
_jsave()
return out
def subscribe_newsletter(email: str, source: str = "site") -> dict: def subscribe_newsletter(email: str, source: str = "site") -> dict:
for n in _newsletter: if use_pb():
dup = safe_first("subscriptions", filter=f"email={q(email)}")
if dup:
return {"ok": True, "message": "已订阅"}
safe_create("subscriptions", {"email": email})
return {"ok": True, "message": "订阅成功"}
_jload()
for n in _j.get("newsletter") or []:
if n["email"] == email: if n["email"] == email:
return {"ok": True, "message": "已订阅"} return {"ok": True, "message": "已订阅"}
_newsletter.append({"email": email, "source": source, "created_at": _now()}) _j.setdefault("newsletter", []).append({"email": email, "source": source, "created_at": _now()})
_persist() _jsave()
return {"ok": True, "message": "订阅成功"} return {"ok": True, "message": "订阅成功"}
def get_notif_prefs(user_id: str) -> dict: def get_notif_prefs(user_id: str) -> dict:
return _notif_prefs.get(user_id) or { default = {
"email": True, "email": True,
"push": False, "push": False,
"match": True, "match": True,
@ -133,9 +142,26 @@ def get_notif_prefs(user_id: str) -> dict:
"marketing": False, "marketing": False,
"quiet_hours": "", "quiet_hours": "",
} }
if use_pb():
row = safe_first("notification_preferences", filter=f"userId={q(user_id)}")
if not row:
return default
channels = row.get("channels") or {}
return {**default, **channels}
_jload()
return _j.get("notif_prefs", {}).get(user_id) or default
def set_notif_prefs(user_id: str, prefs: dict) -> dict: def set_notif_prefs(user_id: str, prefs: dict) -> dict:
_notif_prefs[user_id] = {**get_notif_prefs(user_id), **prefs} merged = {**get_notif_prefs(user_id), **prefs}
_persist() if use_pb():
return _notif_prefs[user_id] row = safe_first("notification_preferences", filter=f"userId={q(user_id)}")
if row:
safe_update("notification_preferences", row["id"], {"channels": merged, "userId": user_id})
else:
safe_create("notification_preferences", {"userId": user_id, "channels": merged})
return merged
_jload()
_j.setdefault("notif_prefs", {})[user_id] = merged
_jsave()
return merged

View File

@ -26,15 +26,9 @@ class PocketBaseService:
if self._token: if self._token:
return True return True
try: try:
r = await client.post( from app.services.pb_client import pb as sync_pb
f"{self.base_url}/api/admins/auth-with-password", if sync_pb.health_ok():
json={ self._token = sync_pb.admin_token()
"identity": settings.pocketbase_admin_email,
"password": settings.pocketbase_admin_password,
},
)
if r.status_code == 200:
self._token = r.json().get("token")
return True return True
except Exception: except Exception:
pass pass

View File

@ -0,0 +1,96 @@
"""SeaweedFS S3-compatible uploads (server: s3.nomadro.com → 127.0.0.1:8333)."""
from __future__ import annotations
import mimetypes
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote
from fastapi import HTTPException, UploadFile
from app.config import settings
@dataclass
class UploadResult:
url: str
object_key: str
size: int
content_type: str
def s3_enabled() -> bool:
return bool(settings.s3_enabled and settings.s3_endpoint and settings.s3_bucket)
def _client():
import boto3
from botocore.client import Config
return boto3.client(
"s3",
endpoint_url=settings.s3_endpoint.rstrip("/"),
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name=settings.s3_region,
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def ensure_bucket() -> None:
if not s3_enabled():
return
client = _client()
bucket = settings.s3_bucket
try:
client.head_bucket(Bucket=bucket)
except Exception:
try:
client.create_bucket(Bucket=bucket)
except Exception:
pass
def public_url_for_key(key: str) -> str:
base = (settings.s3_public_url or settings.s3_endpoint).rstrip("/")
bucket = settings.s3_bucket.strip("/")
return f"{base}/{bucket}/{quote(key, safe='/')}"
def _object_key(filename: str, purpose: str) -> str:
suffix = Path(filename or "upload.bin").suffix.lower() or ".bin"
day = datetime.now(timezone.utc).strftime("%Y/%m/%d")
prefix = (settings.s3_upload_prefix or "nomadweb").strip("/")
purpose_part = (purpose or "uploads").replace("/", "-")[:40]
return f"{prefix}/{purpose_part}/{day}/{uuid.uuid4().hex}{suffix}"
async def upload_file(file: UploadFile, *, purpose: str, max_size: int) -> UploadResult:
if not s3_enabled():
raise HTTPException(status_code=503, detail="S3 存储未配置")
data = await file.read()
if len(data) > max_size:
raise HTTPException(status_code=400, detail=f"文件不能超过 {max_size // 1024 // 1024}MB")
if not data:
raise HTTPException(status_code=400, detail="空文件")
ensure_bucket()
key = _object_key(file.filename or "upload.bin", purpose)
ctype = file.content_type or mimetypes.guess_type(file.filename or "")[0] or "application/octet-stream"
client = _client()
try:
client.put_object(
Bucket=settings.s3_bucket,
Key=key,
Body=data,
ContentType=ctype,
ACL="public-read",
)
except Exception:
client.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=ctype)
url = public_url_for_key(key)
return UploadResult(url=url, object_key=key, size=len(data), content_type=ctype)

View File

@ -1,5 +1,4 @@
"""Social graph: profiles, swipes, matches, DMs — file-backed store.""" """Social graph — PocketBase backed (profiles, swipes, matches, DMs)."""
from __future__ import annotations from __future__ import annotations
import json import json
@ -10,20 +9,15 @@ from pathlib import Path
from typing import Any from typing import Any
from app.data.social_profiles import CANDIDATE_PROFILES, MATCH_INTENTS from app.data.social_profiles import CANDIDATE_PROFILES, MATCH_INTENTS
from app.services.pb_client import pb, pb_quote
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json" from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb
LIKE_ACTIONS = frozenset({"like", "right", "superlike"}) LIKE_ACTIONS = frozenset({"like", "right", "superlike"})
FREE_SWIPE_DAILY_LIMIT = 25 FREE_SWIPE_DAILY_LIMIT = 25
VIP_SWIPE_LIMIT = 9999 VIP_SWIPE_LIMIT = 9999
_profiles: dict[str, dict] = {} # user_id -> profile _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json"
_swipes: list[dict] = [] _j: dict[str, Any] = {}
_matches: list[dict] = []
_conversations: list[dict] = []
_messages: list[dict] = []
_memberships: dict[str, dict] = {} # user_id -> {expires_at, plan}
_orders: dict[str, dict] = {}
def _now_iso() -> str: def _now_iso() -> str:
@ -34,67 +28,93 @@ def _today_key() -> str:
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d") return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d")
def _persist() -> None: def _pair_key(a: str, b: str) -> str:
STORE_PATH.parent.mkdir(parents=True, exist_ok=True) return "|".join(sorted([a, b]))
STORE_PATH.write_text(
json.dumps({
"profiles": _profiles,
"swipes": _swipes,
"matches": _matches,
"conversations": _conversations,
"messages": _messages,
"memberships": _memberships,
"orders": _orders,
}, ensure_ascii=False),
encoding="utf-8",
)
def _restore() -> None: def _profile_looking_for(record: dict) -> list[str]:
global _profiles, _swipes, _matches, _conversations, _messages, _memberships, _orders raw = record.get("lookingFor") or record.get("looking_for") or []
if not STORE_PATH.exists(): cleaned = [x for x in raw if x in MATCH_INTENTS]
return return cleaned or ["friends", "explore"]
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
_profiles = data.get("profiles") or {}
_swipes = data.get("swipes") or []
_matches = data.get("matches") or []
_conversations = data.get("conversations") or []
_messages = data.get("messages") or []
_memberships = data.get("memberships") or {}
_orders = data.get("orders") or {}
_restore() def _peer_profile(user_id: str) -> dict | None:
for p in CANDIDATE_PROFILES:
if p.get("userId") == user_id:
return p
if use_pb():
row = safe_first("profiles", filter=f"userId={q(user_id)}")
if row:
return {
"id": f"user-{user_id}",
"userId": user_id,
"name": row.get("name", "游民"),
"location": row.get("location", "全球"),
"bio": row.get("bio", ""),
"photo": row.get("photo", "🧑‍💻"),
"tags": row.get("tags") or [],
}
prof = (_jload().get("profiles") or {}).get(user_id)
if prof:
return {"id": f"user-{user_id}", "userId": user_id, **prof}
return None
def _reload() -> None: # ── VIP / membership ───────────────────────────────────────────────────
_restore()
def is_vip(user_id: str) -> bool: def is_vip(user_id: str) -> bool:
_reload() if use_pb():
m = _memberships.get(user_id) row = safe_first("memberships", filter=f"userId={q(user_id)}")
if not m: if not row:
return False return False
exp = m.get("expires_at") or 0 return int(row.get("expiresAt") or 0) > int(time.time())
return exp > int(time.time()) _jload()
m = (_j.get("memberships") or {}).get(user_id)
return bool(m and (m.get("expires_at") or 0) > int(time.time()))
def ensure_membership(user_id: str, days: int = 365) -> None: def ensure_membership(user_id: str, days: int = 365) -> None:
_memberships[user_id] = { exp = int(time.time()) + days * 86400
"plan": "vip", if use_pb():
"expires_at": int(time.time()) + days * 86400, row = safe_first("memberships", filter=f"userId={q(user_id)}")
"updated_at": _now_iso(), payload = {"userId": user_id, "plan": "vip", "expiresAt": exp}
} if row:
_persist() safe_update("memberships", row["id"], payload)
else:
safe_create("memberships", payload)
return
_jload()
_j.setdefault("memberships", {})[user_id] = {"plan": "vip", "expires_at": exp, "updated_at": _now_iso()}
_jsave()
# ── Profiles ───────────────────────────────────────────────────────────
def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict: def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict:
if user_id not in _profiles: if use_pb():
_profiles[user_id] = { row = safe_first("profiles", filter=f"userId={q(user_id)}")
if row:
return row
return safe_create(
"profiles",
{
"userId": user_id,
"name": name,
"location": extra.get("location", "全球"),
"citySlug": extra.get("citySlug", ""),
"gender": extra.get("gender", ""),
"single": extra.get("single", ""),
"bio": extra.get("bio", ""),
"photo": extra.get("photo", "🧑‍💻"),
"tags": extra.get("tags", []),
"lookingFor": extra.get("lookingFor", ["friends", "explore"]),
},
) or {}
_jload()
profiles = _j.setdefault("profiles", {})
if user_id not in profiles:
profiles[user_id] = {
"userId": user_id, "userId": user_id,
"name": name, "name": name,
"location": extra.get("location", "全球"), "location": extra.get("location", "全球"),
@ -107,13 +127,12 @@ def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict:
"lookingFor": extra.get("lookingFor", ["friends", "explore"]), "lookingFor": extra.get("lookingFor", ["friends", "explore"]),
"createdAt": _now_iso(), "createdAt": _now_iso(),
} }
_persist() _jsave()
return _profiles[user_id] return profiles[user_id]
def join_member(user_id: str, name: str, payload: dict) -> dict: def join_member(user_id: str, name: str, payload: dict) -> dict:
_reload() return get_or_create_profile(
profile = get_or_create_profile(
user_id, user_id,
name, name,
location=payload.get("city", "全球"), location=payload.get("city", "全球"),
@ -124,16 +143,21 @@ def join_member(user_id: str, name: str, payload: dict) -> dict:
lookingFor=payload.get("lookingFor", ["friends", "explore"]), lookingFor=payload.get("lookingFor", ["friends", "explore"]),
photo=payload.get("photo", "🧑‍💻"), photo=payload.get("photo", "🧑‍💻"),
) )
_persist()
return profile
def _profile_looking_for(record: dict) -> list[str]: def get_public_profile(user_id: str) -> dict | None:
raw = record.get("lookingFor") or [] if use_pb():
cleaned = [x for x in raw if x in MATCH_INTENTS] row = safe_first("profiles", filter=f"userId={q(user_id)}")
return cleaned or ["friends", "explore"] if not row:
return None
return {**row, "vip": is_vip(user_id)}
_jload()
p = (_j.get("profiles") or {}).get(user_id)
return {**p, "vip": is_vip(user_id)} if p else None
# ── Candidates / swipes ────────────────────────────────────────────────
def list_candidates( def list_candidates(
user_id: str, user_id: str,
intent: str = "friends", intent: str = "friends",
@ -142,46 +166,64 @@ def list_candidates(
single: str = "", single: str = "",
exclude_swiped: bool = True, exclude_swiped: bool = True,
) -> list[dict]: ) -> list[dict]:
_reload() swiped_ids: set[str] = set()
my_profile = _profiles.get(user_id)
swiped_ids = set()
if exclude_swiped: if exclude_swiped:
for s in _swipes: if use_pb():
if s.get("userId") == user_id: for s in safe_list("swipes", filter=f"userId={q(user_id)}"):
swiped_ids.add(s.get("profileId")) swiped_ids.add(s.get("profileId", ""))
else:
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == user_id:
swiped_ids.add(s.get("profileId", ""))
pool = list(CANDIDATE_PROFILES) pool = list(CANDIDATE_PROFILES)
# Real members who completed join profile if use_pb():
for uid, prof in _profiles.items(): for row in safe_list("profiles"):
if uid == user_id: uid = row.get("userId", "")
continue if uid and uid != user_id:
pool.append({ pool.append({
"id": f"user-{uid}", "id": f"user-{uid}",
"userId": uid, "userId": uid,
"name": prof.get("name", "游民"), "name": row.get("name", "游民"),
"location": prof.get("location", "全球"), "location": row.get("location", "全球"),
"citySlug": prof.get("citySlug", ""), "citySlug": row.get("citySlug", ""),
"gender": prof.get("gender", ""), "gender": row.get("gender", ""),
"single": prof.get("single", ""), "single": row.get("single", ""),
"bio": prof.get("bio", ""), "bio": row.get("bio", ""),
"photo": prof.get("photo", "🧑‍💻"), "photo": row.get("photo", "🧑‍💻"),
"tags": prof.get("tags", []), "tags": row.get("tags") or [],
"lookingFor": _profile_looking_for(prof), "lookingFor": _profile_looking_for(row),
}) })
if my_profile: else:
pool = [p for p in pool if p.get("userId") != user_id] _jload()
for uid, prof in (_j.get("profiles") or {}).items():
if uid != user_id:
pool.append({
"id": f"user-{uid}",
"userId": uid,
"name": prof.get("name", "游民"),
"location": prof.get("location", "全球"),
"citySlug": prof.get("citySlug", ""),
"gender": prof.get("gender", ""),
"single": prof.get("single", ""),
"bio": prof.get("bio", ""),
"photo": prof.get("photo", "🧑‍💻"),
"tags": prof.get("tags", []),
"lookingFor": _profile_looking_for(prof),
})
results = [] results = []
for p in pool: for p in pool:
if p["id"] in swiped_ids: if p.get("userId") == user_id or p["id"] in swiped_ids:
continue continue
if intent and intent not in _profile_looking_for(p): if intent and intent not in _profile_looking_for(p):
continue continue
if city and city not in (p.get("location") or "") and city != p.get("citySlug"): if city and city not in (p.get("location") or "") and city != p.get("citySlug"):
continue continue
if gender and is_vip(user_id) and gender and p.get("gender") != gender: if gender and is_vip(user_id) and p.get("gender") != gender:
continue continue
if single and is_vip(user_id) and single and p.get("single") != single: if single and is_vip(user_id) and p.get("single") != single:
continue continue
results.append({**p, "intent": intent}) results.append({**p, "intent": intent})
return results return results
@ -189,61 +231,65 @@ def list_candidates(
def swipe_count_today(user_id: str) -> int: def swipe_count_today(user_id: str) -> int:
today = _today_key() today = _today_key()
return sum(1 for s in _swipes if s.get("userId") == user_id and s.get("date") == today) if use_pb():
return sum(
1 for s in safe_list("swipes", filter=f"userId={q(user_id)}")
if (s.get("swipeDate") or "") == today
)
_jload()
return sum(1 for s in _j.get("swipes") or [] if s.get("userId") == user_id and s.get("date") == today)
def get_quota(user_id: str) -> dict: def get_quota(user_id: str) -> dict:
_reload()
vip = is_vip(user_id) vip = is_vip(user_id)
limit = VIP_SWIPE_LIMIT if vip else FREE_SWIPE_DAILY_LIMIT limit = VIP_SWIPE_LIMIT if vip else FREE_SWIPE_DAILY_LIMIT
used = swipe_count_today(user_id) used = swipe_count_today(user_id)
return {"vip": vip, "limit": limit, "used": used, "remaining": max(0, limit - used)} return {"vip": vip, "limit": limit, "used": used, "remaining": max(0, limit - used)}
def _pair_key(a: str, b: str) -> str: def _find_profile(profile_id: str) -> dict | None:
return "|".join(sorted([a, b])) for p in CANDIDATE_PROFILES:
if p["id"] == profile_id:
return p
if profile_id.startswith("user-"):
uid = profile_id[5:]
return _peer_profile(uid)
return None
def _has_reciprocal_like(target_user_id: str, my_profile_id: str) -> bool: def _has_reciprocal_like(target_user_id: str, my_profile_id: str) -> bool:
target_profile = next((p for p in CANDIDATE_PROFILES if p.get("userId") == target_user_id), None) if use_pb():
if not target_profile: rows = safe_list("swipes", filter=f"userId={q(target_user_id)} && profileId={q(my_profile_id)}")
return False return any(r.get("action") in LIKE_ACTIONS for r in rows)
target_pid = target_profile["id"] _jload()
for s in _swipes: for s in _j.get("swipes") or []:
if s.get("userId") == target_user_id and s.get("profileId") == my_profile_id: if s.get("userId") == target_user_id and s.get("profileId") == my_profile_id:
if s.get("action") in LIKE_ACTIONS: if s.get("action") in LIKE_ACTIONS:
return True return True
return False return False
def _ensure_match(user_id: str, profile: dict, intent: str) -> dict | None:
target_uid = profile.get("userId")
if not target_uid:
return None
pk = _pair_key(user_id, target_uid)
for m in _matches:
if m.get("pairKey") == pk:
return m
conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
match = {
"id": secrets.token_hex(6),
"pairKey": pk,
"userAId": user_id,
"userBId": target_uid,
"profileAId": _profiles.get(user_id, {}).get("id", ""),
"profileBId": profile.get("id"),
"intent": intent,
"conversationId": conv["id"],
"matchedAt": _now_iso(),
}
_matches.append(match)
_persist()
return match
def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", match: bool = False) -> dict: def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", match: bool = False) -> dict:
pk = _pair_key(user_a, user_b) pk = _pair_key(user_a, user_b)
for c in _conversations: if use_pb():
row = safe_first("conversations", filter=f"pairKey={q(pk)}")
if row:
return {"id": row["id"], **row}
created = safe_create(
"conversations",
{
"pairKey": pk,
"userAId": user_a,
"userBId": user_b,
"intent": intent,
"lastMessagePreview": "",
"readState": {user_a: _now_iso(), user_b: _now_iso()},
},
)
return {"id": created["id"], **created} if created else {"id": "", "pairKey": pk}
_jload()
for c in _j.get("conversations") or []:
if c.get("pairKey") == pk: if c.get("pairKey") == pk:
return c return c
conv = { conv = {
@ -257,59 +303,94 @@ def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", matc
"lastMessagePreview": "", "lastMessagePreview": "",
"readState": {user_a: _now_iso(), user_b: _now_iso()}, "readState": {user_a: _now_iso(), user_b: _now_iso()},
} }
_conversations.append(conv) _j.setdefault("conversations", []).append(conv)
_persist() _jsave()
return conv return conv
def _find_profile(profile_id: str) -> dict | None: def _ensure_match(user_id: str, profile: dict, intent: str) -> dict | None:
for p in CANDIDATE_PROFILES: target_uid = profile.get("userId")
if p["id"] == profile_id: if not target_uid:
return p return None
if profile_id.startswith("user-"): pk = _pair_key(user_id, target_uid)
uid = profile_id[5:] if use_pb():
prof = _profiles.get(uid) row = safe_first("match_connections", filter=f"pairKey={q(pk)}")
if prof: if row:
return { return row
"id": profile_id, conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
"userId": uid, created = safe_create(
"name": prof.get("name", "游民"), "match_connections",
"location": prof.get("location", "全球"), {
"bio": prof.get("bio", ""), "pairKey": pk,
"photo": prof.get("photo", "🧑‍💻"), "userAId": user_id,
"tags": prof.get("tags", []), "userBId": target_uid,
} "profileBId": profile.get("id", ""),
return None "intent": intent,
"conversationId": conv.get("id", ""),
"matchedAt": _now_iso(),
},
)
return created
_jload()
for m in _j.get("matches") or []:
if m.get("pairKey") == pk:
return m
conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
match = {
"id": secrets.token_hex(6),
"pairKey": pk,
"userAId": user_id,
"userBId": target_uid,
"profileBId": profile.get("id"),
"intent": intent,
"conversationId": conv["id"],
"matchedAt": _now_iso(),
}
_j.setdefault("matches", []).append(match)
_jsave()
return match
def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict: def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict:
_reload()
if action not in LIKE_ACTIONS and action != "dislike": if action not in LIKE_ACTIONS and action != "dislike":
action = "dislike" action = "dislike"
if action == "superlike" and not is_vip(user_id): if action == "superlike" and not is_vip(user_id):
return {"ok": False, "error": "vip_required"} return {"ok": False, "error": "vip_required"}
q = get_quota(user_id) qdata = get_quota(user_id)
if not q["vip"] and q["remaining"] <= 0 and action in LIKE_ACTIONS: if not qdata["vip"] and qdata["remaining"] <= 0 and action in LIKE_ACTIONS:
return {"ok": False, "error": "quota_exceeded"} return {"ok": False, "error": "quota_exceeded"}
for s in _swipes: if use_pb():
if s.get("userId") == user_id and s.get("profileId") == profile_id: dup = safe_first(
"swipes",
filter=f"userId={q(user_id)} && profileId={q(profile_id)}",
)
if dup:
return {"ok": False, "error": "duplicate"} return {"ok": False, "error": "duplicate"}
else:
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == user_id and s.get("profileId") == profile_id:
return {"ok": False, "error": "duplicate"}
profile = _find_profile(profile_id) profile = _find_profile(profile_id)
if not profile: if not profile:
return {"ok": False, "error": "not_found"} return {"ok": False, "error": "not_found"}
_swipes.append({ swipe_payload = {
"id": secrets.token_hex(6),
"userId": user_id, "userId": user_id,
"profileId": profile_id, "profileId": profile_id,
"action": action, "action": action,
"intent": intent, "intent": intent,
"date": _today_key(), "swipeDate": _today_key(),
"createdAt": _now_iso(), }
}) if use_pb():
_persist() safe_create("swipes", swipe_payload)
else:
_jload()
_j.setdefault("swipes", []).append({**swipe_payload, "id": secrets.token_hex(6), "date": _today_key(), "createdAt": _now_iso()})
_jsave()
matched = None matched = None
if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id): if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id):
@ -317,118 +398,153 @@ def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dic
if matched: if matched:
from app.services import community_store from app.services import community_store
my_name = _profiles.get(user_id, {}).get("name", "游民") my_prof = get_public_profile(user_id) or {}
peer_name = profile.get("name", "游民") peer_name = profile.get("name", "游民")
conv_id = matched.get("conversationId", "") conv_id = matched.get("conversationId", "")
link = f"/chat/{conv_id}" if conv_id else "/chat" link = f"/chat/{conv_id}" if conv_id else "/chat"
community_store.add_notification( community_store.add_notification(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link)
user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link
)
peer_uid = profile.get("userId") peer_uid = profile.get("userId")
if peer_uid: if peer_uid:
community_store.add_notification( community_store.add_notification(
peer_uid, "匹配成功 🎉", f"你和 {my_name} 互相喜欢了,快去聊天吧", link peer_uid, "匹配成功 🎉", f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧", link
) )
return { return {"ok": True, "matched": bool(matched), "match": matched}
"ok": True,
"matched": bool(matched),
"match": matched,
}
def get_public_profile(user_id: str) -> dict | None:
_reload()
p = _profiles.get(user_id)
if not p:
return None
return {**p, "vip": is_vip(user_id)}
def undo_last_swipe(user_id: str) -> dict: def undo_last_swipe(user_id: str) -> dict:
_reload() if use_pb():
for i in range(len(_swipes) - 1, -1, -1): rows = safe_list("swipes", filter=f"userId={q(user_id)}")
if _swipes[i].get("userId") == user_id: rows.sort(key=lambda r: r.get("created", ""), reverse=True)
removed = _swipes.pop(i) if not rows:
_persist() return {"ok": False, "error": "nothing_to_undo"}
last = rows[0]
try:
pb.delete_record("swipes", last["id"])
except Exception:
pass
return {"ok": True, "removed": last}
_jload()
swipes = _j.get("swipes") or []
for i in range(len(swipes) - 1, -1, -1):
if swipes[i].get("userId") == user_id:
removed = swipes.pop(i)
_jsave()
return {"ok": True, "removed": removed} return {"ok": True, "removed": removed}
return {"ok": False, "error": "nothing_to_undo"} return {"ok": False, "error": "nothing_to_undo"}
def list_likes_received(user_id: str) -> list[dict]: def list_likes_received(user_id: str) -> list[dict]:
"""Profiles who liked this user's profile (simulated from static pool)."""
_reload()
my_pid = f"user-{user_id}" my_pid = f"user-{user_id}"
likers = [] likers = []
for s in _swipes: if use_pb():
if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS: for s in safe_list("swipes", filter=f"profileId={q(my_pid)}"):
uid = s.get("userId") if s.get("action") not in LIKE_ACTIONS:
p = _profiles.get(uid) continue
p = _peer_profile(s.get("userId", ""))
if p: if p:
likers.append({**p, "id": f"user-{uid}", "userId": uid}) likers.append(p)
return likers
_jload()
for s in _j.get("swipes") or []:
if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS:
p = _peer_profile(s.get("userId", ""))
if p:
likers.append(p)
return likers return likers
def list_likes(user_id: str) -> list[dict]: def list_likes(user_id: str) -> list[dict]:
_reload()
liked_ids = [s["profileId"] for s in _swipes if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS]
out = [] out = []
for pid in liked_ids: if use_pb():
p = _find_profile(pid) for s in safe_list("swipes", filter=f"userId={q(user_id)}"):
if p: if s.get("action") in LIKE_ACTIONS:
out.append(p) p = _find_profile(s.get("profileId", ""))
if p:
out.append(p)
return out
_jload()
for s in _j.get("swipes") or []:
if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS:
p = _find_profile(s.get("profileId", ""))
if p:
out.append(p)
return out return out
def list_mutual(user_id: str) -> list[dict]: def list_mutual(user_id: str) -> list[dict]:
_reload()
items = [] items = []
for m in _matches: if use_pb():
rows = safe_list("match_connections", filter=f"userAId={q(user_id)} || userBId={q(user_id)}")
for m in rows:
peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"]
items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")})
return items
_jload()
for m in _j.get("matches") or []:
if user_id in (m.get("userAId"), m.get("userBId")): if user_id in (m.get("userAId"), m.get("userBId")):
peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"] peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"]
peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None) items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")})
items.append({**m, "peer": peer, "conversationId": m.get("conversationId")})
return items return items
def list_conversations(user_id: str) -> list[dict]: def list_conversations(user_id: str) -> list[dict]:
_reload()
items = [] items = []
for c in _conversations: if use_pb():
rows = safe_list("conversations", filter=f"userAId={q(user_id)} || userBId={q(user_id)}")
rows.sort(key=lambda c: c.get("lastMessageAt") or c.get("created", ""), reverse=True)
for c in rows:
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
read_at = (c.get("readState") or {}).get(user_id, "")
unread = 1 if (c.get("lastMessageAt") or "") > read_at else 0
items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread})
return items
_jload()
for c in _j.get("conversations") or []:
if user_id not in (c.get("userAId"), c.get("userBId")): if user_id not in (c.get("userAId"), c.get("userBId")):
continue continue
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None)
read_at = (c.get("readState") or {}).get(user_id, "") read_at = (c.get("readState") or {}).get(user_id, "")
unread = 1 if c.get("lastMessageAt", "") > read_at else 0 unread = 1 if c.get("lastMessageAt", "") > read_at else 0
items.append({ items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread})
**c,
"peer": peer,
"unreadCount": unread,
})
items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True) items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True)
return items return items
def get_conversation(conv_id: str, user_id: str) -> dict | None: def get_conversation(conv_id: str, user_id: str) -> dict | None:
for c in _conversations: if use_pb():
row = safe_first("conversations", filter=f"id={q(conv_id)}")
if not row or user_id not in (row.get("userAId"), row.get("userBId")):
return None
peer_id = row["userBId"] if row["userAId"] == user_id else row["userAId"]
return {**row, "peer": _peer_profile(peer_id)}
_jload()
for c in _j.get("conversations") or []:
if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")): if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")):
peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"]
peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None) return {**c, "peer": _peer_profile(peer_id)}
return {**c, "peer": peer}
return None return None
def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]: def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]:
_reload()
conv = get_conversation(conv_id, user_id) conv = get_conversation(conv_id, user_id)
if not conv: if not conv:
return [] return []
msgs = [m for m in _messages if m.get("conversationId") == conv_id] if use_pb():
msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}")
msgs.sort(key=lambda m: m.get("created", ""))
read_state = dict(conv.get("readState") or {})
read_state[user_id] = _now_iso()
safe_update("conversations", conv_id, {"readState": read_state})
return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]]
_jload()
msgs = [m for m in _j.get("messages") or [] if m.get("conversationId") == conv_id]
msgs.sort(key=lambda x: x.get("createdAt", "")) msgs.sort(key=lambda x: x.get("createdAt", ""))
if conv.get("readState") is not None: for c in _j.get("conversations") or []:
conv["readState"][user_id] = _now_iso() if c["id"] == conv_id:
_persist() c.setdefault("readState", {})[user_id] = _now_iso()
_jsave()
break
return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]] return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]]
@ -437,6 +553,22 @@ def send_message(conv_id: str, user_id: str, body: str) -> dict | None:
if not conv or not body.strip(): if not conv or not body.strip():
return None return None
body = body.strip()[:2000] body = body.strip()[:2000]
if use_pb():
msg = safe_create(
"messages",
{"conversationId": conv_id, "senderId": user_id, "body": body},
)
if not msg:
return None
read_state = dict(conv.get("readState") or {})
read_state[user_id] = _now_iso()
safe_update(
"conversations",
conv_id,
{"lastMessageAt": _now_iso(), "lastMessagePreview": body[:80], "readState": read_state},
)
return {**msg, "mine": True, "createdAt": _now_iso()}
_jload()
msg = { msg = {
"id": secrets.token_hex(8), "id": secrets.token_hex(8),
"conversationId": conv_id, "conversationId": conv_id,
@ -444,13 +576,13 @@ def send_message(conv_id: str, user_id: str, body: str) -> dict | None:
"body": body, "body": body,
"createdAt": _now_iso(), "createdAt": _now_iso(),
} }
_messages.append(msg) _j.setdefault("messages", []).append(msg)
for c in _conversations: for c in _j.get("conversations") or []:
if c["id"] == conv_id: if c["id"] == conv_id:
c["lastMessageAt"] = _now_iso() c["lastMessageAt"] = _now_iso()
c["lastMessagePreview"] = body[:80] c["lastMessagePreview"] = body[:80]
break break
_persist() _jsave()
return {**msg, "mine": True} return {**msg, "mine": True}
@ -458,30 +590,65 @@ def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool =
order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}" order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}"
status = "paid" if dev_auto_pay else "pending" status = "paid" if dev_auto_pay else "pending"
order = { order = {
"id": order_id, "orderId": order_id,
"userId": user_id, "userId": user_id,
"payType": pay_type, "payType": pay_type,
"amount": amount, "amount": amount,
"status": status, "status": status,
"createdAt": _now_iso(),
} }
_orders[order_id] = order if use_pb():
safe_create("orders", order)
else:
_jload()
_j.setdefault("orders", {})[order_id] = {**order, "id": order_id, "createdAt": _now_iso()}
_jsave()
if dev_auto_pay: if dev_auto_pay:
ensure_membership(user_id) ensure_membership(user_id)
_persist() return {"id": order_id, **order, "createdAt": _now_iso()}
return order
def get_order(order_id: str) -> dict | None: def get_order(order_id: str) -> dict | None:
_reload() if use_pb():
return _orders.get(order_id) row = safe_first("orders", filter=f"orderId={q(order_id)}")
return row
_jload()
return (_j.get("orders") or {}).get(order_id)
def mark_order_paid(order_id: str) -> dict | None: def mark_order_paid(order_id: str) -> dict | None:
order = _orders.get(order_id) if use_pb():
row = safe_first("orders", filter=f"orderId={q(order_id)}")
if not row:
return None
safe_update("orders", row["id"], {"status": "paid"})
ensure_membership(row.get("userId", ""))
return {**row, "status": "paid"}
_jload()
order = (_j.get("orders") or {}).get(order_id)
if not order: if not order:
return None return None
order["status"] = "paid" order["status"] = "paid"
ensure_membership(order["userId"]) ensure_membership(order["userId"])
_persist() _jsave()
return order return order
# ── JSON fallback ──────────────────────────────────────────────────────
def _jload() -> dict:
global _j
if _j:
return _j
if _JSON_PATH.exists():
try:
_j = json.loads(_JSON_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
_j = {}
else:
_j = {}
return _j
def _jsave() -> None:
_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
_JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8")

View File

@ -5,3 +5,5 @@ pydantic[email]==2.10.4
pydantic-settings==2.7.0 pydantic-settings==2.7.0
python-dotenv==1.0.1 python-dotenv==1.0.1
email-validator==2.2.0 email-validator==2.2.0
boto3==1.35.99
python-multipart==0.0.20

View File

@ -0,0 +1,380 @@
#!/usr/bin/env python3
"""Bootstrap PocketBase collections for nomadweb (run on server after deploy)."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.data import community_data # noqa: E402
from app.services.pb_client import PocketBaseError, pb # noqa: E402
def text(name: str, *, required: bool = False, max: int = 0) -> dict[str, Any]:
return {"name": name, "type": "text", "required": required, "max": max}
def number(name: str, *, only_int: bool = False) -> dict[str, Any]:
return {"name": name, "type": "number", "onlyInt": only_int}
def boolean(name: str) -> dict[str, Any]:
return {"name": name, "type": "bool"}
def json_field(name: str) -> dict[str, Any]:
return {"name": name, "type": "json"}
def date(name: str) -> dict[str, Any]:
return {"name": name, "type": "date"}
COLLECTIONS: dict[str, list[dict[str, Any]]] = {
"destinations": [
text("slug", required=True, max=80),
text("name", required=True, max=120),
text("country", max=80),
text("emoji", max=16),
text("region", max=80),
number("cost", only_int=True),
number("speed", only_int=True),
number("temperature", only_int=True),
number("rating"),
number("hue", only_int=True),
text("tag", max=80),
text("description", max=500),
text("nomads_count", max=40),
json_field("highlights"),
number("map_x"),
number("map_y"),
],
"discussions": [
text("title", required=True, max=255),
text("author", max=120),
text("authorUserId", max=80),
text("authorEmoji", max=16),
text("category", max=80),
json_field("tags"),
text("excerpt", max=1000),
number("replies", only_int=True),
number("views", only_int=True),
number("likes", only_int=True),
boolean("pinned"),
text("legacyId", max=120),
],
"discussion_replies": [
text("discussionId", required=True, max=80),
text("userId", max=80),
text("author", max=120),
text("authorEmoji", max=16),
text("body", required=True, max=2000),
],
"discussion_likes": [
text("discussionId", required=True, max=80),
text("userId", required=True, max=80),
],
"meetups": [
text("legacyId", max=120),
text("title", required=True, max=255),
text("city", max=120),
text("destinationSlug", max=80),
text("emoji", max=16),
date("date"),
text("time", max=20),
text("venue", max=255),
text("description", max=1000),
number("rsvpCount", only_int=True),
number("maxAttendees", only_int=True),
text("organizer", max=120),
text("organizerId", max=80),
text("mode", max=40),
text("accessLevel", max=40),
text("mirotalkRoom", max=160),
text("loungeChannel", max=120),
json_field("tags"),
boolean("isUpcoming"),
text("status", max=40),
],
"meetup_rsvps": [
text("meetupId", required=True, max=80),
text("userId", required=True, max=80),
text("status", max=40),
],
"gigs": [
text("legacyId", max=120),
text("title", required=True, max=255),
text("category", max=120),
number("budget", only_int=True),
text("location", max=120),
text("description", max=1000),
text("status", max=40),
text("posterId", max=80),
text("posterName", max=120),
],
"gig_applications": [
text("gigId", required=True, max=80),
text("userId", max=80),
text("applicant", max=255),
text("message", max=1000),
text("status", max=40),
],
"profiles": [
text("userId", max=80),
text("email", max=255),
text("name", max=120),
number("age", only_int=True),
text("location", max=120),
json_field("tags"),
text("bio", max=1000),
text("photo", max=500),
text("gender", max=40),
json_field("lookingFor"),
text("citySlug", max=80),
],
"swipes": [
text("userId", max=80),
text("profileId", max=80),
text("action", max=40),
text("intent", max=40),
text("swipeDate", max=20),
],
"match_connections": [
text("pairKey", max=160),
text("userAId", max=80),
text("userBId", max=80),
text("intent", max=40),
text("conversationId", max=80),
date("matchedAt"),
],
"conversations": [
text("pairKey", max=160),
text("userAId", max=80),
text("userBId", max=80),
text("intent", max=40),
text("lastMessagePreview", max=200),
date("lastMessageAt"),
json_field("readState"),
],
"messages": [
text("conversationId", required=True, max=80),
text("senderId", required=True, max=80),
text("body", required=True, max=2000),
],
"notifications": [
text("title", required=True, max=255),
text("body", max=2000),
text("category", max=80),
text("targetUserId", max=80),
text("actionUrl", max=500),
text("status", max=40),
boolean("read"),
boolean("archived"),
boolean("pinned"),
],
"nomad_accounts": [
text("email", required=True, max=255),
text("passwordHash", max=128),
text("name", max=120),
text("avatar", max=16),
text("legacyUserId", max=80),
json_field("favorites"),
json_field("plan"),
],
"nomad_sessions": [
text("token", required=True, max=128),
text("userId", required=True, max=80),
number("expiresAt", only_int=True),
],
"media_assets": [
text("userId", max=80),
text("url", required=True, max=500),
text("filename", max=255),
text("contentType", max=120),
number("size", only_int=True),
text("objectKey", max=500),
],
"subscriptions": [
text("email", required=True, max=255),
],
"visas": [text("country", max=80), text("type", max=80), number("difficulty", only_int=True)],
"faqs": [text("question", max=500), text("answer", max=2000), number("order", only_int=True)],
"blog_posts": [
text("slug", max=120),
text("title", max=255),
text("excerpt", max=500),
date("published_at"),
],
"tools": [text("name", max=120), text("url", max=500), text("description", max=500)],
"memberships": [
text("userId", required=True, max=80),
text("plan", max=40),
number("expiresAt", only_int=True),
],
"orders": [
text("orderId", required=True, max=120),
text("userId", max=120),
text("payType", max=60),
number("amount", only_int=True),
text("status", max=40),
],
"feedback": [
text("type", max=40),
text("email", max=255),
text("title", max=255),
text("content", max=2000),
text("status", max=40),
],
"service_leads": [
text("serviceSlug", required=True, max=120),
text("userId", max=80),
text("email", max=255),
text("note", max=1000),
text("status", max=40),
],
"content_submissions": [
text("title", required=True, max=255),
text("type", max=40),
text("mediaUrl", max=500),
text("submittedBy", max=80),
text("authorName", max=120),
text("description", max=2000),
text("reviewStatus", max=40),
],
"volunteer_applications": [
text("citySlug", required=True, max=120),
text("userId", max=80),
text("applicantName", max=140),
text("email", max=255),
text("motivation", max=2000),
text("status", max=40),
],
"reports": [
text("reporterId", max=80),
text("targetType", max=80),
text("targetId", max=120),
text("reason", max=2000),
text("status", max=40),
],
"notification_preferences": [
text("userId", required=True, max=80),
json_field("channels"),
],
}
def collection_exists(name: str) -> bool:
try:
pb.request("GET", f"/api/collections/{name}", admin=True)
return True
except PocketBaseError:
return False
def ensure_collection(name: str, fields: list[dict[str, Any]]) -> None:
if collection_exists(name):
print(f"exists: {name}")
return
payload = {
"name": name,
"type": "base",
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"fields": fields,
}
pb.request("POST", "/api/collections", admin=True, json=payload)
print(f"created: {name}")
def seed_if_empty(collection: str, records: list[dict[str, Any]]) -> None:
data = pb.list_records(collection, per_page=1)
if (data.get("totalItems") or 0) > 0:
print(f"seed skip: {collection}")
return
for record in records:
pb.create_record(collection, record)
print(f"seeded: {collection} ({len(records)})")
def meetup_payload(m: dict[str, Any]) -> dict[str, Any]:
date_val = m.get("date")
if date_val and "T" not in str(date_val):
date_val = f"{date_val} 00:00:00.000Z"
return {
"legacyId": m.get("id", ""),
"title": m.get("title", ""),
"city": m.get("city", ""),
"destinationSlug": m.get("destination_slug", ""),
"emoji": m.get("emoji", ""),
"date": date_val,
"time": m.get("time", ""),
"venue": m.get("venue", ""),
"description": m.get("description", ""),
"rsvpCount": m.get("rsvp_count", 0),
"maxAttendees": m.get("max_attendees", 30),
"organizer": m.get("organizer", ""),
"mode": m.get("mode", "offline"),
"accessLevel": m.get("access_level", "public"),
"mirotalkRoom": m.get("mirotalkRoom", ""),
"loungeChannel": m.get("loungeChannel", ""),
"tags": m.get("tags", []),
"isUpcoming": m.get("is_upcoming", True),
"status": "published",
}
def discussion_payload(d: dict[str, Any]) -> dict[str, Any]:
return {
"legacyId": d.get("id", ""),
"title": d.get("title", ""),
"author": d.get("author", ""),
"authorUserId": d.get("author_id", ""),
"authorEmoji": d.get("author_emoji", "🧑‍💻"),
"category": d.get("category", "社区"),
"tags": d.get("tags", []),
"excerpt": d.get("excerpt", ""),
"replies": d.get("reply_count", 0),
"views": d.get("view_count", 0),
"likes": d.get("like_count", 0),
"pinned": d.get("is_pinned", False),
}
def main() -> None:
if not pb.health_ok():
print("PocketBase not reachable")
sys.exit(1)
pb.admin_token()
for name, fields in COLLECTIONS.items():
ensure_collection(name, fields)
seed_if_empty("meetups", [meetup_payload(m) for m in community_data.MEETUPS])
seed_if_empty("discussions", [discussion_payload(d) for d in community_data.DISCUSSIONS])
gigs = getattr(community_data, "GIGS", [])
if gigs:
seed_if_empty(
"gigs",
[
{
"legacyId": g.get("id", ""),
"title": g.get("title", ""),
"category": g.get("category", ""),
"budget": g.get("budget", 0),
"location": g.get("location", ""),
"description": g.get("description", ""),
"status": g.get("status", "open"),
}
for g in gigs
],
)
print("done")
if __name__ == "__main__":
main()

View File

@ -5,6 +5,25 @@ PAYMENT_JOIN_AMOUNT=10000
PAYMENT_DEFAULT_AMOUNT=10000 PAYMENT_DEFAULT_AMOUNT=10000
SITE_BASE_URL=https://nomadweb.nomadro.com SITE_BASE_URL=https://nomadweb.nomadro.com
# PocketBase (host process :8090)
POCKETBASE_URL=http://127.0.0.1:8090
POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
POCKETBASE_ADMIN_PASSWORD=
# SeaweedFS S3 (s3.nomadro.com → 127.0.0.1:8333)
S3_ENABLED=true
S3_ENDPOINT=http://127.0.0.1:8333
S3_PUBLIC_URL=https://s3.nomadro.com
S3_ACCESS_KEY=nomadweb
S3_SECRET_KEY=
S3_BUCKET=nomadweb
S3_REGION=us-east-1
S3_UPLOAD_PREFIX=nomadweb
# ntfy push (docker on :2586)
NTFY_ENABLED=true
NTFY_URL=http://127.0.0.1:2586
ZPAY_PID= ZPAY_PID=
ZPAY_KEY= ZPAY_KEY=
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php

View File

@ -8,7 +8,7 @@ WorkingDirectory=/opt/nomadweb/backend
EnvironmentFile=/opt/nomadweb/deploy/nomadro-api.env EnvironmentFile=/opt/nomadweb/deploy/nomadro-api.env
Environment=POCKETBASE_URL=http://127.0.0.1:8090 Environment=POCKETBASE_URL=http://127.0.0.1:8090
Environment=POCKETBASE_ADMIN_EMAIL=admin@nomadro.com Environment=POCKETBASE_ADMIN_EMAIL=admin@nomadro.com
Environment=POCKETBASE_ADMIN_PASSWORD=admin123456 Environment=POCKETBASE_ADMIN_PASSWORD=Xiao4669805
Environment=CORS_ORIGINS=https://nomadweb.nomadro.com,http://nomadweb.nomadro.com Environment=CORS_ORIGINS=https://nomadweb.nomadro.com,http://nomadweb.nomadro.com
ExecStart=/opt/nomadweb/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8055 --workers 2 ExecStart=/opt/nomadweb/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8055 --workers 2
Restart=always Restart=always

View File

@ -0,0 +1,84 @@
"""Upload backend hotfix files and restart API (no git commit required)."""
from __future__ import annotations
import sys
import time
from pathlib import Path
import paramiko
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
HOST = "107.173.30.245"
USER = "root"
PASS = "Xiao4669805"
REMOTE = "/opt/nomadweb"
ROOT = Path(__file__).resolve().parents[1]
FILES = [
"backend/app/config.py",
"backend/app/main.py",
"backend/app/services/pb_client.py",
"backend/app/services/pb_repo.py",
"backend/app/services/s3_storage.py",
"backend/app/services/ntfy_push.py",
"backend/app/services/pocketbase.py",
"backend/app/services/auth.py",
"backend/app/services/community_store.py",
"backend/app/services/social_store.py",
"backend/app/services/platform_store.py",
"backend/app/routers/media.py",
"backend/requirements.txt",
"backend/scripts/init_pb_collections.py",
"deploy/systemd/nomadro-api.service",
"deploy/nomadro-api.env.example",
]
def main() -> int:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASS, timeout=30)
sftp = client.open_sftp()
for rel in FILES:
local = ROOT / rel
remote = f"{REMOTE}/{rel.replace(chr(92), '/')}"
remote_dir = "/".join(remote.split("/")[:-1])
try:
sftp.stat(remote_dir)
except OSError:
parts = remote_dir.split("/")
for i in range(2, len(parts) + 1):
p = "/".join(parts[:i])
try:
sftp.stat(p)
except OSError:
sftp.mkdir(p)
print("upload", rel)
sftp.put(str(local), remote)
sftp.close()
cmd = f"""
set -e
install -m 644 {REMOTE}/deploy/systemd/nomadro-api.service /etc/systemd/system/nomadro-api.service
systemctl daemon-reload
cd {REMOTE}/backend
.venv/bin/pip install -q -r requirements.txt
.venv/bin/python scripts/init_pb_collections.py || true
systemctl restart nomadro-api
sleep 2
curl -s http://127.0.0.1:8055/api/v1/health
"""
_, stdout, stderr = client.exec_command(cmd, timeout=180)
print(stdout.read().decode())
err = stderr.read().decode()
if err.strip():
print("ERR:", err)
client.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -138,6 +138,8 @@ build_backend() {
cp requirements.txt .venv/.reqsha cp requirements.txt .venv/.reqsha
fi fi
systemctl restart nomadro-api systemctl restart nomadro-api
# Ensure PocketBase collections exist (idempotent)
.venv/bin/python scripts/init_pb_collections.py || echo "PB init skipped"
} }
CHANGED="${CHANGED:-}" CHANGED="${CHANGED:-}"

View File

@ -0,0 +1,86 @@
"""One-time production setup: S3 identity, API env, PocketBase collections."""
from __future__ import annotations
import json
import secrets
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
HOST = "107.173.30.245"
USER = "root"
PASS = "Xiao4669805"
REMOTE = "/opt/nomadweb"
def run(client: paramiko.SSHClient, cmd: str, timeout: int = 120) -> tuple[int, str]:
print(">>", cmd[:180])
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
code = stdout.channel.recv_exit_status()
if out.strip():
print(out[-3000:])
if err.strip():
print("ERR:", err[-1000:])
return code, out
def main() -> None:
s3_secret = secrets.token_urlsafe(24)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASS, timeout=25)
# Add nomadweb S3 identity if missing
_, s3_raw = run(client, "cat /etc/seaweedfs/s3.json")
s3_cfg = json.loads(s3_raw)
identities = s3_cfg.get("identities", [])
nomadweb_id = next((i for i in identities if i.get("name") == "nomadweb"), None)
if nomadweb_id:
creds = (nomadweb_id.get("credentials") or [{}])[0]
s3_secret = creds.get("secretKey") or s3_secret
print("S3 identity nomadweb already exists")
else:
identities.append({
"name": "nomadweb",
"credentials": [{"accessKey": "nomadweb", "secretKey": s3_secret}],
"actions": ["Admin", "Read", "Write", "List", "Tagging"],
})
s3_cfg["identities"] = identities
payload = json.dumps(s3_cfg, indent=2)
run(client, f"cat > /etc/seaweedfs/s3.json <<'EOF'\n{payload}\nEOF")
run(client, "systemctl restart seaweed-s3.service")
print("S3 identity nomadweb created")
# Patch nomadro-api.env (idempotent keys)
patch = {
"POCKETBASE_URL": "http://127.0.0.1:8090",
"POCKETBASE_ADMIN_EMAIL": "admin@nomadro.com",
"POCKETBASE_ADMIN_PASSWORD": "Xiao4669805",
"S3_ENABLED": "true",
"S3_ENDPOINT": "http://127.0.0.1:8333",
"S3_PUBLIC_URL": "https://s3.nomadro.com",
"S3_ACCESS_KEY": "nomadweb",
"S3_SECRET_KEY": s3_secret,
"S3_BUCKET": "nomadweb",
"S3_REGION": "us-east-1",
"S3_UPLOAD_PREFIX": "nomadweb",
"NTFY_ENABLED": "true",
"NTFY_URL": "http://127.0.0.1:2586",
}
py = "import pathlib\np=pathlib.Path('/opt/nomadweb/deploy/nomadro-api.env')\ntext=p.read_text(encoding='utf-8') if p.exists() else ''\nlines=text.splitlines()\nkeys={}\nfor k,v in " + repr(list(patch.items())) + ":\n found=False\n for i,line in enumerate(lines):\n if line.startswith(k+'='):\n lines[i]=k+'='+v\n found=True\n break\n if not found:\n lines.append(k+'='+v)\np.write_text('\\n'.join(lines).rstrip()+'\\n', encoding='utf-8')\n"
run(client, f"python3 -c {json.dumps(py)}")
# Fix systemd PB password
run(client, "sed -i 's/POCKETBASE_ADMIN_PASSWORD=admin123456/POCKETBASE_ADMIN_PASSWORD=Xiao4669805/' /etc/systemd/system/nomadro-api.service")
run(client, "systemctl daemon-reload")
client.close()
print("\nProduction env patched. Run deploy_update.py to pull code + init PB.")
if __name__ == "__main__":
main()