"""Social graph — PocketBase backed (profiles, swipes, matches, DMs).""" 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 from app.services.pb_client import pb, pb_quote from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb LIKE_ACTIONS = frozenset({"like", "right", "superlike"}) FREE_SWIPE_DAILY_LIMIT = 25 VIP_SWIPE_LIMIT = 9999 _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "social_store.json" _j: dict[str, Any] = {} 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 _pair_key(a: str, b: str) -> str: return "|".join(sorted([a, b])) def _profile_looking_for(record: dict) -> list[str]: raw = record.get("lookingFor") or record.get("looking_for") or [] cleaned = [x for x in raw if x in MATCH_INTENTS] return cleaned or ["friends", "explore"] def _peer_profile(user_id: str) -> dict | None: for p in CANDIDATE_PROFILES: if p.get("userId") == user_id: return p if use_pb(): row = safe_first("profiles", filter=f"userId={q(user_id)}") if row: return { "id": f"user-{user_id}", "userId": user_id, "name": row.get("name", "游民"), "location": row.get("location", "全球"), "bio": row.get("bio", ""), "photo": row.get("photo", "🧑‍💻"), "tags": row.get("tags") or [], } prof = (_jload().get("profiles") or {}).get(user_id) if prof: return {"id": f"user-{user_id}", "userId": user_id, **prof} return None # ── VIP / membership ─────────────────────────────────────────────────── def is_vip(user_id: str) -> bool: if use_pb(): row = safe_first("memberships", filter=f"userId={q(user_id)}") if not row: return False return int(row.get("expiresAt") or 0) > int(time.time()) _jload() m = (_j.get("memberships") or {}).get(user_id) return bool(m and (m.get("expires_at") or 0) > int(time.time())) def ensure_membership(user_id: str, days: int = 365) -> None: exp = int(time.time()) + days * 86400 if use_pb(): row = safe_first("memberships", filter=f"userId={q(user_id)}") payload = {"userId": user_id, "plan": "vip", "expiresAt": exp} if row: safe_update("memberships", row["id"], payload) else: safe_create("memberships", payload) return _jload() _j.setdefault("memberships", {})[user_id] = {"plan": "vip", "expires_at": exp, "updated_at": _now_iso()} _jsave() # ── Profiles ─────────────────────────────────────────────────────────── def get_or_create_profile(user_id: str, name: str, **extra: Any) -> dict: if use_pb(): row = safe_first("profiles", filter=f"userId={q(user_id)}") if row: return row return safe_create( "profiles", { "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"]), }, ) or {} _jload() profiles = _j.setdefault("profiles", {}) 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(), } _jsave() return profiles[user_id] def join_member(user_id: str, name: str, payload: dict) -> dict: return 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", "🧑‍💻"), ) def get_public_profile(user_id: str) -> dict | None: if use_pb(): row = safe_first("profiles", filter=f"userId={q(user_id)}") if not row: return None return {**row, "vip": is_vip(user_id)} _jload() p = (_j.get("profiles") or {}).get(user_id) return {**p, "vip": is_vip(user_id)} if p else None # ── Candidates / swipes ──────────────────────────────────────────────── def list_candidates( user_id: str, intent: str = "friends", city: str = "", gender: str = "", single: str = "", exclude_swiped: bool = True, ) -> list[dict]: swiped_ids: set[str] = set() if exclude_swiped: if use_pb(): for s in safe_list("swipes", filter=f"userId={q(user_id)}"): swiped_ids.add(s.get("profileId", "")) else: _jload() for s in _j.get("swipes") or []: if s.get("userId") == user_id: swiped_ids.add(s.get("profileId", "")) pool = list(CANDIDATE_PROFILES) if use_pb(): for row in safe_list("profiles"): uid = row.get("userId", "") if uid and uid != user_id: pool.append({ "id": f"user-{uid}", "userId": uid, "name": row.get("name", "游民"), "location": row.get("location", "全球"), "citySlug": row.get("citySlug", ""), "gender": row.get("gender", ""), "single": row.get("single", ""), "bio": row.get("bio", ""), "photo": row.get("photo", "🧑‍💻"), "tags": row.get("tags") or [], "lookingFor": _profile_looking_for(row), }) else: _jload() for uid, prof in (_j.get("profiles") or {}).items(): if uid != user_id: pool.append({ "id": f"user-{uid}", "userId": uid, "name": prof.get("name", "游民"), "location": prof.get("location", "全球"), "citySlug": prof.get("citySlug", ""), "gender": prof.get("gender", ""), "single": prof.get("single", ""), "bio": prof.get("bio", ""), "photo": prof.get("photo", "🧑‍💻"), "tags": prof.get("tags", []), "lookingFor": _profile_looking_for(prof), }) results = [] for p in pool: if p.get("userId") == user_id or 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 p.get("gender") != gender: continue if single and is_vip(user_id) and p.get("single") != single: continue results.append({**p, "intent": intent}) return results def swipe_count_today(user_id: str) -> int: today = _today_key() if use_pb(): return sum( 1 for s in safe_list("swipes", filter=f"userId={q(user_id)}") if (s.get("swipeDate") or "") == today ) _jload() return sum(1 for s in _j.get("swipes") or [] if s.get("userId") == user_id and s.get("date") == today) def get_quota(user_id: str) -> dict: 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 _find_profile(profile_id: str) -> dict | None: for p in CANDIDATE_PROFILES: if p["id"] == profile_id: return p if profile_id.startswith("user-"): uid = profile_id[5:] return _peer_profile(uid) return None def _has_reciprocal_like(target_user_id: str, my_profile_id: str) -> bool: if use_pb(): rows = safe_list("swipes", filter=f"userId={q(target_user_id)} && profileId={q(my_profile_id)}") return any(r.get("action") in LIKE_ACTIONS for r in rows) _jload() for s in _j.get("swipes") or []: 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_conversation(user_a: str, user_b: str, intent: str = "friends", match: bool = False) -> dict: pk = _pair_key(user_a, user_b) if use_pb(): row = safe_first("conversations", filter=f"pairKey={q(pk)}") if row: return {"id": row["id"], **row} created = safe_create( "conversations", { "pairKey": pk, "userAId": user_a, "userBId": user_b, "intent": intent, "lastMessagePreview": "", "readState": {user_a: _now_iso(), user_b: _now_iso()}, }, ) return {"id": created["id"], **created} if created else {"id": "", "pairKey": pk} _jload() for c in _j.get("conversations") or []: 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()}, } _j.setdefault("conversations", []).append(conv) _jsave() return conv 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) if use_pb(): row = safe_first("match_connections", filter=f"pairKey={q(pk)}") if row: return row conv = _ensure_conversation(user_id, target_uid, intent=intent, match=True) created = safe_create( "match_connections", { "pairKey": pk, "userAId": user_id, "userBId": target_uid, "profileBId": profile.get("id", ""), "intent": intent, "conversationId": conv.get("id", ""), "matchedAt": _now_iso(), }, ) return created _jload() for m in _j.get("matches") or []: 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, "profileBId": profile.get("id"), "intent": intent, "conversationId": conv["id"], "matchedAt": _now_iso(), } _j.setdefault("matches", []).append(match) _jsave() return match def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict: 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"} qdata = get_quota(user_id) if not qdata["vip"] and qdata["remaining"] <= 0 and action in LIKE_ACTIONS: return {"ok": False, "error": "quota_exceeded"} if use_pb(): dup = safe_first( "swipes", filter=f"userId={q(user_id)} && profileId={q(profile_id)}", ) if dup: return {"ok": False, "error": "duplicate"} else: _jload() for s in _j.get("swipes") or []: if s.get("userId") == user_id and s.get("profileId") == profile_id: return {"ok": False, "error": "duplicate"} profile = _find_profile(profile_id) if not profile: return {"ok": False, "error": "not_found"} swipe_payload = { "userId": user_id, "profileId": profile_id, "action": action, "intent": intent, "swipeDate": _today_key(), } if use_pb(): safe_create("swipes", swipe_payload) else: _jload() _j.setdefault("swipes", []).append({**swipe_payload, "id": secrets.token_hex(6), "date": _today_key(), "createdAt": _now_iso()}) _jsave() 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_prof = get_public_profile(user_id) or {} 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_prof.get('name', '游民')} 互相喜欢了,快去聊天吧", link ) return {"ok": True, "matched": bool(matched), "match": matched} def undo_last_swipe(user_id: str) -> dict: if use_pb(): rows = safe_list("swipes", filter=f"userId={q(user_id)}") rows.sort(key=lambda r: r.get("created", ""), reverse=True) if not rows: return {"ok": False, "error": "nothing_to_undo"} last = rows[0] try: pb.delete_record("swipes", last["id"]) except Exception: pass return {"ok": True, "removed": last} _jload() swipes = _j.get("swipes") or [] for i in range(len(swipes) - 1, -1, -1): if swipes[i].get("userId") == user_id: removed = swipes.pop(i) _jsave() return {"ok": True, "removed": removed} return {"ok": False, "error": "nothing_to_undo"} def list_likes_received(user_id: str) -> list[dict]: my_pid = f"user-{user_id}" likers = [] if use_pb(): for s in safe_list("swipes", filter=f"profileId={q(my_pid)}"): if s.get("action") not in LIKE_ACTIONS: continue p = _peer_profile(s.get("userId", "")) if p: likers.append(p) return likers _jload() for s in _j.get("swipes") or []: if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS: p = _peer_profile(s.get("userId", "")) if p: likers.append(p) return likers def list_likes(user_id: str) -> list[dict]: out = [] if use_pb(): for s in safe_list("swipes", filter=f"userId={q(user_id)}"): if s.get("action") in LIKE_ACTIONS: p = _find_profile(s.get("profileId", "")) if p: out.append(p) return out _jload() for s in _j.get("swipes") or []: if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS: p = _find_profile(s.get("profileId", "")) if p: out.append(p) return out def list_mutual(user_id: str) -> list[dict]: items = [] if use_pb(): rows = safe_list("match_connections", filter=f"userAId={q(user_id)} || userBId={q(user_id)}") for m in rows: peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"] items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")}) return items _jload() for m in _j.get("matches") or []: if user_id in (m.get("userAId"), m.get("userBId")): peer_id = m["userBId"] if m["userAId"] == user_id else m["userAId"] items.append({**m, "peer": _peer_profile(peer_id), "conversationId": m.get("conversationId")}) return items def list_conversations(user_id: str) -> list[dict]: items = [] if use_pb(): rows = safe_list("conversations", filter=f"userAId={q(user_id)} || userBId={q(user_id)}") rows.sort(key=lambda c: c.get("lastMessageAt") or c.get("created", ""), reverse=True) for c in rows: peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] read_at = (c.get("readState") or {}).get(user_id, "") unread = 1 if (c.get("lastMessageAt") or "") > read_at else 0 items.append({**c, "peer": _peer_profile(peer_id), "unreadCount": unread}) return items _jload() for c in _j.get("conversations") or []: if user_id not in (c.get("userAId"), c.get("userBId")): continue peer_id = c["userBId"] if c["userAId"] == user_id else c["userAId"] read_at = (c.get("readState") or {}).get(user_id, "") unread = 1 if c.get("lastMessageAt", "") > read_at else 0 items.append({**c, "peer": _peer_profile(peer_id), "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: if use_pb(): row = safe_first("conversations", filter=f"id={q(conv_id)}") if not row or user_id not in (row.get("userAId"), row.get("userBId")): return None peer_id = row["userBId"] if row["userAId"] == user_id else row["userAId"] return {**row, "peer": _peer_profile(peer_id)} _jload() for c in _j.get("conversations") or []: 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"] return {**c, "peer": _peer_profile(peer_id)} return None def list_messages(conv_id: str, user_id: str, limit: int = 50) -> list[dict]: conv = get_conversation(conv_id, user_id) if not conv: return [] if use_pb(): msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}") msgs.sort(key=lambda m: m.get("created", "")) read_state = dict(conv.get("readState") or {}) read_state[user_id] = _now_iso() safe_update("conversations", conv_id, {"readState": read_state}) return [{**m, "mine": m.get("senderId") == user_id} for m in msgs[-limit:]] _jload() msgs = [m for m in _j.get("messages") or [] if m.get("conversationId") == conv_id] msgs.sort(key=lambda x: x.get("createdAt", "")) for c in _j.get("conversations") or []: if c["id"] == conv_id: c.setdefault("readState", {})[user_id] = _now_iso() _jsave() break 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] if use_pb(): msg = safe_create( "messages", {"conversationId": conv_id, "senderId": user_id, "body": body}, ) if not msg: return None read_state = dict(conv.get("readState") or {}) read_state[user_id] = _now_iso() safe_update( "conversations", conv_id, {"lastMessageAt": _now_iso(), "lastMessagePreview": body[:80], "readState": read_state}, ) return {**msg, "mine": True, "createdAt": _now_iso()} _jload() msg = { "id": secrets.token_hex(8), "conversationId": conv_id, "senderId": user_id, "body": body, "createdAt": _now_iso(), } _j.setdefault("messages", []).append(msg) for c in _j.get("conversations") or []: if c["id"] == conv_id: c["lastMessageAt"] = _now_iso() c["lastMessagePreview"] = body[:80] break _jsave() 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 = { "orderId": order_id, "userId": user_id, "payType": pay_type, "amount": amount, "status": status, } if use_pb(): safe_create("orders", order) else: _jload() _j.setdefault("orders", {})[order_id] = {**order, "id": order_id, "createdAt": _now_iso()} _jsave() if dev_auto_pay: ensure_membership(user_id) return {"id": order_id, **order, "createdAt": _now_iso()} def get_order(order_id: str) -> dict | None: if use_pb(): row = safe_first("orders", filter=f"orderId={q(order_id)}") return row _jload() return (_j.get("orders") or {}).get(order_id) def mark_order_paid(order_id: str) -> dict | None: if use_pb(): row = safe_first("orders", filter=f"orderId={q(order_id)}") if not row: return None safe_update("orders", row["id"], {"status": "paid"}) ensure_membership(row.get("userId", "")) return {**row, "status": "paid"} _jload() order = (_j.get("orders") or {}).get(order_id) if not order: return None order["status"] = "paid" ensure_membership(order["userId"]) _jsave() return order # ── JSON fallback ────────────────────────────────────────────────────── def _jload() -> dict: global _j if _j: return _j if _JSON_PATH.exists(): try: _j = json.loads(_JSON_PATH.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): _j = {} else: _j = {} return _j def _jsave() -> None: _JSON_PATH.parent.mkdir(parents=True, exist_ok=True) _JSON_PATH.write_text(json.dumps(_j, ensure_ascii=False), encoding="utf-8")