仅商户整户额度可换号,并支持后台用户绑定固定收款商户。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-15 01:19:36 +08:00
parent 728bac58ee
commit 5a8f2099dc
8 changed files with 435 additions and 141 deletions

View File

@@ -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: