fix: H5支付中转页OAuth重签并禁止自动调起

This commit is contained in:
XingQue committed 2026-08-12 20:57:12 +08:00
1 parent 45a9508ed4
commit 6c5c9a378a
3 files changed
+250 -56

No files matched your search

+34 -3
View File
@@ -166,12 +166,43 @@ def build_native_bridge_url(dingdanid: str, club_id=None) -> str:
return f'https://www.abas.asia/hqhd/dingdan/wx-native-pay/?{q}'
def build_official_jsapi_pay_params(**kwargs):
raise Exception('已改为官方 Native 付款码,不再走服务号 H5 JSAPI')
def build_official_jsapi_pay_params(
*,
dingdanid,
jine,
body,
notify_url='',
openid,
club_id=None,
client_ip='127.0.0.1',
):
"""服务号 JSAPI 统一下单(H5 / 相册中转页)。notify_url 由订单侧 settings 接管。"""
from orders.views.payment import CreateOrderView
helper = CreateOrderView()
return helper.generate_wechat_pay_params(
dingdanid=dingdanid,
jine=jine,
beizhu=(body or '微信支付')[:40] or '微信支付',
user_ip=client_ip or '127.0.0.1',
openid=openid,
club_id=club_id,
app_source='official',
)
def build_mini_jsapi_pay_params(**kwargs):
raise Exception('已改为官方 Native 付款码,不再走小程序码 JSAPI')
raise Exception('小程序请走 CreateOrderView.generate_wechat_pay_params(app_source=mini)')
def build_h5_jsapi_bridge_url(dingdanid: str, club_id=None, return_url='') -> 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="")}'
if return_url:
q += f'&return_url={quote(str(return_url), safe="")}'
return f'https://www.abas.asia/hqhd/dingdan/wx-h5-pay/?{q}'
def resolve_scene_to_dingdanid(scene: str) -> str:
+97 -4
View File
@@ -533,11 +533,101 @@ def alipay_pay_page(request):
def wx_h5_jsapi_pay_page(request):
"""H5 点单密码支付中转页(落在已登记的 /hqhd/dingdan/ 授权目录)。
不是扫码。仅把 WeixinJSBridge 调起挪到商户已配置的目录下,
避免 /xqh5/ 报「当前页面的URL未注册」。
支付参数由同域 sessionStorage 传入(前端写入)。
不是扫码。流程:
1) 带 out_trade_no 进入 → 服务号静默 OAuth 取当前微信 openid
2) 用该 openid 对未付款订单重新统一下单,服务端注入 pay 参数
3) 页面仅用户点击后 WeixinJSBridge.invoke(避免自动调起被微信秒关)
sessionStorage 仅作无 OAuth / 旧链接兜底。
"""
return render(request, 'wechat/h5_jsapi_pay.html', {})
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,
build_h5_jsapi_bridge_url,
)
out_trade_no = (request.GET.get('out_trade_no') or request.GET.get('dingdanid') or '').strip()
club_id = (request.GET.get('club_id') or '').strip() or 'ufo'
code = (request.GET.get('code') or '').strip()
return_url = (request.GET.get('return_url') or '').strip()
ctx = {
'server_pay': None,
'server_error': '',
'out_trade_no': out_trade_no,
'club_id': club_id,
'return_url': return_url,
'oauth_openid_preview': '',
'pay_source': 'sessionStorage',
}
if not out_trade_no:
# 兼容仅 sessionStorage 的旧跳转
return render(request, 'wechat/h5_jsapi_pay.html', ctx)
# 查未付款订单
order = None
jine = None
try:
from orders.models import Order
order = Order.query.filter(OrderID=out_trade_no).first()
if order and int(getattr(order, 'Status', -1) or -1) == 9:
jine = getattr(order, 'Amount', None)
club_id = (getattr(order, 'ClubID', None) or club_id or '').strip() or club_id
except Exception:
logger.exception('[WX_H5_PAY] 查订单失败 dingdan=%s', out_trade_no)
if order is None or jine is None:
ctx['server_error'] = '订单不存在或已支付/已取消,请返回点单页重新下单'
return render(request, 'wechat/h5_jsapi_pay.html', ctx)
appid, secret, _club = get_club_official_credentials(club_id)
if not appid or not secret:
ctx['server_error'] = f'俱乐部 {club_id} 未配置服务号 official_appid/secret'
return render(request, 'wechat/h5_jsapi_pay.html', ctx)
if not code:
redirect_uri = build_h5_jsapi_bridge_url(out_trade_no, club_id, return_url)
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=h5pay#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:
ctx['server_error'] = (wx or {}).get('errmsg') or '微信授权失败,请返回重试'
return render(request, 'wechat/h5_jsapi_pay.html', ctx)
oid = openid.strip()
ctx['oauth_openid_preview'] = (oid[:6] + '...' + oid[-4:]) if len(oid) > 10 else oid
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=jine,
body=getattr(settings, 'WEIXIN_PAY_DESCRIPTION', '星阙订单'),
openid=oid,
club_id=club_id,
client_ip=client_ip,
)
except Exception as exc:
logger.exception('[WX_H5_PAY] OAuth 重下单失败 dingdan=%s', out_trade_no)
ctx['server_error'] = f'统一下单失败:{exc}'
return render(request, 'wechat/h5_jsapi_pay.html', ctx)
# 去掉前端不需要的私有字段副本;_diag 保留给诊断页
ctx['server_pay'] = pay
ctx['club_id'] = club_id
ctx['pay_source'] = 'oauth_server'
return render(request, 'wechat/h5_jsapi_pay.html', ctx)
def wx_native_pay_page(request):
@@ -1331,6 +1421,9 @@ class CreateOrderView(APIView):
'club_id': str(club_id or ''),
'appid': str(APPID or ''),
'app_source': str(source or ''),
'openid_preview': _oid_preview,
'openid_len': len(openid or ''),
'key_source': str(pay_cfg.get('_key_source') or ''),
},
}
+119 -49
View File
@@ -29,17 +29,27 @@
background: #3a0000; border: 2px solid #ff2d2d; color: #ffe0e0;
font-size: 15px; font-weight: 700; line-height: 1.5; word-break: break-all;
}
.warn {
margin: 0 0 16px; padding: 12px; border-radius: 10px;
background: #2a2200; border: 1px solid #c9a227; color: #ffe9a8;
font-size: 13px; line-height: 1.55;
}
</style>
</head>
<body>
<div class="wrap">
<div class="title">微信支付(诊断模式)</div>
<div class="banner" id="banner">收款商户号加载中…</div>
<div class="warn">
请<strong>手动点下方绿色按钮</strong>调起支付(不要依赖自动弹窗)。<br/>
若闪一下就关:看有没有灰字「当前页面的URL未注册」——有=目录仍未生效;没有=把下方绿日志整段复制发回。
</div>
<div class="sub" id="tip">初始化…</div>
<button class="btn" type="button" id="payBtn">立即支付 / 重试</button>
<button class="btn gray" type="button" id="copyBtn">复制下方全部诊断到剪贴板</button>
<pre id="log">loading…</pre>
</div>
{% if server_pay %}{{ server_pay|json_script:"server-pay-data" }}{% endif %}
<script>
(function () {
var STORAGE_KEY = 'wx_h5_jsapi_pay';
@@ -54,15 +64,40 @@
var returnUrl = '';
var diag = {};
// 服务端 OAuth 重签注入(优先于 sessionStorage)
var SERVER_PAY = null;
var SERVER_ERROR = '';
var SERVER_OID = '';
var PAY_SOURCE = 'sessionStorage';
var SERVER_CLUB = '';
var SERVER_ORDER = '';
var SERVER_RETURN = '';
try {
var _el = document.getElementById('server-pay-data');
SERVER_PAY = _el ? JSON.parse(_el.textContent) : null;
} catch (e) { SERVER_PAY = null; }
try {
SERVER_ERROR = '{{ server_error|default:""|escapejs }}';
SERVER_OID = '{{ oauth_openid_preview|default:""|escapejs }}';
PAY_SOURCE = '{{ pay_source|default:"sessionStorage"|escapejs }}';
SERVER_CLUB = '{{ club_id|default:""|escapejs }}';
SERVER_ORDER = '{{ out_trade_no|default:""|escapejs }}';
SERVER_RETURN = '{{ return_url|default:""|escapejs }}';
} catch (e) {}
function setBanner() {
var mch = (diag && diag.mch_id) || '(未知,请看下方日志 payload.debug)';
var app = (pay && (pay.appId || pay.appid)) || (diag && diag.backendAppId) || '(未知)';
var club = (diag && diag.club_id) || '(未知)';
var mch = (diag && diag.mch_id) || '(未知,请看下方日志)';
var app = (pay && (pay.appId || pay.appid)) || (diag && (diag.appid || diag.backendAppId)) || '(未知)';
var club = (diag && diag.club_id) || SERVER_CLUB || '(未知)';
banner.innerHTML =
'【必须看】收款商户号 mch_id = <span style="color:#0f0">' + mch + '</span><br/>' +
'appId = ' + app + '<br/>' +
'club_id = ' + club + '<br/>' +
'请到<strong>这个商户号</strong>的后台配 JSAPI 目录:https://www.abas.asia/hqhd/dingdan/';
'pay来源 = ' + PAY_SOURCE + '<br/>' +
'OAuth openid = ' + (SERVER_OID || '(无)') + '<br/>' +
'请到<strong>这个商户号</strong>的「产品中心 → JSAPI支付 → 支付授权目录」配:<br/>' +
'https://www.abas.asia/hqhd/dingdan/<br/>' +
'以及精确目录:https://www.abas.asia/hqhd/dingdan/wx-h5-pay/';
}
function now() {
@@ -125,27 +160,57 @@
push('beforeHash 是否以 xqh5 开头', beforeHash.indexOf('https://www.abas.asia/xqh5/') === 0);
}
try {
var raw = safeGetStorage();
push('sessionStorage 原始长度', raw ? raw.length : 0);
push('sessionStorage 原始内容', raw || '(空,支付参数没带过来)');
payload = raw ? JSON.parse(raw) : null;
} catch (e) {
payload = null;
push('解析 sessionStorage JSON 失败', String(e && e.message || e));
push('!!!! 支付参数来源', PAY_SOURCE);
push('server_error', SERVER_ERROR || '(无)');
push('oauth_openid_preview', SERVER_OID || '(无)');
push('out_trade_no', SERVER_ORDER || '(无)');
if (SERVER_PAY && typeof SERVER_PAY === 'object' && SERVER_PAY.timeStamp) {
pay = {
appId: SERVER_PAY.appId || SERVER_PAY.appid,
timeStamp: String(SERVER_PAY.timeStamp),
nonceStr: SERVER_PAY.nonceStr,
package: SERVER_PAY.package,
signType: SERVER_PAY.signType || 'MD5',
paySign: SERVER_PAY.paySign
};
diag = Object.assign({}, SERVER_PAY._diag || {}, {
mch_id: (SERVER_PAY._diag && SERVER_PAY._diag.mch_id) || '',
club_id: (SERVER_PAY._diag && SERVER_PAY._diag.club_id) || SERVER_CLUB,
backendAppId: (SERVER_PAY._diag && SERVER_PAY._diag.appid) || pay.appId,
openid_preview: (SERVER_PAY._diag && SERVER_PAY._diag.openid_preview) || SERVER_OID,
key_source: (SERVER_PAY._diag && SERVER_PAY._diag.key_source) || ''
});
returnUrl = SERVER_RETURN || '';
push('使用服务端 OAuth 重签 pay', {
appId: pay.appId,
package: pay.package,
openid_preview: diag.openid_preview,
mch_id: diag.mch_id,
key_source: diag.key_source
});
} else {
try {
var raw = safeGetStorage();
push('sessionStorage 原始长度', raw ? raw.length : 0);
push('sessionStorage 原始内容', raw || '(空)');
payload = raw ? JSON.parse(raw) : null;
} catch (e) {
payload = null;
push('解析 sessionStorage JSON 失败', String(e && e.message || e));
}
pay = payload && payload.pay ? payload.pay : null;
returnUrl = SERVER_RETURN || (payload && payload.returnUrl) || '';
diag = (payload && payload.debug) || {};
if (SERVER_ERROR) {
push('!!!! 服务端 OAuth/下单失败,回落 sessionStorage', SERVER_ERROR);
}
}
pay = payload && payload.pay ? payload.pay : null;
returnUrl = (payload && payload.returnUrl) || '';
diag = (payload && payload.debug) || {};
setBanner();
push('!!!! 收款商户号 mch_id', diag.mch_id || '(空——后端未返回 _diag,请先部署后端)');
push('payload.debug 完整', diag);
push('!!!! 收款商户号 mch_id', diag.mch_id || '(空)');
push('diag 完整', diag);
dumpEnv('页面加载');
push('payload.orderId', payload && payload.orderId);
push('payload.returnUrl', returnUrl || '(空)');
push('payload.ts', payload && payload.ts);
if (pay) {
push('pay.appId', pay.appId || pay.appid || '(空)');
push('pay.timeStamp', pay.timeStamp);
@@ -154,7 +219,14 @@
push('pay.signType', pay.signType || '(默认MD5)');
push('pay.paySign 前8位', String(pay.paySign || '').slice(0, 8));
push('pay.paySign 长度', String(pay.paySign || '').length);
push('完整 pay 对象', pay);
push('完整 pay 对象', {
appId: pay.appId || pay.appid,
timeStamp: pay.timeStamp,
nonceStr: pay.nonceStr,
package: pay.package,
signType: pay.signType,
paySign: pay.paySign
});
} else {
push('pay 对象', '(没有,无法调起支付)');
}
@@ -171,9 +243,9 @@
var invokeAt = 0;
function invokePay() {
dumpEnv('点击/自动调起前');
dumpEnv('点击调起前');
if (!pay || !pay.timeStamp || !pay.nonceStr || !pay.package || !pay.paySign) {
setTip('支付参数丢失,请返回重新下单(见下方日志)', true);
setTip('支付参数丢失:' + (SERVER_ERROR || '请返回重新下单'), true);
push('中止:pay 参数不完整', pay);
return;
}
@@ -202,7 +274,8 @@
nonceStr: req.nonceStr,
package: req.package,
signType: req.signType,
paySign_len: String(req.paySign || '').length
paySign_len: String(req.paySign || '').length,
pay_source: PAY_SOURCE
});
setTip('正在调起微信支付…');
btn.disabled = true;
@@ -218,9 +291,7 @@
try { return JSON.stringify(res); } catch (e) { return String(res); }
})());
var msg = (res && res.err_msg) || '';
var errDesc = (res && (res.err_desc || res.errMsg || res.err_msg)) || '';
push('err_msg', msg);
push('err_desc/其它', errDesc);
try {
var keys = [];
for (var k in res) {
@@ -239,27 +310,30 @@
return;
}
// cancel:真正取消,或微信秒关(常见于未注册等,却只回 cancel)
if (msg.indexOf('cancel') >= 0) {
var quick = costMs < 2500;
push('判定', quick ? '疑似秒关/失败(耗时<2.5s)' : '用户取消(耗时较长)');
push('当前校验 URL', checkUrl);
push('调起用的 appId', appId);
push('pay_source', PAY_SOURCE);
push('oauth_openid', SERVER_OID || '(无)');
push('关键:秒关时屏幕是否出现「当前页面的URL未注册」?有=目录问题;无=把本页绿日志发回');
if (quick) {
setTip('<span class="err">微信秒关并返回 cancel</span><br/>顶部红框的 mch_id 才是要配目录的商户号', true);
setTip('<span class="err">微信秒关 cancel</span><br/>请确认是否闪过「URL未注册」灰字,并把绿日志复制发回', true);
try {
alert(
'支付未完成(cancel 秒关)\n\n' +
'【收款商户号 mch_id】\n' + ((diag && diag.mch_id) || '(未知,先部署后端)') + '\n\n' +
'必须在这个商户后台配:\nhttps://www.abas.asia/hqhd/dingdan/\n\n' +
'appId=' + appId + '\nURL=' + checkUrl + '\nms=' + costMs
'支付秒关 cancel\n\n' +
'mch_id=' + ((diag && diag.mch_id) || '?') + '\n' +
'appId=' + appId + '\n' +
'来源=' + PAY_SOURCE + '\n' +
'openid=' + (SERVER_OID || '?') + '\n' +
'URL=' + checkUrl + '\n' +
'ms=' + costMs + '\n\n' +
'请问:有没有灰字「当前页面的URL未注册」?'
);
} catch (e) {}
} else {
setTip('已取消支付。可点「立即支付」重试');
try {
alert('已取消\nmch_id=' + ((diag && diag.mch_id) || '(未知)') + '\nappId=' + appId + '\nURL=' + checkUrl);
} catch (e) {}
setTip('已取消支付。可再点「立即支付」');
}
return;
}
@@ -268,9 +342,6 @@
push('!!!! 支付失败,请把本页全部绿色日志复制发送 !!!!');
push('当前校验 URL', checkUrl);
push('调起用的 appId', appId);
try {
alert('支付失败诊断\n\n' + msg + '\n\nappId=' + appId + '\nURL=' + checkUrl + '\n\n完整日志在页面绿色区域,点「复制」');
} catch (e) {}
});
} catch (e) {
btn.disabled = false;
@@ -293,18 +364,17 @@
btn.onclick = invokePay;
if (!pay) {
setTip('支付参数丢失:请从星阙点单页重新点「确认支付」再跳进来', true);
push('提示:同域 sessionStorage 丢失常见于开了新窗口/清理缓存;请勿复制链接单独打开本页');
if (SERVER_ERROR && !pay) {
setTip('<span class="err">' + SERVER_ERROR + '</span><br/>请返回点单页重新下单', true);
return;
}
setTip('参数已就绪,自动调起支付…');
if (typeof WeixinJSBridge === 'undefined') {
document.addEventListener('WeixinJSBridgeReady', invokePay, false);
push('等待 WeixinJSBridgeReady');
} else {
setTimeout(invokePay, 80);
if (!pay) {
setTip('支付参数丢失:请从星阙点单页重新点「确认支付」', true);
push('提示:请勿单独打开本页;须从点单页跳转');
return;
}
setTip('参数已就绪。请<strong>手动点击</strong>「立即支付」(已关闭自动调起)');
push('已关闭自动调起,等待用户点击');
})();
</script>
</body>