Files
gitlab-instance-0a899031_cn…/backend/main.py
2026-06-06 04:29:46 -05:00

884 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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] = []
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 RecommendationPayload(BaseModel):
budget: int = 6000
internet: int = 50
climate: str = "any"
tags: list[str] = []
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("@")}
@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)
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"""<!doctype html><html><head><meta charset='utf-8'><title>本地支付</title></head>
<body style='font-family:system-ui;padding:40px;text-align:center'>
<h1>NomadCNA 本地开发支付</h1>
<p>订单 {order_id} 已自动标记为支付成功。</p>
<p>3 秒后返回站点完成会员开通。</p>
<script>
localStorage.setItem('join_pay_order', '{cookie_value}');
setTimeout(function(){{ location.href = {redirect_url!r}; }}, 3000);
</script>
<a href='{redirect_url}'>立即返回</a></body></html>"""
)
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}
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",
},
)
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)
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,
},
)
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",
},
)
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"})
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})
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"})
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]}