nomadweb/backend/app/services/social_store.py
eric 96fa96c61e Migrate auth/community/social to PocketBase and wire SeaweedFS S3 uploads.
Keep FastAPI as the only backend; add media upload, ntfy hook, and PB collection bootstrap for production.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:19:30 -05:00

655 lines
21 KiB
Python

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