测试中,尝试接入支付宝(沙箱)
This commit is contained in:
196
jituan/services/alipay_pay.py
Normal file
196
jituan/services/alipay_pay.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""支付宝支付(RSA2)配置、签名、验签与 wap.pay 跳转 URL 生成。
|
||||
|
||||
沙箱/生产由 settings.ALIPAY_SANDBOX 开关控制,配置存 app_secrets.py。
|
||||
多 club 暂回落全局配置(与 wechat_pay 一致),未来再迁移 Club 表。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from jituan.constants import CLUB_ID_DEFAULT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ==================== 配置读取 ====================
|
||||
|
||||
def get_alipay_config(club_id=None):
|
||||
"""
|
||||
读取支付宝配置。
|
||||
根据 settings.ALIPAY_SANDBOX 开关返回沙箱或生产配置。
|
||||
club_id 暂未使用(预留多 club 扩展),统一回落全局配置。
|
||||
"""
|
||||
sandbox = getattr(settings, 'ALIPAY_SANDBOX', True)
|
||||
if sandbox:
|
||||
cfg = getattr(settings, 'ALIPAY_SANDBOX_CFG', {})
|
||||
else:
|
||||
cfg = getattr(settings, 'ALIPAY_PROD', {})
|
||||
|
||||
return {
|
||||
'APP_ID': cfg.get('APP_ID', ''),
|
||||
'PID': cfg.get('PID', ''),
|
||||
'APP_PRIVATE_KEY': cfg.get('APP_PRIVATE_KEY', ''),
|
||||
'ALIPAY_PUBLIC_KEY': cfg.get('ALIPAY_PUBLIC_KEY', ''),
|
||||
'GATEWAY': cfg.get('GATEWAY', 'https://openapi.alipay.com/gateway.do'),
|
||||
'NOTIFY_URL': cfg.get('NOTIFY_URL', ''),
|
||||
'RETURN_URL': cfg.get('RETURN_URL', ''),
|
||||
'sandbox': sandbox,
|
||||
}
|
||||
|
||||
|
||||
# ==================== 密钥加载 ====================
|
||||
|
||||
def _load_private_key(key_str):
|
||||
"""加载 RSA 私钥,兼容 PEM 格式与裸 Base64(DER)格式。"""
|
||||
if isinstance(key_str, bytes):
|
||||
key_str = key_str.decode('utf-8')
|
||||
|
||||
if '-----BEGIN' in key_str:
|
||||
return serialization.load_pem_private_key(
|
||||
key_str.encode('utf-8'), password=None,
|
||||
)
|
||||
|
||||
der_bytes = base64.b64decode(key_str)
|
||||
return serialization.load_der_private_key(der_bytes, password=None)
|
||||
|
||||
|
||||
def _load_public_key(key_str):
|
||||
"""加载支付宝公钥,兼容 PEM 格式与裸 Base64(DER)格式。"""
|
||||
if not key_str:
|
||||
return None
|
||||
if isinstance(key_str, bytes):
|
||||
key_str = key_str.decode('utf-8')
|
||||
|
||||
if '-----BEGIN' in key_str:
|
||||
return serialization.load_pem_public_key(key_str.encode('utf-8'))
|
||||
|
||||
der_bytes = base64.b64decode(key_str)
|
||||
return serialization.load_der_public_key(der_bytes)
|
||||
|
||||
|
||||
# ==================== 签名 / 验签 ====================
|
||||
|
||||
def _build_sign_content(params):
|
||||
"""构建待签名字符串:按 key 字典序排列,排除 sign/sign_type,空值不参与。"""
|
||||
sorted_keys = sorted(k for k in params if k not in ('sign', 'sign_type'))
|
||||
return '&'.join(f'{k}={params[k]}' for k in sorted_keys if params[k] != '')
|
||||
|
||||
|
||||
def sign_rsa2(content, private_key_str):
|
||||
"""RSA2 (SHA256WithRSA) 签名,返回 Base64 编码的签名字符串。"""
|
||||
private_key = _load_private_key(private_key_str)
|
||||
signature = private_key.sign(
|
||||
content.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return base64.b64encode(signature).decode('ascii')
|
||||
|
||||
|
||||
def verify_rsa2(content, sign_b64, public_key_str):
|
||||
"""使用支付宝公钥验证 RSA2 签名。"""
|
||||
public_key = _load_public_key(public_key_str)
|
||||
if public_key is None:
|
||||
return False
|
||||
try:
|
||||
signature = base64.b64decode(sign_b64)
|
||||
public_key.verify(
|
||||
signature,
|
||||
content.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning('支付宝验签失败: %s', e)
|
||||
return False
|
||||
|
||||
|
||||
def verify_notify_params(params, club_id=None):
|
||||
"""
|
||||
验证支付宝异步回调签名。
|
||||
params 为已解析的字典(来自 POST form 数据)。
|
||||
"""
|
||||
cfg = get_alipay_config(club_id)
|
||||
sign = params.get('sign', '')
|
||||
if not sign:
|
||||
return False
|
||||
sign_type = params.get('sign_type', '')
|
||||
if sign_type and sign_type.upper() != 'RSA2':
|
||||
return False
|
||||
sign_content = _build_sign_content(params)
|
||||
return verify_rsa2(sign_content, sign, cfg['ALIPAY_PUBLIC_KEY'])
|
||||
|
||||
|
||||
# ==================== wap.pay 跳转 URL ====================
|
||||
|
||||
def build_wap_pay_url(out_trade_no, amount, subject, body='', club_id=None, expire_minutes=30):
|
||||
"""
|
||||
生成 alipay.trade.wap.pay 跳转 URL(GET 方式)。
|
||||
|
||||
参数:
|
||||
out_trade_no: 商户订单号(对应 Order.OrderID)
|
||||
amount: 订单金额(Decimal 或字符串,单位元)
|
||||
subject: 订单标题
|
||||
body: 订单描述(可选)
|
||||
club_id: 预留多 club(暂未使用)
|
||||
expire_minutes: 超时关闭时间(分钟)
|
||||
|
||||
返回:
|
||||
完整的支付宝跳转 URL,前端 web-view 直接加载即可。
|
||||
"""
|
||||
cfg = get_alipay_config(club_id)
|
||||
|
||||
biz_content = {
|
||||
'out_trade_no': out_trade_no,
|
||||
'total_amount': str(amount),
|
||||
'subject': subject or '订单支付',
|
||||
'body': body or '',
|
||||
'product_code': 'QUICK_WAP_WAY',
|
||||
'timeout_express': f'{expire_minutes}m',
|
||||
}
|
||||
|
||||
params = {
|
||||
'app_id': cfg['APP_ID'],
|
||||
'method': 'alipay.trade.wap.pay',
|
||||
'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=(',', ':'), ensure_ascii=False),
|
||||
'notify_url': cfg['NOTIFY_URL'],
|
||||
'return_url': cfg['RETURN_URL'],
|
||||
}
|
||||
|
||||
# 签名
|
||||
sign_content = _build_sign_content(params)
|
||||
params['sign'] = sign_rsa2(sign_content, cfg['APP_PRIVATE_KEY'])
|
||||
|
||||
# 拼接 GET URL
|
||||
parts = []
|
||||
for k in sorted(params.keys()):
|
||||
parts.append(f'{k}={quote(str(params[k]), safe="")}')
|
||||
query_string = '&'.join(parts)
|
||||
|
||||
return f"{cfg['GATEWAY']}?{query_string}"
|
||||
|
||||
|
||||
# ==================== 辅助 ====================
|
||||
|
||||
def club_id_from_order(order_id):
|
||||
"""从订单反查 club_id(与 wechat_pay 一致,暂回落默认)。"""
|
||||
from orders.models import Order
|
||||
try:
|
||||
row = Order.query.filter(OrderID=order_id).first()
|
||||
if row and getattr(row, 'ClubID', None):
|
||||
return row.ClubID
|
||||
except Exception:
|
||||
pass
|
||||
return CLUB_ID_DEFAULT
|
||||
Reference in New Issue
Block a user