from __future__ import annotations import secrets import uuid from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.responses import Response as FastAPIResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from .pb_client import PocketBaseError, pb, pb_quote from .settings import get_settings settings = get_settings() settings.upload_dir.mkdir(parents=True, exist_ok=True) app = FastAPI(title="NomadCNA Local API", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=settings.frontend_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads") class AuthPayload(BaseModel): email: str password: str passwordConfirm: str | None = None class SyncSessionPayload(BaseModel): token: str record: dict[str, Any] class EnsureUserPayload(BaseModel): email: str password: str | None = None class CheckUserPayload(BaseModel): email: str class CompleteOrderPayload(BaseModel): order_id: str class JoinPayload(BaseModel): nickname: str = "" gender: str = "" single: str = "" profession: str = "" intro: str = "" wechat: str = "" education: str = "" graduationYear: str = "" phone: str = "" media: str | None = None class ChangePasswordPayload(BaseModel): newPassword: str passwordConfirm: str class FeedbackPayload(BaseModel): type: str email: str = "" title: str content: str class SwipePayload(BaseModel): profileId: str action: str class DiscussionPayload(BaseModel): title: str author: str = "NomadCNA" city: str = "" tags: list[str] = Field(default_factory=list) excerpt: str = "" class GigPayload(BaseModel): title: str category: str = "任务" budget: int = 300 location: str = "远程" description: str = "" class ServiceLeadPayload(BaseModel): serviceSlug: str email: str = "" note: str = "" class NotificationCreatePayload(BaseModel): title: str body: str = "" category: str = "system" priority: str = "normal" targetUserId: str = "" audience: str = "all" actionLabel: str = "" actionUrl: str = "" entityType: str = "" entityId: str = "" icon: str = "🔔" channels: list[str] = Field(default_factory=lambda: ["in_app"]) metadata: dict[str, Any] = Field(default_factory=dict) class NotificationPreferencePayload(BaseModel): channels: dict[str, bool] = Field(default_factory=dict) categories: dict[str, bool] = Field(default_factory=dict) quietHoursStart: str = "22:00" quietHoursEnd: str = "08:00" digest: str = "daily" allowMarketing: bool = False class RecommendationPayload(BaseModel): budget: int = 6000 internet: int = 50 climate: str = "any" tags: list[str] = Field(default_factory=list) limit: int = 12 def set_session_cookie(response: Response, token: str) -> None: response.set_cookie( settings.cookie_name, token, httponly=True, samesite="lax", secure=False, max_age=60 * 60 * 24 * 30, path="/", ) def clear_session_cookie(response: Response) -> None: response.delete_cookie(settings.cookie_name, path="/") def token_from_request(request: Request) -> str | None: auth = request.headers.get("authorization", "") if auth.lower().startswith("bearer "): return auth.split(" ", 1)[1].strip() return request.cookies.get(settings.cookie_name) async def current_user(request: Request) -> tuple[str, dict[str, Any]] | tuple[None, None]: token = token_from_request(request) if not token: return None, None try: data = await pb.request("POST", "/api/collections/users/auth-refresh", token=token) return data.get("token") or token, data.get("record") except Exception: return None, None async def find_user_by_email(email: str) -> dict[str, Any] | None: return await pb.first_record("users", filter=f"email={pb_quote(email.lower())}") async def find_membership(user_id: str) -> dict[str, Any] | None: return await pb.first_record( "memberships", filter=f'userId={pb_quote(user_id)} && status="active"', ) async def ensure_user(email: str, password: str | None = None) -> tuple[dict[str, Any], str, bool]: normalized = email.lower().strip() if "@" not in normalized or "." not in normalized.split("@")[-1]: raise HTTPException(status_code=400, detail="请输入有效邮箱") existing = await find_user_by_email(normalized) is_new = False if existing: login_password = password or "12345678" try: auth = await pb.request( "POST", "/api/collections/users/auth-with-password", json={"identity": normalized, "password": login_password}, ) except Exception: if not password: raise HTTPException(status_code=401, detail="请输入该邮箱的登录密码") raise HTTPException(status_code=401, detail="邮箱或密码不正确") return auth["record"], auth["token"], is_new user_password = password or "12345678" created = await pb.create_record( "users", { "email": normalized, "emailVisibility": True, "verified": True, "password": user_password, "passwordConfirm": user_password, "name": normalized.split("@")[0], }, ) is_new = True await pb.create_record( "profiles", { "userId": created["id"], "email": normalized, "name": normalized.split("@")[0], "age": 0, "location": "", "tags": ["新成员"], "bio": "", "photo": f"https://api.dicebear.com/7.x/initials/svg?seed={normalized}", "needsPasswordChange": password is None, }, ) auth = await pb.request( "POST", "/api/collections/users/auth-with-password", json={"identity": normalized, "password": user_password}, ) return auth["record"], auth["token"], is_new def public_record(record: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in record.items() if not k.startswith("@")} def utc_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z") def default_notification_preferences(user_id: str, email: str = "") -> dict[str, Any]: return { "userId": user_id, "email": email, "channels": {"in_app": True, "email": False, "wechat": False}, "categories": { "system": True, "meetup": True, "gig": True, "match": True, "service": True, "community": True, "city": True, }, "quietHoursStart": "22:00", "quietHoursEnd": "08:00", "digest": "daily", "allowMarketing": False, } def normalize_notification_preferences(record: dict[str, Any] | None, user_id: str, email: str = "") -> dict[str, Any]: defaults = default_notification_preferences(user_id, email) if not record: return defaults public = public_record(record) return { **defaults, **public, "channels": {**defaults["channels"], **(public.get("channels") or {})}, "categories": {**defaults["categories"], **(public.get("categories") or {})}, } async def notification_preferences_for_user(user_id: str, user: dict[str, Any] | None = None) -> dict[str, Any]: record = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}') return normalize_notification_preferences(record, user_id, user.get("email", "") if user else "") def notification_is_muted(item: dict[str, Any], preferences: dict[str, Any]) -> bool: category = item.get("category") or "system" categories = preferences.get("categories") or {} channels = preferences.get("channels") or {} item_channels = item.get("channels") or ["in_app"] category_enabled = bool(categories.get(category, True)) channel_enabled = any(bool(channels.get(channel, False)) for channel in item_channels) return not category_enabled or not channel_enabled async def create_notification( *, title: str, body: str = "", category: str = "system", priority: str = "normal", target_user_id: str = "", audience: str = "all", action_label: str = "", action_url: str = "", entity_type: str = "", entity_id: str = "", icon: str = "🔔", channels: list[str] | None = None, metadata: dict[str, Any] | None = None, ) -> dict[str, Any] | None: try: return await pb.create_record( "notifications", { "title": title, "body": body, "category": category, "priority": priority, "targetUserId": target_user_id, "audience": audience, "actionLabel": action_label, "actionUrl": action_url, "entityType": entity_type, "entityId": entity_id, "icon": icon, "status": "published", "channels": channels or ["in_app"], "metadata": metadata or {}, }, ) except PocketBaseError: return None async def receipt_for(notification_id: str, user_id: str) -> dict[str, Any] | None: if not user_id: return None return await pb.first_record( "notification_receipts", filter=f'notificationId={pb_quote(notification_id)} && userId={pb_quote(user_id)}', ) async def upsert_receipt(notification_id: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any]: existing = await receipt_for(notification_id, user_id) base = {"notificationId": notification_id, "userId": user_id, "pinned": False} if existing: return await pb.update_record("notification_receipts", existing["id"], payload) return await pb.create_record("notification_receipts", {**base, **payload}) async def decorated_notifications_for_user( user_id: str, preferences: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: preferences = preferences or await notification_preferences_for_user(user_id) data = await pb.list_records("notifications", filter='status="published"', per_page=100) notifications = [ public_record(item) for item in data.get("items", []) if item.get("audience", "all") == "all" or item.get("targetUserId") == user_id ] receipts = (await pb.list_records("notification_receipts", filter=f'userId={pb_quote(user_id)}', per_page=200)).get("items", []) receipt_by_notification = {receipt.get("notificationId"): public_record(receipt) for receipt in receipts} decorated = [] for item in notifications: receipt = receipt_by_notification.get(item["id"], {}) archived = bool(receipt.get("archivedAt")) decorated.append( { **item, "receipt": receipt, "read": bool(receipt.get("readAt")), "archived": archived, "pinned": bool(receipt.get("pinned")), "muted": notification_is_muted(item, preferences), } ) decorated.sort(key=lambda item: (item.get("pinned", False), item.get("created", "")), reverse=True) return decorated @app.get("/api/health") async def health() -> dict[str, Any]: return {"ok": True, "service": "NomadCNA FastAPI", "pb": settings.pb_url} @app.post("/api/auth/register") async def register(payload: AuthPayload, response: Response) -> dict[str, Any]: if len(payload.password) < 8: raise HTTPException(status_code=400, detail="密码至少 8 位") if payload.passwordConfirm and payload.password != payload.passwordConfirm: raise HTTPException(status_code=400, detail="两次密码不一致") try: user, token, _ = await ensure_user(payload.email, payload.password) except PocketBaseError as exc: raise HTTPException(status_code=400, detail=str(exc)) set_session_cookie(response, token) return {"ok": True, "token": token, "record": user, "user": user} @app.post("/api/auth/login") async def login(payload: AuthPayload, response: Response) -> dict[str, Any]: try: auth = await pb.request( "POST", "/api/collections/users/auth-with-password", json={"identity": payload.email.lower(), "password": payload.password}, ) except Exception: raise HTTPException(status_code=401, detail="邮箱或密码不正确") set_session_cookie(response, auth["token"]) return {"ok": True, "token": auth["token"], "record": auth["record"], "user": auth["record"]} @app.post("/api/auth/sync-session") async def sync_session(payload: SyncSessionPayload, response: Response) -> dict[str, Any]: set_session_cookie(response, payload.token) return {"ok": True} @app.get("/api/auth/me") async def me(request: Request, response: Response) -> dict[str, Any]: token, user = await current_user(request) if not user: return {"ok": True, "user": None} set_session_cookie(response, token) vip = bool(await find_membership(user["id"])) return {"ok": True, "token": token, "user": {**user, "vip": vip}} @app.post("/api/auth/logout") async def logout(response: Response) -> dict[str, Any]: clear_session_cookie(response) return {"ok": True} @app.post("/api/auth/change-password") async def change_password(payload: ChangePasswordPayload, request: Request) -> dict[str, Any]: token, user = await current_user(request) if not user: raise HTTPException(status_code=401, detail="未登录") if len(payload.newPassword) < 8: raise HTTPException(status_code=400, detail="密码至少 8 位") if payload.newPassword != payload.passwordConfirm: raise HTTPException(status_code=400, detail="两次密码不一致") await pb.update_record( "users", user["id"], {"password": payload.newPassword, "passwordConfirm": payload.passwordConfirm}, ) profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}') if profile: await pb.update_record("profiles", profile["id"], {"needsPasswordChange": False}) return {"ok": True} @app.post("/api/meetup/check-user") async def check_user(payload: CheckUserPayload) -> dict[str, Any]: user = await find_user_by_email(str(payload.email)) vip = bool(await find_membership(user["id"])) if user else False return {"ok": True, "exists": bool(user), "vip": vip, "user_id": user["id"] if user else None} @app.post("/api/meetup/ensure-user") async def ensure_user_endpoint(payload: EnsureUserPayload, response: Response) -> dict[str, Any]: user, token, is_new = await ensure_user(str(payload.email), payload.password) set_session_cookie(response, token) vip = bool(await find_membership(user["id"])) return {"ok": True, "user_id": user["id"], "record": user, "token": token, "is_new": is_new, "vip": vip} @app.post("/api/meetup/set-needs-password-change") async def set_needs_password_change(request: Request) -> dict[str, Any]: _, user = await current_user(request) if not user: raise HTTPException(status_code=401, detail="未登录") profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}') if profile: await pb.update_record("profiles", profile["id"], {"needsPasswordChange": True}) return {"ok": True} @app.get("/api/meetup/need-password-change") async def need_password_change(request: Request) -> dict[str, Any]: _, user = await current_user(request) if not user: return {"ok": True, "need": False} profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}') return {"ok": True, "need": bool(profile and profile.get("needsPasswordChange"))} @app.post("/api/meetup/complete-order") async def complete_order(payload: CompleteOrderPayload, response: Response) -> dict[str, Any]: order = await pb.first_record("orders", filter=f'orderId={pb_quote(payload.order_id)}') if not order: raise HTTPException(status_code=404, detail="订单不存在") if order.get("status") != "paid": await pb.update_record("orders", order["id"], {"status": "paid"}) user_id = order.get("userId", "") user = None if user_id and not user_id.startswith("pending_"): user = await pb.first_record("users", filter=f'id={pb_quote(user_id)}') if not user: email = order.get("email") or f"{payload.order_id}@nomadcna.local" user, token, is_new = await ensure_user(email) else: auth = await pb.request( "POST", "/api/collections/users/auth-with-password", json={"identity": user["email"], "password": "12345678"}, ) token = auth["token"] is_new = False expires = (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S.000Z") membership = await pb.first_record("memberships", filter=f'userId={pb_quote(user["id"])}') payload_membership = { "userId": user["id"], "email": user.get("email", ""), "plan": "pro", "status": "active", "expiresAt": expires, } if membership: await pb.update_record("memberships", membership["id"], payload_membership) else: await pb.create_record("memberships", payload_membership) set_session_cookie(response, token) return {"ok": True, "token": token, "record": user, "is_new": is_new} @app.get("/api/vip/check") async def vip_check(request: Request) -> dict[str, Any]: _, user = await current_user(request) if not user: return {"ok": True, "vip": False} membership = await find_membership(user["id"]) return {"ok": True, "vip": bool(membership), "membership": membership} @app.post("/api/join") async def join(payload: JoinPayload, request: Request) -> dict[str, Any]: _, user = await current_user(request) if not user: raise HTTPException(status_code=401, detail="请先登录") email = user.get("email", "") record_payload = { **payload.model_dump(), "userId": user["id"], "email": email, "isComplete": True, } existing = await pb.first_record("member_applications", filter=f'userId={pb_quote(user["id"])}') if existing: record = await pb.update_record("member_applications", existing["id"], record_payload) else: record = await pb.create_record("member_applications", record_payload) profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}') profile_payload = { "userId": user["id"], "email": email, "name": payload.nickname or user.get("name") or email.split("@")[0], "location": "", "tags": [payload.profession, payload.education, "数字游民"], "bio": payload.intro, "photo": payload.media or f"https://api.dicebear.com/7.x/initials/svg?seed={email}", } if profile: await pb.update_record("profiles", profile["id"], profile_payload) else: await pb.create_record("profiles", profile_payload) await create_notification( title="成员资料已更新", body="你的加入资料已经保存,后续匹配、活动推荐和城市建议会基于这份资料联动。", category="system", priority="normal", target_user_id=user["id"], audience="user", action_label="查看资料", action_url="/join", entity_type="member_application", entity_id=record["id"], icon="👤", ) return {"ok": True, "record": record, "user_id": user["id"]} @app.get("/api/join/status") async def join_status(request: Request) -> dict[str, Any]: _, user = await current_user(request) if not user: return {"ok": True, "hasRecord": False, "isComplete": False} record = await pb.first_record("member_applications", filter=f'userId={pb_quote(user["id"])}') return {"ok": True, "hasRecord": bool(record), "isComplete": bool(record and record.get("isComplete"))} @app.post("/api/upload-media") async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[str, Any]: if file.size and file.size > 20 * 1024 * 1024: raise HTTPException(status_code=400, detail="文件大小不能超过 20MB") suffix = Path(file.filename or "upload.bin").suffix name = f"{uuid.uuid4().hex}{suffix}" target = settings.upload_dir / name content = await file.read() target.write_bytes(content) url = f"http://127.0.0.1:8000/uploads/{name}" user_id = "" _, user = await current_user(request) user_id = user["id"] if user else "" await pb.create_record( "media_assets", { "userId": user_id, "url": url, "filename": file.filename or name, "contentType": file.content_type or "application/octet-stream", "size": len(content), }, ) return {"ok": True, "url": url} @app.post("/api/pay", response_model=None) async def create_pay( response: Response, user_id: str = Form(...), return_url: str = Form(...), total_fee: int = Form(100), type: str = Form("meetup"), channel: str = Form("wxpay"), device: str = Form("pc"), html: str = Form("1"), ) -> HTMLResponse | dict[str, Any]: order_id = f"local_{uuid.uuid4().hex[:16]}" email = "" if user_id.startswith("pending_"): try: raw_hex = user_id.removeprefix("pending_") email = bytes.fromhex(raw_hex).decode("utf-8") except Exception: email = "" await pb.create_record( "orders", { "orderId": order_id, "userId": user_id, "email": email, "totalFee": total_fee, "type": type, "channel": channel, "status": "paid" if settings.dev_auto_pay else "pending", "returnUrl": return_url, "isNew": user_id.startswith("pending_"), }, ) cookie_value = f"{order_id}|{int(datetime.now().timestamp() * 1000)}" response.set_cookie("join_pay_order", cookie_value, path="/", max_age=900) if html == "1": redirect_url = f"{return_url}{'&' if '?' in return_url else '?'}order_id={order_id}" html_response = HTMLResponse( f"""本地支付

NomadCNA 本地开发支付

订单 {order_id} 已自动标记为支付成功。

3 秒后返回站点完成会员开通。

立即返回""" ) html_response.set_cookie("join_pay_order", cookie_value, path="/", max_age=900) return html_response return {"ok": True, "order_id": order_id, "paid": settings.dev_auto_pay} @app.get("/api/pay/status") async def pay_status(order_id: str) -> dict[str, Any]: order = await pb.first_record("orders", filter=f'orderId={pb_quote(order_id)}') return {"ok": True, "paid": bool(order and order.get("status") == "paid"), "order": order} @app.get("/api/cities") async def list_cities() -> dict[str, Any]: data = await pb.list_records("cities", per_page=100, sort="idNumber") return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]} @app.get("/api/cities/{slug}") async def city_detail(slug: str) -> dict[str, Any]: city = await pb.first_record("cities", filter=f'slug={pb_quote(slug)}') if not city: raise HTTPException(status_code=404, detail="城市不存在") detail = await pb.first_record("city_details", filter=f'citySlug={pb_quote(slug)}') content = await pb.list_records( "content_items", filter=f'citySlugs ?~ {pb_quote(slug)}', per_page=20, sort="sortOrder", ) meetups = await pb.list_records("meetups", filter=f'city={pb_quote(city["name"])}', per_page=20) discussions = await pb.list_records("discussions", filter=f'city={pb_quote(city["name"])}', per_page=20) return { "ok": True, "city": public_record(city), "detail": public_record(detail) if detail else None, "content": [public_record(x) for x in content.get("items", [])], "meetups": [public_record(x) for x in meetups.get("items", [])], "discussions": [public_record(x) for x in discussions.get("items", [])], } @app.get("/api/content/home") async def home_content() -> dict[str, Any]: data = await pb.list_records("content_items", filter='status="published"', sort="sortOrder", per_page=20) return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]} @app.get("/api/content/{slug}") async def content_detail(slug: str) -> dict[str, Any]: item = await pb.first_record("content_items", filter=f'slug={pb_quote(slug)}') if not item: raise HTTPException(status_code=404, detail="内容不存在") cities: list[dict[str, Any]] = [] for city_slug in item.get("citySlugs") or []: city = await pb.first_record("cities", filter=f'slug={pb_quote(city_slug)}') if city: cities.append(public_record(city)) return {"ok": True, "item": public_record(item), "cities": cities} @app.get("/api/notifications") async def list_notifications( request: Request, category: str = "all", status: str = "active", q: str = "", muted: str = "include", ) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" preferences = await notification_preferences_for_user(user_id, user) all_items = await decorated_notifications_for_user(user_id, preferences) items = all_items if muted == "exclude": items = [item for item in items if not item.get("muted")] elif muted == "only": items = [item for item in items if item.get("muted")] if category != "all": items = [item for item in items if item.get("category") == category] if status == "unread": items = [item for item in items if not item.get("read") and not item.get("archived") and not item.get("muted")] elif status == "read": items = [item for item in items if item.get("read") and not item.get("archived")] elif status == "archived": items = [item for item in items if item.get("archived")] else: items = [item for item in items if not item.get("archived")] if q.strip(): needle = q.strip().lower() items = [ item for item in items if needle in str(item.get("title", "")).lower() or needle in str(item.get("body", "")).lower() ] unread = sum(1 for item in all_items if not item.get("read") and not item.get("archived") and not item.get("muted")) categories: dict[str, int] = {} muted_count = 0 for item in all_items: if not item.get("archived"): categories[item.get("category", "system")] = categories.get(item.get("category", "system"), 0) + 1 if item.get("muted"): muted_count += 1 return { "ok": True, "items": items, "unread": unread, "categories": categories, "muted": muted_count, "preferences": preferences, } @app.get("/api/notifications/unread-count") async def notification_unread_count(request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" preferences = await notification_preferences_for_user(user_id, user) items = await decorated_notifications_for_user(user_id, preferences) return {"ok": True, "count": sum(1 for item in items if not item.get("read") and not item.get("archived") and not item.get("muted"))} @app.post("/api/notifications") async def create_notification_endpoint(payload: NotificationCreatePayload, request: Request) -> dict[str, Any]: _, user = await current_user(request) target_user_id = payload.targetUserId or (user["id"] if user and payload.audience == "user" else "") record = await create_notification( title=payload.title, body=payload.body, category=payload.category, priority=payload.priority, target_user_id=target_user_id, audience=payload.audience, action_label=payload.actionLabel, action_url=payload.actionUrl, entity_type=payload.entityType, entity_id=payload.entityId, icon=payload.icon, channels=payload.channels, metadata=payload.metadata, ) if not record: raise HTTPException(status_code=500, detail="通知创建失败") return {"ok": True, "record": public_record(record)} @app.post("/api/notifications/{notification_id}/read") async def mark_notification_read(notification_id: str, request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" receipt = await upsert_receipt(notification_id, user_id, {"readAt": utc_now()}) return {"ok": True, "receipt": public_record(receipt)} @app.post("/api/notifications/{notification_id}/unread") async def mark_notification_unread(notification_id: str, request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" receipt = await upsert_receipt(notification_id, user_id, {"readAt": None, "archivedAt": None}) return {"ok": True, "receipt": public_record(receipt)} @app.post("/api/notifications/{notification_id}/archive") async def archive_notification(notification_id: str, request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": utc_now(), "readAt": utc_now()}) return {"ok": True, "receipt": public_record(receipt)} @app.post("/api/notifications/{notification_id}/restore") async def restore_notification(notification_id: str, request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": None, "dismissedAt": None}) return {"ok": True, "receipt": public_record(receipt)} @app.post("/api/notifications/{notification_id}/pin") async def pin_notification(notification_id: str, request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" existing = await receipt_for(notification_id, user_id) pinned = not bool(existing and existing.get("pinned")) receipt = await upsert_receipt(notification_id, user_id, {"pinned": pinned}) return {"ok": True, "receipt": public_record(receipt), "pinned": pinned} @app.post("/api/notifications/mark-all-read") async def mark_all_notifications_read(request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" preferences = await notification_preferences_for_user(user_id, user) items = await decorated_notifications_for_user(user_id, preferences) count = 0 for item in items: if not item.get("read") and not item.get("archived") and not item.get("muted"): await upsert_receipt(item["id"], user_id, {"readAt": utc_now()}) count += 1 return {"ok": True, "count": count} @app.get("/api/notifications/preferences") async def notification_preferences(request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" return {"ok": True, "record": await notification_preferences_for_user(user_id, user)} @app.put("/api/notifications/preferences") async def update_notification_preferences(payload: NotificationPreferencePayload, request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" record_payload = { "userId": user_id, "email": user.get("email", "") if user else "", **payload.model_dump(), } existing = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}') if existing: record = await pb.update_record("notification_preferences", existing["id"], record_payload) else: record = await pb.create_record("notification_preferences", record_payload) return {"ok": True, "record": public_record(record)} def city_recommendation_score(city: dict[str, Any], payload: RecommendationPayload) -> tuple[float, list[str]]: score = float(city.get("overallScore", 0)) * 20 reasons: list[str] = [] cost = int(city.get("costPerMonth") or 999999) speed = int(city.get("internetSpeed") or 0) temp = int(city.get("temperature") or 0) tags = city.get("tags") or [] if cost <= payload.budget: score += 22 reasons.append("预算内") else: over_ratio = min(1.0, (cost - payload.budget) / max(payload.budget, 1)) score -= 18 * over_ratio if speed >= payload.internet: score += 18 reasons.append("网络达标") else: score -= min(18, payload.internet - speed) if payload.climate == "warm" and temp >= 20: score += 8 reasons.append("偏温暖") elif payload.climate == "cool" and temp <= 20: score += 8 reasons.append("偏凉爽") elif payload.climate == "mild" and 14 <= temp <= 24: score += 10 reasons.append("气候温和") matched_tags = [tag for tag in payload.tags if tag in tags] if matched_tags: score += len(matched_tags) * 5 reasons.append("标签匹配:" + "、".join(matched_tags[:3])) if city.get("nomadsNow", 0) >= 300: score += 6 reasons.append("社区活跃") return round(score, 1), reasons[:4] @app.post("/api/recommendations/next-stop") async def next_stop_recommendation(payload: RecommendationPayload, request: Request) -> dict[str, Any]: cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", []) scored = [] for city in cities: score, reasons = city_recommendation_score(city, payload) scored.append({**public_record(city), "matchScore": score, "matchReasons": reasons}) scored.sort(key=lambda item: item["matchScore"], reverse=True) picks = scored[: max(1, min(payload.limit, 30))] _, user = await current_user(request) try: await pb.create_record( "recommendation_logs", { "userId": user["id"] if user else "anonymous", "budget": payload.budget, "internet": payload.internet, "climate": payload.climate, "tags": payload.tags, "resultSlugs": [city.get("slug") for city in picks], }, ) except PocketBaseError: pass return {"ok": True, "items": picks} @app.get("/api/routes") async def list_routes() -> dict[str, Any]: routes = (await pb.list_records("routes", per_page=100)).get("items", []) cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", []) city_by_slug = {city.get("slug"): public_record(city) for city in cities} items = [] for route in routes: record = public_record(route) record["cities"] = [city_by_slug[slug] for slug in route.get("citySlugs", []) if slug in city_by_slug] items.append(record) return {"ok": True, "items": items} @app.get("/api/services") async def list_services() -> dict[str, Any]: data = await pb.list_records("services", filter='status="published"', per_page=100) return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]} @app.post("/api/services/leads") async def create_service_lead(payload: ServiceLeadPayload, request: Request) -> dict[str, Any]: if not payload.serviceSlug.strip(): raise HTTPException(status_code=400, detail="服务不能为空") service = await pb.first_record("services", filter=f'slug={pb_quote(payload.serviceSlug)}') if not service: raise HTTPException(status_code=404, detail="服务不存在") _, user = await current_user(request) record = await pb.create_record( "service_leads", { "serviceSlug": payload.serviceSlug, "userId": user["id"] if user else "", "email": payload.email or (user.get("email", "") if user else ""), "note": payload.note, "status": "new", }, ) await create_notification( title=f"服务咨询已提交:{service.get('title', payload.serviceSlug)}", body="我们已经记录你的服务咨询,后续可以按城市、预算、路线和会员状态继续跟进。", category="service", priority="normal", target_user_id=user["id"] if user else "", audience="user" if user else "all", action_label="查看服务", action_url="/services", entity_type="service_lead", entity_id=record["id"], icon=service.get("icon") or "🧭", ) return {"ok": True, "record": public_record(record), "service": public_record(service)} @app.get("/api/downloads/{filename}") async def download_asset(filename: str) -> FastAPIResponse: if filename == "nomad-china-playbook.pdf": body = ( "%PDF-1.4\n" "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n" "2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n" "3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R >> endobj\n" "4 0 obj << /Length 68 >> stream\n" "BT /F1 18 Tf 24 88 Td (NomadCNA China Digital Nomad Playbook) Tj ET\n" "endstream endobj\n" "xref\n0 5\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000204 00000 n \n" "trailer << /Root 1 0 R /Size 5 >>\nstartxref\n322\n%%EOF\n" ).encode("latin-1") return FastAPIResponse( body, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) if filename == "nomadcna-app-demo.zip": body = b"NomadCNA App demo package. Replace with real installer artifact later.\n" return FastAPIResponse( body, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) raise HTTPException(status_code=404, detail="下载文件不存在") @app.get("/api/meetups") async def list_meetups() -> dict[str, Any]: data = await pb.list_records("meetups", sort="date", per_page=100) return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]} @app.post("/api/meetups") async def create_meetup(payload: dict[str, Any]) -> dict[str, Any]: if payload.get("date") and "T" not in str(payload["date"]): payload["date"] = f"{str(payload['date'])[:10]} 00:00:00.000Z" payload.setdefault("status", "pending") payload.setdefault("rsvpCount", 0) payload.setdefault("attendees", []) record = await pb.create_record("meetups", payload) await create_notification( title=f"新活动已提交:{payload.get('city', '同城')} · {payload.get('venue', '活动地点')}", body=str(payload.get("description") or "新的同城活动已经进入活动列表,审核后可对外展示。"), category="meetup", priority="normal", audience="all", action_label="查看活动", action_url="/meetups", entity_type="meetup", entity_id=record["id"], icon=payload.get("emoji") or "🍹", ) return {"ok": True, "record": record} @app.get("/api/discussions") async def list_discussions() -> dict[str, Any]: data = await pb.list_records("discussions", per_page=100) return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]} @app.post("/api/discussions") async def create_discussion(payload: DiscussionPayload) -> dict[str, Any]: if not payload.title.strip(): raise HTTPException(status_code=400, detail="标题不能为空") record = await pb.create_record( "discussions", { **payload.model_dump(), "replies": 0, "views": 0, }, ) await create_notification( title=f"新讨论:{payload.title}", body=payload.excerpt or f"{payload.author} 在 {payload.city or '社区'} 发起了一个新话题。", category="community", priority="normal", audience="all", action_label="参与讨论", action_url="/discuss", entity_type="discussion", entity_id=record["id"], icon="🔥", metadata={"city": payload.city, "tags": payload.tags}, ) return {"ok": True, "record": public_record(record)} @app.get("/api/gigs") async def list_gigs() -> dict[str, Any]: data = await pb.list_records("gigs", per_page=100) return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]} @app.post("/api/gigs") async def create_gig(payload: GigPayload, request: Request) -> dict[str, Any]: if not payload.title.strip(): raise HTTPException(status_code=400, detail="任务标题不能为空") _, user = await current_user(request) record = await pb.create_record( "gigs", { **payload.model_dump(), "status": "open", }, ) await create_notification( title=f"赏金墙新任务:{payload.title}", body=f"{payload.location} · {payload.category} · ¥{payload.budget}。{payload.description}", category="gig", priority="high" if payload.budget >= 1000 else "normal", audience="all", action_label="查看任务", action_url="/gigs", entity_type="gig", entity_id=record["id"], icon="💰", ) return {"ok": True, "record": public_record(record), "user": public_record(user) if user else None} @app.post("/api/gigs/{gig_id}/apply") async def apply_gig(gig_id: str, request: Request) -> dict[str, Any]: gig = await pb.first_record("gigs", filter=f'id={pb_quote(gig_id)}') if not gig: raise HTTPException(status_code=404, detail="任务不存在") _, user = await current_user(request) applicant = user.get("email") if user else "anonymous" application = await pb.create_record( "gig_applications", { "gigId": gig_id, "userId": user["id"] if user else "", "applicant": applicant, "message": "用户从赏金墙接单", "status": "submitted", }, ) updated = await pb.update_record("gigs", gig_id, {"status": "in_progress"}) await create_notification( title=f"任务已被接单:{gig.get('title', '赏金任务')}", body=f"{applicant} 已接下这个任务,任务状态已进入进行中。", category="gig", priority="high", target_user_id=user["id"] if user else "", audience="user" if user else "all", action_label="查看赏金墙", action_url="/gigs", entity_type="gig_application", entity_id=application["id"], icon="✅", ) return { "ok": True, "message": "已接单", "applicant": applicant, "record": public_record(updated), "application": public_record(application), } @app.get("/api/profiles") async def list_profiles() -> dict[str, Any]: data = await pb.list_records("profiles", per_page=100) return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]} @app.post("/api/matches/swipes") async def save_swipe(payload: SwipePayload, request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" record = await pb.create_record("swipes", {"userId": user_id, "profileId": payload.profileId, "action": payload.action}) if payload.action in {"like", "right", "superlike"}: profile = await pb.first_record("profiles", filter=f'id={pb_quote(payload.profileId)}') await create_notification( title=f"已喜欢 {profile.get('name', '一位成员') if profile else '一位成员'}", body="这条社交动作已经记录,后续可以扩展成互相喜欢、私信和同城约见提醒。", category="match", priority="normal", target_user_id=user_id if user_id != "anonymous" else "", audience="user" if user_id != "anonymous" else "all", action_label="继续匹配", action_url="/dating", entity_type="swipe", entity_id=record["id"], icon="💚", ) return {"ok": True, "record": record} @app.post("/api/feedback") async def feedback(payload: FeedbackPayload) -> dict[str, Any]: record = await pb.create_record("feedback", {**payload.model_dump(), "status": "new"}) await create_notification( title=f"反馈已收到:{payload.title}", body=payload.content[:220], category="system", priority="normal", audience="all", action_label="查看反馈页", action_url="/feedback", entity_type="feedback", entity_id=record["id"], icon="💡", ) return {"ok": True, "record": record} @app.get("/api/stats/member-map") async def member_map() -> dict[str, Any]: data = await pb.list_records("cities", per_page=100, sort="idNumber") items = [ { "id": city.get("idNumber"), "name": city.get("name"), "nameEn": city.get("nameEn"), "emoji": city.get("icon"), "lat": city.get("lat"), "lng": city.get("lng"), "nomadsNow": city.get("nomadsNow"), } for city in data.get("items", []) ] return {"ok": True, "items": items} @app.get("/api/stats/overview") async def stats_overview() -> dict[str, Any]: cities_data = await pb.list_records("cities", per_page=100, sort="idNumber") meetups_data = await pb.list_records("meetups", per_page=1) discussions_data = await pb.list_records("discussions", per_page=1) profiles_data = await pb.list_records("profiles", per_page=1) gigs_data = await pb.list_records("gigs", per_page=1) cities = cities_data.get("items", []) total_nomads = sum(int(city.get("nomadsNow") or 0) for city in cities) min_cost = min((int(city.get("costPerMonth") or 0) for city in cities), default=0) avg_cost = round(sum(int(city.get("costPerMonth") or 0) for city in cities) / max(len(cities), 1)) avg_speed = round(sum(int(city.get("internetSpeed") or 0) for city in cities) / max(len(cities), 1)) return { "ok": True, "stats": { "cities": len(cities), "nomads": total_nomads, "meetups": meetups_data.get("totalItems", 0), "discussions": discussions_data.get("totalItems", 0), "profiles": profiles_data.get("totalItems", 0), "gigs": gigs_data.get("totalItems", 0), "minCost": min_cost, "avgCost": avg_cost, "avgInternet": avg_speed, }, } @app.get("/api/stats/city-ranking") async def city_ranking(metric: str = "overallScore", limit: int = 12) -> dict[str, Any]: allowed = { "overallScore": ("overallScore", True), "cost": ("costPerMonth", False), "internet": ("internetSpeed", True), "community": ("nomadsNow", True), "temperature": ("temperature", True), } field, desc = allowed.get(metric, allowed["overallScore"]) cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", []) cities.sort(key=lambda city: float(city.get(field) or 0), reverse=desc) size = max(1, min(limit, 50)) return {"ok": True, "items": [public_record(city) for city in cities[:size]], "metric": metric} @app.get("/api/geo/china") async def china_geo() -> dict[str, Any]: return {"type": "FeatureCollection", "features": []} @app.post("/api/ai/assistant") async def ai_assistant(payload: dict[str, Any]) -> dict[str, Any]: question = str(payload.get("question", "")).lower() cities = (await pb.list_records("cities", per_page=100, sort="-overallScore")).get("items", []) picks = cities[:3] if "便宜" in question or "budget" in question or "预算" in question: picks = sorted(cities, key=lambda c: c.get("costPerMonth", 999999))[:3] elif "网" in question or "internet" in question: picks = sorted(cities, key=lambda c: c.get("internetSpeed", 0), reverse=True)[:3] answer = "根据当前城市数据,推荐:" + "、".join( f"{c['name']}(¥{c['costPerMonth']}/月,{c['internetSpeed']}Mbps)" for c in picks ) return {"ok": True, "answer": answer, "cities": [public_record(c) for c in picks]}