1867 lines
72 KiB
Python
1867 lines
72 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import io
|
||
import re
|
||
import secrets
|
||
import uuid
|
||
import zipfile
|
||
from datetime import datetime, timedelta, timezone
|
||
from html import escape
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import HTMLResponse
|
||
from fastapi.responses import Response as FastAPIResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel, Field
|
||
|
||
from .payment import XorPayProvider, ZPayProvider, provider_for
|
||
from .pb_client import PocketBaseError, pb, pb_quote
|
||
from .settings import get_settings
|
||
|
||
settings = get_settings()
|
||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||
processed_orders: set[str] = set()
|
||
|
||
app = FastAPI(title="NomadCNA Local API", version="0.1.0")
|
||
|
||
|
||
@app.middleware("http")
|
||
async def normalize_double_api_prefix(request: Request, call_next):
|
||
if request.scope.get("path", "").startswith("/api/api/"):
|
||
request.scope["path"] = request.scope["path"][4:]
|
||
raw_path = request.scope.get("raw_path")
|
||
if isinstance(raw_path, bytes) and raw_path.startswith(b"/api/api/"):
|
||
request.scope["raw_path"] = raw_path[4:]
|
||
return await call_next(request)
|
||
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.frontend_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
|
||
|
||
|
||
class AuthPayload(BaseModel):
|
||
email: str
|
||
|
||
|
||
class GoogleAuthPayload(BaseModel):
|
||
id_token: str
|
||
|
||
|
||
class SyncSessionPayload(BaseModel):
|
||
token: str
|
||
record: dict[str, Any]
|
||
|
||
|
||
class EnsureUserPayload(BaseModel):
|
||
email: str
|
||
|
||
|
||
class CheckUserPayload(BaseModel):
|
||
email: str
|
||
|
||
|
||
class CompleteOrderPayload(BaseModel):
|
||
order_id: str
|
||
|
||
|
||
class JoinPayload(BaseModel):
|
||
nickname: str = ""
|
||
gender: str = ""
|
||
single: str = ""
|
||
profession: str = ""
|
||
intro: str = ""
|
||
wechat: str = ""
|
||
education: str = ""
|
||
graduationYear: str = ""
|
||
phone: str = ""
|
||
media: str | None = None
|
||
|
||
|
||
class FeedbackPayload(BaseModel):
|
||
type: str
|
||
email: str = ""
|
||
title: str
|
||
content: str
|
||
|
||
|
||
class SwipePayload(BaseModel):
|
||
profileId: str
|
||
action: str
|
||
|
||
|
||
class DiscussionPayload(BaseModel):
|
||
title: str
|
||
author: str = "NomadCNA"
|
||
city: str = ""
|
||
tags: list[str] = Field(default_factory=list)
|
||
excerpt: str = ""
|
||
|
||
|
||
class GigPayload(BaseModel):
|
||
title: str
|
||
category: str = "任务"
|
||
budget: int = 300
|
||
location: str = "远程"
|
||
description: str = ""
|
||
|
||
|
||
class ServiceLeadPayload(BaseModel):
|
||
serviceSlug: str
|
||
email: str = ""
|
||
note: str = ""
|
||
|
||
|
||
class ContentSubmissionPayload(BaseModel):
|
||
type: str
|
||
title: str
|
||
subtitle: str = ""
|
||
description: str = ""
|
||
coverImage: str = ""
|
||
mediaUrl: str = ""
|
||
authorName: str = ""
|
||
authorEmail: str = ""
|
||
citySlugs: list[str] = Field(default_factory=list)
|
||
chapters: list[dict[str, Any]] = Field(default_factory=list)
|
||
|
||
|
||
class NotificationCreatePayload(BaseModel):
|
||
title: str
|
||
body: str = ""
|
||
category: str = "system"
|
||
priority: str = "normal"
|
||
targetUserId: str = ""
|
||
audience: str = "all"
|
||
actionLabel: str = ""
|
||
actionUrl: str = ""
|
||
entityType: str = ""
|
||
entityId: str = ""
|
||
icon: str = "🔔"
|
||
channels: list[str] = Field(default_factory=lambda: ["in_app"])
|
||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class NotificationPreferencePayload(BaseModel):
|
||
channels: dict[str, bool] = Field(default_factory=dict)
|
||
categories: dict[str, bool] = Field(default_factory=dict)
|
||
quietHoursStart: str = "22:00"
|
||
quietHoursEnd: str = "08:00"
|
||
digest: str = "daily"
|
||
allowMarketing: bool = False
|
||
|
||
|
||
class RecommendationPayload(BaseModel):
|
||
budget: int = 6000
|
||
internet: int = 50
|
||
climate: str = "any"
|
||
tags: list[str] = Field(default_factory=list)
|
||
limit: int = 12
|
||
|
||
|
||
def set_session_cookie(response: Response, token: str) -> None:
|
||
response.set_cookie(
|
||
settings.cookie_name,
|
||
token,
|
||
httponly=True,
|
||
samesite="lax",
|
||
secure=False,
|
||
max_age=60 * 60 * 24 * 30,
|
||
path="/",
|
||
)
|
||
|
||
|
||
def clear_session_cookie(response: Response) -> None:
|
||
response.delete_cookie(settings.cookie_name, path="/")
|
||
|
||
|
||
def token_from_request(request: Request) -> str | None:
|
||
auth = request.headers.get("authorization", "")
|
||
if auth.lower().startswith("bearer "):
|
||
return auth.split(" ", 1)[1].strip()
|
||
return request.cookies.get(settings.cookie_name)
|
||
|
||
|
||
async def current_user(request: Request) -> tuple[str, dict[str, Any]] | tuple[None, None]:
|
||
token = token_from_request(request)
|
||
if not token:
|
||
return None, None
|
||
try:
|
||
data = await pb.request("POST", "/api/collections/users/auth-refresh", token=token)
|
||
return data.get("token") or token, data.get("record")
|
||
except Exception:
|
||
return None, None
|
||
|
||
|
||
async def find_user_by_email(email: str) -> dict[str, Any] | None:
|
||
return await pb.first_record("users", filter=f"email={pb_quote(email.lower())}")
|
||
|
||
|
||
async def find_membership(user_id: str) -> dict[str, Any] | None:
|
||
membership = await pb.first_record(
|
||
"memberships",
|
||
filter=f'userId={pb_quote(user_id)} && status="active"',
|
||
)
|
||
if membership:
|
||
return membership
|
||
try:
|
||
site_vip = await pb.first_record(
|
||
"site_vip",
|
||
filter=f'user_id={pb_quote(user_id)} && site_id={pb_quote(settings.site_id)}',
|
||
)
|
||
if site_vip and not site_vip.get("expires_at"):
|
||
return site_vip
|
||
if site_vip and str(site_vip.get("expires_at")) > utc_now():
|
||
return site_vip
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def internal_auth_password() -> str:
|
||
password = settings.email_auth_password.strip()
|
||
return password if len(password) >= 8 else "NomadCNAEmailLogin2026!"
|
||
|
||
|
||
async def ensure_profile_for_user(
|
||
user: dict[str, Any],
|
||
*,
|
||
email: str,
|
||
name: str = "",
|
||
photo: str = "",
|
||
) -> None:
|
||
profile_payload = {
|
||
"userId": user["id"],
|
||
"email": email,
|
||
"name": name or user.get("name") or email.split("@")[0],
|
||
"age": 0,
|
||
"location": "",
|
||
"tags": ["新成员"],
|
||
"bio": "",
|
||
"photo": photo or f"https://api.dicebear.com/7.x/initials/svg?seed={email}",
|
||
}
|
||
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
|
||
if profile:
|
||
await pb.update_record("profiles", profile["id"], profile_payload)
|
||
else:
|
||
await pb.create_record("profiles", profile_payload)
|
||
|
||
|
||
async def login_with_internal_password(email: str) -> dict[str, Any]:
|
||
return await pb.request(
|
||
"POST",
|
||
"/api/collections/users/auth-with-password",
|
||
json={"identity": email, "password": internal_auth_password()},
|
||
)
|
||
|
||
|
||
async def ensure_user(
|
||
email: str,
|
||
*,
|
||
name: str = "",
|
||
photo: str = "",
|
||
) -> tuple[dict[str, Any], str, bool]:
|
||
normalized = email.lower().strip()
|
||
if "@" not in normalized or "." not in normalized.split("@")[-1]:
|
||
raise HTTPException(status_code=400, detail="请输入有效邮箱")
|
||
existing = await find_user_by_email(normalized)
|
||
if existing:
|
||
update_payload: dict[str, Any] = {
|
||
"password": internal_auth_password(),
|
||
"passwordConfirm": internal_auth_password(),
|
||
}
|
||
if name and not existing.get("name"):
|
||
update_payload["name"] = name
|
||
await pb.update_record("users", existing["id"], update_payload)
|
||
try:
|
||
auth = await login_with_internal_password(normalized)
|
||
except Exception:
|
||
raise HTTPException(status_code=401, detail="邮箱登录失败")
|
||
await ensure_profile_for_user(auth["record"], email=normalized, name=name, photo=photo)
|
||
return auth["record"], auth["token"], False
|
||
|
||
created = await pb.create_record(
|
||
"users",
|
||
{
|
||
"email": normalized,
|
||
"emailVisibility": True,
|
||
"verified": True,
|
||
"password": internal_auth_password(),
|
||
"passwordConfirm": internal_auth_password(),
|
||
"name": name or normalized.split("@")[0],
|
||
},
|
||
)
|
||
await ensure_profile_for_user(created, email=normalized, name=name, photo=photo)
|
||
auth = await login_with_internal_password(normalized)
|
||
return auth["record"], auth["token"], True
|
||
|
||
|
||
async def verify_google_id_token(id_token: str) -> dict[str, str]:
|
||
if not settings.google_client_id:
|
||
raise HTTPException(status_code=400, detail="未配置 Google 登录")
|
||
token = id_token.strip()
|
||
if not token:
|
||
raise HTTPException(status_code=400, detail="缺少 Google token")
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client:
|
||
res = await client.get("https://oauth2.googleapis.com/tokeninfo", params={"id_token": token})
|
||
data = res.json() if res.is_success else {}
|
||
except Exception:
|
||
raise HTTPException(status_code=401, detail="Google 登录验证失败")
|
||
if data.get("aud") != settings.google_client_id:
|
||
raise HTTPException(status_code=401, detail="Google 登录来源不匹配")
|
||
verified = data.get("email_verified")
|
||
if verified not in {True, "true", "1", 1}:
|
||
raise HTTPException(status_code=401, detail="Google 邮箱未验证")
|
||
email = str(data.get("email") or "").strip().lower()
|
||
if not email:
|
||
raise HTTPException(status_code=401, detail="Google 账号缺少邮箱")
|
||
return {
|
||
"email": email,
|
||
"name": str(data.get("name") or email.split("@")[0]),
|
||
"photo": str(data.get("picture") or ""),
|
||
}
|
||
|
||
|
||
def public_record(record: dict[str, Any]) -> dict[str, Any]:
|
||
return {k: v for k, v in record.items() if not k.startswith("@")}
|
||
|
||
|
||
def utc_now() -> str:
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z")
|
||
|
||
|
||
def clean_submission_text(value: Any, limit: int, *, multiline: bool = False) -> str:
|
||
text = str(value or "").strip()
|
||
if not multiline:
|
||
text = re.sub(r"\s+", " ", text)
|
||
else:
|
||
text = "\n".join(line.strip() for line in text.splitlines()).strip()
|
||
return text[:limit]
|
||
|
||
|
||
def slugify_content_title(value: str) -> str:
|
||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||
return slug[:70].strip("-") or "submission"
|
||
|
||
|
||
def validate_submission_url(value: str, field: str) -> str:
|
||
url = str(value or "").strip()
|
||
if not url:
|
||
raise HTTPException(status_code=400, detail=f"缺少{field}")
|
||
if len(url) > 500:
|
||
raise HTTPException(status_code=400, detail=f"{field}链接过长")
|
||
if not (url.startswith("https://") or url.startswith("http://") or url.startswith("/")):
|
||
raise HTTPException(status_code=400, detail=f"{field}必须是有效链接")
|
||
return url
|
||
|
||
|
||
def normalize_submission_chapters(chapters: list[dict[str, Any]], content_type: str) -> list[dict[str, Any]]:
|
||
normalized: list[dict[str, Any]] = []
|
||
for chapter in chapters[:30]:
|
||
title = clean_submission_text(chapter.get("title", ""), 160)
|
||
if not title:
|
||
continue
|
||
item: dict[str, Any] = {"title": title}
|
||
if content_type == "video":
|
||
time = clean_submission_text(chapter.get("time", "") or chapter.get("minutes", ""), 20)
|
||
if time:
|
||
item["time"] = time
|
||
else:
|
||
try:
|
||
minutes = int(chapter.get("minutes") or 8)
|
||
except Exception:
|
||
minutes = 8
|
||
item["minutes"] = max(1, min(minutes, 999))
|
||
normalized.append(item)
|
||
return normalized
|
||
|
||
|
||
def parse_order_id(order_id: str) -> tuple[str, str]:
|
||
if "_order_" not in order_id:
|
||
return "", "unknown"
|
||
prefix = order_id.split("_order_", 1)[0]
|
||
if "_" not in prefix:
|
||
return prefix, "unknown"
|
||
user_id, pay_type = prefix.rsplit("_", 1)
|
||
return user_id, pay_type.lower()
|
||
|
||
|
||
def decode_pending_email(user_id: str) -> str | None:
|
||
if not user_id.startswith("pending_"):
|
||
return None
|
||
try:
|
||
return bytes.fromhex(user_id.removeprefix("pending_")).decode("utf-8")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def client_ip_from_request(request: Request) -> str:
|
||
forwarded = request.headers.get("x-forwarded-for") or ""
|
||
if forwarded:
|
||
return forwarded.split(",", 1)[0].strip()
|
||
real_ip = request.headers.get("x-real-ip")
|
||
if real_ip:
|
||
return real_ip.strip()
|
||
return request.client.host if request.client else ""
|
||
|
||
|
||
def append_order_id(return_url: str, order_id: str) -> str:
|
||
if not return_url:
|
||
return f"/zh/join/paid?order_id={order_id}"
|
||
if "order_id=" in return_url or "out_trade_no=" in return_url:
|
||
return return_url
|
||
sep = "&" if "?" in return_url else "?"
|
||
return f"{return_url}{sep}order_id={order_id}"
|
||
|
||
|
||
def payment_name(pay_type: str) -> str:
|
||
return {
|
||
"book": "购买书籍",
|
||
"meetup": "数字游民社区报名",
|
||
"salon": "沙龙报名茶歇费",
|
||
"video": "视频解锁",
|
||
"vip": "VIP会员充值",
|
||
}.get(pay_type, "商品支付")
|
||
|
||
|
||
def set_pay_order_cookie(response: Response, order_id: str) -> None:
|
||
response.set_cookie("join_pay_order", f"{order_id}|{int(datetime.now().timestamp() * 1000)}", path="/", max_age=900)
|
||
|
||
|
||
def build_auto_submit_html(pay_url: str, params: dict[str, Any], order_id: str) -> str:
|
||
inputs = "\n".join(
|
||
f'<input type="hidden" name="{escape(str(key), quote=True)}" value="{escape(str(value), quote=True)}" />'
|
||
for key, value in params.items()
|
||
if value is not None and str(value).strip() != ""
|
||
)
|
||
order_value = escape(f"{order_id}|{int(datetime.now().timestamp() * 1000)}", quote=True)
|
||
return f"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title></head>
|
||
<body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;color:#64748b">
|
||
<p>正在跳转到支付页面...</p>
|
||
<form id="payForm" method="POST" action="{escape(pay_url, quote=True)}">{inputs}</form>
|
||
<script>
|
||
try{{localStorage.setItem("join_pay_order","{order_value}");}}catch(e){{}}
|
||
document.cookie="join_pay_order="+encodeURIComponent("{order_value}")+"; path=/; max-age=900";
|
||
document.getElementById("payForm").submit();
|
||
</script></body></html>"""
|
||
|
||
|
||
def build_redirect_html(pay_url: str, order_id: str) -> str:
|
||
order_value = escape(f"{order_id}|{int(datetime.now().timestamp() * 1000)}", quote=True)
|
||
return f"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title></head>
|
||
<body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;color:#64748b">
|
||
<p>正在跳转到支付页面...</p>
|
||
<script>
|
||
try{{localStorage.setItem("join_pay_order","{order_value}");}}catch(e){{}}
|
||
document.cookie="join_pay_order="+encodeURIComponent("{order_value}")+"; path=/; max-age=900";
|
||
location.href={pay_url!r};
|
||
</script></body></html>"""
|
||
|
||
|
||
def build_qr_html(img_src: str, return_url: str, order_id: str) -> str:
|
||
order_value = escape(f"{order_id}|{int(datetime.now().timestamp() * 1000)}", quote=True)
|
||
return f"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>微信扫码支付</title>
|
||
<style>body{{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:linear-gradient(135deg,#667eea,#764ba2)}}.card{{background:#fff;border-radius:20px;padding:36px;text-align:center;box-shadow:0 20px 50px rgba(0,0,0,.25)}}img{{width:220px;height:220px;border-radius:12px;border:1px solid #e2e8f0}}p{{color:#64748b}}</style></head>
|
||
<body><div class="card"><h2>微信扫码支付</h2><p id="tip">请使用微信扫描二维码完成支付</p><img src="{escape(img_src, quote=True)}" alt="支付二维码" /></div>
|
||
<script>
|
||
try{{localStorage.setItem("join_pay_order","{order_value}");}}catch(e){{}}
|
||
document.cookie="join_pay_order="+encodeURIComponent("{order_value}")+"; path=/; max-age=900";
|
||
var orderId={order_id!r};var returnUrl={return_url!r};
|
||
function poll(){{fetch("/api/pay/status?order_id="+encodeURIComponent(orderId),{{cache:"no-store"}}).then(function(r){{return r.json()}}).then(function(d){{if(d&&d.paid){{document.getElementById("tip").textContent="支付成功,正在返回...";location.href=returnUrl;return;}}setTimeout(poll,1000);}}).catch(function(){{setTimeout(poll,1500);}})}}
|
||
setTimeout(poll,1000);
|
||
</script></body></html>"""
|
||
|
||
|
||
async def find_order(order_id: str) -> dict[str, Any] | None:
|
||
return await pb.first_record("orders", filter=f'orderId={pb_quote(order_id)}')
|
||
|
||
|
||
async def upsert_order_record(
|
||
*,
|
||
order_id: str,
|
||
user_id: str,
|
||
email: str = "",
|
||
total_fee: int = 0,
|
||
pay_type: str = "meetup",
|
||
channel: str = "wxpay",
|
||
status: str = "pending",
|
||
return_url: str = "",
|
||
) -> dict[str, Any] | None:
|
||
payload = {
|
||
"orderId": order_id,
|
||
"userId": user_id,
|
||
"email": email,
|
||
"totalFee": total_fee,
|
||
"type": pay_type,
|
||
"channel": channel,
|
||
"status": status,
|
||
"returnUrl": return_url,
|
||
"isNew": user_id.startswith("pending_"),
|
||
}
|
||
existing = await find_order(order_id)
|
||
if existing:
|
||
return await pb.update_record("orders", existing["id"], payload)
|
||
return await pb.create_record("orders", payload)
|
||
|
||
|
||
async def mark_order_paid(order_id: str, pay_price: str = "") -> dict[str, Any] | None:
|
||
order = await find_order(order_id)
|
||
if order:
|
||
return await pb.update_record("orders", order["id"], {"status": "paid"})
|
||
user_id, pay_type = parse_order_id(order_id)
|
||
total_fee = int(float(pay_price or 0) * 100) if pay_price else 0
|
||
email = decode_pending_email(user_id) or ""
|
||
return await upsert_order_record(
|
||
order_id=order_id,
|
||
user_id=user_id,
|
||
email=email,
|
||
total_fee=total_fee,
|
||
pay_type=pay_type,
|
||
status="paid",
|
||
)
|
||
|
||
|
||
async def query_gateway_paid(order_id: str) -> tuple[bool, str]:
|
||
zpay, xorpay = await asyncio.gather(
|
||
ZPayProvider().query_order_status(order_id),
|
||
XorPayProvider().query_order_status(order_id),
|
||
)
|
||
for result in (zpay, xorpay):
|
||
if result.get("paid"):
|
||
return True, str(result.get("pay_price") or "")
|
||
return False, ""
|
||
|
||
|
||
async def ensure_site_vip(user_id: str, order_id: str) -> None:
|
||
try:
|
||
payload = {"user_id": user_id, "site_id": settings.site_id, "order_id": order_id, "expires_at": ""}
|
||
existing = await pb.first_record("site_vip", filter=f'user_id={pb_quote(user_id)} && site_id={pb_quote(settings.site_id)}')
|
||
if existing:
|
||
await pb.update_record("site_vip", existing["id"], payload)
|
||
else:
|
||
await pb.create_record("site_vip", payload)
|
||
except Exception:
|
||
return
|
||
|
||
|
||
async def ensure_paid_membership(user: dict[str, Any], order_id: str) -> None:
|
||
expires = (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S.000Z")
|
||
payload_membership = {
|
||
"userId": user["id"],
|
||
"email": user.get("email", ""),
|
||
"plan": "pro",
|
||
"status": "active",
|
||
"expiresAt": expires,
|
||
}
|
||
membership = await pb.first_record("memberships", filter=f'userId={pb_quote(user["id"])}')
|
||
if membership:
|
||
await pb.update_record("memberships", membership["id"], payload_membership)
|
||
else:
|
||
await pb.create_record("memberships", payload_membership)
|
||
await ensure_site_vip(user["id"], order_id)
|
||
|
||
|
||
async def record_payment(user: dict[str, Any], order: dict[str, Any], order_id: str, pay_price: str = "") -> None:
|
||
try:
|
||
amount = int(float(pay_price or 0) * 100) if pay_price else int(order.get("totalFee") or 0)
|
||
existing = await pb.first_record("payments", filter=f'order_id={pb_quote(order_id)}')
|
||
payload = {
|
||
"user_id": user["id"],
|
||
"type": order.get("type") or parse_order_id(order_id)[1],
|
||
"order_id": order_id,
|
||
"amount": amount,
|
||
"total_fee": amount,
|
||
"channel": order.get("channel") or "wxpay",
|
||
}
|
||
if existing:
|
||
await pb.update_record("payments", existing["id"], payload)
|
||
else:
|
||
await pb.create_record("payments", payload)
|
||
except Exception:
|
||
return
|
||
|
||
|
||
async def user_for_paid_order(order: dict[str, Any], order_id: str) -> tuple[dict[str, Any] | None, str | None, bool]:
|
||
user_id = order.get("userId") or parse_order_id(order_id)[0]
|
||
if user_id.startswith("pending_"):
|
||
email = order.get("email") or decode_pending_email(user_id)
|
||
if not email:
|
||
return None, None, False
|
||
user, token, is_new = await ensure_user(email)
|
||
return user, token, is_new
|
||
if not user_id:
|
||
return None, None, False
|
||
user = await pb.first_record("users", filter=f'id={pb_quote(user_id)}')
|
||
return user, None, False
|
||
|
||
|
||
async def finalize_paid_order(order_id: str, pay_price: str = "") -> tuple[dict[str, Any] | None, str | None, bool]:
|
||
if not order_id:
|
||
return None, None, False
|
||
order = await mark_order_paid(order_id, pay_price)
|
||
if not order:
|
||
return None, None, False
|
||
user, token, is_new = await user_for_paid_order(order, order_id)
|
||
if user:
|
||
await record_payment(user, order, order_id, pay_price)
|
||
await ensure_paid_membership(user, order_id)
|
||
processed_orders.add(order_id)
|
||
return user, token, is_new
|
||
|
||
|
||
def default_notification_preferences(user_id: str, email: str = "") -> dict[str, Any]:
|
||
return {
|
||
"userId": user_id,
|
||
"email": email,
|
||
"channels": {"in_app": True, "email": False, "wechat": False},
|
||
"categories": {
|
||
"system": True,
|
||
"meetup": True,
|
||
"gig": True,
|
||
"match": True,
|
||
"service": True,
|
||
"community": True,
|
||
"city": True,
|
||
},
|
||
"quietHoursStart": "22:00",
|
||
"quietHoursEnd": "08:00",
|
||
"digest": "daily",
|
||
"allowMarketing": False,
|
||
}
|
||
|
||
|
||
def normalize_notification_preferences(record: dict[str, Any] | None, user_id: str, email: str = "") -> dict[str, Any]:
|
||
defaults = default_notification_preferences(user_id, email)
|
||
if not record:
|
||
return defaults
|
||
public = public_record(record)
|
||
return {
|
||
**defaults,
|
||
**public,
|
||
"channels": {**defaults["channels"], **(public.get("channels") or {})},
|
||
"categories": {**defaults["categories"], **(public.get("categories") or {})},
|
||
}
|
||
|
||
|
||
async def notification_preferences_for_user(user_id: str, user: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
record = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}')
|
||
return normalize_notification_preferences(record, user_id, user.get("email", "") if user else "")
|
||
|
||
|
||
def notification_is_muted(item: dict[str, Any], preferences: dict[str, Any]) -> bool:
|
||
category = item.get("category") or "system"
|
||
categories = preferences.get("categories") or {}
|
||
channels = preferences.get("channels") or {}
|
||
item_channels = item.get("channels") or ["in_app"]
|
||
category_enabled = bool(categories.get(category, True))
|
||
channel_enabled = any(bool(channels.get(channel, False)) for channel in item_channels)
|
||
return not category_enabled or not channel_enabled
|
||
|
||
|
||
async def create_notification(
|
||
*,
|
||
title: str,
|
||
body: str = "",
|
||
category: str = "system",
|
||
priority: str = "normal",
|
||
target_user_id: str = "",
|
||
audience: str = "all",
|
||
action_label: str = "",
|
||
action_url: str = "",
|
||
entity_type: str = "",
|
||
entity_id: str = "",
|
||
icon: str = "🔔",
|
||
channels: list[str] | None = None,
|
||
metadata: dict[str, Any] | None = None,
|
||
) -> dict[str, Any] | None:
|
||
try:
|
||
return await pb.create_record(
|
||
"notifications",
|
||
{
|
||
"title": title,
|
||
"body": body,
|
||
"category": category,
|
||
"priority": priority,
|
||
"targetUserId": target_user_id,
|
||
"audience": audience,
|
||
"actionLabel": action_label,
|
||
"actionUrl": action_url,
|
||
"entityType": entity_type,
|
||
"entityId": entity_id,
|
||
"icon": icon,
|
||
"status": "published",
|
||
"channels": channels or ["in_app"],
|
||
"metadata": metadata or {},
|
||
},
|
||
)
|
||
except PocketBaseError:
|
||
return None
|
||
|
||
|
||
async def receipt_for(notification_id: str, user_id: str) -> dict[str, Any] | None:
|
||
if not user_id:
|
||
return None
|
||
return await pb.first_record(
|
||
"notification_receipts",
|
||
filter=f'notificationId={pb_quote(notification_id)} && userId={pb_quote(user_id)}',
|
||
)
|
||
|
||
|
||
async def upsert_receipt(notification_id: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||
existing = await receipt_for(notification_id, user_id)
|
||
base = {"notificationId": notification_id, "userId": user_id, "pinned": False}
|
||
if existing:
|
||
return await pb.update_record("notification_receipts", existing["id"], payload)
|
||
return await pb.create_record("notification_receipts", {**base, **payload})
|
||
|
||
|
||
async def decorated_notifications_for_user(
|
||
user_id: str,
|
||
preferences: dict[str, Any] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
preferences = preferences or await notification_preferences_for_user(user_id)
|
||
data = await pb.list_records("notifications", filter='status="published"', per_page=100)
|
||
notifications = [
|
||
public_record(item)
|
||
for item in data.get("items", [])
|
||
if item.get("audience", "all") == "all" or item.get("targetUserId") == user_id
|
||
]
|
||
receipts = (await pb.list_records("notification_receipts", filter=f'userId={pb_quote(user_id)}', per_page=200)).get("items", [])
|
||
receipt_by_notification = {receipt.get("notificationId"): public_record(receipt) for receipt in receipts}
|
||
decorated = []
|
||
for item in notifications:
|
||
receipt = receipt_by_notification.get(item["id"], {})
|
||
archived = bool(receipt.get("archivedAt"))
|
||
decorated.append(
|
||
{
|
||
**item,
|
||
"receipt": receipt,
|
||
"read": bool(receipt.get("readAt")),
|
||
"archived": archived,
|
||
"pinned": bool(receipt.get("pinned")),
|
||
"muted": notification_is_muted(item, preferences),
|
||
}
|
||
)
|
||
decorated.sort(key=lambda item: (item.get("pinned", False), item.get("created", "")), reverse=True)
|
||
return decorated
|
||
|
||
|
||
@app.get("/api/health")
|
||
async def health() -> dict[str, Any]:
|
||
return {"ok": True, "service": "NomadCNA FastAPI", "pb": settings.pb_url}
|
||
|
||
|
||
@app.post("/api/auth/email")
|
||
async def email_login(payload: AuthPayload, response: Response) -> dict[str, Any]:
|
||
user, token, is_new = await ensure_user(payload.email)
|
||
set_session_cookie(response, token)
|
||
return {"ok": True, "token": token, "record": user, "user": user, "is_new": is_new}
|
||
|
||
|
||
@app.post("/api/auth/google")
|
||
async def google_login(payload: GoogleAuthPayload, response: Response) -> dict[str, Any]:
|
||
profile = await verify_google_id_token(payload.id_token)
|
||
user, token, is_new = await ensure_user(profile["email"], name=profile["name"], photo=profile["photo"])
|
||
set_session_cookie(response, token)
|
||
return {"ok": True, "token": token, "record": user, "user": user, "is_new": is_new, "provider": "google"}
|
||
|
||
|
||
@app.post("/api/auth/sync-session")
|
||
async def sync_session(payload: SyncSessionPayload, response: Response) -> dict[str, Any]:
|
||
set_session_cookie(response, payload.token)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/api/auth/me")
|
||
async def me(request: Request, response: Response) -> dict[str, Any]:
|
||
token, user = await current_user(request)
|
||
if not user:
|
||
return {"ok": True, "user": None}
|
||
set_session_cookie(response, token)
|
||
vip = bool(await find_membership(user["id"]))
|
||
return {"ok": True, "token": token, "user": {**user, "vip": vip}}
|
||
|
||
|
||
@app.post("/api/auth/logout")
|
||
async def logout(response: Response) -> dict[str, Any]:
|
||
clear_session_cookie(response)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/meetup/check-user")
|
||
async def check_user(payload: CheckUserPayload) -> dict[str, Any]:
|
||
user = await find_user_by_email(str(payload.email))
|
||
vip = bool(await find_membership(user["id"])) if user else False
|
||
return {"ok": True, "exists": bool(user), "vip": vip, "user_id": user["id"] if user else None}
|
||
|
||
|
||
@app.post("/api/meetup/ensure-user")
|
||
async def ensure_user_endpoint(payload: EnsureUserPayload, response: Response) -> dict[str, Any]:
|
||
user, token, is_new = await ensure_user(str(payload.email))
|
||
set_session_cookie(response, token)
|
||
vip = bool(await find_membership(user["id"]))
|
||
return {"ok": True, "user_id": user["id"], "record": user, "token": token, "is_new": is_new, "vip": vip}
|
||
|
||
|
||
@app.post("/api/meetup/complete-order")
|
||
async def complete_order(payload: CompleteOrderPayload, response: Response) -> dict[str, Any]:
|
||
order_id = payload.order_id.strip()
|
||
if not order_id:
|
||
raise HTTPException(status_code=400, detail="缺少 order_id")
|
||
order = await find_order(order_id)
|
||
if not order:
|
||
paid, pay_price = await query_gateway_paid(order_id)
|
||
if not paid and not settings.dev_auto_pay:
|
||
raise HTTPException(status_code=400, detail="订单不存在或未支付成功")
|
||
order = await mark_order_paid(order_id, pay_price)
|
||
elif order.get("status") != "paid":
|
||
paid, pay_price = await query_gateway_paid(order_id)
|
||
if not paid and not settings.dev_auto_pay:
|
||
raise HTTPException(status_code=400, detail="订单未支付成功")
|
||
order = await mark_order_paid(order_id, pay_price)
|
||
if not order:
|
||
raise HTTPException(status_code=500, detail="订单处理失败")
|
||
|
||
user, token, is_new = await finalize_paid_order(order_id)
|
||
if not user:
|
||
raise HTTPException(status_code=500, detail="会员开通失败")
|
||
if token:
|
||
set_session_cookie(response, token)
|
||
return {"ok": True, "token": token, "record": user, "is_new": is_new}
|
||
|
||
|
||
@app.get("/api/vip/check")
|
||
async def vip_check(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
if not user:
|
||
return {"ok": True, "vip": False}
|
||
membership = await find_membership(user["id"])
|
||
return {"ok": True, "vip": bool(membership), "membership": membership}
|
||
|
||
|
||
@app.post("/api/join")
|
||
async def join(payload: JoinPayload, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
if not user:
|
||
raise HTTPException(status_code=401, detail="请先登录")
|
||
email = user.get("email", "")
|
||
record_payload = {
|
||
**payload.model_dump(),
|
||
"userId": user["id"],
|
||
"email": email,
|
||
"isComplete": True,
|
||
}
|
||
existing = await pb.first_record("member_applications", filter=f'userId={pb_quote(user["id"])}')
|
||
if existing:
|
||
record = await pb.update_record("member_applications", existing["id"], record_payload)
|
||
else:
|
||
record = await pb.create_record("member_applications", record_payload)
|
||
profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}')
|
||
profile_payload = {
|
||
"userId": user["id"],
|
||
"email": email,
|
||
"name": payload.nickname or user.get("name") or email.split("@")[0],
|
||
"location": "",
|
||
"tags": [payload.profession, payload.education, "数字游民"],
|
||
"bio": payload.intro,
|
||
"photo": payload.media or f"https://api.dicebear.com/7.x/initials/svg?seed={email}",
|
||
}
|
||
if profile:
|
||
await pb.update_record("profiles", profile["id"], profile_payload)
|
||
else:
|
||
await pb.create_record("profiles", profile_payload)
|
||
await create_notification(
|
||
title="成员资料已更新",
|
||
body="你的加入资料已经保存,后续匹配、活动推荐和城市建议会基于这份资料联动。",
|
||
category="system",
|
||
priority="normal",
|
||
target_user_id=user["id"],
|
||
audience="user",
|
||
action_label="查看资料",
|
||
action_url="/join",
|
||
entity_type="member_application",
|
||
entity_id=record["id"],
|
||
icon="👤",
|
||
)
|
||
return {"ok": True, "record": record, "user_id": user["id"]}
|
||
|
||
|
||
@app.get("/api/join/status")
|
||
async def join_status(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
if not user:
|
||
return {"ok": True, "hasRecord": False, "isComplete": False}
|
||
record = await pb.first_record("member_applications", filter=f'userId={pb_quote(user["id"])}')
|
||
return {"ok": True, "hasRecord": bool(record), "isComplete": bool(record and record.get("isComplete"))}
|
||
|
||
|
||
@app.post("/api/upload-media")
|
||
async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[str, Any]:
|
||
purpose = (request.query_params.get("purpose") or "").lower()
|
||
is_content_upload = purpose in {"content", "ebook", "video", "submission"}
|
||
max_size = 200 * 1024 * 1024 if is_content_upload else 20 * 1024 * 1024
|
||
if file.size and file.size > max_size:
|
||
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_size // 1024 // 1024}MB")
|
||
suffix = Path(file.filename or "upload.bin").suffix
|
||
name = f"{uuid.uuid4().hex}{suffix}"
|
||
target = settings.upload_dir / name
|
||
total = 0
|
||
try:
|
||
with target.open("wb") as out:
|
||
while True:
|
||
chunk = await file.read(1024 * 1024)
|
||
if not chunk:
|
||
break
|
||
total += len(chunk)
|
||
if total > max_size:
|
||
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_size // 1024 // 1024}MB")
|
||
out.write(chunk)
|
||
except Exception:
|
||
target.unlink(missing_ok=True)
|
||
raise
|
||
url = f"{settings.upload_public_base_url}/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": total,
|
||
},
|
||
)
|
||
return {"ok": True, "url": url}
|
||
|
||
|
||
@app.post("/api/pay", response_model=None)
|
||
async def create_pay(request: Request, response: Response) -> HTMLResponse | dict[str, Any]:
|
||
content_type = request.headers.get("content-type", "")
|
||
if "application/json" in content_type:
|
||
body = await request.json()
|
||
else:
|
||
form = await request.form()
|
||
body = dict(form)
|
||
|
||
user_id = str(body.get("user_id") or "").strip()
|
||
if not user_id:
|
||
raise HTTPException(status_code=400, detail="缺少 user_id")
|
||
pay_type = str(body.get("type") or settings.payment_join_type or "meetup").lower()
|
||
try:
|
||
total_fee = int(float(body.get("total_fee") or 0))
|
||
except (TypeError, ValueError):
|
||
total_fee = 0
|
||
if total_fee <= 0:
|
||
total_fee = settings.payment_join_amount if pay_type == settings.payment_join_type else settings.payment_default_amount
|
||
channel = str(body.get("channel") or "wxpay")
|
||
device = str(body.get("device") or "pc")
|
||
requested_provider = str(body.get("provider") or "")
|
||
want_html = str(body.get("html") or "0") == "1" or "text/html" in (request.headers.get("accept") or "")
|
||
order_id = f"{user_id}_{pay_type}_order_{int(datetime.now().timestamp())}_{secrets.token_hex(4)}"
|
||
return_url = append_order_id(str(body.get("return_url") or "/zh/join/paid"), order_id)
|
||
email = decode_pending_email(user_id) or ""
|
||
await upsert_order_record(
|
||
order_id=order_id,
|
||
user_id=user_id,
|
||
email=email,
|
||
total_fee=total_fee,
|
||
pay_type=pay_type,
|
||
channel=channel,
|
||
status="paid" if settings.dev_auto_pay else "pending",
|
||
return_url=return_url,
|
||
)
|
||
set_pay_order_cookie(response, order_id)
|
||
|
||
if settings.dev_auto_pay:
|
||
redirect_url = return_url
|
||
html_response = HTMLResponse(build_redirect_html(redirect_url, order_id))
|
||
set_pay_order_cookie(html_response, order_id)
|
||
return html_response if want_html else {"ok": True, "order_id": order_id, "paid": True}
|
||
|
||
amount_yuan = f"{total_fee / 100:.2f}"
|
||
provider = provider_for(device, requested_provider, request.headers.get("user-agent", ""))
|
||
notify_url = f"{settings.base_url}/api/pay/{provider.name}_notify"
|
||
name = payment_name(pay_type)
|
||
client_ip = client_ip_from_request(request)
|
||
|
||
if isinstance(provider, XorPayProvider):
|
||
created = provider.create_order(order_id=order_id, name=name, amount_yuan=amount_yuan, notify_url=notify_url)
|
||
if not want_html:
|
||
return {"ok": True, "order_id": order_id, "provider": provider.name, "params": created["params"], "payUrl": created["pay_url"]}
|
||
html_response = HTMLResponse(build_auto_submit_html(created["pay_url"], created["params"], order_id))
|
||
set_pay_order_cookie(html_response, order_id)
|
||
return html_response
|
||
|
||
api_result = await provider.create_order_api(
|
||
order_id=order_id,
|
||
name=name,
|
||
amount_yuan=amount_yuan,
|
||
channel=channel,
|
||
notify_url=notify_url,
|
||
return_url=return_url,
|
||
client_ip=client_ip,
|
||
device=device,
|
||
)
|
||
if api_result.get("status") == "ok":
|
||
if not want_html:
|
||
return {"ok": True, "order_id": order_id, "provider": provider.name, **api_result}
|
||
if device == "pc" and api_result.get("img"):
|
||
html_response = HTMLResponse(build_qr_html(str(api_result["img"]), return_url, order_id))
|
||
set_pay_order_cookie(html_response, order_id)
|
||
return html_response
|
||
pay_url = api_result.get("payurl2") if device == "h5" and api_result.get("payurl2") else api_result.get("payurl")
|
||
if pay_url:
|
||
html_response = HTMLResponse(build_redirect_html(str(pay_url), order_id))
|
||
set_pay_order_cookie(html_response, order_id)
|
||
return html_response
|
||
|
||
created = provider.create_order(
|
||
order_id=order_id,
|
||
name=name,
|
||
amount_yuan=amount_yuan,
|
||
channel=channel,
|
||
notify_url=notify_url,
|
||
return_url=return_url,
|
||
client_ip=client_ip,
|
||
)
|
||
if not want_html:
|
||
return {"ok": True, "order_id": order_id, "provider": provider.name, "params": created["params"], "payUrl": created["pay_url"]}
|
||
html_response = HTMLResponse(build_auto_submit_html(created["pay_url"], created["params"], order_id))
|
||
set_pay_order_cookie(html_response, order_id)
|
||
return html_response
|
||
|
||
|
||
@app.get("/api/pay/status")
|
||
async def pay_status(order_id: str) -> dict[str, Any]:
|
||
order = await find_order(order_id)
|
||
if order and order.get("status") == "paid":
|
||
return {"ok": True, "paid": True, "order": order}
|
||
paid, pay_price = await query_gateway_paid(order_id)
|
||
if paid:
|
||
order = await mark_order_paid(order_id, pay_price)
|
||
return {"ok": True, "paid": paid, "order": order}
|
||
|
||
|
||
@app.get("/cnomadcna/order_status")
|
||
async def cnomadcna_order_status(order_id: str = "", out_trade_no: str = "") -> dict[str, Any]:
|
||
target = (order_id or out_trade_no).strip()
|
||
if not target:
|
||
return {"paid": False, "error": "missing order_id"}
|
||
result = await pay_status(target)
|
||
return {"paid": bool(result.get("paid")), "order": result.get("order")}
|
||
|
||
|
||
@app.get("/cnomadcna/zpay_order_status")
|
||
async def cnomadcna_zpay_order_status(out_trade_no: str) -> dict[str, Any]:
|
||
return await ZPayProvider().query_order_status(out_trade_no)
|
||
|
||
|
||
@app.api_route("/api/pay/zpay_notify", methods=["GET", "POST"])
|
||
@app.api_route("/cnomadcna/zpay_notify", methods=["GET", "POST"])
|
||
async def zpay_notify(request: Request) -> FastAPIResponse:
|
||
if request.method == "GET":
|
||
data = dict(request.query_params)
|
||
else:
|
||
form = await request.form()
|
||
data = dict(form)
|
||
provider = ZPayProvider()
|
||
order_id = str(data.get("out_trade_no", ""))
|
||
if order_id in processed_orders:
|
||
return FastAPIResponse(provider.success_response(), media_type="text/plain")
|
||
if not provider.verify_notify(data):
|
||
raise HTTPException(status_code=400, detail="sign error")
|
||
parsed = provider.parse_notify(data)
|
||
if parsed.get("trade_status") == "success":
|
||
await finalize_paid_order(parsed.get("order_id", ""), parsed.get("pay_price", ""))
|
||
return FastAPIResponse(provider.success_response(), media_type="text/plain")
|
||
|
||
|
||
@app.post("/api/pay/xorpay_notify")
|
||
@app.post("/cnomadcna/xorpay_notify")
|
||
async def xorpay_notify(request: Request) -> FastAPIResponse:
|
||
form = await request.form()
|
||
data = dict(form)
|
||
provider = XorPayProvider()
|
||
order_id = str(data.get("order_id", ""))
|
||
if order_id in processed_orders:
|
||
return FastAPIResponse(provider.success_response(), media_type="text/plain")
|
||
if not provider.verify_notify(data):
|
||
raise HTTPException(status_code=400, detail="sign error")
|
||
parsed = provider.parse_notify(data)
|
||
await finalize_paid_order(parsed.get("order_id", ""), parsed.get("pay_price", ""))
|
||
return FastAPIResponse(provider.success_response(), media_type="text/plain")
|
||
|
||
|
||
@app.get("/api/cities")
|
||
async def list_cities() -> dict[str, Any]:
|
||
data = await pb.list_records("cities", per_page=100, sort="idNumber")
|
||
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
|
||
|
||
|
||
@app.get("/api/cities/{slug}")
|
||
async def city_detail(slug: str) -> dict[str, Any]:
|
||
city = await pb.first_record("cities", filter=f'slug={pb_quote(slug)}')
|
||
if not city:
|
||
raise HTTPException(status_code=404, detail="城市不存在")
|
||
detail = await pb.first_record("city_details", filter=f'citySlug={pb_quote(slug)}')
|
||
content = await pb.list_records(
|
||
"content_items",
|
||
filter=f'citySlugs ?~ {pb_quote(slug)} && status="published"',
|
||
per_page=20,
|
||
sort="sortOrder",
|
||
)
|
||
meetups = await pb.list_records("meetups", filter=f'city={pb_quote(city["name"])}', per_page=20)
|
||
discussions = await pb.list_records("discussions", filter=f'city={pb_quote(city["name"])}', per_page=20)
|
||
return {
|
||
"ok": True,
|
||
"city": public_record(city),
|
||
"detail": public_record(detail) if detail else None,
|
||
"content": [public_record(x) for x in content.get("items", [])],
|
||
"meetups": [public_record(x) for x in meetups.get("items", [])],
|
||
"discussions": [public_record(x) for x in discussions.get("items", [])],
|
||
}
|
||
|
||
|
||
@app.get("/api/content/home")
|
||
async def home_content() -> dict[str, Any]:
|
||
data = await pb.list_records("content_items", filter='status="published"', sort="sortOrder", per_page=20)
|
||
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
|
||
|
||
|
||
@app.post("/api/content/submissions")
|
||
async def submit_content(payload: ContentSubmissionPayload, request: Request) -> dict[str, Any]:
|
||
content_type = clean_submission_text(payload.type, 20).lower()
|
||
if content_type not in {"ebook", "video"}:
|
||
raise HTTPException(status_code=400, detail="只支持电子书或访谈视频投稿")
|
||
|
||
title = clean_submission_text(payload.title, 255)
|
||
subtitle = clean_submission_text(payload.subtitle, 500) or ("社区电子书投稿" if content_type == "ebook" else "社区访谈视频投稿")
|
||
description = clean_submission_text(payload.description, 2000, multiline=True)
|
||
if not title:
|
||
raise HTTPException(status_code=400, detail="缺少标题")
|
||
if not description:
|
||
raise HTTPException(status_code=400, detail="缺少内容简介")
|
||
|
||
cover_image = validate_submission_url(payload.coverImage, "封面图")
|
||
media_url = validate_submission_url(payload.mediaUrl, "内容文件")
|
||
_, user = await current_user(request)
|
||
author_name = clean_submission_text(payload.authorName or (user or {}).get("name") or "", 120)
|
||
author_email = clean_submission_text(payload.authorEmail or (user or {}).get("email") or "", 255).lower()
|
||
if "@" not in author_email or "." not in author_email.split("@")[-1]:
|
||
raise HTTPException(status_code=400, detail="请填写有效联系邮箱,便于审核沟通")
|
||
|
||
slug = f"ugc-{content_type}-{slugify_content_title(title)}-{uuid.uuid4().hex[:8]}"
|
||
target_url = f"/ebooks/{slug}" if content_type == "ebook" else f"/videos/{slug}"
|
||
cta_label = "查看电子书" if content_type == "ebook" else "观看访谈"
|
||
chapters = normalize_submission_chapters(payload.chapters, content_type)
|
||
city_slugs = [clean_submission_text(x, 80) for x in payload.citySlugs[:12] if clean_submission_text(x, 80)]
|
||
submitted_by = str((user or {}).get("id") or "")
|
||
submitted_at = utc_now()
|
||
base_record: dict[str, Any] = {
|
||
"slug": slug,
|
||
"type": content_type,
|
||
"title": title,
|
||
"titleEn": "",
|
||
"subtitle": subtitle,
|
||
"subtitleEn": "",
|
||
"description": description,
|
||
"descriptionEn": "",
|
||
"coverImage": cover_image,
|
||
"mediaUrl": media_url,
|
||
"targetUrl": target_url,
|
||
"ctaLabel": cta_label,
|
||
"ctaLabelEn": "View ebook" if content_type == "ebook" else "Watch interview",
|
||
"citySlugs": city_slugs,
|
||
"relatedProfiles": [submitted_by] if submitted_by else [],
|
||
"chapters": chapters,
|
||
"durationMinutes": 0,
|
||
"sortOrder": 1000,
|
||
"status": "pending",
|
||
}
|
||
record_payload = {
|
||
**base_record,
|
||
"authorName": author_name,
|
||
"authorEmail": author_email,
|
||
"submittedBy": submitted_by,
|
||
"reviewStatus": "pending",
|
||
"reviewNote": "",
|
||
"submissionMeta": {
|
||
"source": "ugc",
|
||
"submittedAt": submitted_at,
|
||
"ip": client_ip_from_request(request),
|
||
"userAgent": request.headers.get("user-agent", "")[:300],
|
||
},
|
||
}
|
||
try:
|
||
record = await pb.create_record("content_items", record_payload)
|
||
except PocketBaseError:
|
||
record = await pb.create_record("content_items", base_record)
|
||
|
||
await create_notification(
|
||
title="新的社区内容待审核",
|
||
body=f"{author_name or author_email} 提交了《{title}》,类型:{'电子书' if content_type == 'ebook' else '访谈视频'}。",
|
||
category="system",
|
||
priority="high",
|
||
audience="review",
|
||
action_label="查看消息",
|
||
action_url="/messages",
|
||
entity_type="content_submission",
|
||
entity_id=record.get("id", ""),
|
||
icon="📝",
|
||
metadata={"slug": slug, "type": content_type, "status": "pending"},
|
||
)
|
||
return {"ok": True, "status": "pending", "record": public_record(record)}
|
||
|
||
|
||
@app.get("/api/content/{slug}")
|
||
async def content_detail(slug: str, request: Request) -> dict[str, Any]:
|
||
item = await pb.first_record("content_items", filter=f'slug={pb_quote(slug)}')
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
status = str(item.get("status") or "published")
|
||
if status != "published":
|
||
_, user = await current_user(request)
|
||
submitted_by = str(item.get("submittedBy") or "")
|
||
if not (user and submitted_by and user.get("id") == submitted_by):
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
cities: list[dict[str, Any]] = []
|
||
for city_slug in item.get("citySlugs") or []:
|
||
city = await pb.first_record("cities", filter=f'slug={pb_quote(city_slug)}')
|
||
if city:
|
||
cities.append(public_record(city))
|
||
return {"ok": True, "item": public_record(item), "cities": cities}
|
||
|
||
|
||
@app.get("/api/notifications")
|
||
async def list_notifications(
|
||
request: Request,
|
||
category: str = "all",
|
||
status: str = "active",
|
||
q: str = "",
|
||
muted: str = "include",
|
||
) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
preferences = await notification_preferences_for_user(user_id, user)
|
||
all_items = await decorated_notifications_for_user(user_id, preferences)
|
||
items = all_items
|
||
if muted == "exclude":
|
||
items = [item for item in items if not item.get("muted")]
|
||
elif muted == "only":
|
||
items = [item for item in items if item.get("muted")]
|
||
if category != "all":
|
||
items = [item for item in items if item.get("category") == category]
|
||
if status == "unread":
|
||
items = [item for item in items if not item.get("read") and not item.get("archived") and not item.get("muted")]
|
||
elif status == "read":
|
||
items = [item for item in items if item.get("read") and not item.get("archived")]
|
||
elif status == "archived":
|
||
items = [item for item in items if item.get("archived")]
|
||
else:
|
||
items = [item for item in items if not item.get("archived")]
|
||
if q.strip():
|
||
needle = q.strip().lower()
|
||
items = [
|
||
item
|
||
for item in items
|
||
if needle in str(item.get("title", "")).lower() or needle in str(item.get("body", "")).lower()
|
||
]
|
||
unread = sum(1 for item in all_items if not item.get("read") and not item.get("archived") and not item.get("muted"))
|
||
categories: dict[str, int] = {}
|
||
muted_count = 0
|
||
for item in all_items:
|
||
if not item.get("archived"):
|
||
categories[item.get("category", "system")] = categories.get(item.get("category", "system"), 0) + 1
|
||
if item.get("muted"):
|
||
muted_count += 1
|
||
return {
|
||
"ok": True,
|
||
"items": items,
|
||
"unread": unread,
|
||
"categories": categories,
|
||
"muted": muted_count,
|
||
"preferences": preferences,
|
||
}
|
||
|
||
|
||
@app.get("/api/notifications/unread-count")
|
||
async def notification_unread_count(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
preferences = await notification_preferences_for_user(user_id, user)
|
||
items = await decorated_notifications_for_user(user_id, preferences)
|
||
return {"ok": True, "count": sum(1 for item in items if not item.get("read") and not item.get("archived") and not item.get("muted"))}
|
||
|
||
|
||
@app.post("/api/notifications")
|
||
async def create_notification_endpoint(payload: NotificationCreatePayload, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
target_user_id = payload.targetUserId or (user["id"] if user and payload.audience == "user" else "")
|
||
record = await create_notification(
|
||
title=payload.title,
|
||
body=payload.body,
|
||
category=payload.category,
|
||
priority=payload.priority,
|
||
target_user_id=target_user_id,
|
||
audience=payload.audience,
|
||
action_label=payload.actionLabel,
|
||
action_url=payload.actionUrl,
|
||
entity_type=payload.entityType,
|
||
entity_id=payload.entityId,
|
||
icon=payload.icon,
|
||
channels=payload.channels,
|
||
metadata=payload.metadata,
|
||
)
|
||
if not record:
|
||
raise HTTPException(status_code=500, detail="通知创建失败")
|
||
return {"ok": True, "record": public_record(record)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/read")
|
||
async def mark_notification_read(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
receipt = await upsert_receipt(notification_id, user_id, {"readAt": utc_now()})
|
||
return {"ok": True, "receipt": public_record(receipt)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/unread")
|
||
async def mark_notification_unread(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
receipt = await upsert_receipt(notification_id, user_id, {"readAt": None, "archivedAt": None})
|
||
return {"ok": True, "receipt": public_record(receipt)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/archive")
|
||
async def archive_notification(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": utc_now(), "readAt": utc_now()})
|
||
return {"ok": True, "receipt": public_record(receipt)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/restore")
|
||
async def restore_notification(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": None, "dismissedAt": None})
|
||
return {"ok": True, "receipt": public_record(receipt)}
|
||
|
||
|
||
@app.post("/api/notifications/{notification_id}/pin")
|
||
async def pin_notification(notification_id: str, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
existing = await receipt_for(notification_id, user_id)
|
||
pinned = not bool(existing and existing.get("pinned"))
|
||
receipt = await upsert_receipt(notification_id, user_id, {"pinned": pinned})
|
||
return {"ok": True, "receipt": public_record(receipt), "pinned": pinned}
|
||
|
||
|
||
@app.post("/api/notifications/mark-all-read")
|
||
async def mark_all_notifications_read(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
preferences = await notification_preferences_for_user(user_id, user)
|
||
items = await decorated_notifications_for_user(user_id, preferences)
|
||
count = 0
|
||
for item in items:
|
||
if not item.get("read") and not item.get("archived") and not item.get("muted"):
|
||
await upsert_receipt(item["id"], user_id, {"readAt": utc_now()})
|
||
count += 1
|
||
return {"ok": True, "count": count}
|
||
|
||
|
||
@app.get("/api/notifications/preferences")
|
||
async def notification_preferences(request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
return {"ok": True, "record": await notification_preferences_for_user(user_id, user)}
|
||
|
||
|
||
@app.put("/api/notifications/preferences")
|
||
async def update_notification_preferences(payload: NotificationPreferencePayload, request: Request) -> dict[str, Any]:
|
||
_, user = await current_user(request)
|
||
user_id = user["id"] if user else "anonymous"
|
||
record_payload = {
|
||
"userId": user_id,
|
||
"email": user.get("email", "") if user else "",
|
||
**payload.model_dump(),
|
||
}
|
||
existing = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}')
|
||
if existing:
|
||
record = await pb.update_record("notification_preferences", existing["id"], record_payload)
|
||
else:
|
||
record = await pb.create_record("notification_preferences", record_payload)
|
||
return {"ok": True, "record": public_record(record)}
|
||
|
||
|
||
def city_recommendation_score(city: dict[str, Any], payload: RecommendationPayload) -> tuple[float, list[str]]:
|
||
score = float(city.get("overallScore", 0)) * 20
|
||
reasons: list[str] = []
|
||
cost = int(city.get("costPerMonth") or 999999)
|
||
speed = int(city.get("internetSpeed") or 0)
|
||
temp = int(city.get("temperature") or 0)
|
||
tags = city.get("tags") or []
|
||
|
||
if cost <= payload.budget:
|
||
score += 22
|
||
reasons.append("预算内")
|
||
else:
|
||
over_ratio = min(1.0, (cost - payload.budget) / max(payload.budget, 1))
|
||
score -= 18 * over_ratio
|
||
|
||
if speed >= payload.internet:
|
||
score += 18
|
||
reasons.append("网络达标")
|
||
else:
|
||
score -= min(18, payload.internet - speed)
|
||
|
||
if payload.climate == "warm" and temp >= 20:
|
||
score += 8
|
||
reasons.append("偏温暖")
|
||
elif payload.climate == "cool" and temp <= 20:
|
||
score += 8
|
||
reasons.append("偏凉爽")
|
||
elif payload.climate == "mild" and 14 <= temp <= 24:
|
||
score += 10
|
||
reasons.append("气候温和")
|
||
|
||
matched_tags = [tag for tag in payload.tags if tag in tags]
|
||
if matched_tags:
|
||
score += len(matched_tags) * 5
|
||
reasons.append("标签匹配:" + "、".join(matched_tags[:3]))
|
||
|
||
if city.get("nomadsNow", 0) >= 300:
|
||
score += 6
|
||
reasons.append("社区活跃")
|
||
|
||
return round(score, 1), reasons[:4]
|
||
|
||
|
||
@app.post("/api/recommendations/next-stop")
|
||
async def next_stop_recommendation(payload: RecommendationPayload, request: Request) -> dict[str, Any]:
|
||
cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", [])
|
||
scored = []
|
||
for city in cities:
|
||
score, reasons = city_recommendation_score(city, payload)
|
||
scored.append({**public_record(city), "matchScore": score, "matchReasons": reasons})
|
||
scored.sort(key=lambda item: item["matchScore"], reverse=True)
|
||
picks = scored[: max(1, min(payload.limit, 30))]
|
||
|
||
_, user = await current_user(request)
|
||
try:
|
||
await pb.create_record(
|
||
"recommendation_logs",
|
||
{
|
||
"userId": user["id"] if user else "anonymous",
|
||
"budget": payload.budget,
|
||
"internet": payload.internet,
|
||
"climate": payload.climate,
|
||
"tags": payload.tags,
|
||
"resultSlugs": [city.get("slug") for city in picks],
|
||
},
|
||
)
|
||
except PocketBaseError:
|
||
pass
|
||
return {"ok": True, "items": picks}
|
||
|
||
|
||
@app.get("/api/routes")
|
||
async def list_routes() -> dict[str, Any]:
|
||
routes = (await pb.list_records("routes", per_page=100)).get("items", [])
|
||
cities = (await pb.list_records("cities", per_page=100, sort="idNumber")).get("items", [])
|
||
city_by_slug = {city.get("slug"): public_record(city) for city in cities}
|
||
items = []
|
||
for route in routes:
|
||
record = public_record(route)
|
||
record["cities"] = [city_by_slug[slug] for slug in route.get("citySlugs", []) if slug in city_by_slug]
|
||
items.append(record)
|
||
return {"ok": True, "items": items}
|
||
|
||
|
||
@app.get("/api/services")
|
||
async def list_services() -> dict[str, Any]:
|
||
data = await pb.list_records("services", filter='status="published"', per_page=100)
|
||
return {"ok": True, "items": [public_record(x) for x in data.get("items", [])]}
|
||
|
||
|
||
@app.post("/api/services/leads")
|
||
async def create_service_lead(payload: ServiceLeadPayload, request: Request) -> dict[str, Any]:
|
||
if not payload.serviceSlug.strip():
|
||
raise HTTPException(status_code=400, detail="服务不能为空")
|
||
service = await pb.first_record("services", filter=f'slug={pb_quote(payload.serviceSlug)}')
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="服务不存在")
|
||
_, user = await current_user(request)
|
||
record = await pb.create_record(
|
||
"service_leads",
|
||
{
|
||
"serviceSlug": payload.serviceSlug,
|
||
"userId": user["id"] if user else "",
|
||
"email": payload.email or (user.get("email", "") if user else ""),
|
||
"note": payload.note,
|
||
"status": "new",
|
||
},
|
||
)
|
||
await create_notification(
|
||
title=f"服务咨询已提交:{service.get('title', payload.serviceSlug)}",
|
||
body="我们已经记录你的服务咨询,后续可以按城市、预算、路线和会员状态继续跟进。",
|
||
category="service",
|
||
priority="normal",
|
||
target_user_id=user["id"] if user else "",
|
||
audience="user" if user else "all",
|
||
action_label="查看服务",
|
||
action_url="/services",
|
||
entity_type="service_lead",
|
||
entity_id=record["id"],
|
||
icon=service.get("icon") or "🧭",
|
||
)
|
||
return {"ok": True, "record": public_record(record), "service": public_record(service)}
|
||
|
||
|
||
@app.get("/api/downloads/{filename}")
|
||
async def download_asset(filename: str) -> FastAPIResponse:
|
||
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":
|
||
buffer = io.BytesIO()
|
||
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||
archive.writestr(
|
||
"README.txt",
|
||
"\n".join(
|
||
[
|
||
"Nomadro Web App Package",
|
||
"",
|
||
"Open Nomadro-Web-App.html in a browser or visit https://nomadro.cn/zh/digital.",
|
||
"The web app can be installed from Chrome, Edge, Safari, and other modern browsers.",
|
||
"",
|
||
]
|
||
),
|
||
)
|
||
archive.writestr(
|
||
"Nomadro-Web-App.html",
|
||
"""<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<meta http-equiv="refresh" content="0; url=https://nomadro.cn/zh/digital" />
|
||
<title>Nomadro Web App</title>
|
||
<style>
|
||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#fafafa;color:#111827}
|
||
main{max-width:560px;padding:32px;text-align:center}
|
||
a{display:inline-flex;margin-top:16px;border-radius:999px;background:#ff4d4f;color:#fff;padding:12px 20px;text-decoration:none;font-weight:700}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<h1>Nomadro Web App</h1>
|
||
<p>正在打开数字游民指南。如果没有自动跳转,请点击下方按钮。</p>
|
||
<a href="https://nomadro.cn/zh/digital">打开 Nomadro</a>
|
||
</main>
|
||
</body>
|
||
</html>
|
||
""",
|
||
)
|
||
body = buffer.getvalue()
|
||
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]}
|