fix: 平台微信退款改用俱乐部收款配置(付呗/直连分路,不动支付宝)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
166
jituan/services/wechat_v2_refund.py
Normal file
166
jituan/services/wechat_v2_refund.py
Normal 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_config(Club 收款配置)。
|
||||
返回 {'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}
|
||||
@@ -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
|
||||
fb = try_fubei_refund(
|
||||
out_trade_no=out_trade_no,
|
||||
amount_yuan=jine,
|
||||
club_id=club_id_from_order(out_trade_no),
|
||||
)
|
||||
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 '付呗退款失败'}
|
||||
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
|
||||
|
||||
# 检查配置
|
||||
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}'}
|
||||
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,
|
||||
)
|
||||
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 '付呗退款失败'}
|
||||
|
||||
# 生成退款单号
|
||||
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
|
||||
)
|
||||
|
||||
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)}'}
|
||||
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 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>"]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
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}
|
||||
return refund_wechat_v2_by_club(
|
||||
out_trade_no=dingdan_id,
|
||||
amount_yuan=jine,
|
||||
club_id=club_id,
|
||||
transaction_id=transaction_id,
|
||||
reason='客服退款',
|
||||
)
|
||||
|
||||
def generate_nonce_str(self):
|
||||
return ''.join(random.choices(string.ascii_letters + string.digits, k=32))
|
||||
|
||||
Reference in New Issue
Block a user