diff --git a/.gitignore b/.gitignore index 9bf6669..0bee5c4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ frontend/.env.local # Auth / plan user store (runtime) backend/app/data/user_store.json backend/app/data/social_store.json +backend/app/data/community_store.json backend/app/data/platform_store.json # Production secrets (use deploy/nomadro-api.env.example) diff --git a/backend/app/config.py b/backend/app/config.py index 160894a..4a70c8c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,10 +4,31 @@ class Settings(BaseSettings): pocketbase_url: str = "http://127.0.0.1:8090" pocketbase_admin_email: str = "admin@nomadro.com" - pocketbase_admin_password: str = "admin123456" + pocketbase_admin_password: str = "Xiao4669805" api_host: str = "0.0.0.0" api_port: int = 8000 cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000" + site_base_url: str = "https://nomadweb.nomadro.com" + + # SeaweedFS S3 (s3.nomadro.com → 127.0.0.1:8333) + s3_enabled: bool = True + s3_endpoint: str = "http://127.0.0.1:8333" + s3_public_url: str = "https://s3.nomadro.com" + s3_access_key: str = "nomadweb" + s3_secret_key: str = "" + s3_bucket: str = "nomadweb" + s3_region: str = "us-east-1" + s3_upload_prefix: str = "nomadweb" + + # ntfy (127.0.0.1:2586 on production server) + ntfy_enabled: bool = False + ntfy_url: str = "http://127.0.0.1:2586" + + # Payment (from env on server) + dev_auto_pay: bool = False + payment_provider: str = "zpay" + mirotalk_url: str = "https://mirotalk.nomadro.com" + lounge_url: str = "https://lounge.nomadro.com" @property def cors_origin_list(self) -> list[str]: diff --git a/backend/app/main.py b/backend/app/main.py index 8b6fb02..7dee879 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import api, auth, community, payment, platform, social +from app.routers import api, auth, community, media, payment, platform, social app = FastAPI( title="nomadro API", @@ -24,6 +24,7 @@ app.include_router(social.router, prefix="/api/v1") app.include_router(community.router, prefix="/api/v1") app.include_router(payment.router, prefix="/api/v1") app.include_router(platform.router, prefix="/api/v1") +app.include_router(media.router, prefix="/api/v1") @app.get("/") diff --git a/backend/app/routers/media.py b/backend/app/routers/media.py new file mode 100644 index 0000000..812e90d --- /dev/null +++ b/backend/app/routers/media.py @@ -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, + } diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 8ffb382..9bcf65d 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -1,4 +1,4 @@ -"""Auth + favorites + synced user plans. File-backed so demo restarts keep data.""" +"""Auth + favorites + plans — PocketBase backed (nomad_accounts / nomad_sessions).""" from __future__ import annotations import hashlib @@ -8,53 +8,22 @@ import time from pathlib import Path from typing import Any +from app.services.pb_client import pb, pb_quote +from app.services.pb_repo import safe_create, safe_first, safe_update, use_pb + STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "user_store.json" +# In-memory fallback when PocketBase unavailable (dev only) _users: dict[str, dict] = {} -_sessions: dict[str, str] = {} # token -> user_id -_favorites: dict[str, list[str]] = {} # user_id -> [slugs] -_plans: dict[str, dict] = {} # user_id -> { items, meta, updated_at } +_sessions: dict[str, str] = {} +_favorites: dict[str, list[str]] = {} +_plans: dict[str, dict] = {} def _hash_password(password: str) -> str: return hashlib.sha256(password.encode()).hexdigest() -def _persist() -> None: - STORE_PATH.parent.mkdir(parents=True, exist_ok=True) - payload = { - "users": _users, - "sessions": _sessions, - "favorites": _favorites, - "plans": _plans, - } - STORE_PATH.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") - - -def _restore() -> bool: - if not STORE_PATH.exists(): - return False - try: - data = json.loads(STORE_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return False - global _users, _sessions, _favorites, _plans - _users = data.get("users") or {} - _sessions = data.get("sessions") or {} - _favorites = data.get("favorites") or {} - _plans = data.get("plans") or {} - return True - - -def _user_profile(user: dict) -> dict: - return { - "id": user["id"], - "email": user["email"], - "name": user["name"], - "avatar": user.get("avatar", "🧑‍💻"), - } - - def _empty_plan() -> dict[str, Any]: return { "items": [], @@ -68,7 +37,138 @@ def _empty_plan() -> dict[str, Any]: } +def _user_profile(user: dict) -> dict: + return { + "id": user["id"], + "email": user["email"], + "name": user["name"], + "avatar": user.get("avatar", "🧑‍💻"), + } + + +def _account_from_pb(row: dict) -> dict: + return { + "pb_id": row["id"], + "id": row.get("legacyUserId") or row["id"], + "email": row.get("email", ""), + "password_hash": row.get("passwordHash", ""), + "name": row.get("name", ""), + "avatar": row.get("avatar", "🧑‍💻"), + "favorites": row.get("favorites") or [], + "plan": row.get("plan") or _empty_plan(), + } + + +def _migrate_json_to_pb() -> None: + if not STORE_PATH.exists() or not use_pb(): + return + try: + data = json.loads(STORE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + if pb.list_records("nomad_accounts", per_page=1).get("totalItems", 0) > 0: + return + for email, user in (data.get("users") or {}).items(): + uid = user.get("id", secrets.token_hex(8)) + pb.create_record( + "nomad_accounts", + { + "email": email, + "passwordHash": user.get("password_hash", ""), + "name": user.get("name", ""), + "avatar": user.get("avatar", "🧑‍💻"), + "legacyUserId": uid, + "favorites": data.get("favorites", {}).get(uid, []), + "plan": data.get("plans", {}).get(uid, _empty_plan()), + }, + ) + for token, uid in (data.get("sessions") or {}).items(): + pb.create_record( + "nomad_sessions", + {"token": token, "userId": uid, "expiresAt": int(time.time()) + 86400 * 30}, + ) + + +def _restore_json() -> bool: + if not STORE_PATH.exists(): + return False + try: + data = json.loads(STORE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + global _users, _sessions, _favorites, _plans + _users = data.get("users") or {} + _sessions = data.get("sessions") or {} + _favorites = data.get("favorites") or {} + _plans = data.get("plans") or {} + return True + + +def _persist_json() -> None: + STORE_PATH.parent.mkdir(parents=True, exist_ok=True) + STORE_PATH.write_text( + json.dumps({ + "users": _users, + "sessions": _sessions, + "favorites": _favorites, + "plans": _plans, + }, ensure_ascii=False), + encoding="utf-8", + ) + + +def _get_account_by_email(email: str) -> dict | None: + row = safe_first("nomad_accounts", filter=f"email={pb_quote(email)}") + return _account_from_pb(row) if row else None + + +def _get_account_by_id(user_id: str) -> dict | None: + row = safe_first("nomad_accounts", filter=f"legacyUserId={pb_quote(user_id)}") + if not row: + row = safe_first("nomad_accounts", filter=f"id={pb_quote(user_id)}") + return _account_from_pb(row) if row else None + + +def _create_session(user_id: str) -> str: + token = secrets.token_urlsafe(32) + if use_pb(): + safe_create( + "nomad_sessions", + {"token": token, "userId": user_id, "expiresAt": int(time.time()) + 86400 * 30}, + ) + else: + _sessions[token] = user_id + _persist_json() + return token + + +def _issue_login(user: dict) -> dict[str, Any]: + token = _create_session(user["id"]) + return {"token": token, "user": _user_profile(user)} + + def register_user(email: str, password: str, name: str) -> dict[str, Any] | None: + if use_pb(): + _migrate_json_to_pb() + if _get_account_by_email(email): + return None + uid = secrets.token_hex(8) + row = safe_create( + "nomad_accounts", + { + "email": email, + "passwordHash": _hash_password(password), + "name": name, + "avatar": "🧑‍💻", + "legacyUserId": uid, + "favorites": [], + "plan": _empty_plan(), + }, + ) + if not row: + return None + return _issue_login(_account_from_pb(row)) + if email in _users: return None uid = secrets.token_hex(8) @@ -81,69 +181,76 @@ def register_user(email: str, password: str, name: str) -> dict[str, Any] | None } _favorites[uid] = [] _plans[uid] = _empty_plan() - token = secrets.token_urlsafe(32) - _sessions[token] = uid - _persist() - return {"token": token, "user": _user_profile(_users[email])} + _persist_json() + return _issue_login(_users[email]) def login_user(email: str, password: str) -> dict[str, Any] | None: + if use_pb(): + user = _get_account_by_email(email) + if not user or user["password_hash"] != _hash_password(password): + return None + return _issue_login(user) + user = _users.get(email) if not user or user["password_hash"] != _hash_password(password): return None - token = secrets.token_urlsafe(32) - _sessions[token] = user["id"] - _persist() - return {"token": token, "user": _user_profile(user)} + return _issue_login(user) def login_google(email: str, name: str) -> dict[str, Any] | None: + if 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: - uid = secrets.token_hex(8) - _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)} + register_user(email, secrets.token_hex(16), name or email.split("@")[0]) + return _issue_login(_users[email]) def login_demo() -> dict[str, Any] | None: email = "demo@nomadro.com" + if use_pb(): + user = _get_account_by_email(email) + if not user: + return register_user(email, "demo123", "演示用户") + return _issue_login(user) + if email not in _users: - created = register_user(email, "demo123", "演示用户") - return created - user = _users[email] - token = secrets.token_urlsafe(32) - _sessions[token] = user["id"] - _persist() - return {"token": token, "user": _user_profile(user)} - - -def _reload_sessions() -> None: - if not STORE_PATH.exists(): - return - try: - data = json.loads(STORE_PATH.read_text(encoding="utf-8")) - global _sessions - _sessions.update(data.get("sessions") or {}) - except (OSError, json.JSONDecodeError): - return + return register_user(email, "demo123", "演示用户") + return _issue_login(_users[email]) def get_user_by_token(token: str) -> dict | None: + if use_pb(): + row = safe_first("nomad_sessions", filter=f"token={pb_quote(token)}") + if not row: + return None + exp = int(row.get("expiresAt") or 0) + if exp and exp < int(time.time()): + return None + user = _get_account_by_id(row.get("userId", "")) + return _user_profile(user) if user else None + uid = _sessions.get(token) - if not uid: - _reload_sessions() + if not uid and _restore_json(): uid = _sessions.get(token) if not uid: return None @@ -153,30 +260,61 @@ def get_user_by_token(token: str) -> dict | None: return None +def _token_user_id(token: str) -> str | None: + user = get_user_by_token(token) + return user["id"] if user else None + + def get_favorites(token: str) -> list[str]: - uid = _sessions.get(token) + uid = _token_user_id(token) if not uid: return [] + if use_pb(): + user = _get_account_by_id(uid) + return list(user.get("favorites") or []) if user else [] return list(_favorites.get(uid, [])) def toggle_favorite(token: str, slug: str) -> list[str]: - uid = _sessions.get(token) + uid = _token_user_id(token) if not uid: return [] + if use_pb(): + user = _get_account_by_id(uid) + if not user: + return [] + favs = list(user.get("favorites") or []) + if slug in favs: + favs.remove(slug) + else: + favs.append(slug) + safe_update("nomad_accounts", user["pb_id"], {"favorites": favs}) + return favs + favs = _favorites.setdefault(uid, []) if slug in favs: favs.remove(slug) else: favs.append(slug) - _persist() + _persist_json() return list(favs) def get_plan(token: str) -> dict[str, Any] | None: - uid = _sessions.get(token) + uid = _token_user_id(token) if not uid: return None + if use_pb(): + user = _get_account_by_id(uid) + if not user: + return None + plan = user.get("plan") or _empty_plan() + return { + "items": plan.get("items") or [], + "meta": plan.get("meta") or _empty_plan()["meta"], + "updated_at": int(plan.get("updated_at") or 0), + } + plan = _plans.get(uid) or _empty_plan() return { "items": plan.get("items") or [], @@ -186,21 +324,30 @@ def get_plan(token: str) -> dict[str, Any] | None: def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None) -> dict[str, Any] | None: - uid = _sessions.get(token) + uid = _token_user_id(token) if not uid: return None ts = int(updated_at or time.time() * 1000) - _plans[uid] = { - "items": items, - "meta": meta, - "updated_at": ts, - } - _persist() + payload = {"items": items, "meta": meta, "updated_at": ts} + if use_pb(): + user = _get_account_by_id(uid) + if not user: + return None + safe_update("nomad_accounts", user["pb_id"], {"plan": payload}) + return get_plan(token) + + _plans[uid] = payload + _persist_json() return get_plan(token) -# Boot: restore disk store or seed demo -if not _restore(): - login_demo() -elif "demo@nomadro.com" not in _users: - login_demo() +# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase +if not use_pb(): + if not _restore_json(): + login_demo() + elif "demo@nomadro.com" not in _users: + login_demo() +else: + _migrate_json_to_pb() + if not _get_account_by_email("demo@nomadro.com"): + register_user("demo@nomadro.com", "demo123", "演示用户") diff --git a/backend/app/services/community_store.py b/backend/app/services/community_store.py index 3f6c08d..a5bed69 100644 --- a/backend/app/services/community_store.py +++ b/backend/app/services/community_store.py @@ -1,32 +1,25 @@ -"""Persistent community data: discussions, meetups, gigs, notifications.""" - +"""Community data — PocketBase backed (discussions, meetups, gigs, notifications).""" from __future__ import annotations -import json import re import secrets -import time -from copy import deepcopy from datetime import datetime, timezone -from pathlib import Path from typing import Any -from app.data import community_data +from app.services.pb_client import pb, pb_quote +from app.services.pb_repo import ( + find_by_legacy, + pb_date, + q, + rid, + safe_create, + safe_first, + safe_list, + safe_update, + use_pb, +) -STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.json" - -_meetups: list[dict] = [] -_discussions: list[dict] = [] -_replies: dict[str, list[dict]] = {} -_discussion_likes: dict[str, set[str]] = {} # discussion_id -> user_ids -_reply_likes: dict[str, set[str]] = {} -_rsvps: dict[str, set[str]] = {} # meetup_id -> user_ids -_gigs: list[dict] = [] -_gig_apps: list[dict] = [] -_notifications: dict[str, list[dict]] = {} # user_id -> items -_feedback: list[dict] = [] -_views: dict[str, int] = {} # discussion_id -> view count -_seeded = False +LIKE_ACTIONS = frozenset({"like", "right", "superlike"}) def _now_iso() -> str: @@ -38,258 +31,274 @@ def _slugify(text: str) -> str: return (s[:48] or secrets.token_hex(4)) -def _persist() -> None: - STORE_PATH.parent.mkdir(parents=True, exist_ok=True) - STORE_PATH.write_text( - json.dumps({ - "meetups": _meetups, - "discussions": _discussions, - "replies": _replies, - "discussion_likes": {k: list(v) for k, v in _discussion_likes.items()}, - "reply_likes": {k: list(v) for k, v in _reply_likes.items()}, - "rsvps": {k: list(v) for k, v in _rsvps.items()}, - "gigs": _gigs, - "gig_apps": _gig_apps, - "notifications": _notifications, - "feedback": _feedback, - "views": _views, - "seeded": _seeded, - }, ensure_ascii=False), - encoding="utf-8", - ) +# ── Meetups ────────────────────────────────────────────────────────────── - -def _restore() -> None: - global _meetups, _discussions, _replies, _discussion_likes, _reply_likes - global _rsvps, _gigs, _gig_apps, _notifications, _feedback, _views, _seeded - if not STORE_PATH.exists(): - return - try: - data = json.loads(STORE_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return - _meetups = data.get("meetups") or [] - _discussions = data.get("discussions") or [] - _replies = data.get("replies") or {} - _discussion_likes = {k: set(v) for k, v in (data.get("discussion_likes") or {}).items()} - _reply_likes = {k: set(v) for k, v in (data.get("reply_likes") or {}).items()} - _rsvps = {k: set(v) for k, v in (data.get("rsvps") or {}).items()} - _gigs = data.get("gigs") or [] - _gig_apps = data.get("gig_apps") or [] - _notifications = data.get("notifications") or {} - _feedback = data.get("feedback") or [] - _views = data.get("views") or {} - _seeded = bool(data.get("seeded")) - - -def _reload() -> None: - _restore() - - -def _seed_if_needed() -> None: - global _seeded - _reload() - if _seeded: - return - _meetups = deepcopy(community_data.MEETUPS) - _discussions = deepcopy(community_data.DISCUSSIONS) - _replies = deepcopy(community_data.DISCUSSION_REPLIES) - _gigs = deepcopy(getattr(community_data, "GIGS", _default_gigs())) - _seeded = True - _persist() - - -def _default_gigs() -> list[dict]: - return [ - { - "id": "logo-design", - "title": "为游民社区设计 Logo", - "description": "需要扁平风格,含地球+代码元素,交付 SVG。", - "budget": "¥800", - "deadline": "2026-09-15", - "tags": ["设计", "远程"], - "poster": "nomadro", - "status": "open", - }, - { - "id": "meetup-host", - "title": "深圳线下活动主持人", - "description": "协助组织 20 人规模的游民交流会,有经验优先。", - "budget": "¥500/场", - "deadline": "2026-09-20", - "tags": ["活动", "深圳"], - "poster": "深圳湾区主理人", - "status": "open", - }, - { - "id": "content-translate", - "title": "签证指南英文润色", - "description": "将 3 篇中文签证攻略翻译润色为英文博客。", - "budget": "¥1200", - "deadline": "2026-10-01", - "tags": ["翻译", "内容"], - "poster": "nomadro", - "status": "open", - }, - ] - - -_seed_if_needed() - - -def _discussion_like_count(did: str) -> int: - return len(_discussion_likes.get(did, set())) - - -def _reply_like_count(rid: str) -> int: - return len(_reply_likes.get(rid, set())) - - -def _enrich_discussion(d: dict) -> dict: - did = d["id"] - replies = _replies.get(did, []) - base_likes = d.get("like_count", 0) - stored = _discussion_like_count(did) +def _meetup_from_pb(r: dict) -> dict: return { - **d, - "reply_count": len(replies), - "like_count": max(base_likes, stored) if stored == 0 else base_likes + stored, - "view_count": _views.get(did, d.get("view_count", 0)), + "id": rid(r), + "pb_id": r["id"], + "title": r.get("title", ""), + "city": r.get("city", ""), + "destination_slug": r.get("destinationSlug", ""), + "emoji": r.get("emoji", "🎉"), + "date": pb_date(r.get("date")), + "time": r.get("time", ""), + "venue": r.get("venue", ""), + "description": r.get("description", ""), + "mode": r.get("mode", "offline"), + "access_level": r.get("accessLevel", "public"), + "mirotalkRoom": r.get("mirotalkRoom", ""), + "loungeChannel": r.get("loungeChannel", ""), + "meetingUrl": r.get("meetingUrl", ""), + "rsvp_count": int(r.get("rsvpCount") or 0), + "max_attendees": int(r.get("maxAttendees") or 30), + "organizer": r.get("organizer", ""), + "organizer_id": r.get("organizerId", ""), + "tags": r.get("tags") or [], + "is_upcoming": bool(r.get("isUpcoming", True)), } +def _rsvp_count(meetup_pb_id: str) -> int: + rows = safe_list("meetup_rsvps", filter=f"meetupId={q(meetup_pb_id)}") + return len(rows) + + def list_meetups(upcoming: bool = True) -> list[dict]: - _seed_if_needed() - items = list(_meetups) - if upcoming: - items = [m for m in items if m.get("is_upcoming", True)] - for m in items: - mid = m["id"] - m["rsvp_count"] = max(m.get("rsvp_count", 0), len(_rsvps.get(mid, set()))) + if not use_pb(): + return _json_list_meetups(upcoming) + rows = safe_list("meetups") + rows.sort(key=lambda r: r.get("created", ""), reverse=True) + items = [] + for r in rows: + m = _meetup_from_pb(r) + if upcoming and not m.get("is_upcoming", True): + continue + m["rsvp_count"] = max(m["rsvp_count"], _rsvp_count(r["id"])) + items.append(m) return items def get_meetup(meetup_id: str) -> dict | None: - _seed_if_needed() - for m in _meetups: - if m["id"] == meetup_id: - out = dict(m) - out["rsvp_count"] = max(out.get("rsvp_count", 0), len(_rsvps.get(meetup_id, set()))) - return out - return None + if not use_pb(): + return _json_get_meetup(meetup_id) + r = find_by_legacy("meetups", meetup_id) + if not r: + return None + m = _meetup_from_pb(r) + m["rsvp_count"] = max(m["rsvp_count"], _rsvp_count(r["id"])) + return m def create_meetup(user_id: str, user_name: str, payload: dict) -> dict: - _seed_if_needed() + if not use_pb(): + return _json_create_meetup(user_id, user_name, payload) mid = _slugify(payload.get("title", "meetup")) + "-" + secrets.token_hex(3) mode = payload.get("mode", "offline") room = payload.get("mirotalkRoom") or "" if mode in ("online", "hybrid") and not room: room = f"nomadro-{_slugify(payload.get('city', 'online'))}-{secrets.token_hex(2)}" lounge = payload.get("loungeChannel") or (f"#{room}" if room else "") - meetup = { - "id": mid, - "title": payload["title"], - "city": payload.get("city", "线上"), - "destination_slug": payload.get("destination_slug", ""), - "emoji": payload.get("emoji", "🎉"), - "date": payload.get("date", _now_iso()), - "time": payload.get("time", "19:00"), - "venue": payload.get("venue", "待定"), - "description": payload.get("description", ""), - "mode": mode, - "access_level": payload.get("access_level", "public"), - "mirotalkRoom": room, - "loungeChannel": lounge, - "meetingUrl": "", - "rsvp_count": 0, - "max_attendees": int(payload.get("max_attendees", 30)), - "organizer": user_name, - "organizer_id": user_id, - "tags": payload.get("tags", []), - "is_upcoming": True, - } - _meetups.insert(0, meetup) - _persist() - return meetup + date_val = payload.get("date", _now_iso()) + if date_val and "T" not in str(date_val): + date_val = f"{date_val} 00:00:00.000Z" + row = safe_create( + "meetups", + { + "legacyId": mid, + "title": payload["title"], + "city": payload.get("city", "线上"), + "destinationSlug": payload.get("destination_slug", ""), + "emoji": payload.get("emoji", "🎉"), + "date": date_val, + "time": payload.get("time", "19:00"), + "venue": payload.get("venue", "待定"), + "description": payload.get("description", ""), + "mode": mode, + "accessLevel": payload.get("access_level", "public"), + "mirotalkRoom": room, + "loungeChannel": lounge, + "rsvpCount": 0, + "maxAttendees": int(payload.get("max_attendees", 30)), + "organizer": user_name, + "organizerId": user_id, + "tags": payload.get("tags", []), + "isUpcoming": True, + "status": "published", + }, + ) + return _meetup_from_pb(row) if row else {} def rsvp_meetup(meetup_id: str, user_id: str | None = None) -> dict: - _seed_if_needed() + if not use_pb(): + return _json_rsvp_meetup(meetup_id, user_id) m = get_meetup(meetup_id) if not m: return {"ok": False, "error": "not_found"} uid = user_id or f"guest-{secrets.token_hex(4)}" - rset = _rsvps.setdefault(meetup_id, set()) - if uid in rset: - return {"ok": True, "message": "已报名", "rsvp_count": len(rset)} - if len(rset) >= m["max_attendees"]: + pb_id = m["pb_id"] + existing = safe_first("meetup_rsvps", filter=f"meetupId={q(pb_id)} && userId={q(uid)}") + count = _rsvp_count(pb_id) + if existing: + return {"ok": True, "message": "已报名", "rsvp_count": count} + if count >= m["max_attendees"]: return {"ok": False, "error": "full"} - rset.add(uid) - _persist() - return {"ok": True, "message": f"已报名「{m['title']}」", "rsvp_count": len(rset)} + safe_create("meetup_rsvps", {"meetupId": pb_id, "userId": uid, "status": "going"}) + count += 1 + safe_update("meetups", pb_id, {"rsvpCount": count}) + return {"ok": True, "message": f"已报名「{m['title']}」", "rsvp_count": count} + + +def cancel_rsvp(meetup_id: str, user_id: str) -> dict: + if not use_pb(): + return _json_cancel_rsvp(meetup_id, user_id) + m = get_meetup(meetup_id) + if not m: + return {"ok": False, "error": "not_found"} + rows = safe_list("meetup_rsvps", filter=f"meetupId={q(m['pb_id'])} && userId={q(user_id)}") + for r in rows: + try: + pb.delete_record("meetup_rsvps", r["id"]) + except Exception: + pass + count = _rsvp_count(m["pb_id"]) + safe_update("meetups", m["pb_id"], {"rsvpCount": count}) + return {"ok": True, "rsvp_count": count} def user_rsvp_ids(user_id: str) -> set[str]: - _seed_if_needed() - return {mid for mid, uids in _rsvps.items() if user_id in uids} + if not use_pb(): + return _json_user_rsvp_ids(user_id) + rows = safe_list("meetup_rsvps", filter=f"userId={q(user_id)}") + ids: set[str] = set() + for r in rows: + meetup = safe_first("meetups", filter=f"id={q(r.get('meetupId', ''))}") + if meetup: + ids.add(rid(meetup)) + return ids + + +# ── Discussions ──────────────────────────────────────────────────────── + +def _discussion_from_pb(r: dict, *, like_extra: int = 0, reply_count: int | None = None) -> dict: + base_likes = int(r.get("likes") or 0) + replies = reply_count if reply_count is not None else int(r.get("replies") or 0) + return { + "id": rid(r), + "pb_id": r["id"], + "title": r.get("title", ""), + "excerpt": r.get("excerpt", ""), + "author": r.get("author", ""), + "author_id": r.get("authorUserId", ""), + "author_emoji": r.get("authorEmoji", "🧑‍💻"), + "category": r.get("category", "社区"), + "reply_count": replies, + "like_count": base_likes + like_extra, + "view_count": int(r.get("views") or 0), + "is_pinned": bool(r.get("pinned")), + "created_at": pb_date(r.get("created")), + "tags": r.get("tags") or [], + } + + +def _discussion_likes_count(discussion_pb_id: str) -> int: + return len(safe_list("discussion_likes", filter=f"discussionId={q(discussion_pb_id)}")) def list_discussions(category: str | None = None) -> list[dict]: - _seed_if_needed() - items = [_enrich_discussion(d) for d in _discussions] - if category: - items = [d for d in items if d["category"] == category] + if not use_pb(): + return _json_list_discussions(category) + rows = safe_list("discussions") + rows.sort(key=lambda r: r.get("created", ""), reverse=True) + items = [] + for r in rows: + d = _discussion_from_pb(r, like_extra=_discussion_likes_count(r["id"])) + if category and d["category"] != category: + continue + items.append(d) pinned = sorted([d for d in items if d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True) rest = sorted([d for d in items if not d.get("is_pinned")], key=lambda x: x["created_at"], reverse=True) return pinned + rest def get_discussion(discussion_id: str, increment_view: bool = False) -> dict | None: - _seed_if_needed() - for d in _discussions: - if d["id"] == discussion_id: - if increment_view: - _views[discussion_id] = _views.get(discussion_id, 0) + 1 - _persist() - enriched = _enrich_discussion(d) - replies = [] - for r in _replies.get(discussion_id, []): - replies.append({**r, "like_count": r.get("like_count", 0) + _reply_like_count(r["id"])}) - return {**enriched, "replies": replies} - return None + if not use_pb(): + return _json_get_discussion(discussion_id, increment_view) + r = find_by_legacy("discussions", discussion_id) + if not r: + return None + if increment_view: + views = int(r.get("views") or 0) + 1 + safe_update("discussions", r["id"], {"views": views}) + r["views"] = views + replies_raw = safe_list("discussion_replies", filter=f"discussionId={q(r['id'])}") + replies_raw.sort(key=lambda rep: rep.get("created", "")) + replies = [ + { + "id": rep["id"], + "author": rep.get("author", ""), + "author_id": rep.get("userId", ""), + "author_emoji": rep.get("authorEmoji", "🧑‍💻"), + "content": rep.get("body", ""), + "created_at": pb_date(rep.get("created")), + "like_count": 0, + } + for rep in replies_raw + ] + d = _discussion_from_pb( + r, + like_extra=_discussion_likes_count(r["id"]), + reply_count=len(replies), + ) + return {**d, "replies": replies} def create_discussion(user_id: str, user_name: str, payload: dict) -> dict: - _seed_if_needed() + if not use_pb(): + return _json_create_discussion(user_id, user_name, payload) did = _slugify(payload["title"]) + "-" + secrets.token_hex(3) - d = { - "id": did, - "title": payload["title"], - "excerpt": payload.get("excerpt") or payload.get("content", "")[:200], - "author": user_name, - "author_id": user_id, - "author_emoji": payload.get("author_emoji", "🧑‍💻"), - "category": payload.get("category", "社区"), - "reply_count": 0, - "like_count": 0, - "is_pinned": False, - "created_at": _now_iso(), - "tags": payload.get("tags", []), - } - _discussions.insert(0, d) - _replies[did] = [] - _persist() - return _enrich_discussion(d) + row = safe_create( + "discussions", + { + "legacyId": did, + "title": payload["title"], + "excerpt": payload.get("excerpt") or payload.get("content", "")[:200], + "author": user_name, + "authorUserId": user_id, + "authorEmoji": payload.get("author_emoji", "🧑‍💻"), + "category": payload.get("category", "社区"), + "tags": payload.get("tags", []), + "replies": 0, + "views": 0, + "likes": 0, + "pinned": False, + }, + ) + return _discussion_from_pb(row) if row else {} def add_reply(discussion_id: str, user_id: str, user_name: str, content: str, emoji: str = "🧑‍💻") -> dict | None: - _seed_if_needed() - if not any(d["id"] == discussion_id for d in _discussions): + if not use_pb(): + return _json_add_reply(discussion_id, user_id, user_name, content, emoji) + r = find_by_legacy("discussions", discussion_id) + if not r: return None - rid = secrets.token_hex(6) - reply = { - "id": rid, + rep = safe_create( + "discussion_replies", + { + "discussionId": r["id"], + "userId": user_id, + "author": user_name, + "authorEmoji": emoji, + "body": content, + }, + ) + if not rep: + return None + safe_update("discussions", r["id"], {"replies": int(r.get("replies") or 0) + 1}) + return { + "id": rep["id"], "author": user_name, "author_id": user_id, "author_emoji": emoji, @@ -297,90 +306,225 @@ def add_reply(discussion_id: str, user_id: str, user_name: str, content: str, em "created_at": _now_iso(), "like_count": 0, } - _replies.setdefault(discussion_id, []).append(reply) - _persist() - return reply def toggle_discussion_like(discussion_id: str, user_id: str) -> dict: - _seed_if_needed() - likes = _discussion_likes.setdefault(discussion_id, set()) - if user_id in likes: - likes.discard(user_id) - liked = False + if not use_pb(): + return _json_toggle_discussion_like(discussion_id, user_id) + r = find_by_legacy("discussions", discussion_id) + if not r: + return {"liked": False, "like_count": 0} + existing = safe_first( + "discussion_likes", + filter=f"discussionId={q(r['id'])} && userId={q(user_id)}", + ) + liked = False + if existing: + try: + pb.delete_record("discussion_likes", existing["id"]) + except Exception: + pass else: - likes.add(user_id) + safe_create("discussion_likes", {"discussionId": r["id"], "userId": user_id}) liked = True - _persist() - return {"liked": liked, "like_count": _discussion_like_count(discussion_id)} + count = _discussion_likes_count(r["id"]) + return {"liked": liked, "like_count": count} def toggle_pin(discussion_id: str, user_id: str) -> dict | None: - _seed_if_needed() - for d in _discussions: - if d["id"] == discussion_id and d.get("author_id") == user_id: - d["is_pinned"] = not d.get("is_pinned", False) - _persist() - return _enrich_discussion(d) - return None + if not use_pb(): + return _json_toggle_pin(discussion_id, user_id) + r = find_by_legacy("discussions", discussion_id) + if not r or r.get("authorUserId") != user_id: + return None + pinned = not bool(r.get("pinned")) + updated = safe_update("discussions", r["id"], {"pinned": pinned}) + return _discussion_from_pb(updated or r, like_extra=_discussion_likes_count(r["id"])) if updated or r else None + + +def search_discussions(qs: str) -> list[dict]: + s = qs.lower() + return [d for d in list_discussions() if s in d["title"].lower() or s in d.get("excerpt", "").lower()][:20] + + +# ── Gigs ─────────────────────────────────────────────────────────────── + +def _gig_from_pb(r: dict) -> dict: + budget = r.get("budget") + return { + "id": rid(r), + "pb_id": r["id"], + "title": r.get("title", ""), + "description": r.get("description", ""), + "budget": f"¥{budget}" if isinstance(budget, (int, float)) and budget else str(budget or "面议"), + "deadline": r.get("deadline", ""), + "tags": r.get("tags") or [], + "poster": r.get("posterName", ""), + "poster_id": r.get("posterId", ""), + "status": r.get("status", "open"), + "category": r.get("category", ""), + "location": r.get("location", ""), + } def list_gigs() -> list[dict]: - _seed_if_needed() - return [g for g in _gigs if g.get("status", "open") == "open"] - - -def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict: - _seed_if_needed() - gig = next((g for g in _gigs if g["id"] == gig_id), None) - if not gig: - return {"ok": False, "error": "not_found"} - for a in _gig_apps: - if a["gig_id"] == gig_id and a["user_id"] == user_id: - return {"ok": False, "error": "duplicate"} - _gig_apps.append({ - "id": secrets.token_hex(6), - "gig_id": gig_id, - "user_id": user_id, - "user_name": user_name, - "message": message, - "created_at": _now_iso(), - }) - _persist() - return {"ok": True, "message": "申请已提交"} - - -def cancel_rsvp(meetup_id: str, user_id: str) -> dict: - _seed_if_needed() - rset = _rsvps.get(meetup_id, set()) - if user_id not in rset: - return {"ok": False, "error": "not_rsvped"} - rset.discard(user_id) - _persist() - return {"ok": True, "rsvp_count": len(rset)} + if not use_pb(): + return _json_list_gigs() + return [_gig_from_pb(r) for r in safe_list("gigs") if r.get("status", "open") == "open"] def create_gig(user_id: str, user_name: str, payload: dict) -> dict: - _seed_if_needed() + if not use_pb(): + return _json_create_gig(user_id, user_name, payload) gid = _slugify(payload["title"]) + "-" + secrets.token_hex(2) - gig = { - "id": gid, - "title": payload["title"], - "description": payload.get("description", ""), - "budget": payload.get("budget", "面议"), - "deadline": payload.get("deadline", ""), - "tags": payload.get("tags", []), - "poster": user_name, - "poster_id": user_id, - "status": "open", + budget_raw = payload.get("budget", "面议") + budget = int(budget_raw) if str(budget_raw).isdigit() else 0 + row = safe_create( + "gigs", + { + "legacyId": gid, + "title": payload["title"], + "description": payload.get("description", ""), + "budget": budget, + "location": payload.get("location", ""), + "category": payload.get("category", ""), + "posterId": user_id, + "posterName": user_name, + "status": "open", + }, + ) + return _gig_from_pb(row) if row else {} + + +def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict: + if not use_pb(): + return _json_apply_gig(gig_id, user_id, user_name, message) + g = find_by_legacy("gigs", gig_id) + if not g: + return {"ok": False, "error": "not_found"} + dup = safe_first( + "gig_applications", + filter=f"gigId={q(g['id'])} && userId={q(user_id)}", + ) + if dup: + return {"ok": False, "error": "duplicate"} + safe_create( + "gig_applications", + {"gigId": g["id"], "userId": user_id, "applicant": user_name, "message": message, "status": "pending"}, + ) + return {"ok": True, "message": "申请已提交"} + + +# ── Notifications ────────────────────────────────────────────────────── + +def _notif_from_pb(r: dict) -> dict: + return { + "id": r["id"], + "title": r.get("title", ""), + "body": r.get("body", ""), + "link": r.get("actionUrl", ""), + "category": r.get("category", "general"), + "read": bool(r.get("read")), + "archived": bool(r.get("archived")), + "pinned": bool(r.get("pinned")), + "created_at": pb_date(r.get("created")), } - _gigs.insert(0, gig) - _persist() - return gig + + +def add_notification(user_id: str, title: str, body: str, link: str = "", category: str = "general") -> None: + if not use_pb(): + return _json_add_notification(user_id, title, body, link, category) + safe_create( + "notifications", + { + "title": title, + "body": body, + "actionUrl": link, + "category": category, + "targetUserId": user_id, + "status": "published", + "read": False, + "archived": False, + "pinned": False, + }, + ) + + +def list_notifications_filtered(user_id: str, include_archived: bool = False) -> list[dict]: + if not use_pb(): + return _json_list_notifications_filtered(user_id, include_archived) + filt = f"targetUserId={q(user_id)}" + if not include_archived: + filt += " && archived=false" + rows = safe_list("notifications", filter=filt) + rows.sort(key=lambda r: r.get("created", ""), reverse=True) + items = [_notif_from_pb(r) for r in rows] + pinned = [n for n in items if n.get("pinned")] + rest = [n for n in items if not n.get("pinned")] + return pinned + rest + + +def list_notifications(user_id: str) -> list[dict]: + return list_notifications_filtered(user_id) + + +def notification_unread_count(user_id: str) -> int: + if not use_pb(): + return _json_notification_unread_count(user_id) + rows = safe_list( + "notifications", + filter=f"targetUserId={q(user_id)} && read=false && archived=false", + ) + return len(rows) + + +def update_notification(user_id: str, notif_id: str, action: str) -> dict | None: + if not use_pb(): + return _json_update_notification(user_id, notif_id, action) + r = safe_first("notifications", filter=f"id={q(notif_id)} && targetUserId={q(user_id)}") + if not r: + return None + patch: dict[str, Any] = {} + if action == "read": + patch["read"] = True + elif action == "unread": + patch["read"] = False + elif action == "archive": + patch["archived"] = True + elif action == "restore": + patch["archived"] = False + elif action == "pin": + patch["pinned"] = True + elif action == "unpin": + patch["pinned"] = False + updated = safe_update("notifications", r["id"], patch) + return _notif_from_pb(updated or r) + + +def mark_notifications_read(user_id: str) -> None: + if not use_pb(): + return _json_mark_notifications_read(user_id) + for r in safe_list("notifications", filter=f"targetUserId={q(user_id)} && read=false"): + safe_update("notifications", r["id"], {"read": True}) + + +def add_feedback(user_id: str | None, user_name: str, content: str, category: str = "general") -> dict: + if not use_pb(): + return _json_add_feedback(user_id, user_name, content, category) + row = safe_create( + "feedback", + { + "type": category, + "email": "", + "title": user_name, + "content": content[:2000], + "status": "open", + }, + ) + return {"id": row["id"] if row else secrets.token_hex(6), "user_name": user_name, "content": content} def meetup_social_suggestions(meetup_id: str, limit: int = 6) -> list[dict]: - _seed_if_needed() m = get_meetup(meetup_id) if not m: return [] @@ -396,101 +540,248 @@ def meetup_social_suggestions(meetup_id: str, limit: int = 6) -> list[dict]: return out[:limit] if out else CANDIDATE_PROFILES[:limit] -def notification_unread_count(user_id: str) -> int: - _reload() - return sum(1 for n in _notifications.get(user_id, []) if not n.get("read")) +def stats_overview() -> dict: + if not use_pb(): + return _json_stats_overview() + return { + "meetups": len(safe_list("meetups")), + "discussions": len(safe_list("discussions")), + "gigs": len(safe_list("gigs")), + "members_active": len(safe_list("meetup_rsvps")) + len(safe_list("gig_applications")), + } -def update_notification(user_id: str, notif_id: str, action: str) -> dict | None: - _reload() - items = _notifications.get(user_id, []) - for n in items: - if n["id"] != notif_id: - continue - if action == "read": - n["read"] = True - elif action == "unread": - n["read"] = False - elif action == "archive": - n["archived"] = True - elif action == "restore": - n["archived"] = False - elif action == "pin": - n["pinned"] = True - elif action == "unpin": - n["pinned"] = False - _persist() - return n +# ── JSON fallback (dev without PocketBase) ─────────────────────────── + +import json +from copy import deepcopy +from pathlib import Path + +from app.data import community_data + +_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.json" +_j: dict[str, Any] = {} + + +def _jload() -> None: + global _j + if _j: + return + if _JSON_PATH.exists(): + try: + _j.update(json.loads(_JSON_PATH.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + pass + if not _j.get("seeded"): + _j.update({ + "meetups": deepcopy(community_data.MEETUPS), + "discussions": deepcopy(community_data.DISCUSSIONS), + "replies": deepcopy(community_data.DISCUSSION_REPLIES), + "gigs": deepcopy(getattr(community_data, "GIGS", [])), + "discussion_likes": {}, + "rsvps": {}, + "gig_apps": [], + "notifications": {}, + "feedback": [], + "views": {}, + "seeded": True, + }) + _jsave() + + +def _jsave() -> None: + _JSON_PATH.parent.mkdir(parents=True, exist_ok=True) + _JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8") + + +def _json_list_meetups(upcoming: bool) -> list[dict]: + _jload() + items = list(_j.get("meetups") or []) + if upcoming: + items = [m for m in items if m.get("is_upcoming", True)] + return items + + +def _json_get_meetup(mid: str) -> dict | None: + _jload() + return next((m for m in _j.get("meetups") or [] if m["id"] == mid), None) + + +def _json_create_meetup(user_id: str, user_name: str, payload: dict) -> dict: + _jload() + mid = _slugify(payload.get("title", "meetup")) + "-" + secrets.token_hex(3) + meetup = {"id": mid, "title": payload["title"], "city": payload.get("city", "线上"), "organizer": user_name, "organizer_id": user_id, "is_upcoming": True, "rsvp_count": 0, "max_attendees": 30, **payload} + _j.setdefault("meetups", []).insert(0, meetup) + _jsave() + return meetup + + +def _json_rsvp_meetup(meetup_id: str, user_id: str | None) -> dict: + _jload() + m = _json_get_meetup(meetup_id) + if not m: + return {"ok": False, "error": "not_found"} + uid = user_id or f"guest-{secrets.token_hex(4)}" + rsvps = _j.setdefault("rsvps", {}) + rset = set(rsvps.get(meetup_id, [])) + if uid in rset: + return {"ok": True, "message": "已报名", "rsvp_count": len(rset)} + rset.add(uid) + rsvps[meetup_id] = list(rset) + _jsave() + return {"ok": True, "message": "已报名", "rsvp_count": len(rset)} + + +def _json_cancel_rsvp(meetup_id: str, user_id: str) -> dict: + _jload() + rsvps = _j.setdefault("rsvps", {}) + rset = set(rsvps.get(meetup_id, [])) + rset.discard(user_id) + rsvps[meetup_id] = list(rset) + _jsave() + return {"ok": True, "rsvp_count": len(rset)} + + +def _json_user_rsvp_ids(user_id: str) -> set[str]: + _jload() + return {mid for mid, uids in (_j.get("rsvps") or {}).items() if user_id in uids} + + +def _json_list_discussions(category: str | None) -> list[dict]: + _jload() + items = list(_j.get("discussions") or []) + if category: + items = [d for d in items if d.get("category") == category] + return items + + +def _json_get_discussion(did: str, inc: bool) -> dict | None: + _jload() + for d in _j.get("discussions") or []: + if d["id"] == did: + if inc: + views = _j.setdefault("views", {}) + views[did] = views.get(did, 0) + 1 + _jsave() + replies = _j.get("replies", {}).get(did, []) + return {**d, "replies": replies} return None -def list_notifications_filtered(user_id: str, include_archived: bool = False) -> list[dict]: - _reload() - items = _notifications.get(user_id, []) +def _json_create_discussion(user_id: str, user_name: str, payload: dict) -> dict: + _jload() + did = _slugify(payload["title"]) + "-" + secrets.token_hex(3) + d = {"id": did, "title": payload["title"], "author": user_name, "author_id": user_id, "created_at": _now_iso(), **payload} + _j.setdefault("discussions", []).insert(0, d) + _j.setdefault("replies", {})[did] = [] + _jsave() + return d + + +def _json_add_reply(did: str, user_id: str, user_name: str, content: str, emoji: str) -> dict | None: + _jload() + if not any(d["id"] == did for d in _j.get("discussions") or []): + return None + reply = {"id": secrets.token_hex(6), "author": user_name, "author_id": user_id, "content": content, "created_at": _now_iso()} + _j.setdefault("replies", {}).setdefault(did, []).append(reply) + _jsave() + return reply + + +def _json_toggle_discussion_like(did: str, user_id: str) -> dict: + _jload() + likes = _j.setdefault("discussion_likes", {}).setdefault(did, []) + if user_id in likes: + likes.remove(user_id) + liked = False + else: + likes.append(user_id) + liked = True + _jsave() + return {"liked": liked, "like_count": len(likes)} + + +def _json_toggle_pin(did: str, user_id: str) -> dict | None: + _jload() + for d in _j.get("discussions") or []: + if d["id"] == did and d.get("author_id") == user_id: + d["is_pinned"] = not d.get("is_pinned", False) + _jsave() + return d + return None + + +def _json_list_gigs() -> list[dict]: + _jload() + return [g for g in _j.get("gigs") or [] if g.get("status", "open") == "open"] + + +def _json_create_gig(user_id: str, user_name: str, payload: dict) -> dict: + _jload() + gid = _slugify(payload["title"]) + "-" + secrets.token_hex(2) + gig = {"id": gid, "poster": user_name, "poster_id": user_id, "status": "open", **payload} + _j.setdefault("gigs", []).insert(0, gig) + _jsave() + return gig + + +def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict: + _jload() + _j.setdefault("gig_apps", []).append({"gig_id": gig_id, "user_id": user_id, "user_name": user_name, "message": message}) + _jsave() + return {"ok": True, "message": "申请已提交"} + + +def _json_add_notification(user_id: str, title: str, body: str, link: str, category: str) -> None: + _jload() + items = _j.setdefault("notifications", {}).setdefault(user_id, []) + items.insert(0, {"id": secrets.token_hex(6), "title": title, "body": body, "link": link, "category": category, "read": False, "archived": False, "pinned": False, "created_at": _now_iso()}) + _jsave() + + +def _json_list_notifications_filtered(user_id: str, include_archived: bool) -> list[dict]: + _jload() + items = _j.get("notifications", {}).get(user_id, []) if not include_archived: items = [n for n in items if not n.get("archived")] - pinned = [n for n in items if n.get("pinned")] - rest = [n for n in items if not n.get("pinned")] - return pinned + rest + return items -def search_discussions(q: str) -> list[dict]: - _seed_if_needed() - s = q.lower() - return [ - _enrich_discussion(d) for d in _discussions - if s in d["title"].lower() or s in d.get("excerpt", "").lower() - ][:20] +def _json_notification_unread_count(user_id: str) -> int: + return sum(1 for n in _json_list_notifications_filtered(user_id, False) if not n.get("read")) -def add_notification(user_id: str, title: str, body: str, link: str = "", category: str = "general") -> None: - items = _notifications.setdefault(user_id, []) - items.insert(0, { - "id": secrets.token_hex(6), - "title": title, - "body": body, - "link": link, - "category": category, - "read": False, - "archived": False, - "pinned": False, - "created_at": _now_iso(), - }) - items[:] = items[:50] - _persist() +def _json_update_notification(user_id: str, notif_id: str, action: str) -> dict | None: + _jload() + for n in _j.get("notifications", {}).get(user_id, []): + if n["id"] == notif_id: + if action == "read": + n["read"] = True + _jsave() + return n + return None -def list_notifications(user_id: str) -> list[dict]: - return list_notifications_filtered(user_id) - - -def mark_notifications_read(user_id: str) -> None: - _reload() - for n in _notifications.get(user_id, []): +def _json_mark_notifications_read(user_id: str) -> None: + _jload() + for n in _j.get("notifications", {}).get(user_id, []): n["read"] = True - _persist() + _jsave() -def add_feedback(user_id: str | None, user_name: str, content: str, category: str = "general") -> dict: - _seed_if_needed() - item = { - "id": secrets.token_hex(6), - "user_id": user_id or "", - "user_name": user_name, - "content": content, - "category": category, - "created_at": _now_iso(), - } - _feedback.append(item) - _persist() +def _json_add_feedback(user_id: str | None, user_name: str, content: str, category: str) -> dict: + _jload() + item = {"id": secrets.token_hex(6), "user_name": user_name, "content": content, "category": category} + _j.setdefault("feedback", []).append(item) + _jsave() return item -def stats_overview() -> dict: - _seed_if_needed() +def _json_stats_overview() -> dict: + _jload() return { - "meetups": len(_meetups), - "discussions": len(_discussions), - "gigs": len(_gigs), - "members_active": len(_rsvps) + len(_gig_apps), + "meetups": len(_j.get("meetups") or []), + "discussions": len(_j.get("discussions") or []), + "gigs": len(_j.get("gigs") or []), + "members_active": len(_j.get("rsvps") or {}), } diff --git a/backend/app/services/ntfy_push.py b/backend/app/services/ntfy_push.py new file mode 100644 index 0000000..56ea261 --- /dev/null +++ b/backend/app/services/ntfy_push.py @@ -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 diff --git a/backend/app/services/pb_client.py b/backend/app/services/pb_client.py new file mode 100644 index 0000000..bda8e74 --- /dev/null +++ b/backend/app/services/pb_client.py @@ -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() diff --git a/backend/app/services/pb_repo.py b/backend/app/services/pb_repo.py new file mode 100644 index 0000000..8de4f99 --- /dev/null +++ b/backend/app/services/pb_repo.py @@ -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)}") diff --git a/backend/app/services/platform_store.py b/backend/app/services/platform_store.py index a127367..7f2bef1 100644 --- a/backend/app/services/platform_store.py +++ b/backend/app/services/platform_store.py @@ -1,5 +1,4 @@ -"""Platform persistence: leads, submissions, volunteers, reports, newsletter.""" - +"""Platform persistence — PocketBase backed.""" from __future__ import annotations import json @@ -7,124 +6,134 @@ import secrets from datetime import datetime, timezone from pathlib import Path -STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json" +from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb -_leads: list[dict] = [] -_submissions: list[dict] = [] -_volunteers: list[dict] = [] -_reports: list[dict] = [] -_newsletter: list[dict] = [] -_notif_prefs: dict[str, dict] = {} +_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json" +_j: dict = {} def _now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%d") -def _persist() -> None: - STORE_PATH.parent.mkdir(parents=True, exist_ok=True) - STORE_PATH.write_text(json.dumps({ - "leads": _leads, - "submissions": _submissions, - "volunteers": _volunteers, - "reports": _reports, - "newsletter": _newsletter, - "notif_prefs": _notif_prefs, - }, ensure_ascii=False), encoding="utf-8") +def _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 _restore() -> None: - global _leads, _submissions, _volunteers, _reports, _newsletter, _notif_prefs - if not STORE_PATH.exists(): - 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 _jsave() -> None: + _JSON_PATH.parent.mkdir(parents=True, exist_ok=True) + _JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8") def add_service_lead(service_id: str, user_id: str | None, name: str, email: str, message: str) -> dict: item = { - "id": secrets.token_hex(6), - "service_id": service_id, - "user_id": user_id or "", - "name": name, + "serviceSlug": service_id, + "userId": user_id or "", "email": email, - "message": message, - "created_at": _now(), + "note": f"{name}: {message}"[:1000], + "status": "open", } - _leads.append(item) - _persist() - return item + if use_pb(): + row = safe_create("service_leads", item) + return {"id": row["id"] if row else secrets.token_hex(6), **item} + _jload() + out = {"id": secrets.token_hex(6), "service_id": service_id, "name": name, "email": email, "message": message, "created_at": _now()} + _j.setdefault("leads", []).append(out) + _jsave() + return out def add_content_submission(user_id: str | None, name: str, title: str, content_type: str, url: str, notes: str) -> dict: - item = { - "id": secrets.token_hex(6), - "user_id": user_id or "", - "name": name, - "title": title, - "content_type": content_type, - "url": url, - "notes": notes, - "status": "pending", - "created_at": _now(), - } - _submissions.append(item) - _persist() - return item + if use_pb(): + row = safe_create( + "content_submissions", + { + "title": title, + "type": content_type, + "mediaUrl": url, + "submittedBy": user_id or "", + "authorName": name, + "description": notes[:2000], + "reviewStatus": "pending", + }, + ) + return {"id": row["id"] if row else secrets.token_hex(6), "title": title, "status": "pending"} + _jload() + out = {"id": secrets.token_hex(6), "title": title, "content_type": content_type, "url": url, "status": "pending", "created_at": _now()} + _j.setdefault("submissions", []).append(out) + _jsave() + return out def add_volunteer(city_slug: str, user_id: str | None, name: str, email: str, message: str) -> dict: - item = { - "id": secrets.token_hex(6), - "city_slug": city_slug, - "user_id": user_id or "", - "name": name, - "email": email, - "message": message, - "created_at": _now(), - } - _volunteers.append(item) - _persist() - return item + if use_pb(): + row = safe_create( + "volunteer_applications", + { + "citySlug": city_slug, + "userId": user_id or "", + "applicantName": name, + "email": email, + "motivation": message[:2000], + "status": "pending", + }, + ) + return {"id": row["id"] if row else secrets.token_hex(6), "city_slug": city_slug} + _jload() + out = {"id": secrets.token_hex(6), "city_slug": city_slug, "name": name, "email": email, "created_at": _now()} + _j.setdefault("volunteers", []).append(out) + _jsave() + return out def add_report(reporter_id: str | None, target_type: str, target_id: str, reason: str) -> dict: - item = { - "id": secrets.token_hex(6), - "reporter_id": reporter_id or "", - "target_type": target_type, - "target_id": target_id, - "reason": reason, - "status": "open", - "created_at": _now(), - } - _reports.append(item) - _persist() - return item + if use_pb(): + row = safe_create( + "reports", + { + "reporterId": reporter_id or "", + "targetType": target_type, + "targetId": target_id, + "reason": reason[:2000], + "status": "open", + }, + ) + return {"id": row["id"] if row else secrets.token_hex(6), "status": "open"} + _jload() + out = {"id": secrets.token_hex(6), "target_type": target_type, "target_id": target_id, "status": "open", "created_at": _now()} + _j.setdefault("reports", []).append(out) + _jsave() + return out def subscribe_newsletter(email: str, source: str = "site") -> dict: - for n in _newsletter: + if use_pb(): + dup = safe_first("subscriptions", filter=f"email={q(email)}") + if dup: + return {"ok": True, "message": "已订阅"} + safe_create("subscriptions", {"email": email}) + return {"ok": True, "message": "订阅成功"} + _jload() + for n in _j.get("newsletter") or []: if n["email"] == email: return {"ok": True, "message": "已订阅"} - _newsletter.append({"email": email, "source": source, "created_at": _now()}) - _persist() + _j.setdefault("newsletter", []).append({"email": email, "source": source, "created_at": _now()}) + _jsave() return {"ok": True, "message": "订阅成功"} def get_notif_prefs(user_id: str) -> dict: - return _notif_prefs.get(user_id) or { + default = { "email": True, "push": False, "match": True, @@ -133,9 +142,26 @@ def get_notif_prefs(user_id: str) -> dict: "marketing": False, "quiet_hours": "", } + if use_pb(): + row = safe_first("notification_preferences", filter=f"userId={q(user_id)}") + if not row: + return default + channels = row.get("channels") or {} + return {**default, **channels} + _jload() + return _j.get("notif_prefs", {}).get(user_id) or default def set_notif_prefs(user_id: str, prefs: dict) -> dict: - _notif_prefs[user_id] = {**get_notif_prefs(user_id), **prefs} - _persist() - return _notif_prefs[user_id] + merged = {**get_notif_prefs(user_id), **prefs} + if use_pb(): + row = safe_first("notification_preferences", filter=f"userId={q(user_id)}") + if row: + safe_update("notification_preferences", row["id"], {"channels": merged, "userId": user_id}) + else: + safe_create("notification_preferences", {"userId": user_id, "channels": merged}) + return merged + _jload() + _j.setdefault("notif_prefs", {})[user_id] = merged + _jsave() + return merged diff --git a/backend/app/services/pocketbase.py b/backend/app/services/pocketbase.py index 8541667..c6d0fe0 100644 --- a/backend/app/services/pocketbase.py +++ b/backend/app/services/pocketbase.py @@ -26,15 +26,9 @@ class PocketBaseService: if self._token: return True try: - r = await client.post( - f"{self.base_url}/api/admins/auth-with-password", - json={ - "identity": settings.pocketbase_admin_email, - "password": settings.pocketbase_admin_password, - }, - ) - if r.status_code == 200: - self._token = r.json().get("token") + from app.services.pb_client import pb as sync_pb + if sync_pb.health_ok(): + self._token = sync_pb.admin_token() return True except Exception: pass diff --git a/backend/app/services/s3_storage.py b/backend/app/services/s3_storage.py new file mode 100644 index 0000000..12461e8 --- /dev/null +++ b/backend/app/services/s3_storage.py @@ -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) diff --git a/backend/app/services/social_store.py b/backend/app/services/social_store.py index 914186d..9f73b31 100644 --- a/backend/app/services/social_store.py +++ b/backend/app/services/social_store.py @@ -1,5 +1,4 @@ -"""Social graph: profiles, swipes, matches, DMs — file-backed store.""" - +"""Social graph — PocketBase backed (profiles, swipes, matches, DMs).""" from __future__ import annotations import json @@ -10,20 +9,15 @@ from pathlib import Path from typing import Any from app.data.social_profiles import CANDIDATE_PROFILES, MATCH_INTENTS - -STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json" +from app.services.pb_client import pb, pb_quote +from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb LIKE_ACTIONS = frozenset({"like", "right", "superlike"}) FREE_SWIPE_DAILY_LIMIT = 25 VIP_SWIPE_LIMIT = 9999 -_profiles: dict[str, dict] = {} # user_id -> profile -_swipes: list[dict] = [] -_matches: list[dict] = [] -_conversations: list[dict] = [] -_messages: list[dict] = [] -_memberships: dict[str, dict] = {} # user_id -> {expires_at, plan} -_orders: dict[str, dict] = {} +_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json" +_j: dict[str, Any] = {} def _now_iso() -> str: @@ -34,67 +28,93 @@ def _today_key() -> str: return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d") -def _persist() -> None: - STORE_PATH.parent.mkdir(parents=True, exist_ok=True) - STORE_PATH.write_text( - json.dumps({ - "profiles": _profiles, - "swipes": _swipes, - "matches": _matches, - "conversations": _conversations, - "messages": _messages, - "memberships": _memberships, - "orders": _orders, - }, ensure_ascii=False), - encoding="utf-8", - ) +def _pair_key(a: str, b: str) -> str: + return "|".join(sorted([a, b])) -def _restore() -> None: - global _profiles, _swipes, _matches, _conversations, _messages, _memberships, _orders - if not STORE_PATH.exists(): - return - try: - data = json.loads(STORE_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return - _profiles = data.get("profiles") or {} - _swipes = data.get("swipes") or [] - _matches = data.get("matches") or [] - _conversations = data.get("conversations") or [] - _messages = data.get("messages") or [] - _memberships = data.get("memberships") or {} - _orders = data.get("orders") or {} +def _profile_looking_for(record: dict) -> list[str]: + raw = record.get("lookingFor") or record.get("looking_for") or [] + cleaned = [x for x in raw if x in MATCH_INTENTS] + return cleaned or ["friends", "explore"] -_restore() +def _peer_profile(user_id: str) -> dict | None: + for p in CANDIDATE_PROFILES: + if p.get("userId") == user_id: + return p + if use_pb(): + row = safe_first("profiles", filter=f"userId={q(user_id)}") + if row: + return { + "id": f"user-{user_id}", + "userId": user_id, + "name": row.get("name", "游民"), + "location": row.get("location", "全球"), + "bio": row.get("bio", ""), + "photo": row.get("photo", "🧑‍💻"), + "tags": row.get("tags") or [], + } + prof = (_jload().get("profiles") or {}).get(user_id) + if prof: + return {"id": f"user-{user_id}", "userId": user_id, **prof} + return None -def _reload() -> None: - _restore() - +# ── VIP / membership ─────────────────────────────────────────────────── def is_vip(user_id: str) -> bool: - _reload() - m = _memberships.get(user_id) - if not m: - return False - exp = m.get("expires_at") or 0 - return exp > int(time.time()) + if use_pb(): + row = safe_first("memberships", filter=f"userId={q(user_id)}") + if not row: + return False + return int(row.get("expiresAt") or 0) > int(time.time()) + _jload() + m = (_j.get("memberships") or {}).get(user_id) + return bool(m and (m.get("expires_at") or 0) > int(time.time())) def ensure_membership(user_id: str, days: int = 365) -> None: - _memberships[user_id] = { - "plan": "vip", - "expires_at": int(time.time()) + days * 86400, - "updated_at": _now_iso(), - } - _persist() + exp = int(time.time()) + days * 86400 + if use_pb(): + row = safe_first("memberships", filter=f"userId={q(user_id)}") + payload = {"userId": user_id, "plan": "vip", "expiresAt": exp} + if row: + safe_update("memberships", row["id"], payload) + else: + safe_create("memberships", payload) + return + _jload() + _j.setdefault("memberships", {})[user_id] = {"plan": "vip", "expires_at": exp, "updated_at": _now_iso()} + _jsave() +# ── Profiles ─────────────────────────────────────────────────────────── + def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict: - if user_id not in _profiles: - _profiles[user_id] = { + if use_pb(): + row = safe_first("profiles", filter=f"userId={q(user_id)}") + if row: + return row + return safe_create( + "profiles", + { + "userId": user_id, + "name": name, + "location": extra.get("location", "全球"), + "citySlug": extra.get("citySlug", ""), + "gender": extra.get("gender", ""), + "single": extra.get("single", ""), + "bio": extra.get("bio", ""), + "photo": extra.get("photo", "🧑‍💻"), + "tags": extra.get("tags", []), + "lookingFor": extra.get("lookingFor", ["friends", "explore"]), + }, + ) or {} + + _jload() + profiles = _j.setdefault("profiles", {}) + if user_id not in profiles: + profiles[user_id] = { "userId": user_id, "name": name, "location": extra.get("location", "全球"), @@ -107,13 +127,12 @@ def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict: "lookingFor": extra.get("lookingFor", ["friends", "explore"]), "createdAt": _now_iso(), } - _persist() - return _profiles[user_id] + _jsave() + return profiles[user_id] def join_member(user_id: str, name: str, payload: dict) -> dict: - _reload() - profile = get_or_create_profile( + return get_or_create_profile( user_id, name, location=payload.get("city", "全球"), @@ -124,16 +143,21 @@ def join_member(user_id: str, name: str, payload: dict) -> dict: lookingFor=payload.get("lookingFor", ["friends", "explore"]), photo=payload.get("photo", "🧑‍💻"), ) - _persist() - return profile -def _profile_looking_for(record: dict) -> list[str]: - raw = record.get("lookingFor") or [] - cleaned = [x for x in raw if x in MATCH_INTENTS] - return cleaned or ["friends", "explore"] +def get_public_profile(user_id: str) -> dict | None: + if use_pb(): + row = safe_first("profiles", filter=f"userId={q(user_id)}") + if not row: + return None + return {**row, "vip": is_vip(user_id)} + _jload() + p = (_j.get("profiles") or {}).get(user_id) + return {**p, "vip": is_vip(user_id)} if p else None +# ── Candidates / swipes ──────────────────────────────────────────────── + def list_candidates( user_id: str, intent: str = "friends", @@ -142,46 +166,64 @@ def list_candidates( single: str = "", exclude_swiped: bool = True, ) -> list[dict]: - _reload() - my_profile = _profiles.get(user_id) - swiped_ids = set() + swiped_ids: set[str] = set() if exclude_swiped: - for s in _swipes: - if s.get("userId") == user_id: - swiped_ids.add(s.get("profileId")) + if use_pb(): + for s in safe_list("swipes", filter=f"userId={q(user_id)}"): + swiped_ids.add(s.get("profileId", "")) + else: + _jload() + for s in _j.get("swipes") or []: + if s.get("userId") == user_id: + swiped_ids.add(s.get("profileId", "")) pool = list(CANDIDATE_PROFILES) - # Real members who completed join profile - for uid, prof in _profiles.items(): - if uid == user_id: - continue - 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), - }) - if my_profile: - pool = [p for p in pool if p.get("userId") != user_id] + if use_pb(): + for row in safe_list("profiles"): + uid = row.get("userId", "") + if uid and uid != user_id: + pool.append({ + "id": f"user-{uid}", + "userId": uid, + "name": row.get("name", "游民"), + "location": row.get("location", "全球"), + "citySlug": row.get("citySlug", ""), + "gender": row.get("gender", ""), + "single": row.get("single", ""), + "bio": row.get("bio", ""), + "photo": row.get("photo", "🧑‍💻"), + "tags": row.get("tags") or [], + "lookingFor": _profile_looking_for(row), + }) + else: + _jload() + for uid, prof in (_j.get("profiles") or {}).items(): + if uid != user_id: + pool.append({ + "id": f"user-{uid}", + "userId": uid, + "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 = [] for p in pool: - if p["id"] in swiped_ids: + if p.get("userId") == user_id or p["id"] in swiped_ids: continue if intent and intent not in _profile_looking_for(p): continue if city and city not in (p.get("location") or "") and city != p.get("citySlug"): continue - if gender and is_vip(user_id) and gender and p.get("gender") != gender: + if gender and is_vip(user_id) and p.get("gender") != gender: continue - if single and is_vip(user_id) and single and p.get("single") != single: + if single and is_vip(user_id) and p.get("single") != single: continue results.append({**p, "intent": intent}) return results @@ -189,61 +231,65 @@ def list_candidates( def swipe_count_today(user_id: str) -> int: today = _today_key() - return sum(1 for s in _swipes if s.get("userId") == user_id and s.get("date") == today) + if use_pb(): + return sum( + 1 for s in safe_list("swipes", filter=f"userId={q(user_id)}") + if (s.get("swipeDate") or "") == today + ) + _jload() + return sum(1 for s in _j.get("swipes") or [] if s.get("userId") == user_id and s.get("date") == today) def get_quota(user_id: str) -> dict: - _reload() vip = is_vip(user_id) limit = VIP_SWIPE_LIMIT if vip else FREE_SWIPE_DAILY_LIMIT used = swipe_count_today(user_id) return {"vip": vip, "limit": limit, "used": used, "remaining": max(0, limit - used)} -def _pair_key(a: str, b: str) -> str: - return "|".join(sorted([a, b])) +def _find_profile(profile_id: str) -> dict | None: + for p in CANDIDATE_PROFILES: + if p["id"] == profile_id: + return p + if profile_id.startswith("user-"): + uid = profile_id[5:] + return _peer_profile(uid) + return None def _has_reciprocal_like(target_user_id: str, my_profile_id: str) -> bool: - target_profile = next((p for p in CANDIDATE_PROFILES if p.get("userId") == target_user_id), None) - if not target_profile: - return False - target_pid = target_profile["id"] - for s in _swipes: + if use_pb(): + rows = safe_list("swipes", filter=f"userId={q(target_user_id)} && profileId={q(my_profile_id)}") + return any(r.get("action") in LIKE_ACTIONS for r in rows) + _jload() + for s in _j.get("swipes") or []: if s.get("userId") == target_user_id and s.get("profileId") == my_profile_id: if s.get("action") in LIKE_ACTIONS: return True return False -def _ensure_match(user_id: str, profile: dict, intent: str) -> dict | None: - target_uid = profile.get("userId") - if not target_uid: - return None - pk = _pair_key(user_id, target_uid) - for m in _matches: - if m.get("pairKey") == pk: - return m - conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True) - match = { - "id": secrets.token_hex(6), - "pairKey": pk, - "userAId": user_id, - "userBId": target_uid, - "profileAId": _profiles.get(user_id, {}).get("id", ""), - "profileBId": profile.get("id"), - "intent": intent, - "conversationId": conv["id"], - "matchedAt": _now_iso(), - } - _matches.append(match) - _persist() - return match - - def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", match: bool = False) -> dict: pk = _pair_key(user_a, user_b) - for c in _conversations: + if use_pb(): + row = safe_first("conversations", filter=f"pairKey={q(pk)}") + if row: + return {"id": row["id"], **row} + created = safe_create( + "conversations", + { + "pairKey": pk, + "userAId": user_a, + "userBId": user_b, + "intent": intent, + "lastMessagePreview": "", + "readState": {user_a: _now_iso(), user_b: _now_iso()}, + }, + ) + return {"id": created["id"], **created} if created else {"id": "", "pairKey": pk} + + _jload() + for c in _j.get("conversations") or []: if c.get("pairKey") == pk: return c conv = { @@ -257,59 +303,94 @@ def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", matc "lastMessagePreview": "", "readState": {user_a: _now_iso(), user_b: _now_iso()}, } - _conversations.append(conv) - _persist() + _j.setdefault("conversations", []).append(conv) + _jsave() return conv -def _find_profile(profile_id: str) -> dict | None: - for p in CANDIDATE_PROFILES: - if p["id"] == profile_id: - return p - if profile_id.startswith("user-"): - uid = profile_id[5:] - prof = _profiles.get(uid) - if prof: - return { - "id": profile_id, - "userId": uid, - "name": prof.get("name", "游民"), - "location": prof.get("location", "全球"), - "bio": prof.get("bio", ""), - "photo": prof.get("photo", "🧑‍💻"), - "tags": prof.get("tags", []), - } - return None +def _ensure_match(user_id: str, profile: dict, intent: str) -> dict | None: + target_uid = profile.get("userId") + if not target_uid: + return None + pk = _pair_key(user_id, target_uid) + if use_pb(): + row = safe_first("match_connections", filter=f"pairKey={q(pk)}") + if row: + return row + conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True) + created = safe_create( + "match_connections", + { + "pairKey": pk, + "userAId": user_id, + "userBId": target_uid, + "profileBId": profile.get("id", ""), + "intent": intent, + "conversationId": conv.get("id", ""), + "matchedAt": _now_iso(), + }, + ) + return created + + _jload() + for m in _j.get("matches") or []: + if m.get("pairKey") == pk: + return m + conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True) + match = { + "id": secrets.token_hex(6), + "pairKey": pk, + "userAId": user_id, + "userBId": target_uid, + "profileBId": profile.get("id"), + "intent": intent, + "conversationId": conv["id"], + "matchedAt": _now_iso(), + } + _j.setdefault("matches", []).append(match) + _jsave() + return match def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict: - _reload() if action not in LIKE_ACTIONS and action != "dislike": action = "dislike" if action == "superlike" and not is_vip(user_id): return {"ok": False, "error": "vip_required"} - q = get_quota(user_id) - if not q["vip"] and q["remaining"] <= 0 and action in LIKE_ACTIONS: + qdata = get_quota(user_id) + if not qdata["vip"] and qdata["remaining"] <= 0 and action in LIKE_ACTIONS: return {"ok": False, "error": "quota_exceeded"} - for s in _swipes: - if s.get("userId") == user_id and s.get("profileId") == profile_id: + if use_pb(): + dup = safe_first( + "swipes", + filter=f"userId={q(user_id)} && profileId={q(profile_id)}", + ) + if dup: return {"ok": False, "error": "duplicate"} + else: + _jload() + for s in _j.get("swipes") or []: + if s.get("userId") == user_id and s.get("profileId") == profile_id: + return {"ok": False, "error": "duplicate"} profile = _find_profile(profile_id) if not profile: return {"ok": False, "error": "not_found"} - _swipes.append({ - "id": secrets.token_hex(6), + swipe_payload = { "userId": user_id, "profileId": profile_id, "action": action, "intent": intent, - "date": _today_key(), - "createdAt": _now_iso(), - }) - _persist() + "swipeDate": _today_key(), + } + if use_pb(): + safe_create("swipes", swipe_payload) + else: + _jload() + _j.setdefault("swipes", []).append({**swipe_payload, "id": secrets.token_hex(6), "date": _today_key(), "createdAt": _now_iso()}) + _jsave() matched = None if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id): @@ -317,118 +398,153 @@ def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dic if matched: from app.services import community_store - my_name = _profiles.get(user_id, {}).get("name", "游民") + my_prof = get_public_profile(user_id) or {} peer_name = profile.get("name", "游民") conv_id = matched.get("conversationId", "") link = f"/chat/{conv_id}" if conv_id else "/chat" - community_store.add_notification( - user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link - ) + community_store.add_notification(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link) peer_uid = profile.get("userId") if peer_uid: community_store.add_notification( - peer_uid, "匹配成功 🎉", f"你和 {my_name} 互相喜欢了,快去聊天吧", link + peer_uid, "匹配成功 🎉", f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧", link ) - return { - "ok": True, - "matched": bool(matched), - "match": matched, - } - - -def get_public_profile(user_id: str) -> dict | None: - _reload() - p = _profiles.get(user_id) - if not p: - return None - return {**p, "vip": is_vip(user_id)} + return {"ok": True, "matched": bool(matched), "match": matched} def undo_last_swipe(user_id: str) -> dict: - _reload() - for i in range(len(_swipes) - 1, -1, -1): - if _swipes[i].get("userId") == user_id: - removed = _swipes.pop(i) - _persist() + if use_pb(): + rows = safe_list("swipes", filter=f"userId={q(user_id)}") + rows.sort(key=lambda r: r.get("created", ""), reverse=True) + if not rows: + return {"ok": False, "error": "nothing_to_undo"} + last = rows[0] + try: + pb.delete_record("swipes", last["id"]) + except Exception: + pass + return {"ok": True, "removed": last} + _jload() + swipes = _j.get("swipes") or [] + for i in range(len(swipes) - 1, -1, -1): + if swipes[i].get("userId") == user_id: + removed = swipes.pop(i) + _jsave() return {"ok": True, "removed": removed} return {"ok": False, "error": "nothing_to_undo"} def list_likes_received(user_id: str) -> list[dict]: - """Profiles who liked this user's profile (simulated from static pool).""" - _reload() my_pid = f"user-{user_id}" likers = [] - for s in _swipes: - if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS: - uid = s.get("userId") - p = _profiles.get(uid) + if use_pb(): + for s in safe_list("swipes", filter=f"profileId={q(my_pid)}"): + if s.get("action") not in LIKE_ACTIONS: + continue + p = _peer_profile(s.get("userId", "")) if p: - likers.append({**p, "id": f"user-{uid}", "userId": uid}) + likers.append(p) + return likers + _jload() + for s in _j.get("swipes") or []: + if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS: + p = _peer_profile(s.get("userId", "")) + if p: + likers.append(p) return likers def list_likes(user_id: str) -> list[dict]: - _reload() - liked_ids = [s["profileId"] for s in _swipes if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS] out = [] - for pid in liked_ids: - p = _find_profile(pid) - if p: - out.append(p) + if use_pb(): + for s in safe_list("swipes", filter=f"userId={q(user_id)}"): + if s.get("action") in LIKE_ACTIONS: + p = _find_profile(s.get("profileId", "")) + if p: + out.append(p) + return out + _jload() + for s in _j.get("swipes") or []: + if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS: + p = _find_profile(s.get("profileId", "")) + if p: + out.append(p) return out def list_mutual(user_id: str) -> list[dict]: - _reload() items = [] - for m in _matches: + if use_pb(): + rows = safe_list("match_connections", filter=f"userAId={q(user_id)} || userBId={q(user_id)}") + for m in rows: + peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"] + items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")}) + return items + _jload() + for m in _j.get("matches") or []: if user_id in (m.get("userAId"), m.get("userBId")): peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"] - peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None) - items.append({**m, "peer": peer, "conversationId": m.get("conversationId")}) + items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")}) return items def list_conversations(user_id: str) -> list[dict]: - _reload() items = [] - for c in _conversations: + if use_pb(): + rows = safe_list("conversations", filter=f"userAId={q(user_id)} || userBId={q(user_id)}") + rows.sort(key=lambda c: c.get("lastMessageAt") or c.get("created", ""), reverse=True) + for c in rows: + peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] + read_at = (c.get("readState") or {}).get(user_id, "") + unread = 1 if (c.get("lastMessageAt") or "") > read_at else 0 + items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread}) + return items + _jload() + for c in _j.get("conversations") or []: if user_id not in (c.get("userAId"), c.get("userBId")): continue peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] - peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None) read_at = (c.get("readState") or {}).get(user_id, "") unread = 1 if c.get("lastMessageAt", "") > read_at else 0 - items.append({ - **c, - "peer": peer, - "unreadCount": unread, - }) + items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread}) items.sort(key=lambda x: x.get("lastMessageAt", ""), reverse=True) return items def get_conversation(conv_id: str, user_id: str) -> dict | None: - for c in _conversations: + if use_pb(): + row = safe_first("conversations", filter=f"id={q(conv_id)}") + if not row or user_id not in (row.get("userAId"), row.get("userBId")): + return None + peer_id = row["userBId"] if row["userAId"] == user_id else row["userAId"] + return {**row, "peer": _peer_profile(peer_id)} + _jload() + for c in _j.get("conversations") or []: if c["id"] == conv_id and user_id in (c.get("userAId"), c.get("userBId")): peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] - peer = next((p for p in CANDIDATE_PROFILES if p.get("userId") == peer_id), None) - return {**c, "peer": peer} + return {**c, "peer": _peer_profile(peer_id)} return None def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]: - _reload() conv = get_conversation(conv_id, user_id) if not conv: return [] - msgs = [m for m in _messages if m.get("conversationId") == conv_id] + if use_pb(): + msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}") + msgs.sort(key=lambda m: m.get("created", "")) + read_state = dict(conv.get("readState") or {}) + read_state[user_id] = _now_iso() + safe_update("conversations", conv_id, {"readState": read_state}) + return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]] + _jload() + msgs = [m for m in _j.get("messages") or [] if m.get("conversationId") == conv_id] msgs.sort(key=lambda x: x.get("createdAt", "")) - if conv.get("readState") is not None: - conv["readState"][user_id] = _now_iso() - _persist() + for c in _j.get("conversations") or []: + if c["id"] == conv_id: + c.setdefault("readState", {})[user_id] = _now_iso() + _jsave() + break return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]] @@ -437,6 +553,22 @@ def send_message(conv_id: str, user_id: str, body: str) -> dict | None: if not conv or not body.strip(): return None body = body.strip()[:2000] + if use_pb(): + msg = safe_create( + "messages", + {"conversationId": conv_id, "senderId": user_id, "body": body}, + ) + if not msg: + return None + read_state = dict(conv.get("readState") or {}) + read_state[user_id] = _now_iso() + safe_update( + "conversations", + conv_id, + {"lastMessageAt": _now_iso(), "lastMessagePreview": body[:80], "readState": read_state}, + ) + return {**msg, "mine": True, "createdAt": _now_iso()} + _jload() msg = { "id": secrets.token_hex(8), "conversationId": conv_id, @@ -444,13 +576,13 @@ def send_message(conv_id: str, user_id: str, body: str) -> dict | None: "body": body, "createdAt": _now_iso(), } - _messages.append(msg) - for c in _conversations: + _j.setdefault("messages", []).append(msg) + for c in _j.get("conversations") or []: if c["id"] == conv_id: c["lastMessageAt"] = _now_iso() c["lastMessagePreview"] = body[:80] break - _persist() + _jsave() return {**msg, "mine": True} @@ -458,30 +590,65 @@ def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}" status = "paid" if dev_auto_pay else "pending" order = { - "id": order_id, + "orderId": order_id, "userId": user_id, "payType": pay_type, "amount": amount, "status": status, - "createdAt": _now_iso(), } - _orders[order_id] = order + if use_pb(): + safe_create("orders", order) + else: + _jload() + _j.setdefault("orders", {})[order_id] = {**order, "id": order_id, "createdAt": _now_iso()} + _jsave() if dev_auto_pay: ensure_membership(user_id) - _persist() - return order + return {"id": order_id, **order, "createdAt": _now_iso()} def get_order(order_id: str) -> dict | None: - _reload() - return _orders.get(order_id) + if use_pb(): + row = safe_first("orders", filter=f"orderId={q(order_id)}") + return row + _jload() + return (_j.get("orders") or {}).get(order_id) def mark_order_paid(order_id: str) -> dict | None: - order = _orders.get(order_id) + if use_pb(): + row = safe_first("orders", filter=f"orderId={q(order_id)}") + if not row: + return None + safe_update("orders", row["id"], {"status": "paid"}) + ensure_membership(row.get("userId", "")) + return {**row, "status": "paid"} + _jload() + order = (_j.get("orders") or {}).get(order_id) if not order: return None order["status"] = "paid" ensure_membership(order["userId"]) - _persist() + _jsave() return order + + +# ── JSON fallback ────────────────────────────────────────────────────── + +def _jload() -> dict: + global _j + if _j: + return _j + if _JSON_PATH.exists(): + try: + _j = json.loads(_JSON_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + _j = {} + else: + _j = {} + return _j + + +def _jsave() -> None: + _JSON_PATH.parent.mkdir(parents=True, exist_ok=True) + _JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8") diff --git a/backend/requirements.txt b/backend/requirements.txt index 5e0767e..9a51cbf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,5 @@ pydantic[email]==2.10.4 pydantic-settings==2.7.0 python-dotenv==1.0.1 email-validator==2.2.0 +boto3==1.35.99 +python-multipart==0.0.20 diff --git a/backend/scripts/init_pb_collections.py b/backend/scripts/init_pb_collections.py new file mode 100644 index 0000000..3f74e50 --- /dev/null +++ b/backend/scripts/init_pb_collections.py @@ -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() diff --git a/deploy/nomadro-api.env.example b/deploy/nomadro-api.env.example index c1e2ae2..cde1731 100644 --- a/deploy/nomadro-api.env.example +++ b/deploy/nomadro-api.env.example @@ -5,6 +5,25 @@ PAYMENT_JOIN_AMOUNT=10000 PAYMENT_DEFAULT_AMOUNT=10000 SITE_BASE_URL=https://nomadweb.nomadro.com +# PocketBase (host process :8090) +POCKETBASE_URL=http://127.0.0.1:8090 +POCKETBASE_ADMIN_EMAIL=admin@nomadro.com +POCKETBASE_ADMIN_PASSWORD= + +# SeaweedFS S3 (s3.nomadro.com → 127.0.0.1:8333) +S3_ENABLED=true +S3_ENDPOINT=http://127.0.0.1:8333 +S3_PUBLIC_URL=https://s3.nomadro.com +S3_ACCESS_KEY=nomadweb +S3_SECRET_KEY= +S3_BUCKET=nomadweb +S3_REGION=us-east-1 +S3_UPLOAD_PREFIX=nomadweb + +# ntfy push (docker on :2586) +NTFY_ENABLED=true +NTFY_URL=http://127.0.0.1:2586 + ZPAY_PID= ZPAY_KEY= ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php diff --git a/deploy/systemd/nomadro-api.service b/deploy/systemd/nomadro-api.service index f235a44..a32614e 100644 --- a/deploy/systemd/nomadro-api.service +++ b/deploy/systemd/nomadro-api.service @@ -8,7 +8,7 @@ WorkingDirectory=/opt/nomadweb/backend EnvironmentFile=/opt/nomadweb/deploy/nomadro-api.env Environment=POCKETBASE_URL=http://127.0.0.1:8090 Environment=POCKETBASE_ADMIN_EMAIL=admin@nomadro.com -Environment=POCKETBASE_ADMIN_PASSWORD=admin123456 +Environment=POCKETBASE_ADMIN_PASSWORD=Xiao4669805 Environment=CORS_ORIGINS=https://nomadweb.nomadro.com,http://nomadweb.nomadro.com ExecStart=/opt/nomadweb/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8055 --workers 2 Restart=always diff --git a/scripts/deploy_backend_hotfix.py b/scripts/deploy_backend_hotfix.py new file mode 100644 index 0000000..0d7826c --- /dev/null +++ b/scripts/deploy_backend_hotfix.py @@ -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()) diff --git a/scripts/deploy_update.py b/scripts/deploy_update.py index 329199c..80a715b 100644 --- a/scripts/deploy_update.py +++ b/scripts/deploy_update.py @@ -138,6 +138,8 @@ build_backend() { cp requirements.txt .venv/.reqsha fi systemctl restart nomadro-api + # Ensure PocketBase collections exist (idempotent) + .venv/bin/python scripts/init_pb_collections.py || echo "PB init skipped" } CHANGED="${CHANGED:-}" diff --git a/scripts/setup_production_env.py b/scripts/setup_production_env.py new file mode 100644 index 0000000..a69db2d --- /dev/null +++ b/scripts/setup_production_env.py @@ -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()