feat: 微信扫码改印小程序码,本机相册扫直接调起付款密码

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQueandCursor committed 2026-08-09 07:17:10 +08:00
1 parent a6d2f878ab
commit ec08885eca
4 files changed
+322 -113

No files matched your search

+241 -112
View File
@@ -1,22 +1,22 @@
"""微信 Native 扫码支付(trade_type=NATIVE)。
"""微信扫码支付:小程序码(本机相册可扫,直接弹密码)。
「一瞬间出密码」= 二维码必须是微信返回的 weixin://wxpay/bizpayurl?...
他人微信摄像头扫屏幕 → 系统直接弹付款,中间没有网页。
为什么不用 weixin:// 付款码:
- 那才是「另一台手机摄像头扫 → 瞬间出密码」的官方 Native
- 但微信禁止「扫一扫 → 相册」识别付款码(保障支付安全)
本机相册扫这种付款码会被微信官方拦截(保障支付安全)——不是商户能关的。
本机付款请走小程序「本机支付」JSAPI,同样是一瞬间出密码。
相册可扫的 HTTPS 中转页仍保留(wx-native-pay),但不印进主二维码,
因为那条路必然经过网页授权,做不到和 weixin:// 一样的「无感弹窗」。
为什么改用小程序码(微信官方文档也写了可用小程序码替代收款码):
- 相册可以识别小程序码
- 扫完直接进小程序支付页 → wx.requestPayment 出密码
- 用户体感就是:相册一扫,马上出支付,没有乱七八糟的 H5 网页
"""
from __future__ import annotations
import base64
import hashlib
import logging
import random
import string
import time
from urllib.parse import quote
import requests
from django.core.cache import cache
@@ -31,28 +31,23 @@ from utils.money import yuan_to_fen
logger = logging.getLogger(__name__)
CACHE_KEY_PREFIX = 'wx_native_code_'
SESSION_KEY_PREFIX = 'wx_native_pay_'
SCENE_KEY_PREFIX = 'wx_native_scene_'
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}'
# getwxacodeunlimit scene ≤ 32 字节;前缀 + 随机
SCENE_PREFIX = 'np'
PAY_PAGE = 'pages/wx-scan-pay/wx-scan-pay'
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:
q += f'&club_id={quote(str(club_id), safe="")}'
return f'{BRIDGE_URL_BASE}?{q}'
def _scene_key(scene: str) -> str:
return f'{SCENE_KEY_PREFIX}{scene}'
def save_native_pay_session(dingdanid, *, jine, body, notify_url, club_id=None):
def save_native_pay_session(dingdanid, *, jine, body, notify_url, club_id=None, scene=''):
cache.set(
_session_key(dingdanid),
{
@@ -60,6 +55,8 @@ def save_native_pay_session(dingdanid, *, jine, body, notify_url, club_id=None):
'body': (body or '微信支付').strip() or '微信支付',
'notify_url': (notify_url or '').strip(),
'club_id': (club_id or '').strip(),
'scene': (scene or '').strip(),
'dingdanid': (dingdanid or '').strip(),
},
CACHE_TTL_SECONDS,
)
@@ -70,17 +67,70 @@ def get_native_pay_session(dingdanid: str):
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
def resolve_scene_to_dingdanid(scene: str) -> str:
scene = (scene or '').strip()
if not scene:
return ''
dingdanid = cache.get(_scene_key(scene)) or ''
return str(dingdanid).strip()
out = {
'qr_code': code_url,
'qr_image': qr_link_to_base64(code_url),
'pay_method': PAY_METHOD_WECHAT_NATIVE,
def _new_scene() -> str:
# np + 22 位 = 24,远小于 32
rnd = ''.join(random.choices(string.ascii_letters + string.digits, k=22))
return f'{SCENE_PREFIX}{rnd}'
def _png_to_data_url(png_bytes: bytes) -> str:
b64 = base64.b64encode(png_bytes).decode('ascii')
return f'data:image/png;base64,{b64}'
def _fetch_mini_program_code_png(*, club_id: str, scene: str) -> bytes:
from utils.weixin_token import get_club_mini_access_token, is_weixin_token_invalid
access_token = get_club_mini_access_token(club_id)
if not access_token:
raise Exception(f'俱乐部 {club_id} 获取小程序 access_token 失败,无法生成支付码')
post_data = {
'scene': scene,
'page': PAY_PAGE,
'width': 430,
'auto_color': False,
'line_color': {'r': 0, 'g': 0, 'b': 0},
'check_path': False,
}
if bridge_url:
out['bridge_url'] = bridge_url
return out
def _req(tok: str):
url = f'https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={tok}'
return requests.post(url, json=post_data, timeout=15)
wx_resp = _req(access_token)
if wx_resp.headers.get('content-type', '').startswith('application/json'):
try:
err = wx_resp.json()
if is_weixin_token_invalid(err.get('errcode'), err.get('errmsg', '')):
access_token = get_club_mini_access_token(club_id, force_refresh=True)
if access_token:
wx_resp = _req(access_token)
except Exception:
pass
content_type = wx_resp.headers.get('content-type', '')
if wx_resp.status_code != 200 or 'image' not in content_type:
errmsg = '未知错误'
try:
error_info = wx_resp.json()
errmsg = error_info.get('errmsg', errmsg)
logger.error('[WX_NATIVE_MINICODE] fail club=%s err=%s', club_id, error_info)
except Exception:
logger.error(
'[WX_NATIVE_MINICODE] fail club=%s status=%s body=%s',
club_id, wx_resp.status_code, wx_resp.content[:300],
)
raise Exception(f'生成小程序支付码失败: {errmsg}')
return wx_resp.content
def generate_wechat_native_pay_params(
@@ -93,100 +143,64 @@ def generate_wechat_native_pay_params(
client_ip='127.0.0.1',
scene='native',
):
"""统一下单 NATIVE,二维码印官方 weixin:// 付款码(他人扫瞬间出密码)。"""
"""生成「相册可扫」小程序码二维码。"""
dingdanid = (dingdanid or '').strip()
notify_url = (notify_url or '').strip()
body = (body or '微信支付').strip() or '微信支付'
club_id = (club_id or '').strip()
if not dingdanid:
raise Exception('订单号不能为空')
if not notify_url:
raise Exception('缺少微信回调地址 notify_url')
if not club_id:
raise Exception('缺少 club_id,无法生成小程序支付码')
cached = get_native_pay_session(dingdanid) or {}
cached_img = (cached.get('qr_image') or '').strip()
cached_scene = (cached.get('scene') or '').strip()
if cached_img and cached_scene and resolve_scene_to_dingdanid(cached_scene) == dingdanid:
logger.info(
'[WX_NATIVE_MINICODE] reuse dingdan=%s club=%s scene=%s',
dingdanid, club_id, cached_scene,
)
return {
'qr_code': f'minicode:{cached_scene}',
'qr_image': cached_img,
'scene': cached_scene,
'pay_method': PAY_METHOD_WECHAT_NATIVE,
}
pay_scene = _new_scene()
png = _fetch_mini_program_code_png(club_id=club_id, scene=pay_scene)
qr_image = _png_to_data_url(png)
# 给相册/链接兜底页留会话(主二维码仍是 weixin://)
save_native_pay_session(
dingdanid,
jine=jine,
body=body,
notify_url=notify_url,
club_id=club_id,
scene=pay_scene,
)
bridge = build_native_bridge_url(dingdanid, club_id)
# 把图片也塞进 session,便于复用
sess = get_native_pay_session(dingdanid) or {}
sess['qr_image'] = qr_image
cache.set(_session_key(dingdanid), sess, CACHE_TTL_SECONDS)
cache.set(_scene_key(pay_scene), dingdanid, CACHE_TTL_SECONDS)
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,
)
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"""<xml>
<appid>{appid}</appid>
<body>{body}</body>
<mch_id>{mch_id}</mch_id>
<nonce_str>{nonce_str}</nonce_str>
<notify_url>{notify_url}</notify_url>
<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>
<sign>{sign}</sign>
</xml>"""
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],
'[WX_NATIVE_MINICODE] ok dingdan=%s club=%s biz_scene=%s pay_scene=%s',
dingdanid, club_id, scene, pay_scene,
)
return _native_pay_response(code_url, bridge_url=bridge)
return {
'qr_code': f'minicode:{pay_scene}',
'qr_image': qr_image,
'scene': pay_scene,
'pay_method': PAY_METHOD_WECHAT_NATIVE,
}
def build_official_jsapi_pay_params(
def build_mini_jsapi_pay_params(
*,
dingdanid,
jine,
@@ -196,21 +210,38 @@ def build_official_jsapi_pay_params(
club_id=None,
client_ip='127.0.0.1',
):
"""服务号 AppID + 服务号 openid 做 JSAPI,供相册 HTTPS 中转页调起。"""
pay_cfg = get_wechat_v2_config(club_id, app_source='official')
"""小程序 AppID + 小程序 openid → JSAPI,供相册扫码落地页调起密码。"""
from jituan.constants import FUBEI_BIZ_CZJILU, FUBEI_BIZ_ORDER
from jituan.services.mini_pay_router import maybe_create_fubei_jsapi_params
pay_cfg = get_wechat_v2_config(club_id, app_source='mini')
log_wechat_v2_pay_diag(
pay_cfg, scene='native_album_jsapi', openid=openid, order_hint=dingdanid,
pay_cfg, scene='native_minicode_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 openid:
raise Exception('缺少小程序 openid')
biz = FUBEI_BIZ_CZJILU if str(dingdanid).startswith('Cz') or str(dingdanid).startswith('FK') or str(dingdanid).startswith('KH') else FUBEI_BIZ_ORDER
fb = maybe_create_fubei_jsapi_params(
club_id=club_id,
out_trade_no=dingdanid,
amount_yuan=jine,
body=body or '微信支付',
openid=openid,
notify_url=notify_url,
biz_type=biz,
)
if fb is not None:
return fb
if not all([appid, mch_id, key]):
raise Exception(
f'服务号支付配置不完整 club={pay_cfg.get("club_id")} '
f'请配置 official_appid 与商户密钥'
f'小程序支付配置不完整 club={pay_cfg.get("club_id")} '
f'appid={bool(appid)} mch_id={bool(mch_id)} mch_key={bool(key)}'
)
if not openid:
raise Exception('缺少服务号 openid')
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
total_fee = yuan_to_fen(jine)
@@ -255,7 +286,7 @@ def build_official_jsapi_pay_params(
)
parsed = parse_wechat_v2_unifiedorder_response(
response.content,
log_prefix='WX_NATIVE_ALBUM_JSAPI',
log_prefix='WX_NATIVE_MINICODE_JSAPI',
expect='prepay_id',
)
prepay_id = parsed['prepay_id']
@@ -283,3 +314,101 @@ def build_official_jsapi_pay_params(
'signType': sign_type,
'paySign': pay_sign,
}
# 兼容旧 H5 中转页导入(若仍有请求)
def build_native_bridge_url(dingdanid: str, club_id=None) -> 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="")}'
return f'https://www.abas.asia/hqhd/dingdan/wx-native-pay/?{q}'
def build_official_jsapi_pay_params(**kwargs):
"""保留给旧 H5 页;新主路径已改小程序码。"""
kwargs = dict(kwargs)
# 复用 mini 逻辑会错 openid 体系;仍走服务号
from jituan.services.wechat_pay import get_wechat_v2_config as _cfg
pay_cfg = _cfg(kwargs.get('club_id'), app_source='official')
log_wechat_v2_pay_diag(
pay_cfg,
scene='native_album_jsapi_legacy',
openid=kwargs.get('openid'),
order_hint=kwargs.get('dingdanid'),
)
appid = pay_cfg.get('appid') or ''
mch_id = pay_cfg.get('mch_id') or ''
key = pay_cfg.get('key') or ''
openid = kwargs.get('openid') or ''
if not all([appid, mch_id, key, openid]):
raise Exception('服务号支付配置不完整')
dingdanid = kwargs.get('dingdanid')
jine = kwargs.get('jine')
body = kwargs.get('body') or '微信支付'
notify_url = kwargs.get('notify_url')
client_ip = kwargs.get('client_ip') or '127.0.0.1'
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': '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}'
sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().upper()
xml_data = f"""<xml>
<appid>{appid}</appid>
<body>{body}</body>
<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>JSAPI</trade_type>
<sign>{sign}</sign>
</xml>"""
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='WX_NATIVE_OA_JSAPI', expect='prepay_id',
)
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,
}
+2 -1
View File
@@ -1,6 +1,6 @@
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from .views import CreateOrderView, WechatPayNotifyView, AlipayPayNotifyView, alipay_pay_page, wx_native_pay_page, \
from .views import CreateOrderView, WechatPayNotifyView, AlipayPayNotifyView, alipay_pay_page, wx_native_pay_page, WxNativeScanPayView, \
PaymentFailView, PaymentVerifyView, ContinuePayView, DingdanHuoquView, DingdanXiangqingView, JiedanView, \
ShangpinLeixingView, ShangjiaPaifaView, ShangjiaDingdanHuoquView, ShangjiaJiesuanView, \
ShangjiaDingdanXiangqingView, ShangjiaChufaShenqingView, ShangjiaTuikuanShenqingView, \
@@ -25,6 +25,7 @@ urlpatterns = [
path('fubei-notify/', csrf_exempt(FubeiPayNotifyView.as_view()), name='付呗支付回调'),
path('alipay-pay/', csrf_exempt(alipay_pay_page), name='支付宝H5中转页'),
path('wx-native-pay/', csrf_exempt(wx_native_pay_page), name='微信Native扫码中转页'),
path('wx-native-scan-pay', WxNativeScanPayView.as_view(), name='微信小程序码扫码支付'),
path('shibai', PaymentFailView.as_view(), name='老板支付失败接口'),
path('fucha', PaymentVerifyView.as_view(), name='老板支付成功复查接口'),
path('dingdanhuoqu', DingdanHuoquView.as_view(), name='老板订单获取'),
+2
View File
@@ -11,6 +11,7 @@ from .payment import (
ContinuePayView,
alipay_pay_page,
wx_native_pay_page,
WxNativeScanPayView,
)
from .boss_orders import (
DingdanHuoquView,
@@ -75,6 +76,7 @@ __all__ = [
"ContinuePayView",
"alipay_pay_page",
"wx_native_pay_page",
"WxNativeScanPayView",
"DingdanHuoquView", "DingdanXiangqingView", "JiedanView",
"DingdanXiangqingView2", "JiedanView2",
"ShangpinLeixingView", "ShangjiaPaifaView", "ShangjiaDingdanHuoquView",
+77
View File
@@ -651,6 +651,83 @@ def wx_native_pay_page(request):
return render(request, 'wechat/native_pay.html', {'pay': pay, 'out_trade_no': out_trade_no})
class WxNativeScanPayView(APIView):
"""相册扫小程序码落地:用当前用户 openid 对未付款单做 JSAPI,直接出密码。"""
authentication_classes = [JWTAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
from jituan.services.wechat_native_pay import (
build_mini_jsapi_pay_params,
get_native_pay_session,
resolve_scene_to_dingdanid,
)
scene = (request.data.get('scene') or '').strip()
dingdanid = (request.data.get('dingdanid') or request.data.get('out_trade_no') or '').strip()
if scene and not dingdanid:
dingdanid = resolve_scene_to_dingdanid(scene)
if not dingdanid:
return Response({'code': 400, 'message': '无效支付码'}, status=status.HTTP_400_BAD_REQUEST)
session = get_native_pay_session(dingdanid)
if not session:
return Response({'code': 400, 'message': '支付已过期,请返回重新发起'}, status=status.HTTP_400_BAD_REQUEST)
club_id = (session.get('club_id') or '').strip()
notify_url = (session.get('notify_url') or '').strip()
if not notify_url:
if dingdanid.startswith('CzHY'):
notify_url = getattr(settings, 'HUIYUAN_PAY_NOTIFY_URL', '')
elif dingdanid.startswith('CzYJ'):
notify_url = getattr(settings, 'YAJIN_PAY_NOTIFY_URL', '')
elif dingdanid.startswith('CzJF'):
notify_url = getattr(settings, 'JIFEN_PAY_NOTIFY_URL', '')
elif dingdanid.startswith('CzSJ'):
notify_url = getattr(settings, 'SHANGJIA_CZ_PAY_NOTIFY_URL', '')
elif dingdanid.startswith('FK'):
notify_url = getattr(settings, 'FAKUAN_PAY_NOTIFY_URL', '')
elif dingdanid.startswith('KH'):
notify_url = getattr(settings, 'KAOHE_PAY_NOTIFY_URL', '')
elif dingdanid.startswith('CzZP'):
notify_url = getattr(settings, 'ZHUANPAN_PAY_NOTIFY_URL', '')
else:
notify_url = getattr(settings, 'WEIXIN_NOTIFY_URL', '')
pay_openid, _ = get_payment_openid(request, club_id=club_id or None)
if not pay_openid:
return Response({
'code': 400,
'message': '当前小程序未绑定微信,请退出后重新登录再支付',
}, status=status.HTTP_400_BAD_REQUEST)
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_params = build_mini_jsapi_pay_params(
dingdanid=dingdanid,
jine=session.get('jine'),
body=session.get('body') or '微信支付',
notify_url=notify_url,
openid=pay_openid,
club_id=club_id or None,
client_ip=client_ip,
)
except Exception as exc:
logger.exception('小程序码扫码 JSAPI 下单失败 dingdan=%s', dingdanid)
return Response({
'code': 500,
'message': f'下单失败:{exc}',
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response({
'code': 200,
'message': 'ok',
'dingdanid': dingdanid,
'payParams': pay_params,
})
class PaymentVerifyView(APIView):
"""
订单支付成功复查接口 - 前端支付成功后调用,确认订单状态