nomadweb/backend/app/services/social_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

488 lines
14 KiB
Python

"""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)
# Real members who completed join profile
for uid, prof in _profiles.items():
if uid == user_id:
continue
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),
})
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 _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:]
prof = _profiles.get(uid)
if prof:
return {
"id": profile_id,
"userId": uid,
"name": prof.get("name", "游民"),
"location": prof.get("location", "全球"),
"bio": prof.get("bio", ""),
"photo": prof.get("photo", "🧑‍💻"),
"tags": prof.get("tags", []),
}
return None
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 = _find_profile(profile_id)
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_received(user_id: str) -> list[dict]:
"""Profiles who liked this user's profile (simulated from static pool)."""
_reload()
my_pid = f"user-{user_id}"
likers = []
for s in _swipes:
if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS:
uid = s.get("userId")
p = _profiles.get(uid)
if p:
likers.append({**p, "id": f"user-{uid}", "userId": uid})
return likers
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]
out = []
for pid in liked_ids:
p = _find_profile(pid)
if p:
out.append(p)
return out
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