diff --git a/yonghu/migrations/0025_tixianshenhejilu_dakuan_mch_id.py b/yonghu/migrations/0025_tixianshenhejilu_dakuan_mch_id.py new file mode 100644 index 0000000..6b0cb36 --- /dev/null +++ b/yonghu/migrations/0025_tixianshenhejilu_dakuan_mch_id.py @@ -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='实际打款商户号', + ), + ), + ] diff --git a/yonghu/models.py b/yonghu/models.py index 39b3114..a9d428c 100644 --- a/yonghu/models.py +++ b/yonghu/models.py @@ -457,6 +457,11 @@ class TixianShenheJilu(models.Model): max_length=32, null=True, blank=True, db_index=True, 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='微信打款失败原因') create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间') diff --git a/yonghu/tixian_shenhe_services.py b/yonghu/tixian_shenhe_services.py index 3287190..52b45cf 100644 --- a/yonghu/tixian_shenhe_services.py +++ b/yonghu/tixian_shenhe_services.py @@ -505,7 +505,7 @@ def _check_personal_quota_readonly(user_main, leixing, jine, shenhe_danhao=''): if projected_personal > personal_quota: return ( False, - f'今日提现已达个人限额,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}元', + f'【平台个人限额】今日提现已达上限,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}元', 'personal', ) return True, '', '' @@ -530,7 +530,7 @@ def _check_platform_quota_readonly(leixing, shijidaozhang, shenhe_danhao=''): ) return ( False, - f'今日该角色提现总额已达上限,今日已提{daily_used}元,本次到账{additional}元,限额{platform_limit}元', + f'【平台总限额】今日该角色提现已达上限,今日已提{daily_used}元,本次到账{additional}元,限额{platform_limit}元', 'platform', ) @@ -589,7 +589,7 @@ def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe if personal_today + jine > personal_quota: return ( False, - f'今日提现已达个人限额,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}元', + f'【平台个人限额】今日提现已达上限,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}元', 'personal', ) @@ -604,7 +604,7 @@ def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe if platform_stat.total_amount + shijidaozhang > platform_limit: return ( False, - f'今日该角色提现总额已达上限,今日已提{platform_stat.total_amount}元,' + f'【平台总限额】今日该角色提现已达上限,今日已提{platform_stat.total_amount}元,' f'本次到账{shijidaozhang}元,限额{platform_limit}元', '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): """ P0 防双笔:同一 shenhe_danhao 下所有打款记录逐条问微信 - 任一 SUCCESS / 进行中 → 禁止新建;全部终态失败才 ALLOW_NEW - 返回 (action, payload) + - SUCCESS → completed + - 真正待确认(有 package) → wait_confirm(禁止换户) + - 其余(无 package / 限额失败 / IP / 查单失败)→ 标失败,ALLOW_NEW 以便换商户 """ records = list( 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: return RECONCILE_ALLOW_NEW, {} - pending_hit = None + pending_with_package = None for rec in records: if rec.zhuangtai == 1: ok, msg = ensure_shenhe_collect_completed(shenhe_danhao) return RECONCILE_COMPLETED, {'msg': msg, 'synced': ok} - wx_data = query_wechat_transfer_bill(rec.tixian_id) - 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, - } + local_pkg = _parse_package_info(rec.wechat_package) - 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 - 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) + # 本地已有收款包:必须先对齐数字,禁止为换号新建第二笔待确认 + if rec.zhuangtai == 0 and local_pkg: + wx_data = query_wechat_transfer_bill(rec.tixian_id) + if wx_data is None: + return RECONCILE_PENDING, { + 'msg': '有收款处理中,系统正在核对微信状态,请稍后再试', + 'tixian_id': rec.tixian_id, + } + action, payload = _sync_record_from_wx_query(rec, wx_data) + if action in (RECONCILE_COMPLETED, RECONCILE_WAIT_CONFIRM, RECONCILE_PENDING): + return action, payload continue - if pending_hit: - return pending_hit + # 无 package:商户侧基本未进入待确认。限额/IP/卡单都不能挡住换号。 + 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, {} diff --git a/yonghu/tixian_shenhe_views.py b/yonghu/tixian_shenhe_views.py index 67ba234..2f03806 100644 --- a/yonghu/tixian_shenhe_views.py +++ b/yonghu/tixian_shenhe_views.py @@ -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): transfer_scene_id = wx_cfg['TRANSFER_SCENE_ID'] 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) if 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) if action == RECONCILE_PENDING: 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.mch_id = mch_id auto_record.save(update_fields=['wechat_package', 'mch_id']) + _bind_collect_mch(audit, tixian_id, mch_id) return _build_collect_success_response({ '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') ), ) + _bind_collect_mch(audit, tixian_id, mch_id) ensure_shenhe_collect_completed(shenhe_danhao) return Response({ 'code': 0, @@ -505,106 +524,61 @@ def process_audit_collect(request): ) 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) + 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 ip_denied or (clear_fail and resp.status_code in (400, 401, 403)): - 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: - logger.warning( - '商户明确失败(含IP白名单),跳过查单静默换下一户 shenhe=%s ' - 'from=%s code=%s msg=%s http=%s idx=%s/%s', - shenhe_danhao, mch_id, err_code, err_msg, resp.status_code, - 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 - if ip_denied: - return Response({ - 'code': 99, - 'msg': '微信商户未配置服务器IP白名单或配置未生效,请联系管理员检查各商户号', - }) - if last_was_balance: - return Response({ - 'code': 99, - 'msg': '运营账户资金不足,需等管理员充值后才能提现', - }) - 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': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击', - }) + if not skip_query: + 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) + _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, + }, + }) + # PENDING 有下一户:放弃本户继续;无下一户才告诉前端处理中 + if sync_action == RECONCILE_PENDING and not has_next: + return Response({ + 'code': 12, + 'msg': ( + sync_payload.get('msg') + 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}] {err_msg}') + _mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}') - # 有下一户:静默换号,绝不把中间失败文案回前端 + # 资格校验已过:商户用不了就静默换下一个,中间失败文案绝不回前端 if has_next: logger.warning( - '商户转账失败,静默切换下一商户(不回前端) shenhe=%s from=%s ' - 'code=%s msg=%s http=%s query=%s clear=%s idx=%s/%s', + '商户不可用,静默换下一户 shenhe=%s from=%s code=%s msg=%s ' + 'http=%s skip_query=%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), + skip_query, mch_idx + 1, len(mch_cfgs), ) continue @@ -613,6 +587,11 @@ def process_audit_collect(request): TixianAutoRecord.objects.get(tixian_id=tixian_id), ) quota_reserved = False + if ip_denied: + return Response({ + 'code': 99, + 'msg': '所有商户均无法发起打款(请检查各商户IP白名单/日额度),请联系管理员', + }) if last_was_balance: return Response({ 'code': 99,