s''
This commit is contained in:
883
backend/main.py
Normal file
883
backend/main.py
Normal file
@@ -0,0 +1,883 @@
|
||||
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]}
|
||||
Reference in New Issue
Block a user