diff --git a/jituan/services/wechat_native_pay.py b/jituan/services/wechat_native_pay.py index 83cbc3f..52a495c 100644 --- a/jituan/services/wechat_native_pay.py +++ b/jituan/services/wechat_native_pay.py @@ -1,12 +1,13 @@ -"""微信「扫码」支付(相册可用版)。 +"""微信 Native 扫码支付(trade_type=NATIVE)。 -关键点: -- 二维码内容必须是 HTTPS(微信允许相册识别普通链接) -- 不能把 weixin://wxpay 付款码直接印成图(相册必被「保障支付安全」拦截) -- 用户扫 HTTPS 后进 H5 → 服务号 OAuth 拿 openid → JSAPI 统一下单 - → WeixinJSBridge.invoke 直接出密码 +「一瞬间出密码」= 二维码必须是微信返回的 weixin://wxpay/bizpayurl?... +他人微信摄像头扫屏幕 → 系统直接弹付款,中间没有网页。 -本机小程序内仍可用「本机支付」走小程序 JSAPI。 +本机相册扫这种付款码会被微信官方拦截(保障支付安全)——不是商户能关的。 +本机付款请走小程序「本机支付」JSAPI,同样是一瞬间出密码。 + +相册可扫的 HTTPS 中转页仍保留(wx-native-pay),但不印进主二维码, +因为那条路必然经过网页授权,做不到和 weixin:// 一样的「无感弹窗」。 """ from __future__ import annotations @@ -21,11 +22,17 @@ 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_pay_' +CACHE_KEY_PREFIX = 'wx_native_code_' +SESSION_KEY_PREFIX = 'wx_native_pay_' CACHE_TTL_SECONDS = 60 * 30 BRIDGE_URL_BASE = 'https://www.abas.asia/hqhd/dingdan/wx-native-pay/' @@ -34,6 +41,10 @@ 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 build_native_bridge_url(dingdanid: str, club_id=None) -> str: q = f'out_trade_no={quote(dingdanid or "", safe="")}' if club_id: @@ -43,7 +54,7 @@ def build_native_bridge_url(dingdanid: str, club_id=None) -> str: def save_native_pay_session(dingdanid, *, jine, body, notify_url, club_id=None): cache.set( - _cache_key(dingdanid), + _session_key(dingdanid), { 'jine': str(jine), 'body': (body or '微信支付').strip() or '微信支付', @@ -55,10 +66,23 @@ def save_native_pay_session(dingdanid, *, jine, body, notify_url, club_id=None): def get_native_pay_session(dingdanid: str): - data = cache.get(_cache_key(dingdanid)) + data = cache.get(_session_key(dingdanid)) 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 + + out = { + 'qr_code': code_url, + 'qr_image': qr_link_to_base64(code_url), + 'pay_method': PAY_METHOD_WECHAT_NATIVE, + } + if bridge_url: + out['bridge_url'] = bridge_url + return out + + def generate_wechat_native_pay_params( *, dingdanid, @@ -69,13 +93,7 @@ def generate_wechat_native_pay_params( client_ip='127.0.0.1', scene='native', ): - """生成「相册可扫」支付参数:HTTPS 中转码 + 会话缓存。 - - 不再调用 Native unifiedorder(weixin:// 码会被相册拦截)。 - 真实收款在中转页用服务号 JSAPI 完成,notify_url/履约不变。 - """ - from jituan.services.alipay_pay import qr_link_to_base64 - + """统一下单 NATIVE,二维码印官方 weixin:// 付款码(他人扫瞬间出密码)。""" dingdanid = (dingdanid or '').strip() notify_url = (notify_url or '').strip() body = (body or '微信支付').strip() or '微信支付' @@ -84,6 +102,7 @@ def generate_wechat_native_pay_params( if not notify_url: raise Exception('缺少微信回调地址 notify_url') + # 给相册/链接兜底页留会话(主二维码仍是 weixin://) save_native_pay_session( dingdanid, jine=jine, @@ -92,17 +111,79 @@ def generate_wechat_native_pay_params( 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, + + 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, ) - return { - # 复制也给 HTTPS,打开后同一套支付 - 'qr_code': bridge, - 'qr_image': qr_link_to_base64(bridge), - 'bridge_url': bridge, - 'pay_method': PAY_METHOD_WECHAT_NATIVE, + 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], + ) + return _native_pay_response(code_url, bridge_url=bridge) def build_official_jsapi_pay_params( @@ -115,13 +196,7 @@ def build_official_jsapi_pay_params( 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, - ) - + """服务号 AppID + 服务号 openid 做 JSAPI,供相册 HTTPS 中转页调起。""" pay_cfg = get_wechat_v2_config(club_id, app_source='official') log_wechat_v2_pay_diag( pay_cfg, scene='native_album_jsapi', openid=openid, order_hint=dingdanid,