From 2044df384969a28d2d21e5376c4afd46d490eefa Mon Sep 17 00:00:00 2001 From: XingQue Date: Tue, 14 Jul 2026 19:42:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=BA=E5=8C=96=E5=A4=9A=E5=95=86=E6=88=B7?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E8=BD=AE=E6=8D=A2=EF=BC=9A=E6=97=A5=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6/=E5=8D=A1=E4=BD=8F=E7=8A=B6=E6=80=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8D=A2=E4=B8=8B=E4=B8=80=E6=88=B7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 查无单不再卡10分钟宽限期;跳过本单已失败商户;ACCEPTED/PROCESSING无package时放行换号;中间失败不回前端。 Co-authored-by: Cursor --- yonghu/tixian_shenhe_services.py | 38 +++++++++- yonghu/tixian_shenhe_views.py | 115 ++++++++++++++++++++++++------- 2 files changed, 126 insertions(+), 27 deletions(-) diff --git a/yonghu/tixian_shenhe_services.py b/yonghu/tixian_shenhe_services.py index 8dee5e0..846997a 100644 --- a/yonghu/tixian_shenhe_services.py +++ b/yonghu/tixian_shenhe_services.py @@ -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: + # 仅待用户确认/转账中可回传 package;ACCEPTED/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 diff --git a/yonghu/tixian_shenhe_views.py b/yonghu/tixian_shenhe_views.py index 3e8e48f..6cd40bf 100644 --- a/yonghu/tixian_shenhe_views.py +++ b/yonghu/tixian_shenhe_views.py @@ -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),