fix: 平台微信退款改用俱乐部收款配置(付呗/直连分路,不动支付宝)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-28 14:17:24 +08:00
parent 5cfb4a1b2b
commit 5ded53bca8
3 changed files with 206 additions and 148 deletions

View File

@@ -0,0 +1,166 @@
"""微信 V2 退款按订单所属俱乐部的「收款」商户配置Club 表),不走提现 V3不碰支付宝。
通道顺序由调用方保证:先 try_fubei_refund付呗单非付呗再调本模块。
"""
from __future__ import annotations
import hashlib
import logging
import os
import random
import string
import time
from decimal import Decimal
from typing import Any, Dict, Optional
import requests
import xmltodict
from django.conf import settings
from jituan.services.wechat_pay import (
SHARED_WX_MERCHANT_CLUBS,
club_id_from_order,
get_wechat_v2_config,
)
logger = logging.getLogger(__name__)
def _yuan_to_fen(amount) -> int:
return int((Decimal(str(amount)) * 100).quantize(Decimal('1')))
def _refund_no(out_trade_no: str) -> str:
ts = str(int(time.time()))
rand = ''.join(random.choices(string.digits, k=4))
return f'{out_trade_no}REF{ts}{rand}'[:64]
def _dict_to_xml(data: dict) -> str:
parts = ['<xml>']
for k, v in data.items():
parts.append(f'<{k}><![CDATA[{v}]]></{k}>')
parts.append('</xml>')
return ''.join(parts)
def _resolve_pay_certs(club_id: str, pay_cfg: dict) -> tuple:
"""优先 Club.pay_cert_path / pay_key_path仅共享星阙商户号的俱乐部可回落全局退款证书目录。"""
cert_path = (pay_cfg.get('cert_path') or '').strip()
key_path = (pay_cfg.get('key_path') or '').strip()
if cert_path and key_path and os.path.isfile(cert_path) and os.path.isfile(key_path):
return cert_path, key_path, 'club.pay_cert'
cid = (club_id or '').strip()
if cid in SHARED_WX_MERCHANT_CLUBS:
fb_cert = (getattr(settings, 'WEIXIN_CERT_PATH', '') or '').strip()
fb_key = (getattr(settings, 'WEIXIN_KEY_PATH', '') or '').strip()
if fb_cert and fb_key and os.path.isfile(fb_cert) and os.path.isfile(fb_key):
return fb_cert, fb_key, 'settings.WEIXIN_CERT(shared_xq)'
return cert_path, key_path, 'missing'
def refund_wechat_v2_by_club(
*,
out_trade_no: str,
amount_yuan,
club_id: Optional[str] = None,
transaction_id: Optional[str] = None,
reason: str = '客服退款',
) -> Dict[str, Any]:
"""
微信直连商户 V2 退款(/secapi/pay/refund
商户号 / V2 密钥 / AppID / 证书均来自 get_wechat_v2_configClub 收款配置)。
返回 {'code': 0/非0, 'msg': ..., 'refund_id': ...}
"""
oid = (out_trade_no or '').strip()
if not oid:
return {'code': 400, 'msg': '缺少订单号'}
cid = (club_id or '').strip() or club_id_from_order(oid)
pay_cfg = get_wechat_v2_config(cid)
appid = (pay_cfg.get('appid') or '').strip()
mch_id = (pay_cfg.get('mch_id') or '').strip()
key = (pay_cfg.get('key') or '').strip()
cert_path, key_path, cert_source = _resolve_pay_certs(cid, pay_cfg)
missing = [
name
for name, val in (
('appid', appid),
('mch_id', mch_id),
('mch_key', key),
('pay_cert', cert_path),
('pay_key', key_path),
)
if not val
]
if missing:
logger.error(
'[WX_V2_REFUND] 收款配置缺失 club=%s order=%s missing=%s cert_source=%s',
cid, oid, missing, cert_source,
)
return {
'code': 500,
'msg': f'俱乐部收款退款配置缺失: {missing}(请在俱乐部配置上传收款证书 CRT/KEY 并填写商户号与 V2 密钥)',
}
if not os.path.isfile(cert_path) or not os.path.isfile(key_path):
logger.error(
'[WX_V2_REFUND] 证书文件不存在 club=%s cert=%s key=%s',
cid, cert_path, key_path,
)
return {'code': 500, 'msg': '俱乐部收款退款证书文件不存在,请重新上传 CRT/KEY'}
out_refund_no = _refund_no(oid)
total_fee = _yuan_to_fen(amount_yuan)
params = {
'appid': appid,
'mch_id': mch_id,
'nonce_str': ''.join(random.choices(string.ascii_letters + string.digits, k=32)),
'out_refund_no': out_refund_no,
'total_fee': total_fee,
'refund_fee': total_fee,
'refund_desc': (reason or '退款')[:80],
'op_user_id': mch_id,
}
tx = (transaction_id or '').strip()
if tx:
params['transaction_id'] = tx
else:
params['out_trade_no'] = oid
sign_items = sorted((k, v) for k, v in params.items() if v is not None and v != '')
string_a = '&'.join(f'{k}={v}' for k, v in sign_items)
sign = hashlib.md5(f'{string_a}&key={key}'.encode('utf-8')).hexdigest().upper()
params['sign'] = sign
logger.info(
'[WX_V2_REFUND] club=%s order=%s mch_id=%s appid=%s cert_source=%s key_source=%s',
cid, oid, mch_id, appid, cert_source, pay_cfg.get('_key_source'),
)
try:
response = requests.post(
'https://api.mch.weixin.qq.com/secapi/pay/refund',
data=_dict_to_xml(params).encode('utf-8'),
headers={'Content-Type': 'application/xml'},
cert=(cert_path, key_path),
timeout=30,
)
result = xmltodict.parse(response.content)['xml']
logger.info('[WX_V2_REFUND] resp club=%s order=%s result=%s', cid, oid, result)
except Exception as exc:
logger.error('[WX_V2_REFUND] request fail club=%s order=%s err=%s', cid, oid, exc, exc_info=True)
return {'code': 500, 'msg': f'微信退款请求异常: {exc}'}
if result.get('return_code') == 'SUCCESS' and result.get('result_code') == 'SUCCESS':
return {
'code': 0,
'msg': 'success',
'refund_id': result.get('refund_id') or out_refund_no,
}
err_msg = result.get('err_code_des') or result.get('return_msg') or '未知错误'
logger.warning('[WX_V2_REFUND] fail club=%s order=%s msg=%s', cid, oid, err_msg)
return {'code': 400, 'msg': err_msg}

