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)
|
# 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)
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
@ -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("/")
|
||||||
|
|||||||
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
|
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
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
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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
|
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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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
|
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:-}"
|
||||||
|
|||||||
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