强化多商户静默轮换:日额度/卡住状态自动换下一户。

查无单不再卡10分钟宽限期;跳过本单已失败商户;ACCEPTED/PROCESSING无package时放行换号;中间失败不回前端。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-14 19:42:54 +08:00
parent 5c8be5da9e
commit 2044df3849
2 changed files with 126 additions and 27 deletions

View File

@@ -1264,8 +1264,15 @@ def mark_transfer_success(auto_record, *, wechat_transfer_no=None, with_accounti
def _sync_record_from_wx_query(auto_record, wx_data):
"""根据微信查单结果修正本地打款记录,返回 (action, payload)"""
if wx_data.get('_not_found'):
# 从未拿到 package = 未真正进入待确认,查无单应立刻放行换号/重发,
# 不要卡 10 分钟宽限期(否则首户日额度失败后重试永远进不了多商户轮换)。
has_package = bool(_parse_package_info(auto_record.wechat_package))
age = timezone.now() - auto_record.create_time
if auto_record.zhuangtai == 0 and age < timedelta(minutes=PENDING_QUERY_GRACE_MINUTES):
if (
auto_record.zhuangtai == 0
and has_package
and age < timedelta(minutes=PENDING_QUERY_GRACE_MINUTES)
):
return RECONCILE_PENDING, {
'msg': '有收款处理中,请稍后再试',
'tixian_id': auto_record.tixian_id,
@@ -1286,7 +1293,8 @@ def _sync_record_from_wx_query(auto_record, wx_data):
)
return RECONCILE_COMPLETED, {'msg': '提现已成功到账', 'synced': True}
if state in WX_STATE_WAIT:
# 仅待用户确认/转账中可回传 packageACCEPTED/PROCESSING 视为卡在出资户,不假装可收款
if state in ('WAIT_USER_CONFIRM', 'TRANSFERING'):
package = wx_data.get('package_info') or _parse_package_info(auto_record.wechat_package)
if not package:
return RECONCILE_PENDING, {
@@ -1311,6 +1319,13 @@ def _sync_record_from_wx_query(auto_record, wx_data):
'shenhe_danhao': auto_record.shenhe_danhao or '',
}
if state in ('ACCEPTED', 'PROCESSING'):
return RECONCILE_PENDING, {
'msg': f'收款处理中(微信状态:{state}),请稍后再试',
'tixian_id': auto_record.tixian_id,
'wx_state': state,
}
if state in WX_STATE_CANCELING:
return RECONCILE_PENDING, {
'msg': '收款撤销处理中,请稍后再试',
@@ -1394,6 +1409,25 @@ def reconcile_shenhe_wechat_bills(shenhe_danhao):
if action == RECONCILE_WAIT_CONFIRM:
return action, payload
if action == RECONCILE_PENDING:
# ACCEPTED/PROCESSING 且无 package视为该出资户卡住标失败以便换商户重发
qstate = _wx_transfer_state(wx_data)
has_pkg = bool(
wx_data.get('package_info') or _parse_package_info(rec.wechat_package)
)
if (
rec.zhuangtai == 0
and qstate in ('ACCEPTED', 'PROCESSING')
and not has_pkg
):
rec.zhuangtai = 2
rec.fail_reason = (f'微信状态卡住:{qstate},已切换其他商户重试')[:500]
rec.save(update_fields=['zhuangtai', 'fail_reason', 'update_time'])
# 不 release 限额:后续同笔收款仍占用预占
logger.warning(
'对账放行卡住状态 shenhe=%s bill=%s mch=%s state=%s',
shenhe_danhao, rec.tixian_id, rec.mch_id, qstate,
)
continue
if pending_hit is None:
pending_hit = (action, payload)
continue

View File

@@ -78,15 +78,33 @@ def _extract_wx_err(wx_result):
def _is_wx_unsafe_to_switch(err_code='', err_msg='', status_code=None):
"""
结果可能已受理:换号有双笔风险,禁止立刻换商户,只能查单/待确认。
其余明确业务失败日额度、余额不足、INVALID_REQUEST 等)默认可换号。
"""
if status_code in (429, 500, 502, 503):
return True
blob = f'{err_code} {err_msg}'.upper()
keys = (
'FREQUENCY_LIMIT', 'FREQUENCY_LIMITED', 'FREQUENCY_LIMIT_EXCEED',
'RATELIMIT', 'RATE_LIMIT', 'SYSTEM_ERROR',
'频率超限', '系统错误', '结果不明确', '结果未明确',
'RATELIMIT', 'RATE_LIMIT', 'SYSTEM_ERROR', 'ALREADY_EXISTS',
'频率超限', '系统错误', '结果不明确', '结果未明确', '订单已存在',
)
return any(k in blob for k in keys)
def _is_wx_clear_switchable_fail(err_code='', err_msg='', status_code=None):
"""
明确未建单的业务失败:可静默换下一商户(日额度/余额不足/无权限等)。
查单失败(None)时也允许换——避免首户卡死后永不试下一户。
"""
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()
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)
@@ -339,6 +357,24 @@ 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
logger.warning(
'收款开始轮换商户 shenhe=%s count=%s mch_ids=%s',
shenhe_danhao, len(mch_cfgs), [c.get('MCHID') for c in mch_cfgs],
)
last_user_msg = '微信收款发起失败,请稍后重试'
last_was_balance = False
tixian_id = None
@@ -422,24 +458,38 @@ def process_audit_collect(request):
'tixianjilu_id': tixianjilu_id_val,
})
err_code, err_msg = _extract_wx_err(wx_result)
logger.error(
'微信发起转账未进入待确认: bill=%s mch=%s code=%s msg=%s HTTP%s',
tixian_id, mch_id, err_code, err_msg, resp.status_code,
)
# 查单决定:已成功/待确认 → 回前端;无单可换 → 静默试下一户;中间失败不回前端
has_next = mch_idx < len(mch_cfgs) - 1
unsafe = _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 None:
state200 = (wx_result.get('state') or '').upper() if resp.status_code == 200 else ''
if resp.status_code == 200 and state200 == 'SUCCESS':
ar = TixianAutoRecord.objects.get(tixian_id=tixian_id)
from .tixian_shenhe_services import mark_transfer_success
mark_transfer_success(
ar,
wechat_transfer_no=(
wx_result.get('transfer_bill_no') or wx_result.get('transfer_no')
),
)
ensure_shenhe_collect_completed(shenhe_danhao)
return Response({
'code': 12,
'msg': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击',
'code': 0,
'msg': '提现已成功到账',
'data': {'already_completed': True, 'tixianjilu_id': tixianjilu_id_val},
})
if not wx_q.get('_not_found'):
err_code, err_msg = _extract_wx_err(wx_result)
if resp.status_code == 200 and (not err_code or err_msg == '微信接口调用失败'):
err_msg = f'微信状态:{state200 or "未知"}'
logger.error(
'微信发起转账未进入待确认: bill=%s mch=%s code=%s msg=%s HTTP%s state=%s',
tixian_id, mch_id, err_code, err_msg, resp.status_code, state200,
)
has_next = mch_idx < len(mch_cfgs) - 1
unsafe = _is_wx_unsafe_to_switch(err_code, err_msg, resp.status_code)
clear_fail = _is_wx_clear_switchable_fail(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)
sync_action, sync_payload = _sync_record_from_wx_query(ar, wx_q)
if sync_action == RECONCILE_WAIT_CONFIRM:
@@ -458,6 +508,20 @@ def process_audit_collect(request):
'data': {'already_completed': True, 'tixianjilu_id': tixianjilu_id_val},
})
if sync_action == RECONCILE_PENDING:
from .tixian_shenhe_services import _parse_package_info
ar_pkg = _parse_package_info(ar.wechat_package) or wx_q.get('package_info')
qstate = (wx_q.get('state') or '').upper()
# 卡在 ACCEPTED/PROCESSING 且无确认包:首户用不了,有下一户则换号
if has_next and (not ar_pkg) and qstate in ('ACCEPTED', 'PROCESSING'):
user_msg = err_msg or f'商户状态异常({qstate})'
last_user_msg = user_msg
last_was_balance = True
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}')
logger.warning(
'商户卡在%s且无package静默换下一户 shenhe=%s from=%s idx=%s/%s',
qstate, shenhe_danhao, mch_id, mch_idx + 1, len(mch_cfgs),
)
continue
return Response({
'code': 12,
'msg': (
@@ -465,10 +529,10 @@ def process_audit_collect(request):
if isinstance(sync_payload, dict) else sync_payload
) or '有收款处理中,请稍后再试',
})
# ALLOW_NEW微信侧该单已明确失败,下面换号
# ALLOW_NEW微信终态失败,下面换号
if unsafe:
# 频率/5xx即便暂查无单也不换号结果不确定
# 结果不确定且查单失败:禁换号
if unsafe and not clear_fail and wx_q is None:
return Response({
'code': 12,
'msg': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击',
@@ -479,16 +543,17 @@ def process_audit_collect(request):
last_was_balance = _is_wx_balance_insufficient(err_code, err_msg)
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {err_msg}')
# 有下一户:静默换号,绝不把中间失败文案回前端
if has_next:
logger.warning(
'商户转账失败,静默切换下一商户(不回前端) shenhe=%s from=%s '
'code=%s msg=%s idx=%s/%s',
shenhe_danhao, mch_id, err_code, err_msg,
mch_idx + 1, len(mch_cfgs),
'code=%s msg=%s http=%s query=%s clear=%s idx=%s/%s',
shenhe_danhao, mch_id, err_code, err_msg, resp.status_code,
'none' if wx_q is None else ('404' if wx_q.get('_not_found') else 'synced'),
clear_fail, mch_idx + 1, len(mch_cfgs),
)
continue
# 全部商户都失败了才回前端
if quota_reserved:
release_collect_quota_for_record(
TixianAutoRecord.objects.get(tixian_id=tixian_id),