Files
along_django/yonghu/tixian_shenhe_views.py

1065 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 _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(
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 _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: '商家'}
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.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),
skip_failed=','.join(sorted(failed_mchs)) if failed_mchs else '',
)
last_user_msg = '微信收款发起失败,请稍后重试'
last_was_balance = False
last_ip_denied = False
tixian_id = None
tried_details = []
for mch_idx, wx_cfg in enumerate(mch_cfgs):
tixian_id = generate_tixian_id()
mch_id = wx_cfg['MCHID']
_log_wx_collect(
f'尝试第{mch_idx + 1}/{len(mch_cfgs)}个商户',
shenhe=shenhe_danhao, 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:
_log_wx_collect('网络超时禁换号', mch_id=mch_id, bill=tixian_id, err=str(e))
return Response({
'code': 12,
'msg': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击',
})
except (OSError, IOError, ValueError) as e:
err_code, err_msg = 'CERT_ERROR', f'商户证书/私钥异常: {e}'
last_user_msg = err_msg
tried_details.append({
'mch_id': mch_id, 'http': 0, 'code': err_code, 'msg': err_msg,
})
_log_wx_collect('本地证书异常', mch_id=mch_id, err=str(e))
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {err_msg}')
if mch_idx < len(mch_cfgs) - 1:
_log_wx_collect('证书异常→换下一户', from_mch=mch_id, next_idx=mch_idx + 2)
continue
if quota_reserved:
release_collect_quota_for_record(
TixianAutoRecord.objects.get(tixian_id=tixian_id),
)
quota_reserved = False
return _wx_fail_response(
tried_details, err_msg, single_mch=len(mch_cfgs) < 2,
)
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(
'成功待确认',
shenhe=shenhe_danhao, mch_id=mch_id, bill=tixian_id,
tried_before=','.join(d['mch_id'] for d in tried_details),
)
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)
_log_wx_collect('直接成功到账', mch_id=mch_id, bill=tixian_id)
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 = '微信状态:' + (state200 or '未知')
body_preview = json.dumps(wx_result, ensure_ascii=False)[:800]
_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=body_preview,
)
tried_details.append({
'mch_id': mch_id,
'http': resp.status_code,
'code': err_code,
'msg': 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)
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,
},
})
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)
last_ip_denied = last_ip_denied or ip_denied
_mark_auto_record_failed(tixian_id, f'[{mch_id}] {err_msg}')
if has_next:
_log_wx_collect(
'本商户官方拒绝→静默换下一户',
from_mch=mch_id,
next_idx='%s/%s' % (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
return _wx_fail_response(
tried_details,
user_msg,
single_mch=len(mch_cfgs) < 2,
ip_denied=last_ip_denied,
no_balance=last_was_balance,
)
if quota_reserved and tixian_id:
release_collect_quota_for_record(
TixianAutoRecord.objects.get(tixian_id=tixian_id),
)
quota_reserved = False
return _wx_fail_response(
tried_details,
last_user_msg,
single_mch=len(mch_cfgs) < 2,
ip_denied=last_ip_denied,
no_balance=last_was_balance,
)
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),
skip_failed=','.join(sorted(failed_mchs)) if failed_mchs else '',
)
last_user_msg = '微信收款发起失败,请稍后重试'
last_was_balance = False
last_ip_denied = False
tixian_id = None
tried_details = []
for mch_idx, wx_cfg in enumerate(mch_cfgs):
tixian_id = generate_tixian_id()
mch_id = wx_cfg['MCHID']
_log_wx_collect(
f'尝试第{mch_idx + 1}/{len(mch_cfgs)}个商户',
shenhe=shenhe_danhao, 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)
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 "未知"}'
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
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 ('上限' in err_msg)
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:
logger.warning(
'商户不可用,静默换下一户 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,
skip_query, 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
tried = ''.join(tried_mchs) or mch_id
if len(mch_cfgs) < 2:
return Response({
'code': 99,
'msg': (
f'仅1个可用打款商户({tried}),无法轮换。'
f'失败原因:{user_msg}。请在后台再完善并启用第二个商户(私钥须存在)。'
),
})
if ip_denied:
return Response({
'code': 99,
'msg': f'已尝试商户 {tried} 均失败含IP白名单请检查各商户配置',
})
if last_was_balance:
return Response({
'code': 99,
'msg': f'已尝试商户 {tried},运营账户资金不足,需等管理员充值后才能提现',
})
return Response({
'code': 99,
'msg': f'已尝试商户 {tried} 均失败:{user_msg}',
})
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 ''
if last_was_balance:
return Response({
'code': 99,
'msg': (
f'已尝试商户 {tried},运营账户资金不足,需等管理员充值后才能提现'
if tried else '运营账户资金不足,需等管理员充值后才能提现'
),
})
return Response({
'code': 99,
'msg': (
f'已尝试商户 {tried} 均失败:{last_user_msg}'
if tried else 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': '系统繁忙,请稍后重试'})