View File

@@ -1197,7 +1197,7 @@ class AdTongYiTuiKuanPingTai(APIView):
transaction_id = getattr(dingdan_obj, 'WechatTransactionID', None)
weixin_refund_result = self.call_weixin_refund(
out_trade_no=dingdan_id,
Amount=jine,
jine=jine,
transaction_id=transaction_id
)
@@ -1283,88 +1283,35 @@ class AdTongYiTuiKuanPingTai(APIView):
def call_weixin_refund(self, out_trade_no, jine, transaction_id=None):
"""
调用微信退款接口
:param out_trade_no: 商户订单号
:param jine: 退款金额
:param transaction_id: 微信支付订单号(优先使用)
:return: {'success': True/False, 'msg': '消息', 'refund_id': '退款单号'}
平台订单退款:付呗单走付呗;微信直连单按订单 Club 收款配置 V2 退款。
不处理支付宝;不用全局 WEIXIN_*、不用提现 V3。
:return: {'success': True/False, 'msg': '...', 'refund_id': '...'}
"""
try:
from jituan.services.pay_refund_router import try_fubei_refund
from jituan.services.wechat_pay import club_id_from_order
from jituan.services.wechat_v2_refund import refund_wechat_v2_by_club
club_id = club_id_from_order(out_trade_no)
fb = try_fubei_refund(
out_trade_no=out_trade_no,
amount_yuan=jine,
club_id=club_id_from_order(out_trade_no),
club_id=club_id,
)
if fb is not None:
if fb.get('code') == 0:
return {'success': True, 'msg': fb.get('msg') or 'success', 'refund_id': fb.get('refund_id')}
return {'success': False, 'msg': fb.get('msg') or '付呗退款失败'}
# 检查配置
required_settings = ['WEIXIN_APPID', 'WEIXIN_MCHID', 'WEIXIN_SHANGHUMIYAO', 'WEIXIN_CERT_PATH', 'WEIXIN_KEY_PATH']
for setting in required_settings:
if not hasattr(settings, setting) or not getattr(settings, setting):
return {'success': False, 'msg': f'缺少微信支付配置: {setting}'}
# 生成退款单号
refund_id = f"TK{int(time.time())}{random.randint(1000, 9999)}"
total_fee_in_fen = yuan_to_fen(jine)
# 构造请求参数
refund_data = {
'appid': settings.WEIXIN_APPID,
'mch_id': settings.WEIXIN_MCHID,
'nonce_str': hashlib.md5(str(time.time()).encode()).hexdigest(),
'out_refund_no': refund_id,
'total_fee': total_fee_in_fen,
'refund_fee': total_fee_in_fen,
'op_user_id': settings.WEIXIN_MCHID,
'refund_account': 'REFUND_SOURCE_UNSETTLED_FUNDS'
}
# 优先使用 transaction_id否则用 out_trade_no
if transaction_id:
refund_data['transaction_id'] = transaction_id
else:
refund_data['out_trade_no'] = out_trade_no
# 生成签名
sorted_params = sorted(refund_data.items())
sign_string = '&'.join([f"{k}={v}" for k, v in sorted_params if v])
sign_string += f"&key={settings.WEIXIN_SHANGHUMIYAO}"
sign = hashlib.md5(sign_string.encode()).hexdigest().upper()
refund_data['sign'] = sign
xml_data = self.dict_to_xml(refund_data)
url = "https://api.mch.weixin.qq.com/secapi/pay/refund"
response = requests.post(
url,
data=xml_data.encode('utf-8'),
headers={'Content-Type': 'application/xml'},
cert=(settings.WEIXIN_CERT_PATH, settings.WEIXIN_KEY_PATH),
timeout=30
result = refund_wechat_v2_by_club(
out_trade_no=out_trade_no,
amount_yuan=jine,
club_id=club_id,
transaction_id=transaction_id,
reason='平台退款',
)
if response.status_code == 200:
result = self.xml_to_dict(response.content)
if result.get('return_code') == 'SUCCESS' and result.get('result_code') == 'SUCCESS':
logger.info(f"微信退款成功,订单:{out_trade_no},退款单号:{refund_id}")
return {'success': True, 'msg': '退款申请提交成功', 'refund_id': refund_id}
else:
error_msg = result.get('err_code_des', result.get('return_msg', '退款申请失败'))
logger.error(f"微信退款失败,订单:{out_trade_no},错误:{error_msg}")
return {'success': False, 'msg': f'微信退款失败: {error_msg}'}
else:
logger.error(f"微信退款接口调用失败,状态码:{response.status_code}")
return {'success': False, 'msg': '微信退款接口调用失败'}
except requests.exceptions.Timeout:
logger.error(f"微信退款接口请求超时,订单:{out_trade_no}")
return {'success': False, 'msg': '微信退款接口请求超时'}
except Exception as e:
logger.error(f"调用微信退款接口异常: {str(e)}")
return {'success': False, 'msg': f'退款处理异常: {str(e)}'}
if result.get('code') == 0:
return {'success': True, 'msg': result.get('msg') or 'success', 'refund_id': result.get('refund_id')}
return {'success': False, 'msg': result.get('msg') or '微信退款失败'}
def dict_to_xml(self, dict_data):
xml = ["<xml>"]

View File

@@ -918,84 +918,29 @@ class KefuPlatformRefundView(APIView):
def call_wechat_refund(self, dingdan_id, jine, transaction_id=None):
"""
调用微信支付退款接口
:param dingdan_id: 商户订单号我们的订单ID
:param jine: 退款金额
:param transaction_id: 微信支付订单号(优先使用)
:return: {'code': 0, 'msg': '成功', 'refund_id': 'xxx'} 或 {'code': 非0, 'msg': '错误信息'}
平台订单退款:付呗单走付呗;微信直连单按订单 Club 的「收款」配置 V2 退款(不用全局 settings、不用提现 V3
不处理支付宝。
"""
from jituan.services.pay_refund_router import try_fubei_refund
from jituan.services.wechat_pay import club_id_from_order
from jituan.services.wechat_v2_refund import refund_wechat_v2_by_club
club_id = club_id_from_order(dingdan_id)
fb = try_fubei_refund(
out_trade_no=dingdan_id,
amount_yuan=jine,
club_id=club_id_from_order(dingdan_id),
club_id=club_id,
)
if fb is not None:
return fb
# 获取配置
appid = getattr(settings, 'WEIXIN_APPID', '')
mch_id = getattr(settings, 'WEIXIN_MCHID', '')
key = getattr(settings, 'WEIXIN_SHANGHUMIYAO', '')
cert_path = getattr(settings, 'WEIXIN_CERT_PATH', '')
key_path = getattr(settings, 'WEIXIN_KEY_PATH', '')
if not all([appid, mch_id, key, cert_path, key_path]):
missing = [k for k, v in {'appid': appid, 'mch_id': mch_id, 'key': key, 'cert_path': cert_path, 'key_path': key_path}.items() if not v]
logger.error(f"微信支付配置缺失: {missing}")
return {'code': 500, 'msg': f'系统支付配置缺失: {missing}'}
# 生成退款单号
out_refund_no = self.generate_refund_no(dingdan_id)
total_fee = yuan_to_fen(jine)
refund_fee = total_fee
refund_desc = '客服退款'
# 构造请求参数
params = {
'appid': appid,
'mch_id': mch_id,
'nonce_str': self.generate_nonce_str(),
'out_refund_no': out_refund_no,
'total_fee': total_fee,
'refund_fee': refund_fee,
'refund_desc': refund_desc,
}
if transaction_id:
params['transaction_id'] = transaction_id
else:
params['out_trade_no'] = dingdan_id # 使用订单ID作为商户订单号
# 生成签名
sorted_keys = sorted(params.keys())
stringA = '&'.join([f"{k}={params[k]}" for k in sorted_keys])
stringSignTemp = f"{stringA}&key={key}"
sign = hashlib.md5(stringSignTemp.encode('utf-8')).hexdigest().upper()
params['sign'] = sign
xml_data = self.dict_to_xml(params)
url = 'https://api.mch.weixin.qq.com/secapi/pay/refund'
try:
response = requests.post(
url,
data=xml_data,
cert=(cert_path, key_path),
timeout=10
return refund_wechat_v2_by_club(
out_trade_no=dingdan_id,
amount_yuan=jine,
club_id=club_id,
transaction_id=transaction_id,
reason='客服退款',
)
result = xmltodict.parse(response.content)['xml']
logger.info(f"微信退款响应: {result}")
except Exception as e:
logger.error(f"微信退款请求异常: {str(e)}", exc_info=True)
return {'code': 500, 'msg': '微信退款请求异常'}
if result.get('return_code') == 'SUCCESS' and result.get('result_code') == 'SUCCESS':
return {'code': 0, 'msg': 'success', 'refund_id': out_refund_no}
else:
err_msg = result.get('err_code_des', result.get('return_msg', '未知错误'))
logger.warning(f"微信退款失败: {err_msg}")
return {'code': 400, 'msg': err_msg}
def generate_nonce_str(self):
return ''.join(random.choices(string.ascii_letters + string.digits, k=32))