- 新增 wechat_pay_mch_config 表,迁移时从 settings 种子默认商户 - TixianAutoRecord 增加 mch_id;查单/回调解密后按记录对齐 - 后台接口 wxmchlb/wxmchbj/wxmchscwj(权限 8080a)与证书上传 Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
8.4 KiB
Python
205 lines
8.4 KiB
Python
"""后台 — 微信转账多商户配置(小程序配置权限 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,
|
||
},
|
||
})
|