nomadweb/backend/app/services/community_store.py
eric 48408d3079 feat: complete NomadCNA parity layer with platform APIs and pages
Add services, videos, AI assistant, map/weather, gigs post, notifications settings, static legal pages, Google OAuth, payment router, real-user matching, newsletter, and content submission — all using nomadweb UI patterns.

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

497 lines
14 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] = []
_views: dict[str, int] = {} # discussion_id -> view count
_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,
"views": _views,
"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, _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)
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)),
}
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, 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
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 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)}
def create_gig(user_id: str, user_name: str, payload: dict) -> dict:
_seed_if_needed()
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",
}
_gigs.insert(0, gig)
_persist()
return gig
def meetup_social_suggestions(meetup_id: str, limit: int = 6) -> list[dict]:
_seed_if_needed()
m = get_meetup(meetup_id)
if not m:
return []
city = m.get("city", "")
from app.data.social_profiles import CANDIDATE_PROFILES
out = []
for p in CANDIDATE_PROFILES:
if city and city not in (p.get("location") or ""):
continue
out.append(p)
if len(out) >= limit:
break
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 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
return None
def list_notifications_filtered(user_id: str, include_archived: bool = False) -> list[dict]:
_reload()
items = _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
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 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 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, []):
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),
}