Files
along_django/peizhi/wx_mch_views.py
XingQue 685c65691f 支持自动提现多商户轮换:配置入库、打款单强制记 mch_id、收款失败自动换号。
- 新增 wechat_pay_mch_config 表,迁移时从 settings 种子默认商户
- TixianAutoRecord 增加 mch_id;查单/回调解密后按记录对齐
- 后台接口 wxmchlb/wxmchbj/wxmchscwj(权限 8080a)与证书上传

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 02:44:38 +08:00

205 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""后台 — 微信转账多商户配置(小程序配置权限 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,
},
})