"""Social graph: profiles, swipes, matches, DMs — file-backed store.""" from __future__ import annotations import json import secrets import time from datetime import datetime, timezone, timedelta 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" 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] = {} def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() 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 _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 {} _restore() def _reload() -> None: _restore() 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()) 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() def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict: if user_id not in _profiles: _profiles[user_id] = { "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"]), "createdAt": _now_iso(), } _persist() return _profiles[user_id] def join_member(user_id: str, name: str, payload: dict) -> dict: _reload() profile = get_or_create_profile( user_id, name, location=payload.get("city", "全球"), citySlug=payload.get("citySlug", ""), gender=payload.get("gender", ""), single=payload.get("single", ""), bio=payload.get("bio", ""), 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 list_candidates( user_id: str, intent: str = "friends", city: str = "", gender: str = "", single: str = "", exclude_swiped: bool = True, ) -> list[dict]: _reload() my_profile = _profiles.get(user_id) swiped_ids = set() if exclude_swiped: for s in _swipes: if s.get("userId") == user_id: swiped_ids.add(s.get("profileId")) pool = list(CANDIDATE_PROFILES) if my_profile: pool = [p for p in pool if p.get("userId") != user_id] results = [] for p in pool: if 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: continue if single and is_vip(user_id) and single and p.get("single") != single: continue results.append({**p, "intent": intent}) return results 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) 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 _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 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 c.get("pairKey") == pk: return c conv = { "id": secrets.token_hex(8), "pairKey": pk, "type": "match" if match else "direct", "userAId": user_a, "userBId": user_b, "intent": intent, "lastMessageAt": _now_iso(), "lastMessagePreview": "", "readState": {user_a: _now_iso(), user_b: _now_iso()}, } _conversations.append(conv) _persist() return conv 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: return {"ok": False, "error": "quota_exceeded"} for s in _swipes: if s.get("userId") == user_id and s.get("profileId") == profile_id: return {"ok": False, "error": "duplicate"} profile = next((p for p in CANDIDATE_PROFILES if p["id"] == profile_id), None) if not profile: return {"ok": False, "error": "not_found"} _swipes.append({ "id": secrets.token_hex(6), "userId": user_id, "profileId": profile_id, "action": action, "intent": intent, "date": _today_key(), "createdAt": _now_iso(), }) _persist() matched = None if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id): matched = _ensure_match(user_id, profile, intent) if matched: from app.services import community_store my_name = _profiles.get(user_id, {}).get("name", "游民") 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 ) peer_uid = profile.get("userId") if peer_uid: community_store.add_notification( peer_uid, "匹配成功 🎉", f"你和 {my_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)} 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() return {"ok": True, "removed": removed} return {"ok": False, "error": "nothing_to_undo"} 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] return [p for p in CANDIDATE_PROFILES if p["id"] in liked_ids] def list_mutual(user_id: str) -> list[dict]: _reload() items = [] for m in _matches: 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")}) return items def list_conversations(user_id: str) -> list[dict]: _reload() items = [] for c in _conversations: 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.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 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 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] msgs.sort(key=lambda x: x.get("createdAt", "")) if conv.get("readState") is not None: conv["readState"][user_id] = _now_iso() _persist() return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]] def send_message(conv_id: str, user_id: str, body: str) -> dict | None: conv = get_conversation(conv_id, user_id) if not conv or not body.strip(): return None body = body.strip()[:2000] msg = { "id": secrets.token_hex(8), "conversationId": conv_id, "senderId": user_id, "body": body, "createdAt": _now_iso(), } _messages.append(msg) for c in _conversations: if c["id"] == conv_id: c["lastMessageAt"] = _now_iso() c["lastMessagePreview"] = body[:80] break _persist() return {**msg, "mine": True} def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = False) -> dict: 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, "userId": user_id, "payType": pay_type, "amount": amount, "status": status, "createdAt": _now_iso(), } _orders[order_id] = order if dev_auto_pay: ensure_membership(user_id) _persist() return order def get_order(order_id: str) -> dict | None: _reload() return _orders.get(order_id) def mark_order_paid(order_id: str) -> dict | None: order = _orders.get(order_id) if not order: return None order["status"] = "paid" ensure_membership(order["userId"]) _persist() return order