427 lines
14 KiB
Python
427 lines
14 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_lounge_channel, 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 {}
|
|
title = meetup.get("title") or "活动"
|
|
mode = meetup.get("mode") or ""
|
|
organizer_id = meetup.get("organizer_id") or ""
|
|
from app.services.notify import notify_user
|
|
|
|
if organizer_id and organizer_id != user_id:
|
|
notify_user(
|
|
organizer_id,
|
|
"新的活动报名",
|
|
f"{user.get('name', '游民')} 报名了「{title}」",
|
|
"/meetups",
|
|
"meetup",
|
|
)
|
|
|
|
# Confirm to attendee with next-step deep link (live room or profile calendar)
|
|
if mode in ("online", "hybrid"):
|
|
notify_user(
|
|
user_id,
|
|
"报名成功",
|
|
f"你已报名「{title}」,活动开始时可进入直播间",
|
|
f"/meetups/{body.meetup_id}/live",
|
|
"meetup",
|
|
)
|
|
else:
|
|
notify_user(
|
|
user_id,
|
|
"报名成功",
|
|
f"你已报名「{title}」,可在个人中心导出日历",
|
|
"/profile",
|
|
"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", "访客")
|
|
|
|
if mode not in ("online", "hybrid"):
|
|
can_join = False
|
|
lock = lock or "offline"
|
|
|
|
session = MeetupSession(
|
|
canJoin=can_join,
|
|
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") or "",
|
|
)
|
|
if session.canJoin:
|
|
session.videoUrl = build_mirotalk_join_url(meetup, display)
|
|
session.chatUrl = build_lounge_chat_url(meetup)
|
|
if not session.loungeChannel:
|
|
session.loungeChannel = create_lounge_channel(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"])}
|