diff --git a/yonghu/tixian_shenhe_views.py b/yonghu/tixian_shenhe_views.py index 9b9f21f..ff74c4b 100644 --- a/yonghu/tixian_shenhe_views.py +++ b/yonghu/tixian_shenhe_views.py @@ -124,46 +124,11 @@ def _is_wx_clear_switchable_fail(err_code='', err_msg='', status_code=None): def _log_wx_collect(tag, **kwargs): - """收款排障专用:强制 ERROR 级别,日志里一眼能搜到【微信官方收款】""" + """收款排障:日志搜【微信官方收款】即可定位。""" parts = [f'{k}={v}' for k, v in kwargs.items()] logger.error('【微信官方收款】%s | %s', tag, ' | '.join(parts)) -def _wx_fail_response(tried_details, last_msg, *, single_mch=False, ip_denied=False, no_balance=False): - """把每个商户的微信官方原文拼回前端,方便对照日志。""" - tried_ids = [d.get('mch_id') for d in tried_details if d.get('mch_id')] - tried = '、'.join(tried_ids) - detail_lines = [] - for d in tried_details: - detail_lines.append( - f"商户{d.get('mch_id')}: HTTP{d.get('http')}/{d.get('code') or '-'} {d.get('msg') or ''}" - ) - detail_text = ';'.join(detail_lines) if detail_lines else (last_msg or '') - - if single_mch: - msg = ( - f'【微信官方】仅1个可用打款商户({tried or "-"}),无法轮换。' - f'{detail_text}。请后台再完善第二个商户私钥。' - ) - elif ip_denied: - msg = f'【微信官方】已尝试商户 {tried}:{detail_text}' - elif no_balance: - msg = f'【微信官方】已尝试商户 {tried},资金不足:{detail_text}' - else: - msg = f'【微信官方】已尝试商户 {tried} 均失败:{detail_text}' - - _log_wx_collect('最终失败回前端', tried=tried, msg=msg) - return Response({ - 'code': 99, - 'msg': msg[:800], - 'data': { - 'source': 'wechat_official', - 'tried_mch_ids': tried_ids, - 'tries': tried_details, - }, - }) - - def _verify_collect_quota_or_response(user_main, audit, shenhe_danhao): """收款限额校验;不通过时直接返回 Response""" ok, msg, limit_kind = check_collect_quota_limits( @@ -301,6 +266,401 @@ class TixianZddkshApplyView(APIView): finally: release_lock(lock_key) + +def process_audit_collect(request): + """ + POST /yonghu/tixiansq 用户收款(P0 防多提) + + 主路径:tixianjilu_id → Tixianjilu → TixianShenheJilu(状态6) + 若 TixianAutoRecord 已有 tixian_id → 只问微信,非终态绝不新建 + 仅微信 FAIL/CANCELLED 终态后才允许新建打款记录 + """ + from utils.redis_lock import acquire_lock, release_lock + + user_main = request.user + yonghuid = user_main.yonghuid + tixianjilu_id = request.data.get('tixianjilu_id') or request.data.get('id') + shenhe_danhao = (request.data.get('shenhe_danhao') or '').strip() + shenhe_jilu_id = request.data.get('shenhe_jilu_id') + + logger.info( + '收款请求 yonghuid=%s tixianjilu_id=%s shenhe_jilu_id=%s shenhe_danhao=%s', + yonghuid, tixianjilu_id, shenhe_jilu_id, shenhe_danhao, + ) + + try: + from utils.wechat_mch_service import list_enabled_wx_cfgs, wx_cfg_is_complete + + if not user_main.openid: + return Response({'code': 10, 'msg': '用户未绑定微信,无法收款'}) + + mch_cfgs = list_enabled_wx_cfgs() + if not mch_cfgs or not wx_cfg_is_complete(mch_cfgs[0]): + logger.error('微信打款配置不完整 enabled_count=%s', len(mch_cfgs)) + return Response({'code': 11, 'msg': '系统配置异常,请联系客服'}) + if len(mch_cfgs) < 2: + logger.error( + '启用且配置完整的转账商户仅 %s 个,无法轮换 mch_ids=%s ' + '(后台多个启用但私钥路径无效会被跳过;微信商户日额度失败时将无法换号)', + len(mch_cfgs), [c.get('MCHID') for c in mch_cfgs], + ) + + ctx, err = resolve_collect_context( + yonghuid, + tixianjilu_id=tixianjilu_id, + shenhe_danhao=shenhe_danhao, + shenhe_jilu_id=shenhe_jilu_id, + ) + if not ctx: + return Response({'code': 400, 'msg': err}) + + audit = ctx['audit'] + shenhe_danhao = ctx['shenhe_danhao'] + tixianjilu_id_val = ctx['jilu'].id if ctx.get('jilu') else audit.tixianjilu_id + leixing = audit.leixing + + # 微信单笔到账限额:与申请阶段同一标准(shijidaozhang >= 200 驳回并退款) + if shijidaozhang_exceeds_wechat_limit(audit.shijidaozhang): + reason = wechat_limit_collect_message(audit.shijidaozhang) + _, msg = reject_audit_and_refund(user_main, audit, reason) + return Response({'code': 400, 'msg': msg}) + + # 二次资格校验:不通过仅返回错误,审核单保持待收款(6),不退款,用户补齐资质后可再点收款 + collect_ok, collect_msg = validate_collect_eligibility(user_main, leixing) + if not collect_ok: + logger.warning( + '收款校验未通过 yonghuid=%s shenhe_danhao=%s leixing=%s msg=%s', + yonghuid, shenhe_danhao, leixing, collect_msg, + ) + return Response({'code': 400, 'msg': collect_msg}) + + quota_resp = _verify_collect_quota_or_response(user_main, audit, shenhe_danhao) + if quota_resp: + return quota_resp + + lock_key = f'tixian_collect:{shenhe_danhao}' + if not acquire_lock(lock_key, timeout=15): + return Response({'code': 429, 'msg': '操作频繁,请稍后重试'}) + + tixian_id = None + quota_reserved = False + try: + action, payload = reconcile_shenhe_wechat_bills(shenhe_danhao) + if action == RECONCILE_COMPLETED: + ensure_shenhe_collect_completed(shenhe_danhao) + msg = payload.get('msg', '提现已成功到账') if isinstance(payload, dict) else '提现已成功到账' + return Response({ + 'code': 0, + 'msg': msg, + 'data': {'already_completed': True, 'tixianjilu_id': tixianjilu_id_val}, + }) + if action == RECONCILE_WAIT_CONFIRM: + 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 + return Response({'code': 12, 'msg': msg or '有收款处理中,请稍后再试'}) + + if action != RECONCILE_ALLOW_NEW: + return Response({'code': 12, 'msg': '收款处理中,请稍后再试'}) + + with transaction.atomic(): + audit, err = get_audit_for_collect( + shenhe_danhao, yonghuid, audit.id, + ) + if not audit: + return Response({'code': 400, 'msg': err}) + + # 金额一律用审核表已落库数据(申请时扣款+算费),收款不再重算 + jine = audit.shenqing_jine + shouxufei = audit.shouxufei + shijidaozhang = audit.shijidaozhang + feilv = audit.feilv + + if shijidaozhang_exceeds_wechat_limit(shijidaozhang): + reason = wechat_limit_collect_message(shijidaozhang) + _, msg = reject_audit_and_refund(user_main, audit, reason) + return Response({'code': 400, 'msg': msg}) + + ok, msg, limit_kind = reserve_collect_quota_limits( + user_main, leixing, jine, shijidaozhang, shenhe_danhao, + ) + if not ok: + limit_code = 33 if limit_kind == 'platform' else 400 + return Response({'code': limit_code, 'msg': msg}) + + 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 + _log_wx_collect( + '开始轮换', + shenhe=shenhe_danhao, + 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 = [] + + for mch_idx, wx_cfg in enumerate(mch_cfgs): + tixian_id = generate_tixian_id() + mch_id = wx_cfg['MCHID'] + tried_mchs.append(mch_id) + _log_wx_collect( + '尝试商户', + idx=f'{mch_idx + 1}/{len(mch_cfgs)}', + mch_id=mch_id, + bill=tixian_id, + ) + with transaction.atomic(): + TixianAutoRecord.objects.create( + tixian_id=tixian_id, + yonghuid=yonghuid, + leixing=leixing, + jine=jine, + shouxufei=shouxufei, + shijidaozhang=shijidaozhang, + feilv=feilv, + zhuangtai=0, + shenhe_danhao=shenhe_danhao, + mch_id=mch_id, + ) + TixianShenheJilu.objects.filter(id=audit.id).update( + tixian_auto_id=tixian_id, + update_time=timezone.now(), + ) + + body = _build_wx_transfer_body( + wx_cfg, tixian_id, user_main.openid, yonghuid, leixing, shijidaozhang, + ) + + try: + resp, wx_result = _call_wechat_transfer(body, wx_cfg) + except requests.RequestException as e: + # 网络不确定是否已建单:禁止换号,保持待查单防双笔 + logger.error( + '微信打款网络异常(保持待查单): bill=%s mch=%s err=%s', + tixian_id, mch_id, e, exc_info=True, + ) + return Response({ + 'code': 12, + '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 + _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': '微信商户配置异常,请联系管理员'}) + + # 成功进待确认 → 立刻回前端(这是唯一「中途成功回包」的路径) + 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) + 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) + _log_wx_collect( + '成功待确认', + mch_id=mch_id, bill=tixian_id, shenhe=shenhe_danhao, + tried_before=','.join(tried_mchs[:-1]) if len(tried_mchs) > 1 else '', + ) + return _build_collect_success_response({ + 'tixian_id': tixian_id, + 'package_info': package_info, + 'shouxufei': str(shouxufei), + 'shijidaozhang': str(shijidaozhang), + 'current_rate': str(feilv), + 'mch_id': mch_id, + 'shenhe_danhao': shenhe_danhao, + 'tixianjilu_id': tixianjilu_id_val, + }) + + 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') + ), + ) + _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}, + }) + + 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 "未知"}' + _log_wx_collect( + '微信官方拒绝本商户', + shenhe=shenhe_danhao, + mch_id=mch_id, + bill=tixian_id, + http=resp.status_code, + code=err_code, + msg=err_msg, + state=state200, + body=json.dumps(wx_result, ensure_ascii=False)[:800], + ) + tried_details.append( + f'商户{mch_id}: HTTP{resp.status_code}/{err_code or "-"} {err_msg}' + ) + + 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: + 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}] {user_msg}') + + # 资格校验已过:商户用不了就静默换下一个,中间失败文案绝不回前端 + if has_next: + _log_wx_collect( + '本商户官方拒绝→静默换下一户', + from_mch=mch_id, + next_idx=f'{mch_idx + 2}/{len(mch_cfgs)}', + wx_code=err_code, + wx_msg=err_msg, + ) + 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 + 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}, + }) + + if quota_reserved and tixian_id: + release_collect_quota_for_record( + TixianAutoRecord.objects.get(tixian_id=tixian_id), + ) + 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) + 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}, + }) + + finally: + release_lock(lock_key) + except Exception as e: logger.error( '收款接口未捕获异常 yonghuid=%s tixianjilu_id=%s shenhe_danhao=%s err=%s', @@ -308,4 +668,3 @@ class TixianZddkshApplyView(APIView): exc_info=True, ) return Response({'code': 99, 'msg': '系统繁忙,请稍后重试'}) -