仅商户整户额度可换号,并支持后台用户绑定固定收款商户。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
30
peizhi/migrations/0023_user_wechat_mch_bind.py
Normal file
30
peizhi/migrations/0023_user_wechat_mch_bind.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated manually: UserWechatMchBind
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('peizhi', '0022_wechat_pay_mch_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserWechatMchBind',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('yonghuid', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='用户ID')),
|
||||
('mch_id', models.CharField(db_index=True, max_length=32, verbose_name='绑定商户号')),
|
||||
('enabled', models.BooleanField(db_index=True, default=True, verbose_name='是否启用')),
|
||||
('remark', models.CharField(blank=True, default='', max_length=200, verbose_name='备注')),
|
||||
('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('update_time', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '用户转账商户绑定',
|
||||
'verbose_name_plural': '用户转账商户绑定',
|
||||
'db_table': 'user_wechat_mch_bind',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -839,3 +839,24 @@ class WechatPayMchConfig(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.mch_id}({self.name or "-"})'
|
||||
|
||||
|
||||
class UserWechatMchBind(models.Model):
|
||||
"""
|
||||
用户指定收款商户号:启用后该用户收款强制只用绑定商户,不参与自动轮换。
|
||||
改绑不会改变已发出打款单的查单商户(仍按 TixianAutoRecord.mch_id)。
|
||||
"""
|
||||
yonghuid = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='用户ID')
|
||||
mch_id = models.CharField(max_length=32, db_index=True, verbose_name='绑定商户号')
|
||||
enabled = models.BooleanField(default=True, db_index=True, verbose_name='是否启用')
|
||||
remark = models.CharField(max_length=200, blank=True, default='', verbose_name='备注')
|
||||
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_wechat_mch_bind'
|
||||
verbose_name = '用户转账商户绑定'
|
||||
verbose_name_plural = verbose_name
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.yonghuid}->{self.mch_id}'
|
||||
@@ -11,7 +11,10 @@ from .xcx_config_views import (
|
||||
XcxSysPeizhiQueryView, XcxSysPeizhiUpdateView,
|
||||
XcxPageZiyuanQueryView, XcxPageZiyuanUpdateView, XcxPageZiyuanUploadView,
|
||||
)
|
||||
from .wx_mch_views import WechatMchListView, WechatMchSaveView, WechatMchUploadView
|
||||
from .wx_mch_views import (
|
||||
WechatMchListView, WechatMchSaveView, WechatMchUploadView,
|
||||
WechatMchUserBindListView, WechatMchUserBindSaveView, WechatMchUserBindDeleteView,
|
||||
)
|
||||
from .xieyi_views import XieyiListView, XieyiDetailView, XieyiSignStatusView, XieyiSignView, XieyiCheckView
|
||||
|
||||
urlpatterns = [
|
||||
@@ -57,6 +60,9 @@ urlpatterns = [
|
||||
path('wxmchlb', WechatMchListView.as_view(), name='获取转账商户列表'),
|
||||
path('wxmchbj', WechatMchSaveView.as_view(), name='保存转账商户配置'),
|
||||
path('wxmchscwj', WechatMchUploadView.as_view(), name='上传转账商户证书'),
|
||||
path('wxmchUserLb', WechatMchUserBindListView.as_view(), name='用户绑定转账商户列表'),
|
||||
path('wxmchUserBj', WechatMchUserBindSaveView.as_view(), name='保存用户绑定转账商户'),
|
||||
path('wxmchUserSc', WechatMchUserBindDeleteView.as_view(), name='删除用户绑定转账商户'),
|
||||
path('xieyihq', XieyiListView.as_view(), name='小程序获取协议列表'),
|
||||
path('xieyixq', XieyiDetailView.as_view(), name='小程序获取协议详情'),
|
||||
path('xieyijc', XieyiCheckView.as_view(), name='小程序检查协议签署'),
|
||||
|
||||
@@ -9,7 +9,7 @@ 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.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
|
||||
|
||||
@@ -202,3 +202,143 @@ class WechatMchUploadView(APIView):
|
||||
'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}})
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
@@ -88,6 +88,62 @@ def list_enabled_wx_cfgs() -> List[Dict]:
|
||||
return []
|
||||
|
||||
|
||||
def get_bound_mch_id(yonghuid: str) -> Optional[str]:
|
||||
"""启用中的用户绑定商户号;无绑定返回 None。"""
|
||||
yonghuid = str(yonghuid or '').strip()
|
||||
if not yonghuid:
|
||||
return None
|
||||
from peizhi.models import UserWechatMchBind
|
||||
|
||||
row = (
|
||||
UserWechatMchBind.objects.filter(yonghuid=yonghuid, enabled=True)
|
||||
.only('mch_id')
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
return (row.mch_id or '').strip() or None
|
||||
|
||||
|
||||
def list_wx_cfgs_for_collect(yonghuid: str) -> Tuple[List[Dict], Optional[str], bool]:
|
||||
"""
|
||||
收款选户:
|
||||
- 有启用绑定 → 仅返回该一户配置;证书不全/不存在则报错,绝不偷偷落到默认户
|
||||
- 无绑定 → list_enabled_wx_cfgs()
|
||||
返回 (cfgs, err_msg, is_user_bound)
|
||||
"""
|
||||
bound_mch = get_bound_mch_id(yonghuid)
|
||||
if not bound_mch:
|
||||
cfgs = list_enabled_wx_cfgs()
|
||||
return cfgs, None, False
|
||||
|
||||
cfg = get_wx_cfg_by_mch_id(bound_mch)
|
||||
if not cfg or not wx_cfg_is_complete(cfg):
|
||||
logger.error(
|
||||
'用户绑定商户不可用 yonghuid=%s mch_id=%s',
|
||||
yonghuid, bound_mch,
|
||||
)
|
||||
return [], (
|
||||
f'该用户已绑定商户号 {bound_mch},但配置不完整或未启用,'
|
||||
f'请在后台检查证书/启用状态或改绑'
|
||||
), True
|
||||
|
||||
# 绑定户若在配置表被停用:get_wx_cfg_by_mch_id 仍可能因私钥完整返回 cfg,
|
||||
# 强制要求绑定目标在启用列表或行本身 enabled
|
||||
from peizhi.models import WechatPayMchConfig
|
||||
row = WechatPayMchConfig.objects.filter(mch_id=bound_mch).first()
|
||||
if row is not None and not row.enabled:
|
||||
return [], (
|
||||
f'该用户已绑定商户号 {bound_mch},但该商户已停用,请后台改绑或重新启用'
|
||||
), True
|
||||
|
||||
logger.warning(
|
||||
'收款使用用户绑定商户 yonghuid=%s mch_id=%s(强制单户,不轮换)',
|
||||
yonghuid, bound_mch,
|
||||
)
|
||||
return [cfg], None, True
|
||||
|
||||
|
||||
def list_wx_cfgs_for_callback() -> List[Dict]:
|
||||
"""
|
||||
回调验签/解密:包含已停用商户。
|
||||
|
||||
@@ -1444,19 +1444,18 @@ def reconcile_shenhe_wechat_bills(shenhe_danhao):
|
||||
)
|
||||
continue
|
||||
|
||||
# 已失败记录:仅当微信实际已到账才复活;
|
||||
# 勿把 WAIT_USER_CONFIRM 再甩给前端(确认页受限后本地已放弃该户,必须换下一商户)
|
||||
# 已失败记录:仍问微信。若仍待确认必须回旧 package,禁止新建第二笔(防双收)
|
||||
if rec.zhuangtai == 2:
|
||||
wx_data = query_wechat_transfer_bill(rec.tixian_id)
|
||||
if wx_data is None or wx_data.get('_not_found'):
|
||||
continue
|
||||
action, payload = _sync_record_from_wx_query(rec, wx_data)
|
||||
if action == RECONCILE_COMPLETED:
|
||||
if action in (RECONCILE_COMPLETED, RECONCILE_WAIT_CONFIRM):
|
||||
logger.warning(
|
||||
'本地失败单微信仍有效 shenhe=%s bill=%s mch=%s action=%s(禁止新建第二笔)',
|
||||
shenhe_danhao, rec.tixian_id, rec.mch_id, action,
|
||||
)
|
||||
return action, payload
|
||||
logger.warning(
|
||||
'本地已失败单忽略微信待确认 shenhe=%s bill=%s mch=%s action=%s',
|
||||
shenhe_danhao, rec.tixian_id, rec.mch_id, action,
|
||||
)
|
||||
continue
|
||||
|
||||
if pending_with_package:
|
||||
|
||||
@@ -103,24 +103,43 @@ def _is_wx_ip_denied(err_code='', err_msg=''):
|
||||
))
|
||||
|
||||
|
||||
def _is_wx_clear_switchable_fail(err_code='', err_msg='', status_code=None):
|
||||
"""
|
||||
明确未建单的业务失败:可静默换下一商户(日额度/余额不足/IP白名单/无权限等)。
|
||||
"""
|
||||
if _is_wx_ip_denied(err_code, err_msg):
|
||||
return True
|
||||
if _is_wx_unsafe_to_switch(err_code, err_msg, status_code):
|
||||
return False
|
||||
if status_code in (400, 401, 403):
|
||||
return True
|
||||
blob = f'{err_code} {err_msg}'.upper()
|
||||
def _is_wx_user_personal_quota_msg(err_msg=''):
|
||||
"""单用户/个人侧额度:禁止当作商户整户日限换号。"""
|
||||
msg = str(err_msg or '')
|
||||
keys = (
|
||||
'NOT_ENOUGH', 'INVALID_REQUEST', 'NO_AUTH', 'ACCOUNTERROR', 'ACCOUNT_ERROR',
|
||||
'PARAM_ERROR', 'SIGN_ERROR', 'APPID_MCHID_NOT_MATCH', 'CERT_ERROR',
|
||||
'额度', '限额', '上限', '余额不足', '资金不足', '无权限', '商户号异常', '账户异常',
|
||||
'收款受限', '付款受限', '受限', '管控', '不收不付', '日限额', '超出',
|
||||
'向同一用户', '单用户', '同一用户', '收款用户', '该用户',
|
||||
'用户收款', '收款受限', '付款受限',
|
||||
)
|
||||
return any(k in blob for k in keys)
|
||||
return any(k in msg for k in keys)
|
||||
|
||||
|
||||
def _is_wx_mch_total_quota_exhausted(err_code='', err_msg=''):
|
||||
"""
|
||||
仅识别微信「商户整户」日/月转账额度耗尽(如超出商户单日转账额度)。
|
||||
个人侧限额、笼统「受限/管控」、余额不足一律 False。
|
||||
"""
|
||||
if _is_wx_unsafe_to_switch(err_code, err_msg):
|
||||
return False
|
||||
if _is_wx_user_personal_quota_msg(err_msg):
|
||||
return False
|
||||
msg = str(err_msg or '')
|
||||
# 官方文案:超出商户单日转账额度,请核实产品设置是否准确
|
||||
allow_phrases = (
|
||||
'超出商户单日转账额度',
|
||||
'超出商户单月转账额度',
|
||||
'商户单日转账额度',
|
||||
'商户单月转账额度',
|
||||
'商户日转账额度',
|
||||
'商户月转账额度',
|
||||
)
|
||||
if any(p in msg for p in allow_phrases):
|
||||
return True
|
||||
# 宽松但仍要求「商户」+「日/月」+「额度」,避免「受限」误匹配
|
||||
if '商户' in msg and ('额度' in msg or '限额' in msg):
|
||||
if any(k in msg for k in ('单日', '日转账', '单月', '月转账')):
|
||||
if not _is_wx_user_personal_quota_msg(msg):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _log_wx_collect(tag, **kwargs):
|
||||
@@ -289,19 +308,20 @@ def process_audit_collect(request):
|
||||
)
|
||||
|
||||
try:
|
||||
from utils.wechat_mch_service import list_enabled_wx_cfgs, wx_cfg_is_complete
|
||||
from utils.wechat_mch_service import list_wx_cfgs_for_collect, wx_cfg_is_complete
|
||||
|
||||
if not user_main.openid:
|
||||
return Response({'code': 10, 'msg': '用户未绑定微信,无法收款'})
|
||||
|
||||
mch_cfgs = list_enabled_wx_cfgs()
|
||||
mch_cfgs, bind_err, is_user_bound = list_wx_cfgs_for_collect(yonghuid)
|
||||
if bind_err:
|
||||
return Response({'code': 11, 'msg': bind_err})
|
||||
if not mch_cfgs or not wx_cfg_is_complete(mch_cfgs[0]):
|
||||
logger.error('微信打款配置不完整 enabled_count=%s', len(mch_cfgs))
|
||||
logger.error('微信打款配置不完整 enabled_count=%s bound=%s', len(mch_cfgs), is_user_bound)
|
||||
return Response({'code': 11, 'msg': '系统配置异常,请联系客服'})
|
||||
if len(mch_cfgs) < 2:
|
||||
logger.error(
|
||||
'启用且配置完整的转账商户仅 %s 个,无法轮换 mch_ids=%s '
|
||||
'(后台多个启用但私钥路径无效会被跳过;微信商户日额度失败时将无法换号)',
|
||||
if not is_user_bound and len(mch_cfgs) < 2:
|
||||
logger.warning(
|
||||
'启用且配置完整的转账商户仅 %s 个,整户日限额时无法换号 mch_ids=%s',
|
||||
len(mch_cfgs), [c.get('MCHID') for c in mch_cfgs],
|
||||
)
|
||||
|
||||
@@ -399,39 +419,52 @@ def process_audit_collect(request):
|
||||
|
||||
quota_reserved = True
|
||||
|
||||
# 多商户轮换:仅在发起阶段未进入待确认时切换;成功后 mch_id 必须落库
|
||||
# 本审核单已失败过的商户本轮优先跳过,直接试下一户
|
||||
failed_mchs = set(
|
||||
TixianAutoRecord.objects.filter(
|
||||
shenhe_danhao=shenhe_danhao, zhuangtai=2,
|
||||
).exclude(mch_id='').values_list('mch_id', flat=True)
|
||||
)
|
||||
prefer_cfgs = [c for c in mch_cfgs if c.get('MCHID') not in failed_mchs]
|
||||
if prefer_cfgs and len(prefer_cfgs) < len(mch_cfgs):
|
||||
logger.warning(
|
||||
'收款跳过已失败商户 shenhe=%s skip=%s try=%s',
|
||||
shenhe_danhao, sorted(failed_mchs),
|
||||
[c.get('MCHID') for c in prefer_cfgs],
|
||||
)
|
||||
mch_cfgs = prefer_cfgs
|
||||
elif failed_mchs and not prefer_cfgs:
|
||||
# 可用商户都失败过:仍按原序再试一轮(例如限额日切后可能恢复)
|
||||
logger.warning(
|
||||
'可用商户均曾失败,按原序再试 shenhe=%s failed=%s',
|
||||
shenhe_danhao, sorted(failed_mchs),
|
||||
# 选户:绑定用户仅 1 户;未绑定按 priority。
|
||||
# 仅「商户整户日/月额度」+ 查单确认未建单 才静默换号;其它失败立刻回前端。
|
||||
if not is_user_bound and len(mch_cfgs) > 1:
|
||||
failed_mchs = set(
|
||||
TixianAutoRecord.objects.filter(
|
||||
shenhe_danhao=shenhe_danhao, zhuangtai=2,
|
||||
).exclude(mch_id='').values_list('mch_id', flat=True)
|
||||
)
|
||||
prefer_cfgs = [c for c in mch_cfgs if c.get('MCHID') not in failed_mchs]
|
||||
if prefer_cfgs and len(prefer_cfgs) < len(mch_cfgs):
|
||||
logger.warning(
|
||||
'收款优先未失败商户 shenhe=%s skip=%s try=%s',
|
||||
shenhe_danhao, sorted(failed_mchs),
|
||||
[c.get('MCHID') for c in prefer_cfgs],
|
||||
)
|
||||
mch_cfgs = prefer_cfgs
|
||||
|
||||
_log_wx_collect(
|
||||
'开始轮换',
|
||||
'开始选户发起',
|
||||
shenhe=shenhe_danhao,
|
||||
bound=is_user_bound,
|
||||
count=len(mch_cfgs),
|
||||
mch_ids=','.join(c.get('MCHID') for c in mch_cfgs),
|
||||
)
|
||||
last_user_msg = '微信收款发起失败,请稍后重试'
|
||||
last_was_balance = False
|
||||
tixian_id = None
|
||||
tried_mchs = []
|
||||
tried_details = []
|
||||
|
||||
def _fail_front(msg, code=99):
|
||||
nonlocal quota_reserved
|
||||
if quota_reserved and tixian_id:
|
||||
release_collect_quota_for_record(
|
||||
TixianAutoRecord.objects.get(tixian_id=tixian_id),
|
||||
)
|
||||
quota_reserved = False
|
||||
return Response({
|
||||
'code': code,
|
||||
'msg': msg[:800],
|
||||
'data': {
|
||||
'source': 'wechat_official',
|
||||
'tried_mch_ids': tried_mchs,
|
||||
'user_bound': is_user_bound,
|
||||
},
|
||||
})
|
||||
|
||||
for mch_idx, wx_cfg in enumerate(mch_cfgs):
|
||||
tixian_id = generate_tixian_id()
|
||||
mch_id = wx_cfg['MCHID']
|
||||
@@ -441,6 +474,7 @@ def process_audit_collect(request):
|
||||
idx=f'{mch_idx + 1}/{len(mch_cfgs)}',
|
||||
mch_id=mch_id,
|
||||
bill=tixian_id,
|
||||
bound=is_user_bound,
|
||||
)
|
||||
with transaction.atomic():
|
||||
TixianAutoRecord.objects.create(
|
||||
@@ -477,29 +511,16 @@ def process_audit_collect(request):
|
||||
'msg': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击',
|
||||
})
|
||||
except (OSError, IOError, ValueError) as e:
|
||||
# 证书/私钥本地问题:未真正发到微信,可换下一商户
|
||||
# 证书/私钥本地问题:未发到微信;按计划不换号,直接报错
|
||||
logger.error(
|
||||
'微信打款本地配置异常: bill=%s mch=%s err=%s',
|
||||
tixian_id, mch_id, e, exc_info=True,
|
||||
)
|
||||
err_code, err_msg = 'CERT_ERROR', f'商户证书/私钥异常: {e}'
|
||||
last_user_msg = err_msg
|
||||
last_was_balance = False
|
||||
err_msg = f'商户证书/私钥异常: {e}'
|
||||
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {err_msg}')
|
||||
if mch_idx < len(mch_cfgs) - 1:
|
||||
logger.warning(
|
||||
'商户本地配置失败,切换下一商户 shenhe=%s from=%s idx=%s/%s',
|
||||
shenhe_danhao, mch_id, mch_idx + 1, len(mch_cfgs),
|
||||
)
|
||||
continue
|
||||
if quota_reserved:
|
||||
release_collect_quota_for_record(
|
||||
TixianAutoRecord.objects.get(tixian_id=tixian_id),
|
||||
)
|
||||
quota_reserved = False
|
||||
return Response({'code': 99, 'msg': '微信商户配置异常,请联系管理员'})
|
||||
return _fail_front(f'【微信官方】商户{mch_id}配置异常,请联系管理员')
|
||||
|
||||
# 成功进待确认 → 立刻回前端(这是唯一「中途成功回包」的路径)
|
||||
# 成功进待确认 → 立刻回前端
|
||||
if resp.status_code == 200 and wx_result.get('state') == 'WAIT_USER_CONFIRM':
|
||||
package_info = wx_result.get('package_info')
|
||||
auto_record = TixianAutoRecord.objects.get(tixian_id=tixian_id)
|
||||
@@ -558,19 +579,12 @@ def process_audit_collect(request):
|
||||
tried_details.append(
|
||||
f'商户{mch_id}: HTTP{resp.status_code}/{err_code or "-"} {err_msg}'
|
||||
)
|
||||
user_msg = err_msg or '微信收款发起失败,请稍后重试'
|
||||
last_user_msg = user_msg
|
||||
has_next = (not is_user_bound) and (mch_idx < len(mch_cfgs) - 1)
|
||||
|
||||
has_next = mch_idx < len(mch_cfgs) - 1
|
||||
ip_denied = _is_wx_ip_denied(err_code, err_msg)
|
||||
clear_fail = _is_wx_clear_switchable_fail(err_code, err_msg, resp.status_code)
|
||||
# 限额/额度/受限/IP/4xx:一律不先查单(查单常一起挂),直接换号
|
||||
skip_query = (
|
||||
ip_denied
|
||||
or clear_fail
|
||||
or any(k in err_msg for k in ('额度', '限额', '上限', '受限', '管控'))
|
||||
or resp.status_code in (400, 401, 403)
|
||||
)
|
||||
|
||||
if not skip_query:
|
||||
# 结果可能已受理:查单,禁止换号
|
||||
if _is_wx_unsafe_to_switch(err_code, err_msg, resp.status_code):
|
||||
wx_q = query_wechat_transfer_bill(tixian_id, wx_cfg=wx_cfg)
|
||||
if wx_q is not None and not wx_q.get('_not_found'):
|
||||
ar = TixianAutoRecord.objects.get(tixian_id=tixian_id)
|
||||
@@ -592,8 +606,46 @@ def process_audit_collect(request):
|
||||
'tixianjilu_id': tixianjilu_id_val,
|
||||
},
|
||||
})
|
||||
# PENDING 有下一户:放弃本户继续;无下一户才告诉前端处理中
|
||||
if sync_action == RECONCILE_PENDING and not has_next:
|
||||
return Response({
|
||||
'code': 12,
|
||||
'msg': '收款请求结果不明确,系统正在核对,请稍后再试,切勿重复点击',
|
||||
})
|
||||
|
||||
# 唯一可静默换号:商户整户日/月额度 + 查单确认未建单
|
||||
mch_total_quota = _is_wx_mch_total_quota_exhausted(err_code, err_msg)
|
||||
if mch_total_quota and has_next:
|
||||
wx_q = query_wechat_transfer_bill(tixian_id, wx_cfg=wx_cfg)
|
||||
if wx_q is None:
|
||||
# 查单失败:不确定是否已建单,禁止换号
|
||||
_log_wx_collect(
|
||||
'整户额度但查单失败禁止换号',
|
||||
mch_id=mch_id, bill=tixian_id,
|
||||
)
|
||||
return Response({
|
||||
'code': 12,
|
||||
'msg': '商户额度异常且状态核对中,请稍后再试,切勿重复点击',
|
||||
})
|
||||
if not wx_q.get('_not_found'):
|
||||
ar = TixianAutoRecord.objects.get(tixian_id=tixian_id)
|
||||
sync_action, sync_payload = _sync_record_from_wx_query(ar, wx_q)
|
||||
if sync_action == RECONCILE_WAIT_CONFIRM:
|
||||
if isinstance(sync_payload, dict):
|
||||
sync_payload.setdefault('mch_id', mch_id)
|
||||
sync_payload.setdefault('tixianjilu_id', tixianjilu_id_val)
|
||||
_bind_collect_mch(audit, tixian_id, mch_id)
|
||||
return _build_collect_success_response(sync_payload)
|
||||
if sync_action == RECONCILE_COMPLETED:
|
||||
_bind_collect_mch(audit, tixian_id, mch_id)
|
||||
ensure_shenhe_collect_completed(shenhe_danhao)
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '提现已成功到账',
|
||||
'data': {
|
||||
'already_completed': True,
|
||||
'tixianjilu_id': tixianjilu_id_val,
|
||||
},
|
||||
})
|
||||
if sync_action == RECONCILE_PENDING:
|
||||
return Response({
|
||||
'code': 12,
|
||||
'msg': (
|
||||
@@ -601,16 +653,15 @@ def process_audit_collect(request):
|
||||
if isinstance(sync_payload, dict) else sync_payload
|
||||
) or '有收款处理中,请稍后再试',
|
||||
})
|
||||
|
||||
user_msg = err_msg or '微信收款发起失败,请稍后重试'
|
||||
last_user_msg = user_msg
|
||||
last_was_balance = _is_wx_balance_insufficient(err_code, err_msg)
|
||||
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}')
|
||||
|
||||
# 资格校验已过:商户用不了就静默换下一个,中间失败文案绝不回前端
|
||||
if has_next:
|
||||
# ALLOW_NEW(明确失败)才允许继续换号
|
||||
if sync_action != RECONCILE_ALLOW_NEW:
|
||||
return Response({
|
||||
'code': 12,
|
||||
'msg': '有收款处理中,请稍后再试',
|
||||
})
|
||||
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}')
|
||||
_log_wx_collect(
|
||||
'本商户官方拒绝→静默换下一户',
|
||||
'商户整户额度耗尽→静默换下一户',
|
||||
from_mch=mch_id,
|
||||
next_idx=f'{mch_idx + 2}/{len(mch_cfgs)}',
|
||||
wx_code=err_code,
|
||||
@@ -618,34 +669,32 @@ def process_audit_collect(request):
|
||||
)
|
||||
continue
|
||||
|
||||
if quota_reserved:
|
||||
release_collect_quota_for_record(
|
||||
TixianAutoRecord.objects.get(tixian_id=tixian_id),
|
||||
)
|
||||
quota_reserved = False
|
||||
tried = '、'.join(tried_mchs) or mch_id
|
||||
# 其它失败(含绑定用户整户额度、个人限额、余额、IP):不换号,回前端
|
||||
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}')
|
||||
detail = ';'.join(tried_details) if tried_details else user_msg
|
||||
_log_wx_collect('全部商户失败回前端', tried=tried, detail=detail)
|
||||
if len(mch_cfgs) < 2:
|
||||
return Response({
|
||||
'code': 99,
|
||||
'msg': (
|
||||
f'【微信官方】仅1个可用打款商户({tried}),无法轮换。{detail}'
|
||||
f'。请后台再完善第二个商户私钥。'
|
||||
)[:800],
|
||||
'data': {'source': 'wechat_official', 'tried_mch_ids': tried_mchs},
|
||||
})
|
||||
if last_was_balance:
|
||||
return Response({
|
||||
'code': 99,
|
||||
'msg': f'【微信官方】已尝试商户 {tried},资金不足:{detail}'[:800],
|
||||
'data': {'source': 'wechat_official', 'tried_mch_ids': tried_mchs},
|
||||
})
|
||||
return Response({
|
||||
'code': 99,
|
||||
'msg': f'【微信官方】已尝试商户 {tried} 均失败:{detail}'[:800],
|
||||
'data': {'source': 'wechat_official', 'tried_mch_ids': tried_mchs},
|
||||
})
|
||||
tried = '、'.join(tried_mchs) or mch_id
|
||||
if is_user_bound:
|
||||
_log_wx_collect('绑定商户失败回前端', tried=tried, detail=detail)
|
||||
return _fail_front(
|
||||
f'【微信官方】绑定商户 {tried} 发起失败:{detail}'
|
||||
)
|
||||
if mch_total_quota:
|
||||
_log_wx_collect('整户额度无下一户回前端', tried=tried, detail=detail)
|
||||
return _fail_front(
|
||||
f'【微信官方】商户整户转账额度已满({tried}):{detail}'
|
||||
)
|
||||
if _is_wx_balance_insufficient(err_code, err_msg):
|
||||
return _fail_front(
|
||||
f'【微信官方】商户资金不足({tried}):{detail}'
|
||||
)
|
||||
if _is_wx_ip_denied(err_code, err_msg):
|
||||
return _fail_front(
|
||||
f'【微信官方】商户IP未放行({tried}):{detail}'
|
||||
)
|
||||
_log_wx_collect('非整户额度失败不换号回前端', tried=tried, detail=detail)
|
||||
return _fail_front(
|
||||
f'【微信官方】商户 {tried} 发起失败:{detail}'
|
||||
)
|
||||
|
||||
if quota_reserved and tixian_id:
|
||||
release_collect_quota_for_record(
|
||||
@@ -654,14 +703,18 @@ def process_audit_collect(request):
|
||||
quota_reserved = False
|
||||
tried = '、'.join(tried_mchs) if tried_mchs else ''
|
||||
detail = ';'.join(tried_details) if tried_details else last_user_msg
|
||||
_log_wx_collect('轮换结束失败回前端', tried=tried, detail=detail)
|
||||
_log_wx_collect('选户结束失败回前端', tried=tried, detail=detail)
|
||||
return Response({
|
||||
'code': 99,
|
||||
'msg': (
|
||||
f'【微信官方】已尝试商户 {tried} 均失败:{detail}'
|
||||
if tried else f'【微信官方】{last_user_msg}'
|
||||
)[:800],
|
||||
'data': {'source': 'wechat_official', 'tried_mch_ids': tried_mchs},
|
||||
'data': {
|
||||
'source': 'wechat_official',
|
||||
'tried_mch_ids': tried_mchs,
|
||||
'user_bound': is_user_bound,
|
||||
},
|
||||
})
|
||||
|
||||
finally:
|
||||
|
||||
@@ -13256,7 +13256,7 @@ class TixianQueRenAutoView(APIView):
|
||||
mark_transfer_success(auto_record, with_accounting=True)
|
||||
return Response({'code': 0, 'msg': '提现成功', 'data': None})
|
||||
|
||||
# 用户取消或失败(确认页被微信限额/受限常见)
|
||||
# 用户取消或失败:本地标失败;若微信侧仍待确认,再收款仍只能拿到原单(防双笔)
|
||||
raw_fail = (request.data.get('fail_reason') or request.data.get('errMsg') or '').strip()
|
||||
fail_reason = (raw_fail or '用户取消收款')[:500]
|
||||
abandoned_mch = (auto_record.mch_id or '').strip()
|
||||
@@ -13265,12 +13265,12 @@ class TixianQueRenAutoView(APIView):
|
||||
auto_record.save(update_fields=['zhuangtai', 'fail_reason'])
|
||||
release_collect_quota_for_record(auto_record)
|
||||
logger.error(
|
||||
'【微信官方收款】确认失败已放弃商户 | bill=%s | mch_id=%s | reason=%s',
|
||||
'【微信官方收款】确认失败 | bill=%s | mch_id=%s | reason=%s',
|
||||
tixian_id, abandoned_mch, fail_reason,
|
||||
)
|
||||
|
||||
if is_audit_flow:
|
||||
# 新审核流程:申请时已扣款,取消不退余额,审核单恢复 6 待收款,可再次点收款
|
||||
# 新审核流程:申请时已扣款,取消不退余额,审核单恢复 6 待收款
|
||||
from .models import TixianShenheJilu
|
||||
from .tixian_shenhe_services import sync_audit_and_jilu_status
|
||||
try:
|
||||
@@ -13278,11 +13278,9 @@ class TixianQueRenAutoView(APIView):
|
||||
shenhe_danhao=auto_record.shenhe_danhao,
|
||||
)
|
||||
audit.tixian_auto_id = None
|
||||
if hasattr(audit, 'dakuan_mch_id'):
|
||||
audit.dakuan_mch_id = ''
|
||||
sync_audit_and_jilu_status(
|
||||
audit, 6,
|
||||
fail_reason='确认收款失败,可换其他商户重试',
|
||||
fail_reason='用户取消收款,可重新发起',
|
||||
)
|
||||
except TixianShenheJilu.DoesNotExist:
|
||||
logger.warning(f'确认取消但审核记录不存在: {auto_record.shenhe_danhao}')
|
||||
@@ -13308,16 +13306,7 @@ class TixianQueRenAutoView(APIView):
|
||||
zuzhang.save()
|
||||
decrease_tixian_daily_stat(auto_record.leixing, auto_record.shijidaozhang)
|
||||
|
||||
# 告知前端可立刻再调 tixiansq:会跳过刚失败的 mch_id 换下一户
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '已记录失败,可换商户重试',
|
||||
'data': {
|
||||
'can_retry_switch': bool(is_audit_flow),
|
||||
'abandoned_mch_id': abandoned_mch,
|
||||
'fail_reason': fail_reason,
|
||||
},
|
||||
})
|
||||
return Response({'code': 0, 'msg': '提现已取消', 'data': None})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'提现确认异常: {str(e)}', exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user