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>
395 lines
11 KiB
Python
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),
|
|
}
|