nomadweb/backend/app/services/community_store.py
eric ff7b721acb feat: NomadCNA community layer, social matching, payments, and ebook
Port meetups, discussions, gigs, dating/chat, VIP pay (ZPay/XorPay), MiroTalk live, digital academy, and ebook reader. Add persistent community_store, git-first deploy docs, and env template for production secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:38:13 -05:00

395 lines
11 KiB
Python

"""Persistent community data: 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
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] = []
_seeded = False
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _slugify(text: str) -> str:
s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", text.lower()).strip("-")
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,
"seeded": _seeded,
}, ensure_ascii=False),
encoding="utf-8",
)
def _restore() -> None:
global _meetups, _discussions, _replies, _discussion_likes, _reply_likes
global _rsvps, _gigs, _gig_apps, _notifications, _feedback, _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 []
_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)
return {
**d,
"reply_count": len(replies),
"like_count": max(base_likes, stored) if stored == 0 else base_likes + stored,
}
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())))
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
def create_meetup(user_id: str, user_name: str, payload: dict) -> dict:
_seed_if_needed()
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
def rsvp_meetup(meetup_id: str, user_id: str | None = None) -> dict:
_seed_if_needed()
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"]:
return {"ok": False, "error": "full"}
rset.add(uid)
_persist()
return {"ok": True, "message": f"已报名「{m['title']}」", "rsvp_count": len(rset)}
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}
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]
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) -> dict | None:
_seed_if_needed()
for d in _discussions:
if d["id"] == discussion_id:
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
def create_discussion(user_id: str, user_name: str, payload: dict) -> dict:
_seed_if_needed()
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)
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):
return None
rid = secrets.token_hex(6)
reply = {
"id": rid,
"author": user_name,
"author_id": user_id,
"author_emoji": emoji,
"content": content,
"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
else:
likes.add(user_id)
liked = True
_persist()
return {"liked": liked, "like_count": _discussion_like_count(discussion_id)}
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
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 add_notification(user_id: str, title: str, body: str, link: str = "") -> None:
items = _notifications.setdefault(user_id, [])
items.insert(0, {
"id": secrets.token_hex(6),
"title": title,
"body": body,
"link": link,
"read": False,
"created_at": _now_iso(),
})
items[:] = items[:50]
_persist()
def list_notifications(user_id: str) -> list[dict]:
_reload()
return _notifications.get(user_id, [])
def mark_notifications_read(user_id: str) -> None:
_reload()
for n in _notifications.get(user_id, []):
n["read"] = True
_persist()
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()
return item
def stats_overview() -> dict:
_seed_if_needed()
return {
"meetups": len(_meetups),
"discussions": len(_discussions),
"gigs": len(_gigs),
"members_active": len(_rsvps) + len(_gig_apps),
}