Port meetups, discussions, gigs, dating/chat, VIP pay (ZPay/XorPay), MiroTalk live, digital academy, and ebook reader. Add persistent community_store, git-first deploy docs, and env template for production secrets. Co-authored-by: Cursor <cursoragent@cursor.com>
432 lines
12 KiB
Python
432 lines
12 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)
|
|
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
|