"""后台 — 微信转账多商户配置(小程序配置权限 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 WechatPayMchConfig from peizhi.xcx_config_views import _authorize_miniapp_config, _mask_secret from utils.wechat_mch_service import cert_base_dir, seed_from_settings, wx_cfg_is_complete, row_to_wx_cfg logger = logging.getLogger(__name__) _SAFE_NAME = re.compile(r'[^A-Za-z0-9._-]+') def _serialize_row(row, hide_secret=True): return { 'id': row.id, 'mch_id': row.mch_id, 'name': row.name or '', 'appid': row.appid or '', 'api_v3_key': _mask_secret(row.api_v3_key) if hide_secret else (row.api_v3_key or ''), 'api_v3_key_set': bool(row.api_v3_key), 'private_key_path': row.private_key_path or '', 'cert_serial_no': row.cert_serial_no or '', 'platform_pub_key_path': row.platform_pub_key_path or '', 'notify_url': row.notify_url or '', 'transfer_scene_id': row.transfer_scene_id or '1005', 'priority': row.priority, 'enabled': bool(row.enabled), 'private_key_exists': bool(row.private_key_path and os.path.isfile(row.private_key_path)), 'platform_pub_key_exists': bool( row.platform_pub_key_path and os.path.isfile(row.platform_pub_key_path) ), 'create_time': row.create_time.strftime('%Y-%m-%d %H:%M:%S') if row.create_time else '', 'update_time': row.update_time.strftime('%Y-%m-%d %H:%M:%S') if row.update_time else '', } class WechatMchListView(APIView): """POST /peizhi/wxmchlb — 获取全部转账商户配置""" permission_classes = [permissions.IsAuthenticated] parser_classes = [JSONParser] def post(self, request): ok, err = _authorize_miniapp_config(request) if not ok: return err # 若表为空,尝试把 settings 默认商户写进表(仅一次) if not WechatPayMchConfig.objects.exists(): try: seed_from_settings(force=False) except Exception as e: logger.warning('seed wechat mch from settings failed: %s', e) rows = WechatPayMchConfig.objects.all().order_by('priority', 'id') return Response({ 'code': 0, 'data': { 'list': [_serialize_row(r) for r in rows], 'cert_base_dir': cert_base_dir(), }, }) class WechatMchSaveView(APIView): """POST /peizhi/wxmchbj — 新增或更新商户配置""" 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 row_id = data.get('id') mch_id = str(data.get('mch_id') or '').strip() if not mch_id: return Response({'code': 400, 'msg': '商户号不能为空'}) fields = { 'name': str(data.get('name') or '').strip()[:100], 'appid': str(data.get('appid') or '').strip(), 'private_key_path': str(data.get('private_key_path') or '').strip(), 'cert_serial_no': str(data.get('cert_serial_no') or '').strip(), 'platform_pub_key_path': str(data.get('platform_pub_key_path') or '').strip(), 'notify_url': str(data.get('notify_url') or '').strip(), 'transfer_scene_id': str(data.get('transfer_scene_id') or '1005').strip() or '1005', 'priority': int(data.get('priority') or 100), 'enabled': bool(data.get('enabled', True)), } api_v3_key = data.get('api_v3_key') if api_v3_key is not None and str(api_v3_key).strip() and '****' not in str(api_v3_key): fields['api_v3_key'] = str(api_v3_key).strip() try: with transaction.atomic(): if row_id: row = WechatPayMchConfig.objects.select_for_update().get(id=int(row_id)) if row.mch_id != mch_id: if WechatPayMchConfig.objects.filter(mch_id=mch_id).exclude(id=row.id).exists(): return Response({'code': 400, 'msg': '商户号已存在'}) row.mch_id = mch_id for k, v in fields.items(): setattr(row, k, v) if 'api_v3_key' in fields: row.api_v3_key = fields['api_v3_key'] row.save() else: if WechatPayMchConfig.objects.filter(mch_id=mch_id).exists(): return Response({'code': 400, 'msg': '商户号已存在'}) if 'api_v3_key' not in fields or not fields.get('api_v3_key'): return Response({'code': 400, 'msg': '新建必须填写 APIv3 密钥'}) create_data = {'mch_id': mch_id, **fields} row = WechatPayMchConfig.objects.create(**create_data) except WechatPayMchConfig.DoesNotExist: return Response({'code': 404, 'msg': '记录不存在'}) except (TypeError, ValueError) as e: return Response({'code': 400, 'msg': f'参数错误: {e}'}) cfg = row_to_wx_cfg(row) warn = '' if wx_cfg_is_complete(cfg) else '配置尚未完整,收款时该户会被跳过' return Response({ 'code': 0, 'msg': '保存成功' + (f'(提示:{warn})' if warn else ''), 'data': _serialize_row(row), }) class WechatMchUploadView(APIView): """ POST /peizhi/wxmchscwj — 上传商户证书/密钥文件 form-data: username, mch_id, file_type(private_key|platform_pub_key), file """ permission_classes = [permissions.IsAuthenticated] parser_classes = [MultiPartParser, FormParser] ALLOWED_TYPES = { 'private_key': 'apiclient_key.pem', 'platform_pub_key': 'pub_key.pem', } def post(self, request): ok, err = _authorize_miniapp_config(request) if not ok: return err mch_id = str(request.data.get('mch_id') or '').strip() file_type = str(request.data.get('file_type') or '').strip() upload = request.FILES.get('file') if not mch_id: return Response({'code': 400, 'msg': 'mch_id 不能为空'}) if file_type not in self.ALLOWED_TYPES: return Response({'code': 400, 'msg': 'file_type 仅支持 private_key / platform_pub_key'}) if not upload: return Response({'code': 400, 'msg': '请上传文件'}) # 安全文件名:优先固定名,避免多商户同名冲突(已按 mch_id 分目录) raw_name = os.path.basename(upload.name or self.ALLOWED_TYPES[file_type]) safe = _SAFE_NAME.sub('_', raw_name).strip('._') or self.ALLOWED_TYPES[file_type] if not safe.lower().endswith('.pem'): safe = f'{safe}.pem' folder = os.path.join(cert_base_dir(), mch_id) os.makedirs(folder, exist_ok=True) # 固定标准文件名,更稳 dest_name = self.ALLOWED_TYPES[file_type] 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}'}) # 若该商户已有配置行,自动回写路径 row = WechatPayMchConfig.objects.filter(mch_id=mch_id).first() if row: if file_type == 'private_key': row.private_key_path = dest_path row.save(update_fields=['private_key_path', 'update_time']) else: row.platform_pub_key_path = dest_path row.save(update_fields=['platform_pub_key_path', 'update_time']) return Response({ 'code': 0, 'msg': '上传成功', 'data': { 'mch_id': mch_id, 'file_type': file_type, 'saved_path': dest_path, 'original_name': safe, }, })