diff --git a/a_long_dianjing/settings.py b/a_long_dianjing/settings.py index 4b91d11..886a376 100644 --- a/a_long_dianjing/settings.py +++ b/a_long_dianjing/settings.py @@ -667,5 +667,8 @@ WECHAT_PAY_V3_CONFIG = { # 多商户证书上传根目录(每个商户一个子目录:{BASE}/{mch_id}/) WECHAT_MCH_CERT_BASE_DIR = '/opt/1panel/apps/openresty/openresty/www/sites/119.28.221.247.1199/huizhifuzhengshu' +# 支付专用证书目录(与收款 huizhifuzhengshu、旧 tuikuanzhengshu 物理隔离) +WECHAT_PAY_CERT_BASE_DIR = '/opt/1panel/apps/openresty/openresty/www/sites/119.28.221.247.1199/zhifuzhengshu' + CROSS_PLATFORM_SYNC_TOKEN = 'xK9mP2vL8qW5rT7y' \ No newline at end of file diff --git a/dingdan/migrations/0018_dingdan_pay_mch_id.py b/dingdan/migrations/0018_dingdan_pay_mch_id.py new file mode 100644 index 0000000..2cac6ca --- /dev/null +++ b/dingdan/migrations/0018_dingdan_pay_mch_id.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dingdan', '0017_dingdan_guanshi_shangjia_fencheng'), + ] + + operations = [ + migrations.AddField( + model_name='dingdan', + name='pay_mch_id', + field=models.CharField( + blank=True, + db_index=True, + default='', + max_length=32, + verbose_name='下单支付商户号', + ), + ), + ] diff --git a/dingdan/models.py b/dingdan/models.py index 2207dda..c80bd6b 100644 --- a/dingdan/models.py +++ b/dingdan/models.py @@ -64,6 +64,13 @@ class Dingdan(models.Model): db_index=True, verbose_name='微信支付订单号' ) + pay_mch_id = models.CharField( + max_length=32, + blank=True, + default='', + db_index=True, + verbose_name='下单支付商户号', + ) class Meta: db_table = 'dingdan' diff --git a/dingdan/views.py b/dingdan/views.py index e2aef07..8defda0 100644 --- a/dingdan/views.py +++ b/dingdan/views.py @@ -17,6 +17,7 @@ from decimal import Decimal from django.db import transaction from django.conf import settings from utils.xcx_sys_config import wx_cfg +from utils.pay_mch_config import pay_cfg, get_current_pay_mch_id, get_pay_wx_cfg from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import permissions, status @@ -202,6 +203,7 @@ class CreateOrderView(APIView): 'tupian':shangpin_tupian, 'user1_id':f"Boss{request.user.yonghuid}", 'leixing_id': qiangdan_fields['leixing_id'], + 'pay_mch_id': get_current_pay_mch_id(), } # 创建订单记录 @@ -260,9 +262,9 @@ class CreateOrderView(APIView): try: # 微信支付配置 APPID = wx_cfg.WEIXIN_APPID - MCHID = wx_cfg.WEIXIN_MCHID - KEY = wx_cfg.WEIXIN_SHANGHUMIYAO - NOTIFY_URL = wx_cfg.WEIXIN_NOTIFY_URL + MCHID = pay_cfg.WEIXIN_MCHID + KEY = pay_cfg.WEIXIN_SHANGHUMIYAO + NOTIFY_URL = pay_cfg.WEIXIN_NOTIFY_URL if not all([APPID, MCHID, KEY, NOTIFY_URL]): raise Exception('微信支付配置不完整') @@ -509,7 +511,7 @@ class WechatPayNotifyView(APIView): # 按key排序 sorted_keys = sorted(data.keys()) stringA = '&'.join([f"{k}={data[k]}" for k in sorted_keys]) - stringSignTemp = f"{stringA}&key={wx_cfg.WEIXIN_SHANGHUMIYAO}" + stringSignTemp = f"{stringA}&key={pay_cfg.WEIXIN_SHANGHUMIYAO}" calc_sign = hashlib.md5(stringSignTemp.encode('utf-8')).hexdigest().upper() logger.debug(f"计算签名: {calc_sign}, 微信签名: {sign}") return calc_sign == sign @@ -833,8 +835,8 @@ class PaymentVerifyView(APIView): 若查询失败,抛出异常 """ appid = wx_cfg.WEIXIN_APPID - mch_id = wx_cfg.WEIXIN_MCHID - key = wx_cfg.WEIXIN_SHANGHUMIYAO + mch_id = pay_cfg.WEIXIN_MCHID + key = pay_cfg.WEIXIN_SHANGHUMIYAO nonce_str = self._generate_nonce_str() params = { @@ -1058,6 +1060,7 @@ class VirtualCreateOrderView(APIView): 'tupian': shangpin_tupian, 'user1_id': f"Boss{current_user.yonghuid}", 'leixing_id': qiangdan_fields['leixing_id'], + 'pay_mch_id': get_current_pay_mch_id(), } dingdan = Dingdan.objects.create(**order_data) @@ -7130,7 +7133,8 @@ class AdTongYiTuiKuanPingTai(APIView): weixin_refund_result = self.call_weixin_refund( out_trade_no=dingdan_id, jine=jine, - transaction_id=transaction_id + transaction_id=transaction_id, + pay_mch_id=getattr(dingdan_obj, 'pay_mch_id', '') or '', ) if not weixin_refund_result.get('success'): @@ -7217,20 +7221,24 @@ class AdTongYiTuiKuanPingTai(APIView): status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - def call_weixin_refund(self, out_trade_no, jine, transaction_id=None): + def call_weixin_refund(self, out_trade_no, jine, transaction_id=None, pay_mch_id=None): """ 调用微信退款接口 :param out_trade_no: 商户订单号 :param jine: 退款金额 :param transaction_id: 微信支付订单号(优先使用) + :param pay_mch_id: 下单时的支付商户号(有则按原商户退) :return: {'success': True/False, 'msg': '消息', 'refund_id': '退款单号'} """ try: - # 检查配置 - 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}'} + pcfg = get_pay_wx_cfg(pay_mch_id) + mch_id = pcfg.get('MCHID') or '' + key = pcfg.get('KEY') or '' + cert_path = pcfg.get('CERT_PATH') or '' + key_path = pcfg.get('KEY_PATH') or '' + appid = wx_cfg.WEIXIN_APPID + if not all([appid, mch_id, key, cert_path, key_path]): + return {'success': False, 'msg': '缺少微信支付配置'} # 生成退款单号 refund_id = f"TK{int(time.time())}{random.randint(1000, 9999)}" @@ -7238,13 +7246,13 @@ class AdTongYiTuiKuanPingTai(APIView): # 构造请求参数 refund_data = { - 'appid': wx_cfg.WEIXIN_APPID, - 'mch_id': wx_cfg.WEIXIN_MCHID, + 'appid': appid, + 'mch_id': mch_id, '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': wx_cfg.WEIXIN_MCHID, + 'op_user_id': mch_id, 'refund_account': 'REFUND_SOURCE_UNSETTLED_FUNDS' } # 优先使用 transaction_id,否则用 out_trade_no @@ -7256,7 +7264,7 @@ class AdTongYiTuiKuanPingTai(APIView): # 生成签名 sorted_params = sorted(refund_data.items()) sign_string = '&'.join([f"{k}={v}" for k, v in sorted_params if v]) - sign_string += f"&key={wx_cfg.WEIXIN_SHANGHUMIYAO}" + sign_string += f"&key={key}" sign = hashlib.md5(sign_string.encode()).hexdigest().upper() refund_data['sign'] = sign @@ -7266,7 +7274,7 @@ class AdTongYiTuiKuanPingTai(APIView): url, data=xml_data.encode('utf-8'), headers={'Content-Type': 'application/xml'}, - cert=(wx_cfg.WEIXIN_CERT_PATH, wx_cfg.WEIXIN_KEY_PATH), + cert=(cert_path, key_path), timeout=30 ) @@ -7473,7 +7481,11 @@ class AdTongYiTuiKuanPingTai(APIView): # ========== 修改点开始 ========== # 13. 先调用微信退款接口,成功后才更新数据库 - weixin_refund_result = self.call_weixin_refund(dingdan_id, jine) + weixin_refund_result = self.call_weixin_refund( + dingdan_id, + jine, + pay_mch_id=getattr(dingdan_obj, 'pay_mch_id', '') or '', + ) if not weixin_refund_result.get('success'): # 微信退款失败,直接返回错误,数据库不更新 @@ -7588,27 +7600,20 @@ class AdTongYiTuiKuanPingTai(APIView): - def call_weixin_refund(self, dingdan_id, jine): + def call_weixin_refund(self, dingdan_id, jine, pay_mch_id=None): """ - 调用微信退款接口 - 参考你提供的接口,但根据现有模型和配置调整 - - 需要在settings.py中配置以下参数: - WEIXIN_APPID = '你的小程序appid' - WEIXIN_MCHID = '你的商户号' - WEIXIN_SHANGHUMIYAO = '你的商户密钥' - WEIXIN_CERT_PATH = '证书路径/cert/apiclient_cert.pem' - WEIXIN_KEY_PATH = '证书路径/cert/apiclient_key.pem' - + 调用微信退款接口。pay_mch_id 有值时按原下单商户退款。 返回:{'success': True/False, 'msg': '消息'} """ try: - # 检查必要的配置 - 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}'} + pcfg = get_pay_wx_cfg(pay_mch_id) + mch_id = pcfg.get('MCHID') or '' + key = pcfg.get('KEY') or '' + cert_path = pcfg.get('CERT_PATH') or '' + key_path = pcfg.get('KEY_PATH') or '' + appid = wx_cfg.WEIXIN_APPID + if not all([appid, mch_id, key, cert_path, key_path]): + return {'success': False, 'msg': '缺少微信支付配置'} # 生成退款单号 refund_id = f"TK{int(time.time())}{random.randint(1000, 9999)}" @@ -7618,21 +7623,21 @@ class AdTongYiTuiKuanPingTai(APIView): # 准备微信退款参数 refund_data = { - 'appid': wx_cfg.WEIXIN_APPID, - 'mch_id': wx_cfg.WEIXIN_MCHID, + 'appid': appid, + 'mch_id': mch_id, 'nonce_str': hashlib.md5(str(time.time()).encode()).hexdigest(), 'out_trade_no': dingdan_id, # 商户订单号(我们的订单ID) 'out_refund_no': refund_id, # 商户退款单号 'total_fee': total_fee_in_fen, # 订单总金额(分) 'refund_fee': total_fee_in_fen, # 退款金额(分),全额退款 - 'op_user_id': wx_cfg.WEIXIN_MCHID, # 操作员ID,默认为商户号 + 'op_user_id': mch_id, # 操作员ID,默认为商户号 'refund_account': 'REFUND_SOURCE_UNSETTLED_FUNDS' # 退款资金来源 } # 生成签名 sorted_params = sorted(refund_data.items()) - sign_string = '&'.join([f"{key}={value}" for key, value in sorted_params if value]) - sign_string += f"&key={wx_cfg.WEIXIN_SHANGHUMIYAO}" + sign_string = '&'.join([f"{key_}={value}" for key_, value in sorted_params if value]) + sign_string += f"&key={key}" sign = hashlib.md5(sign_string.encode()).hexdigest().upper() refund_data['sign'] = sign @@ -7651,7 +7656,7 @@ class AdTongYiTuiKuanPingTai(APIView): weixin_refund_url, data=xml_data.encode('utf-8'), headers=headers, - cert=(wx_cfg.WEIXIN_CERT_PATH, wx_cfg.WEIXIN_KEY_PATH), + cert=(cert_path, key_path), timeout=timeout ) diff --git a/houtai/view.py b/houtai/view.py index a567500..ba6233a 100644 --- a/houtai/view.py +++ b/houtai/view.py @@ -28,6 +28,7 @@ import xmltodict from django.conf import settings from utils.xcx_sys_config import wx_cfg +from utils.pay_mch_config import pay_cfg, get_pay_wx_cfg from dingdan.models import Dashoutupian, Chufajilu, PreSettlement, OrderDashouHistory # apps/houtai/views.py 继续添加 @@ -1484,12 +1485,13 @@ class CrossPlatformRefundView(APIView): def _call_wechat_refund(self, order): """调用微信支付退款接口,返回 {'code':0, 'msg':'success'} 或错误信息""" - # 获取微信支付配置 + # 获取微信支付配置(优先订单下单商户) appid = wx_cfg.WEIXIN_APPID - mch_id = wx_cfg.WEIXIN_MCHID - key = wx_cfg.WEIXIN_SHANGHUMIYAO - cert_path = wx_cfg.WEIXIN_CERT_PATH - key_path = wx_cfg.WEIXIN_KEY_PATH + pcfg = get_pay_wx_cfg(getattr(order, 'pay_mch_id', None)) + mch_id = pcfg.get('MCHID') or '' + key = pcfg.get('KEY') or '' + cert_path = pcfg.get('CERT_PATH') or '' + key_path = pcfg.get('KEY_PATH') or '' 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] diff --git a/peizhi/migrations/0025_xcx_sys_peizhi_pay_mch_fields.py b/peizhi/migrations/0025_xcx_sys_peizhi_pay_mch_fields.py new file mode 100644 index 0000000..0b2d157 --- /dev/null +++ b/peizhi/migrations/0025_xcx_sys_peizhi_pay_mch_fields.py @@ -0,0 +1,76 @@ +# 支付商户独立字段:从旧 weixin_* 回填,收款侧不动 + +from django.db import migrations, models + + +def copy_weixin_to_pay(apps, schema_editor): + XcxSysPeizhi = apps.get_model('peizhi', 'XcxSysPeizhi') + for row in XcxSysPeizhi.objects.all(): + updates = [] + if not (row.pay_mch_id or '').strip() and (row.weixin_mchid or '').strip(): + row.pay_mch_id = row.weixin_mchid + updates.append('pay_mch_id') + if not (row.pay_api_v2_key or '').strip() and (row.weixin_shanghu_miyao or '').strip(): + row.pay_api_v2_key = row.weixin_shanghu_miyao + updates.append('pay_api_v2_key') + if not (row.pay_notify_url or '').strip() and (row.weixin_notify_url or '').strip(): + row.pay_notify_url = row.weixin_notify_url + updates.append('pay_notify_url') + if not (row.pay_cert_path or '').strip() and (row.weixin_cert_path or '').strip(): + row.pay_cert_path = row.weixin_cert_path + updates.append('pay_cert_path') + if not (row.pay_key_path or '').strip() and (row.weixin_key_path or '').strip(): + row.pay_key_path = row.weixin_key_path + updates.append('pay_key_path') + if updates: + row.save(update_fields=updates) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('peizhi', '0024_xcx_sys_peizhi_shangjia_paifa_yajin'), + ] + + operations = [ + migrations.AddField( + model_name='xcxsyspeizhi', + name='pay_mch_id', + field=models.CharField(blank=True, default='', max_length=32, verbose_name='支付商户号'), + ), + migrations.AddField( + model_name='xcxsyspeizhi', + name='pay_api_v2_key', + field=models.CharField(blank=True, default='', max_length=128, verbose_name='支付APIv2密钥'), + ), + migrations.AddField( + model_name='xcxsyspeizhi', + name='pay_api_v3_key', + field=models.CharField(blank=True, default='', max_length=128, verbose_name='支付APIv3密钥'), + ), + migrations.AddField( + model_name='xcxsyspeizhi', + name='pay_notify_url', + field=models.CharField(blank=True, default='', max_length=300, verbose_name='支付回调URL(支付商户)'), + ), + migrations.AddField( + model_name='xcxsyspeizhi', + name='pay_cert_path', + field=models.CharField(blank=True, default='', max_length=500, verbose_name='支付退款证书路径'), + ), + migrations.AddField( + model_name='xcxsyspeizhi', + name='pay_key_path', + field=models.CharField(blank=True, default='', max_length=500, verbose_name='支付退款密钥路径'), + ), + migrations.AddField( + model_name='xcxsyspeizhi', + name='pay_cert_serial_no', + field=models.CharField(blank=True, default='', max_length=128, verbose_name='支付商户证书序列号'), + ), + migrations.RunPython(copy_weixin_to_pay, noop_reverse), + ] diff --git a/peizhi/models.py b/peizhi/models.py index 6c972be..1735657 100644 --- a/peizhi/models.py +++ b/peizhi/models.py @@ -694,6 +694,14 @@ class XcxSysPeizhi(models.Model): weixin_notify_url = models.CharField(max_length=300, blank=True, default='', verbose_name='支付回调URL') weixin_cert_path = models.CharField(max_length=500, blank=True, default='', verbose_name='退款证书路径') weixin_key_path = models.CharField(max_length=500, blank=True, default='', verbose_name='退款密钥路径') + # 支付专用商户(与提现收款 WechatPayMchConfig / 旧 weixin_* 分离) + pay_mch_id = models.CharField(max_length=32, blank=True, default='', verbose_name='支付商户号') + pay_api_v2_key = models.CharField(max_length=128, blank=True, default='', verbose_name='支付APIv2密钥') + pay_api_v3_key = models.CharField(max_length=128, blank=True, default='', verbose_name='支付APIv3密钥') + pay_notify_url = models.CharField(max_length=300, blank=True, default='', verbose_name='支付回调URL(支付商户)') + pay_cert_path = models.CharField(max_length=500, blank=True, default='', verbose_name='支付退款证书路径') + pay_key_path = models.CharField(max_length=500, blank=True, default='', verbose_name='支付退款密钥路径') + pay_cert_serial_no = models.CharField(max_length=128, blank=True, default='', verbose_name='支付商户证书序列号') notify_base_url = models.CharField( max_length=300, blank=True, default='https://wenhe.nmslb.com/hqhd', verbose_name='业务回调根URL' ) diff --git a/peizhi/pay_mch_views.py b/peizhi/pay_mch_views.py new file mode 100644 index 0000000..b671a0d --- /dev/null +++ b/peizhi/pay_mch_views.py @@ -0,0 +1,169 @@ +"""后台 — 支付专用商户配置(与转账收款 wxmch* 完全分离,权限 8080a)""" +import logging +import os +import re + +from django.db import transaction +from rest_framework import permissions +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from peizhi.models import XcxSysPeizhi +from peizhi.xcx_config_views import _authorize_miniapp_config, _mask_secret +from utils.pay_mch_config import pay_cert_base_dir, pay_cfg +from utils.xcx_sys_config import clear_xcx_sys_peizhi_cache + +logger = logging.getLogger(__name__) + +_SAFE_NAME = re.compile(r'[^A-Za-z0-9._-]+') + + +def _get_or_create_row(): + row = XcxSysPeizhi.objects.first() + if row is None: + row = XcxSysPeizhi.objects.create() + return row + + +def _serialize_pay(row, hide_secret=True): + v2 = (row.pay_api_v2_key or '') if row else '' + v3 = (row.pay_api_v3_key or '') if row else '' + cert = (row.pay_cert_path or '') if row else '' + key = (row.pay_key_path or '') if row else '' + return { + 'pay_mch_id': (row.pay_mch_id or '') if row else pay_cfg.PAY_MCH_ID, + 'pay_api_v2_key': _mask_secret(v2) if hide_secret else v2, + 'pay_api_v2_key_set': bool(v2), + 'pay_api_v3_key': _mask_secret(v3) if hide_secret else v3, + 'pay_api_v3_key_set': bool(v3), + 'pay_notify_url': (row.pay_notify_url or '') if row else '', + 'pay_cert_path': cert, + 'pay_key_path': key, + 'pay_cert_serial_no': (row.pay_cert_serial_no or '') if row else '', + 'pay_cert_exists': bool(cert and os.path.isfile(cert)), + 'pay_key_exists': bool(key and os.path.isfile(key)), + 'cert_base_dir': pay_cert_base_dir(), + 'effective_mch_id': pay_cfg.PAY_MCH_ID, + } + + +class PayMchQueryView(APIView): + """POST /peizhi/paymchhq — 查询支付商户配置""" + permission_classes = [permissions.IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + ok, err = _authorize_miniapp_config(request) + if not ok: + return err + row = XcxSysPeizhi.objects.first() + return Response({'code': 0, 'data': _serialize_pay(row)}) + + +class PayMchUpdateView(APIView): + """POST /peizhi/paymchgx — 更新支付商户配置""" + permission_classes = [permissions.IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + ok, err = _authorize_miniapp_config(request) + if not ok: + return err + + data = request.data + with transaction.atomic(): + row = _get_or_create_row() + if 'pay_mch_id' in data: + row.pay_mch_id = str(data.get('pay_mch_id') or '').strip()[:32] + if 'pay_notify_url' in data: + row.pay_notify_url = str(data.get('pay_notify_url') or '').strip()[:300] + if 'pay_cert_serial_no' in data: + row.pay_cert_serial_no = str(data.get('pay_cert_serial_no') or '').strip()[:128] + if 'pay_cert_path' in data: + row.pay_cert_path = str(data.get('pay_cert_path') or '').strip()[:500] + if 'pay_key_path' in data: + row.pay_key_path = str(data.get('pay_key_path') or '').strip()[:500] + + v2 = data.get('pay_api_v2_key') + if v2 is not None and str(v2).strip() and '****' not in str(v2): + row.pay_api_v2_key = str(v2).strip()[:128] + v3 = data.get('pay_api_v3_key') + if v3 is not None and str(v3).strip() and '****' not in str(v3): + row.pay_api_v3_key = str(v3).strip()[:128] + + row.save() + + clear_xcx_sys_peizhi_cache() + return Response({'code': 0, 'msg': '保存成功', 'data': _serialize_pay(row)}) + + +class PayMchUploadView(APIView): + """ + POST /peizhi/paymchscwj — 上传支付退款证书/密钥 + form-data: username, file_type(cert|key), file + 可选 mch_id(默认用当前 pay_mch_id) + """ + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + ALLOWED_TYPES = { + 'cert': 'apiclient_cert.pem', + 'key': 'apiclient_key.pem', + } + + def post(self, request): + ok, err = _authorize_miniapp_config(request) + if not ok: + return err + + file_type = str(request.data.get('file_type') or '').strip() + upload = request.FILES.get('file') + mch_id = str(request.data.get('mch_id') or request.data.get('pay_mch_id') or '').strip() + + if file_type not in self.ALLOWED_TYPES: + return Response({'code': 400, 'msg': 'file_type 仅支持 cert / key'}) + if not upload: + return Response({'code': 400, 'msg': '请上传文件'}) + + row = _get_or_create_row() + if not mch_id: + mch_id = (row.pay_mch_id or pay_cfg.PAY_MCH_ID or '').strip() + if not mch_id: + return Response({'code': 400, 'msg': '请先填写支付商户号再上传证书'}) + + dest_name = self.ALLOWED_TYPES[file_type] + folder = os.path.join(pay_cert_base_dir(), mch_id) + os.makedirs(folder, exist_ok=True) + dest_path = os.path.join(folder, dest_name) + + try: + with open(dest_path, 'wb') as f: + for chunk in upload.chunks(): + f.write(chunk) + except OSError as e: + logger.error('上传支付证书失败 mch=%s err=%s', mch_id, e, exc_info=True) + return Response({'code': 500, 'msg': f'写入失败: {e}'}) + + if file_type == 'cert': + row.pay_cert_path = dest_path + row.save(update_fields=['pay_cert_path', 'update_time']) + else: + row.pay_key_path = dest_path + row.save(update_fields=['pay_key_path', 'update_time']) + + if not (row.pay_mch_id or '').strip(): + row.pay_mch_id = mch_id + row.save(update_fields=['pay_mch_id', 'update_time']) + + clear_xcx_sys_peizhi_cache() + return Response({ + 'code': 0, + 'msg': '上传成功', + 'data': { + 'mch_id': mch_id, + 'file_type': file_type, + 'path': dest_path, + **_serialize_pay(row), + }, + }) diff --git a/peizhi/urls.py b/peizhi/urls.py index 5fed76c..8858ec2 100644 --- a/peizhi/urls.py +++ b/peizhi/urls.py @@ -15,6 +15,7 @@ from .wx_mch_views import ( WechatMchListView, WechatMchSaveView, WechatMchUploadView, WechatMchUserBindListView, WechatMchUserBindSaveView, WechatMchUserBindDeleteView, ) +from .pay_mch_views import PayMchQueryView, PayMchUpdateView, PayMchUploadView from .xieyi_views import XieyiListView, XieyiDetailView, XieyiSignStatusView, XieyiSignView, XieyiCheckView urlpatterns = [ @@ -63,6 +64,9 @@ urlpatterns = [ path('wxmchUserLb', WechatMchUserBindListView.as_view(), name='用户绑定转账商户列表'), path('wxmchUserBj', WechatMchUserBindSaveView.as_view(), name='保存用户绑定转账商户'), path('wxmchUserSc', WechatMchUserBindDeleteView.as_view(), name='删除用户绑定转账商户'), + path('paymchhq', PayMchQueryView.as_view(), name='获取支付商户配置'), + path('paymchgx', PayMchUpdateView.as_view(), name='更新支付商户配置'), + path('paymchscwj', PayMchUploadView.as_view(), name='上传支付商户证书'), path('xieyihq', XieyiListView.as_view(), name='小程序获取协议列表'), path('xieyixq', XieyiDetailView.as_view(), name='小程序获取协议详情'), path('xieyijc', XieyiCheckView.as_view(), name='小程序检查协议签署'), diff --git a/shangpin/views.py b/shangpin/views.py index ce8d8b2..d8d2fce 100644 --- a/shangpin/views.py +++ b/shangpin/views.py @@ -6,6 +6,7 @@ from rest_framework.throttling import AnonRateThrottle from django.db import models from shangpin.models import ShangpinLeixing, ShangpinZhuanqu, Shangpin from utils.xcx_sys_config import get_shangpin_shenhe_zhuangtai +from utils.pay_mch_config import pay_cfg from utils.jwt_auth import OptionalYonghuidJWTAuthentication, YonghuidJWTAuthentication from yonghu.models import UserMain import logging @@ -549,8 +550,8 @@ class YajinGoumai(APIView): """生成微信小程序支付参数""" # 获取配置 APPID = wx_cfg.WEIXIN_APPID - MCHID = wx_cfg.WEIXIN_MCHID - KEY = wx_cfg.WEIXIN_SHANGHUMIYAO + MCHID = pay_cfg.WEIXIN_MCHID + KEY = pay_cfg.WEIXIN_SHANGHUMIYAO # 根据支付类型获取对应的配置 if pay_type == 'yajin': @@ -876,7 +877,7 @@ class YajinHuitiao(View): # 拼接字符串 sign_string = '&'.join([f"{key}={value}" for key, value in sorted_params if value]) - sign_string += f'&key={wx_cfg.WEIXIN_SHANGHUMIYAO}' + sign_string += f'&key={pay_cfg.WEIXIN_SHANGHUMIYAO}' # 计算签名 calculated_sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().upper() @@ -1126,8 +1127,8 @@ class JifenBuchong(APIView): """生成微信支付参数(完整版,无省略)""" # 微信支付配置 - 从settings中获取 APPID = wx_cfg.WEIXIN_APPID - MCHID = wx_cfg.WEIXIN_MCHID - KEY = wx_cfg.WEIXIN_SHANGHUMIYAO + MCHID = pay_cfg.WEIXIN_MCHID + KEY = pay_cfg.WEIXIN_SHANGHUMIYAO # 根据支付类型获取对应的配置 if pay_type == 'yajin': @@ -1429,7 +1430,7 @@ class JifenHuitiao(View): # 拼接字符串 sign_string = '&'.join([f"{key}={value}" for key, value in sorted_params if value]) - sign_string += f'&key={wx_cfg.WEIXIN_SHANGHUMIYAO}' + sign_string += f'&key={pay_cfg.WEIXIN_SHANGHUMIYAO}' # 计算签名 calculated_sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().upper() @@ -1724,8 +1725,8 @@ class HuiyuanGoumai(APIView): """生成微信支付参数(完整版,无省略)""" # 微信支付配置 - 从settings中获取 APPID = wx_cfg.WEIXIN_APPID - MCHID = wx_cfg.WEIXIN_MCHID - KEY = wx_cfg.WEIXIN_SHANGHUMIYAO + MCHID = pay_cfg.WEIXIN_MCHID + KEY = pay_cfg.WEIXIN_SHANGHUMIYAO # 根据支付类型获取对应的配置 if pay_type == 'yajin': @@ -2269,7 +2270,7 @@ class HuiyuanHuitiao(View): sorted_params = sorted(params.items(), key=lambda x: x[0]) sign_string = '&'.join([f"{key}={value}" for key, value in sorted_params if value]) - sign_string += f'&key={wx_cfg.WEIXIN_SHANGHUMIYAO}' + sign_string += f'&key={pay_cfg.WEIXIN_SHANGHUMIYAO}' calculated_sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().upper() return sign == calculated_sign except Exception as e: @@ -2632,7 +2633,7 @@ class HuiyuanHuitiao(View): # 拼接字符串 sign_string = '&'.join([f"{key}={value}" for key, value in sorted_params if value]) - sign_string += f'&key={wx_cfg.WEIXIN_SHANGHUMIYAO}' + sign_string += f'&key={pay_cfg.WEIXIN_SHANGHUMIYAO}' # 计算签名 calculated_sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().upper() @@ -2963,8 +2964,8 @@ class ShangjiaChongzhi(APIView): """生成微信小程序支付参数""" # 获取配置 APPID = wx_cfg.WEIXIN_APPID - MCHID = wx_cfg.WEIXIN_MCHID - KEY = wx_cfg.WEIXIN_SHANGHUMIYAO + MCHID = pay_cfg.WEIXIN_MCHID + KEY = pay_cfg.WEIXIN_SHANGHUMIYAO # 根据支付类型获取对应的配置 if pay_type == 'shangjia': @@ -3262,7 +3263,7 @@ class ShangjiaHuitiao(View): # 拼接字符串 sign_string = '&'.join([f"{key}={value}" for key, value in sorted_params if value]) - sign_string += f'&key={wx_cfg.WEIXIN_SHANGHUMIYAO}' + sign_string += f'&key={pay_cfg.WEIXIN_SHANGHUMIYAO}' # 计算签名 calculated_sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().upper() diff --git a/utils/pay_mch_config.py b/utils/pay_mch_config.py new file mode 100644 index 0000000..d107163 --- /dev/null +++ b/utils/pay_mch_config.py @@ -0,0 +1,150 @@ +""" +支付专用商户配置(与提现收款 WechatPayMchConfig / wechat_mch_service 完全分离)。 + +读取优先级: + 1) XcxSysPeizhi.pay_* + 2) 过渡回退:旧 weixin_* / settings.WEIXIN_* +历史订单退款:若订单 pay_mch_id 与当前支付商户不一致,但等于旧 weixin_mchid,则用旧 weixin_* 退款。 +""" +import logging +import os + +from django.conf import settings + +from utils.xcx_sys_config import wx_cfg, _val + +logger = logging.getLogger(__name__) + + +def pay_cert_base_dir(): + base = getattr(settings, 'WECHAT_PAY_CERT_BASE_DIR', '') or '' + if base: + return base + return os.path.join(str(settings.BASE_DIR), 'zhifuzhengshu') + + +def _pay_val(pay_attr, legacy_db_attr, settings_attr, default=''): + """pay_* 优先;空则回退旧 weixin_* / settings。""" + v = _val(pay_attr, None, None) + if v is not None and v != '': + return v + return _val(legacy_db_attr, settings_attr, default) or default + + +class PayWxConfig: + """支付商户配置代理(属性名兼容旧 WEIXIN_*,便于替换调用点)。""" + + @property + def WEIXIN_MCHID(self): + return _pay_val('pay_mch_id', 'weixin_mchid', 'WEIXIN_MCHID', '') + + @property + def WEIXIN_SHANGHUMIYAO(self): + return _pay_val('pay_api_v2_key', 'weixin_shanghu_miyao', 'WEIXIN_SHANGHUMIYAO', '') + + @property + def WEIXIN_NOTIFY_URL(self): + return _pay_val('pay_notify_url', 'weixin_notify_url', 'WEIXIN_NOTIFY_URL', '') + + @property + def WEIXIN_CERT_PATH(self): + return _pay_val('pay_cert_path', 'weixin_cert_path', 'WEIXIN_CERT_PATH', '') + + @property + def WEIXIN_KEY_PATH(self): + return _pay_val('pay_key_path', 'weixin_key_path', 'WEIXIN_KEY_PATH', '') + + @property + def PAY_API_V3_KEY(self): + return _val('pay_api_v3_key', None, '') or '' + + @property + def PAY_CERT_SERIAL_NO(self): + return _val('pay_cert_serial_no', None, '') or '' + + # 别名,语义更清晰 + @property + def PAY_MCH_ID(self): + return self.WEIXIN_MCHID + + @property + def PAY_API_V2_KEY(self): + return self.WEIXIN_SHANGHUMIYAO + + +pay_cfg = PayWxConfig() + + +def get_current_pay_mch_id(): + return (pay_cfg.PAY_MCH_ID or '').strip() + + +def pay_cfg_as_dict(): + return { + 'MCHID': pay_cfg.WEIXIN_MCHID, + 'KEY': pay_cfg.WEIXIN_SHANGHUMIYAO, + 'NOTIFY_URL': pay_cfg.WEIXIN_NOTIFY_URL, + 'CERT_PATH': pay_cfg.WEIXIN_CERT_PATH, + 'KEY_PATH': pay_cfg.WEIXIN_KEY_PATH, + 'API_V3_KEY': pay_cfg.PAY_API_V3_KEY, + 'CERT_SERIAL_NO': pay_cfg.PAY_CERT_SERIAL_NO, + } + + +def get_pay_wx_cfg(mch_id=None): + """ + 按商户号取支付配置(用于退款反查)。 + - mch_id 空 / 等于当前支付商户 → 当前 pay_cfg + - mch_id 等于旧 weixin_mchid 且与当前不同 → 用旧 weixin_*(换号后历史单退款) + - 否则若 zhifuzhengshu/{mch_id}/ 下有证书,拼路径但仍需密钥;密钥仅当前/旧库有,故仍回退当前并打日志 + """ + current = pay_cfg_as_dict() + want = (mch_id or '').strip() + current_mch = (current.get('MCHID') or '').strip() + if not want or want == current_mch: + return current + + legacy_mch = (wx_cfg.WEIXIN_MCHID or '').strip() + if want == legacy_mch and want != current_mch: + return { + 'MCHID': wx_cfg.WEIXIN_MCHID, + 'KEY': wx_cfg.WEIXIN_SHANGHUMIYAO, + 'NOTIFY_URL': wx_cfg.WEIXIN_NOTIFY_URL, + 'CERT_PATH': wx_cfg.WEIXIN_CERT_PATH, + 'KEY_PATH': wx_cfg.WEIXIN_KEY_PATH, + 'API_V3_KEY': '', + 'CERT_SERIAL_NO': '', + } + + cert_path = os.path.join(pay_cert_base_dir(), want, 'apiclient_cert.pem') + key_path = os.path.join(pay_cert_base_dir(), want, 'apiclient_key.pem') + if os.path.isfile(cert_path) and os.path.isfile(key_path): + logger.warning( + '支付商户 %s 与当前配置 %s 不一致,仅找到证书文件,密钥仍用当前配置,退款可能失败', + want, current_mch, + ) + out = dict(current) + out['MCHID'] = want + out['CERT_PATH'] = cert_path + out['KEY_PATH'] = key_path + return out + + logger.warning( + '订单支付商户 %s 无可用历史配置,回退当前支付商户 %s', + want, current_mch, + ) + return current + + +def apply_pay_cfg_to_proxy(cfg_dict): + """把 get_pay_wx_cfg 结果转成带 WEIXIN_* 属性的简单对象,兼容旧代码写法。""" + class _Cfg: + pass + + o = _Cfg() + o.WEIXIN_MCHID = cfg_dict.get('MCHID') or '' + o.WEIXIN_SHANGHUMIYAO = cfg_dict.get('KEY') or '' + o.WEIXIN_NOTIFY_URL = cfg_dict.get('NOTIFY_URL') or '' + o.WEIXIN_CERT_PATH = cfg_dict.get('CERT_PATH') or '' + o.WEIXIN_KEY_PATH = cfg_dict.get('KEY_PATH') or '' + return o diff --git a/yonghu/views.py b/yonghu/views.py index 2e5d1b2..e1a2a58 100644 --- a/yonghu/views.py +++ b/yonghu/views.py @@ -6,6 +6,7 @@ import requests from django.db import transaction, IntegrityError from django.conf import settings from utils.xcx_sys_config import wx_cfg +from utils.pay_mch_config import pay_cfg, get_pay_wx_cfg from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status @@ -9211,7 +9212,8 @@ class KefuPlatformRefundView(APIView): refund_result = self.call_wechat_refund( dingdan_id=order.dingdan_id, jine=order.jine, - transaction_id=wechat_transaction_id + transaction_id=wechat_transaction_id, + pay_mch_id=getattr(order, 'pay_mch_id', '') or '', ) if refund_result['code'] != 0: @@ -9337,16 +9339,17 @@ class KefuPlatformRefundView(APIView): return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # ==================== 商户微信支付退款 ==================== - def call_wechat_refund(self, dingdan_id, jine, transaction_id=None): + def call_wechat_refund(self, dingdan_id, jine, transaction_id=None, pay_mch_id=None): """ 调用微信支付退款接口 secapi/pay/refund :return: {'code': 0, 'msg': '成功', 'refund_id': 'xxx'} 或 {'code': 非0, 'msg': '错误信息'} """ appid = wx_cfg.WEIXIN_APPID - mch_id = wx_cfg.WEIXIN_MCHID - key = wx_cfg.WEIXIN_SHANGHUMIYAO - cert_path = wx_cfg.WEIXIN_CERT_PATH - key_path = wx_cfg.WEIXIN_KEY_PATH + pcfg = get_pay_wx_cfg(pay_mch_id) + mch_id = pcfg.get('MCHID') or '' + key = pcfg.get('KEY') or '' + cert_path = pcfg.get('CERT_PATH') or '' + key_path = pcfg.get('KEY_PATH') or '' if not all([appid, mch_id, key, cert_path, key_path]): missing = [ @@ -9596,7 +9599,8 @@ class KefuPlatformRefundView(APIView): refund_result = self.call_wechat_refund( dingdan_id=order.dingdan_id, jine=order.jine, - transaction_id=wechat_transaction_id + transaction_id=wechat_transaction_id, + pay_mch_id=getattr(order, 'pay_mch_id', '') or '', ) if refund_result['code'] != 0: @@ -9672,20 +9676,22 @@ class KefuPlatformRefundView(APIView): logger.error(f"平台退款接口异常: {str(e)}", exc_info=True) return Response({'code': 500, 'msg': '服务器内部错误'}) - def call_wechat_refund(self, dingdan_id, jine, transaction_id=None): + def call_wechat_refund(self, dingdan_id, jine, transaction_id=None, pay_mch_id=None): """ 调用微信支付退款接口 :param dingdan_id: 商户订单号(我们的订单ID) :param jine: 退款金额 :param transaction_id: 微信支付订单号(优先使用) + :param pay_mch_id: 下单支付商户号 :return: {'code': 0, 'msg': '成功', 'refund_id': 'xxx'} 或 {'code': 非0, 'msg': '错误信息'} """ # 获取配置 appid = wx_cfg.WEIXIN_APPID - mch_id = wx_cfg.WEIXIN_MCHID - key = wx_cfg.WEIXIN_SHANGHUMIYAO - cert_path = wx_cfg.WEIXIN_CERT_PATH - key_path = wx_cfg.WEIXIN_KEY_PATH + pcfg = get_pay_wx_cfg(pay_mch_id) + mch_id = pcfg.get('MCHID') or '' + key = pcfg.get('KEY') or '' + cert_path = pcfg.get('CERT_PATH') or '' + key_path = pcfg.get('KEY_PATH') or '' 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] @@ -14837,8 +14843,8 @@ class FaKuanPayView(APIView): def generate_wechat_pay_params(self, dingdanid, jine, openid, pay_type): APPID = wx_cfg.WEIXIN_APPID - MCHID = wx_cfg.WEIXIN_MCHID - KEY = wx_cfg.WEIXIN_SHANGHUMIYAO + MCHID = pay_cfg.WEIXIN_MCHID + KEY = pay_cfg.WEIXIN_SHANGHUMIYAO if pay_type == 'fakuan': PAY_DESCRIPTION = settings.FAKUAN_PAY_DESCRIPTION @@ -15098,7 +15104,7 @@ class FaKuanHuitiaoView(View): # 拼接字符串 sign_string = '&'.join([f"{key}={value}" for key, value in sorted_params if value]) - sign_string += f'&key={wx_cfg.WEIXIN_SHANGHUMIYAO}' + sign_string += f'&key={pay_cfg.WEIXIN_SHANGHUMIYAO}' # 计算签名 calculated_sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().upper() @@ -15360,8 +15366,8 @@ class KaohePayView(APIView): def generate_wechat_pay_params(self, dingdanid, jine, openid, attach, pay_type): """生成微信JSAPI支付参数""" APPID = wx_cfg.WEIXIN_APPID - MCHID = wx_cfg.WEIXIN_MCHID - KEY = wx_cfg.WEIXIN_SHANGHUMIYAO + MCHID = pay_cfg.WEIXIN_MCHID + KEY = pay_cfg.WEIXIN_SHANGHUMIYAO if pay_type == 'kaohe': PAY_DESCRIPTION = settings.KAOHE_PAY_DESCRIPTION @@ -15679,7 +15685,7 @@ class KaohePayCallbackView(View): sorted_params = sorted(params.items(), key=lambda x: x[0]) sign_string = '&'.join([f"{key}={value}" for key, value in sorted_params if value]) - sign_string += f'&key={wx_cfg.WEIXIN_SHANGHUMIYAO}' + sign_string += f'&key={pay_cfg.WEIXIN_SHANGHUMIYAO}' calculated_sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().upper() result = sign == calculated_sign