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}>{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))