Files
Django/jituan/services/wechat_v2_refund.py

167 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""微信 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}