This commit is contained in:
eric
2026-03-08 01:24:59 -06:00
parent c291e95a39
commit f725104c8a
19 changed files with 645 additions and 172 deletions

22
.env.example Normal file
View 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

View File

@@ -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

Binary file not shown.

Binary file not shown.

35
app/config.py Normal file
View 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")

View File

@@ -1,15 +1,30 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import hashlib
import time
"""
支付 API 主入口
支持多支付渠道XorPay、ZPAY通过 PAYMENT_PROVIDER 环境变量切换
"""
import logging
import random
import string
import time
from typing import Dict, Any
import requests
from fastapi import Request, HTTPException
import logging
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
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.add_middleware(
@@ -20,57 +35,30 @@ app.add_middleware(
allow_headers=["*"],
)
# ==================== PocketBase 配置 ====================
PB_URL = "https://pocketbase.hackrobot.cn"
PB_ADMIN_EMAIL = "xiaoshuang.eric@gmail.com"
PB_ADMIN_PASSWORD = "Xiao4669805@"
# ========================================================
# 日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(message)s",
handlers=[
logging.FileHandler("payment_notify.log"),
logging.StreamHandler(),
],
)
# 已处理订单(防重复)
processed_orders: set[str] = set()
def get_pb_client():
client = PocketBase(PB_URL)
try:
client.admins.auth_with_password(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
print("PocketBase admin 登录成功(动态)")
print("PocketBase admin 登录成功")
except Exception as e:
print("PocketBase admin 登录失败:", e)
raise e
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"):
try:
@@ -88,11 +76,11 @@ def create_memos_user(user_id: str, password: str = "123456"):
"username": username,
"password": password,
"role": "USER",
"state": "NORMAL"
"state": "NORMAL",
}
memos_headers = {
"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)
@@ -113,15 +101,89 @@ def create_memos_user(user_id: str, password: str = "123456"):
logging.error(f"创建 memos 用户异常 - username: {user_id}, error: {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")
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}"
user_id = item.get('user_id', 'unknown')
pay_type = item.get('type', 'unknown').lower()
user_id = item.get("user_id", "unknown")
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())
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, "商品支付")
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 = {
"name": name,
"pay_type": "jsapi",
"price": total_fee_yuan,
"order_id": order_id,
"notify_url": notify_url,
}
# 支付方式:前端可传 channel=alipay|wxpayZPAY 使用XorPay 固定 jsapi
channel = item.get("channel", "alipay")
params["sign"] = xorpay_sign(
params["name"],
params["pay_type"],
params["price"],
params["order_id"],
params["notify_url"]
result = provider.create_order(
order_id=order_id,
name=name,
total_fee_yuan=total_fee_yuan,
pay_type=channel,
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 {
"status": "ok",
"xorpay_params": result.get("params", {}),
"xorpay_url": result.get("pay_url", ""),
"provider": provider.name,
}
return {
"status": "ok",
"xorpay_params": params,
"xorpay_url": f"https://xorpay.com/api/cashier/{AID}"
"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")
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:
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}")
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):
username: str = None
password: str = None
username: str | None = None
password: str | None = None
@app.post("/create_user")
async def create_user(item: CreateUserRequest):
@@ -298,13 +342,13 @@ async def create_user(item: CreateUserRequest):
"username": item.username or f"user{timenew}",
"password": item.password or "123456",
"role": "USER",
"state": "NORMAL"
"state": "NORMAL",
}
memos_headers = {
"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_result = memos_resp.json()
print('memos_result-', memos_result)
print("memos_result-", memos_result)
return memos_result

16
app/payment/__init__.py Normal file
View 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",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

59
app/payment/base.py Normal file
View 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
View 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
View 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
View 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 / wxpaypay_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
View File

View File

@@ -4,3 +4,4 @@ sqlalchemy
pydantic
python-multipart
pocketbase
requests