;s
This commit is contained in:
@@ -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),
|
||||
|
||||
734
backend/main.py
734
backend/main.py
@@ -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
269
backend/payment.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user