684 lines
22 KiB
Python
684 lines
22 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.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:
|
|
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 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
|
|
|
|
|
|
# ── 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")
|