import hmac import threading import traceback import uuid import hashlib import xmltodict import time import logging import requests import os import secrets import random import string from calendar import monthrange from decimal import Decimal from datetime import date, datetime, timedelta from django.utils import timezone from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator from django.db import models, transaction, IntegrityError from django.db.models import Q, Count, Sum, F, Exists, OuterRef from gvsdsdk.fluent import db, func, FQ from django.db.models.functions import TruncDate, TruncMonth, TruncYear from django.contrib.auth.hashers import make_password from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.parsers import JSONParser, MultiPartParser, FormParser # 工具类导入 from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss from backend.utils import ( update_dashou_daily_by_action, update_shangjia_daily ) from jituan.services.club_context import filter_club_char_field, filter_queryset_by_club, filter_user_related_by_club, filter_user_qs_by_club, orders_for_request from jituan.services.catalog_scope import block_non_group_catalog_scope from jituan.services.club_penalty import ( filter_penalty_qs, resolve_penalty_club_id, filter_gsfenhong_qs, forbid_penalty_out_of_scope, resolve_penalty_record_club_id, ) from jituan.services.club_user_access import forbid_if_user_out_of_scope, list_response_meta from shop.utils import update_dianpu_daily_stat from products.utils import update_shangpin_daily_stat from orders.notice_tasks import dingdan_guangbo from ..utils import ( verify_kefu_permission, PERMISSION_TO_SHENFEN, SHENFEN_PROFILE_MAP, has_fadan_view_permission, has_merchant_order_permission, check_fadan_permission, pick_order_id, pick_penalty_create_params, write_xiugai_log, XIUGAI_LEIXING_DASHOU, XIUGAI_LEIXING_GUANSHI, XIUGAI_LEIXING_SHANGJIA, XIUGAI_LEIXING_ZUZHANG, XIUGAI_LEIXING_LABEL, ) # models 集中导入 ## gvsdsdk RBAC 模型(使用 PascalCase 字段名,匹配实际数据库表结构) from gvsdsdk.models import Role, Permission, RolePermission, UserRole ## backend from backend.models import WithdrawalDailyStats ## users from users.models import ( UserGuanshi, UserBoss, UserZuzhang, UserKefu, UserDashou, Xiugaijilu, UserShangjia, UserShenheguan ) from users.business_models import User ## orders from orders.models import ( CommissionRate, PlayerDeliveryImage, PenaltyRecord, OrderPlayerHistory, Order, RefundRecord, MerchantOrderExt, Penalty, PenaltyAppealImage, PenaltyBonus ) ## shop from shop.models import Dianpu, DianpuMorenPeizhi, YonghuPingzheng, ShangpinLeixingDianpu, DianpuShangpinShenheShezhi ## products from products.models import ( Shangpin, ShangpinLeixing, ShangpinZhuanqu, Huiyuan, DuociFenhong, Czjilu, Huiyuangoumai, Gsfenhong ) ## config from config.models import ( WithdrawConfig, ShangjiaLianjie, AccountPermission, TixianQuotaDefault, DailyIncomeStat, DailyPayoutStat, PopupPage, PopupConfig, PopupImage ) ## rank from rank.models import ( KaoheguanBankuai, Bankuai, Chenghao, KaoheCishuFeiyong, YonghuChenghao, ShenheJilu, KaoheJiluFeiyong, KaoheJujueJilu ) # 序列化器 from ..serializers import PopupPageSerializer # 全局常量、日志对象 logger = logging.getLogger('houtai') SHOP_PRODUCT_PERMS = ['1199ab', '1199abc', '1199abd'] class ZxkfghdsView(APIView): """ 客服更换打手接口 路径:POST /houtai/zxkfghds 功能: 1. 将当前订单状态改为已退款(5),释放接单打手(若有) 2. 如需跨平台通知,则调用对方平台退款通知接口 3. 基于原订单信息生成全新订单(状态直接为 1,已替老板填写) 4. 生成新的派单链接,并标记为已使用 5. 新链接记录上一个链接ID和上一个订单ID 6. 【关键】不操作商家余额、退款次数、每日统计,因新订单是替换而非新增扣款 权限:002ac(商家订单管理)/ 002ab(平台订单管理)/ 003aa(跨平台管理) """ permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): # ========== 1. 参数提取 ========== username = request.data.get('username', '').strip() or request.data.get('phone', '').strip() OrderID = pick_order_id(request.data) if not username or not OrderID: logger.warning("更换打手参数不完整") return Response({'code': 400, 'msg': '参数不完整'}) # ========== 2. 权限校验(商家链接页客服需 002ac,非仅跨平台 003aa) ========== kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return permissions # 返回 error response if not has_merchant_order_permission(permissions): return Response({'code': 403, 'msg': '您无权处理订单'}) current_user = request.user # ========== 3. 查询原订单及关联信息 ========== try: old_order = Order.query.select_related('shangjia_kuozhan').get(OrderID=OrderID) except Order.DoesNotExist: logger.warning(f"订单不存在: {OrderID}") return Response({'code': 404, 'msg': '订单不存在'}) # 仅允许状态为 2(进行中)或 8(结算中)更换打手 if old_order.Status not in [1,7,2, 8]: return Response({'code': 400, 'msg': '当前订单状态不允许更换打手'}) # 本接口仅处理商家发单(Platform=2) if old_order.Platform != 2: return Response({'code': 400, 'msg': '仅支持商家发单的更换操作'}) # 获取原链接(用于模板ID等) try: old_lianjie = ShangjiaLianjie.query.get(OrderID=OrderID) except ShangjiaLianjie.DoesNotExist: logger.error(f"原链接记录不存在,订单: {OrderID}") return Response({'code': 500, 'msg': '链接数据异常'}) # 商家信息(仅用于记录关联,不操作余额) try: shangjia_ext = old_order.shangjia_kuozhan shangjia_id = shangjia_ext.MerchantID # 获取商家昵称用于新订单扩展表 shangjia_user = User.query.get(UserUID=shangjia_id) shangjia_nicheng = shangjia_user.ShopProfile.nicheng or f"商家{shangjia_id}" except (ObjectDoesNotExist, UserShangjia.DoesNotExist): logger.error(f"商家信息缺失,订单: {OrderID}") return Response({'code': 500, 'msg': '商家数据异常'}) # ========== 4. 退款处理(仅打手侧,不碰商家) ========== with transaction.atomic(): # 5.1 订单状态改为已退款 old_order.Status = 5 old_order.AssignedCS = current_user.Phone old_order.save() # 5.2 处理接单打手(若有) dashou_id = old_order.PlayerID if dashou_id: try: dashou_user = User.query.get(UserUID=dashou_id) dashou_profile = dashou_user.DashouProfile dashou_profile.tuikuanliang += 1 # 退款次数+1 dashou_profile.zhuangtai = 1 # 设为空闲 dashou_profile.save() except (User.DoesNotExist, ObjectDoesNotExist): logger.warning(f"打手 {dashou_id} 不存在,跳过更新") # 5.3 更新退款记录表 RefundRecord.query.update_or_create( OrderID=OrderID, defaults={ 'PlayerID': dashou_id or '', 'ApplicantID': '', 'ProcessorID': current_user.Phone, 'Reason': '客服更换打手退款', 'ApplyStatus': 1, 'Amount': old_order.Amount or 0, 'PlayerCommission': old_order.PlayerCommission or 0, 'Description': old_order.Description or '', 'Remark': '更换打手自动退款', 'Nickname': old_order.Nickname or '', } ) # 5.4 打手每日统计(退款) if dashou_id: try: update_dashou_daily_by_action( yonghuid=dashou_id, amount=old_order.PlayerCommission, action=3 # 3=退款 ) except Exception as e: logger.error(f"打手每日统计更新失败: {e}") # 5.5 客服处理统计(管理员无 KefuProfile,跳过) if hasattr(kefu, 'jinrichuli'): kefu.jinrichuli += 1 kefu.jinyuechuli += 1 kefu.zongchuli += 1 kefu.save() # 至此,原订单已退款,事务提交 # ========== 6. 生成新订单和链接(不扣商家余额) ========== try: with transaction.atomic(): # ---- 6.1 生成新的订单ID和Token ---- def generate_OrderID(): timestamp = int(time.time() * 1000) process_id = os.getpid() % 1000 random_num = random.randint(1000, 9999) return f"SJ{timestamp}{process_id:03d}{random_num}" def generate_secure_token(dd_id, uid): timestamp = int(time.time() * 1_000_000) salt = secrets.token_hex(8) raw = f"{timestamp}:{dd_id}:{uid}:{salt}" token = hashlib.sha256(raw.encode()).hexdigest()[:24] if ShangjiaLianjie.query.filter(LinkToken=token).exists(): return generate_secure_token(dd_id, uid) return token def generate_link_url(token): base = getattr(settings, 'H5_DOMAIN', 'https://h5.yourdomain.com').rstrip('/') return f"{base}/order/{token}" new_OrderID = generate_OrderID() secure_token = generate_secure_token(new_OrderID, shangjia_id) # ---- 6.2 创建新订单(复制核心数据,状态直接为1) ---- new_order = Order.query.create( OrderID=new_OrderID, Status=1, # 下单中 Platform=2, Amount=old_order.Amount, PlayerCommission=old_order.PlayerCommission, ProductTypeID=old_order.ProductTypeID, GrabRequirement=old_order.GrabRequirement, MembershipID=old_order.MembershipID, CommissionReq=old_order.CommissionReq, Description=old_order.Description, Nickname=old_order.Nickname, # 继承游戏昵称 Remark=old_order.Remark, User1ID=old_order.User1ID, # 商家标识 ImageURL=old_order.ImageURL, ExternalOrderID=old_order.ExternalOrderID, ClubID=getattr(old_order, 'ClubID', None) or 'xq', # 注意:不继承 AssignedID、PlayerID ) # ---- 6.3 创建商家扩展表 ---- MerchantOrderExt.query.create( Order=new_order, MerchantID=shangjia_id, MerchantNickname=shangjia_nicheng, ) # ---- 6.4 生成新链接并关联旧链接信息 ---- link_url = generate_link_url(secure_token) new_lianjie = ShangjiaLianjie.query.create( UserID=shangjia_id, ProductTypeID=old_lianjie.ProductTypeID, TemplateID=old_lianjie.TemplateID, LinkURL=link_url, LinkToken=secure_token, OrderID=new_OrderID, is_used=True, # 直接标记已使用 ExpireTime=timezone.now() + timezone.timedelta(days=1), PreviousLinkID=old_lianjie.id, # 记录旧链接ID PreviousOrderID=old_order.OrderID, # 记录旧订单ID ) # ---- 6.5 异步通知(广播新订单、跨平台同步) ---- try: order_info = { 'OrderID': new_OrderID, 'game_type': self._get_game_type_name(new_order.ProductTypeID), 'amount': str(new_order.Amount), 'order_desc': (new_order.Description or '')[:50], } dingdan_guangbo.delay(order_info) except Exception as e: logger.error(f"广播通知失败: {e}") # 注意:此处不更新商家余额、发布量、每日统计,因为这是替换订单 logger.info(f"更换打手成功:旧订单{OrderID}→退款,新订单{new_OrderID}已生成") return Response({ 'code': 0, 'msg': '更换成功', 'data': { 'new_OrderID': new_OrderID, 'new_dingdan_id': new_OrderID, 'new_lianjie': link_url, } }) except Exception as e: logger.error(f"生成新订单事务失败: {str(e)}", exc_info=True) return Response({'code': 500, 'msg': '生成新订单失败'}) def _get_game_type_name(self, ProductTypeID): """根据类型ID获取游戏类型名称""" try: gt = ShangpinLeixing.query.filter(id=ProductTypeID).first() return gt.jieshao if gt else '游戏订单' except Exception: return '游戏订单'