From ef1da8b81afc16015a88fc643760ef6d41578f0e Mon Sep 17 00:00:00 2001 From: XingQue Date: Sun, 9 Aug 2026 07:21:18 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BE=AE=E4=BF=A1=E6=89=AB=E7=A0=81?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E5=AE=98=E6=96=B9Native=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E7=A0=81=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=8D=E5=88=B6=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=EF=BC=8C=E5=8E=BB=E6=8E=89=E6=9C=AC=E6=9C=BA=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- jituan/services/wechat_native_pay.py | 352 +++++---------------------- orders/views/payment.py | 73 +----- 2 files changed, 62 insertions(+), 363 deletions(-) diff --git a/jituan/services/wechat_native_pay.py b/jituan/services/wechat_native_pay.py index f55adb5..e9158fd 100644 --- a/jituan/services/wechat_native_pay.py +++ b/jituan/services/wechat_native_pay.py @@ -1,22 +1,14 @@ -"""微信扫码支付:小程序码(本机相册可扫,直接弹密码)。 +"""微信 Native 扫码支付(trade_type=NATIVE)。 -为什么不用 weixin:// 付款码: -- 那才是「另一台手机摄像头扫 → 瞬间出密码」的官方 Native -- 但微信禁止「扫一扫 → 相册」识别付款码(保障支付安全) - -为什么改用小程序码(微信官方文档也写了可用小程序码替代收款码): -- 相册可以识别小程序码 -- 扫完直接进小程序支付页 → wx.requestPayment 出密码 -- 用户体感就是:相册一扫,马上出支付,没有乱七八糟的 H5 网页 +二维码 = 微信返回的官方付款码 weixin://wxpay/bizpayurl?... +支付结果以商户回调 / 查单为准;小程序侧用轮询确认,不依赖 wx.requestPayment。 """ from __future__ import annotations -import base64 import hashlib import logging import random import string -import time import requests from django.core.cache import cache @@ -31,107 +23,35 @@ 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 -# getwxacodeunlimit scene ≤ 32 字节;前缀 + 随机 -SCENE_PREFIX = 'np' -PAY_PAGE = 'pages/wx-scan-pay/wx-scan-pay' + + +def _cache_key(dingdanid: str) -> str: + return f'{CACHE_KEY_PREFIX}{dingdanid}' def _session_key(dingdanid: str) -> str: return f'{SESSION_KEY_PREFIX}{dingdanid}' -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, scene=''): - cache.set( - _session_key(dingdanid), - { - 'jine': str(jine), - '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, - ) - - def get_native_pay_session(dingdanid: str): data = cache.get(_session_key(dingdanid)) return data if isinstance(data, dict) else None -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() +def _native_pay_response(code_url: str) -> dict: + from jituan.services.alipay_pay import qr_link_to_base64 - -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, + return { + 'qr_code': code_url, + 'qr_image': qr_link_to_base64(code_url), + # 前端「复制支付链接」用同一条官方付款链接 + 'pay_link': code_url, + 'pay_method': PAY_METHOD_WECHAT_NATIVE, } - 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( *, @@ -143,110 +63,51 @@ def generate_wechat_native_pay_params( client_ip='127.0.0.1', scene='native', ): - """生成「相册可扫」小程序码二维码。""" + """统一下单 NATIVE,返回官方付款码 + 可复制链接。""" 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: + cache.set( + _session_key(dingdanid), + { + 'jine': str(jine), + 'body': body, + 'notify_url': notify_url, + 'club_id': (club_id or '').strip(), + }, + 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_MINICODE] reuse dingdan=%s club=%s scene=%s', - dingdanid, club_id, cached_scene, + '[WX_NATIVE] reuse code_url dingdan=%s club=%s scene=%s', + dingdanid, club_id, scene, ) - return { - 'qr_code': f'minicode:{cached_scene}', - 'qr_image': cached_img, - 'scene': cached_scene, - 'pay_method': PAY_METHOD_WECHAT_NATIVE, - } + return _native_pay_response(cached_url) - pay_scene = _new_scene() - png = _fetch_mini_program_code_png(club_id=club_id, scene=pay_scene) - qr_image = _png_to_data_url(png) - - save_native_pay_session( - dingdanid, - jine=jine, - body=body, - notify_url=notify_url, - club_id=club_id, - scene=pay_scene, - ) - # 把图片也塞进 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) - - logger.warning( - '[WX_NATIVE_MINICODE] ok dingdan=%s club=%s biz_scene=%s pay_scene=%s', - dingdanid, club_id, scene, pay_scene, - ) - return { - 'qr_code': f'minicode:{pay_scene}', - 'qr_image': qr_image, - 'scene': pay_scene, - 'pay_method': PAY_METHOD_WECHAT_NATIVE, - } - - -def build_mini_jsapi_pay_params( - *, - dingdanid, - jine, - body, - notify_url, - openid, - club_id=None, - client_ip='127.0.0.1', -): - """小程序 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') + pay_cfg = get_wechat_v2_config(club_id) log_wechat_v2_pay_diag( - pay_cfg, scene='native_minicode_jsapi', openid=openid, order_hint=dingdanid, + 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 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'微信 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' - body = (body or '微信支付').strip() or '微信支付' sign_data = { 'appid': appid, @@ -257,8 +118,7 @@ def build_mini_jsapi_pay_params( 'total_fee': total_fee, 'spbill_create_ip': ip, 'notify_url': notify_url, - 'trade_type': 'JSAPI', - 'openid': openid, + '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}' @@ -270,11 +130,10 @@ def build_mini_jsapi_pay_params( {mch_id} {nonce_str} {notify_url} -{openid} {dingdanid} {ip} {total_fee} -JSAPI +NATIVE {sign} """ @@ -286,37 +145,19 @@ def build_mini_jsapi_pay_params( ) parsed = parse_wechat_v2_unifiedorder_response( response.content, - log_prefix='WX_NATIVE_MINICODE_JSAPI', - expect='prepay_id', + log_prefix=f'WX_NATIVE_{scene}', + expect='code_url', ) - 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, - } + 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 _native_pay_response(code_url) -# 兼容旧 H5 中转页导入(若仍有请求) +# 旧 H5 / 小程序码落地页仍可能 import,保留最小兼容,避免 ImportError 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="")}' @@ -326,89 +167,12 @@ def build_native_bridge_url(dingdanid: str, club_id=None) -> str: 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, - } + raise Exception('已改为官方 Native 付款码,不再走服务号 H5 JSAPI') + + +def build_mini_jsapi_pay_params(**kwargs): + raise Exception('已改为官方 Native 付款码,不再走小程序码 JSAPI') + + +def resolve_scene_to_dingdanid(scene: str) -> str: + return '' diff --git a/orders/views/payment.py b/orders/views/payment.py index ffc296b..daac006 100644 --- a/orders/views/payment.py +++ b/orders/views/payment.py @@ -652,80 +652,15 @@ def wx_native_pay_page(request): class WxNativeScanPayView(APIView): - """相册扫小程序码落地:用当前用户 openid 对未付款单做 JSAPI,直接出密码。""" + """已废弃:现统一走 Native 付款码 + 复制链接,不再用小程序码落地支付。""" 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, - }) + 'code': 410, + 'message': '请返回使用付款码扫码或复制支付链接支付', + }, status=status.HTTP_410_GONE) class PaymentVerifyView(APIView):