's'
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 支付渠道:xorpay | zpay
|
||||||
|
PAYMENT_PROVIDER=xorpay
|
||||||
|
|
||||||
|
# XorPay(默认)
|
||||||
|
XORPAY_AID=8220
|
||||||
|
XORPAY_SECRET=your_secret
|
||||||
|
|
||||||
|
# ZPAY(网关: https://zpayz.cn/)
|
||||||
|
ZPAY_PID=2025121809351743
|
||||||
|
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
|
||||||
|
|
||||||
|
# 回调根地址
|
||||||
|
BASE_URL=https://api.hackrobot.cn
|
||||||
|
|
||||||
|
# PocketBase
|
||||||
|
PB_URL=https://pocketbase.hackrobot.cn
|
||||||
|
PB_ADMIN_EMAIL=your@email.com
|
||||||
|
PB_ADMIN_PASSWORD=your_password
|
||||||
|
|
||||||
|
# Memos
|
||||||
|
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
|
||||||
|
MEMOS_TOKEN=your_token
|
||||||
24
README.md
24
README.md
@@ -1,6 +1,28 @@
|
|||||||
# androidh5api
|
# PayJS API
|
||||||
|
|
||||||
|
支付 API 服务,支持多支付渠道切换(XorPay、ZPAY)。
|
||||||
|
|
||||||
|
## 支付渠道
|
||||||
|
|
||||||
|
| 渠道 | 环境变量 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| XorPay | `PAYMENT_PROVIDER=xorpay` | 默认 |
|
||||||
|
| ZPAY | `PAYMENT_PROVIDER=zpay` | 需配置 `ZPAY_PID`、`ZPAY_KEY` |
|
||||||
|
|
||||||
|
切换渠道:设置环境变量 `PAYMENT_PROVIDER=xorpay` 或 `zpay`,参考 `.env.example`。
|
||||||
|
|
||||||
|
## 模块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── config.py # 配置(环境变量)
|
||||||
|
├── main.py # FastAPI 入口
|
||||||
|
└── payment/
|
||||||
|
├── base.py # 支付抽象基类
|
||||||
|
├── xorpay.py # XorPay 实现
|
||||||
|
├── zpay.py # ZPAY 实现(易支付兼容)
|
||||||
|
└── factory.py # 渠道工厂
|
||||||
|
```
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
BIN
app/__pycache__/config.cpython-312.pyc
Normal file
BIN
app/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
35
app/config.py
Normal file
35
app/config.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
应用配置
|
||||||
|
支付渠道可通过 PAYMENT_PROVIDER 切换:xorpay | zpay
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
PaymentChannel = Literal["xorpay", "zpay"]
|
||||||
|
|
||||||
|
# 当前使用的支付渠道
|
||||||
|
PAYMENT_PROVIDER: PaymentChannel = os.getenv("PAYMENT_PROVIDER", "xorpay").lower()
|
||||||
|
|
||||||
|
# XorPay 配置
|
||||||
|
XORPAY_AID = os.getenv("XORPAY_AID", "8220")
|
||||||
|
XORPAY_SECRET = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e")
|
||||||
|
XORPAY_CASHIER_URL = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier")
|
||||||
|
|
||||||
|
# ZPAY 配置(网关: https://zpayz.cn/)
|
||||||
|
ZPAY_PID = os.getenv("ZPAY_PID", "2025121809351743")
|
||||||
|
ZPAY_KEY = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89")
|
||||||
|
|
||||||
|
# PocketBase
|
||||||
|
PB_URL = os.getenv("PB_URL", "https://pocketbase.hackrobot.cn")
|
||||||
|
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "xiaoshuang.eric@gmail.com")
|
||||||
|
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "Xiao4669805@")
|
||||||
|
|
||||||
|
# Memos
|
||||||
|
MEMOS_API = os.getenv("MEMOS_API", "https://qun.hackrobot.cn/api/v1/users")
|
||||||
|
MEMOS_TOKEN = os.getenv(
|
||||||
|
"MEMOS_TOKEN",
|
||||||
|
"eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 回调地址(用于生成 notify_url)
|
||||||
|
BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn")
|
||||||
382
app/main.py
382
app/main.py
@@ -1,15 +1,30 @@
|
|||||||
from fastapi import FastAPI
|
"""
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
支付 API 主入口
|
||||||
from pydantic import BaseModel
|
支持多支付渠道(XorPay、ZPAY),通过 PAYMENT_PROVIDER 环境变量切换
|
||||||
import hashlib
|
"""
|
||||||
import time
|
import logging
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import time
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
import logging
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
from pocketbase import PocketBase
|
from pocketbase import PocketBase
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
PAYMENT_PROVIDER,
|
||||||
|
BASE_URL,
|
||||||
|
PB_URL,
|
||||||
|
PB_ADMIN_EMAIL,
|
||||||
|
PB_ADMIN_PASSWORD,
|
||||||
|
MEMOS_API,
|
||||||
|
MEMOS_TOKEN,
|
||||||
|
)
|
||||||
|
from .payment import get_payment_provider
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -20,57 +35,30 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==================== PocketBase 配置 ====================
|
# 日志
|
||||||
PB_URL = "https://pocketbase.hackrobot.cn"
|
logging.basicConfig(
|
||||||
PB_ADMIN_EMAIL = "xiaoshuang.eric@gmail.com"
|
level=logging.INFO,
|
||||||
PB_ADMIN_PASSWORD = "Xiao4669805@"
|
format="%(asctime)s - %(message)s",
|
||||||
# ========================================================
|
handlers=[
|
||||||
|
logging.FileHandler("payment_notify.log"),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 已处理订单(防重复)
|
||||||
|
processed_orders: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
def get_pb_client():
|
def get_pb_client():
|
||||||
client = PocketBase(PB_URL)
|
client = PocketBase(PB_URL)
|
||||||
try:
|
try:
|
||||||
client.admins.auth_with_password(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
|
client.admins.auth_with_password(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
|
||||||
print("PocketBase admin 登录成功(动态)")
|
print("PocketBase admin 登录成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("PocketBase admin 登录失败:", e)
|
print("PocketBase admin 登录失败:", e)
|
||||||
raise e
|
raise e
|
||||||
return client
|
return client
|
||||||
|
|
||||||
# 配置日志(添加控制台输出,便于调试)
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s - %(message)s',
|
|
||||||
handlers=[
|
|
||||||
logging.FileHandler('xorpay_notify.log'),
|
|
||||||
logging.StreamHandler() # 新增:同时输出到控制台
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# 全局已处理订单
|
|
||||||
processed_orders = set()
|
|
||||||
|
|
||||||
# ==================== XorPay 配置 ====================
|
|
||||||
AID = "8220"
|
|
||||||
SECRET = "afcacd99570945f88de62624aaa3578e"
|
|
||||||
# ====================================================
|
|
||||||
|
|
||||||
# memos 配置
|
|
||||||
MEMOS_API = "https://qun.hackrobot.cn/api/v1/users"
|
|
||||||
MEMOS_TOKEN = "eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg"
|
|
||||||
|
|
||||||
def xorpay_sign(name: str, pay_type: str, price: str, order_id: str, notify_url: str) -> str:
|
|
||||||
raw = name + pay_type + price + order_id + notify_url + SECRET
|
|
||||||
return hashlib.md5(raw.encode('utf-8')).hexdigest().lower()
|
|
||||||
|
|
||||||
def verify_xorpay_notify(data: dict) -> bool:
|
|
||||||
received_sign = data.get("sign", "")
|
|
||||||
aoid = data.get("aoid", "")
|
|
||||||
order_id = data.get("order_id", "")
|
|
||||||
pay_price = data.get("pay_price", "")
|
|
||||||
pay_time = data.get("pay_time", "")
|
|
||||||
raw = aoid + order_id + pay_price + pay_time + SECRET
|
|
||||||
calculated_sign = hashlib.md5(raw.encode('utf-8')).hexdigest().lower()
|
|
||||||
return calculated_sign == received_sign
|
|
||||||
|
|
||||||
def create_memos_user(user_id: str, password: str = "123456"):
|
def create_memos_user(user_id: str, password: str = "123456"):
|
||||||
try:
|
try:
|
||||||
@@ -88,11 +76,11 @@ def create_memos_user(user_id: str, password: str = "123456"):
|
|||||||
"username": username,
|
"username": username,
|
||||||
"password": password,
|
"password": password,
|
||||||
"role": "USER",
|
"role": "USER",
|
||||||
"state": "NORMAL"
|
"state": "NORMAL",
|
||||||
}
|
}
|
||||||
memos_headers = {
|
memos_headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {MEMOS_TOKEN}"
|
"Authorization": f"Bearer {MEMOS_TOKEN}",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(MEMOS_API, json=memos_data, headers=memos_headers, timeout=10)
|
response = requests.post(MEMOS_API, json=memos_data, headers=memos_headers, timeout=10)
|
||||||
@@ -113,15 +101,89 @@ def create_memos_user(user_id: str, password: str = "123456"):
|
|||||||
logging.error(f"创建 memos 用户异常 - username: {user_id}, error: {e}")
|
logging.error(f"创建 memos 用户异常 - username: {user_id}, error: {e}")
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_order_id(order_id: str) -> tuple[str, str]:
|
||||||
|
"""从 order_id 解析 user_id 和 pay_type"""
|
||||||
|
if "_order_" not in order_id:
|
||||||
|
return "", "unknown"
|
||||||
|
prefix = order_id.split("_order_")[0]
|
||||||
|
if "_" in prefix:
|
||||||
|
parts = prefix.rsplit("_", 1)
|
||||||
|
return parts[0], parts[1].lower()
|
||||||
|
return prefix, "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_payment_success(order_id: str, pay_price: str, provider_name: str):
|
||||||
|
"""支付成功后的统一业务处理"""
|
||||||
|
user_id, pay_type = _parse_order_id(order_id)
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},订单: {order_id}")
|
||||||
|
|
||||||
|
if pay_type == "vip":
|
||||||
|
create_memos_user(user_id)
|
||||||
|
|
||||||
|
if pay_type == "meetup":
|
||||||
|
logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}")
|
||||||
|
|
||||||
|
valid_types = ["vip", "book", "meetup", "video"]
|
||||||
|
if pay_type not in valid_types:
|
||||||
|
pay_type = "vip"
|
||||||
|
|
||||||
|
amount_cents = int(float(pay_price or 0) * 100)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pb_client = get_pb_client()
|
||||||
|
if pay_type == "meetup":
|
||||||
|
solan = pb_client.collection("solan")
|
||||||
|
existing = solan.get_list(1, 1, {
|
||||||
|
"filter": f'user_id = "{user_id}"',
|
||||||
|
"sort": "-created",
|
||||||
|
})
|
||||||
|
if existing.items:
|
||||||
|
record_id = existing.items[0].id
|
||||||
|
solan.update(record_id, {
|
||||||
|
"type": "meetup",
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount_cents,
|
||||||
|
})
|
||||||
|
logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
|
||||||
|
else:
|
||||||
|
solan.create({
|
||||||
|
"user_id": user_id,
|
||||||
|
"type": "meetup",
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount_cents,
|
||||||
|
})
|
||||||
|
logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}")
|
||||||
|
else:
|
||||||
|
pb_client.collection("payments").create({
|
||||||
|
"user_id": user_id,
|
||||||
|
"type": pay_type,
|
||||||
|
"order_id": order_id,
|
||||||
|
"amount": amount_cents,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"PocketBase 操作失败: {e}")
|
||||||
|
logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}")
|
||||||
|
finally:
|
||||||
|
processed_orders.add(order_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 支付接口 ====================
|
||||||
|
|
||||||
|
|
||||||
@app.post("/payh5")
|
@app.post("/payh5")
|
||||||
async def payh5(item: dict):
|
async def payh5(item: dict):
|
||||||
print('payh5 item:', item)
|
"""创建 H5 支付订单(根据 PAYMENT_PROVIDER 使用对应渠道)"""
|
||||||
|
print("payh5 item:", item)
|
||||||
|
|
||||||
total_fee_yuan = f"{item['total_fee'] / 100:.2f}"
|
total_fee_yuan = f"{item['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()
|
||||||
|
|
||||||
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}"
|
||||||
|
|
||||||
@@ -133,37 +195,108 @@ async def payh5(item: dict):
|
|||||||
}
|
}
|
||||||
name = type_map.get(pay_type, "商品支付")
|
name = type_map.get(pay_type, "商品支付")
|
||||||
|
|
||||||
notify_url = "https://api.hackrobot.cn/xorpay_notify"
|
provider = get_payment_provider()
|
||||||
|
notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify"
|
||||||
|
notify_url = f"{BASE_URL.rstrip('/')}{notify_path}"
|
||||||
|
|
||||||
params = {
|
# 支付方式:前端可传 channel=alipay|wxpay,ZPAY 使用;XorPay 固定 jsapi
|
||||||
"name": name,
|
channel = item.get("channel", "alipay")
|
||||||
"pay_type": "jsapi",
|
|
||||||
"price": total_fee_yuan,
|
|
||||||
"order_id": order_id,
|
|
||||||
"notify_url": notify_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
params["sign"] = xorpay_sign(
|
result = provider.create_order(
|
||||||
params["name"],
|
order_id=order_id,
|
||||||
params["pay_type"],
|
name=name,
|
||||||
params["price"],
|
total_fee_yuan=total_fee_yuan,
|
||||||
params["order_id"],
|
pay_type=channel,
|
||||||
params["notify_url"]
|
notify_url=notify_url,
|
||||||
|
return_url=item.get("return_url"),
|
||||||
|
client_ip=item.get("client_ip"),
|
||||||
)
|
)
|
||||||
|
|
||||||
print("生成的参数:", params)
|
# 兼容旧版前端:保留 xorpay_params / xorpay_url 字段名
|
||||||
|
if provider.name == "xorpay":
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"xorpay_params": params,
|
"xorpay_params": result.get("params", {}),
|
||||||
"xorpay_url": f"https://xorpay.com/api/cashier/{AID}"
|
"xorpay_url": result.get("pay_url", ""),
|
||||||
|
"provider": provider.name,
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"params": result.get("params", {}),
|
||||||
|
"pay_url": result.get("pay_url", ""),
|
||||||
|
"provider": provider.name,
|
||||||
|
**{k: v for k, v in result.items() if k not in ("status", "params", "pay_url", "provider")},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/xorpay_notify")
|
||||||
|
async def xorpay_notify(request: Request):
|
||||||
|
"""XorPay 异步通知(POST form)"""
|
||||||
|
form_data = await request.form()
|
||||||
|
data = {k: v for k, v in form_data.items()}
|
||||||
|
|
||||||
|
logging.info(f"XorPay 异步通知: {data}")
|
||||||
|
|
||||||
|
order_id = data.get("order_id", "")
|
||||||
|
if order_id in processed_orders:
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
provider = get_payment_provider()
|
||||||
|
if provider.name != "xorpay":
|
||||||
|
raise HTTPException(status_code=400, detail="xorpay_notify only for xorpay")
|
||||||
|
|
||||||
|
if not provider.verify_notify(data):
|
||||||
|
logging.error(f"XorPay 验签失败: {data}")
|
||||||
|
raise HTTPException(status_code=400, detail="sign error")
|
||||||
|
|
||||||
|
parsed = provider.parse_notify(data)
|
||||||
|
if parsed.get("trade_status") != "success":
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
_handle_payment_success(order_id, parsed.get("pay_price", data.get("pay_price", "")), "xorpay")
|
||||||
|
return provider.success_response()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/zpay_notify")
|
||||||
|
@app.post("/zpay_notify")
|
||||||
|
async def zpay_notify(request: Request):
|
||||||
|
"""ZPAY 异步通知(GET 或 POST)"""
|
||||||
|
if request.method == "GET":
|
||||||
|
data = dict(request.query_params)
|
||||||
|
else:
|
||||||
|
form_data = await request.form()
|
||||||
|
data = {k: v for k, v in form_data.items()}
|
||||||
|
|
||||||
|
logging.info(f"ZPAY 异步通知: {data}")
|
||||||
|
|
||||||
|
order_id = data.get("out_trade_no", "")
|
||||||
|
if order_id in processed_orders:
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
provider = get_payment_provider()
|
||||||
|
if provider.name != "zpay":
|
||||||
|
raise HTTPException(status_code=400, detail="zpay_notify only for zpay")
|
||||||
|
|
||||||
|
if not provider.verify_notify(data):
|
||||||
|
logging.error(f"ZPAY 验签失败: {data}")
|
||||||
|
raise HTTPException(status_code=400, detail="sign error")
|
||||||
|
|
||||||
|
parsed = provider.parse_notify(data)
|
||||||
|
if parsed.get("trade_status") != "success":
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
_handle_payment_success(order_id, parsed.get("pay_price", data.get("money", "")), "zpay")
|
||||||
|
return provider.success_response()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 其他接口 ====================
|
||||||
|
|
||||||
|
|
||||||
@app.post("/submit_meetup_application")
|
@app.post("/submit_meetup_application")
|
||||||
async def submit_meetup_application(item: dict):
|
async def submit_meetup_application(item: dict):
|
||||||
print('submit_meetup_application item:', item)
|
print("submit_meetup_application item:", 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")
|
||||||
|
|
||||||
@@ -196,100 +329,11 @@ async def submit_meetup_application(item: dict):
|
|||||||
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")
|
||||||
|
|
||||||
@app.post("/xorpay_notify")
|
|
||||||
async def xorpay_notify(request: Request):
|
|
||||||
form_data = await request.form()
|
|
||||||
data = {key: value for key, value in form_data.items()}
|
|
||||||
|
|
||||||
logging.info(f"XorPay 异步通知原始数据: {data}")
|
|
||||||
print("XorPay 异步通知:", data)
|
|
||||||
|
|
||||||
order_id = data.get("order_id", "")
|
|
||||||
|
|
||||||
if order_id in processed_orders:
|
|
||||||
print(f"订单 {order_id} 已处理过,跳过")
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
if not verify_xorpay_notify(data):
|
|
||||||
print("验签失败")
|
|
||||||
logging.error(f"验签失败: {data}")
|
|
||||||
raise HTTPException(status_code=400, detail="sign error")
|
|
||||||
|
|
||||||
if "_order_" not in order_id:
|
|
||||||
raise HTTPException(status_code=400, detail="invalid order_id format")
|
|
||||||
|
|
||||||
prefix = order_id.split("_order_")[0]
|
|
||||||
|
|
||||||
if "_" in prefix:
|
|
||||||
parts = prefix.rsplit("_", 1)
|
|
||||||
user_id = parts[0]
|
|
||||||
pay_type = parts[1].lower()
|
|
||||||
else:
|
|
||||||
user_id = prefix
|
|
||||||
pay_type = "unknown"
|
|
||||||
|
|
||||||
print(f"支付成功,用户ID: {user_id},类型: {pay_type},订单: {order_id}")
|
|
||||||
|
|
||||||
if pay_type == "vip":
|
|
||||||
create_memos_user(user_id)
|
|
||||||
|
|
||||||
if pay_type == "meetup":
|
|
||||||
logging.info(f"meetup 类型支付成功(独立逻辑),不创建 memos 用户 - user_id: {user_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
pb_client = get_pb_client()
|
|
||||||
|
|
||||||
valid_types = ["vip", "book", "meetup", "video"]
|
|
||||||
if pay_type not in valid_types:
|
|
||||||
print(f"无效的 pay_type: {pay_type},强制设为 vip")
|
|
||||||
pay_type = "vip"
|
|
||||||
|
|
||||||
amount_cents = int(float(data.get("pay_price", 0)) * 100)
|
|
||||||
|
|
||||||
if pay_type == "meetup":
|
|
||||||
solan = pb_client.collection("solan")
|
|
||||||
# 修复 filter 语法:添加空格和 '=' 操作符(PocketBase 要求)
|
|
||||||
# 同时添加 sort 以取最新记录(防止同一 user_id 多条)
|
|
||||||
existing = solan.get_list(1, 1, {
|
|
||||||
"filter": f'user_id = "{user_id}"',
|
|
||||||
"sort": "-created" # 按创建时间倒序,取最新一条
|
|
||||||
})
|
|
||||||
if existing.items:
|
|
||||||
record_id = existing.items[0].id
|
|
||||||
solan.update(record_id, {
|
|
||||||
"type": "meetup",
|
|
||||||
"order_id": order_id,
|
|
||||||
"amount": amount_cents,
|
|
||||||
})
|
|
||||||
logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}")
|
|
||||||
else:
|
|
||||||
solan.create({
|
|
||||||
"user_id": user_id,
|
|
||||||
"type": "meetup",
|
|
||||||
"order_id": order_id,
|
|
||||||
"amount": amount_cents,
|
|
||||||
})
|
|
||||||
logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}")
|
|
||||||
else:
|
|
||||||
pb_client.collection("payments").create({
|
|
||||||
"user_id": user_id,
|
|
||||||
"type": pay_type,
|
|
||||||
"order_id": order_id,
|
|
||||||
"amount": amount_cents,
|
|
||||||
})
|
|
||||||
|
|
||||||
processed_orders.add(order_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"PocketBase 操作失败: {e}")
|
|
||||||
logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}")
|
|
||||||
processed_orders.add(order_id) # 防止重复通知
|
|
||||||
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
class CreateUserRequest(BaseModel):
|
class CreateUserRequest(BaseModel):
|
||||||
username: str = None
|
username: str | None = None
|
||||||
password: str = None
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@app.post("/create_user")
|
@app.post("/create_user")
|
||||||
async def create_user(item: CreateUserRequest):
|
async def create_user(item: CreateUserRequest):
|
||||||
@@ -298,13 +342,13 @@ async def create_user(item: CreateUserRequest):
|
|||||||
"username": item.username or f"user{timenew}",
|
"username": item.username or f"user{timenew}",
|
||||||
"password": item.password or "123456",
|
"password": item.password or "123456",
|
||||||
"role": "USER",
|
"role": "USER",
|
||||||
"state": "NORMAL"
|
"state": "NORMAL",
|
||||||
}
|
}
|
||||||
memos_headers = {
|
memos_headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {MEMOS_TOKEN}"
|
"Authorization": f"Bearer {MEMOS_TOKEN}",
|
||||||
}
|
}
|
||||||
memos_resp = requests.post(MEMOS_API, json=memos_data, headers=memos_headers)
|
memos_resp = requests.post(MEMOS_API, json=memos_data, headers=memos_headers)
|
||||||
memos_result = memos_resp.json()
|
memos_result = memos_resp.json()
|
||||||
print('memos_result-', memos_result)
|
print("memos_result-", memos_result)
|
||||||
return memos_result
|
return memos_result
|
||||||
16
app/payment/__init__.py
Normal file
16
app/payment/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
支付模块
|
||||||
|
支持多支付渠道,可通过配置切换
|
||||||
|
"""
|
||||||
|
from .base import PaymentProvider
|
||||||
|
from .xorpay import XorPayProvider
|
||||||
|
from .zpay import ZPayProvider
|
||||||
|
from .factory import get_payment_provider, reset_provider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PaymentProvider",
|
||||||
|
"XorPayProvider",
|
||||||
|
"ZPayProvider",
|
||||||
|
"get_payment_provider",
|
||||||
|
"reset_provider",
|
||||||
|
]
|
||||||
BIN
app/payment/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/payment/__pycache__/base.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/payment/__pycache__/factory.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/factory.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/payment/__pycache__/xorpay.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/xorpay.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/payment/__pycache__/zpay.cpython-312.pyc
Normal file
BIN
app/payment/__pycache__/zpay.cpython-312.pyc
Normal file
Binary file not shown.
59
app/payment/base.py
Normal file
59
app/payment/base.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
支付渠道抽象基类
|
||||||
|
定义统一的支付接口,便于切换不同支付渠道(XorPay、ZPAY 等)
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentProvider(ABC):
|
||||||
|
"""支付渠道抽象基类"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""支付渠道名称"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: str,
|
||||||
|
name: str,
|
||||||
|
total_fee_yuan: str,
|
||||||
|
pay_type: str,
|
||||||
|
notify_url: str,
|
||||||
|
return_url: Optional[str] = None,
|
||||||
|
client_ip: Optional[str] = None,
|
||||||
|
extra: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
创建支付订单
|
||||||
|
:return: 包含支付跳转信息等,格式由各渠道自定义
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def verify_notify(self, data: Dict[str, Any]) -> bool:
|
||||||
|
"""验证异步通知签名"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
解析异步通知,提取统一字段
|
||||||
|
:return: {
|
||||||
|
"order_id": str, # 商户订单号
|
||||||
|
"trade_no": str, # 渠道订单号
|
||||||
|
"pay_price": str, # 实付金额(元)
|
||||||
|
"pay_time": str, # 支付时间
|
||||||
|
"trade_status": str, # 支付状态
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def success_response(self) -> str:
|
||||||
|
"""异步通知成功时需返回的字符串(如 'ok' 或 'success')"""
|
||||||
|
pass
|
||||||
39
app/payment/factory.py
Normal file
39
app/payment/factory.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
支付渠道工厂
|
||||||
|
根据配置返回对应的支付 Provider
|
||||||
|
"""
|
||||||
|
from ..config import (
|
||||||
|
PAYMENT_PROVIDER,
|
||||||
|
XORPAY_AID,
|
||||||
|
XORPAY_SECRET,
|
||||||
|
XORPAY_CASHIER_URL,
|
||||||
|
ZPAY_PID,
|
||||||
|
ZPAY_KEY,
|
||||||
|
)
|
||||||
|
from .base import PaymentProvider
|
||||||
|
from .xorpay import XorPayProvider
|
||||||
|
from .zpay import ZPayProvider
|
||||||
|
|
||||||
|
_provider_instance: PaymentProvider | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_payment_provider() -> PaymentProvider:
|
||||||
|
"""获取当前配置的支付渠道实例(单例)"""
|
||||||
|
global _provider_instance
|
||||||
|
if _provider_instance is not None:
|
||||||
|
return _provider_instance
|
||||||
|
if PAYMENT_PROVIDER == "zpay":
|
||||||
|
_provider_instance = ZPayProvider(pid=ZPAY_PID, key=ZPAY_KEY)
|
||||||
|
else:
|
||||||
|
_provider_instance = XorPayProvider(
|
||||||
|
aid=XORPAY_AID,
|
||||||
|
secret=XORPAY_SECRET,
|
||||||
|
cashier_url=XORPAY_CASHIER_URL,
|
||||||
|
)
|
||||||
|
return _provider_instance
|
||||||
|
|
||||||
|
|
||||||
|
def reset_provider():
|
||||||
|
"""重置 Provider(用于测试或切换渠道)"""
|
||||||
|
global _provider_instance
|
||||||
|
_provider_instance = None
|
||||||
82
app/payment/xorpay.py
Normal file
82
app/payment/xorpay.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
XorPay 支付渠道实现
|
||||||
|
文档: https://xorpay.com
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from .base import PaymentProvider
|
||||||
|
|
||||||
|
|
||||||
|
class XorPayProvider(PaymentProvider):
|
||||||
|
"""XorPay 支付"""
|
||||||
|
|
||||||
|
def __init__(self, aid: str, secret: str, cashier_url: str = "https://xorpay.com/api/cashier"):
|
||||||
|
self.aid = aid
|
||||||
|
self.secret = secret
|
||||||
|
self.cashier_url = cashier_url.rstrip("/")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "xorpay"
|
||||||
|
|
||||||
|
def _sign(self, name: str, pay_type: str, price: str, order_id: str, notify_url: str) -> str:
|
||||||
|
raw = name + pay_type + price + order_id + notify_url + self.secret
|
||||||
|
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
|
||||||
|
|
||||||
|
def create_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: str,
|
||||||
|
name: str,
|
||||||
|
total_fee_yuan: str,
|
||||||
|
pay_type: str,
|
||||||
|
notify_url: str,
|
||||||
|
return_url: Optional[str] = None,
|
||||||
|
client_ip: Optional[str] = None,
|
||||||
|
extra: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
# XorPay 使用 jsapi 表示 H5/JSAPI 支付
|
||||||
|
channel_pay_type = "jsapi"
|
||||||
|
params = {
|
||||||
|
"name": name,
|
||||||
|
"pay_type": channel_pay_type,
|
||||||
|
"price": total_fee_yuan,
|
||||||
|
"order_id": order_id,
|
||||||
|
"notify_url": notify_url,
|
||||||
|
}
|
||||||
|
params["sign"] = self._sign(
|
||||||
|
params["name"],
|
||||||
|
params["pay_type"],
|
||||||
|
params["price"],
|
||||||
|
params["order_id"],
|
||||||
|
params["notify_url"],
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
received_sign = data.get("sign", "")
|
||||||
|
aoid = data.get("aoid", "")
|
||||||
|
order_id = data.get("order_id", "")
|
||||||
|
pay_price = data.get("pay_price", "")
|
||||||
|
pay_time = data.get("pay_time", "")
|
||||||
|
raw = aoid + order_id + pay_price + pay_time + self.secret
|
||||||
|
calculated = hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
|
||||||
|
return calculated == received_sign
|
||||||
|
|
||||||
|
def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"order_id": data.get("order_id", ""),
|
||||||
|
"trade_no": data.get("aoid", ""),
|
||||||
|
"pay_price": data.get("pay_price", ""),
|
||||||
|
"pay_time": data.get("pay_time", ""),
|
||||||
|
"trade_status": "success", # XorPay 验签通过即视为成功
|
||||||
|
}
|
||||||
|
|
||||||
|
def success_response(self) -> str:
|
||||||
|
return "ok"
|
||||||
153
app/payment/zpay.py
Normal file
153
app/payment/zpay.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
ZPAY 支付渠道实现
|
||||||
|
文档: https://z-pay.cn/doc.html
|
||||||
|
支持:页面跳转支付、API 接口支付、异步通知验签
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .base import PaymentProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ZPayProvider(PaymentProvider):
|
||||||
|
"""ZPAY 支付(易支付兼容接口)"""
|
||||||
|
|
||||||
|
# API 地址
|
||||||
|
SUBMIT_URL = "https://zpayz.cn/submit.php" # 页面跳转
|
||||||
|
MAPI_URL = "https://zpayz.cn/mapi.php" # API 接口
|
||||||
|
|
||||||
|
def __init__(self, pid: str, key: str):
|
||||||
|
self.pid = pid
|
||||||
|
self.key = key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "zpay"
|
||||||
|
|
||||||
|
def _md5_sign(self, params: Dict[str, Any], exclude: Optional[set] = None) -> str:
|
||||||
|
"""
|
||||||
|
ZPAY MD5 签名算法:
|
||||||
|
1. 参数按 ASCII 排序,sign/sign_type/空值不参与
|
||||||
|
2. 拼接为 a=b&c=d
|
||||||
|
3. sign = md5(拼接串 + KEY),小写
|
||||||
|
"""
|
||||||
|
exclude = exclude or {"sign", "sign_type"}
|
||||||
|
filtered = {k: v for k, v in params.items() if k not in exclude and v is not None and v != ""}
|
||||||
|
sorted_keys = sorted(filtered.keys())
|
||||||
|
parts = [f"{k}={filtered[k]}" for k in sorted_keys]
|
||||||
|
raw = "&".join(parts) + self.key
|
||||||
|
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
|
||||||
|
|
||||||
|
def create_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: str,
|
||||||
|
name: str,
|
||||||
|
total_fee_yuan: str,
|
||||||
|
pay_type: str,
|
||||||
|
notify_url: str,
|
||||||
|
return_url: Optional[str] = None,
|
||||||
|
client_ip: Optional[str] = None,
|
||||||
|
extra: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
# ZPAY: alipay / wxpay(pay_type 可为业务类型或支付方式,此处按支付方式解析)
|
||||||
|
channel_type = "wxpay" if str(pay_type).lower() in ("wx", "wechat", "wxpay") else "alipay"
|
||||||
|
params = {
|
||||||
|
"pid": self.pid,
|
||||||
|
"type": channel_type,
|
||||||
|
"out_trade_no": order_id,
|
||||||
|
"notify_url": notify_url,
|
||||||
|
"name": name,
|
||||||
|
"money": total_fee_yuan,
|
||||||
|
"return_url": return_url or notify_url,
|
||||||
|
}
|
||||||
|
if client_ip:
|
||||||
|
params["clientip"] = client_ip
|
||||||
|
if extra and extra.get("param"):
|
||||||
|
params["param"] = str(extra["param"])
|
||||||
|
params["sign_type"] = "MD5"
|
||||||
|
params["sign"] = self._md5_sign(params)
|
||||||
|
|
||||||
|
# 页面跳转方式:返回 form 提交 URL 和参数
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"provider": self.name,
|
||||||
|
"params": params,
|
||||||
|
"pay_url": self.SUBMIT_URL,
|
||||||
|
"submit_method": "POST",
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_order_api(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: str,
|
||||||
|
name: str,
|
||||||
|
total_fee_yuan: str,
|
||||||
|
pay_type: str,
|
||||||
|
notify_url: str,
|
||||||
|
return_url: Optional[str] = None,
|
||||||
|
client_ip: str = "127.0.0.1",
|
||||||
|
device: str = "pc",
|
||||||
|
extra: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
ZPAY API 接口支付(返回 payurl/qrcode 等)
|
||||||
|
适用于需要二维码或 H5 跳转的场景
|
||||||
|
"""
|
||||||
|
channel_type = "wxpay" if pay_type.lower() in ("wx", "wechat", "wxpay") else "alipay"
|
||||||
|
params = {
|
||||||
|
"pid": self.pid,
|
||||||
|
"type": channel_type,
|
||||||
|
"out_trade_no": order_id,
|
||||||
|
"notify_url": notify_url,
|
||||||
|
"name": name,
|
||||||
|
"money": total_fee_yuan,
|
||||||
|
"clientip": client_ip or "127.0.0.1",
|
||||||
|
"device": device,
|
||||||
|
}
|
||||||
|
if return_url:
|
||||||
|
params["return_url"] = return_url
|
||||||
|
if extra and extra.get("param"):
|
||||||
|
params["param"] = str(extra["param"])
|
||||||
|
params["sign_type"] = "MD5"
|
||||||
|
params["sign"] = self._md5_sign(params)
|
||||||
|
|
||||||
|
resp = requests.post(self.MAPI_URL, data=params, timeout=15)
|
||||||
|
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
||||||
|
if isinstance(result, dict) and result.get("code") == 1:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"provider": self.name,
|
||||||
|
"payurl": result.get("payurl"),
|
||||||
|
"payurl2": result.get("payurl2"), # 微信 H5
|
||||||
|
"qrcode": result.get("qrcode"),
|
||||||
|
"img": result.get("img"),
|
||||||
|
"O_id": result.get("O_id"),
|
||||||
|
"trade_no": result.get("trade_no"),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"provider": self.name,
|
||||||
|
"msg": result.get("msg", resp.text),
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_notify(self, data: Dict[str, Any]) -> bool:
|
||||||
|
received = data.get("sign", "")
|
||||||
|
calculated = self._md5_sign(data)
|
||||||
|
return calculated == received
|
||||||
|
|
||||||
|
def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# ZPAY 通知:trade_status 为 TRADE_SUCCESS 表示成功
|
||||||
|
status = "success" if data.get("trade_status") == "TRADE_SUCCESS" else "pending"
|
||||||
|
return {
|
||||||
|
"order_id": data.get("out_trade_no", ""),
|
||||||
|
"trade_no": data.get("trade_no", ""),
|
||||||
|
"pay_price": data.get("money", ""),
|
||||||
|
"pay_time": "", # ZPAY 通知未明确返回
|
||||||
|
"trade_status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
def success_response(self) -> str:
|
||||||
|
return "success"
|
||||||
0
payment_notify.log
Normal file
0
payment_notify.log
Normal file
@@ -4,3 +4,4 @@ sqlalchemy
|
|||||||
pydantic
|
pydantic
|
||||||
python-multipart
|
python-multipart
|
||||||
pocketbase
|
pocketbase
|
||||||
|
requests
|
||||||
Reference in New Issue
Block a user