diff --git a/jituan/services/alipay_pay.py b/jituan/services/alipay_pay.py new file mode 100644 index 0000000..ef5ae4d --- /dev/null +++ b/jituan/services/alipay_pay.py @@ -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 diff --git a/orders/urls.py b/orders/urls.py index eaa38ba..7a83996 100644 --- a/orders/urls.py +++ b/orders/urls.py @@ -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='老板订单获取'), diff --git a/orders/views/__init__.py b/orders/views/__init__.py index 9d4bb1e..ffc3956 100644 --- a/orders/views/__init__.py +++ b/orders/views/__init__.py @@ -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", diff --git a/orders/views/payment.py b/orders/views/payment.py index 3469ed3..fe7bdd1 100644 --- a/orders/views/payment.py +++ b/orders/views/payment.py @@ -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 + diff --git a/templates/alipay/pay.html b/templates/alipay/pay.html new file mode 100644 index 0000000..7cf28c5 --- /dev/null +++ b/templates/alipay/pay.html @@ -0,0 +1,101 @@ + + +
+ + +