This commit is contained in:
root
2026-06-07 12:48:17 +08:00
parent 9ee203c221
commit 283d65d1d1
46 changed files with 1205 additions and 2579 deletions

View File

@@ -134,7 +134,6 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
json_field("tags"),
text("bio", max=1000),
text("photo", max=500),
boolean("needsPasswordChange"),
],
"member_applications": [
text("userId", max=80),
@@ -158,6 +157,12 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
text("status", max=40),
date("expiresAt"),
],
"site_vip": [
text("user_id", required=True, max=80),
text("site_id", required=True, max=80),
text("order_id", max=120),
date("expires_at"),
],
"orders": [
text("orderId", required=True, max=80),
text("userId", max=120),
@@ -169,6 +174,14 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
text("returnUrl", max=500),
boolean("isNew"),
],
"payments": [
text("user_id", max=120),
text("type", max=60),
text("order_id", required=True, max=120),
number("amount", only_int=True),
number("total_fee", only_int=True),
text("channel", max=40),
],
"media_assets": [
text("userId", max=80),
text("url", required=True, max=500),

View File

@@ -1,25 +1,42 @@
from __future__ import annotations
import asyncio
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from html import escape
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
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,
@@ -32,8 +49,10 @@ app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads"
class AuthPayload(BaseModel):
email: str
password: str
passwordConfirm: str | None = None
class GoogleAuthPayload(BaseModel):
id_token: str
class SyncSessionPayload(BaseModel):
@@ -43,7 +62,6 @@ class SyncSessionPayload(BaseModel):
class EnsureUserPayload(BaseModel):
email: str
password: str | None = None
class CheckUserPayload(BaseModel):
@@ -67,11 +85,6 @@ class JoinPayload(BaseModel):
media: str | None = None
class ChangePasswordPayload(BaseModel):
newPassword: str
passwordConfirm: str
class FeedbackPayload(BaseModel):
type: str
email: str = ""
@@ -178,65 +191,129 @@ async def find_user_by_email(email: str) -> dict[str, Any] | None:
async def find_membership(user_id: str) -> dict[str, Any] | None:
return await pb.first_record(
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
async def ensure_user(email: str, password: str | None = None) -> tuple[dict[str, Any], str, bool]:
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)
is_new = False
if existing:
login_password = password or "12345678"
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 pb.request(
"POST",
"/api/collections/users/auth-with-password",
json={"identity": normalized, "password": login_password},
)
auth = await login_with_internal_password(normalized)
except Exception:
if not password:
raise HTTPException(status_code=401, detail="请输入该邮箱的登录密码")
raise HTTPException(status_code=401, detail="邮箱或密码不正确")
return auth["record"], auth["token"], is_new
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
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],
"password": internal_auth_password(),
"passwordConfirm": internal_auth_password(),
"name": name or 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
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]:
@@ -247,6 +324,239 @@ def utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z")
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,
@@ -389,32 +699,19 @@ 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))
@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}
return {"ok": True, "token": token, "record": user, "user": user, "is_new": is_new}
@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/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")
@@ -439,26 +736,6 @@ async def logout(response: Response) -> dict[str, Any]:
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))
@@ -468,69 +745,36 @@ async def check_user(payload: CheckUserPayload) -> dict[str, Any]:
@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)
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/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)}')
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:
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
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="订单处理失败")
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)
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}
@@ -608,7 +852,7 @@ async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[s
target = settings.upload_dir / name
content = await file.read()
target.write_bytes(content)
url = f"http://127.0.0.1:8000/uploads/{name}"
url = f"{settings.upload_public_base_url}/uploads/{name}"
user_id = ""
_, user = await current_user(request)
user_id = user["id"] if user else ""
@@ -626,63 +870,161 @@ async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[s
@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_"),
},
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,
)
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)
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
return {"ok": True, "order_id": order_id, "paid": settings.dev_auto_pay}
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 pb.first_record("orders", filter=f'orderId={pb_quote(order_id)}')
return {"ok": True, "paid": bool(order and order.get("status") == "paid"), "order": order}
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")

