# houtai/utils.py import logging import json from functools import lru_cache from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from rest_framework.response import Response from .models import AbnormalUserLog, PlayerDailyStats, MerchantDailyStats, \ ManagerRenewalDailyStats, ManagerDailyStats, LeaderDailyStats, WithdrawalDailyStats from gvsdsdk.models import UserRole as SdkUserRole, RolePermission as SdkRolePermission, Permission as SdkPermission from django.db import transaction from django.db.models import F from datetime import date from decimal import Decimal from gvsdsdk.fluent import db, func, FQ logger = logging.getLogger(__name__) def _club_id_for_yonghuid(yonghuid, club_id=None): from jituan.constants import CLUB_ID_DEFAULT from jituan.services.club_user import get_user_club_id from users.business_models import User if club_id: return club_id u = User.query.filter(UserUID=yonghuid).first() return get_user_club_id(u) if u else CLUB_ID_DEFAULT def _get_real_ip(request): """获取真实IP(考虑代理)""" x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: ip = x_forwarded_for.split(',')[0].strip() return ip, x_forwarded_for else: ip = request.META.get('REMOTE_ADDR') return ip, '' def _record_abnormal(account_id, request, attempt_type, detail=''): """记录异常操作(仅用于越权等攻击行为)""" try: real_ip, xff = _get_real_ip(request) headers = {k: v for k, v in request.META.items() if k.startswith('HTTP_')} headers_json = json.dumps(headers, ensure_ascii=False)[:2000] request_params = json.dumps(request.query_params.dict(), ensure_ascii=False) if request.query_params else '' request_body = request.body.decode('utf-8', errors='ignore')[:5000] AbnormalUserLog.query.create( account_id=account_id, ip_address=real_ip, real_ip=real_ip, x_forwarded_for=xff, user_agent=request.META.get('HTTP_USER_AGENT', ''), request_method=request.method, request_path=request.path, request_params=request_params, request_body=request_body, request_headers=headers_json, attempt_type=attempt_type, detail=detail, ) logger.warning(f"记录异常用户: {account_id} IP:{real_ip} 类型:{attempt_type}") except Exception as e: logger.error(f"记录异常日志失败: {e}") class _AllPermissions(list): """拥有 000001 超级管理权限时使用,使任何权限码 in 检查都返回 True""" def __contains__(self, item): return True def verify_kefu_permission(request, username_from_frontend=None): """ 验证客服/管理员身份,并返回该用户的所有权限列表(基于新RBAC表) 只有前端传递的username与当前登录用户不匹配时,才记录异常并警告前端。 参数: request: DRF request 对象 username_from_frontend: 前端传递的账号(用于防越权) 返回: (kefu_obj, permissions_list) # 验证通过 (None, Response) # 验证失败,直接返回Response """ current_user = request.user # ---------- 1. 必须是客服或管理员 ---------- from jituan.constants import SUPER_ADMIN_PHONES is_super_phone = getattr(current_user, 'Phone', '') in SUPER_ADMIN_PHONES is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser or is_super_phone if current_user.UserType != 'kefu' and not is_admin: return None, Response({'code': 403, 'msg': '身份错误,非客服账号'}, status=403) # ---------- 2. 客服扩展表存在且状态正常(管理员跳过) ---------- kefu = None if not is_admin: try: kefu = current_user.KefuProfile if kefu.zhuangtai != 1: return None, Response({'code': 403, 'msg': '客服账号已被禁用'}, status=403) except ObjectDoesNotExist: return None, Response({'code': 403, 'msg': '客服账号不存在'}, status=403) else: # 管理员无需客服扩展表,用占位对象以通过 kefu_obj is None 检查 from types import SimpleNamespace kefu = SimpleNamespace(user=current_user) # ---------- 3. 越权检测(只记录前端篡改username的情况) ---------- if username_from_frontend and current_user.Phone != username_from_frontend: detail = f'前端username: {username_from_frontend},实际登录账号: {current_user.Phone}' _record_abnormal(current_user.Phone, request, '越权尝试篡改username', detail=detail) return None, Response({ 'code': 403, 'msg': '身份错误,无权操作其他账号,相关操作已记录' }, status=403) # ---------- 4. 获取用户的所有权限 ---------- # 使用 gvsdsdk RBAC 表(UserRole → RolePermission → Permission) try: user_uuid = current_user.UserUUID role_uuids = list(SdkUserRole.objects.filter( UserUUID=user_uuid ).values_list('RoleUUID', flat=True)) if role_uuids: perm_uuids = list(SdkRolePermission.objects.filter( RoleUUID__in=role_uuids ).values_list('PermUUID', flat=True)) permissions = list(SdkPermission.objects.filter( PermUUID__in=perm_uuids, PermStatus=1 ).values_list('PermCode', flat=True).distinct()) else: permissions = [] except Exception: permissions = [] # 管理员/超级用户自动拥有所有权限 if is_admin and '000001' not in permissions: permissions.insert(0, '000001') # 拥有 000001(超级管理权限)的用户自动获得所有功能权限 # 使用 _AllPermissions 包装,使任何权限码检查都自动通过 if '000001' in permissions: permissions = _AllPermissions(permissions) if not permissions: logger.warning(f"用户 {current_user.Phone} 没有任何权限,请检查角色分配") return kefu, permissions def update_dashou_daily_by_action(yonghuid, amount, action, club_id=None): """ 根据行为类型更新打手每日统计(接单、成交、退款) """ amount = Decimal(str(amount)) if amount else Decimal('0.00') if amount <= 0: return cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): stat, created = PlayerDailyStats.objects.select_for_update().get_or_create( club_id=cid, PlayerID=yonghuid, Date=today, defaults={ 'club_id': cid, 'PlayerID': yonghuid, 'Date': today, 'AcceptedOrderTotal': 0, 'AcceptedAmount': Decimal('0.00'), 'CompletedOrderTotal': 0, 'CompletedAmount': Decimal('0.00'), 'RefundCount': 0, 'RefundAmount': Decimal('0.00'), } ) update_fields = {} if action == 1: # 接单 update_fields['AcceptedOrderTotal'] = F('AcceptedOrderTotal') + 1 update_fields['AcceptedAmount'] = F('AcceptedAmount') + amount elif action == 2: # 成交 update_fields['CompletedOrderTotal'] = F('CompletedOrderTotal') + 1 update_fields['CompletedAmount'] = F('CompletedAmount') + amount elif action == 3: # 退款 update_fields['RefundCount'] = F('RefundCount') + 1 update_fields['RefundAmount'] = F('RefundAmount') + amount else: return # 无效行为 if update_fields: PlayerDailyStats.query.filter(id=stat.id).update(**update_fields) def update_shangjia_daily(yonghuid, amount, action, order_id=None, club_id=None): """ 根据操作行为更新商家每日统计(派发、结算、退款) """ amount = Decimal(str(amount)) if amount else Decimal('0.00') if amount <= 0: return cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): stat, created = MerchantDailyStats.objects.select_for_update().get_or_create( club_id=cid, MerchantID=yonghuid, Date=today, defaults={ 'club_id': cid, 'MerchantID': yonghuid, 'Date': today, 'AssignedOrderCount': 0, 'AssignedAmount': Decimal('0.00'), 'SettledOrderCount': 0, 'SettledAmount': Decimal('0.00'), 'RefundOrderCount': 0, 'RefundAmount': Decimal('0.00'), } ) # 构建本次更新的字段,使用 F 表达式进行原子累加 update_fields = {} if action == 1: # 派发 update_fields['AssignedOrderCount'] = F('AssignedOrderCount') + 1 update_fields['AssignedAmount'] = F('AssignedAmount') + amount elif action == 2: # 结算(成交) update_fields['SettledOrderCount'] = F('SettledOrderCount') + 1 update_fields['SettledAmount'] = F('SettledAmount') + amount elif action == 3: # 退款 update_fields['RefundOrderCount'] = F('RefundOrderCount') + 1 update_fields['RefundAmount'] = F('RefundAmount') + amount else: # 无效的行为类型,直接返回 return # 执行原子更新 MerchantDailyStats.query.filter(id=stat.id).update(**update_fields) if order_id: try: from merchant_ops.services.stats import sync_staff_stats_by_order_id sync_staff_stats_by_order_id(order_id, amount, action) except Exception: logger.warning('同步客服日统计失败 order_id=%s', order_id, exc_info=True) def update_guanshi_daily_by_action(yonghuid, action, amount=Decimal('0.00'), club_id=None): """ 根据操作行为更新管事每日统计(邀请打手、充值会员、订单/押金分红) """ cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): stat, created = ManagerDailyStats.objects.select_for_update().get_or_create( club_id=cid, ManagerID=yonghuid, Date=today, defaults={ 'club_id': cid, 'ManagerID': yonghuid, 'Date': today, 'InvitedPlayerCount': 0, 'RechargedPlayerCount': 0, 'TotalIncome': Decimal('0.00'), } ) if action == 1: # 邀请打手:无论新创建还是已存在,直接 +1 if created: stat.InvitedPlayerCount = 1 stat.save() else: ManagerDailyStats.query.filter(id=stat.id).update( InvitedPlayerCount=F('InvitedPlayerCount') + 1 ) elif action == 2: amount = Decimal(str(amount)) if amount else Decimal('0.00') if amount <= 0: return if created: stat.RechargedPlayerCount = 1 stat.TotalIncome = amount stat.save() else: ManagerDailyStats.query.filter(id=stat.id).update( RechargedPlayerCount=F('RechargedPlayerCount') + 1, TotalIncome=F('TotalIncome') + amount ) elif action in (3, 4): amount = Decimal(str(amount)) if amount else Decimal('0.00') if amount <= 0: return if created: stat.TotalIncome = amount stat.save() else: ManagerDailyStats.query.filter(id=stat.id).update( TotalIncome=F('TotalIncome') + amount ) def update_guanshi_xufei_daily(yonghuid, xufei_jine=Decimal('0.00'), club_id=None): """管事续费统计(单独表)""" cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): stat, created = ManagerRenewalDailyStats.objects.select_for_update().get_or_create( club_id=cid, ManagerID=yonghuid, Date=today, defaults={ 'club_id': cid, 'ManagerID': yonghuid, 'Date': today, 'RenewalTotal': 1, 'RenewalRevenue': xufei_jine, } ) if not created: ManagerRenewalDailyStats.query.filter(id=stat.id).update( RenewalTotal=F('RenewalTotal') + 1, RenewalRevenue=F('RenewalRevenue') + xufei_jine ) def update_zuzhang_daily_by_action(yonghuid, action, amount=Decimal('0.00'), club_id=None): """根据操作行为更新组长每日统计""" cid = _club_id_for_yonghuid(yonghuid, club_id) today = date.today() with transaction.atomic(): stat, created = LeaderDailyStats.objects.select_for_update().get_or_create( club_id=cid, LeaderID=yonghuid, Date=today, defaults={ 'club_id': cid, 'LeaderID': yonghuid, 'Date': today, 'InvitedManagerCount': 0, 'TotalIncome': Decimal('0.00'), 'CommissionAmount': Decimal('0.00'), } ) if created: # 新记录直接设置初始值 if action == 1: stat.InvitedManagerCount = 1 elif action == 2: amount = Decimal(str(amount)) if amount else Decimal('0.00') stat.TotalIncome = amount stat.CommissionAmount = amount stat.save() else: # 已存在记录,使用 F 表达式原子累加 if action == 1: LeaderDailyStats.query.filter(id=stat.id).update( InvitedManagerCount=F('InvitedManagerCount') + 1 ) elif action == 2: amount = Decimal(str(amount)) if amount else Decimal('0.00') if amount > 0: LeaderDailyStats.query.filter(id=stat.id).update( TotalIncome=F('TotalIncome') + amount, CommissionAmount=F('CommissionAmount') + amount ) def update_tixian_daily_stat(leixing, amount, club_id=None): """ 更新每日提现统计(平台维度,按俱乐部) leixing: 1=打手, 2=管事, 3=组长 amount: 本次提现金额(申请金额,含手续费) """ from jituan.constants import CLUB_ID_DEFAULT from jituan.services.withdraw_config import get_or_create_platform_daily_stat club_id = (club_id or '').strip() or CLUB_ID_DEFAULT today = date.today() with transaction.atomic(): stat = get_or_create_platform_daily_stat(club_id, leixing, today) WithdrawalDailyStats.query.filter(id=stat.id).update( total_amount=F('total_amount') + amount ) # 罚款权限(与 permission 表一致) # 66693a/b/c + 66694c:按身份管理罚款;99933abs:罚款申请(通用,可查看列表并操作各身份) _DEFAULT_FADAN_PERM_TO_SHENFEN = { '66693a': 1, # 罚款管理-打手 '66693c': 2, # 罚款管理-管事 '66693b': 3, # 罚款管理-商家 '66694c': 4, # 罚款管理-组长 } _FADAN_GENERIC_PERMS = ( '99933abs', # 罚款申请权限 ) _SHENFEN_KEYWORDS = { '打手': 1, '管事': 2, '商家': 3, '组长': 4, } SHENFEN_PROFILE_MAP = { 1: 'DashouProfile', 2: 'GuanshiProfile', 3: 'ShopProfile', 4: 'ZuzhangProfile', } @lru_cache(maxsize=1) def _get_fadan_permission_config(): """ 加载罚款相关权限码(硬编码 + permission 表按 perm_name 补充)。 仅匹配 perm_name 含「罚款/罚单」,避免 qtczcaiwu 等描述里带「罚款」的财务权限误入。 """ perm_map = dict(_DEFAULT_FADAN_PERM_TO_SHENFEN) generic_codes = list(_FADAN_GENERIC_PERMS) try: rows = Permission.query.filter( Q(perm_name__icontains='罚款') | Q(perm_name__icontains='罚单') ).values('perm_code', 'perm_name') for row in rows: code = row['perm_code'] name = row['perm_name'] or '' matched_sf = None for kw, sf in _SHENFEN_KEYWORDS.items(): if kw in name: matched_sf = sf break if matched_sf is not None: perm_map[code] = matched_sf elif code not in generic_codes: generic_codes.append(code) except Exception as e: logger.warning(f"从 permission 表加载罚款权限失败,使用硬编码映射: {e}") return perm_map, tuple(generic_codes) # 兼容旧引用 PERMISSION_TO_SHENFEN = _DEFAULT_FADAN_PERM_TO_SHENFEN def has_fadan_view_permission(permissions, account_id=''): """列表/统计:拥有任一罚款权限或超级管理员权限即可查看。""" if '000001' in permissions: return True perm_map, generic_codes = _get_fadan_permission_config() all_codes = set(perm_map.keys()) | set(generic_codes) if any(p in permissions for p in all_codes): return True logger.warning( f"罚款查看权限不足 account={account_id} " f"user_perms={permissions} need_any={sorted(all_codes)}" ) return False def check_fadan_permission(permissions, shenfen): """创建/处理:需拥有对应身份的罚款权限,或通用罚款权限,或超级管理员权限。""" if '000001' in permissions: return True if any(p in permissions for p in _get_fadan_permission_config()[1]): return True shenfen = int(shenfen) perm_map, _ = _get_fadan_permission_config() for perm, sf in perm_map.items(): if sf == shenfen and perm in permissions: return True return False def fmt_datetime(dt): """格式化时间为前端通用字符串。""" if not dt: return '' return dt.strftime('%Y-%m-%d %H:%M:%S') def datetime_aliases(dt): """同时返回 CreateTime / create_time,避免前后端字段名不一致。""" s = fmt_datetime(dt) return {'CreateTime': s, 'create_time': s} def pick_order_id(data): """兼容前端 dingdan_id 与 SDK 字段 OrderID。""" return str(data.get('OrderID') or data.get('dingdan_id') or '').strip() def pick_leixing_id(data): """兼容前端 leixing_id 与 SDK 字段 ProductTypeID。""" val = data.get('leixing_id') if val is None or val == '': val = data.get('ProductTypeID') if val is None or val == '': return None try: return int(val) except (ValueError, TypeError): return None def pick_penalty_create_params(data): """兼容罚单创建:SDK 字段名与旧版拼音字段名。""" penalized_id = str(data.get('PenalizedUserID') or data.get('beichufa_id') or '').strip() reason = str(data.get('Reason') or data.get('chufaliyou') or '').strip() fine_amount = data.get('FineAmount') if fine_amount is None: fine_amount = data.get('fakuanjine') affects = data.get('AffectsGrabbing') if affects is None: affects = data.get('yingxiang_qiangdan', 1) return penalized_id, reason, fine_amount, affects # ---------- 修改记录(Xiugaijilu) ---------- XIUGAI_LEIXING_DASHOU = 2 XIUGAI_LEIXING_GUANSHI = 3 XIUGAI_LEIXING_SHANGJIA = 4 XIUGAI_LEIXING_ZUZHANG = 5 XIUGAI_LEIXING_LABEL = { XIUGAI_LEIXING_DASHOU: '打手', XIUGAI_LEIXING_GUANSHI: '管事', XIUGAI_LEIXING_SHANGJIA: '商家', XIUGAI_LEIXING_ZUZHANG: '组长', } def write_xiugai_log(*, yonghuid, xiugaiid, leixing, qitashuoming, club_id=None, request_ip='', **extra_fields): """ 统一写入 xiugaijilu 修改记录。 qitashuoming 应详细描述本次操作(尤其是金额、身份、上下级变更等)。 extra_fields 可传入 xiugaitijiao、shangjiayue、guanshiyue 等模型字段。 """ from users.models import Xiugaijilu from jituan.services.admin_audit import log_admin_audit_from_xiugai if not qitashuoming: label = XIUGAI_LEIXING_LABEL.get(leixing, f'类型{leixing}') qitashuoming = f'【后台】修改{label}信息,操作人{xiugaiid}' Xiugaijilu.query.create( yonghuid=str(yonghuid), xiugaiid=str(xiugaiid), leixing=leixing, qitashuoming=qitashuoming, **extra_fields, ) log_admin_audit_from_xiugai( yonghuid=yonghuid, xiugaiid=xiugaiid, leixing=leixing, qitashuoming=qitashuoming, club_id=club_id, request_ip=request_ip, )