feat: 微信扫码改为HTTPS+服务号JSAPI,支持相册扫码直接出密码
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d342c4b818
commit
2d5f2ada79
4 files changed
+278
-63
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>{mch_id}</mch_id>
|
||||
<nonce_str>{nonce_str}</nonce_str>
|
||||
<notify_url>{notify_url}</notify_url>
|
||||
<openid>{openid}</openid>
|
||||
<out_trade_no>{dingdanid}</out_trade_no>
|
||||
<spbill_create_ip>{ip}</spbill_create_ip>
|
||||
<total_fee>{total_fee}</total_fee>
|
||||
<trade_type>NATIVE</trade_type>
|
||||
<trade_type>JSAPI</trade_type>
|
||||
<sign>{sign}</sign>
|
||||
</xml>"""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+117
-9
@@ -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):
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap {% if not code_url %}err{% endif %}">
|
||||
{% if code_url %}
|
||||
<div class="wrap {% if error %}err{% endif %}">
|
||||
{% if error %}
|
||||
<div class="title">暂时无法支付</div>
|
||||
<div class="sub">{{ error }}</div>
|
||||
{% elif pay %}
|
||||
<div class="logo">微</div>
|
||||
<div class="title">微信支付</div>
|
||||
<div class="sub">请点击下方按钮完成支付<br>点击后将调起付款密码</div>
|
||||
<a class="btn" href="{{ code_url }}">立即支付</a>
|
||||
<div class="hint">若点击无反应,请返回小程序使用「复制支付链接」</div>
|
||||
<div class="sub" id="tip">正在调起支付...</div>
|
||||
<button class="btn" type="button" id="payBtn">立即支付</button>
|
||||
<script>
|
||||
(function () {
|
||||
var pay = {
|
||||
appId: '{{ pay.appId|escapejs }}',
|
||||
timeStamp: '{{ pay.timeStamp|escapejs }}',
|
||||
nonceStr: '{{ pay.nonceStr|escapejs }}',
|
||||
package: '{{ pay.package|escapejs }}',
|
||||
signType: '{{ pay.signType|escapejs }}',
|
||||
paySign: '{{ pay.paySign|escapejs }}'
|
||||
};
|
||||
function invokePay() {
|
||||
if (typeof WeixinJSBridge === 'undefined') {
|
||||
document.getElementById('tip').innerText = '请在微信内打开并点击「立即支付」';
|
||||
return;
|
||||
}
|
||||
WeixinJSBridge.invoke('getBrandWCPayRequest', pay, function (res) {
|
||||
var msg = (res && res.err_msg) || '';
|
||||
if (msg === 'get_brand_wcpay_request:ok') {
|
||||
document.getElementById('tip').innerText = '支付成功,请返回小程序查看';
|
||||
} else if (msg === 'get_brand_wcpay_request:cancel') {
|
||||
document.getElementById('tip').innerText = '已取消支付,可再次点击下方按钮';
|
||||
} else {
|
||||
document.getElementById('tip').innerText = '支付未完成,请重试';
|
||||
}
|
||||
});
|
||||
}
|
||||
document.getElementById('payBtn').onclick = invokePay;
|
||||
if (typeof WeixinJSBridge === 'undefined') {
|
||||
document.addEventListener('WeixinJSBridgeReady', invokePay, false);
|
||||
} else {
|
||||
setTimeout(invokePay, 100);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="title">支付已失效</div>
|
||||
<div class="sub">链接无效或已过期,请返回小程序重新发起支付</div>
|
||||
<div class="sub">请返回小程序重新发起支付</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user