nomadweb/backend/app/routers/api.py
eric f215b684f6 Wire connect-ring notifications and durable meetup RSVP.
Require login for meetup RSVP with cancel, notify organizers/authors/posters on RSVP/reply/gig/match, optional ntfy + Listmonk hooks, and honest newsletter messaging.

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

400 lines
13 KiB
Python

from fastapi import APIRouter, HTTPException, Query, Header
from app.schemas import (
BlogPost, BlogPostDetail, ChartData, CompareRequest, CostCalculatorRequest,
CostCalculatorResponse, Destination, Discussion, DiscussionDetail, FAQ,
Meetup, MeetupRsvpRequest, MeetupSession, NextStopRequest, NextStopResponse, NomadRoute,
DigitalLesson, DigitalJob,
RecommendedDestination, SearchResult, StatsResponse,
SubscribeRequest, SubscribeResponse, Testimonial, Tool, Visa,
)
from app.services.pocketbase import pb
from app.data import mock_data
from app.data import community_data
from app.services.recommendations import recommend_destinations
from app.services.meetup_live import (
build_lounge_chat_url, build_mirotalk_join_url, can_access_meetup, create_mirotalk_room_slug,
)
from app.services.auth import get_user_by_token
from app.services import community_store, social_store
from app.services import city_service
from app.data import digital_content
router = APIRouter()
@router.get("/health")
async def health():
pb_ok = await pb.is_available()
return {"status": "ok", "pocketbase": "connected" if pb_ok else "fallback"}
@router.get("/stats", response_model=StatsResponse)
async def get_stats():
dests = await pb.get_destinations()
avg = sum(d["cost"] for d in dests) // len(dests) if dests else 42
return StatsResponse(
countries=195,
avg_cost=avg // 100,
satisfaction=87,
total_nomads="35.6M",
active_today=127,
)
@router.get("/ticker")
async def get_ticker():
return {"messages": mock_data.TICKER_MESSAGES}
@router.get("/destinations", response_model=list[Destination])
async def list_destinations(
region: str | None = Query(None),
search: str | None = Query(None),
sort: str = Query("rating"),
):
data = await pb.get_destinations(region=region, search=search, sort=sort)
return [Destination(**d) for d in data]
@router.get("/destinations/{slug}", response_model=Destination)
async def get_destination(slug: str):
data = await pb.get_destination(slug)
if not data:
raise HTTPException(404, "目的地不存在")
return Destination(**data)
@router.get("/destinations/{slug}/full")
async def get_destination_full(slug: str):
"""City depth: guide / cost / reviews / related meetups & content."""
data = await city_service.get_destination_full(slug)
if not data:
raise HTTPException(404, "目的地不存在")
return data
@router.get("/cities/{slug}")
async def city_detail_compat(slug: str):
"""NomadCNA-compatible city detail payload."""
data = await city_service.get_destination_full(slug)
if not data:
raise HTTPException(404, "城市不存在")
return data
@router.post("/destinations/compare", response_model=list[Destination])
async def compare_destinations(body: CompareRequest):
all_dests = await pb.get_destinations()
result = [d for d in all_dests if d["slug"] in body.slugs]
if len(result) < 2:
raise HTTPException(400, "至少需要 2 个有效目的地")
return [Destination(**d) for d in result]
@router.get("/visas", response_model=list[Visa])
async def list_visas():
return [Visa(**v) for v in await pb.get_visas()]
@router.get("/faqs", response_model=list[FAQ])
async def list_faqs():
return [FAQ(**f) for f in await pb.get_faqs()]
@router.get("/testimonials", response_model=list[Testimonial])
async def list_testimonials():
return [Testimonial(**t) for t in await pb.get_testimonials()]
@router.get("/tools", response_model=list[Tool])
async def list_tools():
return [Tool(**t) for t in await pb.get_tools()]
@router.get("/blog", response_model=list[BlogPost])
async def list_blog():
return [BlogPost(**b) for b in await pb.get_blog_posts()]
@router.get("/blog/{slug}", response_model=BlogPostDetail)
async def get_blog(slug: str):
post = await pb.get_blog_post(slug)
if not post:
raise HTTPException(404, "文章不存在")
return BlogPostDetail(**post)
@router.post("/subscribe", response_model=SubscribeResponse)
async def subscribe(body: SubscribeRequest):
ok, msg = await pb.subscribe(body.email)
return SubscribeResponse(success=ok, message=msg)
@router.post("/calculator", response_model=CostCalculatorResponse)
async def cost_calculator(body: CostCalculatorRequest):
dest = await pb.get_destination(body.destination_slug)
if not dest:
raise HTTPException(404, "目的地不存在")
multipliers = {"budget": 0.7, "mid": 1.0, "premium": 1.5}
m = multipliers.get(body.housing, 1.0)
base = dest["cost"]
breakdown = {
"🏠 住宿": int(base * 0.35 * m),
"🍜 餐饮": int(base * 0.25 * m),
"🚗 交通": int(base * 0.10),
"📶 网络/办公": int(base * 0.10),
"🎉 娱乐社交": int(base * 0.12 * m),
"🏥 保险医疗": int(base * 0.08),
}
per_month = sum(breakdown.values())
total = per_month * body.months
return CostCalculatorResponse(
destination=f"{dest['name']}, {dest['country']}",
emoji=dest["emoji"],
months=body.months,
breakdown=breakdown,
total=total,
per_month=per_month,
)
@router.get("/charts/cost", response_model=ChartData)
async def chart_cost():
dests = await pb.get_destinations()
dests = sorted(dests, key=lambda x: x["cost"])
return ChartData(
labels=[f"{d['name']} {d['emoji']}" for d in dests],
datasets=[{
"label": "月生活费 (元)",
"data": [d["cost"] for d in dests],
}],
)
@router.get("/charts/speed", response_model=ChartData)
async def chart_speed():
dests = await pb.get_destinations()
dests = sorted(dests, key=lambda x: x["speed"], reverse=True)
return ChartData(
labels=[f"{d['name']} {d['speed']}Mbps" for d in dests],
datasets=[{"data": [d["speed"] for d in dests]}],
)
@router.get("/charts/growth", response_model=ChartData)
async def chart_growth():
return ChartData(
labels=["2019", "2020", "2021", "2022", "2023", "2024", "2025", "2026"],
datasets=[{
"label": "全球数字游民 (百万人)",
"data": [7.3, 10.9, 15.5, 24.0, 28.5, 31.2, 33.8, 35.6],
}],
)
@router.get("/charts/radar", response_model=ChartData)
async def chart_radar():
return ChartData(
labels=["生活成本", "网络速度", "安全性", "社群活跃", "气候环境", "签证便利"],
datasets=[
{"label": "清迈", "data": [95, 80, 85, 95, 70, 90]},
{"label": "里斯本", "data": [60, 90, 92, 80, 85, 95]},
],
)
@router.get("/search", response_model=list[SearchResult])
async def global_search(q: str = Query(..., min_length=1)):
query = q.lower().strip()
results: list[SearchResult] = []
for d in await pb.get_destinations(search=query):
results.append(SearchResult(
type="destination",
title=f"{d['name']}, {d['country']}",
subtitle=f"¥{d['cost']}/月 · ⭐{d['rating']}",
emoji=d["emoji"],
url=f"/destinations/{d['slug']}",
))
for b in await pb.get_blog_posts():
if query in b["title"].lower() or query in b.get("excerpt", "").lower():
results.append(SearchResult(
type="blog", title=b["title"], subtitle=b["excerpt"][:60],
emoji=b["emoji"], url=f"/blog/{b['slug']}",
))
for v in await pb.get_visas():
if query in v["name"].lower() or query in v["country"].lower():
results.append(SearchResult(
type="visa", title=v["name"], subtitle=v["country"],
emoji=v["flag"], url="/#visa",
))
for f in await pb.get_faqs():
if query in f["question"].lower() or query in f["answer"].lower():
results.append(SearchResult(
type="faq", title=f["question"], subtitle=f["answer"][:60],
emoji="❓", url="/#faq",
))
return results[:12]
# ── Community (ported from NomadCNA, adapted for nomadro) ──
@router.get("/meetups", response_model=list[Meetup])
async def list_meetups(upcoming: bool = True):
return [Meetup(**m) for m in community_store.list_meetups(upcoming)]
@router.get("/meetups/{meetup_id}", response_model=Meetup)
async def get_meetup(meetup_id: str):
m = community_store.get_meetup(meetup_id)
if not m:
raise HTTPException(404, "活动不存在")
return Meetup(**m)
@router.post("/meetups/rsvp")
async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "请先登录后再报名,以便保留名额")
user = get_user_by_token(authorization[7:])
if not user:
raise HTTPException(401, "请先登录后再报名")
user_id = user["id"]
res = community_store.rsvp_meetup(body.meetup_id, user_id)
if not res.get("ok"):
if res.get("error") == "not_found":
raise HTTPException(404, "活动不存在")
raise HTTPException(400, "活动已满员")
meetup = community_store.get_meetup(body.meetup_id) or {}
organizer_id = meetup.get("organizer_id") or ""
if organizer_id and organizer_id != user_id:
from app.services.notify import notify_user
notify_user(
organizer_id,
"新的活动报名",
f"{user.get('name', '游民')} 报名了「{meetup.get('title', '活动')}」",
f"/meetups",
"meetup",
)
return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]}
@router.get("/discussions", response_model=list[Discussion])
async def list_discussions(category: str | None = Query(None)):
return [Discussion(**d) for d in community_store.list_discussions(category)]
@router.get("/discussions/{discussion_id}", response_model=DiscussionDetail)
async def get_discussion(discussion_id: str):
d = community_store.get_discussion(discussion_id, increment_view=True)
if not d:
raise HTTPException(404, "讨论不存在")
return DiscussionDetail(**d)
@router.get("/routes", response_model=list[NomadRoute])
async def list_routes():
dests = await pb.get_destinations()
return [NomadRoute(**{k: v for k, v in r.items() if k != "cities"}) for r in city_service.list_routes_enriched(dests)]
@router.get("/routes/full")
async def list_routes_full():
dests = await pb.get_destinations()
return {"ok": True, "items": city_service.list_routes_enriched(dests)}
@router.post("/recommendations/next-stop", response_model=NextStopResponse)
async def next_stop_recommendations(body: NextStopRequest):
dests = await pb.get_destinations()
recommended = recommend_destinations(
dests,
budget=body.budget,
internet=body.internet,
climate=body.climate,
tags=body.tags,
priority=body.priority,
limit=body.limit,
)
routes = city_service.list_routes_enriched(dests)
return NextStopResponse(
items=[RecommendedDestination(**d) for d in recommended],
routes=[NomadRoute(**{k: v for k, v in r.items() if k != "cities"}) for r in routes],
)
@router.get("/meetups/{meetup_id}/session", response_model=MeetupSession)
async def meetup_session(meetup_id: str, authorization: str | None = Header(None)):
meetup = community_store.get_meetup(meetup_id)
if not meetup:
raise HTTPException(404, "活动不存在")
user = None
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
is_vip = social_store.is_vip(user["id"]) if user else False
can_join, lock = can_access_meetup(meetup, user, is_vip)
mode = meetup.get("mode", "offline")
display = (user or {}).get("name", "访客")
session = MeetupSession(
canJoin=can_join and mode in ("online", "hybrid"),
lockReason=lock if not can_join else "",
displayName=display,
mode=mode,
mirotalkRoom=meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup),
loungeChannel=meetup.get("loungeChannel", ""),
)
if session.canJoin:
session.videoUrl = build_mirotalk_join_url(meetup, display)
session.chatUrl = build_lounge_chat_url(meetup)
return session
# ── Digital academy content ──
@router.get("/digital/course")
async def digital_course():
return {"modules": digital_content.COURSE_MODULES, "siteId": digital_content.SITE_ID}
@router.get("/digital/lessons/{module_index}/{lesson_index}", response_model=DigitalLesson)
async def digital_lesson(module_index: int, lesson_index: int, authorization: str | None = Header(None)):
key = f"{module_index}-{lesson_index}"
lesson = digital_content.LESSONS.get(key)
if not lesson:
raise HTTPException(404, "课时不存在")
if not lesson.get("free"):
user = None
if authorization and authorization.startswith("Bearer "):
user = get_user_by_token(authorization[7:])
if not user or not social_store.is_vip(user["id"]):
raise HTTPException(403, "需要 VIP 会员解锁")
return DigitalLesson(**lesson)
@router.get("/digital/jobs", response_model=list[DigitalJob])
async def digital_jobs():
return [DigitalJob(**j) for j in digital_content.JOBS]
@router.get("/digital/vip/check")
async def digital_vip_check(authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
return {"vip": False}
user = get_user_by_token(authorization[7:])
if not user:
return {"vip": False}
return {"vip": social_store.is_vip(user["id"])}