1318 lines
49 KiB
Python
1318 lines
49 KiB
Python
from __future__ import annotations
|
||
|
||
import secrets
|
||
import uuid
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import HTMLResponse
|
||
from fastapi.responses import Response as FastAPIResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel, Field
|
||
|
||
from .pb_client import PocketBaseError, pb, pb_quote
|
||
from .settings import get_settings
|
||
|
||
settings = get_settings()
|
||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
app = FastAPI(title="NomadCNA Local API", version="0.1.0")
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.frontend_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
|
||
|
||
|
||
class AuthPayload(BaseModel):
|
||
email: str
|
||
password: str
|
||
passwordConfirm: str | None = None
|
||
|
||
|
||
class SyncSessionPayload(BaseModel):
|
||
token: str
|
||
record: dict[str, Any]
|
||
|
||
|
||
class EnsureUserPayload(BaseModel):
|
||
email: str
|
||
password: str | None = None
|
||
|
||
|
||
class CheckUserPayload(BaseModel):
|
||
email: str
|
||
|
||
|
||
class CompleteOrderPayload(BaseModel):
|
||
order_id: str
|
||
|
||
|
||
class JoinPayload(BaseModel):
|
||
nickname: str = ""
|
||
gender: str = ""
|
||
single: str = ""
|
||
profession: str = ""
|
||
intro: str = ""
|
||
wechat: str = ""
|
||
education: str = ""
|
||
graduationYear: str = ""
|
||
phone: str = ""
|
||
media: str | None = None
|
||
|
||
|
||
class ChangePasswordPayload(BaseModel):
|
||
newPassword: str
|
||
passwordConfirm: str
|
||
|
||
|
||
class FeedbackPayload(BaseModel):
|
||
type: str
|
||
email: str = ""
|
||
title: str
|
||
content: str
|
||
|
||
|
||
class SwipePayload(BaseModel):
|
||
profileId: str
|
||
action: str
|
||
|
||
|
||
class DiscussionPayload(BaseModel):
|
||
title: str
|
||
author: str = "NomadCNA"
|
||
city: str = ""
|
||
tags: list[str] = Field(default_factory=list)
|
||
excerpt: str = ""
|
||
|
||
|
||
class GigPayload(BaseModel):
|
||
title: str
|
||
category: str = "任务"
|
||
budget: int = 300
|
||
location: str = "远程"
|
||
description: str = ""
|
||
|
||
|
||
class ServiceLeadPayload(BaseModel):
|
||
serviceSlug: str
|
||
email: str = ""
|
||
note: str = ""
|
||
|
||
|
||
class NotificationCreatePayload(BaseModel):
|
||
title: str
|
||
body: str = ""
|
||
category: str = "system"
|
||
priority: str = "normal"
|
||
targetUserId: str = ""
|
||
audience: str = "all"
|
||
actionLabel: str = ""
|
||
actionUrl: str = ""
|
||
entityType: str = ""
|
||
entityId: str = ""
|
||
icon: str = "🔔"
|
||
channels: list[str] = Field(default_factory=lambda: ["in_app"])
|
||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class NotificationPreferencePayload(BaseModel):
|
||
channels: dict[str, bool] = Field(default_factory=dict)
|
||
categories: dict[str, bool] = Field(default_factory=dict)
|
||
quietHoursStart: str = "22:00"
|
||
quietHoursEnd: str = "08:00"
|
||
digest: str = "daily"
|
||
allowMarketing: bool = False
|
||
|
||
|
||
class RecommendationPayload(BaseModel):
|
||
budget: int = 6000
|
||
internet: int = 50
|
||
climate: str = "any"
|
||
tags: list[str] = Field(default_factory=list)
|
||
limit: int = 12
|
||
|
||
|
||
def set_session_cookie(response: Response, token: str) -> None:
|
||
response.set_cookie(
|
||
settings.cookie_name,
|
||
token,
|
||
httponly=True,
|
||
samesite="lax",
|
||
secure=False,
|
||
max_age=60 * 60 * 24 * 30,
|
||
path="/",
|
||
)
|
||
|
||
|
||
def clear_session_cookie(response: Response) -> None:
|
||
response.delete_cookie(settings.cookie_name, path="/")
|
||
|
||
|
||
def token_from_request(request: Request) -> str | None:
|
||
auth = request.headers.get("authorization", "")
|
||
if auth.lower().startswith("bearer "):
|
||
return auth.split(" ", 1)[1].strip()
|
||
return request.cookies.get(settings.cookie_name)
|
||
|
||
|
||
async def current_user(request: Request) -> tuple[str, dict[str, Any]] | tuple[None, None]:
|
||
token = token_from_request(request)
|
||
if not token:
|
||
return None, None
|
||
try:
|
||
data = await pb.request("POST", "/api/collections/users/auth-refresh", token=token)
|
||
return data.get("token") or token, data.get("record")
|
||
except Exception:
|
||
return None, None
|
||
|
||
|
||
async def find_user_by_email(email: str) -> dict[str, Any] | None:
|
||
return await pb.first_record("users", filter=f"email={pb_quote(email.lower())}")
|
||
|
||
|
||
async def find_membership(user_id: str) -> dict[str, Any] | None:
|
||
return await pb.first_record(
|
||
"memberships",
|
||
filter=f'userId={pb_quote(user_id)} && status="active"',
|
||
)
|
||
|
||
|
||
async def ensure_user(email: str, password: str | None = None) -> tuple[dict[str, Any], str, bool]:
|
||
normalized = email.lower().strip()
|
||
if "@" not in normalized or "." not in normalized.split("@")[-1]:
|
||
raise HTTPException(status_code=400, detail="请输入有效邮箱")
|
||
existing = await find_user_by_email(normalized)
|
||
is_new = False
|
||
if existing:
|
||
login_password = password or "12345678"
|
||
try:
|
||
auth = await pb.request(
|
||
"POST",
|
||
"/api/collections/users/auth-with-password",
|
||
json={"identity": normalized, "password": login_password},
|
||
)
|
||
except Exception:
|
||
if not password:
|
||
raise HTTPException(status_code=401, detail="请输入该邮箱的登录密码")
|
||
raise HTTPException(status_code=401, detail="邮箱或密码不正确")
|
||
return auth["record"], auth["token"], is_new
|
||
|
||
user_password = password or "12345678"
|
||
created = await pb.create_record(
|
||
"users",
|
||
{
|
||
"email": normalized,
|
||
"emailVisibility": True,
|
||
"verified": True,
|
||
"password": user_password,
|
||
"passwordConfirm": user_password,
|
||
"name": normalized.split("@")[0],
|
||
},
|
||
)
|
||
is_new = True
|
||
await pb.create_record(
|
||
"profiles",
|
||
{
|
||
"userId": created["id"],
|
||
"email": normalized,
|
||
"name": normalized.split("@")[0],
|
||
"age": 0,
|
||
"location": "",
|
||
"tags": ["新成员"],
|
||
"bio": "",
|
||
"photo": f"https://api.dicebear.com/7.x/initials/svg?seed={normalized}",
|
||
"needsPasswordChange": password is None,
|
||
},
|
||
)
|
||
auth = await pb.request(
|
||
"POST",
|
||
"/api/collections/users/auth-with-password",
|
||
json={"identity": normalized, "password": user_password},
|
||
)
|
||
return auth["record"], auth["token"], is_new
|
||
|
||
|
||
def public_record(record: dict[str, Any]) -> dict[str, Any]:
|
||
return {k: v for k, v in record.items() if not k.startswith("@")}
|
||
|
||
|
||
def utc_now() -> str:
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z")
|
||
|
||
|
||
def default_notification_preferences(user_id: str, email: str = "") -> dict[str, Any]:
|
||
return {
|
||
"userId": user_id,
|
||
"email": email,
|
||
"channels": {"in_app": True, "email": False, "wechat": False},
|
||
"categories": {
|
||
"system": True,
|
||
"meetup": True,
|
||
"gig": True,
|
||
"match": True,
|
||
"service": True,
|
||
"community": True,
|
||
"city": True,
|
||
},
|
||
"quietHoursStart": "22:00",
|
||
"quietHoursEnd": "08:00",
|
||
"digest": "daily",
|
||
"allowMarketing": False,
|
||
}
|
||
|
||
|
||
def normalize_notification_preferences(record: dict[str, Any] | None, user_id: str, email: str = "") -> dict[str, Any]:
|
||
defaults = default_notification_preferences(user_id, email)
|
||
if not record:
|
||
return defaults
|
||
public = public_record(record)
|
||
return {
|
||
**defaults,
|
||
**public,
|
||
"channels": {**defaults["channels"], **(public.get("channels") or {})},
|
||
"categories": {**defaults["categories"], **(public.get("categories") or {})},
|
||
}
|
||
|
||
|
||
async def notification_preferences_for_user(user_id: str, user: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
record = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}')
|
||
return normalize_notification_preferences(record, user_id, user.get("email", "") if user else "")
|
||
|
||
|
||
def notification_is_muted(item: dict[str, Any], preferences: dict[str, Any]) -> bool:
|
||
category = item.get("category") or "system"
|
||
categories = preferences.get("categories") or {}
|
||
channels = preferences.get("channels") or {}
|
||
item_channels = item.get("channels") or ["in_app"]
|
||
category_enabled = bool(categories.get(category, True))
|
||
channel_enabled = any(bool(channels.get(channel, False)) for channel in item_channels)
|
||
return not category_enabled or not channel_enabled
|
||
|
||
|
||
async def create_notification(
|
||
*,
|
||
title: str,
|
||
body: str = "",
|
||
category: str = "system",
|
||
priority: str = "normal",
|
||
target_user_id: str = "",
|
||
audience: str = "all",
|
||
action_label: str = "",
|
||
action_url: str = "",
|
||
entity_type: str = "",
|
||
entity_id: str = "",
|
||
icon: str = "🔔",
|
||
channels: list[str] | None = None,
|
||
metadata: dict[str, Any] | None = None,
|
||
) -> dict[str, Any] | None:
|
||
try:
|
||
return await pb.create_record(
|
||
"notifications",
|
||
{
|
||
"title": title,
|
||
"body": body,
|
||
"category": category,
|
||
"priority": priority,
|
||
"targetUserId": target_user_id,
|
||
"audience": audience,
|
||
"actionLabel": action_label,
|
||
"actionUrl": action_url,
|
||
"entityType": entity_type,
|
||
"entityId": entity_id,
|
||
"icon": icon,
|
||
"status": "published",
|
||
"channels": channels or ["in_app"],
|
||
"metadata": metadata or {},
|
||
},
|
||
)
|
||
except PocketBaseError:
|
||
return None
|
||
|
||
|
||
async def receipt_for(notification_id: str, user_id: str) -> dict[str, Any] | None:
|
||
if not user_id:
|
||
return None
|
||
return await pb.first_record(
|
||
"notification_receipts",
|
||
filter=f'notificationId={pb_quote(notification_id)} && userId={pb_quote(user_id)}',
|
||
)
|
||
|
||
|
||
async def upsert_receipt(notification_id: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||
existing = await receipt_for(notification_id, user_id)
|
||
base = {"notificationId": notification_id, "userId": user_id, "pinned": False}
|
||
if existing:
|
||
return await pb.update_record("notification_receipts", existing["id"], payload)
|
||
return await pb.create_record("notification_receipts", {**base, **payload})
|
||
|
||
|
||
async def decorated_notifications_for_user(
|
||
user_id: str,
|
||
preferences: dict[str, Any] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
preferences = preferences or await notification_preferences_for_user(user_id)
|
||
data = await pb.list_records("notifications", filter='status="published"', per_page=100)
|
||
notifications = [
|
||
public_record(item)
|
||
for item in data.get("items", [])
|
||
if item.get("audience", "all") == "all" or item.get("targetUserId") == user_id
|
||
]
|
||
receipts = (await pb.list_records("notification_receipts", filter=f'userId={pb_quote(user_id)}', per_page=200)).get("items", [])
|
||
receipt_by_notification = {receipt.get("notificationId"): public_record(receipt) for receipt in receipts}
|
||
decorated = []
|
||
for item in notifications:
|
||
receipt = receipt_by_notification.get(item["id"], {})
|
||
archived = bool(receipt.get("archivedAt"))
|
||
decorated.append(
|
||
{
|
||
**item,
|
||
"receipt": receipt,
|
||
"read": bool(receipt.get("readAt")),
|
||
"archived": archived,
|
||
"pinned": bool(receipt.get("pinned")),
|
||
"muted": notification_is_muted(item, preferences),
|
||
}
|
||
)
|
||
decorated.sort(key=lambda item: (item.get("pinned", False), item.get("created", "")), reverse=True)
|
||
return decorated
|
||
|
||
|
||
@app.get("/api/health")
|
||
async def health() -> dict[str, Any]:
|
||
return {"ok": True, "service": "NomadCNA FastAPI", "pb": settings.pb_url}
|
||
|
||
|
||
@app.post("/api/auth/register")
|
||
async def register(payload: AuthPayload, response: Response) -> dict[str, Any]:
|
||
if len(payload.password) < 8:
|
||
raise HTTPException(status_code=400, detail="密码至少 8 位")
|
||
if payload.passwordConfirm and payload.password != payload.passwordConfirm:
|
||
raise HTTPException(status_code=400, detail="两次密码不一致")
|
||
try:
|
||
user, token, _ = await ensure_user(payload.email, payload.password)
|
||
except PocketBaseError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
set_session_cookie(response, token)
|
||
return {"ok": True, "token": token, "record": user, "user": user}
|
||
|
||
|
||
@app.post("/api/auth/login")
|
||
async def login(payload: AuthPayload, response: Response) -> dict[str, Any]:
|
||
try:
|
||
auth = await pb.request(
|
||
"POST",
|
||
"/api/collections/users/auth-with-password",
|
||
json={"identity": payload.email.lower(), "password": payload.password},
|
||
)
|
||
except Exception:
|
||
raise HTTPException(status_code=401, detail="邮箱或密码不正确")
|
||
set_session_cookie(response, auth["token"])
|
||
return {"ok": True, "token": auth["token"], "record": auth["record"], "user": auth["record"]}
|
||
|
||
|
||
@app.post("/api/auth/sync-session")
|
||
async def sync_session(payload: SyncSessionPayload, response: Response) -> dict[str, Any]:
|
||
set_session_cookie(response, payload.token)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/api/auth/me")
|
||
async def me(request: Request, response: Response) -> dict[str, Any]:
|
||
token, user = await current_user(request)
|
||
if not user:
|
||
return {"ok": True, "user": None}
|
||
set_session_cookie(response, token)
|
||
vip = bool(await find_membership(user["id"]))
|
||
return {"ok": True, "token": token, "user": {**user, "vip": vip}}
|
||
|
||
|
||
@app.post("/api/auth/logout")
|
||
async def logout(response: Response) -> dict[str, Any]:
|
||
clear_session_cookie(response)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/auth/change-password")
|
||
async def change_password(payload: ChangePasswordPayload, request: Request) -> dict[str, Any]:
|
||
token, user = await current_user(request)
|
||
if not user:
|
||
raise HTTPException(status_code=401, detail="未登录")
|
||
if len(payload.newPassword) < 8:
|
||
raise HTTPException(status_code=400, detail="密码至少 8 位")
|
||
if payload.newPassword != payload.passwordConfirm:
|
||
raise HTTPException(status_code=400, detail="两次密码不一致")
|
||
await pb.update_record(
|
||
"users",
|
||
user["id"],
|
||
{"password": payload.newPassword, "passwordConfirm": payload.passwordConfirm},
|
||
)
|
||
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
|
||
if profile:
|
||
await pb.update_record("profiles", profile["id"], {"needsPasswordChange": False})
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/meetup/check-user")
|
||
async def check_user(payload: CheckUserPayload) -> dict[str, Any]:
|
||
user = await find_user_by_email(str(payload.email))
|
||
vip = bool(await find_membership(user["id"])) if user else False
|
||
return {"ok": True, "exists": bool(user), "vip": vip, "user_id": user["id"] if user else None}
|
||
|
||
|
||
@app.post("/api/meetup/ensure-user")
|
||
async def ensure_user_endpoint(payload: EnsureUserPayload, response: Response) -> dict[str, Any]:
|
||
user, token, is_new = await ensure_user(str(payload.email), payload.password)
|
||
set_session_cookie(response, token)
|
||
vip = bool(await find_membership(user["id"]))
|
||
return {"ok": True, "user_id": user["id"], "record": user, "token": token, "is_new": is_new, "vip": vip}
|
||
|
||
|
||
@app.post("/api/meetup/set-needs-password-change")
|
||
async def set_needs_password_change(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
if not user:
|
||
raise HTTPException(status_code=401, detail="未登录")
|
||
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
|
||
if profile:
|
||
await pb.update_record("profiles", profile["id"], {"needsPasswordChange": True})
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/api/meetup/need-password-change")
|
||
async def need_password_change(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
if not user:
|
||
return {"ok": True, "need": False}
|
||
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
|
||
return {"ok": True, "need": bool(profile and profile.get("needsPasswordChange"))}
|
||
|
||
|
||
@app.post("/api/meetup/complete-order")
|
||
async def complete_order(payload: CompleteOrderPayload, response: Response) -> dict[str, Any]:
|
||
order = await pb.first_record("orders", filter=f'orderId={pb_quote(payload.order_id)}')
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="订单不存在")
|
||
if order.get("status") != "paid":
|
||
await pb.update_record("orders", order["id"], {"status": "paid"})
|
||
user_id = order.get("userId", "")
|
||
user = None
|
||
if user_id and not user_id.startswith("pending_"):
|
||
user = await pb.first_record("users", filter=f'id={pb_quote(user_id)}')
|
||
if not user:
|
||
email = order.get("email") or f"{payload.order_id}@nomadcna.local"
|
||
user, token, is_new = await ensure_user(email)
|
||
else:
|
||
auth = await pb.request(
|
||
"POST",
|
||
"/api/collections/users/auth-with-password",
|
||
json={"identity": user["email"], "password": "12345678"},
|
||
)
|
||
token = auth["token"]
|
||
is_new = False
|
||
|
||
expires = (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S.000Z")
|
||
membership = await pb.first_record("memberships", filter=f'userId={pb_quote(user["id"])}')
|
||
payload_membership = {
|
||
"userId": user["id"],
|
||
"email": user.get("email", ""),
|
||
"plan": "pro",
|
||
"status": "active",
|
||
"expiresAt": expires,
|
||
}
|
||
if membership:
|
||
await pb.update_record("memberships", membership["id"], payload_membership)
|
||
else:
|
||
await pb.create_record("memberships", payload_membership)
|
||
set_session_cookie(response, token)
|
||
return {"ok": True, "token": token, "record": user, "is_new": is_new}
|
||
|
||
|
||
@app.get("/api/vip/check")
|
||
async def vip_check(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
if not user:
|
||
return {"ok": True, "vip": False}
|
||
membership = await find_membership(user["id"])
|
||
return {"ok": True, "vip": bool(membership), "membership": membership}
|
||
|
||
|
||
@app.post("/api/join")
|
||
async def join(payload: JoinPayload, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
if not user:
|
||
raise HTTPException(status_code=401, detail="请先登录")
|
||
email = user.get("email", "")
|
||
record_payload = {
|
||
**payload.model_dump(),
|
||
"userId": user["id"],
|
||
"email": email,
|
||
"isComplete": True,
|
||
}
|
||
existing = await pb.first_record("member_applications", filter=f'userId={pb_quote(user["id"])}')
|
||
if existing:
|
||
record = await pb.update_record("member_applications", existing["id"], record_payload)
|
||
else:
|
||
record = await pb.create_record("member_applications", record_payload)
|
||
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
|
||
profile_payload = {
|
||
"userId": user["id"],
|
||
"email": email,
|
||
"name": payload.nickname or user.get("name") or email.split("@")[0],
|
||
"location": "",
|
||
"tags": [payload.profession, payload.education, "数字游民"],
|
||
"bio": payload.intro,
|
||
"photo": payload.media or f"https://api.dicebear.com/7.x/initials/svg?seed={email}",
|
||
}
|
||
if profile:
|
||
await pb.update_record("profiles", profile["id"], profile_payload)
|
||
else:
|
||
await pb.create_record("profiles", profile_payload)
|
||
await create_notification(
|
||
title="成员资料已更新",
|
||
body="你的加入资料已经保存,后续匹配、活动推荐和城市建议会基于这份资料联动。",
|
||
category="system",
|
||
priority="normal",
|
||
target_user_id=user["id"],
|
||
audience="user",
|
||
action_label="查看资料",
|
||
action_url="/join",
|
||
entity_type="member_application",
|
||
entity_id=record["id"],
|
||
icon="👤",
|
||
)
|
||
return {"ok": True, "record": record, "user_id": user["id"]}
|
||
|
||
|
||
@app.get("/api/join/status")
|
||
async def join_status(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
if not user:
|
||
return {"ok": True, "hasRecord": False, "isComplete": False}
|
||
record = await pb.first_record("member_applications", filter=f'userId={pb_quote(user["id"])}')
|
||
return {"ok": True, "hasRecord": bool(record), "isComplete": bool(record and record.get("isComplete"))}
|
||
|
||
|
||
@app.post("/api/upload-media")
|
||
async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[str, Any]:
|
||
if file.size and file.size > 20 * 1024 * 1024:
|
||
raise HTTPException(status_code=400, detail="文件大小不能超过 20MB")
|
||
suffix = Path(file.filename or "upload.bin").suffix
|
||
name = f"{uuid.uuid4().hex}{suffix}"
|
||
target = settings.upload_dir / name
|
||
content = await file.read()
|
||
target.write_bytes(content)
|
||
url = f"http://127.0.0.1:8000/uploads/{name}"
|
||
user_id = ""
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else ""
|
||
await pb.create_record(
|
||
"media_assets",
|
||
{
|
||
"userId": user_id,
|
||
"url": url,
|
||
"filename": file.filename or name,
|
||
"contentType": file.content_type or "application/octet-stream",
|
||
"size": len(content),
|
||
},
|
||
)
|
||
return {"ok": True, "url": url}
|
||
|
||
|
||
@app.post("/api/pay", response_model=None)
|
||
async def create_pay(
|
||
response: Response,
|
||
user_id: str = Form(...),
|
||
return_url: str = Form(...),
|
||
total_fee: int = Form(100),
|
||
type: str = Form("meetup"),
|
||
channel: str = Form("wxpay"),
|
||
device: str = Form("pc"),
|
||
html: str = Form("1"),
|
||
) -> HTMLResponse | dict[str, Any]:
|
||
order_id = f"local_{uuid.uuid4().hex[:16]}"
|
||
email = ""
|
||
if user_id.startswith("pending_"):
|
||
try:
|
||
raw_hex = user_id.removeprefix("pending_")
|
||
email = bytes.fromhex(raw_hex).decode("utf-8")
|
||
except Exception:
|
||
email = ""
|
||
await pb.create_record(
|
||
"orders",
|
||
{
|
||
"orderId": order_id,
|
||
"userId": user_id,
|
||
"email": email,
|
||
"totalFee": total_fee,
|
||
"type": type,
|
||
"channel": channel,
|
||
"status": "paid" if settings.dev_auto_pay else "pending",
|
||
"returnUrl": return_url,
|
||
"isNew": user_id.startswith("pending_"),
|
||
},
|
||
)
|
||
cookie_value = f"{order_id}|{int(datetime.now().timestamp() * 1000)}"
|
||
response.set_cookie("join_pay_order", cookie_value, path="/", max_age=900)
|
||
if html == "1":
|
||
redirect_url = f"{return_url}{'&' if '?' in return_url else '?'}order_id={order_id}"
|
||
html_response = HTMLResponse(
|
||
f"""<!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}
|
||
|
||
|
||
@app.get("/api/notifications")
|
||
async def list_notifications(
|
||
request: Request,
|
||
category: str = "all",
|
||
status: str = "active",
|
||
q: str = "",
|
||
muted: str = "include",
|
||
) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
preferences = await notification_preferences_for_user(user_id, user)
|
||
all_items = await decorated_notifications_for_user(user_id, preferences)
|
||
items = all_items
|
||
if muted == "exclude":
|
||
items = [item for item in items if not item.get("muted")]
|
||
elif muted == "only":
|
||
items = [item for item in items if item.get("muted")]
|
||
if category != "all":
|
||
items = [item for item in items if item.get("category") == category]
|
||
if status == "unread":
|
||
items = [item for item in items if not item.get("read") and not item.get("archived") and not item.get("muted")]
|
||
elif status == "read":
|
||
items = [item for item in items if item.get("read") and not item.get("archived")]
|
||
elif status == "archived":
|
||
items = [item for item in items if item.get("archived")]
|
||
else:
|
||
items = [item for item in items if not item.get("archived")]
|
||
if q.strip():
|
||
needle = q.strip().lower()
|
||
items = [
|
||
item
|
||
for item in items
|
||
if needle in str(item.get("title", "")).lower() or needle in str(item.get("body", "")).lower()
|
||
]
|
||
unread = sum(1 for item in all_items if not item.get("read") and not item.get("archived") and not item.get("muted"))
|
||
categories: dict[str, int] = {}
|
||
muted_count = 0
|
||
for item in all_items:
|
||
if not item.get("archived"):
|
||
categories[item.get("category", "system")] = categories.get(item.get("category", "system"), 0) + 1
|
||
if item.get("muted"):
|
||
muted_count += 1
|
||
return {
|
||
"ok": True,
|
||
"items": items,
|
||
"unread": unread,
|
||
"categories": categories,
|
||
"muted": muted_count,
|
||
"preferences": preferences,
|
||
}
|
||
|
||
|
||
@app.get("/api/notifications/unread-count")
|
||
async def notification_unread_count(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
preferences = await notification_preferences_for_user(user_id, user)
|
||
items = await decorated_notifications_for_user(user_id, preferences)
|
||
return {"ok": True, "count": sum(1 for item in items if not item.get("read") and not item.get("archived") and not item.get("muted"))}
|
||
|
||
|
||
@app.post("/api/notifications")
|
||
async def create_notification_endpoint(payload: NotificationCreatePayload, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
target_user_id = payload.targetUserId or (user["id"] if user and payload.audience == "user" else "")
|
||
record = await create_notification(
|
||
title=payload.title,
|
||
body=payload.body,
|
||
category=payload.category,
|
||
priority=payload.priority,
|
||
target_user_id=target_user_id,
|
||
audience=payload.audience,
|
||
action_label=payload.actionLabel,
|
||
action_url=payload.actionUrl,
|
||
entity_type=payload.entityType,
|
||
entity_id=payload.entityId,
|
||
icon=payload.icon,
|
||
channels=payload.channels,
|
||
metadata=payload.metadata,
|
||
)
|
||
if not record:
|
||
raise HTTPException(status_code=500, detail="通知创建失败")
|
||
return {"ok": True, "record": public_record(record)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/read")
|
||
async def mark_notification_read(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
receipt = await upsert_receipt(notification_id, user_id, {"readAt": utc_now()})
|
||
return {"ok": True, "receipt": public_record(receipt)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/unread")
|
||
async def mark_notification_unread(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
receipt = await upsert_receipt(notification_id, user_id, {"readAt": None, "archivedAt": None})
|
||
return {"ok": True, "receipt": public_record(receipt)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/archive")
|
||
async def archive_notification(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": utc_now(), "readAt": utc_now()})
|
||
return {"ok": True, "receipt": public_record(receipt)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/restore")
|
||
async def restore_notification(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": None, "dismissedAt": None})
|
||
return {"ok": True, "receipt": public_record(receipt)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/pin")
|
||
async def pin_notification(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
existing = await receipt_for(notification_id, user_id)
|
||
pinned = not bool(existing and existing.get("pinned"))
|
||
receipt = await upsert_receipt(notification_id, user_id, {"pinned": pinned})
|
||
return {"ok": True, "receipt": public_record(receipt), "pinned": pinned}
|
||
|
||
|
||
@app.post("/api/notifications/mark-all-read")
|
||
async def mark_all_notifications_read(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
preferences = await notification_preferences_for_user(user_id, user)
|
||
items = await decorated_notifications_for_user(user_id, preferences)
|
||
count = 0
|
||
for item in items:
|
||
if not item.get("read") and not item.get("archived") and not item.get("muted"):
|
||
await upsert_receipt(item["id"], user_id, {"readAt": utc_now()})
|
||
count += 1
|
||
return {"ok": True, "count": count}
|
||
|
||
|
||
@app.get("/api/notifications/preferences")
|
||
async def notification_preferences(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
return {"ok": True, "record": await notification_preferences_for_user(user_id, user)}
|
||
|
||
|
||
@app.put("/api/notifications/preferences")
|
||
async def update_notification_preferences(payload: NotificationPreferencePayload, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
record_payload = {
|
||
"userId": user_id,
|
||
"email": user.get("email", "") if user else "",
|
||
**payload.model_dump(),
|
||
}
|
||
existing = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}')
|
||
if existing:
|
||
record = await pb.update_record("notification_preferences", existing["id"], record_payload)
|
||
else:
|
||
record = await pb.create_record("notification_preferences", record_payload)
|
||
return {"ok": True, "record": public_record(record)}
|
||
|
||
|
||
def city_recommendation_score(city: dict[str, Any], payload: RecommendationPayload) -> tuple[float, list[str]]:
|
||
score = float(city.get("overallScore", 0)) * 20
|
||
reasons: list[str] = []
|
||
cost = int(city.get("costPerMonth") or 999999)
|
||
speed = int(city.get("internetSpeed") or 0)
|
||
temp = int(city.get("temperature") or 0)
|
||
tags = city.get("tags") or []
|
||
|
||
if cost <= payload.budget:
|
||
score += 22
|
||
reasons.append("预算内")
|
||
else:
|
||
over_ratio = min(1.0, (cost - payload.budget) / max(payload.budget, 1))
|
||
score -= 18 * over_ratio
|
||
|
||
if speed >= payload.internet:
|
||
score += 18
|
||
reasons.append("网络达标")
|
||
else:
|
||
score -= min(18, payload.internet - speed)
|
||
|
||
if payload.climate == "warm" and temp >= 20:
|
||
score += 8
|
||
reasons.append("偏温暖")
|
||
elif payload.climate == "cool" and temp <= 20:
|
||
score += 8
|
||
reasons.append("偏凉爽")
|
||
elif payload.climate == "mild" and 14 <= temp <= 24:
|
||
score += 10
|
||
reasons.append("气候温和")
|
||
|
||
matched_tags = [tag for tag in payload.tags if tag in tags]
|
||
if matched_tags:
|
||
score += len(matched_tags) * 5
|
||
reasons.append("标签匹配:" + "、".join(matched_tags[:3]))
|
||
|
||
if city.get("nomadsNow", 0) >= 300:
|
||
score += 6
|
||
reasons.append("社区活跃")
|
||
|
||
return round(score, 1), reasons[:4]
|
||
|
||
|
||
@app.post("/api/recommendations/next-stop")
|
||
async def next_stop_recommendation(payload: RecommendationPayload, request: Request) -> dict[str, Any]:
|
||
cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", [])
|
||
scored = []
|
||
for city in cities:
|
||
score, reasons = city_recommendation_score(city, payload)
|
||
scored.append({**public_record(city), "matchScore": score, "matchReasons": reasons})
|
||
scored.sort(key=lambda item: item["matchScore"], reverse=True)
|
||
picks = scored[: max(1, min(payload.limit, 30))]
|
||
|
||
_, user = await current_user(request)
|
||
try:
|
||
await pb.create_record(
|
||
"recommendation_logs",
|
||
{
|
||
"userId": user["id"] if user else "anonymous",
|
||
"budget": payload.budget,
|
||
"internet": payload.internet,
|
||
"climate": payload.climate,
|
||
"tags": payload.tags,
|
||
"resultSlugs": [city.get("slug") for city in picks],
|
||
},
|
||
)
|
||
except PocketBaseError:
|
||
pass
|
||
return {"ok": True, "items": picks}
|
||
|
||
|
||
@app.get("/api/routes")
|
||
async def list_routes() -> dict[str, Any]:
|
||
routes = (await pb.list_records("routes", per_page=100)).get("items", [])
|
||
cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", [])
|
||
city_by_slug = {city.get("slug"): public_record(city) for city in cities}
|
||
items = []
|
||
for route in routes:
|
||
record = public_record(route)
|
||
record["cities"] = [city_by_slug[slug] for slug in route.get("citySlugs", []) if slug in city_by_slug]
|
||
items.append(record)
|
||
return {"ok": True, "items": items}
|
||
|
||
|
||
@app.get("/api/services")
|
||
async def list_services() -> dict[str, Any]:
|
||
data = await pb.list_records("services", filter='status="published"', per_page=100)
|
||
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
|
||
|
||
|
||
@app.post("/api/services/leads")
|
||
async def create_service_lead(payload: ServiceLeadPayload, request: Request) -> dict[str, Any]:
|
||
if not payload.serviceSlug.strip():
|
||
raise HTTPException(status_code=400, detail="服务不能为空")
|
||
service = await pb.first_record("services", filter=f'slug={pb_quote(payload.serviceSlug)}')
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="服务不存在")
|
||
_, user = await current_user(request)
|
||
record = await pb.create_record(
|
||
"service_leads",
|
||
{
|
||
"serviceSlug": payload.serviceSlug,
|
||
"userId": user["id"] if user else "",
|
||
"email": payload.email or (user.get("email", "") if user else ""),
|
||
"note": payload.note,
|
||
"status": "new",
|
||
},
|
||
)
|
||
await create_notification(
|
||
title=f"服务咨询已提交:{service.get('title', payload.serviceSlug)}",
|
||
body="我们已经记录你的服务咨询,后续可以按城市、预算、路线和会员状态继续跟进。",
|
||
category="service",
|
||
priority="normal",
|
||
target_user_id=user["id"] if user else "",
|
||
audience="user" if user else "all",
|
||
action_label="查看服务",
|
||
action_url="/services",
|
||
entity_type="service_lead",
|
||
entity_id=record["id"],
|
||
icon=service.get("icon") or "🧭",
|
||
)
|
||
return {"ok": True, "record": public_record(record), "service": public_record(service)}
|
||
|
||
|
||
@app.get("/api/downloads/{filename}")
|
||
async def download_asset(filename: str) -> FastAPIResponse:
|
||
if filename == "nomad-china-playbook.pdf":
|
||
body = (
|
||
"%PDF-1.4\n"
|
||
"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"
|
||
"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"
|
||
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R >> endobj\n"
|
||
"4 0 obj << /Length 68 >> stream\n"
|
||
"BT /F1 18 Tf 24 88 Td (NomadCNA China Digital Nomad Playbook) Tj ET\n"
|
||
"endstream endobj\n"
|
||
"xref\n0 5\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000204 00000 n \n"
|
||
"trailer << /Root 1 0 R /Size 5 >>\nstartxref\n322\n%%EOF\n"
|
||
).encode("latin-1")
|
||
return FastAPIResponse(
|
||
body,
|
||
media_type="application/pdf",
|
||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||
)
|
||
if filename == "nomadcna-app-demo.zip":
|
||
body = b"NomadCNA App demo package. Replace with real installer artifact later.\n"
|
||
return FastAPIResponse(
|
||
body,
|
||
media_type="application/zip",
|
||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||
)
|
||
raise HTTPException(status_code=404, detail="下载文件不存在")
|
||
|
||
|
||
@app.get("/api/meetups")
|
||
async def list_meetups() -> dict[str, Any]:
|
||
data = await pb.list_records("meetups", sort="date", per_page=100)
|
||
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
|
||
|
||
|
||
@app.post("/api/meetups")
|
||
async def create_meetup(payload: dict[str, Any]) -> dict[str, Any]:
|
||
if payload.get("date") and "T" not in str(payload["date"]):
|
||
payload["date"] = f"{str(payload['date'])[:10]} 00:00:00.000Z"
|
||
payload.setdefault("status", "pending")
|
||
payload.setdefault("rsvpCount", 0)
|
||
payload.setdefault("attendees", [])
|
||
record = await pb.create_record("meetups", payload)
|
||
await create_notification(
|
||
title=f"新活动已提交:{payload.get('city', '同城')} · {payload.get('venue', '活动地点')}",
|
||
body=str(payload.get("description") or "新的同城活动已经进入活动列表,审核后可对外展示。"),
|
||
category="meetup",
|
||
priority="normal",
|
||
audience="all",
|
||
action_label="查看活动",
|
||
action_url="/meetups",
|
||
entity_type="meetup",
|
||
entity_id=record["id"],
|
||
icon=payload.get("emoji") or "🍹",
|
||
)
|
||
return {"ok": True, "record": record}
|
||
|
||
|
||
@app.get("/api/discussions")
|
||
async def list_discussions() -> dict[str, Any]:
|
||
data = await pb.list_records("discussions", per_page=100)
|
||
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
|
||
|
||
|
||
@app.post("/api/discussions")
|
||
async def create_discussion(payload: DiscussionPayload) -> dict[str, Any]:
|
||
if not payload.title.strip():
|
||
raise HTTPException(status_code=400, detail="标题不能为空")
|
||
record = await pb.create_record(
|
||
"discussions",
|
||
{
|
||
**payload.model_dump(),
|
||
"replies": 0,
|
||
"views": 0,
|
||
},
|
||
)
|
||
await create_notification(
|
||
title=f"新讨论:{payload.title}",
|
||
body=payload.excerpt or f"{payload.author} 在 {payload.city or '社区'} 发起了一个新话题。",
|
||
category="community",
|
||
priority="normal",
|
||
audience="all",
|
||
action_label="参与讨论",
|
||
action_url="/discuss",
|
||
entity_type="discussion",
|
||
entity_id=record["id"],
|
||
icon="🔥",
|
||
metadata={"city": payload.city, "tags": payload.tags},
|
||
)
|
||
return {"ok": True, "record": public_record(record)}
|
||
|
||
|
||
@app.get("/api/gigs")
|
||
async def list_gigs() -> dict[str, Any]:
|
||
data = await pb.list_records("gigs", per_page=100)
|
||
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
|
||
|
||
|
||
@app.post("/api/gigs")
|
||
async def create_gig(payload: GigPayload, request: Request) -> dict[str, Any]:
|
||
if not payload.title.strip():
|
||
raise HTTPException(status_code=400, detail="任务标题不能为空")
|
||
_, user = await current_user(request)
|
||
record = await pb.create_record(
|
||
"gigs",
|
||
{
|
||
**payload.model_dump(),
|
||
"status": "open",
|
||
},
|
||
)
|
||
await create_notification(
|
||
title=f"赏金墙新任务:{payload.title}",
|
||
body=f"{payload.location} · {payload.category} · ¥{payload.budget}。{payload.description}",
|
||
category="gig",
|
||
priority="high" if payload.budget >= 1000 else "normal",
|
||
audience="all",
|
||
action_label="查看任务",
|
||
action_url="/gigs",
|
||
entity_type="gig",
|
||
entity_id=record["id"],
|
||
icon="💰",
|
||
)
|
||
return {"ok": True, "record": public_record(record), "user": public_record(user) if user else None}
|
||
|
||
|
||
@app.post("/api/gigs/{gig_id}/apply")
|
||
async def apply_gig(gig_id: str, request: Request) -> dict[str, Any]:
|
||
gig = await pb.first_record("gigs", filter=f'id={pb_quote(gig_id)}')
|
||
if not gig:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
_, user = await current_user(request)
|
||
applicant = user.get("email") if user else "anonymous"
|
||
application = await pb.create_record(
|
||
"gig_applications",
|
||
{
|
||
"gigId": gig_id,
|
||
"userId": user["id"] if user else "",
|
||
"applicant": applicant,
|
||
"message": "用户从赏金墙接单",
|
||
"status": "submitted",
|
||
},
|
||
)
|
||
updated = await pb.update_record("gigs", gig_id, {"status": "in_progress"})
|
||
await create_notification(
|
||
title=f"任务已被接单:{gig.get('title', '赏金任务')}",
|
||
body=f"{applicant} 已接下这个任务,任务状态已进入进行中。",
|
||
category="gig",
|
||
priority="high",
|
||
target_user_id=user["id"] if user else "",
|
||
audience="user" if user else "all",
|
||
action_label="查看赏金墙",
|
||
action_url="/gigs",
|
||
entity_type="gig_application",
|
||
entity_id=application["id"],
|
||
icon="✅",
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"message": "已接单",
|
||
"applicant": applicant,
|
||
"record": public_record(updated),
|
||
"application": public_record(application),
|
||
}
|
||
|
||
|
||
@app.get("/api/profiles")
|
||
async def list_profiles() -> dict[str, Any]:
|
||
data = await pb.list_records("profiles", per_page=100)
|
||
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
|
||
|
||
|
||
@app.post("/api/matches/swipes")
|
||
async def save_swipe(payload: SwipePayload, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
record = await pb.create_record("swipes", {"userId": user_id, "profileId": payload.profileId, "action": payload.action})
|
||
if payload.action in {"like", "right", "superlike"}:
|
||
profile = await pb.first_record("profiles", filter=f'id={pb_quote(payload.profileId)}')
|
||
await create_notification(
|
||
title=f"已喜欢 {profile.get('name', '一位成员') if profile else '一位成员'}",
|
||
body="这条社交动作已经记录,后续可以扩展成互相喜欢、私信和同城约见提醒。",
|
||
category="match",
|
||
priority="normal",
|
||
target_user_id=user_id if user_id != "anonymous" else "",
|
||
audience="user" if user_id != "anonymous" else "all",
|
||
action_label="继续匹配",
|
||
action_url="/dating",
|
||
entity_type="swipe",
|
||
entity_id=record["id"],
|
||
icon="💚",
|
||
)
|
||
return {"ok": True, "record": record}
|
||
|
||
|
||
@app.post("/api/feedback")
|
||
async def feedback(payload: FeedbackPayload) -> dict[str, Any]:
|
||
record = await pb.create_record("feedback", {**payload.model_dump(), "status": "new"})
|
||
await create_notification(
|
||
title=f"反馈已收到:{payload.title}",
|
||
body=payload.content[:220],
|
||
category="system",
|
||
priority="normal",
|
||
audience="all",
|
||
action_label="查看反馈页",
|
||
action_url="/feedback",
|
||
entity_type="feedback",
|
||
entity_id=record["id"],
|
||
icon="💡",
|
||
)
|
||
return {"ok": True, "record": record}
|
||
|
||
|
||
@app.get("/api/stats/member-map")
|
||
async def member_map() -> dict[str, Any]:
|
||
data = await pb.list_records("cities", per_page=100, sort="idNumber")
|
||
items = [
|
||
{
|
||
"id": city.get("idNumber"),
|
||
"name": city.get("name"),
|
||
"nameEn": city.get("nameEn"),
|
||
"emoji": city.get("icon"),
|
||
"lat": city.get("lat"),
|
||
"lng": city.get("lng"),
|
||
"nomadsNow": city.get("nomadsNow"),
|
||
}
|
||
for city in data.get("items", [])
|
||
]
|
||
return {"ok": True, "items": items}
|
||
|
||
|
||
@app.get("/api/stats/overview")
|
||
async def stats_overview() -> dict[str, Any]:
|
||
cities_data = await pb.list_records("cities", per_page=100, sort="idNumber")
|
||
meetups_data = await pb.list_records("meetups", per_page=1)
|
||
discussions_data = await pb.list_records("discussions", per_page=1)
|
||
profiles_data = await pb.list_records("profiles", per_page=1)
|
||
gigs_data = await pb.list_records("gigs", per_page=1)
|
||
cities = cities_data.get("items", [])
|
||
total_nomads = sum(int(city.get("nomadsNow") or 0) for city in cities)
|
||
min_cost = min((int(city.get("costPerMonth") or 0) for city in cities), default=0)
|
||
avg_cost = round(sum(int(city.get("costPerMonth") or 0) for city in cities) / max(len(cities), 1))
|
||
avg_speed = round(sum(int(city.get("internetSpeed") or 0) for city in cities) / max(len(cities), 1))
|
||
return {
|
||
"ok": True,
|
||
"stats": {
|
||
"cities": len(cities),
|
||
"nomads": total_nomads,
|
||
"meetups": meetups_data.get("totalItems", 0),
|
||
"discussions": discussions_data.get("totalItems", 0),
|
||
"profiles": profiles_data.get("totalItems", 0),
|
||
"gigs": gigs_data.get("totalItems", 0),
|
||
"minCost": min_cost,
|
||
"avgCost": avg_cost,
|
||
"avgInternet": avg_speed,
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/api/stats/city-ranking")
|
||
async def city_ranking(metric: str = "overallScore", limit: int = 12) -> dict[str, Any]:
|
||
allowed = {
|
||
"overallScore": ("overallScore", True),
|
||
"cost": ("costPerMonth", False),
|
||
"internet": ("internetSpeed", True),
|
||
"community": ("nomadsNow", True),
|
||
"temperature": ("temperature", True),
|
||
}
|
||
field, desc = allowed.get(metric, allowed["overallScore"])
|
||
cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", [])
|
||
cities.sort(key=lambda city: float(city.get(field) or 0), reverse=desc)
|
||
size = max(1, min(limit, 50))
|
||
return {"ok": True, "items": [public_record(city) for city in cities[:size]], "metric": metric}
|
||
|
||
|
||
@app.get("/api/geo/china")
|
||
async def china_geo() -> dict[str, Any]:
|
||
return {"type": "FeatureCollection", "features": []}
|
||
|
||
|
||
@app.post("/api/ai/assistant")
|
||
async def ai_assistant(payload: dict[str, Any]) -> dict[str, Any]:
|
||
question = str(payload.get("question", "")).lower()
|
||
cities = (await pb.list_records("cities", per_page=100, sort="-overallScore")).get("items", [])
|
||
picks = cities[:3]
|
||
if "便宜" in question or "budget" in question or "预算" in question:
|
||
picks = sorted(cities, key=lambda c: c.get("costPerMonth", 999999))[:3]
|
||
elif "网" in question or "internet" in question:
|
||
picks = sorted(cities, key=lambda c: c.get("internetSpeed", 0), reverse=True)[:3]
|
||
answer = "根据当前城市数据,推荐:" + "、".join(
|
||
f"{c['name']}(¥{c['costPerMonth']}/月,{c['internetSpeed']}Mbps)" for c in picks
|
||
)
|
||
return {"ok": True, "answer": answer, "cities": [public_record(c) for c in picks]}
|