Files
along_django/peizhi/wx_mch_views.py

345 lines
14 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 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}})