"""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), }