diff --git a/jituan/constants.py b/jituan/constants.py index 2f997ee..5516b79 100644 --- a/jituan/constants.py +++ b/jituan/constants.py @@ -44,17 +44,20 @@ MINI_PAY_CHANNEL_FUBEI = 'fubei' MINI_PAY_CHANNEL_CHOICES = (MINI_PAY_CHANNEL_WECHAT, MINI_PAY_CHANNEL_FUBEI) PAY_METHOD_WECHAT = 'wechat' +PAY_METHOD_WECHAT_NATIVE = 'wechat_native' PAY_METHOD_ALIPAY = 'alipay' PAY_METHOD_FUBEI = 'fubei' PAY_METHOD_LABELS = { PAY_METHOD_WECHAT: '微信支付', + PAY_METHOD_WECHAT_NATIVE: '微信扫码', PAY_METHOD_ALIPAY: '支付宝', PAY_METHOD_FUBEI: '微信支付', # 前端不感知,展示仍为微信 } PAY_METHOD_TO_CHANNEL = { PAY_METHOD_WECHAT: PAY_CHANNEL_WECHAT_JSAPI, + PAY_METHOD_WECHAT_NATIVE: PAY_CHANNEL_WECHAT_JSAPI, # 同商户;trade_type 不同 PAY_METHOD_ALIPAY: PAY_CHANNEL_ALIPAY_WAP, PAY_METHOD_FUBEI: PAY_CHANNEL_FUBEI_WX_MINI, } diff --git a/jituan/services/pay_confirm.py b/jituan/services/pay_confirm.py index ae13f7e..ab9c8f7 100644 --- a/jituan/services/pay_confirm.py +++ b/jituan/services/pay_confirm.py @@ -40,7 +40,9 @@ def confirm_remote_payment( } is_alipay = cache.get(f'alipay_pay_url_{out_trade_no}') is not None - if is_alipay: + is_wx_native = cache.get(f'wx_native_code_{out_trade_no}') is not None + # 微信 Native 与支付宝 cache 互斥;有 Native 标记时直接走微信查单,避免误走支付宝 + if is_alipay and not is_wx_native: from jituan.services.alipay_pay import query_alipay_trade trade_status = query_alipay_trade(out_trade_no, club_id) if trade_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'): @@ -64,18 +66,19 @@ def confirm_remote_payment( 'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None, } - # cache 过期兜底:再试支付宝 - from jituan.services.alipay_pay import query_alipay_trade - alipay_status = query_alipay_trade(out_trade_no, club_id) - if alipay_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'): - logger.info('confirm_remote: cache 过期但支付宝查单成功 %s', out_trade_no) - return { - 'ok': True, - 'pending': False, - 'channel': 'alipay', - 'transaction_id': None, - 'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None, - } + # cache 过期兜底:再试支付宝(微信 Native 单禁止走支付宝查单) + if not is_wx_native: + from jituan.services.alipay_pay import query_alipay_trade + alipay_status = query_alipay_trade(out_trade_no, club_id) + if alipay_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'): + logger.info('confirm_remote: cache 过期但支付宝查单成功 %s', out_trade_no) + return { + 'ok': True, + 'pending': False, + 'channel': 'alipay', + 'transaction_id': None, + 'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None, + } if trade_state in (None, 'NOTPAY', 'USERPAYING', ''): return {'ok': False, 'pending': True, 'channel': 'wechat', 'trade_state': trade_state} diff --git a/jituan/services/wechat_native_pay.py b/jituan/services/wechat_native_pay.py new file mode 100644 index 0000000..a7dc641 --- /dev/null +++ b/jituan/services/wechat_native_pay.py @@ -0,0 +1,138 @@ +"""微信 Native 扫码支付(trade_type=NATIVE)。 + +与 JSAPI 共用普通商户 mch_id / API 密钥;不走付呗。 +返回 code_url + 二维码图;履约仍走各业务原有 notify_url / fulfill_*。 +""" +from __future__ import annotations + +import hashlib +import logging +import random +import string + +import requests +from django.core.cache import cache + +from jituan.constants import PAY_METHOD_WECHAT_NATIVE +from jituan.services.wechat_pay import ( + get_wechat_v2_config, + log_wechat_v2_pay_diag, + parse_wechat_v2_unifiedorder_response, +) +from utils.money import yuan_to_fen + +logger = logging.getLogger(__name__) + +CACHE_KEY_PREFIX = 'wx_native_code_' +CACHE_TTL_SECONDS = 60 * 30 + + +def _cache_key(dingdanid: str) -> str: + return f'{CACHE_KEY_PREFIX}{dingdanid}' + + +def generate_wechat_native_pay_params( + *, + dingdanid, + jine, + body, + notify_url, + club_id=None, + client_ip='127.0.0.1', + scene='native', +): + """统一下单 NATIVE,返回 {qr_code, qr_image, pay_method}。 + + 同一业务单号未过期时复用 cache 中的 code_url,避免重复点支付产生双码。 + 不需要 openid。第一期不走付呗。 + """ + from jituan.services.alipay_pay import qr_link_to_base64 + + dingdanid = (dingdanid or '').strip() + notify_url = (notify_url or '').strip() + body = (body or '微信支付').strip() or '微信支付' + if not dingdanid: + raise Exception('订单号不能为空') + if not notify_url: + raise Exception('缺少微信回调地址 notify_url') + + cached = cache.get(_cache_key(dingdanid)) or {} + cached_url = (cached.get('code_url') or '').strip() + if cached_url: + logger.info( + '[WX_NATIVE] reuse code_url dingdan=%s club=%s scene=%s', + dingdanid, club_id, scene, + ) + return { + 'qr_code': cached_url, + 'qr_image': qr_link_to_base64(cached_url), + 'pay_method': PAY_METHOD_WECHAT_NATIVE, + } + + pay_cfg = get_wechat_v2_config(club_id) + log_wechat_v2_pay_diag( + pay_cfg, scene=f'native:{scene}', openid='', order_hint=dingdanid, + ) + appid = pay_cfg.get('appid') or '' + mch_id = pay_cfg.get('mch_id') or '' + key = pay_cfg.get('key') or '' + if not all([appid, mch_id, key]): + raise Exception( + f'微信 Native 配置不完整 club={pay_cfg.get("club_id")} ' + f'appid={bool(appid)} mch_id={bool(mch_id)} mch_key={bool(key)}' + ) + + nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) + total_fee = yuan_to_fen(jine) + ip = (client_ip or '127.0.0.1').split(',')[0].strip() or '127.0.0.1' + + sign_data = { + 'appid': appid, + 'mch_id': mch_id, + 'nonce_str': nonce_str, + 'body': body, + 'out_trade_no': dingdanid, + 'total_fee': total_fee, + 'spbill_create_ip': ip, + 'notify_url': notify_url, + 'trade_type': 'NATIVE', + } + sorted_items = sorted(sign_data.items(), key=lambda x: x[0]) + sign_string = '&'.join(f'{k}={v}' for k, v in sorted_items if v) + f'&key={key}' + sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().upper() + + xml_data = f""" +{appid} +{body} +{mch_id} +{nonce_str} +{notify_url} +{dingdanid} +{ip} +{total_fee} +NATIVE +{sign} +""" + + response = requests.post( + 'https://api.mch.weixin.qq.com/pay/unifiedorder', + data=xml_data.encode('utf-8'), + headers={'Content-Type': 'application/xml'}, + timeout=10, + ) + parsed = parse_wechat_v2_unifiedorder_response( + response.content, + log_prefix=f'WX_NATIVE_{scene}', + expect='code_url', + ) + code_url = parsed['code_url'] + cache.set(_cache_key(dingdanid), {'code_url': code_url}, CACHE_TTL_SECONDS) + logger.warning( + '[WX_NATIVE] unifiedorder ok dingdan=%s club=%s mch=%s scene=%s', + dingdanid, pay_cfg.get('club_id'), mch_id, scene, + ) + return { + 'qr_code': code_url, + 'qr_image': qr_link_to_base64(code_url), + 'pay_method': PAY_METHOD_WECHAT_NATIVE, + } diff --git a/jituan/services/wechat_pay.py b/jituan/services/wechat_pay.py index 3c465b5..3ad3efd 100644 --- a/jituan/services/wechat_pay.py +++ b/jituan/services/wechat_pay.py @@ -271,10 +271,12 @@ def log_wechat_v2_pay_diag(pay_cfg, *, scene='', openid='', order_hint=''): ) -def parse_wechat_v2_unifiedorder_response(content, *, log_prefix='WX_UNIFIED'): +def parse_wechat_v2_unifiedorder_response(content, *, log_prefix='WX_UNIFIED', expect='prepay_id'): """ 解析微信 V2 统一下单 XML;失败时记录完整原始响应并抛出可读异常。 - 成功返回 {'prepay_id': '...'}。 + expect: + - prepay_id:JSAPI(默认,旧行为不变) + - code_url:Native 扫码 """ import defusedxml.ElementTree as ET @@ -294,6 +296,11 @@ def parse_wechat_v2_unifiedorder_response(content, *, log_prefix='WX_UNIFIED'): result_code = _xml_text(root, 'result_code') if return_code == 'SUCCESS' and result_code == 'SUCCESS': + if expect == 'code_url': + code_url = _xml_text(root, 'code_url') + if not code_url: + raise Exception(f'微信 Native 下单成功但缺少 code_url: {raw}') + return {'code_url': code_url, 'prepay_id': _xml_text(root, 'prepay_id')} prepay_id = _xml_text(root, 'prepay_id') if not prepay_id: raise Exception(f'微信支付成功但缺少 prepay_id: {raw}') diff --git a/orders/views/payment.py b/orders/views/payment.py index 8e8f77e..be977f9 100644 --- a/orders/views/payment.py +++ b/orders/views/payment.py @@ -909,7 +909,7 @@ 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) - # 支付宝支付不需要 openid + # JSAPI 需要 openid;支付宝 / 微信 Native 扫码不需要 if pay_method == 'wechat' and not user_openid: return Response({'code': 10, 'msg': '用户openid不存在', 'data': None}) @@ -995,6 +995,17 @@ class CreateOrderView(APIView): subject=getattr(settings, 'WEIXIN_PAY_DESCRIPTION', '星阙订单'), club_id=club_id, ) + elif pay_method == 'wechat_native': + from jituan.services.wechat_native_pay import generate_wechat_native_pay_params + pay_params = generate_wechat_native_pay_params( + dingdanid=dingdanid, + jine=shangpin_jiage, + body=getattr(settings, 'WEIXIN_PAY_DESCRIPTION', '星阙订单'), + notify_url=settings.WEIXIN_NOTIFY_URL, + club_id=club_id, + client_ip=self.get_client_ip(request), + scene='order', + ) else: from jituan.services.club_user import get_pay_app_source pay_params = self.generate_wechat_pay_params( @@ -1239,7 +1250,7 @@ class ContinuePayView(APIView): pay_method = (request.data.get('pay_method') or 'wechat').strip().lower() if not dingdanid: return Response({'code': 1, 'msg': '订单ID不能为空', 'data': None}) - if pay_method not in ('wechat', 'alipay'): + if pay_method not in ('wechat', 'alipay', 'wechat_native'): return Response({'code': 1, 'msg': '不支持的支付方式', 'data': None}) current_user = request.user @@ -1321,6 +1332,17 @@ class ContinuePayView(APIView): subject=subject, club_id=club_id, ) + elif pay_method == 'wechat_native': + from jituan.services.wechat_native_pay import generate_wechat_native_pay_params + pay_params = generate_wechat_native_pay_params( + dingdanid=dingdanid, + jine=jine, + body=subject, + notify_url=settings.WEIXIN_NOTIFY_URL, + club_id=club_id, + client_ip=helper.get_client_ip(request), + scene='order_continue', + ) else: from jituan.services.club_user import get_pay_app_source pay_params = helper.generate_wechat_pay_params( diff --git a/products/views/chongzhi_payment.py b/products/views/chongzhi_payment.py index dbab32a..bdcf9a5 100644 --- a/products/views/chongzhi_payment.py +++ b/products/views/chongzhi_payment.py @@ -166,7 +166,7 @@ class ShangjiaChongzhi(APIView): 'message': '订单创建失败,请重试' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - # 5. 生成支付参数(支持支付宝/微信) + # 5. 生成支付参数(支付宝 / 微信Native / 微信JSAPI) if pay_method == 'alipay': try: from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params @@ -183,6 +183,25 @@ class ShangjiaChongzhi(APIView): 'code': 500, 'message': f'支付参数生成失败: {str(e)}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + elif pay_method == 'wechat_native': + try: + from jituan.services.wechat_native_pay import generate_wechat_native_pay_params + pay_params = generate_wechat_native_pay_params( + dingdanid=dingdanid, + jine=jine, + body=settings.SHANGJIA_CZ_PAY_DESCRIPTION, + notify_url=settings.SHANGJIA_CZ_PAY_NOTIFY_URL, + club_id=pay_club, + client_ip=self.get_client_ip(), + scene='shangjia', + ) + except Exception as e: + chongzhi_jilu.delete() + logger.error(f"生成微信Native支付参数失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'支付参数生成失败: {str(e)}' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: try: pay_openid, _ = get_payment_openid(request, club_id=pay_club) diff --git a/products/views/huiyuan_payment.py b/products/views/huiyuan_payment.py index 100acbc..ffaddd5 100644 --- a/products/views/huiyuan_payment.py +++ b/products/views/huiyuan_payment.py @@ -173,7 +173,7 @@ class HuiyuanGoumai(APIView): f"会员{huiyuanid}, 俱乐部{club_id}, 体验={is_trial}, " f"金额{jine}元, 天数{purchase_days}") - # 7. 生成支付参数(支持支付宝/微信) + # 7. 生成支付参数(支付宝 / 微信Native / 微信JSAPI) if pay_method == 'alipay': try: from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params @@ -193,6 +193,28 @@ class HuiyuanGoumai(APIView): 'code': 500, 'message': f'支付参数生成失败: {str(e)}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + elif pay_method == 'wechat_native': + try: + from jituan.services.wechat_native_pay import generate_wechat_native_pay_params + pay_params = generate_wechat_native_pay_params( + dingdanid=dingdanid, + jine=jine, + body=settings.HUIYUAN_PAY_DESCRIPTION, + notify_url=settings.HUIYUAN_PAY_NOTIFY_URL, + club_id=club_id, + client_ip=self.get_client_ip(), + scene='huiyuan', + ) + except Exception as e: + from jituan.services.member_recharge import MEMBER_CANCELLED_STATUS + chongzhi_jilu.zhuangtai = MEMBER_CANCELLED_STATUS + chongzhi_jilu.shuoming = f"{shuoming} | 下单失败:{str(e)[:40]}"[:100] + chongzhi_jilu.save(update_fields=['zhuangtai', 'shuoming']) + logger.error(f"生成会员微信Native参数失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'支付参数生成失败: {str(e)}' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: try: pay_openid, pay_club = get_payment_openid(request, club_id=club_id) diff --git a/products/views/jifen_payment.py b/products/views/jifen_payment.py index 9cd4825..1f33ee7 100644 --- a/products/views/jifen_payment.py +++ b/products/views/jifen_payment.py @@ -143,7 +143,7 @@ class JifenBuchong(APIView): logger.info(f"创建积分充值订单: 用户{request.user.UserUID}, 订单{dingdanid}, 金额{jine}元") - # 6. 生成支付参数(支持支付宝/微信) + # 6. 生成支付参数(支付宝 / 微信Native / 微信JSAPI) if pay_method == 'alipay': try: from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params @@ -160,6 +160,25 @@ class JifenBuchong(APIView): 'code': 500, 'message': f'支付参数生成失败: {str(e)}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + elif pay_method == 'wechat_native': + try: + from jituan.services.wechat_native_pay import generate_wechat_native_pay_params + pay_params = generate_wechat_native_pay_params( + dingdanid=dingdanid, + jine=jine, + body=settings.JIFEN_PAY_DESCRIPTION, + notify_url=settings.JIFEN_PAY_NOTIFY_URL, + club_id=pay_club, + client_ip=self.get_client_ip(), + scene='jifen', + ) + except Exception as e: + chongzhi_jilu.delete() + logger.error(f"生成微信Native支付参数失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'支付参数生成失败: {str(e)}' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: try: pay_openid, _ = get_payment_openid(request, club_id=pay_club) diff --git a/products/views/yajin_payment.py b/products/views/yajin_payment.py index 09270c0..f4cda90 100644 --- a/products/views/yajin_payment.py +++ b/products/views/yajin_payment.py @@ -157,7 +157,7 @@ class YajinGoumai(APIView): logger.info(f"创建押金充值订单: 用户{request.user.UserUID}, 订单{dingdanid}, 金额{jine}元") - # 5. 生成支付参数(支持支付宝/微信) + # 5. 生成支付参数(支付宝 / 微信Native / 微信JSAPI) if pay_method == 'alipay': try: from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params @@ -174,6 +174,25 @@ class YajinGoumai(APIView): 'code': 500, 'message': f'支付参数生成失败: {str(e)}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + elif pay_method == 'wechat_native': + try: + from jituan.services.wechat_native_pay import generate_wechat_native_pay_params + pay_params = generate_wechat_native_pay_params( + dingdanid=dingdanid, + jine=jine, + body=settings.YAJIN_PAY_DESCRIPTION, + notify_url=settings.YAJIN_PAY_NOTIFY_URL, + club_id=pay_club, + client_ip=self.get_client_ip(), + scene='yajin', + ) + except Exception as e: + chongzhi_jilu.delete() + logger.error(f"生成微信Native支付参数失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'支付参数生成失败: {str(e)}' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: try: pay_openid, _ = get_payment_openid(request, club_id=pay_club) diff --git a/products/views/zhuanpan_payment.py b/products/views/zhuanpan_payment.py index a6d0854..1151374 100644 --- a/products/views/zhuanpan_payment.py +++ b/products/views/zhuanpan_payment.py @@ -281,6 +281,19 @@ class ZhuanpanGoumai(APIView): subject=getattr(settings, 'ZHUANPAN_PAY_DESCRIPTION', '转盘购票'), club_id=club_id, ) + elif pay_method == 'wechat_native': + from jituan.services.wechat_native_pay import generate_wechat_native_pay_params + xff = request.META.get('HTTP_X_FORWARDED_FOR') + client_ip = (xff.split(',')[0].strip() if xff else '') or request.META.get('REMOTE_ADDR') or '127.0.0.1' + pay_params = generate_wechat_native_pay_params( + dingdanid=dingdanid, + jine=jine, + body=getattr(settings, 'ZHUANPAN_PAY_DESCRIPTION', '转盘购票'), + notify_url=settings.ZHUANPAN_PAY_NOTIFY_URL, + club_id=club_id, + client_ip=client_ip, + scene='zhuanpan', + ) else: pay_openid, _ = get_payment_openid(request, club_id=club_id) if not pay_openid: diff --git a/users/views/pay.py b/users/views/pay.py index f02b606..8fbcd46 100644 --- a/users/views/pay.py +++ b/users/views/pay.py @@ -153,7 +153,7 @@ class FaKuanPayView(APIView): club_id=penalty_cz_club_id(fadan), ) - # 生成支付参数(支持支付宝/微信) + # 生成支付参数(支付宝 / 微信Native / 微信JSAPI) # 支付必须用「当前小程序/打手写入俱乐部」的 AppID+openid; # 与罚单 ClubID(申请人俱乐部)混用会导致部分人 openid/appid 不一致而下单失败。 if pay_method == 'alipay': @@ -173,6 +173,26 @@ class FaKuanPayView(APIView): 'message': f'支付参数生成失败: {str(e)}', 'msg': f'支付参数生成失败: {str(e)}', }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + elif pay_method == 'wechat_native': + try: + from jituan.services.wechat_native_pay import generate_wechat_native_pay_params + pay_params = generate_wechat_native_pay_params( + dingdanid=dingdanid, + jine=jine, + body=settings.FAKUAN_PAY_DESCRIPTION, + notify_url=settings.FAKUAN_PAY_NOTIFY_URL, + club_id=pay_club, + client_ip=self.get_client_ip(), + scene='fakuan', + ) + except Exception as e: + chongzhi_jilu.delete() + logger.error(f"生成微信Native支付参数失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'支付参数生成失败: {str(e)}', + 'msg': f'支付参数生成失败: {str(e)}', + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) else: pay_openid, pay_club = get_payment_openid(request, club_id=pay_club) if not pay_openid: @@ -684,7 +704,7 @@ class KaohePayView(APIView): attach_str = json.dumps({'oid': dingdanid}, ensure_ascii=False) logger.info(f"attach内容: {attach_str},长度: {len(attach_str.encode('utf-8'))}字节") - # 11. 生成支付参数(支持支付宝/微信) + # 11. 生成支付参数(支付宝 / 微信Native / 微信JSAPI) if pay_method == 'alipay': try: from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params @@ -703,6 +723,27 @@ class KaohePayView(APIView): logger.error(f"删除临时表记录失败: {str(del_e)}") logger.error(f"生成支付宝支付参数失败: {str(e)}", exc_info=True) return Response({'code': 500, 'message': f'支付参数生成失败: {str(e)}'}) + elif pay_method == 'wechat_native': + try: + from jituan.services.wechat_native_pay import generate_wechat_native_pay_params + pay_params = generate_wechat_native_pay_params( + dingdanid=dingdanid, + jine=float(fee), + body=settings.KAOHE_PAY_DESCRIPTION, + notify_url=settings.KAOHE_PAY_NOTIFY_URL, + club_id=pay_club, + client_ip=self.get_client_ip(), + scene='kaohe', + ) + logger.info(f"微信Native支付参数生成成功: {dingdanid}") + except Exception as e: + order.delete() + try: + KaohePayTemp.query.filter(dingdan_id=dingdanid).delete() + except Exception as del_e: + logger.error(f"删除临时表记录失败: {str(del_e)}") + logger.error(f"生成微信Native支付参数失败: {str(e)}", exc_info=True) + return Response({'code': 500, 'message': f'支付参数生成失败: {str(e)}'}) else: try: pay_params = self.generate_wechat_pay_params(