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:
parent
48408d3079
commit
96fa96c61e
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@ frontend/.env.local
|
||||
# Auth / plan user store (runtime)
|
||||
backend/app/data/user_store.json
|
||||
backend/app/data/social_store.json
|
||||
backend/app/data/community_store.json
|
||||
backend/app/data/platform_store.json
|
||||
|
||||
# Production secrets (use deploy/nomadro-api.env.example)
|
||||
|
||||
@ -4,10 +4,31 @@
|
||||
class Settings(BaseSettings):
|
||||
pocketbase_url: str = "http://127.0.0.1:8090"
|
||||
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_port: int = 8000
|
||||
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
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
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(
|
||||
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(payment.router, prefix="/api/v1")
|
||||
app.include_router(platform.router, prefix="/api/v1")
|
||||
app.include_router(media.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
58
backend/app/routers/media.py
Normal file
58
backend/app/routers/media.py
Normal 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,
|
||||
}
|
||||
@ -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
|
||||
|
||||
import hashlib
|
||||
@ -8,53 +8,22 @@ 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] = {} # token -> user_id
|
||||
_favorites: dict[str, list[str]] = {} # user_id -> [slugs]
|
||||
_plans: dict[str, dict] = {} # user_id -> { items, meta, updated_at }
|
||||
_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 _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]:
|
||||
return {
|
||||
"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:
|
||||
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)
|
||||
@ -81,69 +181,76 @@ def register_user(email: str, password: str, name: str) -> dict[str, Any] | None
|
||||
}
|
||||
_favorites[uid] = []
|
||||
_plans[uid] = _empty_plan()
|
||||
token = secrets.token_urlsafe(32)
|
||||
_sessions[token] = uid
|
||||
_persist()
|
||||
return {"token": token, "user": _user_profile(_users[email])}
|
||||
_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
|
||||
token = secrets.token_urlsafe(32)
|
||||
_sessions[token] = user["id"]
|
||||
_persist()
|
||||
return {"token": token, "user": _user_profile(user)}
|
||||
return _issue_login(user)
|
||||
|
||||
|
||||
def login_google(email: str, name: str) -> dict[str, Any] | None:
|
||||
if email not in _users:
|
||||
if use_pb():
|
||||
user = _get_account_by_email(email)
|
||||
if not user:
|
||||
uid = secrets.token_hex(8)
|
||||
_users[email] = {
|
||||
"id": uid,
|
||||
row = safe_create(
|
||||
"nomad_accounts",
|
||||
{
|
||||
"email": email,
|
||||
"password_hash": _hash_password(secrets.token_hex(16)),
|
||||
"passwordHash": _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)}
|
||||
"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:
|
||||
created = register_user(email, "demo123", "演示用户")
|
||||
return created
|
||||
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
|
||||
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:
|
||||
_reload_sessions()
|
||||
if not uid and _restore_json():
|
||||
uid = _sessions.get(token)
|
||||
if not uid:
|
||||
return None
|
||||
@ -153,30 +260,61 @@ def get_user_by_token(token: str) -> dict | 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]:
|
||||
uid = _sessions.get(token)
|
||||
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 = _sessions.get(token)
|
||||
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()
|
||||
_persist_json()
|
||||
return list(favs)
|
||||
|
||||
|
||||
def get_plan(token: str) -> dict[str, Any] | None:
|
||||
uid = _sessions.get(token)
|
||||
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 [],
|
||||
@ -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:
|
||||
uid = _sessions.get(token)
|
||||
uid = _token_user_id(token)
|
||||
if not uid:
|
||||
return None
|
||||
ts = int(updated_at or time.time() * 1000)
|
||||
_plans[uid] = {
|
||||
"items": items,
|
||||
"meta": meta,
|
||||
"updated_at": ts,
|
||||
}
|
||||
_persist()
|
||||
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)
|
||||
|
||||
|
||||
# Boot: restore disk store or seed demo
|
||||
if not _restore():
|
||||
# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
|
||||
if not use_pb():
|
||||
if not _restore_json():
|
||||
login_demo()
|
||||
elif "demo@nomadro.com" not in _users:
|
||||
login_demo()
|
||||
else:
|
||||
_migrate_json_to_pb()
|
||||
if not _get_account_by_email("demo@nomadro.com"):
|
||||
register_user("demo@nomadro.com", "demo123", "演示用户")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
31
backend/app/services/ntfy_push.py
Normal file
31
backend/app/services/ntfy_push.py
Normal 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
|
||||
165
backend/app/services/pb_client.py
Normal file
165
backend/app/services/pb_client.py
Normal 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()
|
||||
80
backend/app/services/pb_repo.py
Normal file
80
backend/app/services/pb_repo.py
Normal 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)}")
|
||||
@ -1,5 +1,4 @@
|
||||
"""Platform persistence: leads, submissions, volunteers, reports, newsletter."""
|
||||
|
||||
"""Platform persistence — PocketBase backed."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@ -7,124 +6,134 @@ import secrets
|
||||
from datetime import datetime, timezone
|
||||
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] = []
|
||||
_submissions: list[dict] = []
|
||||
_volunteers: list[dict] = []
|
||||
_reports: list[dict] = []
|
||||
_newsletter: list[dict] = []
|
||||
_notif_prefs: dict[str, dict] = {}
|
||||
_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json"
|
||||
_j: dict = {}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _persist() -> None:
|
||||
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
STORE_PATH.write_text(json.dumps({
|
||||
"leads": _leads,
|
||||
"submissions": _submissions,
|
||||
"volunteers": _volunteers,
|
||||
"reports": _reports,
|
||||
"newsletter": _newsletter,
|
||||
"notif_prefs": _notif_prefs,
|
||||
}, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def _restore() -> None:
|
||||
global _leads, _submissions, _volunteers, _reports, _newsletter, _notif_prefs
|
||||
if not STORE_PATH.exists():
|
||||
return
|
||||
def _jload() -> dict:
|
||||
global _j
|
||||
if _j:
|
||||
return _j
|
||||
if _JSON_PATH.exists():
|
||||
try:
|
||||
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||
_j = json.loads(_JSON_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 {}
|
||||
_j = {}
|
||||
else:
|
||||
_j = {}
|
||||
return _j
|
||||
|
||||
|
||||
_restore()
|
||||
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")
|
||||
|
||||
|
||||
def add_service_lead(service_id: str, user_id: str | None, name: str, email: str, message: str) -> dict:
|
||||
item = {
|
||||
"id": secrets.token_hex(6),
|
||||
"service_id": service_id,
|
||||
"user_id": user_id or "",
|
||||
"name": name,
|
||||
"serviceSlug": service_id,
|
||||
"userId": user_id or "",
|
||||
"email": email,
|
||||
"message": message,
|
||||
"created_at": _now(),
|
||||
"note": f"{name}: {message}"[:1000],
|
||||
"status": "open",
|
||||
}
|
||||
_leads.append(item)
|
||||
_persist()
|
||||
return item
|
||||
if use_pb():
|
||||
row = safe_create("service_leads", 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:
|
||||
item = {
|
||||
"id": secrets.token_hex(6),
|
||||
"user_id": user_id or "",
|
||||
"name": name,
|
||||
if use_pb():
|
||||
row = safe_create(
|
||||
"content_submissions",
|
||||
{
|
||||
"title": title,
|
||||
"content_type": content_type,
|
||||
"url": url,
|
||||
"notes": notes,
|
||||
"status": "pending",
|
||||
"created_at": _now(),
|
||||
}
|
||||
_submissions.append(item)
|
||||
_persist()
|
||||
return item
|
||||
"type": content_type,
|
||||
"mediaUrl": url,
|
||||
"submittedBy": user_id or "",
|
||||
"authorName": name,
|
||||
"description": notes[:2000],
|
||||
"reviewStatus": "pending",
|
||||
},
|
||||
)
|
||||
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:
|
||||
item = {
|
||||
"id": secrets.token_hex(6),
|
||||
"city_slug": city_slug,
|
||||
"user_id": user_id or "",
|
||||
"name": name,
|
||||
if use_pb():
|
||||
row = safe_create(
|
||||
"volunteer_applications",
|
||||
{
|
||||
"citySlug": city_slug,
|
||||
"userId": user_id or "",
|
||||
"applicantName": name,
|
||||
"email": email,
|
||||
"message": message,
|
||||
"created_at": _now(),
|
||||
}
|
||||
_volunteers.append(item)
|
||||
_persist()
|
||||
return item
|
||||
"motivation": message[:2000],
|
||||
"status": "pending",
|
||||
},
|
||||
)
|
||||
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:
|
||||
item = {
|
||||
"id": secrets.token_hex(6),
|
||||
"reporter_id": reporter_id or "",
|
||||
"target_type": target_type,
|
||||
"target_id": target_id,
|
||||
"reason": reason,
|
||||
if use_pb():
|
||||
row = safe_create(
|
||||
"reports",
|
||||
{
|
||||
"reporterId": reporter_id or "",
|
||||
"targetType": target_type,
|
||||
"targetId": target_id,
|
||||
"reason": reason[:2000],
|
||||
"status": "open",
|
||||
"created_at": _now(),
|
||||
}
|
||||
_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:
|
||||
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:
|
||||
return {"ok": True, "message": "已订阅"}
|
||||
_newsletter.append({"email": email, "source": source, "created_at": _now()})
|
||||
_persist()
|
||||
_j.setdefault("newsletter", []).append({"email": email, "source": source, "created_at": _now()})
|
||||
_jsave()
|
||||
return {"ok": True, "message": "订阅成功"}
|
||||
|
||||
|
||||
def get_notif_prefs(user_id: str) -> dict:
|
||||
return _notif_prefs.get(user_id) or {
|
||||
default = {
|
||||
"email": True,
|
||||
"push": False,
|
||||
"match": True,
|
||||
@ -133,9 +142,26 @@ def get_notif_prefs(user_id: str) -> dict:
|
||||
"marketing": False,
|
||||
"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:
|
||||
_notif_prefs[user_id] = {**get_notif_prefs(user_id), **prefs}
|
||||
_persist()
|
||||
return _notif_prefs[user_id]
|
||||
merged = {**get_notif_prefs(user_id), **prefs}
|
||||
if use_pb():
|
||||
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
|
||||
|
||||
@ -26,15 +26,9 @@ class PocketBaseService:
|
||||
if self._token:
|
||||
return True
|
||||
try:
|
||||
r = await client.post(
|
||||
f"{self.base_url}/api/admins/auth-with-password",
|
||||
json={
|
||||
"identity": settings.pocketbase_admin_email,
|
||||
"password": settings.pocketbase_admin_password,
|
||||
},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
self._token = r.json().get("token")
|
||||
from app.services.pb_client import pb as sync_pb
|
||||
if sync_pb.health_ok():
|
||||
self._token = sync_pb.admin_token()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
96
backend/app/services/s3_storage.py
Normal file
96
backend/app/services/s3_storage.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
import json
|
||||
@ -10,20 +9,15 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.data.social_profiles import CANDIDATE_PROFILES, MATCH_INTENTS
|
||||
|
||||
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json"
|
||||
from app.services.pb_client import pb, pb_quote
|
||||
from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb
|
||||
|
||||
LIKE_ACTIONS = frozenset({"like", "right", "superlike"})
|
||||
FREE_SWIPE_DAILY_LIMIT = 25
|
||||
VIP_SWIPE_LIMIT = 9999
|
||||
|
||||
_profiles: dict[str, dict] = {} # user_id -> profile
|
||||
_swipes: list[dict] = []
|
||||
_matches: list[dict] = []
|
||||
_conversations: list[dict] = []
|
||||
_messages: list[dict] = []
|
||||
_memberships: dict[str, dict] = {} # user_id -> {expires_at, plan}
|
||||
_orders: dict[str, dict] = {}
|
||||
_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json"
|
||||
_j: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
@ -34,67 +28,93 @@ def _today_key() -> str:
|
||||
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _persist() -> None:
|
||||
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
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 _pair_key(a: str, b: str) -> str:
|
||||
return "|".join(sorted([a, b]))
|
||||
|
||||
|
||||
def _restore() -> None:
|
||||
global _profiles, _swipes, _matches, _conversations, _messages, _memberships, _orders
|
||||
if not STORE_PATH.exists():
|
||||
return
|
||||
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 {}
|
||||
def _profile_looking_for(record: dict) -> list[str]:
|
||||
raw = record.get("lookingFor") or record.get("looking_for") or []
|
||||
cleaned = [x for x in raw if x in MATCH_INTENTS]
|
||||
return cleaned or ["friends", "explore"]
|
||||
|
||||
|
||||
_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:
|
||||
_restore()
|
||||
|
||||
# ── VIP / membership ───────────────────────────────────────────────────
|
||||
|
||||
def is_vip(user_id: str) -> bool:
|
||||
_reload()
|
||||
m = _memberships.get(user_id)
|
||||
if not m:
|
||||
if use_pb():
|
||||
row = safe_first("memberships", filter=f"userId={q(user_id)}")
|
||||
if not row:
|
||||
return False
|
||||
exp = m.get("expires_at") or 0
|
||||
return exp > int(time.time())
|
||||
return int(row.get("expiresAt") or 0) > 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:
|
||||
_memberships[user_id] = {
|
||||
"plan": "vip",
|
||||
"expires_at": int(time.time()) + days * 86400,
|
||||
"updated_at": _now_iso(),
|
||||
}
|
||||
_persist()
|
||||
exp = int(time.time()) + days * 86400
|
||||
if use_pb():
|
||||
row = safe_first("memberships", filter=f"userId={q(user_id)}")
|
||||
payload = {"userId": user_id, "plan": "vip", "expiresAt": exp}
|
||||
if row:
|
||||
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:
|
||||
if user_id not in _profiles:
|
||||
_profiles[user_id] = {
|
||||
if use_pb():
|
||||
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,
|
||||
"name": name,
|
||||
"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"]),
|
||||
"createdAt": _now_iso(),
|
||||
}
|
||||
_persist()
|
||||
return _profiles[user_id]
|
||||
_jsave()
|
||||
return profiles[user_id]
|
||||
|
||||
|
||||
def join_member(user_id: str, name: str, payload: dict) -> dict:
|
||||
_reload()
|
||||
profile = get_or_create_profile(
|
||||
return get_or_create_profile(
|
||||
user_id,
|
||||
name,
|
||||
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"]),
|
||||
photo=payload.get("photo", "🧑💻"),
|
||||
)
|
||||
_persist()
|
||||
return profile
|
||||
|
||||
|
||||
def _profile_looking_for(record: dict) -> list[str]:
|
||||
raw = record.get("lookingFor") or []
|
||||
cleaned = [x for x in raw if x in MATCH_INTENTS]
|
||||
return cleaned or ["friends", "explore"]
|
||||
def get_public_profile(user_id: str) -> dict | None:
|
||||
if use_pb():
|
||||
row = safe_first("profiles", filter=f"userId={q(user_id)}")
|
||||
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(
|
||||
user_id: str,
|
||||
intent: str = "friends",
|
||||
@ -142,19 +166,39 @@ def list_candidates(
|
||||
single: str = "",
|
||||
exclude_swiped: bool = True,
|
||||
) -> list[dict]:
|
||||
_reload()
|
||||
my_profile = _profiles.get(user_id)
|
||||
swiped_ids = set()
|
||||
swiped_ids: set[str] = set()
|
||||
if exclude_swiped:
|
||||
for s in _swipes:
|
||||
if use_pb():
|
||||
for s in safe_list("swipes", filter=f"userId={q(user_id)}"):
|
||||
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"))
|
||||
swiped_ids.add(s.get("profileId", ""))
|
||||
|
||||
pool = list(CANDIDATE_PROFILES)
|
||||
# Real members who completed join profile
|
||||
for uid, prof in _profiles.items():
|
||||
if uid == user_id:
|
||||
continue
|
||||
if use_pb():
|
||||
for row in safe_list("profiles"):
|
||||
uid = row.get("userId", "")
|
||||
if uid and uid != user_id:
|
||||
pool.append({
|
||||
"id": f"user-{uid}",
|
||||
"userId": uid,
|
||||
"name": row.get("name", "游民"),
|
||||
"location": row.get("location", "全球"),
|
||||
"citySlug": row.get("citySlug", ""),
|
||||
"gender": row.get("gender", ""),
|
||||
"single": row.get("single", ""),
|
||||
"bio": row.get("bio", ""),
|
||||
"photo": row.get("photo", "🧑💻"),
|
||||
"tags": row.get("tags") or [],
|
||||
"lookingFor": _profile_looking_for(row),
|
||||
})
|
||||
else:
|
||||
_jload()
|
||||
for uid, prof in (_j.get("profiles") or {}).items():
|
||||
if uid != user_id:
|
||||
pool.append({
|
||||
"id": f"user-{uid}",
|
||||
"userId": uid,
|
||||
@ -168,20 +212,18 @@ def list_candidates(
|
||||
"tags": prof.get("tags", []),
|
||||
"lookingFor": _profile_looking_for(prof),
|
||||
})
|
||||
if my_profile:
|
||||
pool = [p for p in pool if p.get("userId") != user_id]
|
||||
|
||||
results = []
|
||||
for p in pool:
|
||||
if p["id"] in swiped_ids:
|
||||
if p.get("userId") == user_id or p["id"] in swiped_ids:
|
||||
continue
|
||||
if intent and intent not in _profile_looking_for(p):
|
||||
continue
|
||||
if city and city not in (p.get("location") or "") and city != p.get("citySlug"):
|
||||
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
|
||||
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
|
||||
results.append({**p, "intent": intent})
|
||||
return results
|
||||
@ -189,61 +231,65 @@ def list_candidates(
|
||||
|
||||
def swipe_count_today(user_id: str) -> int:
|
||||
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:
|
||||
_reload()
|
||||
vip = is_vip(user_id)
|
||||
limit = VIP_SWIPE_LIMIT if vip else FREE_SWIPE_DAILY_LIMIT
|
||||
used = swipe_count_today(user_id)
|
||||
return {"vip": vip, "limit": limit, "used": used, "remaining": max(0, limit - used)}
|
||||
|
||||
|
||||
def _pair_key(a: str, b: str) -> str:
|
||||
return "|".join(sorted([a, b]))
|
||||
def _find_profile(profile_id: str) -> dict | None:
|
||||
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:
|
||||
target_profile = next((p for p in CANDIDATE_PROFILES if p.get("userId") == target_user_id), None)
|
||||
if not target_profile:
|
||||
return False
|
||||
target_pid = target_profile["id"]
|
||||
for s in _swipes:
|
||||
if use_pb():
|
||||
rows = safe_list("swipes", filter=f"userId={q(target_user_id)} && profileId={q(my_profile_id)}")
|
||||
return any(r.get("action") in LIKE_ACTIONS for r in rows)
|
||||
_jload()
|
||||
for s in _j.get("swipes") or []:
|
||||
if s.get("userId") == target_user_id and s.get("profileId") == my_profile_id:
|
||||
if s.get("action") in LIKE_ACTIONS:
|
||||
return True
|
||||
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:
|
||||
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:
|
||||
return c
|
||||
conv = {
|
||||
@ -257,42 +303,74 @@ def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", matc
|
||||
"lastMessagePreview": "",
|
||||
"readState": {user_a: _now_iso(), user_b: _now_iso()},
|
||||
}
|
||||
_conversations.append(conv)
|
||||
_persist()
|
||||
_j.setdefault("conversations", []).append(conv)
|
||||
_jsave()
|
||||
return conv
|
||||
|
||||
|
||||
def _find_profile(profile_id: str) -> dict | None:
|
||||
for p in CANDIDATE_PROFILES:
|
||||
if p["id"] == profile_id:
|
||||
return p
|
||||
if profile_id.startswith("user-"):
|
||||
uid = profile_id[5:]
|
||||
prof = _profiles.get(uid)
|
||||
if prof:
|
||||
return {
|
||||
"id": profile_id,
|
||||
"userId": uid,
|
||||
"name": prof.get("name", "游民"),
|
||||
"location": prof.get("location", "全球"),
|
||||
"bio": prof.get("bio", ""),
|
||||
"photo": prof.get("photo", "🧑💻"),
|
||||
"tags": prof.get("tags", []),
|
||||
}
|
||||
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)
|
||||
if use_pb():
|
||||
row = safe_first("match_connections", filter=f"pairKey={q(pk)}")
|
||||
if row:
|
||||
return row
|
||||
conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True)
|
||||
created = safe_create(
|
||||
"match_connections",
|
||||
{
|
||||
"pairKey": pk,
|
||||
"userAId": user_id,
|
||||
"userBId": target_uid,
|
||||
"profileBId": profile.get("id", ""),
|
||||
"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:
|
||||
_reload()
|
||||
if action not in LIKE_ACTIONS and action != "dislike":
|
||||
action = "dislike"
|
||||
if action == "superlike" and not is_vip(user_id):
|
||||
return {"ok": False, "error": "vip_required"}
|
||||
q = get_quota(user_id)
|
||||
if not q["vip"] and q["remaining"] <= 0 and action in LIKE_ACTIONS:
|
||||
qdata = get_quota(user_id)
|
||||
if not qdata["vip"] and qdata["remaining"] <= 0 and action in LIKE_ACTIONS:
|
||||
return {"ok": False, "error": "quota_exceeded"}
|
||||
|
||||
for s in _swipes:
|
||||
if use_pb():
|
||||
dup = safe_first(
|
||||
"swipes",
|
||||
filter=f"userId={q(user_id)} && profileId={q(profile_id)}",
|
||||
)
|
||||
if dup:
|
||||
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"}
|
||||
|
||||
@ -300,16 +378,19 @@ def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dic
|
||||
if not profile:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
|
||||
_swipes.append({
|
||||
"id": secrets.token_hex(6),
|
||||
swipe_payload = {
|
||||
"userId": user_id,
|
||||
"profileId": profile_id,
|
||||
"action": action,
|
||||
"intent": intent,
|
||||
"date": _today_key(),
|
||||
"createdAt": _now_iso(),
|
||||
})
|
||||
_persist()
|
||||
"swipeDate": _today_key(),
|
||||
}
|
||||
if use_pb():
|
||||
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
|
||||
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:
|
||||
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", "游民")
|
||||
conv_id = matched.get("conversationId", "")
|
||||
link = f"/chat/{conv_id}" if conv_id else "/chat"
|
||||
community_store.add_notification(
|
||||
user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link
|
||||
)
|
||||
community_store.add_notification(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link)
|
||||
peer_uid = profile.get("userId")
|
||||
if peer_uid:
|
||||
community_store.add_notification(
|
||||
peer_uid, "匹配成功 🎉", f"你和 {my_name} 互相喜欢了,快去聊天吧", link
|
||||
peer_uid, "匹配成功 🎉", f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧", link
|
||||
)
|
||||
|
||||
return {
|
||||
"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)}
|
||||
return {"ok": True, "matched": bool(matched), "match": matched}
|
||||
|
||||
|
||||
def undo_last_swipe(user_id: str) -> dict:
|
||||
_reload()
|
||||
for i in range(len(_swipes) - 1, -1, -1):
|
||||
if _swipes[i].get("userId") == user_id:
|
||||
removed = _swipes.pop(i)
|
||||
_persist()
|
||||
if use_pb():
|
||||
rows = safe_list("swipes", filter=f"userId={q(user_id)}")
|
||||
rows.sort(key=lambda r: r.get("created", ""), reverse=True)
|
||||
if not rows:
|
||||
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": False, "error": "nothing_to_undo"}
|
||||
|
||||
|
||||
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}"
|
||||
likers = []
|
||||
for s in _swipes:
|
||||
if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS:
|
||||
uid = s.get("userId")
|
||||
p = _profiles.get(uid)
|
||||
if use_pb():
|
||||
for s in safe_list("swipes", filter=f"profileId={q(my_pid)}"):
|
||||
if s.get("action") not in LIKE_ACTIONS:
|
||||
continue
|
||||
p = _peer_profile(s.get("userId", ""))
|
||||
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
|
||||
|
||||
|
||||
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 = []
|
||||
for pid in liked_ids:
|
||||
p = _find_profile(pid)
|
||||
if use_pb():
|
||||
for s in safe_list("swipes", filter=f"userId={q(user_id)}"):
|
||||
if s.get("action") in LIKE_ACTIONS:
|
||||
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
|
||||
|
||||
|
||||
def list_mutual(user_id: str) -> list[dict]:
|
||||
_reload()
|
||||
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")):
|
||||
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, "conversationId": m.get("conversationId")})
|
||||
items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")})
|
||||
return items
|
||||
|
||||
|
||||
def list_conversations(user_id: str) -> list[dict]:
|
||||
_reload()
|
||||
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")):
|
||||
continue
|
||||
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, "")
|
||||
unread = 1 if c.get("lastMessageAt", "") > read_at else 0
|
||||
items.append({
|
||||
**c,
|
||||
"peer": peer,
|
||||
"unreadCount": unread,
|
||||
})
|
||||
items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread})
|
||||
items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
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")):
|
||||
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}
|
||||
return {**c, "peer": _peer_profile(peer_id)}
|
||||
return None
|
||||
|
||||
|
||||
def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]:
|
||||
_reload()
|
||||
conv = get_conversation(conv_id, user_id)
|
||||
if not conv:
|
||||
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", ""))
|
||||
if conv.get("readState") is not None:
|
||||
conv["readState"][user_id] = _now_iso()
|
||||
_persist()
|
||||
for c in _j.get("conversations") or []:
|
||||
if c["id"] == conv_id:
|
||||
c.setdefault("readState", {})[user_id] = _now_iso()
|
||||
_jsave()
|
||||
break
|
||||
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():
|
||||
return None
|
||||
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 = {
|
||||
"id": secrets.token_hex(8),
|
||||
"conversationId": conv_id,
|
||||
@ -444,13 +576,13 @@ def send_message(conv_id: str, user_id: str, body: str) -> dict | None:
|
||||
"body": body,
|
||||
"createdAt": _now_iso(),
|
||||
}
|
||||
_messages.append(msg)
|
||||
for c in _conversations:
|
||||
_j.setdefault("messages", []).append(msg)
|
||||
for c in _j.get("conversations") or []:
|
||||
if c["id"] == conv_id:
|
||||
c["lastMessageAt"] = _now_iso()
|
||||
c["lastMessagePreview"] = body[:80]
|
||||
break
|
||||
_persist()
|
||||
_jsave()
|
||||
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)}"
|
||||
status = "paid" if dev_auto_pay else "pending"
|
||||
order = {
|
||||
"id": order_id,
|
||||
"orderId": order_id,
|
||||
"userId": user_id,
|
||||
"payType": pay_type,
|
||||
"amount": amount,
|
||||
"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:
|
||||
ensure_membership(user_id)
|
||||
_persist()
|
||||
return order
|
||||
return {"id": order_id, **order, "createdAt": _now_iso()}
|
||||
|
||||
|
||||
def get_order(order_id: str) -> dict | None:
|
||||
_reload()
|
||||
return _orders.get(order_id)
|
||||
if use_pb():
|
||||
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:
|
||||
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:
|
||||
return None
|
||||
order["status"] = "paid"
|
||||
ensure_membership(order["userId"])
|
||||
_persist()
|
||||
_jsave()
|
||||
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")
|
||||
|
||||
@ -5,3 +5,5 @@ pydantic[email]==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
python-dotenv==1.0.1
|
||||
email-validator==2.2.0
|
||||
boto3==1.35.99
|
||||
python-multipart==0.0.20
|
||||
|
||||
380
backend/scripts/init_pb_collections.py
Normal file
380
backend/scripts/init_pb_collections.py
Normal 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()
|
||||
@ -5,6 +5,25 @@ PAYMENT_JOIN_AMOUNT=10000
|
||||
PAYMENT_DEFAULT_AMOUNT=10000
|
||||
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_KEY=
|
||||
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php
|
||||
|
||||
@ -8,7 +8,7 @@ WorkingDirectory=/opt/nomadweb/backend
|
||||
EnvironmentFile=/opt/nomadweb/deploy/nomadro-api.env
|
||||
Environment=POCKETBASE_URL=http://127.0.0.1:8090
|
||||
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
|
||||
ExecStart=/opt/nomadweb/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8055 --workers 2
|
||||
Restart=always
|
||||
|
||||
84
scripts/deploy_backend_hotfix.py
Normal file
84
scripts/deploy_backend_hotfix.py
Normal 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())
|
||||
@ -138,6 +138,8 @@ build_backend() {
|
||||
cp requirements.txt .venv/.reqsha
|
||||
fi
|
||||
systemctl restart nomadro-api
|
||||
# Ensure PocketBase collections exist (idempotent)
|
||||
.venv/bin/python scripts/init_pb_collections.py || echo "PB init skipped"
|
||||
}
|
||||
|
||||
CHANGED="${CHANGED:-}"
|
||||
|
||||
86
scripts/setup_production_env.py
Normal file
86
scripts/setup_production_env.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user