269
backend/payment.py Normal file
View File

@@ -0,0 +1,269 @@
from __future__ import annotations
import hashlib
import json
from typing import Any
import httpx
from .settings import get_settings
def _md5(value: str) -> str:
return hashlib.md5(value.encode("utf-8")).hexdigest().lower()
def _is_internal_ip(ip: str | None) -> bool:
if not ip:
return True
value = ip.strip().lower()
if value in {"127.0.0.1", "localhost", "::1"}:
return True
if value.startswith("10.") or value.startswith("192.168."):
return True
if value.startswith("172."):
parts = value.split(".")
if len(parts) > 1 and parts[1].isdigit():
return 16 <= int(parts[1]) <= 31
return False
def resolve_channel(channel: str | None) -> str:
return "wxpay" if str(channel or "").lower() in {"", "wx", "wechat", "wxpay"} else "alipay"
class ZPayProvider:
name = "zpay"
def __init__(self) -> None:
settings = get_settings()
self.pid = settings.zpay_pid
self.key = settings.zpay_key
self.submit_url = settings.zpay_submit_url
self.mapi_url = settings.zpay_mapi_url
self.query_url = settings.zpay_query_url
def sign(self, params: dict[str, Any]) -> str:
filtered = {
key: value
for key, value in params.items()
if key not in {"sign", "sign_type"} and value is not None and str(value) != ""
}
raw = "&".join(f"{key}={filtered[key]}" for key in sorted(filtered)) + self.key
return _md5(raw)
def create_order(
self,
*,
order_id: str,
name: str,
amount_yuan: str,
channel: str,
notify_url: str,
return_url: str,
client_ip: str = "",
) -> dict[str, Any]:
params: dict[str, Any] = {
"pid": self.pid,
"type": resolve_channel(channel),
"out_trade_no": order_id,
"notify_url": notify_url,
"return_url": return_url,
"name": name,
"money": amount_yuan,
"sign_type": "MD5",
}
if client_ip and not _is_internal_ip(client_ip):
params["clientip"] = client_ip
params["sign"] = self.sign(params)
return {
"status": "ok",
"provider": self.name,
"params": params,
"pay_url": self.submit_url,
"submit_method": "POST",
}
async def create_order_api(
self,
*,
order_id: str,
name: str,
amount_yuan: str,
channel: str,
notify_url: str,
return_url: str,
client_ip: str = "",
device: str = "pc",
) -> dict[str, Any]:
params: dict[str, Any] = {
"pid": self.pid,
"type": resolve_channel(channel),
"out_trade_no": order_id,
"notify_url": notify_url,
"return_url": return_url,
"name": name,
"money": amount_yuan,
"device": device,
"sign_type": "MD5",
}
if client_ip and not _is_internal_ip(client_ip):
params["clientip"] = client_ip
params["sign"] = self.sign(params)
try:
async with httpx.AsyncClient(timeout=15.0, trust_env=False) as client:
res = await client.post(self.mapi_url, data=params)
except Exception as exc:
return {"status": "error", "provider": self.name, "msg": str(exc)}
result: Any
try:
result = res.json()
except Exception:
result = {}
if isinstance(result, str):
try:
result = json.loads(result)
except Exception:
result = {}
if not isinstance(result, dict):
result = {}
msg = result.get("msg")
if isinstance(msg, str) and msg.strip().startswith("{"):
try:
inner = json.loads(msg)
if isinstance(inner, dict):
result = inner
except Exception:
pass
try:
code = int(float(result.get("code", 0)))
except (TypeError, ValueError):
code = 0
if code == 1:
return {
"status": "ok",
"provider": self.name,
"payurl": result.get("payurl"),
"payurl2": result.get("payurl2"),
"qrcode": result.get("qrcode"),
"img": result.get("img"),
"trade_no": result.get("trade_no"),
}
return {"status": "error", "provider": self.name, "msg": result.get("msg") or res.text[:500]}
def verify_notify(self, data: dict[str, Any]) -> bool:
return self.sign(data) == str(data.get("sign", ""))
def parse_notify(self, data: dict[str, Any]) -> dict[str, Any]:
return {
"order_id": str(data.get("out_trade_no", "")),
"trade_no": str(data.get("trade_no", "")),
"pay_price": str(data.get("money", "")),
"trade_status": "success" if data.get("trade_status") == "TRADE_SUCCESS" else "pending",
}
async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
res = await client.get(
self.query_url,
params={
"act": "order",
"pid": self.pid,
"key": self.key,
"out_trade_no": order_id,
},
)
data = res.json() if res.is_success else {}
except Exception as exc:
return {"paid": False, "error": str(exc)}
try:
code = int(float(data.get("code", 0)))
status = int(float(data.get("status", 0)))
except (TypeError, ValueError):
return {"paid": False, "msg": data.get("msg", "查询失败")}
pay_price = data.get("money") or data.get("pay_price") or ""
return {"paid": code == 1 and status == 1, "status": status, "pay_price": str(pay_price)}
def success_response(self) -> str:
return "success"
class XorPayProvider:
name = "xorpay"
def __init__(self) -> None:
settings = get_settings()
self.aid = settings.xorpay_aid
self.secret = settings.xorpay_secret
self.cashier_url = settings.xorpay_cashier_url.rstrip("/")
self.query_url = settings.xorpay_query_url.rstrip("/")
def create_order(
self,
*,
order_id: str,
name: str,
amount_yuan: str,
notify_url: str,
) -> dict[str, Any]:
params = {
"name": name,
"pay_type": "jsapi",
"price": amount_yuan,
"order_id": order_id,
"notify_url": notify_url,
}
params["sign"] = _md5(
params["name"] + params["pay_type"] + params["price"] + params["order_id"] + params["notify_url"] + self.secret
)
return {"status": "ok", "provider": self.name, "params": params, "pay_url": f"{self.cashier_url}/{self.aid}"}
def verify_notify(self, data: dict[str, Any]) -> bool:
raw = (
str(data.get("aoid", ""))
+ str(data.get("order_id", ""))
+ str(data.get("pay_price", ""))
+ str(data.get("pay_time", ""))
+ self.secret
)
return _md5(raw) == str(data.get("sign", ""))
def parse_notify(self, data: dict[str, Any]) -> dict[str, Any]:
return {
"order_id": str(data.get("order_id", "")),
"trade_no": str(data.get("aoid", "")),
"pay_price": str(data.get("pay_price", "")),
"trade_status": "success",
}
async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]:
sign = _md5(order_id + self.secret)
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
res = await client.get(f"{self.query_url}/{self.aid}", params={"order_id": order_id, "sign": sign})
data = res.json() if res.is_success else {}
status = str(data.get("status", "")).lower()
return {
"paid": status in {"payed", "success"},
"status": status,
"pay_price": data.get("pay_price") or data.get("price") or "",
}
except Exception as exc:
return {"paid": False, "error": str(exc)}
def success_response(self) -> str:
return "ok"
def provider_for(device: str = "pc", requested: str = "", user_agent: str = "") -> ZPayProvider | XorPayProvider:
req = requested.strip().lower()
if req == "xorpay":
return XorPayProvider()
if req == "zpay":
return ZPayProvider()
if device.lower() == "wechat" or "micromessenger" in user_agent.lower():
return XorPayProvider()
return ZPayProvider()

