645 lines
29 KiB
Python
645 lines
29 KiB
Python
# yonghu/tixian_shenhe_collect_b.py
|
||
"""
|
||
自动提现审核 — 用户侧接口:
|
||
① TixianZddkshApplyView → POST /yonghu/zddksh 提交审核(扣款)
|
||
② process_audit_collect → POST /yonghu/tixiansq 收款(P0:一单到底,先查微信再决定是否新建)
|
||
"""
|
||
import decimal
|
||
import json
|
||
import logging
|
||
import random
|
||
import string
|
||
import time
|
||
|
||
import requests
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
from rest_framework import permissions
|
||
from rest_framework.response import Response
|
||
from rest_framework.views import APIView
|
||
|
||
from .models import TixianAutoRecord, TixianShenheJilu
|
||
from .tixian_shenhe_services import (
|
||
RECONCILE_ALLOW_NEW,
|
||
RECONCILE_COMPLETED,
|
||
RECONCILE_PENDING,
|
||
RECONCILE_WAIT_CONFIRM,
|
||
check_collect_quota_limits,
|
||
create_audit_application,
|
||
ensure_shenhe_collect_completed,
|
||
get_audit_for_collect,
|
||
query_wechat_transfer_bill,
|
||
reconcile_shenhe_wechat_bills,
|
||
reject_audit_and_refund,
|
||
release_collect_quota_for_record,
|
||
reserve_collect_quota_limits,
|
||
resolve_collect_context,
|
||
shijidaozhang_exceeds_wechat_limit,
|
||
validate_collect_eligibility,
|
||
wechat_limit_collect_message,
|
||
_sync_record_from_wx_query,
|
||
)
|
||
|
||
logger = logging.getLogger('yonghu.tixian_shenhe')
|
||
|
||
|
||
def generate_tixian_id():
|
||
"""生成唯一微信商户打款单号:TX + 时间戳(13位) + 4位随机数"""
|
||
timestamp = str(int(time.time() * 1000))
|
||
rand = ''.join(random.choices(string.digits, k=4))
|
||
return f'TX{timestamp}{rand}'
|
||
|
||
|
||
def _build_collect_success_response(payload):
|
||
return Response({
|
||
'code': 0,
|
||
'msg': '请确认收款',
|
||
'data': payload,
|
||
})
|
||
|
||
|
||
def _is_wx_balance_insufficient(err_code='', err_msg=''):
|
||
blob = f'{err_code} {err_msg}'.upper()
|
||
return any(k in blob for k in ('NOT_ENOUGH', 'INSUFFICIENT', '余额不足', '资金不足'))
|
||
|
||
|
||
def _extract_wx_err(wx_result):
|
||
"""解析微信错误码/文案(兼容 detail 为对象)。"""
|
||
err_code = str((wx_result or {}).get('code') or '')
|
||
raw = (wx_result or {}).get('message')
|
||
if raw is None:
|
||
raw = (wx_result or {}).get('detail')
|
||
if isinstance(raw, dict):
|
||
raw = raw.get('message') or raw.get('detail') or json.dumps(raw, ensure_ascii=False)
|
||
err_msg = str(raw or '微信接口调用失败')
|
||
return err_code, err_msg
|
||
|
||
|
||
def _is_wx_unsafe_to_switch(err_code='', err_msg='', status_code=None):
|
||
"""
|
||
结果可能已受理:换号有双笔风险,禁止立刻换商户,只能查单/待确认。
|
||
"""
|
||
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', 'ALREADY_EXISTS',
|
||
'频率超限', '系统错误', '结果不明确', '结果未明确', '订单已存在',
|
||
)
|
||
return any(k in blob for k in keys)
|
||
|
||
|
||
def _is_wx_ip_denied(err_code='', err_msg=''):
|
||
"""商户未配置服务器出口 IP 白名单(创建/查单都会拦)。"""
|
||
blob = f'{err_code} {err_msg}'.upper().replace('\t', '')
|
||
if '268491067' in blob:
|
||
return True
|
||
if 'IP' in blob and '不允许' in blob:
|
||
return True
|
||
return any(k in blob for k in (
|
||
'IP地址不允许', 'IP 地址不允许', '不允许调用该接口', '不允许调用接口',
|
||
'NOT_ALLOW_IP', 'IP_DENIED',
|
||
))
|
||
|
||
|
||
def _is_wx_clear_switchable_fail(err_code='', err_msg='', status_code=None):
|
||
"""
|
||
明确未建单的业务失败:可静默换下一商户(日额度/余额不足/IP白名单/无权限等)。
|
||
"""
|
||
if _is_wx_ip_denied(err_code, err_msg):
|
||
return True
|
||
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)
|
||
|
||
|
||
def _verify_collect_quota_or_response(user_main, audit, shenhe_danhao):
|
||
"""收款限额校验;不通过时直接返回 Response"""
|
||
ok, msg, limit_kind = check_collect_quota_limits(
|
||
user_main,
|
||
audit.leixing,
|
||
audit.shenqing_jine,
|
||
audit.shijidaozhang,
|
||
shenhe_danhao,
|
||
)
|
||
if not ok:
|
||
limit_code = 33 if limit_kind == 'platform' else 400
|
||
logger.warning(
|
||
'收款限额校验未通过 yonghuid=%s shenhe_danhao=%s leixing=%s kind=%s msg=%s',
|
||
user_main.yonghuid, shenhe_danhao, audit.leixing, limit_kind, msg,
|
||
)
|
||
return Response({'code': limit_code, 'msg': msg})
|
||
return None
|
||
|
||
|
||
def _mark_auto_record_failed(tixian_id, fail_reason):
|
||
"""微信明确失败时标记打款记录终态,避免后续一直被当成「处理中」"""
|
||
TixianAutoRecord.objects.filter(tixian_id=tixian_id, zhuangtai=0).update(
|
||
zhuangtai=2,
|
||
fail_reason=(fail_reason or '打款失败')[:500],
|
||
update_time=timezone.now(),
|
||
)
|
||
|
||
|
||
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: '商家'}
|
||
desc_map = {1: '佣金提现', 2: '分红提现', 3: '分红提现', 4: '分佣提现', 5: '押金提现', 6: '余额提现'}
|
||
transfer_scene_report_infos = []
|
||
if transfer_scene_id == '1005':
|
||
transfer_scene_report_infos = [
|
||
{'info_type': '岗位类型', 'info_content': role_map.get(leixing, '用户')},
|
||
{'info_type': '报酬说明', 'info_content': desc_map.get(leixing, '提现')},
|
||
]
|
||
elif transfer_scene_id == '1009':
|
||
transfer_scene_report_infos = [
|
||
{'info_type': '采购商品名称', 'info_content': '平台服务'},
|
||
]
|
||
body = {
|
||
'appid': wx_cfg['APPID'],
|
||
'out_bill_no': tixian_id,
|
||
'transfer_scene_id': transfer_scene_id,
|
||
'openid': openid,
|
||
'transfer_amount': int(shijidaozhang * 100),
|
||
'transfer_remark': f'兄弟们 收款前记得录屏发快手',
|
||
'transfer_scene_report_infos': transfer_scene_report_infos,
|
||
}
|
||
notify_url = str(wx_cfg.get('NOTIFY_URL') or '').strip()
|
||
if notify_url:
|
||
body['notify_url'] = notify_url
|
||
return body
|
||
|
||
|
||
def _call_wechat_transfer(body, wx_cfg):
|
||
from utils.wechat_v3 import build_authorization
|
||
|
||
url = 'https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills'
|
||
body_json = json.dumps(body, separators=(',', ':'))
|
||
auth = build_authorization('POST', url, body_json, wx_cfg=wx_cfg)
|
||
headers = {
|
||
'Authorization': auth,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
'User-Agent': 'Mozilla/5.0',
|
||
}
|
||
resp = requests.post(url, data=body_json, headers=headers, timeout=15)
|
||
try:
|
||
wx_result = resp.json()
|
||
except ValueError:
|
||
wx_result = {}
|
||
return resp, wx_result
|
||
|
||
|
||
class TixianZddkshApplyView(APIView):
|
||
"""POST /yonghu/zddksh 提交审核申请(扣款,不创建 TixianAutoRecord)"""
|
||
permission_classes = [permissions.IsAuthenticated]
|
||
|
||
def post(self, request):
|
||
from utils.redis_lock import acquire_lock, release_lock
|
||
|
||
user_main = request.user
|
||
lock_key = f'tixian_audit_apply:{user_main.yonghuid}'
|
||
if not acquire_lock(lock_key, timeout=10):
|
||
return Response({'code': 429, 'msg': '操作频繁,请稍后重试'})
|
||
|
||
try:
|
||
leixing = request.data.get('leixing')
|
||
jine_str = request.data.get('jine')
|
||
if leixing is None or jine_str is None:
|
||
return Response({'code': 1, 'msg': '缺少参数: leixing 或 jine'})
|
||
|
||
try:
|
||
leixing = int(leixing)
|
||
jine = decimal.Decimal(str(jine_str))
|
||
except (ValueError, decimal.InvalidOperation):
|
||
return Response({'code': 2, 'msg': '参数格式错误'})
|
||
|
||
if leixing not in [1, 2, 3, 4, 5, 6]:
|
||
return Response({'code': 3, 'msg': '提现类型无效'})
|
||
if jine <= decimal.Decimal('0.1'):
|
||
return Response({'code': 4, 'msg': '提现金额必须大于0.1'})
|
||
|
||
try:
|
||
data = create_audit_application(user_main, leixing, jine)
|
||
except ValueError as e:
|
||
return Response({'code': 400, 'msg': str(e)})
|
||
except Exception as e:
|
||
logger.error(f'提现审核申请异常: {e}', exc_info=True)
|
||
return Response({'code': 99, 'msg': '提现申请失败,请稍后重试'})
|
||
|
||
if data.get('freeze_only'):
|
||
return Response({
|
||
'code': 0,
|
||
'msg': data.get('msg') or '资金已划入冻结池',
|
||
'data': data,
|
||
})
|
||
|
||
return Response({'code': 0, 'msg': '提现申请已提交,请等待审核', 'data': data})
|
||
|
||
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.warning(
|
||
'启用且配置完整的转账商户仅 %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
|
||
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
|
||
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
|
||
|
||
for mch_idx, wx_cfg in enumerate(mch_cfgs):
|
||
tixian_id = generate_tixian_id()
|
||
mch_id = wx_cfg['MCHID']
|
||
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'])
|
||
|
||
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')
|
||
),
|
||
)
|
||
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 "未知"}'
|
||
logger.error(
|
||
'微信发起转账未进入待确认: bill=%s mch=%s code=%s msg=%s HTTP%s state=%s body=%s',
|
||
tixian_id, mch_id, err_code, err_msg, resp.status_code, state200,
|
||
json.dumps(wx_result, ensure_ascii=False)[:500],
|
||
)
|
||
|
||
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 白名单/明确 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': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击',
|
||
})
|
||
|
||
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}')
|
||
|
||
# 有下一户:静默换号,绝不把中间失败文案回前端
|
||
if has_next:
|
||
logger.warning(
|
||
'商户转账失败,静默切换下一商户(不回前端) shenhe=%s from=%s '
|
||
'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),
|
||
)
|
||
quota_reserved = False
|
||
if last_was_balance:
|
||
return Response({
|
||
'code': 99,
|
||
'msg': '运营账户资金不足,需等管理员充值后才能提现',
|
||
})
|
||
return Response({'code': 99, 'msg': user_msg})
|
||
|
||
if quota_reserved and tixian_id:
|
||
release_collect_quota_for_record(
|
||
TixianAutoRecord.objects.get(tixian_id=tixian_id),
|
||
)
|
||
quota_reserved = False
|
||
if last_was_balance:
|
||
return Response({
|
||
'code': 99,
|
||
'msg': '运营账户资金不足,需等管理员充值后才能提现',
|
||
})
|
||
return Response({'code': 99, 'msg': last_user_msg})
|
||
|
||
finally:
|
||
release_lock(lock_key)
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
'收款接口未捕获异常 yonghuid=%s tixianjilu_id=%s shenhe_danhao=%s err=%s',
|
||
yonghuid, tixianjilu_id, shenhe_danhao, e,
|
||
exc_info=True,
|
||
)
|
||
return Response({'code': 99, 'msg': '系统繁忙,请稍后重试'})
|