from __future__ import annotations import asyncio import io import re import secrets import uuid import zipfile from datetime import datetime, timedelta, timezone from html import escape from pathlib import Path from typing import Any import httpx from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import Response as FastAPIResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from .minio_storage import upload_bytes_to_minio, upload_file_to_minio from .payment import XorPayProvider, ZPayProvider, provider_for 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) processed_orders: set[str] = set() app = FastAPI(title="NomadCNA Local API", version="0.1.0") @app.middleware("http") async def normalize_double_api_prefix(request: Request, call_next): if request.scope.get("path", "").startswith("/api/api/"): request.scope["path"] = request.scope["path"][4:] raw_path = request.scope.get("raw_path") if isinstance(raw_path, bytes) and raw_path.startswith(b"/api/api/"): request.scope["raw_path"] = raw_path[4:] return await call_next(request) 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 class GoogleAuthPayload(BaseModel): id_token: str class SyncSessionPayload(BaseModel): token: str record: dict[str, Any] class EnsureUserPayload(BaseModel): email: str 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 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 ContentSubmissionPayload(BaseModel): type: str title: str subtitle: str = "" description: str = "" coverImage: str = "" mediaUrl: str = "" authorName: str = "" authorEmail: str = "" citySlugs: list[str] = Field(default_factory=list) chapters: list[dict[str, Any]] = Field(default_factory=list) 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: membership = await pb.first_record( "memberships", filter=f'userId={pb_quote(user_id)} && status="active"', ) if membership: return membership try: site_vip = await pb.first_record( "site_vip", filter=f'user_id={pb_quote(user_id)} && site_id={pb_quote(settings.site_id)}', ) if site_vip and not site_vip.get("expires_at"): return site_vip if site_vip and str(site_vip.get("expires_at")) > utc_now(): return site_vip except Exception: return None return None def internal_auth_password() -> str: password = settings.email_auth_password.strip() return password if len(password) >= 8 else "12345678" def auth_password_for_email(email: str) -> str: if email.lower().strip() == settings.pb_admin_email.lower().strip(): return settings.pb_admin_password return internal_auth_password() async def ensure_profile_for_user( user: dict[str, Any], *, email: str, name: str = "", photo: str = "", ) -> None: profile_payload = { "userId": user["id"], "email": email, "name": name or user.get("name") or email.split("@")[0], "age": 0, "location": "", "tags": ["新成员"], "bio": "", "photo": photo or f"https://api.dicebear.com/7.x/initials/svg?seed={email}", } profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}') if profile: await pb.update_record("profiles", profile["id"], profile_payload) else: await pb.create_record("profiles", profile_payload) async def login_with_internal_password(email: str) -> dict[str, Any]: return await pb.request( "POST", "/api/collections/users/auth-with-password", json={"identity": email, "password": auth_password_for_email(email)}, ) async def ensure_user( email: str, *, name: str = "", photo: str = "", ) -> 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) if existing: update_payload: dict[str, Any] = { "password": auth_password_for_email(normalized), "passwordConfirm": auth_password_for_email(normalized), } if name and not existing.get("name"): update_payload["name"] = name await pb.update_record("users", existing["id"], update_payload) try: auth = await login_with_internal_password(normalized) except Exception: raise HTTPException(status_code=401, detail="邮箱登录失败") await ensure_profile_for_user(auth["record"], email=normalized, name=name, photo=photo) return auth["record"], auth["token"], False created = await pb.create_record( "users", { "email": normalized, "emailVisibility": True, "verified": True, "password": auth_password_for_email(normalized), "passwordConfirm": auth_password_for_email(normalized), "name": name or normalized.split("@")[0], }, ) await ensure_profile_for_user(created, email=normalized, name=name, photo=photo) auth = await login_with_internal_password(normalized) return auth["record"], auth["token"], True async def verify_google_id_token(id_token: str) -> dict[str, str]: if not settings.google_client_id: raise HTTPException(status_code=400, detail="未配置 Google 登录") token = id_token.strip() if not token: raise HTTPException(status_code=400, detail="缺少 Google token") try: async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: res = await client.get("https://oauth2.googleapis.com/tokeninfo", params={"id_token": token}) data = res.json() if res.is_success else {} except Exception: raise HTTPException(status_code=401, detail="Google 登录验证失败") if data.get("aud") != settings.google_client_id: raise HTTPException(status_code=401, detail="Google 登录来源不匹配") verified = data.get("email_verified") if verified not in {True, "true", "1", 1}: raise HTTPException(status_code=401, detail="Google 邮箱未验证") email = str(data.get("email") or "").strip().lower() if not email: raise HTTPException(status_code=401, detail="Google 账号缺少邮箱") return { "email": email, "name": str(data.get("name") or email.split("@")[0]), "photo": str(data.get("picture") or ""), } 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 clean_submission_text(value: Any, limit: int, *, multiline: bool = False) -> str: text = str(value or "").strip() if not multiline: text = re.sub(r"\s+", " ", text) else: text = "\n".join(line.strip() for line in text.splitlines()).strip() return text[:limit] def slugify_content_title(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") return slug[:70].strip("-") or "submission" def validate_submission_url(value: str, field: str) -> str: url = str(value or "").strip() if not url: raise HTTPException(status_code=400, detail=f"缺少{field}") if len(url) > 500: raise HTTPException(status_code=400, detail=f"{field}链接过长") if not (url.startswith("https://") or url.startswith("http://") or url.startswith("/")): raise HTTPException(status_code=400, detail=f"{field}必须是有效链接") return url def normalize_submission_chapters(chapters: list[dict[str, Any]], content_type: str) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] for chapter in chapters[:30]: title = clean_submission_text(chapter.get("title", ""), 160) if not title: continue item: dict[str, Any] = {"title": title} if content_type == "video": time = clean_submission_text(chapter.get("time", "") or chapter.get("minutes", ""), 20) if time: item["time"] = time else: try: minutes = int(chapter.get("minutes") or 8) except Exception: minutes = 8 item["minutes"] = max(1, min(minutes, 999)) normalized.append(item) return normalized def parse_order_id(order_id: str) -> tuple[str, str]: if "_order_" not in order_id: return "", "unknown" prefix = order_id.split("_order_", 1)[0] if "_" not in prefix: return prefix, "unknown" user_id, pay_type = prefix.rsplit("_", 1) return user_id, pay_type.lower() def decode_pending_email(user_id: str) -> str | None: if not user_id.startswith("pending_"): return None try: return bytes.fromhex(user_id.removeprefix("pending_")).decode("utf-8") except Exception: return None def client_ip_from_request(request: Request) -> str: forwarded = request.headers.get("x-forwarded-for") or "" if forwarded: return forwarded.split(",", 1)[0].strip() real_ip = request.headers.get("x-real-ip") if real_ip: return real_ip.strip() return request.client.host if request.client else "" def append_order_id(return_url: str, order_id: str) -> str: if not return_url: return f"/zh/join/paid?order_id={order_id}" if "order_id=" in return_url or "out_trade_no=" in return_url: return return_url sep = "&" if "?" in return_url else "?" return f"{return_url}{sep}order_id={order_id}" def payment_name(pay_type: str) -> str: return { "book": "购买书籍", "meetup": "数字游民社区报名", "salon": "沙龙报名茶歇费", "video": "视频解锁", "vip": "VIP会员充值", }.get(pay_type, "商品支付") def set_pay_order_cookie(response: Response, order_id: str) -> None: response.set_cookie("join_pay_order", f"{order_id}|{int(datetime.now().timestamp() * 1000)}", path="/", max_age=900) def build_auto_submit_html(pay_url: str, params: dict[str, Any], order_id: str) -> str: inputs = "\n".join( f'' for key, value in params.items() if value is not None and str(value).strip() != "" ) order_value = escape(f"{order_id}|{int(datetime.now().timestamp() * 1000)}", quote=True) return f"""跳转支付

