Keep FastAPI as the only backend; add media upload, ntfy hook, and PB collection bootstrap for production. Co-authored-by: Cursor <cursoragent@cursor.com>
178 lines
5.8 KiB
Python
178 lines
5.8 KiB
Python
import httpx
|
|
from typing import Any
|
|
|
|
from app.config import settings
|
|
from app.data import mock_data
|
|
|
|
|
|
class PocketBaseService:
|
|
def __init__(self):
|
|
self.base_url = settings.pocketbase_url.rstrip("/")
|
|
self._token: str | None = None
|
|
self._available: bool | None = None
|
|
|
|
async def is_available(self) -> bool:
|
|
if self._available is not None:
|
|
return self._available
|
|
try:
|
|
async with httpx.AsyncClient(timeout=1.0) as client:
|
|
r = await client.get(f"{self.base_url}/api/health")
|
|
self._available = r.status_code == 200
|
|
except Exception:
|
|
self._available = False
|
|
return self._available
|
|
|
|
async def _auth(self, client: httpx.AsyncClient) -> bool:
|
|
if self._token:
|
|
return True
|
|
try:
|
|
from app.services.pb_client import pb as sync_pb
|
|
if sync_pb.health_ok():
|
|
self._token = sync_pb.admin_token()
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
async def list_records(self, collection: str, sort: str = "") -> list[dict[str, Any]]:
|
|
if not await self.is_available():
|
|
return []
|
|
|
|
params: dict[str, str] = {"perPage": "200"}
|
|
if sort:
|
|
params["sort"] = sort
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
headers = {}
|
|
if await self._auth(client):
|
|
headers["Authorization"] = self._token or ""
|
|
|
|
r = await client.get(
|
|
f"{self.base_url}/api/collections/{collection}/records",
|
|
params=params,
|
|
headers=headers,
|
|
)
|
|
if r.status_code == 200:
|
|
items = r.json().get("items", [])
|
|
return items if items else []
|
|
except Exception:
|
|
pass
|
|
return []
|
|
|
|
async def create_record(self, collection: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
|
if not await self.is_available():
|
|
return None
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
headers = {}
|
|
if await self._auth(client):
|
|
headers["Authorization"] = self._token or ""
|
|
|
|
r = await client.post(
|
|
f"{self.base_url}/api/collections/{collection}/records",
|
|
json=data,
|
|
headers=headers,
|
|
)
|
|
if r.status_code in (200, 201):
|
|
return r.json()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
async def get_destinations(
|
|
self, region: str | None = None, search: str | None = None, sort: str = "rating"
|
|
) -> list[dict]:
|
|
records = await self.list_records("destinations", sort=f"-{sort}" if sort != "cost" else "cost")
|
|
if not records:
|
|
records = mock_data.DESTINATIONS
|
|
else:
|
|
records = [self._map_destination(r) for r in records]
|
|
|
|
if region and region != "all":
|
|
records = [d for d in records if d.get("region") == region]
|
|
if search:
|
|
q = search.lower()
|
|
records = [
|
|
d for d in records
|
|
if q in d.get("name", "").lower() or q in d.get("country", "").lower()
|
|
]
|
|
return self._sort_destinations(records, sort)
|
|
|
|
async def get_destination(self, slug: str) -> dict | None:
|
|
records = await self.get_destinations()
|
|
return next((d for d in records if d.get("slug") == slug), None)
|
|
|
|
async def get_visas(self) -> list[dict]:
|
|
records = await self.list_records("visas", sort="difficulty")
|
|
return records if records else mock_data.VISAS
|
|
|
|
async def get_faqs(self) -> list[dict]:
|
|
records = await self.list_records("faqs", sort="order")
|
|
return records if records else mock_data.FAQS
|
|
|
|
async def get_testimonials(self) -> list[dict]:
|
|
records = await self.list_records("testimonials")
|
|
return records if records else mock_data.TESTIMONIALS
|
|
|
|
async def get_tools(self) -> list[dict]:
|
|
records = await self.list_records("tools")
|
|
return records if records else mock_data.TOOLS
|
|
|
|
async def get_blog_posts(self) -> list[dict]:
|
|
records = await self.list_records("blog_posts", sort="-published_at")
|
|
return records if records else mock_data.BLOG_POSTS
|
|
|
|
async def get_blog_post(self, slug: str) -> dict | None:
|
|
posts = await self.get_blog_posts()
|
|
post = next((p for p in posts if p.get("slug") == slug), None)
|
|
if not post:
|
|
return None
|
|
content = post.get("content") or mock_data.BLOG_CONTENT.get(slug, "")
|
|
return {**post, "content": content}
|
|
|
|
async def subscribe(self, email: str) -> tuple[bool, str]:
|
|
existing = await self.list_records("subscriptions")
|
|
if any(s.get("email") == email for s in existing):
|
|
return True, "你已经订阅过了,欢迎回来!"
|
|
|
|
result = await self.create_record("subscriptions", {"email": email})
|
|
if result:
|
|
return True, "订阅成功!欢迎加入 nomadro 社区 🎉"
|
|
# fallback: always succeed in demo mode
|
|
return True, "订阅成功!欢迎加入 nomadro 社区 🎉"
|
|
|
|
def _map_destination(self, r: dict) -> dict:
|
|
return {
|
|
"id": r.get("id", ""),
|
|
"slug": r.get("slug", ""),
|
|
"name": r.get("name", ""),
|
|
"country": r.get("country", ""),
|
|
"emoji": r.get("emoji", ""),
|
|
"tag": r.get("tag", ""),
|
|
"description": r.get("description", ""),
|
|
"region": r.get("region", ""),
|
|
"cost": r.get("cost", 0),
|
|
"speed": r.get("speed", 0),
|
|
"temperature": r.get("temperature", 0),
|
|
"rating": r.get("rating", 0),
|
|
"hue": r.get("hue", 170),
|
|
"nomads_count": r.get("nomads_count", ""),
|
|
"highlights": r.get("highlights", []),
|
|
"map_x": r.get("map_x", 0),
|
|
"map_y": r.get("map_y", 0),
|
|
}
|
|
|
|
def _sort_destinations(self, records: list[dict], sort: str) -> list[dict]:
|
|
if sort == "cost-asc":
|
|
return sorted(records, key=lambda x: x.get("cost", 0))
|
|
if sort == "cost-desc":
|
|
return sorted(records, key=lambda x: x.get("cost", 0), reverse=True)
|
|
if sort == "speed":
|
|
return sorted(records, key=lambda x: x.get("speed", 0), reverse=True)
|
|
return sorted(records, key=lambda x: x.get("rating", 0), reverse=True)
|
|
|
|
|
|
pb = PocketBaseService()
|