345 lines
14 KiB
Python
345 lines
14 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 UserWechatMchBind, 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,
|
||
},
|
||
})
|
||
|
||
|
||
def _serialize_bind(row):
|
||
mch = WechatPayMchConfig.objects.filter(mch_id=row.mch_id).first()
|
||
return {
|
||
'id': row.id,
|
||
'yonghuid': row.yonghuid,
|
||
'mch_id': row.mch_id,
|
||
'mch_name': (mch.name if mch else '') or '',
|
||
'mch_enabled': bool(mch.enabled) if mch else False,
|
||
'enabled': bool(row.enabled),
|
||
'remark': row.remark or '',
|
||
'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 WechatMchUserBindListView(APIView):
|
||
"""POST /peizhi/wxmchUserLb — 用户绑定商户列表"""
|
||
permission_classes = [permissions.IsAuthenticated]
|
||
parser_classes = [JSONParser]
|
||
|
||
def post(self, request):
|
||
ok, err = _authorize_miniapp_config(request)
|
||
if not ok:
|
||
return err
|
||
|
||
yonghuid = str(request.data.get('yonghuid') or '').strip()
|
||
qs = UserWechatMchBind.objects.all().order_by('-update_time', '-id')
|
||
if yonghuid:
|
||
qs = qs.filter(yonghuid=yonghuid)
|
||
|
||
try:
|
||
page = max(1, int(request.data.get('page') or 1))
|
||
limit = min(100, max(1, int(request.data.get('limit') or 50)))
|
||
except (TypeError, ValueError):
|
||
page, limit = 1, 50
|
||
total = qs.count()
|
||
start = (page - 1) * limit
|
||
rows = list(qs[start:start + limit])
|
||
return Response({
|
||
'code': 0,
|
||
'data': {
|
||
'list': [_serialize_bind(r) for r in rows],
|
||
'total': total,
|
||
'page': page,
|
||
'limit': limit,
|
||
},
|
||
})
|
||
|
||
|
||
class WechatMchUserBindSaveView(APIView):
|
||
"""POST /peizhi/wxmchUserBj — 新增/更新用户绑定商户"""
|
||
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
|
||
yonghuid = str(data.get('yonghuid') or '').strip()
|
||
mch_id = str(data.get('mch_id') or '').strip()
|
||
if not yonghuid:
|
||
return Response({'code': 400, 'msg': '用户ID不能为空'})
|
||
if not mch_id:
|
||
return Response({'code': 400, 'msg': '商户号不能为空'})
|
||
if not WechatPayMchConfig.objects.filter(mch_id=mch_id).exists():
|
||
return Response({'code': 400, 'msg': f'商户号 {mch_id} 不在转账商户配置中'})
|
||
|
||
remark = str(data.get('remark') or '').strip()[:200]
|
||
enabled = bool(data.get('enabled', True))
|
||
row_id = data.get('id')
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
if row_id:
|
||
row = UserWechatMchBind.objects.select_for_update().get(id=int(row_id))
|
||
# 改用户ID时防冲突
|
||
if row.yonghuid != yonghuid:
|
||
if UserWechatMchBind.objects.filter(yonghuid=yonghuid).exclude(id=row.id).exists():
|
||
return Response({'code': 400, 'msg': '该用户已有绑定记录'})
|
||
row.yonghuid = yonghuid
|
||
row.mch_id = mch_id
|
||
row.remark = remark
|
||
row.enabled = enabled
|
||
row.save()
|
||
else:
|
||
row, created = UserWechatMchBind.objects.update_or_create(
|
||
yonghuid=yonghuid,
|
||
defaults={
|
||
'mch_id': mch_id,
|
||
'remark': remark,
|
||
'enabled': enabled,
|
||
},
|
||
)
|
||
if not created:
|
||
# update_or_create 已更新
|
||
pass
|
||
except UserWechatMchBind.DoesNotExist:
|
||
return Response({'code': 404, 'msg': '绑定记录不存在'})
|
||
except (TypeError, ValueError) as e:
|
||
return Response({'code': 400, 'msg': f'参数错误: {e}'})
|
||
|
||
logger.warning(
|
||
'保存用户商户绑定 yonghuid=%s mch_id=%s enabled=%s by=%s',
|
||
yonghuid, mch_id, enabled, getattr(request.user, 'username', ''),
|
||
)
|
||
return Response({
|
||
'code': 0,
|
||
'msg': '保存成功(已发出的待确认单不会因改绑新建第二笔)',
|
||
'data': _serialize_bind(row),
|
||
})
|
||
|
||
|
||
class WechatMchUserBindDeleteView(APIView):
|
||
"""POST /peizhi/wxmchUserSc — 删除用户绑定"""
|
||
permission_classes = [permissions.IsAuthenticated]
|
||
parser_classes = [JSONParser]
|
||
|
||
def post(self, request):
|
||
ok, err = _authorize_miniapp_config(request)
|
||
if not ok:
|
||
return err
|
||
|
||
row_id = request.data.get('id')
|
||
yonghuid = str(request.data.get('yonghuid') or '').strip()
|
||
if not row_id and not yonghuid:
|
||
return Response({'code': 400, 'msg': '请传 id 或 yonghuid'})
|
||
|
||
qs = UserWechatMchBind.objects.all()
|
||
if row_id:
|
||
qs = qs.filter(id=int(row_id))
|
||
else:
|
||
qs = qs.filter(yonghuid=yonghuid)
|
||
deleted, _ = qs.delete()
|
||
if not deleted:
|
||
return Response({'code': 404, 'msg': '记录不存在'})
|
||
return Response({'code': 0, 'msg': '已删除绑定', 'data': {'deleted': deleted}})
|