feat: 接入微信 Native 扫码支付(复用原回调与履约)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQueandCursor committed 2026-08-08 19:55:20 +08:00
1 parent 2643321801
commit 486c050c00
11 files changed
+329 -23

No files matched your search

+3
View File
@@ -44,17 +44,20 @@ MINI_PAY_CHANNEL_FUBEI = 'fubei'
MINI_PAY_CHANNEL_CHOICES = (MINI_PAY_CHANNEL_WECHAT, MINI_PAY_CHANNEL_FUBEI)
PAY_METHOD_WECHAT = 'wechat'
PAY_METHOD_WECHAT_NATIVE = 'wechat_native'
PAY_METHOD_ALIPAY = 'alipay'
PAY_METHOD_FUBEI = 'fubei'
PAY_METHOD_LABELS = {
PAY_METHOD_WECHAT: '微信支付',
PAY_METHOD_WECHAT_NATIVE: '微信扫码',
PAY_METHOD_ALIPAY: '支付宝',
PAY_METHOD_FUBEI: '微信支付', # 前端不感知,展示仍为微信
}
PAY_METHOD_TO_CHANNEL = {
PAY_METHOD_WECHAT: PAY_CHANNEL_WECHAT_JSAPI,
PAY_METHOD_WECHAT_NATIVE: PAY_CHANNEL_WECHAT_JSAPI, # 同商户;trade_type 不同
PAY_METHOD_ALIPAY: PAY_CHANNEL_ALIPAY_WAP,
PAY_METHOD_FUBEI: PAY_CHANNEL_FUBEI_WX_MINI,
}
+16 -13
View File
@@ -40,7 +40,9 @@ def confirm_remote_payment(
}
is_alipay = cache.get(f'alipay_pay_url_{out_trade_no}') is not None
if is_alipay:
is_wx_native = 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
trade_status = query_alipay_trade(out_trade_no, club_id)
if trade_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
@@ -64,18 +66,19 @@ def confirm_remote_payment(
'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None,
}
# cache 过期兜底:再试支付宝
from jituan.services.alipay_pay import query_alipay_trade
alipay_status = query_alipay_trade(out_trade_no, club_id)
if alipay_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
logger.info('confirm_remote: cache 过期但支付宝查单成功 %s', out_trade_no)
return {
'ok': True,
'pending': False,
'channel': 'alipay',
'transaction_id': None,
'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None,
}
# cache 过期兜底:再试支付宝(微信 Native 单禁止走支付宝查单)
if not is_wx_native:
from jituan.services.alipay_pay import query_alipay_trade
alipay_status = query_alipay_trade(out_trade_no, club_id)
if alipay_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
logger.info('confirm_remote: cache 过期但支付宝查单成功 %s', out_trade_no)
return {
'ok': True,
'pending': False,
'channel': 'alipay',
'transaction_id': None,
'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None,
}
if trade_state in (None, 'NOTPAY', 'USERPAYING', ''):
return {'ok': False, 'pending': True, 'channel': 'wechat', 'trade_state': trade_state}
+138
View File
@@ -0,0 +1,138 @@
"""微信 Native 扫码支付(trade_type=NATIVE)。
与 JSAPI 共用普通商户 mch_id / API 密钥;不走付呗。
返回 code_url + 二维码图;履约仍走各业务原有 notify_url / fulfill_*。
"""
from __future__ import annotations
import hashlib
import logging
import random
import string
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_TTL_SECONDS = 60 * 30
def _cache_key(dingdanid: str) -> str:
return f'{CACHE_KEY_PREFIX}{dingdanid}'
def generate_wechat_native_pay_params(
*,
dingdanid,
jine,
body,
notify_url,
club_id=None,
client_ip='127.0.0.1',
scene='native',
):
"""统一下单 NATIVE,返回 {qr_code, qr_image, pay_method}。
同一业务单号未过期时复用 cache 中的 code_url,避免重复点支付产生双码。
不需要 openid。第一期不走付呗。
"""
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 '微信支付'
if not dingdanid:
raise Exception('订单号不能为空')
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 {
'qr_code': cached_url,
'qr_image': qr_link_to_base64(cached_url),
'pay_method': PAY_METHOD_WECHAT_NATIVE,
}
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',
dingdanid, pay_cfg.get('club_id'), mch_id, scene,
)
return {
'qr_code': code_url,
'qr_image': qr_link_to_base64(code_url),
'pay_method': PAY_METHOD_WECHAT_NATIVE,
}
+9 -2
View File
@@ -271,10 +271,12 @@ def log_wechat_v2_pay_diag(pay_cfg, *, scene='', openid='', order_hint=''):
)
def parse_wechat_v2_unifiedorder_response(content, *, log_prefix='WX_UNIFIED'):
def parse_wechat_v2_unifiedorder_response(content, *, log_prefix='WX_UNIFIED', expect='prepay_id'):
"""
解析微信 V2 统一下单 XML;失败时记录完整原始响应并抛出可读异常。
成功返回 {'prepay_id': '...'}。
expect:
- prepay_id:JSAPI(默认,旧行为不变)
- code_url:Native 扫码
"""
import defusedxml.ElementTree as ET
@@ -294,6 +296,11 @@ def parse_wechat_v2_unifiedorder_response(content, *, log_prefix='WX_UNIFIED'):
result_code = _xml_text(root, 'result_code')
if return_code == 'SUCCESS' and result_code == 'SUCCESS':
if expect == 'code_url':
code_url = _xml_text(root, 'code_url')
if not code_url:
raise Exception(f'微信 Native 下单成功但缺少 code_url: {raw}')
return {'code_url': code_url, 'prepay_id': _xml_text(root, 'prepay_id')}
prepay_id = _xml_text(root, 'prepay_id')
if not prepay_id:
raise Exception(f'微信支付成功但缺少 prepay_id: {raw}')
+24 -2
View File
@@ -909,7 +909,7 @@ class CreateOrderView(APIView):
laoban_yonghuid = getattr(current_user, 'UserUID', '') or getattr(current_user, 'yonghuid', '')
user_openid, club_id = get_payment_openid(request, current_user, club_id)
# 支付宝支付不需要 openid
# JSAPI 需要 openid;支付宝 / 微信 Native 扫码不需要
if pay_method == 'wechat' and not user_openid:
return Response({'code': 10, 'msg': '用户openid不存在', 'data': None})
@@ -995,6 +995,17 @@ class CreateOrderView(APIView):
subject=getattr(settings, 'WEIXIN_PAY_DESCRIPTION', '星阙订单'),
club_id=club_id,
)
elif pay_method == 'wechat_native':
from jituan.services.wechat_native_pay import generate_wechat_native_pay_params
pay_params = generate_wechat_native_pay_params(
dingdanid=dingdanid,
jine=shangpin_jiage,
body=getattr(settings, 'WEIXIN_PAY_DESCRIPTION', '星阙订单'),
notify_url=settings.WEIXIN_NOTIFY_URL,
club_id=club_id,
client_ip=self.get_client_ip(request),
scene='order',
)
else:
from jituan.services.club_user import get_pay_app_source
pay_params = self.generate_wechat_pay_params(
@@ -1239,7 +1250,7 @@ class ContinuePayView(APIView):
pay_method = (request.data.get('pay_method') or 'wechat').strip().lower()
if not dingdanid:
return Response({'code': 1, 'msg': '订单ID不能为空', 'data': None})
if pay_method not in ('wechat', 'alipay'):
if pay_method not in ('wechat', 'alipay', 'wechat_native'):
return Response({'code': 1, 'msg': '不支持的支付方式', 'data': None})
current_user = request.user
@@ -1321,6 +1332,17 @@ class ContinuePayView(APIView):
subject=subject,
club_id=club_id,
)
elif pay_method == 'wechat_native':
from jituan.services.wechat_native_pay import generate_wechat_native_pay_params
pay_params = generate_wechat_native_pay_params(
dingdanid=dingdanid,
jine=jine,
body=subject,
notify_url=settings.WEIXIN_NOTIFY_URL,
club_id=club_id,
client_ip=helper.get_client_ip(request),
scene='order_continue',
)
else:
from jituan.services.club_user import get_pay_app_source
pay_params = helper.generate_wechat_pay_params(
+20 -1
View File
@@ -166,7 +166,7 @@ class ShangjiaChongzhi(APIView):
'message': '订单创建失败,请重试'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# 5. 生成支付参数(支持支付宝/微信)
# 5. 生成支付参数(支付宝 / 微信Native / 微信JSAPI)
if pay_method == 'alipay':
try:
from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params
@@ -183,6 +183,25 @@ class ShangjiaChongzhi(APIView):
'code': 500,
'message': f'支付参数生成失败: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif pay_method == 'wechat_native':
try:
from jituan.services.wechat_native_pay import generate_wechat_native_pay_params
pay_params = generate_wechat_native_pay_params(
dingdanid=dingdanid,
jine=jine,
body=settings.SHANGJIA_CZ_PAY_DESCRIPTION,
notify_url=settings.SHANGJIA_CZ_PAY_NOTIFY_URL,
club_id=pay_club,
client_ip=self.get_client_ip(),
scene='shangjia',
)
except Exception as e:
chongzhi_jilu.delete()
logger.error(f"生成微信Native支付参数失败: {str(e)}")
return Response({
'code': 500,
'message': f'支付参数生成失败: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
try:
pay_openid, _ = get_payment_openid(request, club_id=pay_club)
+23 -1
View File
@@ -173,7 +173,7 @@ class HuiyuanGoumai(APIView):
f"会员{huiyuanid}, 俱乐部{club_id}, 体验={is_trial}, "
f"金额{jine}元, 天数{purchase_days}")
# 7. 生成支付参数(支持支付宝/微信)
# 7. 生成支付参数(支付宝 / 微信Native / 微信JSAPI)
if pay_method == 'alipay':
try:
from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params
@@ -193,6 +193,28 @@ class HuiyuanGoumai(APIView):
'code': 500,
'message': f'支付参数生成失败: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif pay_method == 'wechat_native':
try:
from jituan.services.wechat_native_pay import generate_wechat_native_pay_params
pay_params = generate_wechat_native_pay_params(
dingdanid=dingdanid,
jine=jine,
body=settings.HUIYUAN_PAY_DESCRIPTION,
notify_url=settings.HUIYUAN_PAY_NOTIFY_URL,
club_id=club_id,
client_ip=self.get_client_ip(),
scene='huiyuan',
)
except Exception as e:
from jituan.services.member_recharge import MEMBER_CANCELLED_STATUS
chongzhi_jilu.zhuangtai = MEMBER_CANCELLED_STATUS
chongzhi_jilu.shuoming = f"{shuoming} | 下单失败:{str(e)[:40]}"[:100]
chongzhi_jilu.save(update_fields=['zhuangtai', 'shuoming'])
logger.error(f"生成会员微信Native参数失败: {str(e)}")
return Response({
'code': 500,
'message': f'支付参数生成失败: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
try:
pay_openid, pay_club = get_payment_openid(request, club_id=club_id)
+20 -1
View File
@@ -143,7 +143,7 @@ class JifenBuchong(APIView):
logger.info(f"创建积分充值订单: 用户{request.user.UserUID}, 订单{dingdanid}, 金额{jine}元")
# 6. 生成支付参数(支持支付宝/微信)
# 6. 生成支付参数(支付宝 / 微信Native / 微信JSAPI)
if pay_method == 'alipay':
try:
from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params
@@ -160,6 +160,25 @@ class JifenBuchong(APIView):
'code': 500,
'message': f'支付参数生成失败: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif pay_method == 'wechat_native':
try:
from jituan.services.wechat_native_pay import generate_wechat_native_pay_params
pay_params = generate_wechat_native_pay_params(
dingdanid=dingdanid,
jine=jine,
body=settings.JIFEN_PAY_DESCRIPTION,
notify_url=settings.JIFEN_PAY_NOTIFY_URL,
club_id=pay_club,
client_ip=self.get_client_ip(),
scene='jifen',
)
except Exception as e:
chongzhi_jilu.delete()
logger.error(f"生成微信Native支付参数失败: {str(e)}")
return Response({
'code': 500,
'message': f'支付参数生成失败: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
try:
pay_openid, _ = get_payment_openid(request, club_id=pay_club)
+20 -1
View File
@@ -157,7 +157,7 @@ class YajinGoumai(APIView):
logger.info(f"创建押金充值订单: 用户{request.user.UserUID}, 订单{dingdanid}, 金额{jine}元")
# 5. 生成支付参数(支持支付宝/微信)
# 5. 生成支付参数(支付宝 / 微信Native / 微信JSAPI)
if pay_method == 'alipay':
try:
from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params
@@ -174,6 +174,25 @@ class YajinGoumai(APIView):
'code': 500,
'message': f'支付参数生成失败: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif pay_method == 'wechat_native':
try:
from jituan.services.wechat_native_pay import generate_wechat_native_pay_params
pay_params = generate_wechat_native_pay_params(
dingdanid=dingdanid,
jine=jine,
body=settings.YAJIN_PAY_DESCRIPTION,
notify_url=settings.YAJIN_PAY_NOTIFY_URL,
club_id=pay_club,
client_ip=self.get_client_ip(),
scene='yajin',
)
except Exception as e:
chongzhi_jilu.delete()
logger.error(f"生成微信Native支付参数失败: {str(e)}")
return Response({
'code': 500,
'message': f'支付参数生成失败: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
try:
pay_openid, _ = get_payment_openid(request, club_id=pay_club)
+13
View File
@@ -281,6 +281,19 @@ class ZhuanpanGoumai(APIView):
subject=getattr(settings, 'ZHUANPAN_PAY_DESCRIPTION', '转盘购票'),
club_id=club_id,
)
elif pay_method == 'wechat_native':
from jituan.services.wechat_native_pay import generate_wechat_native_pay_params
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'
pay_params = generate_wechat_native_pay_params(
dingdanid=dingdanid,
jine=jine,
body=getattr(settings, 'ZHUANPAN_PAY_DESCRIPTION', '转盘购票'),
notify_url=settings.ZHUANPAN_PAY_NOTIFY_URL,
club_id=club_id,
client_ip=client_ip,
scene='zhuanpan',
)
else:
pay_openid, _ = get_payment_openid(request, club_id=club_id)
if not pay_openid:
+43 -2
View File
@@ -153,7 +153,7 @@ class FaKuanPayView(APIView):
club_id=penalty_cz_club_id(fadan),
)
# 生成支付参数(支持支付宝/微信)
# 生成支付参数(支付宝 / 微信Native / 微信JSAPI)
# 支付必须用「当前小程序/打手写入俱乐部」的 AppID+openid;
# 与罚单 ClubID(申请人俱乐部)混用会导致部分人 openid/appid 不一致而下单失败。
if pay_method == 'alipay':
@@ -173,6 +173,26 @@ class FaKuanPayView(APIView):
'message': f'支付参数生成失败: {str(e)}',
'msg': f'支付参数生成失败: {str(e)}',
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif pay_method == 'wechat_native':
try:
from jituan.services.wechat_native_pay import generate_wechat_native_pay_params
pay_params = generate_wechat_native_pay_params(
dingdanid=dingdanid,
jine=jine,
body=settings.FAKUAN_PAY_DESCRIPTION,
notify_url=settings.FAKUAN_PAY_NOTIFY_URL,
club_id=pay_club,
client_ip=self.get_client_ip(),
scene='fakuan',
)
except Exception as e:
chongzhi_jilu.delete()
logger.error(f"生成微信Native支付参数失败: {str(e)}")
return Response({
'code': 500,
'message': f'支付参数生成失败: {str(e)}',
'msg': f'支付参数生成失败: {str(e)}',
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
pay_openid, pay_club = get_payment_openid(request, club_id=pay_club)
if not pay_openid:
@@ -684,7 +704,7 @@ class KaohePayView(APIView):
attach_str = json.dumps({'oid': dingdanid}, ensure_ascii=False)
logger.info(f"attach内容: {attach_str},长度: {len(attach_str.encode('utf-8'))}字节")
# 11. 生成支付参数(支持支付宝/微信)
# 11. 生成支付参数(支付宝 / 微信Native / 微信JSAPI)
if pay_method == 'alipay':
try:
from jituan.services.alipay_pay import generate_alipay_czjilu_pay_params
@@ -703,6 +723,27 @@ class KaohePayView(APIView):
logger.error(f"删除临时表记录失败: {str(del_e)}")
logger.error(f"生成支付宝支付参数失败: {str(e)}", exc_info=True)
return Response({'code': 500, 'message': f'支付参数生成失败: {str(e)}'})
elif pay_method == 'wechat_native':
try:
from jituan.services.wechat_native_pay import generate_wechat_native_pay_params
pay_params = generate_wechat_native_pay_params(
dingdanid=dingdanid,
jine=float(fee),
body=settings.KAOHE_PAY_DESCRIPTION,
notify_url=settings.KAOHE_PAY_NOTIFY_URL,
club_id=pay_club,
client_ip=self.get_client_ip(),
scene='kaohe',
)
logger.info(f"微信Native支付参数生成成功: {dingdanid}")
except Exception as e:
order.delete()
try:
KaohePayTemp.query.filter(dingdan_id=dingdanid).delete()
except Exception as del_e:
logger.error(f"删除临时表记录失败: {str(del_e)}")
logger.error(f"生成微信Native支付参数失败: {str(e)}", exc_info=True)
return Response({'code': 500, 'message': f'支付参数生成失败: {str(e)}'})
else:
try:
pay_params = self.generate_wechat_pay_params(