收款商户限额/不可用时强制静默换号,审核单记录实际打款商户号。

对账无package一律放行;额度/IP/4xx跳过查单直接试下一户;新增 dakuan_mch_id 防止串户;平台限额文案加前缀区分。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-14 21:13:41 +08:00
parent 2f3889855c
commit 9bcd7b3a21
4 changed files with 165 additions and 148 deletions

View File

@@ -0,0 +1,23 @@
# Generated manually: TixianShenheJilu.dakuan_mch_id
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('yonghu', '0024_tixianautorecord_mch_id'),
]
operations = [
migrations.AddField(
model_name='tixianshenhejilu',
name='dakuan_mch_id',
field=models.CharField(
blank=True,
db_index=True,
default='',
max_length=32,
verbose_name='实际打款商户号',
),
),
]

View File

@@ -457,6 +457,11 @@ class TixianShenheJilu(models.Model):
max_length=32, null=True, blank=True, db_index=True, max_length=32, null=True, blank=True, db_index=True,
verbose_name='最近一次打款单号(非唯一,重试收款会更新)' verbose_name='最近一次打款单号(非唯一,重试收款会更新)'
) )
# 实际进入待确认/打款成功的微信商户号(与 TixianAutoRecord.mch_id 对齐,防串户)
dakuan_mch_id = models.CharField(
max_length=32, blank=True, default='', db_index=True,
verbose_name='实际打款商户号'
)
fail_reason = models.TextField(null=True, blank=True, verbose_name='微信打款失败原因') fail_reason = models.TextField(null=True, blank=True, verbose_name='微信打款失败原因')
create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间') update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')

View File

