diff --git a/jituan/services/pay_confirm.py b/jituan/services/pay_confirm.py index ab9c8f7..d4eb417 100644 --- a/jituan/services/pay_confirm.py +++ b/jituan/services/pay_confirm.py @@ -40,7 +40,10 @@ def confirm_remote_payment( } is_alipay = cache.get(f'alipay_pay_url_{out_trade_no}') is not None - is_wx_native = cache.get(f'wx_native_code_{out_trade_no}') is not None + is_wx_native = ( + cache.get(f'wx_native_pay_{out_trade_no}') is not None + or 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 diff --git a/jituan/services/wechat_native_pay.py b/jituan/services/wechat_native_pay.py index 7f3696d..83cbc3f 100644 --- a/jituan/services/wechat_native_pay.py +++ b/jituan/services/wechat_native_pay.py @@ -1,7 +1,12 @@ -"""微信 Native 扫码支付(trade_type=NATIVE)。 +"""微信「扫码」支付(相册可用版)。 -与 JSAPI 共用普通商户 mch_id / API 密钥;不走付呗。 -返回 code_url + 二维码图;履约仍走各业务原有 notify_url / fulfill_*。 +关键点: +- 二维码内容必须是 HTTPS(微信允许相册识别普通链接) +- 不能把 weixin://wxpay 付款码直接印成图(相册必被「保障支付安全」拦截) +- 用户扫 HTTPS 后进 H5 → 服务号 OAuth 拿 openid → JSAPI 统一下单 + → WeixinJSBridge.invoke 直接出密码 + +本机小程序内仍可用「本机支付」走小程序 JSAPI。 """ from __future__ import annotations @@ -9,38 +14,49 @@ import hashlib import logging import random import string +import time +from urllib.parse import quote 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_KEY_PREFIX = 'wx_native_pay_' 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}' -def _native_pay_response(code_url: str) -> dict: - """二维码直接编码官方 weixin://wxpay/bizpayurl(供另一台手机扫,直接出密码)。 - 本机相册识别付款码会被微信官方拦截,无法伪装;本机付款走小程序 JSAPI。 - """ - from jituan.services.alipay_pay import qr_link_to_base64 - return { - 'qr_code': code_url, - 'qr_image': qr_link_to_base64(code_url), - 'pay_method': PAY_METHOD_WECHAT_NATIVE, - } +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 save_native_pay_session(dingdanid, *, jine, body, notify_url, club_id=None): + cache.set( + _cache_key(dingdanid), + { + 'jine': str(jine), + 'body': (body or '微信支付').strip() or '微信支付', + 'notify_url': (notify_url or '').strip(), + 'club_id': (club_id or '').strip(), + }, + CACHE_TTL_SECONDS, + ) + + +def get_native_pay_session(dingdanid: str): + data = cache.get(_cache_key(dingdanid)) + return data if isinstance(data, dict) else None def generate_wechat_native_pay_params( @@ -53,11 +69,13 @@ def generate_wechat_native_pay_params( client_ip='127.0.0.1', scene='native', ): - """统一下单 NATIVE,返回 {qr_code, qr_image, pay_method}。 + """生成「相册可扫」支付参数:HTTPS 中转码 + 会话缓存。 - 同一业务单号未过期时复用 cache 中的 code_url,避免重复点支付产生双码。 - 不需要 openid。第一期不走付呗。 + 不再调用 Native unifiedorder(weixin:// 码会被相册拦截)。 + 真实收款在中转页用服务号 JSAPI 完成,notify_url/履约不变。 """ + 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 '微信支付' @@ -66,31 +84,63 @@ def generate_wechat_native_pay_params( 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 _native_pay_response(cached_url) + save_native_pay_session( + dingdanid, + jine=jine, + body=body, + notify_url=notify_url, + club_id=club_id, + ) + bridge = build_native_bridge_url(dingdanid, club_id) + logger.warning( + '[WX_NATIVE_ALBUM] session ok dingdan=%s club=%s scene=%s bridge=%s', + dingdanid, club_id, scene, bridge, + ) + return { + # 复制也给 HTTPS,打开后同一套支付 + 'qr_code': bridge, + 'qr_image': qr_link_to_base64(bridge), + 'bridge_url': bridge, + 'pay_method': PAY_METHOD_WECHAT_NATIVE, + } - pay_cfg = get_wechat_v2_config(club_id) + +def build_official_jsapi_pay_params( + *, + dingdanid, + jine, + body, + notify_url, + openid, + club_id=None, + client_ip='127.0.0.1', +): + """服务号 AppID + 服务号 openid 做 JSAPI 统一下单,供 H5 WeixinJSBridge 调起。""" + from jituan.services.wechat_pay import ( + get_wechat_v2_config, + log_wechat_v2_pay_diag, + parse_wechat_v2_unifiedorder_response, + ) + + pay_cfg = get_wechat_v2_config(club_id, app_source='official') log_wechat_v2_pay_diag( - pay_cfg, scene=f'native:{scene}', openid='', order_hint=dingdanid, + pay_cfg, scene='native_album_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 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)}' + f'服务号支付配置不完整 club={pay_cfg.get("club_id")} ' + f'请配置 official_appid 与商户密钥' ) + if not openid: + raise Exception('缺少服务号 openid') 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, @@ -101,7 +151,8 @@ def generate_wechat_native_pay_params( 'total_fee': total_fee, 'spbill_create_ip': ip, 'notify_url': notify_url, - 'trade_type': 'NATIVE', + '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}' @@ -113,10 +164,11 @@ def generate_wechat_native_pay_params( {mch_id} {nonce_str} {notify_url} +{openid} {dingdanid} {ip} {total_fee} -NATIVE +JSAPI {sign} """ @@ -128,13 +180,31 @@ def generate_wechat_native_pay_params( ) parsed = parse_wechat_v2_unifiedorder_response( response.content, - log_prefix=f'WX_NATIVE_{scene}', - expect='code_url', + log_prefix='WX_NATIVE_ALBUM_JSAPI', + expect='prepay_id', ) - 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) + 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/views/payment.py b/orders/views/payment.py index cef878a..db60449 100644 --- a/orders/views/payment.py +++ b/orders/views/payment.py @@ -531,16 +531,124 @@ def alipay_pay_page(request): def wx_native_pay_page(request): - """微信 Native 扫码中转页:相册扫 HTTPS 码后跳转到 weixin:// 付款链接。""" + """相册可扫的 HTTPS 中转页:OAuth 取服务号 openid → JSAPI → 直接出密码。 + + 二维码故意不用 weixin:// 付款码(会被相册安全拦截)。 + """ + from urllib.parse import quote + from jituan.services.club_resolver import get_club_official_credentials, oauth2_access_token + from jituan.services.wechat_native_pay import ( + build_official_jsapi_pay_params, + get_native_pay_session, + build_native_bridge_url, + ) + out_trade_no = (request.GET.get('out_trade_no') or request.GET.get('dingdanid') or '').strip() - code_url = '' - if out_trade_no: - cached = cache.get(f'wx_native_code_{out_trade_no}') or {} - if isinstance(cached, dict): - code_url = (cached.get('code_url') or '').strip() - elif isinstance(cached, str): - code_url = cached.strip() - return render(request, 'wechat/native_pay.html', {'code_url': code_url, 'out_trade_no': out_trade_no}) + club_id = (request.GET.get('club_id') or '').strip() + code = (request.GET.get('code') or '').strip() + + if not out_trade_no: + return render(request, 'wechat/native_pay.html', {'error': '缺少订单号'}) + + session = get_native_pay_session(out_trade_no) + if not session: + # 兼容:从业务单反查 + try: + from products.models import Czjilu + row = Czjilu.query.filter(dingdan_id=out_trade_no).first() + if row and int(getattr(row, 'zhuangtai', -1) or -1) in (9,): + session = { + 'jine': str(row.jine), + 'body': (row.shuoming or '微信支付')[:40], + 'notify_url': '', + 'club_id': (getattr(row, 'club_id', None) or club_id or ''), + } + except Exception: + session = None + if not session: + try: + from orders.models import Order + row = Order.query.filter(OrderID=out_trade_no).first() + if row and int(getattr(row, 'Status', -1) or -1) == 9: + session = { + 'jine': str(row.Amount), + 'body': getattr(settings, 'WEIXIN_PAY_DESCRIPTION', '星阙订单'), + 'notify_url': getattr(settings, 'WEIXIN_NOTIFY_URL', ''), + 'club_id': (getattr(row, 'ClubID', None) or club_id or ''), + } + except Exception: + session = None + + if not session: + return render(request, 'wechat/native_pay.html', { + 'error': '支付已过期,请返回小程序重新发起', + }) + + club_id = club_id or (session.get('club_id') or '') + notify_url = (session.get('notify_url') or '').strip() + if not notify_url: + # 按单号前缀回落业务回调(与现网一致) + if out_trade_no.startswith('CzHY'): + notify_url = getattr(settings, 'HUIYUAN_PAY_NOTIFY_URL', '') + elif out_trade_no.startswith('CzYJ'): + notify_url = getattr(settings, 'YAJIN_PAY_NOTIFY_URL', '') + elif out_trade_no.startswith('CzJF'): + notify_url = getattr(settings, 'JIFEN_PAY_NOTIFY_URL', '') + elif out_trade_no.startswith('CzSJ'): + notify_url = getattr(settings, 'SHANGJIA_CZ_PAY_NOTIFY_URL', '') + elif out_trade_no.startswith('FK'): + notify_url = getattr(settings, 'FAKUAN_PAY_NOTIFY_URL', '') + elif out_trade_no.startswith('KH'): + notify_url = getattr(settings, 'KAOHE_PAY_NOTIFY_URL', '') + elif out_trade_no.startswith('CzZP'): + notify_url = getattr(settings, 'ZHUANPAN_PAY_NOTIFY_URL', '') + else: + notify_url = getattr(settings, 'WEIXIN_NOTIFY_URL', '') + + appid, secret, _club = get_club_official_credentials(club_id) + if not appid or not secret: + return render(request, 'wechat/native_pay.html', { + 'error': '俱乐部未配置服务号(official_appid),无法相册支付。请联系管理员配置后重试,或返回小程序点「本机支付」。', + }) + + # 无 code:跳转网页授权(静默) + if not code: + redirect_uri = build_native_bridge_url(out_trade_no, club_id) + oauth = ( + 'https://open.weixin.qq.com/connect/oauth2/authorize' + f'?appid={quote(appid)}' + f'&redirect_uri={quote(redirect_uri, safe="")}' + '&response_type=code&scope=snsapi_base&state=pay#wechat_redirect' + ) + from django.shortcuts import redirect + return redirect(oauth) + + wx = oauth2_access_token(code, club_id=club_id) + openid = (wx or {}).get('openid') or '' + if not openid: + return render(request, 'wechat/native_pay.html', { + 'error': (wx or {}).get('errmsg') or '微信授权失败,请返回重试', + }) + + 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 = build_official_jsapi_pay_params( + dingdanid=out_trade_no, + jine=session.get('jine'), + body=session.get('body') or '微信支付', + notify_url=notify_url, + openid=openid, + club_id=club_id, + client_ip=client_ip, + ) + except Exception as exc: + logger.exception('相册支付 JSAPI 下单失败 dingdan=%s', out_trade_no) + return render(request, 'wechat/native_pay.html', { + 'error': f'下单失败:{exc}', + }) + + return render(request, 'wechat/native_pay.html', {'pay': pay, 'out_trade_no': out_trade_no}) class PaymentVerifyView(APIView): diff --git a/templates/wechat/native_pay.html b/templates/wechat/native_pay.html index 517d9bb..cdc143a 100644 --- a/templates/wechat/native_pay.html +++ b/templates/wechat/native_pay.html @@ -15,22 +15,56 @@ display: block; width: 100%; padding: 16px; font-size: 17px; font-weight: 600; border: none; border-radius: 10px; background: #07c160; color: #fff; text-decoration: none; } - .btn:active { opacity: 0.9; } - .hint { margin-top: 20px; font-size: 13px; color: #aaa; line-height: 1.5; } .err .title { color: #333; } -
-{% if code_url %} +
+{% if error %} +
暂时无法支付
+
{{ error }}
+{% elif pay %}
微信支付
-
请点击下方按钮完成支付
点击后将调起付款密码
- 立即支付 -
若点击无反应,请返回小程序使用「复制支付链接」
+
正在调起支付...
+ + {% else %}
支付已失效
-
链接无效或已过期,请返回小程序重新发起支付
+
请返回小程序重新发起支付
{% endif %}