# yonghu/tixian_shenhe_views.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.conf import settings 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 utils.redis_lock import acquire_lock, release_lock from utils.wechat_v3 import build_authorization from gvsdsdk.fluent import db, func, FQ from .models import TixianAutoRecord from .tixian_shenhe_services import ( RECONCILE_ALLOW_NEW, RECONCILE_AUDIT_CLOSED, RECONCILE_COMPLETED, RECONCILE_PENDING, RECONCILE_WAIT_CONFIRM, check_collect_quota_limits, create_audit_application, close_audit_over_limit_refund, close_audit_wechat_failed, get_audit_for_collect, handle_post_transfer_failure, reconcile_shenhe_wechat_bills, release_collect_quota_for_record, reserve_collect_quota_limits, resolve_collect_context, validate_collect_amount, validate_collect_eligibility, ) 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 _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.UserUID, 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.query.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': '平台服务'}, ] return { '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'平台提现-{yonghuid}', 'transfer_scene_report_infos': transfer_scene_report_infos, } def _call_wechat_transfer(body, wx_cfg): 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) 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): user_main = request.user lock_key = f'tixian_audit_apply:{user_main.UserUID}' identifier = acquire_lock(lock_key, timeout=10) if not identifier: 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': '提现申请失败,请稍后重试'}) return Response({'code': 0, 'msg': '提现申请已提交,请等待审核', 'data': data}) finally: release_lock(lock_key, identifier) def process_audit_collect(request): """ POST /yonghu/tixiansq 用户收款(P0 防多提) 主路径:tixianjilu_id → Tixianjilu → TixianShenheJilu(状态6) 若 TixianAutoRecord 已有 tixian_id → 只问微信,非终态绝不新建 仅微信 FAIL/CANCELLED 终态后才允许新建打款记录 """ user_main = request.user yonghuid = user_main.UserUID 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: if not user_main.OpenID: return Response({'code': 10, 'msg': '用户未绑定微信,无法收款'}) wx_cfg = settings.WECHAT_PAY_V3_CONFIG if not wx_cfg.get('APPID') or not wx_cfg.get('MCHID') or not wx_cfg.get('TRANSFER_SCENE_ID'): logger.error('微信打款配置不完整 APPID=%s MCHID=%s', wx_cfg.get('APPID'), wx_cfg.get('MCHID')) return Response({'code': 11, 'msg': '系统配置异常,请联系客服'}) 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 amount_ok, amount_msg = validate_collect_amount(audit) if not amount_ok: with transaction.atomic(): close_audit_over_limit_refund(audit, None, amount_msg) return Response({ 'code': 400, 'msg': f'{amount_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}' identifier = acquire_lock(lock_key, timeout=15) if not identifier: return Response({'code': 429, 'msg': '操作频繁,请稍后重试'}) tixian_id = None quota_reserved = False try: action, payload = reconcile_shenhe_wechat_bills(shenhe_danhao) if action == RECONCILE_COMPLETED: return Response({'code': 8, 'msg': payload.get('msg', '该提现已完成,请勿重复操作')}) if action == RECONCILE_AUDIT_CLOSED: return Response({ 'code': 400, 'msg': payload.get('msg', '该笔提现已关闭,请重新申请'), }) 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 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 tixian_id = generate_tixian_id() TixianAutoRecord.query.create( tixian_id=tixian_id, yonghuid=yonghuid, leixing=leixing, jine=jine, shouxufei=shouxufei, shijidaozhang=shijidaozhang, feilv=feilv, zhuangtai=0, shenhe_danhao=shenhe_danhao, ) audit.tixian_auto_id = tixian_id audit.save(update_fields=['tixian_auto_id', 'update_time']) 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) if resp.status_code == 200 and wx_result.get('state') == 'WAIT_USER_CONFIRM': package_info = wx_result.get('package_info') auto_record = TixianAutoRecord.query.get(tixian_id=tixian_id) auto_record.wechat_package = json.dumps(package_info, ensure_ascii=False) auto_record.save(update_fields=['wechat_package']) 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': wx_cfg['MCHID'], 'shenhe_danhao': shenhe_danhao, 'tixianjilu_id': tixianjilu_id_val, }) err_code = wx_result.get('code', '') err_msg = wx_result.get('message', wx_result.get('detail', '微信接口调用失败')) logger.error( '微信发起转账未进入待确认: bill=%s code=%s msg=%s HTTP%s', tixian_id, err_code, err_msg, resp.status_code, ) is_balance_issue = _is_wx_balance_insufficient(err_code, err_msg) if is_balance_issue: _mark_auto_record_failed(tixian_id, err_msg) if quota_reserved: release_collect_quota_for_record( TixianAutoRecord.query.get(tixian_id=tixian_id), ) quota_reserved = False return Response({ 'code': 99, 'msg': '运营账户资金不足,需等管理员充值后才能提现', }) post_action, post_payload = handle_post_transfer_failure( tixian_id, shenhe_danhao, err_code, err_msg, ) if post_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(post_payload) if post_action == RECONCILE_COMPLETED: return Response({'code': 8, 'msg': '该提现已完成,请勿重复操作'}) if post_action == RECONCILE_AUDIT_CLOSED: return Response({ 'code': 400, 'msg': post_payload.get('msg', '该笔提现已关闭,请重新申请'), }) if post_action == RECONCILE_PENDING: pending_msg = ( post_payload.get('msg', post_payload) if isinstance(post_payload, dict) else post_payload ) return Response({'code': 12, 'msg': pending_msg or '有收款处理中,请稍后再试'}) user_msg = err_msg or '微信收款发起失败,请稍后重试' _mark_auto_record_failed(tixian_id, user_msg) if quota_reserved: release_collect_quota_for_record( TixianAutoRecord.query.get(tixian_id=tixian_id), ) quota_reserved = False return Response({'code': 99, 'msg': user_msg}) except requests.RequestException as e: logger.error( '微信打款网络异常(保持待查单): bill=%s err=%s', tixian_id, e, exc_info=True, ) return Response({ 'code': 12, 'msg': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击', }) finally: release_lock(lock_key, identifier) 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': '系统繁忙,请稍后重试'})