'c1'
This commit is contained in:
10
.env.example
10
.env.example
@@ -17,14 +17,14 @@ XORPAY_SECRET=your_secret
|
|||||||
ZPAY_PID=2025121809351743
|
ZPAY_PID=2025121809351743
|
||||||
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
|
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
|
||||||
|
|
||||||
# PocketBase(check-user、ensure-user、支付回调必需)
|
# PocketBase(check-user、ensure-user、支付回调、nomadvip/digital/cnomadcna 共用)
|
||||||
PB_URL=https://pocketbase.hackrobot.cn
|
PB_URL=https://pocketbase.hackrobot.cn
|
||||||
PB_ADMIN_EMAIL=your@email.com
|
PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com
|
||||||
PB_ADMIN_PASSWORD=your_password
|
PB_ADMIN_PASSWORD=Xiao4669805@
|
||||||
|
|
||||||
# Memos(仅 nomadvip 使用)
|
# Memos(nomadvip 支付成功后创建账号,与 payjsapi 同源)
|
||||||
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
||||||
MEMOS_TOKEN=your_token
|
MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg
|
||||||
|
|
||||||
# MinIO 对象存储(可选)
|
# MinIO 对象存储(可选)
|
||||||
MINIO_ENDPOINT=minioweb.hackrobot.cn
|
MINIO_ENDPOINT=minioweb.hackrobot.cn
|
||||||
|
|||||||
Binary file not shown.
58
app/main.py
58
app/main.py
@@ -14,6 +14,7 @@
|
|||||||
线上部署:BASE_URL=https://api.hackrobot.cn(必须为 ZPAY 可公网访问的地址)
|
线上部署:BASE_URL=https://api.hackrobot.cn(必须为 ZPAY 可公网访问的地址)
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
@@ -46,21 +47,57 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _SafeStderrProxy:
|
||||||
|
def write(self, message: str) -> int:
|
||||||
|
try:
|
||||||
|
return sys.stderr.write(message)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
try:
|
||||||
|
sys.stderr.flush()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_log(level: int, message: str, *args, **kwargs) -> None:
|
||||||
|
try:
|
||||||
|
logging.log(level, message, *args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
"""捕获未处理异常,记录日志后返回 500(HTTPException 交由 FastAPI 默认处理)"""
|
"""捕获未处理异常,记录日志后返回 500(HTTPException 交由 FastAPI 默认处理)"""
|
||||||
if isinstance(exc, HTTPException):
|
if isinstance(exc, HTTPException):
|
||||||
raise exc
|
raise exc
|
||||||
logging.error(f"未处理异常: {request.url.path} - {exc}", exc_info=True)
|
_safe_log(logging.ERROR, "未处理异常: %s - %s", request.url.path, exc, exc_info=True)
|
||||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||||
|
|
||||||
# 日志(仅控制台,不写文件,避免 git 本地/线上不一致)
|
logging.raiseExceptions = False
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s - %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
handlers=[logging.StreamHandler()],
|
handlers=[logging.StreamHandler(_SafeStderrProxy())],
|
||||||
|
force=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_stderr_write(message: str) -> None:
|
||||||
|
try:
|
||||||
|
sys.stderr.write(message)
|
||||||
|
sys.stderr.flush()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _should_trace_request(path: str) -> bool:
|
||||||
|
return any(
|
||||||
|
segment in path for segment in ("/nomadvip", "/digital", "/cnomadcna", "/nomadlms")
|
||||||
|
)
|
||||||
|
|
||||||
# 启动时校验 BASE_URL(线上部署必须为公网可访问地址,否则 ZPAY 无法回调)
|
# 启动时校验 BASE_URL(线上部署必须为公网可访问地址,否则 ZPAY 无法回调)
|
||||||
def _check_base_url():
|
def _check_base_url():
|
||||||
url = (BASE_URL or "").strip().lower()
|
url = (BASE_URL or "").strip().lower()
|
||||||
@@ -93,6 +130,17 @@ async def health(check_zpay: str = ""):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# 请求日志中间件:每个请求都打印到 stderr,确保终端可见
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_requests(request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
if _should_trace_request(path):
|
||||||
|
_safe_stderr_write(f"[payjsapi] >>> {request.method} {path}\n")
|
||||||
|
resp = await call_next(request)
|
||||||
|
if _should_trace_request(path):
|
||||||
|
_safe_stderr_write(f"[payjsapi] <<< {request.method} {path} {resp.status_code}\n")
|
||||||
|
return resp
|
||||||
|
|
||||||
# 挂载路由
|
# 挂载路由
|
||||||
app.include_router(nomadvip_router)
|
app.include_router(nomadvip_router)
|
||||||
app.include_router(digital_router)
|
app.include_router(digital_router)
|
||||||
@@ -110,7 +158,7 @@ async def root():
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"service": "payjsapi",
|
"service": "payjsapi",
|
||||||
"routes": {
|
"routes": {
|
||||||
"nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/zpay_order_status, /nomadvip/zpay_notify, /nomadvip/xorpay_notify",
|
"nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/check_vip, /nomadvip/check_vip_by_email, /nomadvip/order_status, /nomadvip/zpay_order_status, /nomadvip/zpay_notify, /nomadvip/xorpay_notify",
|
||||||
"digital": "/digital/payh5, /digital/payh5/redirect, /digital/zpay_order_status, /digital/zpay_notify, /digital/xorpay_notify",
|
"digital": "/digital/payh5, /digital/payh5/redirect, /digital/zpay_order_status, /digital/zpay_notify, /digital/xorpay_notify",
|
||||||
"cnomadcna": "/cnomadcna/payh5, ...",
|
"cnomadcna": "/cnomadcna/payh5, ...",
|
||||||
"nomadlms": "/nomadlms/payh5, ...",
|
"nomadlms": "/nomadlms/payh5, ...",
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
from .base import PaymentProvider
|
from .base import PaymentProvider
|
||||||
from .xorpay import XorPayProvider
|
from .xorpay import XorPayProvider
|
||||||
from .zpay import ZPayProvider
|
from .zpay import ZPayProvider
|
||||||
from .factory import get_payment_provider, get_payment_provider_by_name, reset_provider
|
from .factory import (
|
||||||
|
get_payment_provider,
|
||||||
|
get_payment_provider_by_name,
|
||||||
|
reset_provider,
|
||||||
|
resolve_provider,
|
||||||
|
resolve_channel,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PaymentProvider",
|
"PaymentProvider",
|
||||||
@@ -14,4 +20,6 @@ __all__ = [
|
|||||||
"get_payment_provider",
|
"get_payment_provider",
|
||||||
"get_payment_provider_by_name",
|
"get_payment_provider_by_name",
|
||||||
"reset_provider",
|
"reset_provider",
|
||||||
|
"resolve_provider",
|
||||||
|
"resolve_channel",
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -49,3 +49,31 @@ def reset_provider():
|
|||||||
"""重置 Provider(用于测试或切换渠道)"""
|
"""重置 Provider(用于测试或切换渠道)"""
|
||||||
global _provider_instance
|
global _provider_instance
|
||||||
_provider_instance = None
|
_provider_instance = None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_provider(
|
||||||
|
device: str | None = None,
|
||||||
|
req_provider: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
) -> PaymentProvider:
|
||||||
|
"""
|
||||||
|
根据设备解析支付渠道:
|
||||||
|
- 微信内(device=wechat 或 UA 含 MicroMessenger) → xorpay
|
||||||
|
- PC/手机浏览器 → zpay
|
||||||
|
显式传入的 provider 优先。
|
||||||
|
"""
|
||||||
|
req = (req_provider or "").strip().lower()
|
||||||
|
if req in ("xorpay", "zpay"):
|
||||||
|
return get_payment_provider_by_name(req)
|
||||||
|
dev = (device or "pc").lower()
|
||||||
|
if dev == "wechat":
|
||||||
|
return get_payment_provider_by_name("xorpay")
|
||||||
|
ua = (user_agent or "").lower()
|
||||||
|
if "micromessenger" in ua:
|
||||||
|
return get_payment_provider_by_name("xorpay")
|
||||||
|
return get_payment_provider_by_name("zpay")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_channel(item_channel: str | None) -> str:
|
||||||
|
"""支付方式固定为微信支付,支付宝暂不提供"""
|
||||||
|
return "wxpay"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ XorPay 支付渠道实现
|
|||||||
import hashlib
|
import hashlib
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from .base import PaymentProvider
|
from .base import PaymentProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +26,10 @@ class XorPayProvider(PaymentProvider):
|
|||||||
raw = name + pay_type + price + order_id + notify_url + self.secret
|
raw = name + pay_type + price + order_id + notify_url + self.secret
|
||||||
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
|
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
|
||||||
|
|
||||||
|
def _query2_sign(self, order_id: str) -> str:
|
||||||
|
raw = order_id + self.secret
|
||||||
|
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
|
||||||
|
|
||||||
def create_order(
|
def create_order(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -80,3 +86,23 @@ class XorPayProvider(PaymentProvider):
|
|||||||
|
|
||||||
def success_response(self) -> str:
|
def success_response(self) -> str:
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
def query_order_status(self, order_id: str, timeout: int = 10) -> Dict[str, Any]:
|
||||||
|
url = f"https://xorpay.com/api/query2/{self.aid}"
|
||||||
|
params = {
|
||||||
|
"order_id": order_id,
|
||||||
|
"sign": self._query2_sign(order_id),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.get(url, params=params, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json() or {}
|
||||||
|
status = str(data.get("status", "")).strip().lower()
|
||||||
|
return {
|
||||||
|
"paid": status in ("payed", "success"),
|
||||||
|
"status": status,
|
||||||
|
"pay_price": data.get("pay_price") or data.get("price") or "",
|
||||||
|
"raw": data,
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
return {"paid": False, "error": str(error)}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,11 +10,23 @@ import time
|
|||||||
from fastapi import APIRouter, Request, HTTPException
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
|
||||||
from ..config import BASE_URL
|
from ..config import BASE_URL
|
||||||
from ..payment import get_payment_provider
|
from ..payment import (
|
||||||
|
get_payment_provider,
|
||||||
|
get_payment_provider_by_name,
|
||||||
|
resolve_provider,
|
||||||
|
resolve_channel,
|
||||||
|
)
|
||||||
from ..payment.zpay import ZPayProvider
|
from ..payment.zpay import ZPayProvider
|
||||||
from ..services import handle_payment_success, processed_orders
|
from ..services import handle_payment_success, processed_orders
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_log(level: int, message: str, *args, **kwargs) -> None:
|
||||||
|
try:
|
||||||
|
logging.log(level, message, *args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def create_digital_router(prefix: str, project_name: str, site_id: str | None = None) -> APIRouter:
|
def create_digital_router(prefix: str, project_name: str, site_id: str | None = None) -> APIRouter:
|
||||||
"""
|
"""
|
||||||
创建 digital/cnomadcna/nomadlms 的支付路由
|
创建 digital/cnomadcna/nomadlms 的支付路由
|
||||||
@@ -31,12 +43,29 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
return f"{base}{path}"
|
return f"{base}{path}"
|
||||||
|
|
||||||
@router.post("/payh5")
|
@router.post("/payh5")
|
||||||
async def payh5(item: dict):
|
async def payh5(item: dict, request: Request):
|
||||||
"""创建 H5 支付订单"""
|
"""创建 H5 支付订单。默认微信支付,PC/手机用 zpay,微信内用 xorpay,支付宝暂不提供"""
|
||||||
print(f"{project_name}/payh5 item:", item)
|
req_provider = (item.get("provider") or "").strip().lower() or None
|
||||||
total_fee_yuan = f"{item['total_fee'] / 100:.2f}"
|
device = (item.get("device") or "pc").lower()
|
||||||
|
ua = (request.headers.get("user-agent") or "").strip()
|
||||||
|
provider = resolve_provider(device, req_provider, ua)
|
||||||
|
channel = resolve_channel(item.get("channel"))
|
||||||
user_id = item.get("user_id", "unknown")
|
user_id = item.get("user_id", "unknown")
|
||||||
pay_type = item.get("type", "unknown").lower()
|
pay_type = (item.get("type") or "unknown").lower()
|
||||||
|
_safe_log(
|
||||||
|
logging.INFO,
|
||||||
|
"[payjsapi] %s/payh5 user_id=%s type=%s provider=%s channel=%s",
|
||||||
|
project_name,
|
||||||
|
user_id,
|
||||||
|
pay_type,
|
||||||
|
provider.name,
|
||||||
|
channel,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
total_fee = int(item.get("total_fee", 80))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
total_fee = 80
|
||||||
|
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||||
@@ -47,10 +76,8 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
"vip": "VIP会员充值",
|
"vip": "VIP会员充值",
|
||||||
}
|
}
|
||||||
name = type_map.get(pay_type, "商品支付")
|
name = type_map.get(pay_type, "商品支付")
|
||||||
provider = get_payment_provider()
|
|
||||||
notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify"
|
notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify"
|
||||||
notify_url = _notify_path(notify_path)
|
notify_url = _notify_path(notify_path)
|
||||||
channel = item.get("channel", "alipay")
|
|
||||||
result = provider.create_order(
|
result = provider.create_order(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -60,9 +87,19 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
return_url=item.get("return_url"),
|
return_url=item.get("return_url"),
|
||||||
client_ip=item.get("client_ip"),
|
client_ip=item.get("client_ip"),
|
||||||
)
|
)
|
||||||
|
_safe_log(
|
||||||
|
logging.INFO,
|
||||||
|
"[payjsapi] %s/payh5 created order_id=%s provider=%s notify_url=%s return_url=%s",
|
||||||
|
project_name,
|
||||||
|
order_id,
|
||||||
|
provider.name,
|
||||||
|
notify_url,
|
||||||
|
item.get("return_url") or "",
|
||||||
|
)
|
||||||
if provider.name == "xorpay":
|
if provider.name == "xorpay":
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
"order_id": order_id,
|
||||||
"xorpay_params": result.get("params", {}),
|
"xorpay_params": result.get("params", {}),
|
||||||
"xorpay_url": result.get("pay_url", ""),
|
"xorpay_url": result.get("pay_url", ""),
|
||||||
"provider": provider.name,
|
"provider": provider.name,
|
||||||
@@ -79,17 +116,27 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
|
|
||||||
@router.post("/payh5/redirect")
|
@router.post("/payh5/redirect")
|
||||||
async def payh5_redirect(item: dict, request: Request):
|
async def payh5_redirect(item: dict, request: Request):
|
||||||
"""ZPAY mapi 接口,返回 302 或二维码"""
|
"""ZPAY mapi 接口,返回 302 或二维码。PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||||
from fastapi.responses import RedirectResponse, JSONResponse
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
|
|
||||||
provider = get_payment_provider()
|
provider = get_payment_provider_by_name("zpay")
|
||||||
if provider.name != "zpay":
|
channel = resolve_channel(item.get("channel"))
|
||||||
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay 渠道")
|
_safe_log(
|
||||||
total_fee = float(item.get("total_fee", 80))
|
logging.INFO,
|
||||||
|
"[payjsapi] %s/payh5/redirect user_id=%s type=%s device=%s channel=%s",
|
||||||
|
project_name,
|
||||||
|
item.get("user_id", ""),
|
||||||
|
item.get("type", ""),
|
||||||
|
item.get("device", "pc"),
|
||||||
|
channel,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
total_fee = float(item.get("total_fee", 80))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
total_fee = 80.0
|
||||||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||||
user_id = item.get("user_id", "unknown")
|
user_id = item.get("user_id", "unknown")
|
||||||
pay_type = item.get("type", "unknown").lower()
|
pay_type = item.get("type", "unknown").lower()
|
||||||
channel = item.get("channel", "alipay")
|
|
||||||
return_url = item.get("return_url")
|
return_url = item.get("return_url")
|
||||||
device = item.get("device", "pc")
|
device = item.get("device", "pc")
|
||||||
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
|
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
|
||||||
@@ -107,8 +154,6 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
}
|
}
|
||||||
name = type_map.get(pay_type, "商品支付")
|
name = type_map.get(pay_type, "商品支付")
|
||||||
notify_url = _notify_path("/zpay_notify")
|
notify_url = _notify_path("/zpay_notify")
|
||||||
if not isinstance(provider, ZPayProvider):
|
|
||||||
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay")
|
|
||||||
result = provider.create_order_api(
|
result = provider.create_order_api(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -121,6 +166,14 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
)
|
)
|
||||||
if result.get("status") != "ok":
|
if result.get("status") != "ok":
|
||||||
raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败"))
|
raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败"))
|
||||||
|
_safe_log(
|
||||||
|
logging.INFO,
|
||||||
|
"[payjsapi] %s/payh5/redirect created order_id=%s device=%s channel=%s",
|
||||||
|
project_name,
|
||||||
|
order_id,
|
||||||
|
device,
|
||||||
|
channel,
|
||||||
|
)
|
||||||
payurl = result.get("payurl")
|
payurl = result.get("payurl")
|
||||||
payurl2 = result.get("payurl2")
|
payurl2 = result.get("payurl2")
|
||||||
img = result.get("img")
|
img = result.get("img")
|
||||||
@@ -146,23 +199,66 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
raise HTTPException(status_code=400, detail="仅 zpay 渠道支持")
|
raise HTTPException(status_code=400, detail="仅 zpay 渠道支持")
|
||||||
result = provider.query_order_status(out_trade_no, timeout=15)
|
result = provider.query_order_status(out_trade_no, timeout=15)
|
||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
logging.warning(f"{project_name} ZPAY 订单查询失败: {result.get('error')}")
|
_safe_log(logging.WARNING, "%s ZPAY 订单查询失败: %s", project_name, result.get("error"))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@router.get("/order_status")
|
||||||
|
async def order_status(order_id: str = "", out_trade_no: str = ""):
|
||||||
|
order_id = (order_id or out_trade_no or "").strip()
|
||||||
|
if not order_id:
|
||||||
|
return {"paid": False, "error": "missing order_id"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..services.pb import get_pb_client
|
||||||
|
|
||||||
|
pb = get_pb_client()
|
||||||
|
records = pb.collection("payments").get_list(
|
||||||
|
1, 1, {"filter": f'order_id = "{order_id}"'}
|
||||||
|
)
|
||||||
|
if records.items:
|
||||||
|
return {"paid": True, "source": "pocketbase"}
|
||||||
|
except Exception as error:
|
||||||
|
_safe_log(logging.WARNING, "%s order_status PocketBase failed: %s", project_name, error)
|
||||||
|
|
||||||
|
try:
|
||||||
|
xorpay = get_payment_provider_by_name("xorpay")
|
||||||
|
xorpay_result = xorpay.query_order_status(order_id, timeout=8)
|
||||||
|
if xorpay_result.get("paid"):
|
||||||
|
return {
|
||||||
|
"paid": True,
|
||||||
|
"source": "xorpay",
|
||||||
|
"status": xorpay_result.get("status", ""),
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
_safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error)
|
||||||
|
|
||||||
|
try:
|
||||||
|
zpay = get_payment_provider_by_name("zpay")
|
||||||
|
if isinstance(zpay, ZPayProvider):
|
||||||
|
zpay_result = zpay.query_order_status(order_id, timeout=8)
|
||||||
|
if zpay_result.get("paid"):
|
||||||
|
return {
|
||||||
|
"paid": True,
|
||||||
|
"source": "zpay",
|
||||||
|
"status": zpay_result.get("status", ""),
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
_safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error)
|
||||||
|
|
||||||
|
return {"paid": False}
|
||||||
|
|
||||||
@router.post("/xorpay_notify")
|
@router.post("/xorpay_notify")
|
||||||
async def xorpay_notify(request: Request):
|
async def xorpay_notify(request: Request):
|
||||||
"""XorPay 异步通知"""
|
"""XorPay 异步通知"""
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
data = {k: v for k, v in form_data.items()}
|
data = {k: v for k, v in form_data.items()}
|
||||||
logging.info(f"{project_name} XorPay 异步通知: {data}")
|
_safe_log(logging.INFO, "%s XorPay 异步通知: %s", project_name, data)
|
||||||
order_id = data.get("order_id", "")
|
order_id = data.get("order_id", "")
|
||||||
if order_id in processed_orders:
|
if order_id in processed_orders:
|
||||||
return "ok"
|
return "ok"
|
||||||
provider = get_payment_provider()
|
provider = get_payment_provider_by_name("xorpay")
|
||||||
if provider.name != "xorpay":
|
|
||||||
raise HTTPException(status_code=400, detail="xorpay_notify only for xorpay")
|
|
||||||
if not provider.verify_notify(data):
|
if not provider.verify_notify(data):
|
||||||
logging.error(f"{project_name} XorPay 验签失败: {data}")
|
_safe_log(logging.ERROR, "%s XorPay 验签失败: %s", project_name, data)
|
||||||
raise HTTPException(status_code=400, detail="sign error")
|
raise HTTPException(status_code=400, detail="sign error")
|
||||||
parsed = provider.parse_notify(data)
|
parsed = provider.parse_notify(data)
|
||||||
if parsed.get("trade_status") != "success":
|
if parsed.get("trade_status") != "success":
|
||||||
@@ -185,15 +281,13 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None =
|
|||||||
else:
|
else:
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
data = {k: v for k, v in form_data.items()}
|
data = {k: v for k, v in form_data.items()}
|
||||||
logging.info(f"{project_name} ZPAY 异步通知: {data}")
|
_safe_log(logging.INFO, "%s ZPAY 异步通知: %s", project_name, data)
|
||||||
order_id = data.get("out_trade_no", "")
|
order_id = data.get("out_trade_no", "")
|
||||||
if order_id in processed_orders:
|
if order_id in processed_orders:
|
||||||
return "success"
|
return "success"
|
||||||
provider = get_payment_provider()
|
provider = get_payment_provider_by_name("zpay")
|
||||||
if provider.name != "zpay":
|
|
||||||
raise HTTPException(status_code=400, detail="zpay_notify only for zpay")
|
|
||||||
if not provider.verify_notify(data):
|
if not provider.verify_notify(data):
|
||||||
logging.error(f"{project_name} ZPAY 验签失败: {data}")
|
_safe_log(logging.ERROR, "%s ZPAY 验签失败: %s", project_name, data)
|
||||||
raise HTTPException(status_code=400, detail="sign error")
|
raise HTTPException(status_code=400, detail="sign error")
|
||||||
parsed = provider.parse_notify(data)
|
parsed = provider.parse_notify(data)
|
||||||
if parsed.get("trade_status") != "success":
|
if parsed.get("trade_status") != "success":
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ router = APIRouter(tags=["common"])
|
|||||||
@router.post("/submit_meetup_application")
|
@router.post("/submit_meetup_application")
|
||||||
async def submit_meetup_application(item: dict):
|
async def submit_meetup_application(item: dict):
|
||||||
"""数字游民社区报名表单"""
|
"""数字游民社区报名表单"""
|
||||||
print("submit_meetup_application item:", item)
|
logging.info("submit_meetup_application item: %s", item)
|
||||||
user_id = item.get("user_id")
|
user_id = item.get("user_id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=400, detail="missing user_id")
|
raise HTTPException(status_code=400, detail="missing user_id")
|
||||||
@@ -41,7 +41,7 @@ async def submit_meetup_application(item: dict):
|
|||||||
logging.info(f"meetup 申请表单提交成功(待支付) - user_id: {user_id}, record_id: {record.id}")
|
logging.info(f"meetup 申请表单提交成功(待支付) - user_id: {user_id}, record_id: {record.id}")
|
||||||
return {"status": "ok", "record_id": record.id}
|
return {"status": "ok", "record_id": record.id}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"meetup 申请提交失败: {e}")
|
logging.error("meetup 申请提交失败: %s", e)
|
||||||
logging.error(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}")
|
logging.error(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}")
|
||||||
raise HTTPException(status_code=500, detail="submit failed")
|
raise HTTPException(status_code=500, detail="submit failed")
|
||||||
|
|
||||||
@@ -61,5 +61,5 @@ async def create_user(item: CreateUserRequest):
|
|||||||
username = item.username or f"user{timenew}"
|
username = item.username or f"user{timenew}"
|
||||||
password = item.password or "12345678"
|
password = item.password or "12345678"
|
||||||
result = sdk.create_user_by_username(username=username, password=password)
|
result = sdk.create_user_by_username(username=username, password=password)
|
||||||
print("memos_result-", result)
|
logging.info("memos_result- %s", result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -14,9 +14,18 @@ from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
|||||||
router = APIRouter(prefix="/api/meetup", tags=["meetup"])
|
router = APIRouter(prefix="/api/meetup", tags=["meetup"])
|
||||||
DEFAULT_PASSWORD = "12345678" # PocketBase 默认要求至少 8 位
|
DEFAULT_PASSWORD = "12345678" # PocketBase 默认要求至少 8 位
|
||||||
|
|
||||||
|
# Admin token 缓存,避免每次请求都向 PocketBase 认证(PB 远程时易超时)
|
||||||
|
_ADMIN_TOKEN_CACHE: tuple[str, float] | None = None
|
||||||
|
_TOKEN_CACHE_TTL = 300 # 5 分钟
|
||||||
|
|
||||||
|
|
||||||
def _get_admin_token() -> tuple[str | None, str | None]:
|
def _get_admin_token() -> tuple[str | None, str | None]:
|
||||||
"""返回 (token, error_detail)。token 为 None 时 error_detail 为失败原因"""
|
"""返回 (token, error_detail)。token 为 None 时 error_detail 为失败原因。带 5 分钟缓存。"""
|
||||||
|
global _ADMIN_TOKEN_CACHE
|
||||||
|
now = time.time()
|
||||||
|
if _ADMIN_TOKEN_CACHE and _ADMIN_TOKEN_CACHE[1] > now:
|
||||||
|
return _ADMIN_TOKEN_CACHE[0], None
|
||||||
|
|
||||||
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
||||||
return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置"
|
return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置"
|
||||||
base = (PB_URL or "").rstrip("/")
|
base = (PB_URL or "").rstrip("/")
|
||||||
@@ -26,11 +35,14 @@ def _get_admin_token() -> tuple[str | None, str | None]:
|
|||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{base}{path}",
|
f"{base}{path}",
|
||||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||||
timeout=10,
|
timeout=4,
|
||||||
)
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
return data.get("token"), None
|
token = data.get("token")
|
||||||
|
if token:
|
||||||
|
_ADMIN_TOKEN_CACHE = (token, now + _TOKEN_CACHE_TTL)
|
||||||
|
return token, None
|
||||||
if r.status_code == 404:
|
if r.status_code == 404:
|
||||||
continue # 尝试下一个路径
|
continue # 尝试下一个路径
|
||||||
try:
|
try:
|
||||||
@@ -56,9 +68,10 @@ class EnsureUserRequest(BaseModel):
|
|||||||
password: str | None = None
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _check_site_vip(user_id: str, site_id: str = "meetup") -> bool:
|
def _check_site_vip(user_id: str, site_id: str = "meetup", token: str | None = None) -> bool:
|
||||||
"""检查用户是否有有效 site_vip(未过期)"""
|
"""检查用户是否有有效 site_vip(未过期)。可传入 token 避免重复认证。"""
|
||||||
token, _ = _get_admin_token()
|
if not token:
|
||||||
|
token, _ = _get_admin_token()
|
||||||
if not token:
|
if not token:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
@@ -72,7 +85,7 @@ def _check_site_vip(user_id: str, site_id: str = "meetup") -> bool:
|
|||||||
"sort": "-expires_at",
|
"sort": "-expires_at",
|
||||||
},
|
},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=10,
|
timeout=4,
|
||||||
)
|
)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
return False
|
return False
|
||||||
@@ -115,7 +128,7 @@ async def check_user(item: CheckUserRequest):
|
|||||||
f"{PB_URL}/api/collections/users/records",
|
f"{PB_URL}/api/collections/users/records",
|
||||||
params={"filter": filter_val, "perPage": 1},
|
params={"filter": filter_val, "perPage": 1},
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=10,
|
timeout=4,
|
||||||
)
|
)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
raise HTTPException(status_code=500, detail="查询失败")
|
raise HTTPException(status_code=500, detail="查询失败")
|
||||||
@@ -123,7 +136,7 @@ async def check_user(item: CheckUserRequest):
|
|||||||
items = data.get("items") or []
|
items = data.get("items") or []
|
||||||
exists = len(items) > 0
|
exists = len(items) > 0
|
||||||
user_id = items[0].get("id") if items else None
|
user_id = items[0].get("id") if items else None
|
||||||
vip = _check_site_vip(user_id, "meetup") if user_id else False
|
vip = _check_site_vip(user_id, "meetup", token) if user_id else False
|
||||||
return {"ok": True, "exists": exists, "vip": vip, "user_id": user_id}
|
return {"ok": True, "exists": exists, "vip": vip, "user_id": user_id}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
"""nomadvip 项目支付接口:/nomadvip/*,支持 xorpay/zpay(PAYMENT_PROVIDER)"""
|
"""nomadvip 项目支付接口:/nomadvip/*,支持 xorpay / zpay。"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
import requests
|
||||||
from fastapi.responses import RedirectResponse, JSONResponse
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
from ..config import BASE_URL
|
from ..config import BASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
|
||||||
from ..payment import get_payment_provider, get_payment_provider_by_name
|
from ..payment import (
|
||||||
|
get_payment_provider,
|
||||||
|
get_payment_provider_by_name,
|
||||||
|
resolve_provider,
|
||||||
|
resolve_channel,
|
||||||
|
)
|
||||||
from ..payment.zpay import ZPayProvider
|
from ..payment.zpay import ZPayProvider
|
||||||
from ..services import handle_payment_success, processed_orders
|
from ..services import handle_payment_success, processed_orders
|
||||||
|
|
||||||
@@ -16,15 +23,27 @@ router = APIRouter(prefix="/nomadvip", tags=["nomadvip"])
|
|||||||
|
|
||||||
|
|
||||||
def _get_notify_path(provider_name: str) -> str:
|
def _get_notify_path(provider_name: str) -> str:
|
||||||
return f"{BASE_URL.rstrip('/')}/nomadvip/xorpay_notify" if provider_name == "xorpay" else f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify"
|
if provider_name == "xorpay":
|
||||||
|
return f"{BASE_URL.rstrip('/')}/nomadvip/xorpay_notify"
|
||||||
|
return f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/payh5")
|
@router.post("/payh5")
|
||||||
async def nomadvip_payh5(item: dict):
|
async def nomadvip_payh5(item: dict, request: Request):
|
||||||
"""nomadvip 专用:创建支付订单,支持 per-request provider 覆盖(微信用 xorpay,PC/H5 用 zpay)"""
|
"""创建支付订单。微信内用 xorpay,PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||||
print("nomadvip/payh5 item:", item)
|
req_provider = (item.get("provider") or "").strip().lower() or None
|
||||||
req_provider = (item.get("provider") or "").strip().lower()
|
device = (item.get("device") or "pc").lower()
|
||||||
provider = get_payment_provider_by_name(req_provider) if req_provider in ("xorpay", "zpay") else get_payment_provider()
|
ua = (request.headers.get("user-agent") or "").strip()
|
||||||
|
provider = resolve_provider(device, req_provider, ua)
|
||||||
|
channel = resolve_channel(item.get("channel"))
|
||||||
|
logging.info(
|
||||||
|
"[payjsapi] payh5 user_id=%s type=%s provider=%s channel=%s",
|
||||||
|
item.get("user_id", ""),
|
||||||
|
item.get("type", ""),
|
||||||
|
provider.name,
|
||||||
|
channel,
|
||||||
|
)
|
||||||
|
|
||||||
total_fee = int(item.get("total_fee", 880))
|
total_fee = int(item.get("total_fee", 880))
|
||||||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||||
user_id = item.get("user_id", "unknown")
|
user_id = item.get("user_id", "unknown")
|
||||||
@@ -32,6 +51,7 @@ async def nomadvip_payh5(item: dict):
|
|||||||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||||
|
|
||||||
type_map = {
|
type_map = {
|
||||||
"book": "购买书籍",
|
"book": "购买书籍",
|
||||||
"meetup": "数字游民社区报名",
|
"meetup": "数字游民社区报名",
|
||||||
@@ -40,7 +60,6 @@ async def nomadvip_payh5(item: dict):
|
|||||||
}
|
}
|
||||||
name = type_map.get(pay_type, "VIP会员充值")
|
name = type_map.get(pay_type, "VIP会员充值")
|
||||||
notify_url = _get_notify_path(provider.name)
|
notify_url = _get_notify_path(provider.name)
|
||||||
channel = item.get("channel", "alipay")
|
|
||||||
result = provider.create_order(
|
result = provider.create_order(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -50,15 +69,27 @@ async def nomadvip_payh5(item: dict):
|
|||||||
return_url=item.get("return_url"),
|
return_url=item.get("return_url"),
|
||||||
client_ip=item.get("client_ip"),
|
client_ip=item.get("client_ip"),
|
||||||
)
|
)
|
||||||
|
logging.info(
|
||||||
|
"[payjsapi] payh5 created order_id=%s provider=%s notify_url=%s return_url=%s",
|
||||||
|
order_id,
|
||||||
|
provider.name,
|
||||||
|
notify_url,
|
||||||
|
item.get("return_url") or "",
|
||||||
|
)
|
||||||
|
|
||||||
if provider.name == "xorpay":
|
if provider.name == "xorpay":
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
"order_id": order_id,
|
||||||
"xorpay_params": result.get("params", {}),
|
"xorpay_params": result.get("params", {}),
|
||||||
"xorpay_url": result.get("pay_url", ""),
|
"xorpay_url": result.get("pay_url", ""),
|
||||||
"provider": provider.name,
|
"provider": provider.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
params = result.get("params", {})
|
params = result.get("params", {})
|
||||||
params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""}
|
params_str = {
|
||||||
|
k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"params": params_str,
|
"params": params_str,
|
||||||
@@ -70,35 +101,49 @@ async def nomadvip_payh5(item: dict):
|
|||||||
|
|
||||||
@router.post("/payh5/redirect")
|
@router.post("/payh5/redirect")
|
||||||
async def nomadvip_payh5_redirect(item: dict, request: Request):
|
async def nomadvip_payh5_redirect(item: dict, request: Request):
|
||||||
"""nomadvip 专用:ZPAY mapi 接口(xorpay 无需 redirect,直接 payh5 即可)"""
|
"""ZPAY mapi 接口,PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||||
req_provider = (item.get("provider") or "").strip().lower()
|
provider = get_payment_provider_by_name("zpay")
|
||||||
provider = get_payment_provider_by_name("zpay") if req_provider == "zpay" else get_payment_provider()
|
channel = resolve_channel(item.get("channel"))
|
||||||
if not isinstance(provider, ZPayProvider):
|
|
||||||
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay,xorpay 请直接使用 payh5")
|
logging.info("nomadvip payh5/redirect request: item=%s", item)
|
||||||
logging.info(f"nomadvip payh5/redirect 请求: item={item}")
|
|
||||||
try:
|
try:
|
||||||
total_fee = float(item.get("total_fee", 880))
|
total_fee = float(item.get("total_fee", 880))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
logging.warning(f"nomadvip payh5/redirect 无效 total_fee: {item.get('total_fee')}")
|
logging.warning(
|
||||||
|
"nomadvip payh5/redirect invalid total_fee=%s", item.get("total_fee")
|
||||||
|
)
|
||||||
total_fee = 880.0
|
total_fee = 880.0
|
||||||
|
|
||||||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||||
user_id = item.get("user_id", "unknown")
|
user_id = item.get("user_id", "unknown")
|
||||||
pay_type = (item.get("type") or "vip").lower()
|
pay_type = (item.get("type") or "vip").lower()
|
||||||
channel = item.get("channel", "alipay")
|
|
||||||
return_url = item.get("return_url")
|
return_url = item.get("return_url")
|
||||||
device = item.get("device", "pc")
|
device = item.get("device", "pc")
|
||||||
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
|
client_ip = item.get("client_ip") or (
|
||||||
|
request.client.host if request.client else "127.0.0.1"
|
||||||
|
)
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
if forwarded:
|
if forwarded:
|
||||||
client_ip = forwarded.split(",")[0].strip()
|
client_ip = forwarded.split(",")[0].strip()
|
||||||
|
|
||||||
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
|
||||||
type_map = {"book": "购买书籍", "meetup": "数字游民社区报名", "video": "视频解锁", "vip": "VIP会员充值"}
|
|
||||||
|
# 追加 order_id,供 paid 页在 cookie 丢失时恢复 user_id。
|
||||||
|
if return_url and "order_id=" not in return_url and "out_trade_no=" not in return_url:
|
||||||
|
sep = "&" if "?" in return_url else "?"
|
||||||
|
return_url = f"{return_url}{sep}order_id={order_id}"
|
||||||
|
|
||||||
|
type_map = {
|
||||||
|
"book": "购买书籍",
|
||||||
|
"meetup": "数字游民社区报名",
|
||||||
|
"video": "视频解锁",
|
||||||
|
"vip": "VIP会员充值",
|
||||||
|
}
|
||||||
name = type_map.get(pay_type, "VIP会员充值")
|
name = type_map.get(pay_type, "VIP会员充值")
|
||||||
notify_url = f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify"
|
notify_url = f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify"
|
||||||
if not isinstance(provider, ZPayProvider):
|
|
||||||
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay")
|
|
||||||
result = provider.create_order_api(
|
result = provider.create_order_api(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -111,82 +156,684 @@ async def nomadvip_payh5_redirect(item: dict, request: Request):
|
|||||||
)
|
)
|
||||||
if result.get("status") != "ok":
|
if result.get("status") != "ok":
|
||||||
err_msg = result.get("msg", "ZPAY mapi 创建订单失败")
|
err_msg = result.get("msg", "ZPAY mapi 创建订单失败")
|
||||||
logging.error(f"nomadvip payh5/redirect ZPAY 创建订单失败: {err_msg}, result={result}")
|
logging.error(
|
||||||
|
"nomadvip payh5/redirect ZPAY create failed: %s result=%s",
|
||||||
|
err_msg,
|
||||||
|
result,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=500, detail=err_msg)
|
raise HTTPException(status_code=500, detail=err_msg)
|
||||||
|
|
||||||
payurl = result.get("payurl")
|
payurl = result.get("payurl")
|
||||||
payurl2 = result.get("payurl2")
|
payurl2 = result.get("payurl2")
|
||||||
img = result.get("img")
|
img = result.get("img")
|
||||||
if device == "pc" and img:
|
if device == "pc" and img:
|
||||||
return JSONResponse(status_code=200, content={
|
return JSONResponse(
|
||||||
"status": "ok", "qrcode_mode": True, "order_id": order_id,
|
status_code=200,
|
||||||
"img": img, "payurl": payurl, "return_url": return_url, "channel": channel,
|
content={
|
||||||
})
|
"status": "ok",
|
||||||
|
"qrcode_mode": True,
|
||||||
|
"order_id": order_id,
|
||||||
|
"img": img,
|
||||||
|
"payurl": payurl,
|
||||||
|
"return_url": return_url,
|
||||||
|
"channel": channel,
|
||||||
|
},
|
||||||
|
)
|
||||||
if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"):
|
if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"):
|
||||||
return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id})
|
return JSONResponse(
|
||||||
|
status_code=200, content={"use_form": True, "order_id": order_id}
|
||||||
|
)
|
||||||
|
|
||||||
headers = {"X-Pay-Order-Id": order_id}
|
headers = {"X-Pay-Order-Id": order_id}
|
||||||
if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2:
|
if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2:
|
||||||
return RedirectResponse(url=payurl2, status_code=302, headers=headers)
|
return RedirectResponse(url=payurl2, status_code=302, headers=headers)
|
||||||
if payurl:
|
if payurl:
|
||||||
return RedirectResponse(url=payurl, status_code=302, headers=headers)
|
return RedirectResponse(url=payurl, status_code=302, headers=headers)
|
||||||
logging.error(f"nomadvip payh5/redirect ZPAY 未返回支付链接: device={device}, channel={channel}, result keys={list(result.keys())}")
|
|
||||||
|
logging.error(
|
||||||
|
"nomadvip payh5/redirect ZPAY missing pay url device=%s channel=%s keys=%s",
|
||||||
|
device,
|
||||||
|
channel,
|
||||||
|
list(result.keys()),
|
||||||
|
)
|
||||||
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
|
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/zpay_order_status")
|
@router.get("/zpay_order_status")
|
||||||
async def nomadvip_zpay_order_status(out_trade_no: str):
|
async def nomadvip_zpay_order_status(out_trade_no: str):
|
||||||
"""nomadvip 专用:查询 ZPAY 订单支付状态(xorpay 无此接口)"""
|
"""查询 ZPAY 订单支付状态。"""
|
||||||
provider = get_payment_provider()
|
provider = get_payment_provider()
|
||||||
if not isinstance(provider, ZPayProvider):
|
if not isinstance(provider, ZPayProvider):
|
||||||
raise HTTPException(status_code=400, detail="zpay_order_status 仅支持 zpay")
|
raise HTTPException(status_code=400, detail="zpay_order_status 仅支持 zpay")
|
||||||
result = provider.query_order_status(out_trade_no, timeout=15)
|
result = provider.query_order_status(out_trade_no, timeout=15)
|
||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
logging.warning(f"nomadvip ZPAY 订单查询失败: {result.get('error')}")
|
logging.warning(
|
||||||
|
"nomadvip ZPAY order status failed: %s", result.get("error")
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/order_status")
|
def _get_linked_email_for_user(pb, user_id: str) -> str | None:
|
||||||
async def nomadvip_order_status(order_id: str):
|
"""按 user_id 查询 vip_email_link 关联邮箱。"""
|
||||||
"""nomadvip 通用:查询订单支付状态。zpay 先查 ZPAY 实时,xorpay 或失败时查 PocketBase"""
|
try:
|
||||||
zpay = get_payment_provider_by_name("zpay")
|
link_rec = pb.collection("vip_email_link").get_list(
|
||||||
if isinstance(zpay, ZPayProvider):
|
1, 1, {"filter": f'anonymous_user_id = "{user_id}"'}
|
||||||
result = zpay.query_order_status(order_id, timeout=15)
|
)
|
||||||
if not result.get("error") and result.get("paid"):
|
if link_rec.items:
|
||||||
return result
|
return link_rec.items[0].get("email") or ""
|
||||||
# zpay 查不到或 xorpay:查 PocketBase(notify 后写入)
|
|
||||||
|
if user_id and user_id.startswith("user") and user_id[4:].isdigit():
|
||||||
|
link_rec = pb.collection("vip_email_link").get_list(
|
||||||
|
1, 1, {"filter": f'user_id = "{user_id}"'}
|
||||||
|
)
|
||||||
|
if link_rec.items:
|
||||||
|
return link_rec.items[0].get("email") or ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _uid_has_vip(pb, uid: str, site_id: str = "vip") -> bool:
|
||||||
|
if not uid:
|
||||||
|
return False
|
||||||
|
|
||||||
|
sv = pb.collection("site_vip").get_list(
|
||||||
|
1, 1, {"filter": f'user_id = "{uid}" && site_id = "{site_id}"'}
|
||||||
|
)
|
||||||
|
if sv.items:
|
||||||
|
rec = sv.items[0]
|
||||||
|
expires = rec.get("expires_at") or ""
|
||||||
|
if not expires:
|
||||||
|
return True
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
exp_dt = datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
||||||
|
now = datetime.now(exp_dt.tzinfo) if exp_dt.tzinfo else datetime.now()
|
||||||
|
return exp_dt.timestamp() >= now.timestamp()
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
pm = pb.collection("payments").get_list(
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
{"filter": f'user_id = "{uid}" && type = "vip"', "sort": "-created"},
|
||||||
|
)
|
||||||
|
return bool(pm.items)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/check_vip")
|
||||||
|
async def nomadvip_check_vip(user_id: str = ""):
|
||||||
|
"""Return vip status for a user_id."""
|
||||||
|
user_id = (user_id or "").strip()
|
||||||
|
logging.info("[payjsapi] check_vip user_id=%s", user_id or "(empty)")
|
||||||
|
if not user_id:
|
||||||
|
return {"vip": False, "error": "missing user_id"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ..services.pb import get_pb_client
|
from ..services.pb import get_pb_client
|
||||||
|
|
||||||
pb = get_pb_client()
|
pb = get_pb_client()
|
||||||
records = pb.collection("payments").get_list(1, 1, {"filter": f'order_id = "{order_id}"'})
|
if not _uid_has_vip(pb, user_id):
|
||||||
return {"paid": len(records.items) > 0}
|
logging.info("[payjsapi] check_vip miss user_id=%s vip=False", user_id)
|
||||||
except Exception as e:
|
return {"vip": False}
|
||||||
logging.warning(f"nomadvip PocketBase 查询失败: {e}")
|
|
||||||
return {"paid": False, "error": str(e)}
|
email = _get_linked_email_for_user(pb, user_id)
|
||||||
|
if not email:
|
||||||
|
admin_token = _get_pb_admin_token()
|
||||||
|
if admin_token and user_id.startswith("user") and user_id[4:].isdigit():
|
||||||
|
linked_member = _pb_find_linked_member_from_order(admin_token, user_id)
|
||||||
|
if linked_member:
|
||||||
|
linked_user_id, linked_email = linked_member
|
||||||
|
email = linked_email
|
||||||
|
_pb_upsert_email_link(admin_token, linked_email, linked_user_id, user_id)
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
"[payjsapi] check_vip hit user_id=%s vip=True email=%s",
|
||||||
|
user_id,
|
||||||
|
email or "(none)",
|
||||||
|
)
|
||||||
|
return {"vip": True, "email": email}
|
||||||
|
except Exception as error:
|
||||||
|
logging.warning("[payjsapi] check_vip failed: %s", error)
|
||||||
|
return {"vip": False, "error": str(error)}
|
||||||
|
|
||||||
|
def _get_pb_admin_token() -> str | None:
|
||||||
|
"""获取 PocketBase 管理员 token。"""
|
||||||
|
if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
for path in [
|
||||||
|
"/api/collections/_superusers/auth-with-password",
|
||||||
|
"/api/admins/auth-with-password",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{base}{path}",
|
||||||
|
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json().get("token")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pb_escape(value: str) -> str:
|
||||||
|
return str(value).replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
|
||||||
|
def _pb_list_email_links(admin_token: str, email: str) -> list[dict]:
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||||
|
|
||||||
|
for candidate in [email, email.lower()]:
|
||||||
|
safe_email = _pb_escape(candidate)
|
||||||
|
response = requests.get(
|
||||||
|
f"{base}/api/collections/vip_email_link/records",
|
||||||
|
params={"filter": f'email = "{safe_email}"', "perPage": 1},
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
continue
|
||||||
|
|
||||||
|
items = (response.json() or {}).get("items") or []
|
||||||
|
if items:
|
||||||
|
return items
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _pb_upsert_email_link(
|
||||||
|
admin_token: str,
|
||||||
|
email: str,
|
||||||
|
pb_user_id: str,
|
||||||
|
anonymous_user_id: str | None = None,
|
||||||
|
):
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {admin_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
items = _pb_list_email_links(admin_token, email)
|
||||||
|
payload = {"email": email.lower(), "user_id": pb_user_id}
|
||||||
|
if anonymous_user_id:
|
||||||
|
payload["anonymous_user_id"] = anonymous_user_id
|
||||||
|
|
||||||
|
if items:
|
||||||
|
record_id = items[0].get("id")
|
||||||
|
if record_id:
|
||||||
|
requests.patch(
|
||||||
|
f"{base}/api/collections/vip_email_link/records/{record_id}",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
requests.post(
|
||||||
|
f"{base}/api/collections/vip_email_link/records",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pb_move_site_vip(
|
||||||
|
admin_token: str,
|
||||||
|
from_user_id: str,
|
||||||
|
to_user_id: str,
|
||||||
|
site_id: str = "vip",
|
||||||
|
):
|
||||||
|
if not from_user_id or not to_user_id or from_user_id == to_user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
safe_from_user_id = _pb_escape(from_user_id)
|
||||||
|
safe_site_id = _pb_escape(site_id)
|
||||||
|
response = requests.get(
|
||||||
|
f"{base}/api/collections/site_vip/records",
|
||||||
|
params={
|
||||||
|
"filter": f'user_id = "{safe_from_user_id}" && site_id = "{safe_site_id}"',
|
||||||
|
"perPage": 500,
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {admin_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
items = (response.json() or {}).get("items") or []
|
||||||
|
for item in items:
|
||||||
|
record_id = item.get("id")
|
||||||
|
if not record_id:
|
||||||
|
continue
|
||||||
|
requests.patch(
|
||||||
|
f"{base}/api/collections/site_vip/records/{record_id}",
|
||||||
|
json={"user_id": to_user_id},
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pb_latest_vip_payment(admin_token: str, user_id: str) -> dict | None:
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
safe_user_id = _pb_escape(user_id)
|
||||||
|
response = requests.get(
|
||||||
|
f"{base}/api/collections/payments/records",
|
||||||
|
params={
|
||||||
|
"filter": f'user_id = "{safe_user_id}" && type = "vip"',
|
||||||
|
"perPage": 1,
|
||||||
|
"sort": "-created",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = (response.json() or {}).get("items") or []
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
|
||||||
|
def _pb_list_site_vip_by_order(
|
||||||
|
admin_token: str,
|
||||||
|
order_id: str,
|
||||||
|
site_id: str = "vip",
|
||||||
|
) -> list[dict]:
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
safe_order_id = _pb_escape(order_id)
|
||||||
|
safe_site_id = _pb_escape(site_id)
|
||||||
|
response = requests.get(
|
||||||
|
f"{base}/api/collections/site_vip/records",
|
||||||
|
params={
|
||||||
|
"filter": f'order_id = "{safe_order_id}" && site_id = "{safe_site_id}"',
|
||||||
|
"perPage": 500,
|
||||||
|
"sort": "-created",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return (response.json() or {}).get("items") or []
|
||||||
|
|
||||||
|
|
||||||
|
def _pb_get_user_email(admin_token: str, user_id: str) -> str | None:
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
response = requests.get(
|
||||||
|
f"{base}/api/collections/users/records/{user_id}",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
email = (response.json() or {}).get("email") or ""
|
||||||
|
return email.strip().lower() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _pb_find_linked_member_from_order(
|
||||||
|
admin_token: str,
|
||||||
|
anonymous_user_id: str,
|
||||||
|
) -> tuple[str, str] | None:
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
safe_user_id = _pb_escape(anonymous_user_id)
|
||||||
|
response = requests.get(
|
||||||
|
f"{base}/api/collections/payments/records",
|
||||||
|
params={
|
||||||
|
"filter": f'user_id = "{safe_user_id}" && type = "vip"',
|
||||||
|
"perPage": 20,
|
||||||
|
"sort": "-created",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = (response.json() or {}).get("items") or []
|
||||||
|
seen_orders = set()
|
||||||
|
for item in items:
|
||||||
|
order_id = (item.get("order_id") or "").strip()
|
||||||
|
if not order_id or order_id in seen_orders:
|
||||||
|
continue
|
||||||
|
seen_orders.add(order_id)
|
||||||
|
|
||||||
|
for site_vip in _pb_list_site_vip_by_order(admin_token, order_id):
|
||||||
|
linked_user_id = (site_vip.get("user_id") or "").strip()
|
||||||
|
if (
|
||||||
|
not linked_user_id
|
||||||
|
or linked_user_id == anonymous_user_id
|
||||||
|
or (linked_user_id.startswith("user") and linked_user_id[4:].isdigit())
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
email = _pb_get_user_email(admin_token, linked_user_id)
|
||||||
|
if email:
|
||||||
|
return linked_user_id, email
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pb_ensure_site_vip(
|
||||||
|
admin_token: str,
|
||||||
|
from_user_id: str,
|
||||||
|
to_user_id: str,
|
||||||
|
site_id: str = "vip",
|
||||||
|
):
|
||||||
|
if not from_user_id or not to_user_id or from_user_id == to_user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
safe_from_user_id = _pb_escape(from_user_id)
|
||||||
|
safe_to_user_id = _pb_escape(to_user_id)
|
||||||
|
safe_site_id = _pb_escape(site_id)
|
||||||
|
source_records = requests.get(
|
||||||
|
f"{base}/api/collections/site_vip/records",
|
||||||
|
params={
|
||||||
|
"filter": f'user_id = "{safe_from_user_id}" && site_id = "{safe_site_id}"',
|
||||||
|
"perPage": 500,
|
||||||
|
"sort": "-created",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
to_records = requests.get(
|
||||||
|
f"{base}/api/collections/site_vip/records",
|
||||||
|
params={
|
||||||
|
"filter": f'user_id = "{safe_to_user_id}" && site_id = "{safe_site_id}"',
|
||||||
|
"perPage": 500,
|
||||||
|
"sort": "-created",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
payment = _pb_latest_vip_payment(admin_token, from_user_id)
|
||||||
|
order_id = (payment or {}).get("order_id") or ""
|
||||||
|
order_records = (
|
||||||
|
_pb_list_site_vip_by_order(admin_token, order_id, site_id=site_id)
|
||||||
|
if order_id
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
source_items = (source_records.json() or {}).get("items") or [] if source_records.status_code == 200 else []
|
||||||
|
target_items = (to_records.json() or {}).get("items") or [] if to_records.status_code == 200 else []
|
||||||
|
canonical = (target_items or source_items or order_records or [None])[0]
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {admin_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if canonical and canonical.get("id"):
|
||||||
|
patch_body = {"user_id": to_user_id, "site_id": site_id}
|
||||||
|
next_order_id = order_id or (canonical.get("order_id") or "").strip()
|
||||||
|
if next_order_id:
|
||||||
|
patch_body["order_id"] = next_order_id
|
||||||
|
requests.patch(
|
||||||
|
f"{base}/api/collections/site_vip/records/{canonical['id']}",
|
||||||
|
json=patch_body,
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicate_ids = set()
|
||||||
|
for record in [*target_items, *source_items, *order_records]:
|
||||||
|
record_id = record.get("id")
|
||||||
|
if record_id and record_id != canonical["id"]:
|
||||||
|
duplicate_ids.add(record_id)
|
||||||
|
for record_id in duplicate_ids:
|
||||||
|
requests.delete(
|
||||||
|
f"{base}/api/collections/site_vip/records/{record_id}",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not order_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
requests.post(
|
||||||
|
f"{base}/api/collections/site_vip/records",
|
||||||
|
json={"user_id": to_user_id, "site_id": site_id, "order_id": order_id},
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/check_vip_by_email")
|
||||||
|
async def nomadvip_check_vip_by_email(item: dict):
|
||||||
|
"""Recover VIP by email/password, optionally with anonymous user_id."""
|
||||||
|
email = (item.get("email") or "").strip().lower()
|
||||||
|
password = (item.get("password") or "").strip()
|
||||||
|
anonymous_user_id = (
|
||||||
|
item.get("user_id") or item.get("anonymousUserId") or ""
|
||||||
|
).strip()
|
||||||
|
logging.info(
|
||||||
|
"[payjsapi] check_vip_by_email email=%s user_id=%s",
|
||||||
|
email or "(empty)",
|
||||||
|
anonymous_user_id or "(empty)",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
return {"vip": False, "error": "missing email or password"}
|
||||||
|
|
||||||
|
base = (PB_URL or "").rstrip("/")
|
||||||
|
login_res = requests.post(
|
||||||
|
f"{base}/api/collections/users/auth-with-password",
|
||||||
|
json={"identity": email, "password": password},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if login_res.status_code != 200:
|
||||||
|
try:
|
||||||
|
error = login_res.json() or {}
|
||||||
|
except Exception:
|
||||||
|
error = {}
|
||||||
|
logging.info(
|
||||||
|
"[payjsapi] check_vip_by_email login failed status=%s message=%s",
|
||||||
|
login_res.status_code,
|
||||||
|
error.get("message", ""),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"vip": False,
|
||||||
|
"error": error.get("message") or "invalid email or password",
|
||||||
|
}
|
||||||
|
|
||||||
|
login_data = login_res.json() or {}
|
||||||
|
token = login_data.get("token")
|
||||||
|
record = login_data.get("record") or {}
|
||||||
|
pb_user_id = (record.get("id") or "").strip()
|
||||||
|
if not token or not pb_user_id:
|
||||||
|
return {"vip": False, "error": "login failed"}
|
||||||
|
|
||||||
|
admin_token = _get_pb_admin_token()
|
||||||
|
if not admin_token:
|
||||||
|
return {"vip": False, "error": "PocketBase admin auth failed"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..services.pb import get_pb_client
|
||||||
|
|
||||||
|
pb = get_pb_client()
|
||||||
|
matched_user_id = None
|
||||||
|
recovered_anonymous_user_id = (
|
||||||
|
anonymous_user_id
|
||||||
|
if anonymous_user_id.startswith("user") and anonymous_user_id[4:].isdigit()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
recovery_source = None
|
||||||
|
|
||||||
|
if recovered_anonymous_user_id and _uid_has_vip(pb, recovered_anonymous_user_id):
|
||||||
|
matched_user_id = recovered_anonymous_user_id
|
||||||
|
recovery_source = "request_user_id"
|
||||||
|
else:
|
||||||
|
items = _pb_list_email_links(admin_token, email)
|
||||||
|
logging.info(
|
||||||
|
"[payjsapi] check_vip_by_email vip_email_link email=%s count=%s",
|
||||||
|
email,
|
||||||
|
len(items),
|
||||||
|
)
|
||||||
|
for link_record in items:
|
||||||
|
linked_ids = []
|
||||||
|
linked_pb_user_id = (link_record.get("user_id") or "").strip()
|
||||||
|
linked_anonymous_user_id = (
|
||||||
|
link_record.get("anonymous_user_id") or ""
|
||||||
|
).strip()
|
||||||
|
if linked_pb_user_id:
|
||||||
|
linked_ids.append(linked_pb_user_id)
|
||||||
|
if linked_anonymous_user_id and linked_anonymous_user_id not in linked_ids:
|
||||||
|
linked_ids.append(linked_anonymous_user_id)
|
||||||
|
|
||||||
|
for linked_uid in linked_ids:
|
||||||
|
if not _uid_has_vip(pb, linked_uid):
|
||||||
|
continue
|
||||||
|
matched_user_id = linked_uid
|
||||||
|
if linked_anonymous_user_id.startswith("user") and linked_anonymous_user_id[4:].isdigit():
|
||||||
|
recovered_anonymous_user_id = linked_anonymous_user_id
|
||||||
|
elif linked_uid.startswith("user") and linked_uid[4:].isdigit():
|
||||||
|
recovered_anonymous_user_id = linked_uid
|
||||||
|
recovery_source = "vip_email_link"
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched_user_id:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matched_user_id and _uid_has_vip(pb, pb_user_id):
|
||||||
|
matched_user_id = pb_user_id
|
||||||
|
recovered_anonymous_user_id = None
|
||||||
|
recovery_source = "pb_user_id"
|
||||||
|
|
||||||
|
if not matched_user_id:
|
||||||
|
logging.info("[payjsapi] check_vip_by_email no vip match for email=%s", email)
|
||||||
|
return {
|
||||||
|
"vip": False,
|
||||||
|
"error": "no linked vip record found; provide the paid user_id if this is a historical anonymous order",
|
||||||
|
}
|
||||||
|
|
||||||
|
if recovered_anonymous_user_id:
|
||||||
|
_pb_ensure_site_vip(admin_token, recovered_anonymous_user_id, pb_user_id)
|
||||||
|
_pb_upsert_email_link(
|
||||||
|
admin_token,
|
||||||
|
email,
|
||||||
|
pb_user_id,
|
||||||
|
recovered_anonymous_user_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_pb_upsert_email_link(admin_token, email, pb_user_id)
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
"[payjsapi] check_vip_by_email recovered by %s matched_user_id=%s effectiveUserId=%s anonymousUserId=%s",
|
||||||
|
recovery_source or "unknown",
|
||||||
|
matched_user_id,
|
||||||
|
pb_user_id,
|
||||||
|
recovered_anonymous_user_id or "(none)",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"vip": True,
|
||||||
|
"token": token,
|
||||||
|
"record": record,
|
||||||
|
"effectiveUserId": pb_user_id,
|
||||||
|
"anonymousUserId": recovered_anonymous_user_id,
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
logging.warning("[payjsapi] check_vip_by_email failed: %s", error)
|
||||||
|
return {"vip": False, "error": str(error)}
|
||||||
|
|
||||||
|
@router.get("/order_status")
|
||||||
|
async def nomadvip_order_status(order_id: str = "", out_trade_no: str = ""):
|
||||||
|
"""查询订单支付状态,优先查 ZPAY,失败时回退 PocketBase。"""
|
||||||
|
order_id = (order_id or out_trade_no or "").strip()
|
||||||
|
logging.info("[payjsapi] order_status order_id=%s", order_id or "(空)")
|
||||||
|
if not order_id:
|
||||||
|
return {"paid": False, "error": "缺少 order_id 或 out_trade_no"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..services.pb import get_pb_client
|
||||||
|
|
||||||
|
pb = get_pb_client()
|
||||||
|
records = pb.collection("payments").get_list(
|
||||||
|
1, 1, {"filter": f'order_id = "{order_id}"'}
|
||||||
|
)
|
||||||
|
paid = len(records.items) > 0
|
||||||
|
logging.info("[payjsapi] order_status PocketBase paid=%s", paid)
|
||||||
|
if paid:
|
||||||
|
return {"paid": True, "source": "pocketbase"}
|
||||||
|
except Exception as error:
|
||||||
|
logging.warning("[payjsapi] order_status PocketBase failed: %s", error)
|
||||||
|
|
||||||
|
xorpay = get_payment_provider_by_name("xorpay")
|
||||||
|
xorpay_result = xorpay.query_order_status(order_id, timeout=8)
|
||||||
|
if xorpay_result.get("error"):
|
||||||
|
logging.warning(
|
||||||
|
"[payjsapi] order_status XorPay query failed order_id=%s error=%s",
|
||||||
|
order_id,
|
||||||
|
xorpay_result.get("error"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.info(
|
||||||
|
"[payjsapi] order_status XorPay status=%s paid=%s",
|
||||||
|
xorpay_result.get("status", ""),
|
||||||
|
xorpay_result.get("paid", False),
|
||||||
|
)
|
||||||
|
if xorpay_result.get("paid"):
|
||||||
|
return {
|
||||||
|
"paid": True,
|
||||||
|
"source": "xorpay",
|
||||||
|
"status": xorpay_result.get("status"),
|
||||||
|
"pay_price": xorpay_result.get("pay_price") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
zpay = get_payment_provider_by_name("zpay")
|
||||||
|
if isinstance(zpay, ZPayProvider):
|
||||||
|
result = zpay.query_order_status(order_id, timeout=8)
|
||||||
|
if not result.get("error") and result.get("paid"):
|
||||||
|
logging.info("[payjsapi] order_status ZPAY paid=True")
|
||||||
|
return result
|
||||||
|
|
||||||
|
return {"paid": False}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/xorpay_notify")
|
@router.post("/xorpay_notify")
|
||||||
async def nomadvip_xorpay_notify(request: Request):
|
async def nomadvip_xorpay_notify(request: Request):
|
||||||
"""nomadvip 专用:XorPay 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER)"""
|
"""XorPay 异步通知。"""
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
data = {k: v for k, v in form_data.items()}
|
data = {k: v for k, v in form_data.items()}
|
||||||
logging.info(f"nomadvip XorPay 异步通知: {data}")
|
logging.info("nomadvip XorPay notify: %s", data)
|
||||||
order_id = data.get("order_id", "")
|
order_id = data.get("order_id", "")
|
||||||
if order_id in processed_orders:
|
if order_id in processed_orders:
|
||||||
logging.info(f"nomadvip 订单已处理,跳过: {order_id}")
|
logging.info("nomadvip order already processed, skip: %s", order_id)
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
provider = get_payment_provider_by_name("xorpay")
|
provider = get_payment_provider_by_name("xorpay")
|
||||||
if not provider.verify_notify(data):
|
if not provider.verify_notify(data):
|
||||||
logging.error(f"nomadvip XorPay 验签失败: {data}")
|
logging.error("nomadvip XorPay verify failed: %s", data)
|
||||||
raise HTTPException(status_code=400, detail="sign error")
|
raise HTTPException(status_code=400, detail="sign error")
|
||||||
|
|
||||||
parsed = provider.parse_notify(data)
|
parsed = provider.parse_notify(data)
|
||||||
if parsed.get("trade_status") != "success":
|
if parsed.get("trade_status") != "success":
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
pay_price = parsed.get("pay_price", data.get("pay_price", ""))
|
pay_price = parsed.get("pay_price", data.get("pay_price", ""))
|
||||||
try:
|
try:
|
||||||
handle_payment_success(order_id, pay_price, "nomadvip-xorpay", use_memos=True, site_id="vip")
|
handle_payment_success(
|
||||||
logging.info(f"nomadvip 业务处理完成: order_id={order_id}")
|
order_id,
|
||||||
except Exception as e:
|
pay_price,
|
||||||
logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True)
|
"nomadvip-xorpay",
|
||||||
|
use_memos=True,
|
||||||
|
site_id="vip",
|
||||||
|
)
|
||||||
|
logging.info("nomadvip business done: order_id=%s", order_id)
|
||||||
|
except Exception as error:
|
||||||
|
logging.error(
|
||||||
|
"nomadvip PocketBase/Memos failed: %s", error, exc_info=True
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
return provider.success_response()
|
return provider.success_response()
|
||||||
|
|
||||||
@@ -194,31 +841,43 @@ async def nomadvip_xorpay_notify(request: Request):
|
|||||||
@router.get("/zpay_notify")
|
@router.get("/zpay_notify")
|
||||||
@router.post("/zpay_notify")
|
@router.post("/zpay_notify")
|
||||||
async def nomadvip_zpay_notify(request: Request):
|
async def nomadvip_zpay_notify(request: Request):
|
||||||
"""nomadvip 专用:ZPAY 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER)"""
|
"""ZPAY 异步通知。"""
|
||||||
provider = get_payment_provider_by_name("zpay")
|
provider = get_payment_provider_by_name("zpay")
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
data = dict(request.query_params)
|
data = dict(request.query_params)
|
||||||
else:
|
else:
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
data = {k: v for k, v in form_data.items()}
|
data = {k: v for k, v in form_data.items()}
|
||||||
logging.info(f"nomadvip ZPAY 异步通知: {data}")
|
|
||||||
|
logging.info("nomadvip ZPAY notify: %s", data)
|
||||||
order_id = data.get("out_trade_no", "")
|
order_id = data.get("out_trade_no", "")
|
||||||
if order_id in processed_orders:
|
if order_id in processed_orders:
|
||||||
logging.info(f"nomadvip 订单已处理,跳过: {order_id}")
|
logging.info("nomadvip order already processed, skip: %s", order_id)
|
||||||
return "success"
|
return "success"
|
||||||
|
|
||||||
if not provider.verify_notify(data):
|
if not provider.verify_notify(data):
|
||||||
logging.error(f"nomadvip ZPAY 验签失败: {data}")
|
logging.error("nomadvip ZPAY verify failed: %s", data)
|
||||||
raise HTTPException(status_code=400, detail="sign error")
|
raise HTTPException(status_code=400, detail="sign error")
|
||||||
|
|
||||||
parsed = provider.parse_notify(data)
|
parsed = provider.parse_notify(data)
|
||||||
trade_status = parsed.get("trade_status") or data.get("trade_status", "")
|
trade_status = parsed.get("trade_status") or data.get("trade_status", "")
|
||||||
if trade_status != "success" and trade_status != "TRADE_SUCCESS":
|
if trade_status not in ("success", "TRADE_SUCCESS"):
|
||||||
logging.info(f"nomadvip 非成功状态,跳过: trade_status={trade_status}")
|
logging.info("nomadvip non-success trade status, skip: %s", trade_status)
|
||||||
return "success"
|
return "success"
|
||||||
|
|
||||||
pay_price = parsed.get("pay_price") or data.get("money", "")
|
pay_price = parsed.get("pay_price") or data.get("money", "")
|
||||||
try:
|
try:
|
||||||
handle_payment_success(order_id, pay_price, "nomadvip-zpay", use_memos=True, site_id="vip")
|
handle_payment_success(
|
||||||
logging.info(f"nomadvip 业务处理完成: order_id={order_id}")
|
order_id,
|
||||||
except Exception as e:
|
pay_price,
|
||||||
logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True)
|
"nomadvip-zpay",
|
||||||
|
use_memos=True,
|
||||||
|
site_id="vip",
|
||||||
|
)
|
||||||
|
logging.info("nomadvip business done: order_id=%s", order_id)
|
||||||
|
except Exception as error:
|
||||||
|
logging.error(
|
||||||
|
"nomadvip PocketBase/Memos failed: %s", error, exc_info=True
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
return provider.success_response()
|
return provider.success_response()
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ from fastapi import APIRouter, Request, HTTPException
|
|||||||
from fastapi.responses import RedirectResponse, JSONResponse
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
|
|
||||||
from ..config import BASE_URL
|
from ..config import BASE_URL
|
||||||
from ..payment import get_payment_provider, get_payment_provider_by_name
|
from ..payment import (
|
||||||
|
get_payment_provider,
|
||||||
|
get_payment_provider_by_name,
|
||||||
|
resolve_provider,
|
||||||
|
resolve_channel,
|
||||||
|
)
|
||||||
from ..payment.zpay import ZPayProvider
|
from ..payment.zpay import ZPayProvider
|
||||||
from ..services import handle_payment_success, processed_orders
|
from ..services import handle_payment_success, processed_orders
|
||||||
|
|
||||||
@@ -61,10 +66,13 @@ def _sync_wxh5_vip_to_supabase(user_id: str, plan: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/payh5")
|
@router.post("/payh5")
|
||||||
async def wxh5_payh5(item: dict):
|
async def wxh5_payh5(item: dict, request: Request):
|
||||||
"""wxH5 专用:创建支付订单,支持 per-request provider 覆盖"""
|
"""wxH5 专用:创建支付订单。微信内用 xorpay,PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||||
req_provider = (item.get("provider") or "").strip().lower()
|
req_provider = (item.get("provider") or "").strip().lower() or None
|
||||||
provider = get_payment_provider_by_name(req_provider) if req_provider in ("xorpay", "zpay") else get_payment_provider()
|
device = (item.get("device") or "pc").lower()
|
||||||
|
ua = (request.headers.get("user-agent") or "").strip()
|
||||||
|
provider = resolve_provider(device, req_provider, ua)
|
||||||
|
channel = resolve_channel(item.get("channel"))
|
||||||
plan = (item.get("plan") or "year").lower()
|
plan = (item.get("plan") or "year").lower()
|
||||||
total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0]
|
total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0]
|
||||||
total_fee = int(item.get("total_fee", total_fee))
|
total_fee = int(item.get("total_fee", total_fee))
|
||||||
@@ -81,7 +89,6 @@ async def wxh5_payh5(item: dict):
|
|||||||
type_map = {"book": "购买书籍", "meetup": "社区报名", "video": "视频解锁", "vip": "VIP会员充值"}
|
type_map = {"book": "购买书籍", "meetup": "社区报名", "video": "视频解锁", "vip": "VIP会员充值"}
|
||||||
name = type_map.get(pay_type, "VIP会员充值")
|
name = type_map.get(pay_type, "VIP会员充值")
|
||||||
notify_url = _get_notify_path(provider.name)
|
notify_url = _get_notify_path(provider.name)
|
||||||
channel = item.get("channel", "alipay")
|
|
||||||
result = provider.create_order(
|
result = provider.create_order(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -113,11 +120,9 @@ async def wxh5_payh5(item: dict):
|
|||||||
|
|
||||||
@router.post("/payh5/redirect")
|
@router.post("/payh5/redirect")
|
||||||
async def wxh5_payh5_redirect(item: dict, request: Request):
|
async def wxh5_payh5_redirect(item: dict, request: Request):
|
||||||
"""wxH5 专用:ZPAY mapi 接口(xorpay 无需 redirect)"""
|
"""wxH5 专用:ZPAY mapi 接口,PC/手机用 zpay,默认微信支付,支付宝暂不提供"""
|
||||||
req_provider = (item.get("provider") or "").strip().lower()
|
provider = get_payment_provider_by_name("zpay")
|
||||||
provider = get_payment_provider_by_name("zpay") if req_provider == "zpay" else get_payment_provider()
|
channel = resolve_channel(item.get("channel"))
|
||||||
if not isinstance(provider, ZPayProvider):
|
|
||||||
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay")
|
|
||||||
plan = (item.get("plan") or "year").lower()
|
plan = (item.get("plan") or "year").lower()
|
||||||
total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0]
|
total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0]
|
||||||
try:
|
try:
|
||||||
@@ -127,7 +132,6 @@ async def wxh5_payh5_redirect(item: dict, request: Request):
|
|||||||
total_fee_yuan = f"{total_fee / 100:.2f}"
|
total_fee_yuan = f"{total_fee / 100:.2f}"
|
||||||
user_id = item.get("user_id", "unknown")
|
user_id = item.get("user_id", "unknown")
|
||||||
pay_type = (item.get("type") or "vip").lower()
|
pay_type = (item.get("type") or "vip").lower()
|
||||||
channel = item.get("channel", "alipay")
|
|
||||||
device = item.get("device", "pc")
|
device = item.get("device", "pc")
|
||||||
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
|
client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1")
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -123,7 +123,8 @@ def handle_payment_success(
|
|||||||
logging.error(f"meetup 支付成功但创建用户失败: {email}")
|
logging.error(f"meetup 支付成功但创建用户失败: {email}")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},站点: {site_id},订单: {order_id}")
|
logging.info("[payjsapi] 支付成功 provider=%s user_id=%s pay_type=%s site_id=%s order_id=%s",
|
||||||
|
provider_name, user_id, pay_type, site_id, order_id)
|
||||||
|
|
||||||
if pay_type == "vip" and use_memos:
|
if pay_type == "vip" and use_memos:
|
||||||
create_memos_user(user_id, password="12345678")
|
create_memos_user(user_id, password="12345678")
|
||||||
@@ -162,12 +163,19 @@ def handle_payment_success(
|
|||||||
})
|
})
|
||||||
logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}")
|
logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}")
|
||||||
else:
|
else:
|
||||||
pb_client.collection("payments").create({
|
existing = pb_client.collection("payments").get_list(1, 1, {
|
||||||
"user_id": user_id,
|
"filter": f'order_id = "{order_id}"',
|
||||||
"type": pay_type,
|
|
||||||
"order_id": order_id,
|
|
||||||
"amount": amount_cents,
|
|
||||||
})
|
})
|
||||||
|
if not existing.items:
|
||||||
|
payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"type": pay_type,
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount_cents,
|
||||||
|
"total_fee": amount_cents,
|
||||||
|
"channel": "wxpay",
|
||||||
|
}
|
||||||
|
pb_client.collection("payments").create(payload)
|
||||||
|
|
||||||
# 写入 site_vip(站点独立 VIP)。meetup 类型统一写 site_id=meetup
|
# 写入 site_vip(站点独立 VIP)。meetup 类型统一写 site_id=meetup
|
||||||
effective_site_id = "meetup" if pay_type == "meetup" else site_id
|
effective_site_id = "meetup" if pay_type == "meetup" else site_id
|
||||||
@@ -196,6 +204,5 @@ def handle_payment_success(
|
|||||||
|
|
||||||
processed_orders.add(order_id)
|
processed_orders.add(order_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"PocketBase 操作失败: {e}")
|
logging.error("PocketBase 操作失败 order_id=%s error=%s", order_id, e)
|
||||||
logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}")
|
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"""PocketBase 客户端(基于 SDK)"""
|
"""PocketBase 客户端(基于 SDK)"""
|
||||||
|
import logging
|
||||||
|
|
||||||
from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD
|
||||||
from app.sdk.pocketbase import PocketBaseSDK
|
from app.sdk.pocketbase import PocketBaseSDK
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
_pb_sdk: PocketBaseSDK | None = None
|
_pb_sdk: PocketBaseSDK | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -20,5 +23,5 @@ def get_pb_client():
|
|||||||
"""获取已认证的 PocketBase 客户端"""
|
"""获取已认证的 PocketBase 客户端"""
|
||||||
sdk = _get_pb_sdk()
|
sdk = _get_pb_sdk()
|
||||||
client = sdk.get_client()
|
client = sdk.get_client()
|
||||||
print("PocketBase admin 登录成功")
|
logger.info("PocketBase admin 登录成功")
|
||||||
return client
|
return client
|
||||||
|
|||||||
4
run-with-logs.bat
Normal file
4
run-with-logs.bat
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
REM 强制无缓冲输出,确保日志立即显示在终端
|
||||||
|
set PYTHONUNBUFFERED=1
|
||||||
|
python -u run.py
|
||||||
4
run-with-logs.sh
Normal file
4
run-with-logs.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 强制无缓冲输出,确保日志立即显示在终端
|
||||||
|
export PYTHONUNBUFFERED=1
|
||||||
|
exec python -u run.py
|
||||||
28
run.py
28
run.py
@@ -1,11 +1,31 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ.setdefault("PYTHONUNBUFFERED", "1")
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
|
if hasattr(sys.stderr, "reconfigure"):
|
||||||
|
sys.stderr.reconfigure(line_buffering=True)
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import os
|
|
||||||
port = int(os.getenv("PORT", "8007"))
|
port = int(os.getenv("PORT", "8007"))
|
||||||
uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=True)
|
reload_enabled = os.getenv("UVICORN_RELOAD", "0").strip().lower() in ("1", "true", "yes")
|
||||||
# uvicorn.run("app.main:app", host="0.0.0.0", port=8200, reload=True)
|
try:
|
||||||
|
sys.stderr.write(f"\n[payjsapi] startup http://0.0.0.0:{port}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=port,
|
||||||
|
reload=reload_enabled,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user