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"])}