feat: 微信扫码改为HTTPS+服务号JSAPI,支持相册扫码直接出密码

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQueandCursor committed 2026-08-09 06:58:07 +08:00
1 parent d342c4b818
commit 2d5f2ada79
4 files changed
+278 -63

No files matched your search

+4 -1
View File
@@ -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
+115 -45
View File
@@ -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
View File
@@ -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):
+42 -8
View File
@@ -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>