View File

@@ -5,15 +5,68 @@ from functools import lru_cache
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent.parent
def _read_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key:
values[key] = value
return values
def _load_env_files() -> None:
loaded: dict[str, str] = {}
for path in [
ROOT_DIR / ".env",
ROOT_DIR / ".env.local",
ROOT_DIR / "backend" / ".env",
ROOT_DIR / "backend" / ".env.local",
]:
loaded.update(_read_env_file(path))
for key, value in loaded.items():
os.environ.setdefault(key, value)
_load_env_files()
def _split_csv(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def _bool_env(name: str, default: str = "false") -> bool:
return os.getenv(name, default).lower() in {"1", "true", "yes", "on"}
class Settings:
def __init__(self) -> None:
self.pb_url = os.getenv("PB_URL", "http://127.0.0.1:8090").rstrip("/")
self.pb_admin_email = os.getenv("PB_ADMIN_EMAIL", "admin@nomadcna.local")
self.pb_admin_password = os.getenv("PB_ADMIN_PASSWORD", "NomadCNA123456")
self.pb_url = (
os.getenv("PB_INTERNAL_URL")
or os.getenv("POCKETBASE_INTERNAL_URL")
or os.getenv("PB_URL")
or os.getenv("POCKETBASE_URL")
or "http://127.0.0.1:8090"
).rstrip("/")
self.pb_admin_email = (
os.getenv("PB_ADMIN_EMAIL")
or os.getenv("POCKETBASE_EMAIL")
or "admin@nomadcna.local"
)
self.pb_admin_password = (
os.getenv("PB_ADMIN_PASSWORD")
or os.getenv("POCKETBASE_PASSWORD")
or "NomadCNA123456"
)
self.cookie_name = os.getenv("API_COOKIE_NAME", "pb_session")
self.frontend_origins = _split_csv(
os.getenv(
@@ -22,7 +75,38 @@ class Settings:
)
)
self.upload_dir = Path(os.getenv("UPLOAD_DIR", "backend/uploads"))
self.dev_auto_pay = os.getenv("DEV_AUTO_PAY", "true").lower() == "true"
self.dev_auto_pay = _bool_env("DEV_AUTO_PAY", "true")
self.site_id = os.getenv("SITE_ID") or os.getenv("NEXT_PUBLIC_SITE_ID") or "meetup"
self.base_url = (
os.getenv("API_PUBLIC_BASE_URL")
or os.getenv("BASE_URL")
or os.getenv("PAYMENT_API_URL")
or os.getenv("NEXT_PUBLIC_API_BASE_URL")
or "http://127.0.0.1:8000"
).rstrip("/")
if self.base_url == "/api":
self.base_url = (os.getenv("NEXT_PUBLIC_SITE_URL") or "http://127.0.0.1:3001").rstrip("/")
self.upload_public_base_url = (
os.getenv("UPLOAD_PUBLIC_BASE_URL")
or os.getenv("API_PUBLIC_BASE_URL")
or os.getenv("BASE_URL")
or self.base_url
).rstrip("/")
self.email_auth_password = os.getenv("EMAIL_AUTH_PASSWORD", "NomadCNAEmailLogin2026!")
self.google_client_id = os.getenv("GOOGLE_CLIENT_ID") or os.getenv("NEXT_PUBLIC_GOOGLE_CLIENT_ID") or ""
self.payment_provider = os.getenv("PAYMENT_PROVIDER", "zpay").lower()
self.payment_default_amount = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "100") or "100")
self.payment_join_amount = int(os.getenv("PAYMENT_JOIN_AMOUNT", str(self.payment_default_amount)) or str(self.payment_default_amount))
self.payment_join_type = os.getenv("PAYMENT_JOIN_TYPE", "meetup")
self.zpay_pid = os.getenv("ZPAY_PID", "2025121809351743")
self.zpay_key = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89")
self.zpay_submit_url = os.getenv("ZPAY_SUBMIT_URL", "https://zpayz.cn/submit.php")
self.zpay_mapi_url = os.getenv("ZPAY_MAPI_URL", "https://zpayz.cn/mapi.php")
self.zpay_query_url = os.getenv("ZPAY_QUERY_URL", "https://zpayz.cn/api.php")
self.xorpay_aid = os.getenv("XORPAY_AID", "8220")
self.xorpay_secret = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e")
self.xorpay_cashier_url = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier")
self.xorpay_query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2")
@lru_cache(maxsize=1)