"""微信 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 = [''] for k, v in data.items(): parts.append(f'<{k}>') parts.append('') 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}