first commit
This commit is contained in:
commit
dcf7a239d4
BIN
unipay/.notify.py.swp
Normal file
BIN
unipay/.notify.py.swp
Normal file
Binary file not shown.
0
unipay/__init__.py
Normal file
0
unipay/__init__.py
Normal file
44
unipay/core.py
Normal file
44
unipay/core.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# unipay/core.py
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
class GatewayError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Gateway(ABC):
|
||||||
|
"""
|
||||||
|
抽象统一支付网关接口。
|
||||||
|
实现应异步(aiohttp)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_payment(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
创建支付订单。
|
||||||
|
payload 包含:amount, currency, subject/description, return_url, notify_url, customer/context 等
|
||||||
|
返回标准字典,包含至少:{"provider":"wechat|alipay|paypal|stripe", "data": {...}}
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def refund(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
发起退款。
|
||||||
|
payload 内常见字段:out_trade_no, out_refund_no, amount, total_amount, reason
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def query(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
查询订单或退款状态。
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def handle_notify(self, headers: Dict[str,str], body: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
验签并解密回调,返回解密后的标准 dict(包括 out_trade_no, status, attach 等)
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
109
unipay/init.py
Normal file
109
unipay/init.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# init.py
|
||||||
|
import os
|
||||||
|
from unipay.notify import get_provider
|
||||||
|
from ahserver.serverenv import ServerEnv
|
||||||
|
|
||||||
|
# 从 env 或配置载入 provider conf(这里只示例)
|
||||||
|
CONF = {
|
||||||
|
"wechat": {
|
||||||
|
"mchid": os.getenv("WXP_MCHID",""),
|
||||||
|
"serial_no": os.getenv("WXP_SERIAL",""),
|
||||||
|
"privkey_pem": open(os.getenv("WXP_PRIVKEY","./merchant_private_key.pem"),"rb").read(),
|
||||||
|
"platform_pub_pem": open(os.getenv("WXP_PLATFORM_PUB","./platform_pub.pem"),"rb").read(),
|
||||||
|
"api_v3_key": os.getenv("WXP_API_V3_KEY","").encode()
|
||||||
|
},
|
||||||
|
"paypal": {
|
||||||
|
"client_id": os.getenv("PP_ID",""),
|
||||||
|
"client_secret": os.getenv("PP_SECRET",""),
|
||||||
|
"sandbox": True
|
||||||
|
},
|
||||||
|
"alipay": {
|
||||||
|
"app_id": os.getenv("ALIPAY_APPID",""),
|
||||||
|
"privkey_pem": open(os.getenv("ALIPAY_PRIV","./alipay_priv.pem"),"rb").read(),
|
||||||
|
"alipay_pub_pem": open(os.getenv("ALIPAY_PUB","./alipay_pub.pem"),"rb").read(),
|
||||||
|
"sandbox": True
|
||||||
|
},
|
||||||
|
"stripe": {
|
||||||
|
"api_key": os.getenv("STRIPE_KEY","")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PROVIDERS = {}
|
||||||
|
|
||||||
|
|
||||||
|
# 下单接口(统一)
|
||||||
|
async def create_payment(request, params_kw=None):
|
||||||
|
if params_kw is None:
|
||||||
|
params_kw = request.paams_kw
|
||||||
|
data = params_kw
|
||||||
|
provider = data.get("provider")
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
return {"error":"unknown provider"}
|
||||||
|
try:
|
||||||
|
res = await PROVIDERS[provider].create_payment(data)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
# 查询
|
||||||
|
async def query_payment(request, params_kw=None):
|
||||||
|
if params_kw is None:
|
||||||
|
params_kw = request.paams_kw
|
||||||
|
data = params_kw
|
||||||
|
provider = data.get("provider")
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
return {"error":"unknown provider"}
|
||||||
|
try:
|
||||||
|
res = await PROVIDERS[provider].query(data)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
# 退款
|
||||||
|
async def refund_payment(request, params_kw=None):
|
||||||
|
if params_kw is None:
|
||||||
|
params_kw = request.paams_kw
|
||||||
|
data = params_kw
|
||||||
|
provider = data.get("provider")
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
return {"error":"unknown provider"}
|
||||||
|
try:
|
||||||
|
res = await PROVIDERS[provider].refund(data)
|
||||||
|
return res
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
# 回调入口:你可把厂商回调用各自 endpoint 再转发到这里,或在厂商控制台按各自 URL 配置
|
||||||
|
async def payment_notify(request, callback, params_kw=None):
|
||||||
|
if params_kw is None:
|
||||||
|
params_kw = request.paams_kw
|
||||||
|
data = params_kw
|
||||||
|
provider = params_kw.provider
|
||||||
|
headers = dict(request.headers)
|
||||||
|
body = await request.text()
|
||||||
|
try:
|
||||||
|
data = await PROVIDERS[provider].handle_notify(headers, body)
|
||||||
|
# 这里 data 应包含标准化字段:out_trade_no/status/attach 等
|
||||||
|
# TODO: 业务幂等处理
|
||||||
|
# 返回厂商要求的固定成功响应
|
||||||
|
await callback(request, data)
|
||||||
|
if provider == "wechat":
|
||||||
|
return {"code":"SUCCESS", "message":"OK"}
|
||||||
|
else:
|
||||||
|
return "OK"
|
||||||
|
except Exception as e:
|
||||||
|
return web.Response(status=500, text=str(e))
|
||||||
|
|
||||||
|
# callback url= "/unipay/notify/{provider}"
|
||||||
|
|
||||||
|
def load_unipay():
|
||||||
|
PROVIDERS["wechat"] = get_provider("wechat", CONF["wechat"]),
|
||||||
|
PROVIDERS["paypal"] = get_provider("paypal", CONF["paypal"]),
|
||||||
|
PROVIDERS["alipay"] = get_provider("alipay", CONF["alipay"]),
|
||||||
|
PROVIDERS["stripe"] = get_provider("stripe", CONF["stripe"])
|
||||||
|
env = ServerEnv()
|
||||||
|
env.payment_notify = payment_notify
|
||||||
|
env.create_payment = create_payment
|
||||||
|
env.query_payment = query_payment
|
||||||
|
env.refund_payment = refund_payment
|
||||||
|
|
||||||
19
unipay/notify.py
Normal file
19
unipay/notify.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# unipay/notify.py
|
||||||
|
from typing import Dict
|
||||||
|
from .providers.wechat import WechatGateway
|
||||||
|
from .providers.paypal import PayPalGateway
|
||||||
|
from .providers.alipay import AlipayGateway
|
||||||
|
from .providers.stripe import StripeGateway
|
||||||
|
|
||||||
|
# 简单工厂:你可以按需扩展配置注入
|
||||||
|
def get_provider(name: str, conf: Dict):
|
||||||
|
if name == "wechat":
|
||||||
|
return WechatGateway(**conf)
|
||||||
|
if name == "paypal":
|
||||||
|
return PayPalGateway(**conf)
|
||||||
|
if name == "alipay":
|
||||||
|
return AlipayGateway(**conf)
|
||||||
|
if name == "stripe":
|
||||||
|
return StripeGateway(**conf)
|
||||||
|
raise ValueError("unknown provider")
|
||||||
|
|
||||||
0
unipay/providers/__init__.py
Normal file
0
unipay/providers/__init__.py
Normal file
203
unipay/providers/alipay.py
Normal file
203
unipay/providers/alipay.py
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
# providers/alipay.py
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
from ..core import Gateway, GatewayError
|
||||||
|
from ..utils import safe_json_dumps
|
||||||
|
|
||||||
|
ALIPAY_GATEWAY = "https://openapi.alipay.com/gateway.do"
|
||||||
|
|
||||||
|
|
||||||
|
class AlipayProvider(GateWay):
|
||||||
|
"""
|
||||||
|
生产级支付宝接口(H5 支付 / 退款 / 查询 / 回调验签)
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app_id: str,
|
||||||
|
app_private_key_pem: str,
|
||||||
|
alipay_public_key_pem: str,
|
||||||
|
notify_url: str,
|
||||||
|
):
|
||||||
|
self.app_id = app_id
|
||||||
|
self.notify_url = notify_url
|
||||||
|
|
||||||
|
# -------- load keys -------
|
||||||
|
self._private_key = serialization.load_pem_private_key(
|
||||||
|
app_private_key_pem.encode(), password=None
|
||||||
|
)
|
||||||
|
self._alipay_public_key = serialization.load_pem_public_key(
|
||||||
|
alipay_public_key_pem.encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=20))
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 工具函数(签名 / 验签)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def _sign(self, unsigned_str: str) -> str:
|
||||||
|
"""
|
||||||
|
RSA2(SHA256)签名
|
||||||
|
"""
|
||||||
|
signature = self._private_key.sign(
|
||||||
|
unsigned_str.encode(),
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
hashes.SHA256(),
|
||||||
|
)
|
||||||
|
return base64.b64encode(signature).decode()
|
||||||
|
|
||||||
|
def _verify(self, data: str, sign: str) -> bool:
|
||||||
|
"""
|
||||||
|
验证支付宝回调签名
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._alipay_public_key.verify(
|
||||||
|
base64.b64decode(sign),
|
||||||
|
data.encode(),
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
hashes.SHA256()
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _build_sign_content(self, params: Dict[str, str]) -> str:
|
||||||
|
"""
|
||||||
|
生成 "k=v&k2=v2" 的签名串
|
||||||
|
"""
|
||||||
|
sorted_items = sorted((k, v) for k, v in params.items() if v is not None)
|
||||||
|
return "&".join(f"{k}={v}" for k, v in sorted_items)
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 支付 - H5 页面跳转
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
async def create_payment(self, *, out_trade_no: str, subject: str, amount: str) -> str:
|
||||||
|
"""
|
||||||
|
返回一个可以在 H5 里直接重定向的支付宝支付 URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
biz_content = {
|
||||||
|
"out_trade_no": out_trade_no,
|
||||||
|
"total_amount": amount,
|
||||||
|
"subject": subject,
|
||||||
|
"product_code": "QUICK_WAP_WAY"
|
||||||
|
}
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"app_id": self.app_id,
|
||||||
|
"method": "alipay.trade.wap.pay",
|
||||||
|
"format": "JSON",
|
||||||
|
"charset": "utf-8",
|
||||||
|
"sign_type": "RSA2",
|
||||||
|
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"version": "1.0",
|
||||||
|
"notify_url": self.notify_url,
|
||||||
|
"biz_content": json.dumps(biz_content, separators=(",", ":"))
|
||||||
|
}
|
||||||
|
|
||||||
|
# 生成签名
|
||||||
|
unsigned_str = self._build_sign_content(params)
|
||||||
|
sign = self._sign(unsigned_str)
|
||||||
|
params["sign"] = sign
|
||||||
|
|
||||||
|
query_str = urllib.parse.urlencode(params)
|
||||||
|
return f"{ALIPAY_GATEWAY}?{query_str}"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 查询订单
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
async def query(self, out_trade_no: str) -> Dict[str, Any]:
|
||||||
|
biz_content = {
|
||||||
|
"out_trade_no": out_trade_no,
|
||||||
|
}
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"app_id": self.app_id,
|
||||||
|
"method": "alipay.trade.query",
|
||||||
|
"format": "JSON",
|
||||||
|
"charset": "utf-8",
|
||||||
|
"sign_type": "RSA2",
|
||||||
|
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"version": "1.0",
|
||||||
|
"biz_content": json.dumps(biz_content, separators=(",", ":"))
|
||||||
|
}
|
||||||
|
|
||||||
|
# 签名
|
||||||
|
unsigned_str = self._build_sign_content(params)
|
||||||
|
params["sign"] = self._sign(unsigned_str)
|
||||||
|
|
||||||
|
async with self.session.post(ALIPAY_GATEWAY, data=params) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 退款
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
async def refund(self, *, out_trade_no: str, refund_amount: str, out_request_no: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
out_request_no 必须全局唯一(一个退款请求一个编号)
|
||||||
|
"""
|
||||||
|
|
||||||
|
biz_content = {
|
||||||
|
"out_trade_no": out_trade_no,
|
||||||
|
"refund_amount": refund_amount,
|
||||||
|
"out_request_no": out_request_no
|
||||||
|
}
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"app_id": self.app_id,
|
||||||
|
"method": "alipay.trade.refund",
|
||||||
|
"format": "JSON",
|
||||||
|
"charset": "utf-8",
|
||||||
|
"sign_type": "RSA2",
|
||||||
|
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"version": "1.0",
|
||||||
|
"biz_content": json.dumps(biz_content, separators=(",", ":"))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned_str = self._build_sign_content(params)
|
||||||
|
params["sign"] = self._sign(unsigned_str)
|
||||||
|
|
||||||
|
async with self.session.post(ALIPAY_GATEWAY, data=params) as resp:
|
||||||
|
return await resp.json()
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 回调 / 异步通知(验签)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
async def handle_notify(self, request) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
支付宝异步通知验签
|
||||||
|
"""
|
||||||
|
form = await request.post()
|
||||||
|
params = dict(form)
|
||||||
|
|
||||||
|
sign = params.pop("sign", None)
|
||||||
|
sign_type = params.pop("sign_type", None)
|
||||||
|
|
||||||
|
unsigned_str = self._build_sign_content(params)
|
||||||
|
|
||||||
|
if not sign:
|
||||||
|
return {"verified": False, "msg": "no sign"}
|
||||||
|
|
||||||
|
ok = self._verify(unsigned_str, sign)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"verified": ok,
|
||||||
|
"provider": "alipay",
|
||||||
|
"data": params,
|
||||||
|
}
|
||||||
|
|
||||||
151
unipay/providers/paypal.py
Normal file
151
unipay/providers/paypal.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
# providers/paypal.py
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
import os
|
||||||
|
from ..core import Gateway, GatewayError
|
||||||
|
from ..utils import safe_json_dumps
|
||||||
|
|
||||||
|
class PayPalGateway(Gateway):
|
||||||
|
def __init__(self, client_id: str, client_secret: str, sandbox: bool = True):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.sandbox = sandbox
|
||||||
|
self.base_url = "https://api-m.sandbox.paypal.com" if sandbox else "https://api-m.paypal.com"
|
||||||
|
self._token: t.Optional[str] = None
|
||||||
|
self._token_expiry: t.Optional[float] = None
|
||||||
|
|
||||||
|
async def _get_access_token(self) -> str:
|
||||||
|
if self._token and self._token_expiry and self._token_expiry > asyncio.get_event_loop().time():
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
url = f"{self.base_url}/v1/oauth2/token"
|
||||||
|
auth = aiohttp.BasicAuth(self.client_id, self.client_secret)
|
||||||
|
data = {"grant_type": "client_credentials"}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(url, data=data, auth=auth) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
r = await resp.json()
|
||||||
|
self._token = r["access_token"]
|
||||||
|
self._token_expiry = asyncio.get_event_loop().time() + r.get("expires_in", 300) - 10
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
async def create_payment(self, payload: t.Dict[str, t.Any]) -> str:
|
||||||
|
"""
|
||||||
|
创建 PayPal 订单
|
||||||
|
payload 必须包含:
|
||||||
|
- amount: 数值
|
||||||
|
- currency: 币种
|
||||||
|
- description / subject
|
||||||
|
- return_url
|
||||||
|
- notify_url
|
||||||
|
- out_trade_no: 商户订单号
|
||||||
|
"""
|
||||||
|
access_token = await self._get_access_token()
|
||||||
|
url = f"{self.base_url}/v2/checkout/orders"
|
||||||
|
|
||||||
|
# PayPal 订单 body
|
||||||
|
body = {
|
||||||
|
"intent": "CAPTURE",
|
||||||
|
"purchase_units": [
|
||||||
|
{
|
||||||
|
"amount": {
|
||||||
|
"currency_code": payload["currency"],
|
||||||
|
"value": f"{payload['amount']:.2f}"
|
||||||
|
},
|
||||||
|
"custom_id": payload["out_trade_no"], # 保留商户交易号
|
||||||
|
"description": payload.get("description") or payload.get("subject", "")
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"application_context": {
|
||||||
|
"return_url": payload["return_url"],
|
||||||
|
"cancel_url": payload.get("cancel_url", payload["return_url"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(url, json=body, headers={"Authorization": f"Bearer {access_token}"}) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = await resp.json()
|
||||||
|
# 获取 PayPal 端交易号
|
||||||
|
transaction_id = data["id"]
|
||||||
|
# 获取跳转 URL
|
||||||
|
approval_url = next((link["href"] for link in data.get("links", []) if link.get("rel") == "approve"), None)
|
||||||
|
return approval_url
|
||||||
|
# return {"provider": "paypal", "data": data, "out_trade_no": payload["out_trade_no"], "transaction_id": transaction_id}
|
||||||
|
|
||||||
|
async def refund(self, payload: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
|
||||||
|
"""
|
||||||
|
payload:
|
||||||
|
- transaction_id: PayPal 端交易号
|
||||||
|
- amount
|
||||||
|
- out_refund_no
|
||||||
|
"""
|
||||||
|
access_token = await self._get_access_token()
|
||||||
|
url = f"{self.base_url}/v2/payments/captures/{payload['transaction_id']}/refund"
|
||||||
|
body = {"amount": {"value": f"{payload['amount']:.2f}", "currency_code": payload.get("currency", "USD")}}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(url, json=body, headers={"Authorization": f"Bearer {access_token}"}) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
r = await resp.json()
|
||||||
|
return {
|
||||||
|
"out_trade_no": payload.get("out_trade_no"),
|
||||||
|
"refund_id": r.get("id"),
|
||||||
|
"status": r.get("status")
|
||||||
|
}
|
||||||
|
|
||||||
|
async def query(self, payload: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
|
||||||
|
"""
|
||||||
|
查询订单状态
|
||||||
|
payload:
|
||||||
|
- transaction_id: PayPal 端交易号
|
||||||
|
"""
|
||||||
|
access_token = await self._get_access_token()
|
||||||
|
url = f"{self.base_url}/v2/checkout/orders/{payload['transaction_id']}"
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, headers={"Authorization": f"Bearer {access_token}"}) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
r = await resp.json()
|
||||||
|
out_trade_no = None
|
||||||
|
if "purchase_units" in r and len(r["purchase_units"]) > 0:
|
||||||
|
out_trade_no = r["purchase_units"][0].get("custom_id")
|
||||||
|
return {
|
||||||
|
"out_trade_no": out_trade_no,
|
||||||
|
"transaction_id": r.get("id"),
|
||||||
|
"status": r.get("status"),
|
||||||
|
"data": r
|
||||||
|
}
|
||||||
|
|
||||||
|
async def handle_notify(self, headers: t.Dict[str,str], body: str) -> t.Dict[str, t.Any]:
|
||||||
|
"""
|
||||||
|
PayPal webhook 处理
|
||||||
|
返回 dict 包含:
|
||||||
|
- out_trade_no: 商户交易号
|
||||||
|
- transaction_id: PayPal 端交易号
|
||||||
|
- status: COMPLETED/REFUNDED 等
|
||||||
|
- attach: webhook 原始数据
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
event = json.loads(body)
|
||||||
|
out_trade_no = None
|
||||||
|
transaction_id = None
|
||||||
|
if event.get("resource"):
|
||||||
|
transaction_id = event["resource"].get("id")
|
||||||
|
# 先取 custom_id(创建订单时传入)
|
||||||
|
pu = event["resource"].get("purchase_units", [])
|
||||||
|
if pu and len(pu) > 0:
|
||||||
|
out_trade_no = pu[0].get("custom_id")
|
||||||
|
# fallback: invoice_id
|
||||||
|
if not out_trade_no and event["resource"].get("invoice_id"):
|
||||||
|
out_trade_no = event["resource"]["invoice_id"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": "paypal",
|
||||||
|
"out_trade_no": out_trade_no,
|
||||||
|
"transaction_id": transaction_id,
|
||||||
|
"status": event.get("event_type"),
|
||||||
|
"attach": event
|
||||||
|
}
|
||||||
|
|
||||||
39
unipay/providers/stripe.py
Normal file
39
unipay/providers/stripe.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# unipay/providers/stripe.py
|
||||||
|
import aiohttp, json
|
||||||
|
from ..core import Gateway, GatewayError
|
||||||
|
|
||||||
|
class StripeGateway(Gateway):
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base = "https://api.stripe.com/v1"
|
||||||
|
async def create_payment(self, payload):
|
||||||
|
# 使用 PaymentIntent -> 前端使用 stripe.js 完成卡片采集
|
||||||
|
url = self.base + "/payment_intents"
|
||||||
|
body = {
|
||||||
|
"amount": str(payload["amount_total"]), # in cents
|
||||||
|
"currency": payload.get("currency","usd"),
|
||||||
|
"payment_method_types[]": "card",
|
||||||
|
"description": payload.get("description",""),
|
||||||
|
"metadata[out_trade_no]": payload.get("out_trade_no","")
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as s:
|
||||||
|
async with s.post(url, data=body, auth=aiohttp.BasicAuth(self.api_key, "")) as r:
|
||||||
|
return {"provider":"stripe","data": await r.json()}
|
||||||
|
async def refund(self, payload):
|
||||||
|
url = self.base + "/refunds"
|
||||||
|
body = {"charge": payload["charge_id"], "amount": str(payload.get("refund_amount"))}
|
||||||
|
async with aiohttp.ClientSession() as s:
|
||||||
|
async with s.post(url, data=body, auth=aiohttp.BasicAuth(self.api_key, "")) as r:
|
||||||
|
return {"provider":"stripe","data": await r.json()}
|
||||||
|
async def query(self, payload):
|
||||||
|
# query payment intent or charge
|
||||||
|
pid = payload.get("payment_intent_id") or payload.get("charge_id")
|
||||||
|
if not pid:
|
||||||
|
raise GatewayError("need payment_intent_id or charge_id")
|
||||||
|
async with aiohttp.ClientSession() as s:
|
||||||
|
async with s.get(self.base + f"/payment_intents/{pid}", auth=aiohttp.BasicAuth(self.api_key, "")) as r:
|
||||||
|
return {"provider":"stripe","data": await r.json()}
|
||||||
|
async def handle_notify(self, headers, body):
|
||||||
|
# stripe webhook: verify signature header (Stripe-Signature) — production use official lib or implement verification
|
||||||
|
return {"provider":"stripe","data": json.loads(body)}
|
||||||
|
|
||||||
266
unipay/providers/wechat.py
Normal file
266
unipay/providers/wechat.py
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
# -*- coding:utf-8 -*-
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import aiohttp
|
||||||
|
from abc import ABC
|
||||||
|
from typing import Dict, Any
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
|
||||||
|
WECHAT_API_BASE = "https://api.mch.weixin.qq.com"
|
||||||
|
|
||||||
|
|
||||||
|
class WeChatGateway(ABC):
|
||||||
|
"""
|
||||||
|
微信支付网关(适配统一 Gateway 接口)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
mchid: str,
|
||||||
|
appid: str,
|
||||||
|
cert_serial_no: str,
|
||||||
|
private_key_pem: str,
|
||||||
|
api_v3_key: str):
|
||||||
|
|
||||||
|
self.mchid = mchid
|
||||||
|
self.appid = appid
|
||||||
|
self.cert_serial_no = cert_serial_no
|
||||||
|
self.api_v3_key = api_v3_key.encode()
|
||||||
|
|
||||||
|
# 加载私钥
|
||||||
|
self._private_key = serialization.load_pem_private_key(
|
||||||
|
private_key_pem.encode(),
|
||||||
|
password=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 缓存微信平台证书
|
||||||
|
self._platform_certs = {} # serial_no -> public_key
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 工具方法:微信签名 V3
|
||||||
|
# -----------------------------------------------------
|
||||||
|
def _sign(self, method: str, url_path: str, body: str) -> str:
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
nonce_str = base64.urlsafe_b64encode(
|
||||||
|
json.dumps(time.time()).encode()
|
||||||
|
).decode()[:16]
|
||||||
|
|
||||||
|
message = f"{method}\n{url_path}\n{timestamp}\n{nonce_str}\n{body}\n"
|
||||||
|
|
||||||
|
signature = self._private_key.sign(
|
||||||
|
message.encode(),
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
hashes.SHA256()
|
||||||
|
)
|
||||||
|
signature_b64 = base64.b64encode(signature).decode()
|
||||||
|
|
||||||
|
token = (
|
||||||
|
f'mchid="{self.mchid}",'
|
||||||
|
f'nonce_str="{nonce_str}",'
|
||||||
|
f'signature="{signature_b64}",'
|
||||||
|
f'timestamp="{timestamp}",'
|
||||||
|
f'serial_no="{self.cert_serial_no}"'
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 工具:下载平台证书
|
||||||
|
# -----------------------------------------------------
|
||||||
|
async def _ensure_platform_cert(self, session: aiohttp.ClientSession):
|
||||||
|
url_path = "/v3/certificates"
|
||||||
|
url = WECHAT_API_BASE + url_path
|
||||||
|
auth = self._sign("GET", url_path, "")
|
||||||
|
|
||||||
|
async with session.get(url, headers={
|
||||||
|
"Authorization": f"WECHATPAY2-SHA256-RSA2048 {auth}",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
|
||||||
|
for cert in data.get("data", []):
|
||||||
|
sn = cert["serial_no"]
|
||||||
|
encrypt_info = cert["encrypt_certificate"]
|
||||||
|
|
||||||
|
# 解密平台证书
|
||||||
|
aesgcm = AESGCM(self.api_v3_key)
|
||||||
|
pub_pem = aesgcm.decrypt(
|
||||||
|
nonce=encrypt_info["nonce"].encode(),
|
||||||
|
data=base64.b64decode(encrypt_info["ciphertext"]),
|
||||||
|
associated_data=encrypt_info["associated_data"].encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
pub_key = serialization.load_pem_public_key(pub_pem.encode())
|
||||||
|
self._platform_certs[sn] = pub_key
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 工具:验签回调
|
||||||
|
# -----------------------------------------------------
|
||||||
|
async def _verify_callback(self, headers: Dict[str, str], body: str):
|
||||||
|
serial = headers.get("Wechatpay-Serial")
|
||||||
|
timestamp = headers.get("Wechatpay-Timestamp")
|
||||||
|
nonce = headers.get("Wechatpay-Nonce")
|
||||||
|
signature = headers.get("Wechatpay-Signature")
|
||||||
|
|
||||||
|
if serial not in self._platform_certs:
|
||||||
|
# 自动拉取平台证书
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
await self._ensure_platform_cert(session)
|
||||||
|
|
||||||
|
pub_key = self._platform_certs.get(serial)
|
||||||
|
if pub_key is None:
|
||||||
|
raise ValueError("Platform cert not found")
|
||||||
|
|
||||||
|
message = f"{timestamp}\n{nonce}\n{body}\n".encode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
pub_key.verify(
|
||||||
|
base64.b64decode(signature),
|
||||||
|
message,
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
hashes.SHA256()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("Invalid callback signature")
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 工具:解密 resource.ciphertext
|
||||||
|
# -----------------------------------------------------
|
||||||
|
def _decrypt_resource(self, resource: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
aesgcm = AESGCM(self.api_v3_key)
|
||||||
|
plaintext = aesgcm.decrypt(
|
||||||
|
nonce=resource["nonce"].encode(),
|
||||||
|
data=base64.b64decode(resource["ciphertext"]),
|
||||||
|
associated_data=resource["associated_data"].encode()
|
||||||
|
).decode()
|
||||||
|
return json.loads(plaintext)
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 实现统一接口
|
||||||
|
# -----------------------------------------------------
|
||||||
|
async def create_payment(self, payload: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
H5 下单(默认 H5,可改为 jsapi/native)
|
||||||
|
"""
|
||||||
|
body = {
|
||||||
|
"appid": self.appid,
|
||||||
|
"mchid": self.mchid,
|
||||||
|
"description": payload["subject"],
|
||||||
|
"out_trade_no": payload["out_trade_no"],
|
||||||
|
"notify_url": payload["notify_url"],
|
||||||
|
"amount": {
|
||||||
|
"total": int(payload["amount"]),
|
||||||
|
"currency": payload.get("currency", "CNY")
|
||||||
|
},
|
||||||
|
"scene_info": {
|
||||||
|
"payer_client_ip": payload.get("client_ip", "127.0.0.1"),
|
||||||
|
"h5_info": {"type": "Wap"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body_str = json.dumps(body, ensure_ascii=False)
|
||||||
|
|
||||||
|
url_path = "/v3/pay/transactions/h5"
|
||||||
|
url = WECHAT_API_BASE + url_path
|
||||||
|
auth = self._sign("POST", url_path, body_str)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(url, data=body_str, headers={
|
||||||
|
"Authorization": f"WECHATPAY2-SHA256-RSA2048 {auth}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}) as resp:
|
||||||
|
ret = await resp.json()
|
||||||
|
return ret.get("h5_url")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": "wechat",
|
||||||
|
"data": {
|
||||||
|
"out_trade_no": body["out_trade_no"],
|
||||||
|
"h5_url": ret.get("h5_url"),
|
||||||
|
"wx_transaction_id": ret.get("transaction_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
async def refund(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
body = {
|
||||||
|
"out_trade_no": payload["out_trade_no"],
|
||||||
|
"out_refund_no": payload["out_refund_no"],
|
||||||
|
"amount": {
|
||||||
|
"refund": int(payload["amount"]),
|
||||||
|
"total": int(payload["total_amount"]),
|
||||||
|
"currency": payload.get("currency", "CNY")
|
||||||
|
},
|
||||||
|
"reason": payload.get("reason")
|
||||||
|
}
|
||||||
|
body_str = json.dumps(body, ensure_ascii=False)
|
||||||
|
|
||||||
|
url_path = "/v3/refund/domestic/refunds"
|
||||||
|
url = WECHAT_API_BASE + url_path
|
||||||
|
auth = self._sign("POST", url_path, body_str)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(url, data=body_str, headers={
|
||||||
|
"Authorization": f"WECHATPAY2-SHA256-RSA2048 {auth}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}) as resp:
|
||||||
|
ret = await resp.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": "wechat",
|
||||||
|
"data": ret
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
async def query(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
订单查询或退款查询
|
||||||
|
支持:
|
||||||
|
/v3/pay/transactions/out-trade-no/{out_trade_no}
|
||||||
|
/v3/refund/domestic/refunds/{out_refund_no}
|
||||||
|
"""
|
||||||
|
if "out_trade_no" in payload:
|
||||||
|
url_path = f"/v3/pay/transactions/out-trade-no/{payload['out_trade_no']}?mchid={self.mchid}"
|
||||||
|
else:
|
||||||
|
url_path = f"/v3/refund/domestic/refunds/{payload['out_refund_no']}"
|
||||||
|
|
||||||
|
url = WECHAT_API_BASE + url_path
|
||||||
|
auth = self._sign("GET", url_path, "")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, headers={
|
||||||
|
"Authorization": f"WECHATPAY2-SHA256-RSA2048 {auth}",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}) as resp:
|
||||||
|
ret = await resp.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": "wechat",
|
||||||
|
"data": ret
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
async def handle_notify(self, headers: Dict[str, str], body: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
解密/验证回调,返回标准 dict:
|
||||||
|
out_trade_no
|
||||||
|
transaction_id
|
||||||
|
trade_state
|
||||||
|
payer info
|
||||||
|
"""
|
||||||
|
await self._verify_callback(headers, body)
|
||||||
|
|
||||||
|
payload = json.loads(body)
|
||||||
|
resource = payload["resource"]
|
||||||
|
decrypted = self._decrypt_resource(resource)
|
||||||
|
|
||||||
|
# 返回标准结构
|
||||||
|
return {
|
||||||
|
"provider": "wechat",
|
||||||
|
"data": decrypted,
|
||||||
|
"out_trade_no": decrypted.get("out_trade_no"),
|
||||||
|
"trans_no": decrypted.get("transaction_id"),
|
||||||
|
"status": decrypted.get("trade_state")
|
||||||
|
}
|
||||||
|
|
||||||
11
unipay/utils.py
Normal file
11
unipay/utils.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# unipay/utils.py
|
||||||
|
import time, secrets, json
|
||||||
|
def now_ts() -> str:
|
||||||
|
return str(int(time.time()))
|
||||||
|
|
||||||
|
def nonce_str(n=32):
|
||||||
|
return secrets.token_hex(n//2)
|
||||||
|
|
||||||
|
def safe_json_dumps(obj):
|
||||||
|
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
5
wwwroot/notify/alipay/index.dspy
Normal file
5
wwwroot/notify/alipay/index.dspy
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
debug(f'/unipay/notify/alipay/index.dspy:{params_kw}')
|
||||||
|
ns = params_kw.copy()
|
||||||
|
ns.provider = 'alipay'
|
||||||
|
return await payment_notify(request, recharge_accounting, params_kw=ns)
|
||||||
|
|
||||||
5
wwwroot/notify/paypal/index.dspy
Normal file
5
wwwroot/notify/paypal/index.dspy
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
debug(f'/unipay/notify/paypal/index.dspy:{params_kw}')
|
||||||
|
ns = params_kw.copy()
|
||||||
|
ns.provider = 'paypal'
|
||||||
|
return await payment_notify(request, recharge_accounting, params_kw=ns)
|
||||||
|
|
||||||
5
wwwroot/notify/stripe/index.dspy
Normal file
5
wwwroot/notify/stripe/index.dspy
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
debug(f'/unipay/notify/stripe/index.dspy:{params_kw}')
|
||||||
|
ns = params_kw.copy()
|
||||||
|
ns.provider = 'stripe'
|
||||||
|
return await payment_notify(request, recharge_accounting, params_kw=ns)
|
||||||
|
|
||||||
6
wwwroot/notify/wechat/index.dspy
Normal file
6
wwwroot/notify/wechat/index.dspy
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
debug(f'/unipay/notify/wechat/index.dspy:{params_kw}')
|
||||||
|
ns = params_kw.copy()
|
||||||
|
ns.provider = 'wechat'
|
||||||
|
return await payment_notify(request, recharge_accounting, params_kw=ns)
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user