diff --git a/jituan/services/wechat_native_pay.py b/jituan/services/wechat_native_pay.py index 52a495c..f55adb5 100644 --- a/jituan/services/wechat_native_pay.py +++ b/jituan/services/wechat_native_pay.py @@ -1,22 +1,22 @@ -"""微信 Native 扫码支付(trade_type=NATIVE)。 +"""微信扫码支付:小程序码(本机相册可扫,直接弹密码)。 -「一瞬间出密码」= 二维码必须是微信返回的 weixin://wxpay/bizpayurl?... -他人微信摄像头扫屏幕 → 系统直接弹付款,中间没有网页。 +为什么不用 weixin:// 付款码: +- 那才是「另一台手机摄像头扫 → 瞬间出密码」的官方 Native +- 但微信禁止「扫一扫 → 相册」识别付款码(保障支付安全) -本机相册扫这种付款码会被微信官方拦截(保障支付安全)——不是商户能关的。 -本机付款请走小程序「本机支付」JSAPI,同样是一瞬间出密码。 - -相册可扫的 HTTPS 中转页仍保留(wx-native-pay),但不印进主二维码, -因为那条路必然经过网页授权,做不到和 weixin:// 一样的「无感弹窗」。 +为什么改用小程序码(微信官方文档也写了可用小程序码替代收款码): +- 相册可以识别小程序码 +- 扫完直接进小程序支付页 → wx.requestPayment 出密码 +- 用户体感就是:相册一扫,马上出支付,没有乱七八糟的 H5 网页 """ from __future__ import annotations +import base64 import hashlib import logging import random import string import time -from urllib.parse import quote import requests from django.core.cache import cache @@ -31,28 +31,23 @@ from utils.money import yuan_to_fen logger = logging.getLogger(__name__) -CACHE_KEY_PREFIX = 'wx_native_code_' SESSION_KEY_PREFIX = 'wx_native_pay_' +SCENE_KEY_PREFIX = 'wx_native_scene_' CACHE_TTL_SECONDS = 60 * 30 -BRIDGE_URL_BASE = 'https://www.abas.asia/hqhd/dingdan/wx-native-pay/' - - -def _cache_key(dingdanid: str) -> str: - return f'{CACHE_KEY_PREFIX}{dingdanid}' +# getwxacodeunlimit scene ≤ 32 字节;前缀 + 随机 +SCENE_PREFIX = 'np' +PAY_PAGE = 'pages/wx-scan-pay/wx-scan-pay' def _session_key(dingdanid: str) -> str: return f'{SESSION_KEY_PREFIX}{dingdanid}' -def build_native_bridge_url(dingdanid: str, club_id=None) -> str: - q = f'out_trade_no={quote(dingdanid or "", safe="")}' - if club_id: - q += f'&club_id={quote(str(club_id), safe="")}' - return f'{BRIDGE_URL_BASE}?{q}' +def _scene_key(scene: str) -> str: + return f'{SCENE_KEY_PREFIX}{scene}' -def save_native_pay_session(dingdanid, *, jine, body, notify_url, club_id=None): +def save_native_pay_session(dingdanid, *, jine, body, notify_url, club_id=None, scene=''): cache.set( _session_key(dingdanid), { @@ -60,6 +55,8 @@ def save_native_pay_session(dingdanid, *, jine, body, notify_url, club_id=None): 'body': (body or '微信支付').strip() or '微信支付', 'notify_url': (notify_url or '').strip(), 'club_id': (club_id or '').strip(), + 'scene': (scene or '').strip(), + 'dingdanid': (dingdanid or '').strip(), }, CACHE_TTL_SECONDS, ) @@ -70,17 +67,70 @@ def get_native_pay_session(dingdanid: str): return data if isinstance(data, dict) else None -def _native_pay_response(code_url: str, *, bridge_url: str = '') -> dict: - from jituan.services.alipay_pay import qr_link_to_base64 +def resolve_scene_to_dingdanid(scene: str) -> str: + scene = (scene or '').strip() + if not scene: + return '' + dingdanid = cache.get(_scene_key(scene)) or '' + return str(dingdanid).strip() - out = { - 'qr_code': code_url, - 'qr_image': qr_link_to_base64(code_url), - 'pay_method': PAY_METHOD_WECHAT_NATIVE, + +def _new_scene() -> str: + # np + 22 位 = 24,远小于 32 + rnd = ''.join(random.choices(string.ascii_letters + string.digits, k=22)) + return f'{SCENE_PREFIX}{rnd}' + + +def _png_to_data_url(png_bytes: bytes) -> str: + b64 = base64.b64encode(png_bytes).decode('ascii') + return f'data:image/png;base64,{b64}' + + +def _fetch_mini_program_code_png(*, club_id: str, scene: str) -> bytes: + from utils.weixin_token import get_club_mini_access_token, is_weixin_token_invalid + + access_token = get_club_mini_access_token(club_id) + if not access_token: + raise Exception(f'俱乐部 {club_id} 获取小程序 access_token 失败,无法生成支付码') + + post_data = { + 'scene': scene, + 'page': PAY_PAGE, + 'width': 430, + 'auto_color': False, + 'line_color': {'r': 0, 'g': 0, 'b': 0}, + 'check_path': False, } - if bridge_url: - out['bridge_url'] = bridge_url - return out + + def _req(tok: str): + url = f'https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={tok}' + return requests.post(url, json=post_data, timeout=15) + + wx_resp = _req(access_token) + if wx_resp.headers.get('content-type', '').startswith('application/json'): + try: + err = wx_resp.json() + if is_weixin_token_invalid(err.get('errcode'), err.get('errmsg', '')): + access_token = get_club_mini_access_token(club_id, force_refresh=True) + if access_token: + wx_resp = _req(access_token) + except Exception: + pass + + content_type = wx_resp.headers.get('content-type', '') + if wx_resp.status_code != 200 or 'image' not in content_type: + errmsg = '未知错误' + try: + error_info = wx_resp.json() + errmsg = error_info.get('errmsg', errmsg) + logger.error('[WX_NATIVE_MINICODE] fail club=%s err=%s', club_id, error_info) + except Exception: + logger.error( + '[WX_NATIVE_MINICODE] fail club=%s status=%s body=%s', + club_id, wx_resp.status_code, wx_resp.content[:300], + ) + raise Exception(f'生成小程序支付码失败: {errmsg}') + return wx_resp.content def generate_wechat_native_pay_params( @@ -93,100 +143,64 @@ def generate_wechat_native_pay_params( client_ip='127.0.0.1', scene='native', ): - """统一下单 NATIVE,二维码印官方 weixin:// 付款码(他人扫瞬间出密码)。""" + """生成「相册可扫」小程序码二维码。""" dingdanid = (dingdanid or '').strip() notify_url = (notify_url or '').strip() body = (body or '微信支付').strip() or '微信支付' + club_id = (club_id or '').strip() if not dingdanid: raise Exception('订单号不能为空') if not notify_url: raise Exception('缺少微信回调地址 notify_url') + if not club_id: + raise Exception('缺少 club_id,无法生成小程序支付码') + + cached = get_native_pay_session(dingdanid) or {} + cached_img = (cached.get('qr_image') or '').strip() + cached_scene = (cached.get('scene') or '').strip() + if cached_img and cached_scene and resolve_scene_to_dingdanid(cached_scene) == dingdanid: + logger.info( + '[WX_NATIVE_MINICODE] reuse dingdan=%s club=%s scene=%s', + dingdanid, club_id, cached_scene, + ) + return { + 'qr_code': f'minicode:{cached_scene}', + 'qr_image': cached_img, + 'scene': cached_scene, + 'pay_method': PAY_METHOD_WECHAT_NATIVE, + } + + pay_scene = _new_scene() + png = _fetch_mini_program_code_png(club_id=club_id, scene=pay_scene) + qr_image = _png_to_data_url(png) - # 给相册/链接兜底页留会话(主二维码仍是 weixin://) save_native_pay_session( dingdanid, jine=jine, body=body, notify_url=notify_url, club_id=club_id, + scene=pay_scene, ) - bridge = build_native_bridge_url(dingdanid, club_id) + # 把图片也塞进 session,便于复用 + sess = get_native_pay_session(dingdanid) or {} + sess['qr_image'] = qr_image + cache.set(_session_key(dingdanid), sess, CACHE_TTL_SECONDS) + cache.set(_scene_key(pay_scene), dingdanid, CACHE_TTL_SECONDS) - 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 _native_pay_response(cached_url, bridge_url=bridge) - - 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 code=%s', - dingdanid, pay_cfg.get('club_id'), mch_id, scene, code_url[:48], + '[WX_NATIVE_MINICODE] ok dingdan=%s club=%s biz_scene=%s pay_scene=%s', + dingdanid, club_id, scene, pay_scene, ) - return _native_pay_response(code_url, bridge_url=bridge) + return { + 'qr_code': f'minicode:{pay_scene}', + 'qr_image': qr_image, + 'scene': pay_scene, + 'pay_method': PAY_METHOD_WECHAT_NATIVE, + } -def build_official_jsapi_pay_params( +def build_mini_jsapi_pay_params( *, dingdanid, jine, @@ -196,21 +210,38 @@ def build_official_jsapi_pay_params( club_id=None, client_ip='127.0.0.1', ): - """服务号 AppID + 服务号 openid 做 JSAPI,供相册 HTTPS 中转页调起。""" - pay_cfg = get_wechat_v2_config(club_id, app_source='official') + """小程序 AppID + 小程序 openid → JSAPI,供相册扫码落地页调起密码。""" + from jituan.constants import FUBEI_BIZ_CZJILU, FUBEI_BIZ_ORDER + from jituan.services.mini_pay_router import maybe_create_fubei_jsapi_params + + pay_cfg = get_wechat_v2_config(club_id, app_source='mini') log_wechat_v2_pay_diag( - pay_cfg, scene='native_album_jsapi', openid=openid, order_hint=dingdanid, + pay_cfg, scene='native_minicode_jsapi', openid=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 openid: + raise Exception('缺少小程序 openid') + + biz = FUBEI_BIZ_CZJILU if str(dingdanid).startswith('Cz') or str(dingdanid).startswith('FK') or str(dingdanid).startswith('KH') else FUBEI_BIZ_ORDER + fb = maybe_create_fubei_jsapi_params( + club_id=club_id, + out_trade_no=dingdanid, + amount_yuan=jine, + body=body or '微信支付', + openid=openid, + notify_url=notify_url, + biz_type=biz, + ) + if fb is not None: + return fb + if not all([appid, mch_id, key]): raise Exception( - f'服务号支付配置不完整 club={pay_cfg.get("club_id")} ' - f'请配置 official_appid 与商户密钥' + f'小程序支付配置不完整 club={pay_cfg.get("club_id")} ' + f'appid={bool(appid)} mch_id={bool(mch_id)} mch_key={bool(key)}' ) - if not openid: - raise Exception('缺少服务号 openid') nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) total_fee = yuan_to_fen(jine) @@ -255,7 +286,7 @@ def build_official_jsapi_pay_params( ) parsed = parse_wechat_v2_unifiedorder_response( response.content, - log_prefix='WX_NATIVE_ALBUM_JSAPI', + log_prefix='WX_NATIVE_MINICODE_JSAPI', expect='prepay_id', ) prepay_id = parsed['prepay_id'] @@ -283,3 +314,101 @@ def build_official_jsapi_pay_params( 'signType': sign_type, 'paySign': pay_sign, } + + +# 兼容旧 H5 中转页导入(若仍有请求) +def build_native_bridge_url(dingdanid: str, club_id=None) -> str: + from urllib.parse import quote + q = f'out_trade_no={quote(dingdanid or "", safe="")}' + if club_id: + q += f'&club_id={quote(str(club_id), safe="")}' + return f'https://www.abas.asia/hqhd/dingdan/wx-native-pay/?{q}' + + +def build_official_jsapi_pay_params(**kwargs): + """保留给旧 H5 页;新主路径已改小程序码。""" + kwargs = dict(kwargs) + # 复用 mini 逻辑会错 openid 体系;仍走服务号 + from jituan.services.wechat_pay import get_wechat_v2_config as _cfg + pay_cfg = _cfg(kwargs.get('club_id'), app_source='official') + log_wechat_v2_pay_diag( + pay_cfg, + scene='native_album_jsapi_legacy', + openid=kwargs.get('openid'), + order_hint=kwargs.get('dingdanid'), + ) + appid = pay_cfg.get('appid') or '' + mch_id = pay_cfg.get('mch_id') or '' + key = pay_cfg.get('key') or '' + openid = kwargs.get('openid') or '' + if not all([appid, mch_id, key, openid]): + raise Exception('服务号支付配置不完整') + dingdanid = kwargs.get('dingdanid') + jine = kwargs.get('jine') + body = kwargs.get('body') or '微信支付' + notify_url = kwargs.get('notify_url') + client_ip = kwargs.get('client_ip') or '127.0.0.1' + 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': 'JSAPI', + 'openid': openid, + } + 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} +{openid} +{dingdanid} +{ip} +{total_fee} +JSAPI +{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='WX_NATIVE_OA_JSAPI', expect='prepay_id', + ) + prepay_id = parsed['prepay_id'] + timestamp = str(int(time.time())) + pay_nonce = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) + package = f'prepay_id={prepay_id}' + sign_type = 'MD5' + pay_sign_data = { + 'appId': appid, + 'timeStamp': timestamp, + 'nonceStr': pay_nonce, + 'package': package, + 'signType': sign_type, + } + pay_sign_string = '&'.join( + f'{k}={pay_sign_data[k]}' for k in sorted(pay_sign_data.keys()) + ) + f'&key={key}' + pay_sign = hashlib.md5(pay_sign_string.encode('utf-8')).hexdigest().upper() + return { + 'appId': appid, + 'timeStamp': timestamp, + 'nonceStr': pay_nonce, + 'package': package, + 'signType': sign_type, + 'paySign': pay_sign, + } diff --git a/orders/urls.py b/orders/urls.py index 5a19390..5dccd07 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, AlipayPayNotifyView, alipay_pay_page, wx_native_pay_page, \ +from .views import CreateOrderView, WechatPayNotifyView, AlipayPayNotifyView, alipay_pay_page, wx_native_pay_page, WxNativeScanPayView, \ PaymentFailView, PaymentVerifyView, ContinuePayView, DingdanHuoquView, DingdanXiangqingView, JiedanView, \ ShangpinLeixingView, ShangjiaPaifaView, ShangjiaDingdanHuoquView, ShangjiaJiesuanView, \ ShangjiaDingdanXiangqingView, ShangjiaChufaShenqingView, ShangjiaTuikuanShenqingView, \ @@ -25,6 +25,7 @@ urlpatterns = [ path('fubei-notify/', csrf_exempt(FubeiPayNotifyView.as_view()), name='付呗支付回调'), path('alipay-pay/', csrf_exempt(alipay_pay_page), name='支付宝H5中转页'), path('wx-native-pay/', csrf_exempt(wx_native_pay_page), name='微信Native扫码中转页'), + path('wx-native-scan-pay', WxNativeScanPayView.as_view(), name='微信小程序码扫码支付'), 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 73c5b70..503cc05 100644 --- a/orders/views/__init__.py +++ b/orders/views/__init__.py @@ -11,6 +11,7 @@ from .payment import ( ContinuePayView, alipay_pay_page, wx_native_pay_page, + WxNativeScanPayView, ) from .boss_orders import ( DingdanHuoquView, @@ -75,6 +76,7 @@ __all__ = [ "ContinuePayView", "alipay_pay_page", "wx_native_pay_page", + "WxNativeScanPayView", "DingdanHuoquView", "DingdanXiangqingView", "JiedanView", "DingdanXiangqingView2", "JiedanView2", "ShangpinLeixingView", "ShangjiaPaifaView", "ShangjiaDingdanHuoquView", diff --git a/orders/views/payment.py b/orders/views/payment.py index db60449..ffc296b 100644 --- a/orders/views/payment.py +++ b/orders/views/payment.py @@ -651,6 +651,83 @@ def wx_native_pay_page(request): return render(request, 'wechat/native_pay.html', {'pay': pay, 'out_trade_no': out_trade_no}) +class WxNativeScanPayView(APIView): + """相册扫小程序码落地:用当前用户 openid 对未付款单做 JSAPI,直接出密码。""" + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request): + from jituan.services.wechat_native_pay import ( + build_mini_jsapi_pay_params, + get_native_pay_session, + resolve_scene_to_dingdanid, + ) + + scene = (request.data.get('scene') or '').strip() + dingdanid = (request.data.get('dingdanid') or request.data.get('out_trade_no') or '').strip() + if scene and not dingdanid: + dingdanid = resolve_scene_to_dingdanid(scene) + if not dingdanid: + return Response({'code': 400, 'message': '无效支付码'}, status=status.HTTP_400_BAD_REQUEST) + + session = get_native_pay_session(dingdanid) + if not session: + return Response({'code': 400, 'message': '支付已过期,请返回重新发起'}, status=status.HTTP_400_BAD_REQUEST) + + club_id = (session.get('club_id') or '').strip() + notify_url = (session.get('notify_url') or '').strip() + if not notify_url: + if dingdanid.startswith('CzHY'): + notify_url = getattr(settings, 'HUIYUAN_PAY_NOTIFY_URL', '') + elif dingdanid.startswith('CzYJ'): + notify_url = getattr(settings, 'YAJIN_PAY_NOTIFY_URL', '') + elif dingdanid.startswith('CzJF'): + notify_url = getattr(settings, 'JIFEN_PAY_NOTIFY_URL', '') + elif dingdanid.startswith('CzSJ'): + notify_url = getattr(settings, 'SHANGJIA_CZ_PAY_NOTIFY_URL', '') + elif dingdanid.startswith('FK'): + notify_url = getattr(settings, 'FAKUAN_PAY_NOTIFY_URL', '') + elif dingdanid.startswith('KH'): + notify_url = getattr(settings, 'KAOHE_PAY_NOTIFY_URL', '') + elif dingdanid.startswith('CzZP'): + notify_url = getattr(settings, 'ZHUANPAN_PAY_NOTIFY_URL', '') + else: + notify_url = getattr(settings, 'WEIXIN_NOTIFY_URL', '') + + pay_openid, _ = get_payment_openid(request, club_id=club_id or None) + if not pay_openid: + return Response({ + 'code': 400, + 'message': '当前小程序未绑定微信,请退出后重新登录再支付', + }, status=status.HTTP_400_BAD_REQUEST) + + 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' + try: + pay_params = build_mini_jsapi_pay_params( + dingdanid=dingdanid, + jine=session.get('jine'), + body=session.get('body') or '微信支付', + notify_url=notify_url, + openid=pay_openid, + club_id=club_id or None, + client_ip=client_ip, + ) + except Exception as exc: + logger.exception('小程序码扫码 JSAPI 下单失败 dingdan=%s', dingdanid) + return Response({ + 'code': 500, + 'message': f'下单失败:{exc}', + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response({ + 'code': 200, + 'message': 'ok', + 'dingdanid': dingdanid, + 'payParams': pay_params, + }) + + class PaymentVerifyView(APIView): """ 订单支付成功复查接口 - 前端支付成功后调用,确认订单状态