测试中,尝试接入支付宝(沙箱)
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
|
||||
@@ -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='老板订单获取'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,15 +900,23 @@ class CreateOrderView(APIView):
|
||||
PlatformOrderExt.query.create(**pingtaikuozhan_data)'''
|
||||
|
||||
|
||||
# 7. 生成微信支付参数(向微信官方发送请求)
|
||||
pay_params = self.generate_wechat_pay_params(
|
||||
dingdanid=dingdanid,
|
||||
jine=shangpin_jiage,
|
||||
beizhu=getattr(settings, 'WEIXIN_PAY_DESCRIPTION', '星阙订单'),
|
||||
user_ip=self.get_client_ip(request),
|
||||
openid=user_openid,
|
||||
club_id=club_id,
|
||||
)
|
||||
# 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,
|
||||
beizhu=getattr(settings, 'WEIXIN_PAY_DESCRIPTION', '星阙订单'),
|
||||
user_ip=self.get_client_ip(request),
|
||||
openid=user_openid,
|
||||
club_id=club_id,
|
||||
)
|
||||
|
||||
if not pay_params:
|
||||
# 手动抛出异常触发事务回滚
|
||||
@@ -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
101
templates/alipay/pay.html
Normal 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>
|
||||
Reference in New Issue
Block a user