正在跳转到支付页面...

{inputs}
""" def build_redirect_html(pay_url: str, order_id: str) -> str: order_value = escape(f"{order_id}|{int(datetime.now().timestamp() * 1000)}", quote=True) return f"""跳转支付

正在跳转到支付页面...

""" def build_qr_html(img_src: str, return_url: str, order_id: str) -> str: order_value = escape(f"{order_id}|{int(datetime.now().timestamp() * 1000)}", quote=True) return f"""微信扫码支付

微信扫码支付

请使用微信扫描二维码完成支付

支付二维码
""" async def find_order(order_id: str) -> dict[str, Any] | None: return await pb.first_record("orders", filter=f'orderId={pb_quote(order_id)}') async def upsert_order_record( *, order_id: str, user_id: str, email: str = "", total_fee: int = 0, pay_type: str = "meetup", channel: str = "wxpay", status: str = "pending", return_url: str = "", ) -> dict[str, Any] | None: payload = { "orderId": order_id, "userId": user_id, "email": email, "totalFee": total_fee, "type": pay_type, "channel": channel, "status": status, "returnUrl": return_url, "isNew": user_id.startswith("pending_"), } existing = await find_order(order_id) if existing: return await pb.update_record("orders", existing["id"], payload) return await pb.create_record("orders", payload) async def mark_order_paid(order_id: str, pay_price: str = "") -> dict[str, Any] | None: order = await find_order(order_id) if order: return await pb.update_record("orders", order["id"], {"status": "paid"}) user_id, pay_type = parse_order_id(order_id) total_fee = int(float(pay_price or 0) * 100) if pay_price else 0 email = decode_pending_email(user_id) or "" return await upsert_order_record( order_id=order_id, user_id=user_id, email=email, total_fee=total_fee, pay_type=pay_type, status="paid", ) async def query_gateway_paid(order_id: str) -> tuple[bool, str]: zpay, xorpay = await asyncio.gather( ZPayProvider().query_order_status(order_id), XorPayProvider().query_order_status(order_id), ) for result in (zpay, xorpay): if result.get("paid"): return True, str(result.get("pay_price") or "") return False, "" async def ensure_site_vip(user_id: str, order_id: str) -> None: try: payload = {"user_id": user_id, "site_id": settings.site_id, "order_id": order_id, "expires_at": ""} existing = await pb.first_record("site_vip", filter=f'user_id={pb_quote(user_id)} && site_id={pb_quote(settings.site_id)}') if existing: await pb.update_record("site_vip", existing["id"], payload) else: await pb.create_record("site_vip", payload) except Exception: return async def ensure_paid_membership(user: dict[str, Any], order_id: str) -> None: expires = (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S.000Z") payload_membership = { "userId": user["id"], "email": user.get("email", ""), "plan": "pro", "status": "active", "expiresAt": expires, } membership = await pb.first_record("memberships", filter=f'userId={pb_quote(user["id"])}') if membership: await pb.update_record("memberships", membership["id"], payload_membership) else: await pb.create_record("memberships", payload_membership) await ensure_site_vip(user["id"], order_id) async def record_payment(user: dict[str, Any], order: dict[str, Any], order_id: str, pay_price: str = "") -> None: try: amount = int(float(pay_price or 0) * 100) if pay_price else int(order.get("totalFee") or 0) existing = await pb.first_record("payments", filter=f'order_id={pb_quote(order_id)}') payload = { "user_id": user["id"], "type": order.get("type") or parse_order_id(order_id)[1], "order_id": order_id, "amount": amount, "total_fee": amount, "channel": order.get("channel") or "wxpay", } if existing: await pb.update_record("payments", existing["id"], payload) else: await pb.create_record("payments", payload) except Exception: return async def user_for_paid_order(order: dict[str, Any], order_id: str) -> tuple[dict[str, Any] | None, str | None, bool]: user_id = order.get("userId") or parse_order_id(order_id)[0] if user_id.startswith("pending_"): email = order.get("email") or decode_pending_email(user_id) if not email: return None, None, False user, token, is_new = await ensure_user(email) return user, token, is_new if not user_id: return None, None, False user = await pb.first_record("users", filter=f'id={pb_quote(user_id)}') return user, None, False async def finalize_paid_order(order_id: str, pay_price: str = "") -> tuple[dict[str, Any] | None, str | None, bool]: if not order_id: return None, None, False order = await mark_order_paid(order_id, pay_price) if not order: return None, None, False user, token, is_new = await user_for_paid_order(order, order_id) if user: await record_payment(user, order, order_id, pay_price) await ensure_paid_membership(user, order_id) processed_orders.add(order_id) return user, token, is_new 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/email") async def email_login(payload: AuthPayload, response: Response) -> dict[str, Any]: user, token, is_new = await ensure_user(payload.email) set_session_cookie(response, token) return {"ok": True, "token": token, "record": user, "user": user, "is_new": is_new} @app.post("/api/auth/google") async def google_login(payload: GoogleAuthPayload, response: Response) -> dict[str, Any]: profile = await verify_google_id_token(payload.id_token) user, token, is_new = await ensure_user(profile["email"], name=profile["name"], photo=profile["photo"]) set_session_cookie(response, token) return {"ok": True, "token": token, "record": user, "user": user, "is_new": is_new, "provider": "google"} @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/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)) 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/complete-order") async def complete_order(payload: CompleteOrderPayload, response: Response) -> dict[str, Any]: order_id = payload.order_id.strip() if not order_id: raise HTTPException(status_code=400, detail="缺少 order_id") order = await find_order(order_id) if not order: paid, pay_price = await query_gateway_paid(order_id) if not paid and not settings.dev_auto_pay: raise HTTPException(status_code=400, detail="订单不存在或未支付成功") order = await mark_order_paid(order_id, pay_price) elif order.get("status") != "paid": paid, pay_price = await query_gateway_paid(order_id) if not paid and not settings.dev_auto_pay: raise HTTPException(status_code=400, detail="订单未支付成功") order = await mark_order_paid(order_id, pay_price) if not order: raise HTTPException(status_code=500, detail="订单处理失败") user, token, is_new = await finalize_paid_order(order_id) if not user: raise HTTPException(status_code=500, detail="会员开通失败") if token: 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]: purpose = (request.query_params.get("purpose") or "").lower() is_content_upload = purpose in {"content", "ebook", "video", "submission"} max_size = 200 * 1024 * 1024 if is_content_upload else 20 * 1024 * 1024 if file.size and file.size > max_size: raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_size // 1024 // 1024}MB") upload = await upload_file_to_minio(file, purpose=purpose or "uploads", max_size=max_size) user_id = "" _, user = await current_user(request) user_id = user["id"] if user else "" await pb.create_record( "media_assets", { "userId": user_id, "url": upload.url, "filename": file.filename or upload.object_key, "contentType": file.content_type or "application/octet-stream", "size": upload.size, "objectKey": upload.object_key, }, ) return {"ok": True, "url": upload.url, "objectKey": upload.object_key} @app.post("/api/pay", response_model=None) async def create_pay(request: Request, response: Response) -> HTMLResponse | dict[str, Any]: content_type = request.headers.get("content-type", "") if "application/json" in content_type: body = await request.json() else: form = await request.form() body = dict(form) user_id = str(body.get("user_id") or "").strip() if not user_id: raise HTTPException(status_code=400, detail="缺少 user_id") pay_type = str(body.get("type") or settings.payment_join_type or "meetup").lower() try: total_fee = int(float(body.get("total_fee") or 0)) except (TypeError, ValueError): total_fee = 0 if total_fee <= 0: total_fee = settings.payment_join_amount if pay_type == settings.payment_join_type else settings.payment_default_amount channel = str(body.get("channel") or "wxpay") device = str(body.get("device") or "pc") requested_provider = str(body.get("provider") or "") want_html = str(body.get("html") or "0") == "1" or "text/html" in (request.headers.get("accept") or "") order_id = f"{user_id}_{pay_type}_order_{int(datetime.now().timestamp())}_{secrets.token_hex(4)}" return_url = append_order_id(str(body.get("return_url") or "/zh/join/paid"), order_id) email = decode_pending_email(user_id) or "" await upsert_order_record( order_id=order_id, user_id=user_id, email=email, total_fee=total_fee, pay_type=pay_type, channel=channel, status="paid" if settings.dev_auto_pay else "pending", return_url=return_url, ) set_pay_order_cookie(response, order_id) if settings.dev_auto_pay: redirect_url = return_url html_response = HTMLResponse(build_redirect_html(redirect_url, order_id)) set_pay_order_cookie(html_response, order_id) return html_response if want_html else {"ok": True, "order_id": order_id, "paid": True} amount_yuan = f"{total_fee / 100:.2f}" provider = provider_for(device, requested_provider, request.headers.get("user-agent", "")) notify_url = f"{settings.base_url}/api/pay/{provider.name}_notify" name = payment_name(pay_type) client_ip = client_ip_from_request(request) if isinstance(provider, XorPayProvider): created = provider.create_order(order_id=order_id, name=name, amount_yuan=amount_yuan, notify_url=notify_url) if not want_html: return {"ok": True, "order_id": order_id, "provider": provider.name, "params": created["params"], "payUrl": created["pay_url"]} html_response = HTMLResponse(build_auto_submit_html(created["pay_url"], created["params"], order_id)) set_pay_order_cookie(html_response, order_id) return html_response api_result = await provider.create_order_api( order_id=order_id, name=name, amount_yuan=amount_yuan, channel=channel, notify_url=notify_url, return_url=return_url, client_ip=client_ip, device=device, ) if api_result.get("status") == "ok": if not want_html: return {"ok": True, "order_id": order_id, "provider": provider.name, **api_result} if device == "pc" and api_result.get("img"): html_response = HTMLResponse(build_qr_html(str(api_result["img"]), return_url, order_id)) set_pay_order_cookie(html_response, order_id) return html_response pay_url = api_result.get("payurl2") if device == "h5" and api_result.get("payurl2") else api_result.get("payurl") if pay_url: html_response = HTMLResponse(build_redirect_html(str(pay_url), order_id)) set_pay_order_cookie(html_response, order_id) return html_response created = provider.create_order( order_id=order_id, name=name, amount_yuan=amount_yuan, channel=channel, notify_url=notify_url, return_url=return_url, client_ip=client_ip, ) if not want_html: return {"ok": True, "order_id": order_id, "provider": provider.name, "params": created["params"], "payUrl": created["pay_url"]} html_response = HTMLResponse(build_auto_submit_html(created["pay_url"], created["params"], order_id)) set_pay_order_cookie(html_response, order_id) return html_response @app.get("/api/pay/status") async def pay_status(order_id: str) -> dict[str, Any]: order = await find_order(order_id) if order and order.get("status") == "paid": return {"ok": True, "paid": True, "order": order} paid, pay_price = await query_gateway_paid(order_id) if paid: order = await mark_order_paid(order_id, pay_price) return {"ok": True, "paid": paid, "order": order} @app.get("/cnomadcna/order_status") async def cnomadcna_order_status(order_id: str = "", out_trade_no: str = "") -> dict[str, Any]: target = (order_id or out_trade_no).strip() if not target: return {"paid": False, "error": "missing order_id"} result = await pay_status(target) return {"paid": bool(result.get("paid")), "order": result.get("order")} @app.get("/cnomadcna/zpay_order_status") async def cnomadcna_zpay_order_status(out_trade_no: str) -> dict[str, Any]: return await ZPayProvider().query_order_status(out_trade_no) @app.api_route("/api/pay/zpay_notify", methods=["GET", "POST"]) @app.api_route("/cnomadcna/zpay_notify", methods=["GET", "POST"]) async def zpay_notify(request: Request) -> FastAPIResponse: if request.method == "GET": data = dict(request.query_params) else: form = await request.form() data = dict(form) provider = ZPayProvider() order_id = str(data.get("out_trade_no", "")) if order_id in processed_orders: return FastAPIResponse(provider.success_response(), media_type="text/plain") if not provider.verify_notify(data): raise HTTPException(status_code=400, detail="sign error") parsed = provider.parse_notify(data) if parsed.get("trade_status") == "success": await finalize_paid_order(parsed.get("order_id", ""), parsed.get("pay_price", "")) return FastAPIResponse(provider.success_response(), media_type="text/plain") @app.post("/api/pay/xorpay_notify") @app.post("/cnomadcna/xorpay_notify") async def xorpay_notify(request: Request) -> FastAPIResponse: form = await request.form() data = dict(form) provider = XorPayProvider() order_id = str(data.get("order_id", "")) if order_id in processed_orders: return FastAPIResponse(provider.success_response(), media_type="text/plain") if not provider.verify_notify(data): raise HTTPException(status_code=400, detail="sign error") parsed = provider.parse_notify(data) await finalize_paid_order(parsed.get("order_id", ""), parsed.get("pay_price", "")) return FastAPIResponse(provider.success_response(), media_type="text/plain") @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)} && status="published"', 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.post("/api/content/submissions") async def submit_content(payload: ContentSubmissionPayload, request: Request) -> dict[str, Any]: content_type = clean_submission_text(payload.type, 20).lower() if content_type not in {"ebook", "video"}: raise HTTPException(status_code=400, detail="只支持电子书或访谈视频投稿") title = clean_submission_text(payload.title, 255) subtitle = clean_submission_text(payload.subtitle, 500) or ("社区电子书投稿" if content_type == "ebook" else "社区访谈视频投稿") description = clean_submission_text(payload.description, 2000, multiline=True) if not title: raise HTTPException(status_code=400, detail="缺少标题") if not description: raise HTTPException(status_code=400, detail="缺少内容简介") cover_image = validate_submission_url(payload.coverImage, "封面图") media_url = validate_submission_url(payload.mediaUrl, "内容文件") _, user = await current_user(request) author_name = clean_submission_text(payload.authorName or (user or {}).get("name") or "", 120) author_email = clean_submission_text(payload.authorEmail or (user or {}).get("email") or "", 255).lower() if "@" not in author_email or "." not in author_email.split("@")[-1]: raise HTTPException(status_code=400, detail="请填写有效联系邮箱,便于审核沟通") slug = f"ugc-{content_type}-{slugify_content_title(title)}-{uuid.uuid4().hex[:8]}" target_url = f"/ebooks/{slug}" if content_type == "ebook" else f"/videos/{slug}" cta_label = "查看电子书" if content_type == "ebook" else "观看访谈" chapters = normalize_submission_chapters(payload.chapters, content_type) city_slugs = [clean_submission_text(x, 80) for x in payload.citySlugs[:12] if clean_submission_text(x, 80)] submitted_by = str((user or {}).get("id") or "") submitted_at = utc_now() base_record: dict[str, Any] = { "slug": slug, "type": content_type, "title": title, "titleEn": "", "subtitle": subtitle, "subtitleEn": "", "description": description, "descriptionEn": "", "coverImage": cover_image, "mediaUrl": media_url, "targetUrl": target_url, "ctaLabel": cta_label, "ctaLabelEn": "View ebook" if content_type == "ebook" else "Watch interview", "citySlugs": city_slugs, "relatedProfiles": [submitted_by] if submitted_by else [], "chapters": chapters, "durationMinutes": 0, "sortOrder": 1000, "status": "pending", } record_payload = { **base_record, "authorName": author_name, "authorEmail": author_email, "submittedBy": submitted_by, "reviewStatus": "pending", "reviewNote": "", "submissionMeta": { "source": "ugc", "submittedAt": submitted_at, "ip": client_ip_from_request(request), "userAgent": request.headers.get("user-agent", "")[:300], }, } try: record = await pb.create_record("content_items", record_payload) except PocketBaseError: record = await pb.create_record("content_items", base_record) await create_notification( title="新的社区内容待审核", body=f"{author_name or author_email} 提交了《{title}》,类型:{'电子书' if content_type == 'ebook' else '访谈视频'}。", category="system", priority="high", audience="review", action_label="查看消息", action_url="/messages", entity_type="content_submission", entity_id=record.get("id", ""), icon="📝", metadata={"slug": slug, "type": content_type, "status": "pending"}, ) return {"ok": True, "status": "pending", "record": public_record(record)} @app.get("/api/content/{slug}") async def content_detail(slug: str, request: Request) -> 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="内容不存在") status = str(item.get("status") or "published") if status != "published": _, user = await current_user(request) submitted_by = str(item.get("submittedBy") or "") if not (user and submitted_by and user.get("id") == submitted_by): 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: async def redirect_to_minio(body: bytes, content_type: str) -> RedirectResponse: object_key = f"{settings.minio_upload_prefix}/downloads/{filename}" asset = await upload_bytes_to_minio(body, object_key=object_key, content_type=content_type) return RedirectResponse(asset.url, status_code=307) 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 await redirect_to_minio(body, "application/pdf") if filename == "nomadcna-app-demo.zip": buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr( "README.txt", "\n".join( [ "Nomadro Web App Package", "", "Open Nomadro-Web-App.html in a browser or visit https://nomadro.cn/zh/digital.", "The web app can be installed from Chrome, Edge, Safari, and other modern browsers.", "", ] ), ) archive.writestr( "Nomadro-Web-App.html", """ Nomadro Web App

Nomadro Web App

正在打开数字游民指南。如果没有自动跳转,请点击下方按钮。

打开 Nomadro
""", ) body = buffer.getvalue() return await redirect_to_minio(body, "application/zip") 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" mode = str(payload.get("mode") or "offline").lower() if mode not in {"offline", "online", "hybrid"}: mode = "offline" access_level = str(payload.get("accessLevel") or "public").lower() if access_level not in {"public", "members", "vip", "hosts"}: access_level = "public" payload["mode"] = mode payload["accessLevel"] = access_level payload.setdefault("status", "pending") payload.setdefault("rsvpCount", 0) payload.setdefault("attendees", []) payload.setdefault("maxAttendees", 0) payload.setdefault("organizer", "NomadCNA") payload.setdefault("meetingProvider", "mirotalk" if mode != "offline" else "") payload.setdefault("mirotalkRoom", "") payload.setdefault("meetingUrl", "") payload.setdefault("replayUrl", "") if not isinstance(payload.get("features"), list): payload["features"] = [] 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]}