943 lines
30 KiB
Python
943 lines
30 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 membership_expires_at(user_id: str) -> int:
|
||
if use_pb():
|
||
row = safe_first("memberships", filter=f"userId={q(user_id)}")
|
||
return int((row or {}).get("expiresAt") or 0)
|
||
_jload()
|
||
return int(((_j.get("memberships") or {}).get(user_id) or {}).get("expires_at") or 0)
|
||
|
||
|
||
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:
|
||
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", "🧑💻"),
|
||
)
|
||
try:
|
||
seed_demo_social_activity(user_id)
|
||
except Exception:
|
||
pass
|
||
return profile
|
||
|
||
|
||
def get_public_profile(user_id: str) -> dict | None:
|
||
if not user_id:
|
||
return None
|
||
# Accept /members/user-{id} style links
|
||
lookup = user_id[5:] if user_id.startswith("user-") else user_id
|
||
|
||
if use_pb():
|
||
row = safe_first("profiles", filter=f"userId={q(lookup)}")
|
||
if row:
|
||
return {
|
||
"id": row.get("id") or f"user-{lookup}",
|
||
"userId": row.get("userId") or lookup,
|
||
"name": row.get("name") or "游民",
|
||
"location": row.get("location") or "",
|
||
"citySlug": row.get("citySlug") or "",
|
||
"gender": row.get("gender") or "",
|
||
"single": row.get("single") or "",
|
||
"bio": row.get("bio") or "",
|
||
"photo": row.get("photo") or "🧑💻",
|
||
"tags": row.get("tags") or [],
|
||
"lookingFor": _profile_looking_for(row),
|
||
"vip": is_vip(lookup),
|
||
}
|
||
else:
|
||
_jload()
|
||
p = (_j.get("profiles") or {}).get(lookup)
|
||
if p:
|
||
return {
|
||
"id": f"user-{lookup}",
|
||
"userId": lookup,
|
||
"name": p.get("name") or "游民",
|
||
"location": p.get("location") or "",
|
||
"citySlug": p.get("citySlug") or "",
|
||
"gender": p.get("gender") or "",
|
||
"single": p.get("single") or "",
|
||
"bio": p.get("bio") or "",
|
||
"photo": p.get("photo") or "🧑💻",
|
||
"tags": p.get("tags") or [],
|
||
"lookingFor": _profile_looking_for(p),
|
||
"vip": is_vip(lookup),
|
||
}
|
||
|
||
for cand in CANDIDATE_PROFILES:
|
||
if cand.get("userId") == lookup or cand.get("id") == lookup or cand.get("id") == user_id:
|
||
return {
|
||
"id": cand.get("id") or f"user-{cand.get('userId', lookup)}",
|
||
"userId": cand.get("userId") or lookup,
|
||
"name": cand.get("name") or "游民",
|
||
"location": cand.get("location") or "",
|
||
"citySlug": cand.get("citySlug") or "",
|
||
"gender": cand.get("gender") or "",
|
||
"single": cand.get("single") or "",
|
||
"bio": cand.get("bio") or "",
|
||
"photo": cand.get("photo") or "🧑💻",
|
||
"tags": cand.get("tags") or [],
|
||
"lookingFor": _profile_looking_for(cand),
|
||
"vip": False,
|
||
}
|
||
return None
|
||
|
||
|
||
def normalize_peer(peer: dict | None) -> dict | None:
|
||
if not peer:
|
||
return None
|
||
uid = peer.get("userId") or ""
|
||
return {
|
||
"id": peer.get("id") or (f"user-{uid}" if uid else "unknown"),
|
||
"userId": uid,
|
||
"name": peer.get("name") or "游民",
|
||
"location": peer.get("location") or "",
|
||
"citySlug": peer.get("citySlug") or "",
|
||
"gender": peer.get("gender") or "",
|
||
"single": peer.get("single") or "",
|
||
"bio": peer.get("bio") or "",
|
||
"photo": peer.get("photo") or "🧑💻",
|
||
"tags": peer.get("tags") or [],
|
||
"lookingFor": _profile_looking_for(peer),
|
||
"intent": peer.get("intent") or "",
|
||
}
|
||
|
||
|
||
# ── 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.notify import notify_user
|
||
|
||
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"
|
||
notify_user(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link, "match")
|
||
peer_uid = profile.get("userId")
|
||
if peer_uid:
|
||
notify_user(
|
||
peer_uid,
|
||
"匹配成功 🎉",
|
||
f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧",
|
||
link,
|
||
"match",
|
||
)
|
||
|
||
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:
|
||
if 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") or "") > read_at else 0
|
||
items.append({**c, "peer": normalize_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
|
||
if 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": normalize_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": normalize_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": normalize_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 grant_ebook(user_id: str) -> None:
|
||
if not user_id:
|
||
return
|
||
if use_pb():
|
||
existing = safe_first("ebook_entitlements", filter=f"userId={q(user_id)}")
|
||
if existing:
|
||
safe_update("ebook_entitlements", existing["id"], {"active": True})
|
||
else:
|
||
safe_create("ebook_entitlements", {"userId": user_id, "active": True, "product": "nomad-code"})
|
||
return
|
||
_jload()
|
||
_j.setdefault("ebook_entitlements", {})[user_id] = {"active": True, "product": "nomad-code", "updated_at": _now_iso()}
|
||
_jsave()
|
||
|
||
|
||
def has_ebook(user_id: str) -> bool:
|
||
if not user_id:
|
||
return False
|
||
if use_pb():
|
||
row = safe_first("ebook_entitlements", filter=f"userId={q(user_id)} && active=true")
|
||
return bool(row)
|
||
_jload()
|
||
ent = (_j.get("ebook_entitlements") or {}).get(user_id) or {}
|
||
return bool(ent.get("active"))
|
||
|
||
|
||
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:
|
||
_fulfill_order(user_id, pay_type)
|
||
return {"id": order_id, **order, "createdAt": _now_iso()}
|
||
|
||
|
||
def _fulfill_order(user_id: str, pay_type: str) -> None:
|
||
if pay_type == "ebook":
|
||
grant_ebook(user_id)
|
||
else:
|
||
ensure_membership(user_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"})
|
||
_fulfill_order(row.get("userId", ""), row.get("payType") or "join")
|
||
return {**row, "status": "paid"}
|
||
_jload()
|
||
order = (_j.get("orders") or {}).get(order_id)
|
||
if not order:
|
||
return None
|
||
order["status"] = "paid"
|
||
_fulfill_order(order.get("userId", ""), order.get("payType") or "join")
|
||
_jsave()
|
||
return order
|
||
|
||
|
||
def seed_demo_social_activity(user_id: str) -> None:
|
||
"""Give a real user a few incoming likes + one chat so Connect feels alive."""
|
||
if not user_id:
|
||
return
|
||
my_pid = f"user-{user_id}"
|
||
peers = [p for p in CANDIDATE_PROFILES if p.get("userId")][:4]
|
||
if not peers:
|
||
return
|
||
|
||
buddy = peers[0]
|
||
buddy_uid = buddy["userId"]
|
||
already = False
|
||
if use_pb():
|
||
already = bool(safe_first("swipes", filter=f"userId={q(buddy_uid)} && profileId={q(my_pid)}"))
|
||
else:
|
||
_jload()
|
||
already = bool((_j.get("demo_seeded") or {}).get(user_id)) or any(
|
||
s.get("userId") == buddy_uid and s.get("profileId") == my_pid for s in (_j.get("swipes") or [])
|
||
)
|
||
if already:
|
||
return
|
||
|
||
# Incoming likes from candidates toward this user
|
||
for p in peers[:3]:
|
||
peer_uid = p["userId"]
|
||
if use_pb():
|
||
if not safe_first("swipes", filter=f"userId={q(peer_uid)} && profileId={q(my_pid)}"):
|
||
safe_create(
|
||
"swipes",
|
||
{
|
||
"userId": peer_uid,
|
||
"profileId": my_pid,
|
||
"action": "like",
|
||
"intent": "friends",
|
||
"swipeDate": _today_key(),
|
||
},
|
||
)
|
||
else:
|
||
_jload()
|
||
swipes = _j.setdefault("swipes", [])
|
||
if not any(s.get("userId") == peer_uid and s.get("profileId") == my_pid for s in swipes):
|
||
swipes.append(
|
||
{
|
||
"id": secrets.token_hex(6),
|
||
"userId": peer_uid,
|
||
"profileId": my_pid,
|
||
"action": "like",
|
||
"intent": "friends",
|
||
"date": _today_key(),
|
||
"createdAt": _now_iso(),
|
||
}
|
||
)
|
||
|
||
# Mutual match + messages with first peer
|
||
buddy_pid = buddy["id"]
|
||
if use_pb():
|
||
if not safe_first("swipes", filter=f"userId={q(user_id)} && profileId={q(buddy_pid)}"):
|
||
safe_create(
|
||
"swipes",
|
||
{
|
||
"userId": user_id,
|
||
"profileId": buddy_pid,
|
||
"action": "like",
|
||
"intent": "friends",
|
||
"swipeDate": _today_key(),
|
||
},
|
||
)
|
||
else:
|
||
_jload()
|
||
swipes = _j.setdefault("swipes", [])
|
||
if not any(s.get("userId") == user_id and s.get("profileId") == buddy_pid for s in swipes):
|
||
swipes.append(
|
||
{
|
||
"id": secrets.token_hex(6),
|
||
"userId": user_id,
|
||
"profileId": buddy_pid,
|
||
"action": "like",
|
||
"intent": "friends",
|
||
"date": _today_key(),
|
||
"createdAt": _now_iso(),
|
||
}
|
||
)
|
||
|
||
match = _ensure_match(user_id, buddy, "friends")
|
||
conv_id = (match or {}).get("conversationId") or ""
|
||
if not conv_id:
|
||
conv = _ensure_conversation(user_id, buddy_uid, intent="friends", match=True)
|
||
conv_id = conv.get("id", "")
|
||
|
||
hello = f"嗨!我在{buddy.get('location', '旅途中')},看到你也在用 nomadro,交个朋友呀 👋"
|
||
reply = "你好!我也刚到,有机会一起去联合办公或 meetup~"
|
||
|
||
if conv_id:
|
||
if use_pb():
|
||
msgs = safe_list("messages", filter=f"conversationId={q(conv_id)}")
|
||
if not msgs:
|
||
safe_create("messages", {"conversationId": conv_id, "senderId": buddy_uid, "body": hello})
|
||
safe_create("messages", {"conversationId": conv_id, "senderId": user_id, "body": reply})
|
||
safe_update(
|
||
"conversations",
|
||
conv_id,
|
||
{
|
||
"lastMessageAt": _now_iso(),
|
||
"lastMessagePreview": reply[:80],
|
||
"readState": {user_id: _now_iso(), buddy_uid: _now_iso()},
|
||
},
|
||
)
|
||
else:
|
||
_jload()
|
||
msgs = [m for m in (_j.get("messages") or []) if m.get("conversationId") == conv_id]
|
||
if not msgs:
|
||
_j.setdefault("messages", []).extend(
|
||
[
|
||
{
|
||
"id": secrets.token_hex(8),
|
||
"conversationId": conv_id,
|
||
"senderId": buddy_uid,
|
||
"body": hello,
|
||
"createdAt": _now_iso(),
|
||
},
|
||
{
|
||
"id": secrets.token_hex(8),
|
||
"conversationId": conv_id,
|
||
"senderId": user_id,
|
||
"body": reply,
|
||
"createdAt": _now_iso(),
|
||
},
|
||
]
|
||
)
|
||
for c in _j.get("conversations") or []:
|
||
if c.get("id") == conv_id:
|
||
c["lastMessageAt"] = _now_iso()
|
||
c["lastMessagePreview"] = reply[:80]
|
||
break
|
||
|
||
try:
|
||
from app.services import community_store
|
||
from app.services.notify import notify_user
|
||
|
||
notify_user(
|
||
user_id,
|
||
"有人喜欢了你",
|
||
f"{buddy.get('name', '游民')} 等人对你感兴趣,去看看吧",
|
||
"/dating/likes",
|
||
"match",
|
||
)
|
||
if conv_id:
|
||
notify_user(
|
||
user_id,
|
||
"新消息",
|
||
f"{buddy.get('name', '游民')}: {hello[:40]}",
|
||
f"/chat/{conv_id}",
|
||
"chat",
|
||
)
|
||
community_store.add_notification(
|
||
user_id,
|
||
"欢迎来到匹配",
|
||
"左滑跳过、右滑喜欢、上滑超级喜欢。匹配成功可私信。",
|
||
"/dating",
|
||
"system",
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
if not use_pb():
|
||
_jload()
|
||
_j.setdefault("demo_seeded", {})[user_id] = True
|
||
_jsave()
|
||
else:
|
||
pass
|
||
|
||
|
||
# ── 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")
|