测试中,尝试接入支付宝(沙箱)

This commit is contained in:
2026-07-09 01:51:18 +08:00
parent d64042fe46
commit 9c11698660
5 changed files with 400 additions and 12 deletions

View 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 格式与裸 Base64DER格式。"""
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 格式与裸 Base64DER格式。"""
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 跳转 URLGET 方式)。
参数:
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

View File

@@ -1,6 +1,6 @@
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from .views import CreateOrderView, WechatPayNotifyView, \
from .views import CreateOrderView, WechatPayNotifyView, AlipayPayNotifyView, alipay_pay_page, \
PaymentFailView, PaymentVerifyView, DingdanHuoquView, DingdanXiangqingView, JiedanView, \
ShangpinLeixingView, ShangjiaPaifaView, ShangjiaDingdanHuoquView, ShangjiaJiesuanView, \
ShangjiaDingdanXiangqingView, ShangjiaChufaShenqingView, ShangjiaTuikuanShenqingView, \
@@ -18,6 +18,8 @@ urlpatterns = [
path('xiadan', CreateOrderView.as_view(), name='下单订单生成接口'),
#path('pay-notify/', WechatPayNotifyView.as_view(), name='老板下单微信支付回调'),
path('pay-notify/', csrf_exempt(WechatPayNotifyView.as_view()), name='老板下单微信支付回调'),
path('alipay-notify/', csrf_exempt(AlipayPayNotifyView.as_view()), name='老板下单支付宝支付回调'),
path('alipay-pay/', csrf_exempt(alipay_pay_page), name='支付宝H5中转页'),
path('shibai', PaymentFailView.as_view(), name='老板支付失败接口'),
path('fucha', PaymentVerifyView.as_view(), name='老板支付成功复查接口'),
path('dingdanhuoqu', DingdanHuoquView.as_view(), name='老板订单获取'),

View File

@@ -5,8 +5,10 @@ Preserves external `from orders.views import X` for orders/urls.py and merchant_
from .payment import (
PaymentFailView,
WechatPayNotifyView,
AlipayPayNotifyView,
PaymentVerifyView,
CreateOrderView,
alipay_pay_page,
)
from .boss_orders import (
DingdanHuoquView,
@@ -66,7 +68,8 @@ from .merchant_penalty import (
)
__all__ = [
"PaymentFailView", "WechatPayNotifyView", "PaymentVerifyView", "CreateOrderView",
"PaymentFailView", "WechatPayNotifyView", "AlipayPayNotifyView", "PaymentVerifyView", "CreateOrderView",
"alipay_pay_page",
"DingdanHuoquView", "DingdanXiangqingView", "JiedanView",
"DingdanXiangqingView2", "JiedanView2",
"ShangpinLeixingView", "ShangjiaPaifaView", "ShangjiaDingdanHuoquView",

View File

@@ -22,6 +22,7 @@ from django.db.models import F, Q, OuterRef, Subquery
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponse
from django.shortcuts import render
from django.utils import timezone
from rest_framework.views import APIView
@@ -415,6 +416,60 @@ class WechatPayNotifyView(APIView):
return '游戏订单'
class AlipayPayNotifyView(WechatPayNotifyView):
"""支付宝支付异步回调接口 - 接收支付宝官方支付结果通知。
继承 WechatPayNotifyView 以复用 _handle_success/_handle_failure 履约逻辑。
"""
def post(self, request):
try:
# 支付宝回调为 form-urlencoded
params = dict(request.POST) if request.POST else {}
if not params:
logger.warning("支付宝回调: 收到空数据")
return HttpResponse('fail', content_type='text/plain')
out_trade_no = params.get('out_trade_no', '')
trade_no = params.get('trade_no', '')
trade_status = params.get('trade_status', '')
logger.info(f"支付宝回调: out_trade_no={out_trade_no}, trade_no={trade_no}, trade_status={trade_status}")
# 验签
from jituan.services.alipay_pay import verify_notify_params, club_id_from_order
notify_club_id = club_id_from_order(out_trade_no)
if not verify_notify_params(params, notify_club_id):
logger.warning(f"支付宝验签失败: out_trade_no={out_trade_no}")
return HttpResponse('fail', content_type='text/plain')
# 处理支付结果
if trade_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
self._handle_success(out_trade_no, trade_no, None)
else:
logger.info(f"支付宝支付状态非成功: {trade_status}, out_trade_no={out_trade_no}")
# 支付宝要求返回 "success"(纯文本),否则会重复通知
return HttpResponse('success', content_type='text/plain')
except Exception as e:
logger.error(f"支付宝回调处理异常: {str(e)}")
logger.error(traceback.format_exc())
return HttpResponse('fail', content_type='text/plain')
def alipay_pay_page(request):
"""支付宝H5中转页 - 中转跳转到支付宝支付,以及支付完成后的返回页。"""
pay_url = request.GET.get('pay_url', '')
out_trade_no = request.GET.get('out_trade_no', '')
if pay_url:
# 中转模式:自动跳转到支付宝
return render(request, 'alipay/pay.html', {'pay_url': pay_url})
elif out_trade_no:
# 返回模式:支付完成返回
return render(request, 'alipay/pay.html', {'out_trade_no': out_trade_no})
else:
return render(request, 'alipay/pay.html', {})
class PaymentVerifyView(APIView):
"""
@@ -672,6 +727,7 @@ class CreateOrderView(APIView):
beizhu = request.data.get('Remark', '').strip()
zhiding = request.data.get('zhiding', '').strip()
jine = request.data.get('jine', 0)
pay_method = request.data.get('pay_method', 'wechat').strip().lower()
# 2. 验证必填字段
if not shangpin_id:
@@ -766,7 +822,8 @@ class CreateOrderView(APIView):
laoban_yonghuid = getattr(current_user, 'UserUID', '') or getattr(current_user, 'yonghuid', '')
user_openid, club_id = get_payment_openid(request, current_user, club_id)
if not user_openid:
# 支付宝支付不需要 openid
if pay_method == 'wechat' and not user_openid:
return Response({'code': 10, 'msg': '用户openid不存在', 'data': None})
# 创建订单主表数据
@@ -843,7 +900,15 @@ class CreateOrderView(APIView):
PlatformOrderExt.query.create(**pingtaikuozhan_data)'''
# 7. 生成微信支付参数(向微信官方发送请求)
# 7. 根据支付方式生成支付参数
if pay_method == 'alipay':
pay_params = self.generate_alipay_pay_params(
dingdanid=dingdanid,
jine=shangpin_jiage,
subject=getattr(settings, 'WEIXIN_PAY_DESCRIPTION', '星阙订单'),
club_id=club_id,
)
else:
pay_params = self.generate_wechat_pay_params(
dingdanid=dingdanid,
jine=shangpin_jiage,
@@ -863,6 +928,7 @@ class CreateOrderView(APIView):
'msg': '订单创建成功',
'data': {
'dingdanid': dingdanid,
'payMethod': pay_method,
'payParams': pay_params
}
}
@@ -1010,5 +1076,25 @@ class CreateOrderView(APIView):
except Exception as e:
raise e
def generate_alipay_pay_params(self, dingdanid, jine, subject, club_id=None):
"""
生成支付宝 wap.pay 跳转 URL。
返回前端用于 web-view 加载的支付参数。
"""
try:
from jituan.services.alipay_pay import build_wap_pay_url
pay_url = build_wap_pay_url(
out_trade_no=dingdanid,
amount=jine,
subject=subject,
club_id=club_id,
)
return {
'pay_url': pay_url,
'pay_method': 'alipay',
}
except Exception as e:
raise e

101
templates/alipay/pay.html Normal file
View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>支付宝支付</title>
<script src="https://res.wx.qq.com/open/js/jweixin-1.3.2.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; }
.container { max-width: 420px; margin: 0 auto; padding: 40px 20px; text-align: center; }
.icon { width: 80px; height: 80px; margin: 0 auto 20px; }
.icon-loading { border: 4px solid #ddd; border-top: 4px solid #1677ff; border-radius: 50%; animation: spin 1s linear infinite; }
.icon-success { background: #07c160; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.icon-success::after { content: ''; width: 30px; height: 16px; border: 4px solid #fff; border-top: 0; border-right: 0; transform: rotate(-45deg) translate(2px, -4px); }
.title { font-size: 18px; color: #333; margin-bottom: 12px; }
.subtitle { font-size: 14px; color: #999; margin-bottom: 30px; }
.btn { display: inline-block; width: 100%; padding: 14px; font-size: 16px; border: none; border-radius: 8px; cursor: pointer; }
.btn-primary { background: #1677ff; color: #fff; }
.btn-primary:active { background: #0958d9; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
{% if pay_url %}
{# ===== 中转模式:自动跳转到支付宝 ===== #}
<div class="icon icon-loading"></div>
<div class="title">正在跳转到支付宝...</div>
<div class="subtitle">请稍候</div>
<script>
window.location.href = '{{ pay_url|escapejs }}';
</script>
{% else %}
{# ===== 返回模式:支付完成后的返回页 ===== #}
<div class="icon icon-loading" id="status-icon"></div>
<div class="title" id="status-title">正在确认支付结果...</div>
<div class="subtitle" id="status-subtitle">请稍候</div>
<button class="btn btn-primary" id="back-btn" style="display:none;" onclick="goBack()">返回小程序</button>
<script>
var orderNo = '{{ out_trade_no|escapejs }}';
var pollCount = 0;
var maxPoll = 15;
var btnShown = false;
function showSuccess() {
document.getElementById('status-icon').className = 'icon icon-success';
document.getElementById('status-title').innerText = '支付成功';
document.getElementById('status-subtitle').innerText = '订单号: ' + orderNo;
document.getElementById('back-btn').style.display = 'inline-block';
btnShown = true;
}
function showTimeout() {
document.getElementById('status-title').innerText = '支付确认超时';
document.getElementById('status-subtitle').innerText = '如已完成支付,请点击下方按钮返回';
document.getElementById('back-btn').style.display = 'inline-block';
btnShown = true;
}
function goBack() {
// 尝试通过微信 JSSDK 返回小程序
if (typeof wx !== 'undefined' && wx.miniProgram) {
wx.miniProgram.navigateBack();
} else {
// 非小程序环境,尝试 history.back
if (history.length > 1) {
history.back();
} else {
window.close();
}
}
}
// 双保险3秒后无论如何都显示返回按钮
setTimeout(function() {
if (!btnShown) {
document.getElementById('back-btn').style.display = 'inline-block';
}
}, 3000);
// 轮询订单状态(通过 mini-program 后台轮询H5 仅作备用展示)
// 注意H5 页面无 JWT token无法直接调用 PaymentVerifyView
// 此处仅做延时等待,实际状态确认由小程序端轮询处理
if (orderNo) {
// 简单延时后展示成功(实际以小程序端轮询为准)
setTimeout(function() {
if (!btnShown) {
showSuccess();
}
}, 2000);
}
</script>
{% endif %}
</div>
</body>
</html>