From 18f3d66937bf02d5fc81f710dc47ad3eba6359d2 Mon Sep 17 00:00:00 2001 From: XingQue Date: Tue, 28 Jul 2026 14:17:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B9=B3=E5=8F=B0=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E9=80=80=E6=AC=BE=E6=94=B9=E7=94=A8=E4=BF=B1=E4=B9=90=E9=83=A8?= =?UTF-8?q?=E6=94=B6=E6=AC=BE=E9=85=8D=E7=BD=AE=EF=BC=88=E4=BB=98=E5=91=97?= =?UTF-8?q?/=E7=9B=B4=E8=BF=9E=E5=88=86=E8=B7=AF=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=8A=A8=E6=94=AF=E4=BB=98=E5=AE=9D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- jituan/services/wechat_v2_refund.py | 166 ++++++++++++++++++++++++++++ orders/views/admin.py | 107 +++++------------- users/views/kefu_orders.py | 81 +++----------- 3 files changed, 206 insertions(+), 148 deletions(-) create mode 100644 jituan/services/wechat_v2_refund.py diff --git a/jituan/services/wechat_v2_refund.py b/jituan/services/wechat_v2_refund.py new file mode 100644 index 0000000..85dc4e4 --- /dev/null +++ b/jituan/services/wechat_v2_refund.py @@ -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 = [''] + 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} diff --git a/orders/views/admin.py b/orders/views/admin.py index 349e68b..4dfa98e 100644 --- a/orders/views/admin.py +++ b/orders/views/admin.py @@ -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 = [""] diff --git a/users/views/kefu_orders.py b/users/views/kefu_orders.py index fc4e022..1a5d680 100644 --- a/users/views/kefu_orders.py +++ b/users/views/kefu_orders.py @@ -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))