@@ -505,7 +505,7 @@ def _check_personal_quota_readonly(user_main, leixing, jine, shenhe_danhao=''):
if projected_personal > personal_quota: if projected_personal > personal_quota:
return ( return (
False, False,
f'今日提现已达个人限额,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}', f'【平台个人限额】今日提现已达上限,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}',
'personal', 'personal',
) )
return True, '', '' return True, '', ''
@@ -530,7 +530,7 @@ def _check_platform_quota_readonly(leixing, shijidaozhang, shenhe_danhao=''):
) )
return ( return (
False, False,
f'今日该角色提现总额已达上限,今日已提{daily_used}元,本次到账{additional}元,限额{platform_limit}', f'【平台总限额】今日该角色提现已达上限,今日已提{daily_used}元,本次到账{additional}元,限额{platform_limit}',
'platform', 'platform',
) )
@@ -589,7 +589,7 @@ def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe
if personal_today + jine > personal_quota: if personal_today + jine > personal_quota:
return ( return (
False, False,
f'今日提现已达个人限额,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}', f'【平台个人限额】今日提现已达上限,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}',
'personal', 'personal',
) )
@@ -604,7 +604,7 @@ def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe
if platform_stat.total_amount + shijidaozhang > platform_limit: if platform_stat.total_amount + shijidaozhang > platform_limit:
return ( return (
False, False,
f'今日该角色提现总额已达上限,今日已提{platform_stat.total_amount}元,' f'【平台总限额】今日该角色提现已达上限,今日已提{platform_stat.total_amount}元,'
f'本次到账{shijidaozhang}元,限额{platform_limit}', f'本次到账{shijidaozhang}元,限额{platform_limit}',
'platform', 'platform',
) )
@@ -1369,11 +1369,19 @@ def get_auto_record_for_collect(shenhe_danhao):
) )
def _mark_auto_record_failed_local(rec, reason):
if rec.zhuangtai == 0:
rec.zhuangtai = 2
rec.fail_reason = (reason or '打款失败')[:500]
rec.save(update_fields=['zhuangtai', 'fail_reason', 'update_time'])
def reconcile_shenhe_wechat_bills(shenhe_danhao): def reconcile_shenhe_wechat_bills(shenhe_danhao):
""" """
P0 防双笔:同一 shenhe_danhao 下所有打款记录逐条问微信 P0 防双笔:同一 shenhe_danhao 下所有打款记录逐条问微信
任一 SUCCESS / 进行中 → 禁止新建;全部终态失败才 ALLOW_NEW - SUCCESS → completed
返回 (action, payload) - 真正待确认(有 package) → wait_confirm禁止换户
- 其余(无 package / 限额失败 / IP / 查单失败)→ 标失败ALLOW_NEW 以便换商户
""" """
records = list( records = list(
TixianAutoRecord.objects.filter(shenhe_danhao=shenhe_danhao).order_by('-create_time') TixianAutoRecord.objects.filter(shenhe_danhao=shenhe_danhao).order_by('-create_time')
@@ -1386,66 +1394,68 @@ def reconcile_shenhe_wechat_bills(shenhe_danhao):
if not records: if not records:
return RECONCILE_ALLOW_NEW, {} return RECONCILE_ALLOW_NEW, {}
pending_hit = None pending_with_package = None
for rec in records: for rec in records:
if rec.zhuangtai == 1: if rec.zhuangtai == 1:
ok, msg = ensure_shenhe_collect_completed(shenhe_danhao) ok, msg = ensure_shenhe_collect_completed(shenhe_danhao)
return RECONCILE_COMPLETED, {'msg': msg, 'synced': ok} return RECONCILE_COMPLETED, {'msg': msg, 'synced': ok}
wx_data = query_wechat_transfer_bill(rec.tixian_id) local_pkg = _parse_package_info(rec.wechat_package)
if wx_data is None:
# IP 未放行时创建/查单都会失败。若从未拿到 package说明没进待确认
# 标失败放行换商户;否则会永远卡在「处理中」并反复打第一个商户。
has_pkg = bool(_parse_package_info(rec.wechat_package))
if rec.zhuangtai == 0 and not has_pkg:
rec.zhuangtai = 2
rec.fail_reason = '查单不可用且未待确认常见于IP白名单已换其他商户重试'
rec.save(update_fields=['zhuangtai', 'fail_reason', 'update_time'])
logger.warning(
'对账查单不可用已放行换号 shenhe=%s bill=%s mch=%s',
shenhe_danhao, rec.tixian_id, rec.mch_id,
)
continue
logger.error(
'对账查单不可用 shenhe_danhao=%s bill=%s',
shenhe_danhao, rec.tixian_id,
)
return RECONCILE_PENDING, {
'msg': '有收款处理中,系统正在核对微信状态,请稍后再试',
'tixian_id': rec.tixian_id,
}
action, payload = _sync_record_from_wx_query(rec, wx_data) # 本地已有收款包:必须先对齐数字,禁止为换号新建第二笔待确认
if action == RECONCILE_COMPLETED: if rec.zhuangtai == 0 and local_pkg:
return action, payload wx_data = query_wechat_transfer_bill(rec.tixian_id)
if action == RECONCILE_WAIT_CONFIRM: if wx_data is None:
return action, payload return RECONCILE_PENDING, {
if action == RECONCILE_PENDING: 'msg': '有收款处理中,系统正在核对微信状态,请稍后再试',
# ACCEPTED/PROCESSING 且无 package视为该出资户卡住标失败以便换商户重发 'tixian_id': rec.tixian_id,
qstate = _wx_transfer_state(wx_data) }
has_pkg = bool( action, payload = _sync_record_from_wx_query(rec, wx_data)
wx_data.get('package_info') or _parse_package_info(rec.wechat_package) if action in (RECONCILE_COMPLETED, RECONCILE_WAIT_CONFIRM, RECONCILE_PENDING):
) return action, payload
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 continue
if pending_hit: # 无 package商户侧基本未进入待确认。限额/IP/卡单都不能挡住换号。
return pending_hit if rec.zhuangtai == 0 and not local_pkg:
wx_data = query_wechat_transfer_bill(rec.tixian_id)
if wx_data is None or wx_data.get('_not_found'):
_mark_auto_record_failed_local(
rec, '未进入待确认(查无单或查单失败),已换商户重试',
)
continue
action, payload = _sync_record_from_wx_query(rec, wx_data)
if action == RECONCILE_COMPLETED:
return action, payload
if action == RECONCILE_WAIT_CONFIRM:
return action, payload
# PENDING / ALLOW_NEW若仍无 package标失败继续换号
if action == RECONCILE_PENDING:
pkg = (
(payload or {}).get('package_info')
if isinstance(payload, dict) else None
) or wx_data.get('package_info')
if pkg:
if pending_with_package is None:
pending_with_package = (action, payload)
continue
_mark_auto_record_failed_local(
rec,
f'微信不可用状态:{_wx_transfer_state(wx_data) or "未知"},已换商户重试',
)
continue
# 已失败记录:仍问一遍微信,防本地误标
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 in (RECONCILE_COMPLETED, RECONCILE_WAIT_CONFIRM):
return action, payload
continue
if pending_with_package:
return pending_with_package
return RECONCILE_ALLOW_NEW, {} return RECONCILE_ALLOW_NEW, {}

View File

@@ -151,6 +151,17 @@ def _mark_auto_record_failed(tixian_id, fail_reason):
) )
def _bind_collect_mch(audit, tixian_id, mch_id):
"""成功进入待确认时,审核单与打款单同时记下实际商户号,防串户。"""
fields = {
'tixian_auto_id': tixian_id,
'update_time': timezone.now(),
}
if hasattr(audit, 'dakuan_mch_id'):
fields['dakuan_mch_id'] = (mch_id or '')[:32]
TixianShenheJilu.objects.filter(id=audit.id).update(**fields)
def _build_wx_transfer_body(wx_cfg, tixian_id, openid, yonghuid, leixing, shijidaozhang): def _build_wx_transfer_body(wx_cfg, tixian_id, openid, yonghuid, leixing, shijidaozhang):
transfer_scene_id = wx_cfg['TRANSFER_SCENE_ID'] transfer_scene_id = wx_cfg['TRANSFER_SCENE_ID']
role_map = {1: '打手', 2: '管事', 3: '组长', 4: '考核官', 5: '打手', 6: '商家'} role_map = {1: '打手', 2: '管事', 3: '组长', 4: '考核官', 5: '打手', 6: '商家'}
@@ -341,6 +352,12 @@ def process_audit_collect(request):
quota_resp = _verify_collect_quota_or_response(user_main, audit, shenhe_danhao) quota_resp = _verify_collect_quota_or_response(user_main, audit, shenhe_danhao)
if quota_resp: if quota_resp:
return quota_resp return quota_resp
if isinstance(payload, dict):
_bind_collect_mch(
audit,
payload.get('tixian_id') or '',
payload.get('mch_id') or '',
)
return _build_collect_success_response(payload) return _build_collect_success_response(payload)
if action == RECONCILE_PENDING: if action == RECONCILE_PENDING:
msg = payload.get('msg') if isinstance(payload, dict) else payload msg = payload.get('msg') if isinstance(payload, dict) else payload
@@ -466,6 +483,7 @@ def process_audit_collect(request):
auto_record.wechat_package = json.dumps(package_info, ensure_ascii=False) auto_record.wechat_package = json.dumps(package_info, ensure_ascii=False)
auto_record.mch_id = mch_id auto_record.mch_id = mch_id
auto_record.save(update_fields=['wechat_package', 'mch_id']) auto_record.save(update_fields=['wechat_package', 'mch_id'])
_bind_collect_mch(audit, tixian_id, mch_id)
return _build_collect_success_response({ return _build_collect_success_response({
'tixian_id': tixian_id, 'tixian_id': tixian_id,
@@ -488,6 +506,7 @@ def process_audit_collect(request):
wx_result.get('transfer_bill_no') or wx_result.get('transfer_no') wx_result.get('transfer_bill_no') or wx_result.get('transfer_no')
), ),
) )
_bind_collect_mch(audit, tixian_id, mch_id)
ensure_shenhe_collect_completed(shenhe_danhao) ensure_shenhe_collect_completed(shenhe_danhao)
return Response({ return Response({
'code': 0, 'code': 0,
@@ -505,106 +524,61 @@ def process_audit_collect(request):
) )
has_next = mch_idx < len(mch_cfgs) - 1 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)
ip_denied = _is_wx_ip_denied(err_code, err_msg) 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 ('额度' in err_msg)
or ('限额' in err_msg)
or resp.status_code in (400, 401, 403)
)
# IP 白名单/明确 4xx创建失败时查单也会被同一 IP 拦住,查了只会误卡「处理中」 if not skip_query:
# 直接标失败并静默换下一户,绝不回前端中间错误 wx_q = query_wechat_transfer_bill(tixian_id, wx_cfg=wx_cfg)
if ip_denied or (clear_fail and resp.status_code in (400, 401, 403)): if wx_q is not None and not wx_q.get('_not_found'):
user_msg = err_msg or '微信收款发起失败,请稍后重试' ar = TixianAutoRecord.objects.get(tixian_id=tixian_id)
last_user_msg = user_msg sync_action, sync_payload = _sync_record_from_wx_query(ar, wx_q)
last_was_balance = _is_wx_balance_insufficient(err_code, err_msg) if sync_action == RECONCILE_WAIT_CONFIRM:
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}') if isinstance(sync_payload, dict):
if has_next: sync_payload.setdefault('mch_id', mch_id)
logger.warning( sync_payload.setdefault('tixianjilu_id', tixianjilu_id_val)
'商户明确失败(含IP白名单),跳过查单静默换下一户 shenhe=%s ' _bind_collect_mch(audit, tixian_id, mch_id)
'from=%s code=%s msg=%s http=%s idx=%s/%s', return _build_collect_success_response(sync_payload)
shenhe_danhao, mch_id, err_code, err_msg, resp.status_code, if sync_action == RECONCILE_COMPLETED:
mch_idx + 1, len(mch_cfgs), _bind_collect_mch(audit, tixian_id, mch_id)
) ensure_shenhe_collect_completed(shenhe_danhao)
continue return Response({
if quota_reserved: 'code': 0,
release_collect_quota_for_record( 'msg': '提现已成功到账',
TixianAutoRecord.objects.get(tixian_id=tixian_id), 'data': {
) 'already_completed': True,
quota_reserved = False 'tixianjilu_id': tixianjilu_id_val,
if ip_denied: },
return Response({ })
'code': 99, # PENDING 有下一户:放弃本户继续;无下一户才告诉前端处理中
'msg': '微信商户未配置服务器IP白名单或配置未生效请联系管理员检查各商户号', if sync_action == RECONCILE_PENDING and not has_next:
}) return Response({
if last_was_balance: 'code': 12,
return Response({ 'msg': (
'code': 99, sync_payload.get('msg')
'msg': '运营账户资金不足,需等管理员充值后才能提现', if isinstance(sync_payload, dict) else sync_payload
}) ) or '有收款处理中,请稍后再试',
return Response({'code': 99, 'msg': user_msg}) })
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:
if isinstance(sync_payload, dict):
sync_payload.setdefault('mch_id', mch_id)
sync_payload.setdefault('tixianjilu_id', tixianjilu_id_val)
quota_resp = _verify_collect_quota_or_response(user_main, audit, shenhe_danhao)
if quota_resp:
return quota_resp
return _build_collect_success_response(sync_payload)
if sync_action == RECONCILE_COMPLETED:
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:
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': (
sync_payload.get('msg')
if isinstance(sync_payload, dict) else sync_payload
) or '有收款处理中,请稍后再试',
})
# ALLOW_NEW微信终态失败下面换号
# 结果不确定且查单失败:禁换号
if unsafe and not clear_fail and wx_q is None:
return Response({
'code': 12,
'msg': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击',
})
user_msg = err_msg or '微信收款发起失败,请稍后重试' user_msg = err_msg or '微信收款发起失败,请稍后重试'
last_user_msg = user_msg last_user_msg = user_msg
last_was_balance = _is_wx_balance_insufficient(err_code, err_msg) last_was_balance = _is_wx_balance_insufficient(err_code, err_msg)
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {err_msg}') _mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}')
# 有下一户:静默换号,绝不把中间失败文案回前端 # 资格校验已过:商户用不了就静默换下一个,中间失败文案绝不回前端
if has_next: if has_next:
logger.warning( logger.warning(
'商户转账失败,静默换下一商户(不回前端) shenhe=%s from=%s ' '商户不可用,静默换下一 shenhe=%s from=%s code=%s msg=%s '
'code=%s msg=%s http=%s query=%s clear=%s idx=%s/%s', 'http=%s skip_query=%s idx=%s/%s',
shenhe_danhao, mch_id, err_code, err_msg, resp.status_code, 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'), skip_query, mch_idx + 1, len(mch_cfgs),
clear_fail, mch_idx + 1, len(mch_cfgs),
) )
continue continue
@@ -613,6 +587,11 @@ def process_audit_collect(request):
TixianAutoRecord.objects.get(tixian_id=tixian_id), TixianAutoRecord.objects.get(tixian_id=tixian_id),
) )
quota_reserved = False quota_reserved = False
if ip_denied:
return Response({
'code': 99,
'msg': '所有商户均无法发起打款请检查各商户IP白名单/日额度),请联系管理员',
})
if last_was_balance: if last_was_balance:
return Response({ return Response({
'code': 99, 'code': 99,