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}
|
||||
Reference in New Issue
Block a user