diff --git a/backend/views/exams.py b/backend/views/exams.py index aa32aab..88ed5e1 100644 --- a/backend/views/exams.py +++ b/backend/views/exams.py @@ -580,12 +580,19 @@ class KhjlglView(APIView): pending=Count('jilu_id', filter=Q(zhuangtai=3)), ).order_by('-total') + _stats_agg = stats_base.aggregate( + total=Count('id'), + passed=Count('id', filter=Q(zhuangtai=1)), + failed=Count('id', filter=Q(zhuangtai=2)), + in_progress=Count('id', filter=Q(zhuangtai=0)), + pending=Count('id', filter=Q(zhuangtai=3)), + ) stats = { - 'total': stats_base.count(), - 'passed': stats_base.filter(zhuangtai=1).count(), - 'failed': stats_base.filter(zhuangtai=2).count(), - 'in_progress': stats_base.filter(zhuangtai=0).count(), - 'pending': stats_base.filter(zhuangtai=3).count(), + 'total': _stats_agg['total'], + 'passed': _stats_agg['passed'], + 'failed': _stats_agg['failed'], + 'in_progress': _stats_agg['in_progress'], + 'pending': _stats_agg['pending'], 'total_fee': float(agg['total_fee'] or 0), 'tag_stats': [ { @@ -702,8 +709,11 @@ class KhjlczView(APIView): for f in KaoheJiluFeiyong.query.filter(jilu_id=jilu_id).order_by('cishu') ] reject_list = [] - for r in KaoheJujueJilu.query.filter(jilu_id=jilu_id).order_by('-CreateTime'): - kg_prof = _batch_user_profile_map([r.shenheguan_id]).get(r.shenheguan_id, {}) + _reject_records = list(KaoheJujueJilu.query.filter(jilu_id=jilu_id).order_by('-CreateTime')) + _kg_ids = [r.shenheguan_id for r in _reject_records if r.shenheguan_id] + _kg_prof_map = _batch_user_profile_map(_kg_ids) if _kg_ids else {} + for r in _reject_records: + kg_prof = _kg_prof_map.get(r.shenheguan_id, {}) reject_list.append({ 'cishu': r.cishu, 'shenheguan_id': r.shenheguan_id, diff --git a/backend/views/finance.py b/backend/views/finance.py index f67d21a..7152fe4 100644 --- a/backend/views/finance.py +++ b/backend/views/finance.py @@ -1,4 +1,4 @@ -import hmac +import hmac import threading import traceback import uuid @@ -12,6 +12,7 @@ import secrets import random import string from calendar import monthrange +from collections import defaultdict from decimal import Decimal from datetime import date, datetime, timedelta from django.utils import timezone @@ -155,13 +156,18 @@ class CwhybkhqView(APIView): if 'huiyuancaiwu' not in permissions: return Response({'code': 403, 'msg': '无会员财务查看权限'}, status=403) + all_bankuai = list(Bankuai.query.all()) + bankuai_ids = [bk.bankuai_id for bk in all_bankuai] + all_members = Huiyuan.query.filter(bankuai_id__in=bankuai_ids).values('huiyuan_id', 'jieshao', 'bankuai_id') + members_by_bankuai = defaultdict(list) + for m in all_members: + members_by_bankuai[m['bankuai_id']].append(dict(m)) bankuai_list = [] - for bk in Bankuai.query.all(): - members = Huiyuan.query.filter(bankuai=bk).values('huiyuan_id', 'jieshao', 'bankuai_id') + for bk in all_bankuai: bankuai_list.append({ 'bankuai_id': bk.bankuai_id, 'mingcheng': bk.mingcheng, - 'members': list(members) + 'members': members_by_bankuai.get(bk.bankuai_id, []) }) return Response({'code': 0, 'msg': 'ok', 'data': bankuai_list}) @@ -597,21 +603,30 @@ class CwhqjtddsjView(APIView): summary_qs = filter_queryset_by_club( Order.query.filter(**filters), request, club_field='ClubID', ) - total_order_count = summary_qs.count() - total_order_amount = summary_qs.aggregate(s=Sum('Amount'))['s'] or 0 - total_success_count = summary_qs.filter(Status=3).count() - total_success_amount = summary_qs.filter(Status=3).aggregate(s=Sum('Amount'))['s'] or 0 - total_refund_count = summary_qs.filter(Status=5).count() - total_dashou = summary_qs.filter(Status=3).aggregate(s=Sum('PlayerCommission'))['s'] or 0 - total_dianpu_fenhong = summary_qs.filter(Status=3, Platform=1).aggregate( - s=Sum('pingtai_kuozhan__ShopIncome'))['s'] or 0 - - platform_amount = summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('Amount'))['s'] or 0 - merchant_amount = summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('Amount'))['s'] or 0 - platform_dashou_sum = \ - summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('PlayerCommission'))['s'] or 0 - merchant_dashou_sum = \ - summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('PlayerCommission'))['s'] or 0 + agg = summary_qs.aggregate( + total_order_count=Count('id'), + total_order_amount=Sum('Amount'), + total_success_count=Count('id', filter=Q(Status=3)), + total_success_amount=Sum('Amount', filter=Q(Status=3)), + total_refund_count=Count('id', filter=Q(Status=5)), + total_dashou=Sum('PlayerCommission', filter=Q(Status=3)), + total_dianpu_fenhong=Sum('pingtai_kuozhan__ShopIncome', filter=Q(Status=3, Platform=1)), + platform_amount=Sum('Amount', filter=Q(Status=3, Platform=1)), + merchant_amount=Sum('Amount', filter=Q(Status=3, Platform=2)), + platform_dashou_sum=Sum('PlayerCommission', filter=Q(Status=3, Platform=1)), + merchant_dashou_sum=Sum('PlayerCommission', filter=Q(Status=3, Platform=2)), + ) + total_order_count = agg['total_order_count'] or 0 + total_order_amount = agg['total_order_amount'] or 0 + total_success_count = agg['total_success_count'] or 0 + total_success_amount = agg['total_success_amount'] or 0 + total_refund_count = agg['total_refund_count'] or 0 + total_dashou = agg['total_dashou'] or 0 + total_dianpu_fenhong = agg['total_dianpu_fenhong'] or 0 + platform_amount = agg['platform_amount'] or 0 + merchant_amount = agg['merchant_amount'] or 0 + platform_dashou_sum = agg['platform_dashou_sum'] or 0 + merchant_dashou_sum = agg['merchant_dashou_sum'] or 0 profit_platform = platform_amount - platform_dashou_sum - total_dianpu_fenhong profit_merchant = merchant_amount - merchant_dashou_sum diff --git a/backend/views/guanli.py b/backend/views/guanli.py index e2fbe0b..4cfa3b7 100644 --- a/backend/views/guanli.py +++ b/backend/views/guanli.py @@ -1,4 +1,4 @@ -import hmac +import hmac import threading import traceback import uuid @@ -357,9 +357,16 @@ class GetGuanliDetailView(APIView): custom_first = {} # 首次分红定制(cishu=1) extra_map = {} # 其他次数 { cishu: { huiyuan_id: { guanshi_fenhong, jieshao, jiage } } } + # 批量预取会员信息 + _huiyuan_ids = [r.huiyuan for r in duoci_records] + _huiyuan_map = { + h.huiyuan_id: h + for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao', 'jiage') + } + for record in duoci_records: # 获取会员信息 - member = Huiyuan.query.filter(huiyuan_id=record.huiyuan).first() + member = _huiyuan_map.get(record.huiyuan) if not member: continue member_info = { @@ -711,10 +718,16 @@ class UpdateGuanliView(APIView): if custom_first is not None and isinstance(custom_first, dict): from jituan.services.club_penalty import resolve_gsfenhong_club_id guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid) - # 获取当前所有首次定制记录 - existing_first = DuociFenhong.query.filter( - club_id=guanshi_club, yonghuid=yonghuid, cishu=1, + # 批量预取当前所有首次定制记录(避免循环内 N+1) + existing_first_list = list( + DuociFenhong.query.filter( + club_id=guanshi_club, yonghuid=yonghuid, cishu=1, + ) ) + _existing_map = {r.huiyuan: r for r in existing_first_list} + _to_create = [] + _to_update = [] + _to_delete_ids = [] # 前端传来的定制字典 {会员ID: 金额} for huiyuan_id, amount in custom_first.items(): try: @@ -722,24 +735,34 @@ class UpdateGuanliView(APIView): except: continue if amount <= 0: - DuociFenhong.query.filter( - club_id=guanshi_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1, - ).delete() + rec = _existing_map.get(huiyuan_id) + if rec is not None: + _to_delete_ids.append(rec.id) else: - DuociFenhong.query.update_or_create( - club_id=guanshi_club, - yonghuid=yonghuid, - huiyuan=huiyuan_id, - cishu=1, - defaults={ - 'guanshi_fenhong': amount, - 'zuzhang_fenhong': Decimal('0'), - }, - ) + rec = _existing_map.get(huiyuan_id) + if rec is not None: + rec.guanshi_fenhong = amount + rec.zuzhang_fenhong = Decimal('0') + _to_update.append(rec) + else: + _to_create.append(DuociFenhong( + club_id=guanshi_club, + yonghuid=yonghuid, + huiyuan=huiyuan_id, + cishu=1, + guanshi_fenhong=amount, + zuzhang_fenhong=Decimal('0'), + )) # 删除那些在前端字典中不存在的首次定制记录(即已取消定制) - for record in existing_first: - if record.huiyuan not in custom_first: - record.delete() + for rec in existing_first_list: + if rec.huiyuan not in custom_first and rec.id not in _to_delete_ids: + _to_delete_ids.append(rec.id) + if _to_create: + DuociFenhong.objects.bulk_create(_to_create) + if _to_update: + DuociFenhong.objects.bulk_update(_to_update, ['guanshi_fenhong', 'zuzhang_fenhong']) + if _to_delete_ids: + DuociFenhong.objects.filter(id__in=_to_delete_ids).delete() # 4.3 额外次数分红(cishu >= 2) if extra_map is not None and isinstance(extra_map, dict): @@ -747,6 +770,8 @@ class UpdateGuanliView(APIView): guanshi_club = resolve_gsfenhong_club_id(dashouid=yonghuid) # 收集需要保留的记录 keep_set = set() + # 解析前端数据,先汇总 keep_set 与目标金额 + _extra_targets = [] # [(cishu, huiyuan_id, amount)] for times_str, members in extra_map.items(): try: cishu = int(times_str) @@ -767,24 +792,42 @@ class UpdateGuanliView(APIView): except: continue keep_set.add((cishu, huiyuan_id)) - # 更新或创建 - DuociFenhong.query.update_or_create( + _extra_targets.append((cishu, huiyuan_id, amount)) + # 批量预取已有额外次数记录 + existing_extra_list = list( + DuociFenhong.query.filter( + club_id=guanshi_club, yonghuid=yonghuid, cishu__gte=2, + ) + ) + _extra_map = {(r.cishu, r.huiyuan): r for r in existing_extra_list} + _to_create = [] + _to_update = [] + _to_delete_ids = [] + for cishu, huiyuan_id, amount in _extra_targets: + rec = _extra_map.get((cishu, huiyuan_id)) + if rec is not None: + rec.guanshi_fenhong = amount + rec.zuzhang_fenhong = Decimal('0') + _to_update.append(rec) + else: + _to_create.append(DuociFenhong( club_id=guanshi_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=cishu, - defaults={ - 'guanshi_fenhong': amount, - 'zuzhang_fenhong': Decimal('0') - } - ) + guanshi_fenhong=amount, + zuzhang_fenhong=Decimal('0'), + )) # 删除不在 keep_set 中的额外次数记录 - existing_extra = DuociFenhong.query.filter( - club_id=guanshi_club, yonghuid=yonghuid, cishu__gte=2, - ) - for record in existing_extra: - if (record.cishu, record.huiyuan) not in keep_set: - record.delete() + for rec in existing_extra_list: + if (rec.cishu, rec.huiyuan) not in keep_set: + _to_delete_ids.append(rec.id) + if _to_create: + DuociFenhong.objects.bulk_create(_to_create) + if _to_update: + DuociFenhong.objects.bulk_update(_to_update, ['guanshi_fenhong', 'zuzhang_fenhong']) + if _to_delete_ids: + DuociFenhong.objects.filter(id__in=_to_delete_ids).delete() dividend_parts = [] if perm_enabled is not None: diff --git a/backend/views/kefu.py b/backend/views/kefu.py index 0e68b9b..7bfbe29 100644 --- a/backend/views/kefu.py +++ b/backend/views/kefu.py @@ -342,6 +342,12 @@ class KefuGetDashouDetailView(APIView): member_records = Huiyuangoumai.query.filter( yonghu_id=uid ).order_by('-CreateTime') + # 批量预取会员信息 + _huiyuan_ids = [r.huiyuan_id for r in member_records] + _huiyuan_map = { + h.huiyuan_id: h.jieshao + for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao') + } member_list = [] for record in member_records: # 检查是否过期 @@ -351,11 +357,7 @@ class KefuGetDashouDetailView(APIView): is_expired = record.daoqi_time < timezone.now() if record.daoqi_time else True if not is_expired: - try: - huiyuan = Huiyuan.query.get(huiyuan_id=record.huiyuan_id) - huiyuan_name = huiyuan.jieshao - except Huiyuan.DoesNotExist: - huiyuan_name = '未知会员' + huiyuan_name = _huiyuan_map.get(record.huiyuan_id, '未知会员') member_list.append({ 'id': record.id, 'huiyuan_name': huiyuan_name, @@ -828,8 +830,12 @@ class KefuGetShangjiaListView(APIView): return Response({'code': 400, 'msg': '商家状态格式错误'}) # 5. 统计总商家数(应用筛选后) - total = shangjia_qs.count() - valid_merchant_count = shangjia_qs.filter(zhuangtai=1).count() + _agg = shangjia_qs.aggregate( + total=Count('id'), + valid=Count('id', filter=Q(zhuangtai=1)), + ) + total = _agg['total'] + valid_merchant_count = _agg['valid'] # 6. 分页 offset = (page - 1) * page_size diff --git a/backend/views/penalties.py b/backend/views/penalties.py index 15e7173..66b594f 100644 --- a/backend/views/penalties.py +++ b/backend/views/penalties.py @@ -1,4 +1,4 @@ -import hmac +import hmac import threading import traceback import uuid @@ -518,13 +518,15 @@ class FaKuanChuangJianView(APIView): user=request.user, ), ) - for relative_url in uploaded_urls: - PenaltyAppealImage.query.create( + PenaltyAppealImage.objects.bulk_create([ + PenaltyAppealImage( Penalty=penalty, PenalizedUserID=PenalizedUserID, ImageURL=relative_url, - Purpose=1 + Purpose=1, ) + for relative_url in uploaded_urls + ]) from users.fadan_fenhong_utils import lock_penalty_bonus lock_penalty_bonus(penalty) diff --git a/backend/views/products.py b/backend/views/products.py index 2e6969a..0681681 100644 --- a/backend/views/products.py +++ b/backend/views/products.py @@ -1,4 +1,4 @@ -import hmac +import hmac import threading import traceback import uuid @@ -147,14 +147,16 @@ class GetProductBaseDataView(APIView): # 获取所有商品类型(按 paixu 升序) type_qs = ShangpinLeixing.query.all().order_by('paixu') + # 批量聚合商品数量,避免循环内 N+1 count + _type_counts = { + item['leixing_id']: item['cnt'] + for item in Shangpin.query.filter( + dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False + ).values('leixing_id').annotate(cnt=Count('id')) + } type_list = [] for t in type_qs: - # 🔥 修改:统计该类型下的公共商品数量(店铺为空且店铺商品类型为空) - product_count = Shangpin.query.filter( - leixing_id=t.id, - dianpu__isnull=True, # 所属店铺为空 - dianpu_leixing__isnull=True # 店铺商品类型为空 - ).count() + product_count = _type_counts.get(t.id, 0) type_list.append({ 'id': t.id, 'jieshao': t.jieshao or '', @@ -166,14 +168,15 @@ class GetProductBaseDataView(APIView): # 获取所有专区(按 paixu 升序) zone_qs = ShangpinZhuanqu.query.all().order_by('paixu') + _zone_counts = { + item['zhuanqu_id']: item['cnt'] + for item in Shangpin.query.filter( + dianpu__isnull=True, dianpu_leixing__isnull=True, zhuanqu_id__isnull=False + ).values('zhuanqu_id').annotate(cnt=Count('id')) + } zone_list = [] for z in zone_qs: - # 🔥 修改:统计该专区下的公共商品数量(店铺为空且店铺商品类型为空) - product_count = Shangpin.query.filter( - zhuanqu_id=z.id, - dianpu__isnull=True, # 所属店铺为空 - dianpu_leixing__isnull=True # 店铺商品类型为空 - ).count() + product_count = _zone_counts.get(z.id, 0) zone_list.append({ 'id': z.id, 'mingzi': z.mingzi or '', @@ -368,10 +371,14 @@ class GetProductListView(APIView): # 获取所有商品类型(按 paixu 升序) type_qs = ShangpinLeixing.query.all().order_by('paixu') + # 批量聚合商品数量,避免循环内 N+1 count + _type_counts = { + item['leixing_id']: item['cnt'] + for item in Shangpin.query.filter(leixing_id__isnull=False).values('leixing_id').annotate(cnt=Count('id')) + } type_list = [] for t in type_qs: - # 统计该类型下的商品数量(忽略审核状态?通常统计所有商品,可根据需求调整) - product_count = Shangpin.query.filter(leixing_id=t.id).count() + product_count = _type_counts.get(t.id, 0) type_list.append({ 'id': t.id, 'jieshao': t.jieshao or '', @@ -383,9 +390,13 @@ class GetProductListView(APIView): # 获取所有专区(按 paixu 升序) zone_qs = ShangpinZhuanqu.query.all().order_by('paixu') + _zone_counts = { + item['zhuanqu_id']: item['cnt'] + for item in Shangpin.query.filter(zhuanqu_id__isnull=False).values('zhuanqu_id').annotate(cnt=Count('id')) + } zone_list = [] for z in zone_qs: - product_count = Shangpin.query.filter(zhuanqu_id=z.id).count() + product_count = _zone_counts.get(z.id, 0) zone_list.append({ 'id': z.id, 'mingzi': z.mingzi or '', diff --git a/backend/views/roles.py b/backend/views/roles.py index 1dccde3..38a75a9 100644 --- a/backend/views/roles.py +++ b/backend/views/roles.py @@ -1,4 +1,4 @@ -import hmac +import hmac import threading import traceback import uuid @@ -157,19 +157,29 @@ class GetRolePermissionView(APIView): kefu_user_uuids = list( User.objects.filter(KefuProfile__isnull=False).values_list('UserUUID', flat=True) ) - roles = filter_roles_for_request(request) + roles = list(filter_roles_for_request(request)) + # 批量预取 RolePermission + Permission(避免循环内 N+1) + _role_uuids = [r.RoleUUID for r in roles] + _rp_pairs = list( + RolePermission.objects.filter(RoleUUID__in=_role_uuids) + .values_list('RoleUUID', 'PermUUID') + ) if _role_uuids else [] + _perm_uuids = list({p for _, p in _rp_pairs}) + _perm_rows_by_uuid = { + p['PermUUID']: p + for p in Permission.objects.filter(PermUUID__in=_perm_uuids) + .values('PermUUID', 'PermCode', 'PermName', 'PermDesc') + } if _perm_uuids else {} + _perms_by_role = {} + for role_uuid, perm_uuid in _rp_pairs: + perm_row = _perm_rows_by_uuid.get(perm_uuid) + if perm_row is not None: + _perms_by_role.setdefault(role_uuid, []).append(_serialize_permission_row(perm_row)) + roles_data = [] for role in roles: user_count = count_role_users_in_scope(role, request, kefu_user_uuids) - role_perm_uuids = RolePermission.objects.filter( - RoleUUID=role.RoleUUID - ).values_list('PermUUID', flat=True) - perms_of_role = [ - _serialize_permission_row(p) - for p in Permission.objects.filter( - PermUUID__in=list(role_perm_uuids) - ).values('PermCode', 'PermName', 'PermDesc') - ] + perms_of_role = _perms_by_role.get(role.RoleUUID, []) roles_data.append({ 'role_code': role.RoleName, 'role_name': role.RoleName, @@ -434,18 +444,29 @@ class GetAdminUserListView(APIView): paginator = Paginator(users, page_size) page_obj = paginator.get_page(page) + # 批量预取本页所有用户的 UserRole + Role(避免循环内 N+1) + _page_users = list(page_obj) + _page_user_uuids = [u.UserUUID for u in _page_users] + _ur_pairs = list( + UserRole.objects.filter(UserUUID__in=_page_user_uuids) + .values_list('UserUUID', 'RoleUUID') + ) if _page_user_uuids else [] + _role_uuids_in_page = list({r for _, r in _ur_pairs}) + _role_name_by_uuid = { + r.RoleUUID: r.RoleName + for r in Role.objects.filter(RoleUUID__in=_role_uuids_in_page) + } if _role_uuids_in_page else {} + _roles_by_user = {} + for user_uuid, role_uuid in _ur_pairs: + role_name = _role_name_by_uuid.get(role_uuid) + if role_name is not None: + _roles_by_user.setdefault(user_uuid, []).append( + {'role_code': role_name, 'role_name': role_name} + ) + data_list = [] - for user in page_obj: - # 通过 gvsdsdk UserRole 获取用户角色 - user_role_uuids = list( - UserRole.objects.filter(UserUUID=user.UserUUID) - .values_list('RoleUUID', flat=True) - ) - user_roles = Role.objects.filter(RoleUUID__in=user_role_uuids) - roles_data = [ - {'role_code': r.RoleName, 'role_name': r.RoleName} - for r in user_roles - ] + for user in _page_users: + roles_data = _roles_by_user.get(user.UserUUID, []) data_list.append({ 'phone': user.Phone or '', 'yonghuid': user.UserUID or '', diff --git a/backend/views/sections.py b/backend/views/sections.py index 9df043d..d99a9e5 100644 --- a/backend/views/sections.py +++ b/backend/views/sections.py @@ -1,4 +1,4 @@ -import hmac +import hmac import threading import traceback import uuid @@ -173,19 +173,27 @@ class HqbkxxView(APIView): price_map[row.huiyuan_id] = row huiyuan_list = [] - for hy in Huiyuan.query.all(): - row = price_map.get(hy.huiyuan_id) + # 在受限 scope 下预过滤,避免 Huiyuan 全表遍历 + # 注:当 scope != DATA_SCOPE_ALL 且 club_id != CLUB_ID_DEFAULT 时, + # 只有 price_map 中存在记录的会员才可能被保留(无记录的会被跳过) + huiyuan_qs = Huiyuan.query.all() + if scope != DATA_SCOPE_ALL and club_id != CLUB_ID_DEFAULT: + huiyuan_qs = huiyuan_qs.filter(huiyuan_id__in=list(price_map.keys())) + # 仅查询所需字段,减少内存占用(dict 比 model 实例更轻) + huiyuan_qs = huiyuan_qs.values('huiyuan_id', 'jieshao', 'bankuai_id', 'jiage', 'guanshifc', 'zuzhangfc') + for hy in huiyuan_qs: + row = price_map.get(hy['huiyuan_id']) if scope != DATA_SCOPE_ALL and club_id != CLUB_ID_DEFAULT and row is None: continue if row is not None and not row.is_enabled: continue item = { - 'huiyuan_id': hy.huiyuan_id, - 'jieshao': hy.jieshao, - 'bankuai_id': hy.bankuai_id, - 'jiage': str(row.jiage) if row else str(hy.jiage), - 'guanshifc': str(row.guanshifc) if row else str(hy.guanshifc), - 'zuzhangfc': str(row.zuzhangfc) if row else str(hy.zuzhangfc), + 'huiyuan_id': hy['huiyuan_id'], + 'jieshao': hy['jieshao'], + 'bankuai_id': hy['bankuai_id'], + 'jiage': str(row.jiage) if row else str(hy['jiage']), + 'guanshifc': str(row.guanshifc) if row else str(hy['guanshifc']), + 'zuzhangfc': str(row.zuzhangfc) if row else str(hy['zuzhangfc']), } huiyuan_list.append(item) diff --git a/backend/views/system.py b/backend/views/system.py index e02baa8..1407dd0 100644 --- a/backend/views/system.py +++ b/backend/views/system.py @@ -1,4 +1,4 @@ -import hmac +import hmac import threading import traceback import uuid @@ -241,11 +241,16 @@ class GetProductTypeZoneView(APIView): return Response({'code': 400, 'msg': scope_err}) # 查询所有商品类型(含专区数量) + # 一次性分组聚合所有类型的专区数量,避免循环内 N+1 count + # 注:ShangpinZhuanqu.leixing_id 是 PositiveIntegerField(非 FK),无法用反向 annotate + zone_count_rows = ShangpinZhuanqu.query.values('leixing_id').annotate(cnt=Count('id')) + zone_count_map = {row['leixing_id']: row['cnt'] for row in zone_count_rows} + types = ShangpinLeixing.query.all().order_by('-paixu') # paixu 越大越靠前 type_list = [] for t in types: - # 统计该类型下的专区数量 - zone_count = ShangpinZhuanqu.query.filter(leixing_id=t.id).count() + # 从预聚合的 map 中取专区数量 + zone_count = zone_count_map.get(t.id, 0) type_list.append({ 'id': t.id, 'jieshao': t.jieshao or '', @@ -531,10 +536,11 @@ class ModifyProductTypeZoneView(APIView): # 删除该类型下的所有专区 zones = ShangpinZhuanqu.query.filter(leixing_id=type_id) if delete_products: - # 删除专区下的所有商品 - for zone in zones: - Shangpin.query.filter(zhuanqu_id=zone.id).delete() - zones.delete() + # 收集 zone_ids 后批量删除,避免循环内 N+1 delete + zone_ids = list(zones.values_list('id', flat=True)) + if zone_ids: + Shangpin.query.filter(zhuanqu_id__in=zone_ids).delete() + ShangpinZhuanqu.query.filter(id__in=zone_ids).delete() # 删除直接关联该类型的商品 Shangpin.query.filter(leixing_id=type_id).delete() else: @@ -928,7 +934,8 @@ class PopupNoticeModifyAPIView(APIView): if cascade: # 级联删除:使用事务保证原子性 with transaction.atomic(): - popups = page.popups.all() + # 预加载 images 关联,避免循环内 N+1 访问 related manager + popups = page.popups.prefetch_related('images') for popup in popups: # 删除弹窗下的所有图片文件 for img in popup.images.all(): diff --git a/backend/views/titles.py b/backend/views/titles.py index f88e569..4a63e10 100644 --- a/backend/views/titles.py +++ b/backend/views/titles.py @@ -1,4 +1,4 @@ -import hmac +import hmac import threading import traceback import uuid @@ -19,7 +19,7 @@ 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 django.db.models import Q, Count, Sum, F, Exists, OuterRef, Prefetch from gvsdsdk.fluent import db, func, FQ from django.db.models.functions import TruncDate, TruncMonth, TruncYear from django.contrib.auth.hashers import make_password @@ -132,13 +132,16 @@ class KhpzhqView(APIView): bankuai_list = list(Bankuai.query.values('bankuai_id', 'mingcheng')) - chenghao_qs = Chenghao.query.prefetch_related('kaohecishufeiyong_set').all() + chenghao_qs = Chenghao.query.prefetch_related( + Prefetch('kaohecishufeiyong_set', queryset=KaoheCishuFeiyong.objects.order_by('cishu')) + ).all() chenghao_list = [] for ch in chenghao_qs: # 获取费用:按次数排序,转为 {cishu: feiyong} 字典 + # prefetch_related 已用 Prefetch 排序,直接遍历不会绕过缓存 feiyong_dict = { item.cishu: float(item.feiyong) - for item in ch.kaohecishufeiyong_set.order_by('cishu') + for item in ch.kaohecishufeiyong_set.all() } max_cishu = max(feiyong_dict.keys()) if feiyong_dict else 0 diff --git a/backend/views/zuzhang.py b/backend/views/zuzhang.py index 196d5fc..03b9e22 100644 --- a/backend/views/zuzhang.py +++ b/backend/views/zuzhang.py @@ -1,4 +1,4 @@ -import hmac +import hmac import threading import traceback import uuid @@ -351,9 +351,16 @@ class GetZuzhangDetailView(APIView): custom_first = {} # 首次分红定制(cishu=1) extra_map = {} # 其他次数 { cishu: { huiyuan_id: { zuzhang_fenhong, jieshao, jiage } } } + # 批量预取会员信息 + _huiyuan_ids = [r.huiyuan for r in duoci_records] + _huiyuan_map = { + h.huiyuan_id: h + for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao', 'jiage') + } + for record in duoci_records: # 获取会员信息 - member = Huiyuan.query.filter(huiyuan_id=record.huiyuan).first() + member = _huiyuan_map.get(record.huiyuan) if not member: continue member_info = { @@ -580,9 +587,16 @@ class UpdateZuzhangView(APIView): if custom_first is not None and isinstance(custom_first, dict): from jituan.services.club_penalty import resolve_gsfenhong_club_id zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid) - existing_first = DuociFenhong.query.filter( - club_id=zuzhang_club, yonghuid=yonghuid, cishu=1, + # 批量预取当前所有首次定制记录(避免循环内 N+1) + existing_first_list = list( + DuociFenhong.query.filter( + club_id=zuzhang_club, yonghuid=yonghuid, cishu=1, + ) ) + _existing_map = {r.huiyuan: r for r in existing_first_list} + _to_create = [] + _to_update = [] + _to_delete_ids = [] for huiyuan_id, amount in custom_first.items(): try: amount = Decimal(str(amount)) @@ -590,30 +604,41 @@ class UpdateZuzhangView(APIView): continue if amount <= 0: # 删除定制(恢复默认) - DuociFenhong.query.filter( - club_id=zuzhang_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=1, - ).delete() + rec = _existing_map.get(huiyuan_id) + if rec is not None: + _to_delete_ids.append(rec.id) else: - DuociFenhong.query.update_or_create( - club_id=zuzhang_club, - yonghuid=yonghuid, - huiyuan=huiyuan_id, - cishu=1, - defaults={ - 'zuzhang_fenhong': amount, - 'guanshi_fenhong': Decimal('0') # 组长表只关心组长分红 - } - ) + rec = _existing_map.get(huiyuan_id) + if rec is not None: + rec.zuzhang_fenhong = amount + rec.guanshi_fenhong = Decimal('0') # 组长表只关心组长分红 + _to_update.append(rec) + else: + _to_create.append(DuociFenhong( + club_id=zuzhang_club, + yonghuid=yonghuid, + huiyuan=huiyuan_id, + cishu=1, + zuzhang_fenhong=amount, + guanshi_fenhong=Decimal('0'), + )) # 删除那些在前端字典中不存在的首次定制记录(即已取消定制) - for record in existing_first: - if record.huiyuan not in custom_first: - record.delete() + for rec in existing_first_list: + if rec.huiyuan not in custom_first and rec.id not in _to_delete_ids: + _to_delete_ids.append(rec.id) + if _to_create: + DuociFenhong.objects.bulk_create(_to_create) + if _to_update: + DuociFenhong.objects.bulk_update(_to_update, ['zuzhang_fenhong', 'guanshi_fenhong']) + if _to_delete_ids: + DuociFenhong.objects.filter(id__in=_to_delete_ids).delete() # 4.3 额外次数分红(cishu >= 2) if extra_map is not None and isinstance(extra_map, dict): from jituan.services.club_penalty import resolve_gsfenhong_club_id zuzhang_club = resolve_gsfenhong_club_id(dashouid=yonghuid) keep_set = set() + _extra_targets = [] # [(cishu, huiyuan_id, amount)] for times_str, members in extra_map.items(): try: cishu = int(times_str) @@ -634,23 +659,42 @@ class UpdateZuzhangView(APIView): except: continue keep_set.add((cishu, huiyuan_id)) - DuociFenhong.query.update_or_create( + _extra_targets.append((cishu, huiyuan_id, amount)) + # 批量预取已有额外次数记录 + existing_extra_list = list( + DuociFenhong.query.filter( + club_id=zuzhang_club, yonghuid=yonghuid, cishu__gte=2, + ) + ) + _extra_map = {(r.cishu, r.huiyuan): r for r in existing_extra_list} + _to_create = [] + _to_update = [] + _to_delete_ids = [] + for cishu, huiyuan_id, amount in _extra_targets: + rec = _extra_map.get((cishu, huiyuan_id)) + if rec is not None: + rec.zuzhang_fenhong = amount + rec.guanshi_fenhong = Decimal('0') + _to_update.append(rec) + else: + _to_create.append(DuociFenhong( club_id=zuzhang_club, yonghuid=yonghuid, huiyuan=huiyuan_id, cishu=cishu, - defaults={ - 'zuzhang_fenhong': amount, - 'guanshi_fenhong': Decimal('0') - } - ) + zuzhang_fenhong=amount, + guanshi_fenhong=Decimal('0'), + )) # 删除不在 keep_set 中的额外次数记录 - existing_extra = DuociFenhong.query.filter( - club_id=zuzhang_club, yonghuid=yonghuid, cishu__gte=2, - ) - for record in existing_extra: - if (record.cishu, record.huiyuan) not in keep_set: - record.delete() + for rec in existing_extra_list: + if (rec.cishu, rec.huiyuan) not in keep_set: + _to_delete_ids.append(rec.id) + if _to_create: + DuociFenhong.objects.bulk_create(_to_create) + if _to_update: + DuociFenhong.objects.bulk_update(_to_update, ['zuzhang_fenhong', 'guanshi_fenhong']) + if _to_delete_ids: + DuociFenhong.objects.filter(id__in=_to_delete_ids).delete() dividend_parts = [] if perm_enabled is not None: diff --git a/backend/views/zxkf.py b/backend/views/zxkf.py index 302ee0c..1068eb5 100644 --- a/backend/views/zxkf.py +++ b/backend/views/zxkf.py @@ -175,7 +175,7 @@ class ZxkfghdsView(APIView): shangjia_ext = old_order.shangjia_kuozhan shangjia_id = shangjia_ext.MerchantID # 获取商家昵称用于新订单扩展表 - shangjia_user = User.query.get(UserUID=shangjia_id) + shangjia_user = User.query.select_related('ShopProfile').get(UserUID=shangjia_id) shangjia_nicheng = shangjia_user.ShopProfile.nicheng or f"商家{shangjia_id}" except (ObjectDoesNotExist, UserShangjia.DoesNotExist): logger.error(f"商家信息缺失,订单: {OrderID}") diff --git a/check_prod_uid.py b/check_prod_uid.py deleted file mode 100644 index 9dfab53..0000000 --- a/check_prod_uid.py +++ /dev/null @@ -1,27 +0,0 @@ -"""检查生产库中 UserUID 为空的用户""" -import pymysql - -conn = pymysql.connect( - host='gvsds.com', port=50030, - user='root', password='sajksh.sdfGH3YUge.wjkd+', - database='xaio_cheng_xu', charset='utf8mb4' -) -cur = conn.cursor() - -# UserUID 为 NULL 的用户数 -cur.execute("SELECT COUNT(*) FROM user WHERE UserUID IS NULL OR UserUID = ''") -print(f'UserUID为空的用户数: {cur.fetchone()[0]}') - -# 总用户数 -cur.execute("SELECT COUNT(*) FROM user") -print(f'总用户数: {cur.fetchone()[0]}') - -# 抽样 UserUID 为 NULL 的用户 -cur.execute("SELECT UserUUID, UserName, OpenID, Phone FROM user WHERE UserUID IS NULL OR UserUID = '' LIMIT 5") -rows = cur.fetchall() -if rows: - print('\nUserUID为空的用户示例:') - for r in rows: - print(f' UserName={r[1]}, OpenID={r[2]}, Phone={r[3]}') - -conn.close() diff --git a/config/views/admin_config.py b/config/views/admin_config.py index 9158179..127dc32 100644 --- a/config/views/admin_config.py +++ b/config/views/admin_config.py @@ -1,4 +1,4 @@ -import io +import io import os import re import time @@ -394,40 +394,46 @@ class AdminUpdateConfigView(APIView): # 🔴 修复:更新QQ群配置 - 确保根据peizhiid正确更新对应的记录 qq_groups = request.data.get('qq_groups') if qq_groups and isinstance(qq_groups, list): + # 收集所有 peizhiid,批量预取已有记录(避免循环内 N+1) + _peizhiids = [g.get('peizhiid') for g in qq_groups if g.get('peizhiid')] + _existing_map = {} + if _peizhiids: + for q in Qunpeizhi.query.filter(GroupType__in=_peizhiids): + # 如果存在多个相同 peizhiid 的记录,取第一个(应该只有一个) + if q.GroupType not in _existing_map: + _existing_map[q.GroupType] = q + + _to_create = [] + _to_update = [] updated_groups = [] for group_data in qq_groups: peizhiid = group_data.get('peizhiid') if not peizhiid: continue - # 🔴 修复:使用filter()确保查询到正确的记录 - qun_configs = Qunpeizhi.query.filter(GroupType=peizhiid) - - if qun_configs.exists(): - # 如果存在多个相同peizhiid的记录,取第一个(应该只有一个) - qun_config = qun_configs.first() - - # 更新字段 - neirong = group_data.get('neirong') - qunid = group_data.get('qunid') - jieshao = group_data.get('jieshao') + neirong = group_data.get('neirong') + qunid = group_data.get('qunid') + jieshao = group_data.get('jieshao') + qun_config = _existing_map.get(peizhiid) + if qun_config is not None: + # 更新已有记录 if neirong is not None: qun_config.GroupContent = neirong or '' if qunid is not None: qun_config.GroupID = qunid or '' if jieshao is not None: qun_config.Description = jieshao or '' - - qun_config.save() + _to_update.append(qun_config) else: - # 如果不存在,创建新的(使用提供的peizhiid) - qun_config = Qunpeizhi.query.create( + # 不存在则创建新的 + qun_config = Qunpeizhi( GroupType=peizhiid, - GroupContent=group_data.get('neirong', '') or '', - GroupID=group_data.get('qunid', '') or '', - Description=group_data.get('jieshao', '') or '' + GroupContent=neirong or '', + GroupID=qunid or '', + Description=jieshao or '', ) + _to_create.append(qun_config) updated_groups.append({ 'peizhiid': qun_config.GroupType, @@ -436,6 +442,13 @@ class AdminUpdateConfigView(APIView): 'jieshao': qun_config.Description }) + if _to_create: + Qunpeizhi.objects.bulk_create(_to_create) + if _to_update: + Qunpeizhi.objects.bulk_update( + _to_update, ['GroupContent', 'GroupID', 'Description'] + ) + if updated_groups: response_data['qq_groups'] = updated_groups diff --git a/config/views/admin_stats.py b/config/views/admin_stats.py index 93fa84b..f45e880 100644 --- a/config/views/admin_stats.py +++ b/config/views/admin_stats.py @@ -1,4 +1,4 @@ -import io +import io import os import re import time @@ -270,6 +270,13 @@ class DashouPaihangView(APIView): # 第一名数量不足,不展示 return [], False + # 批量预取老板扩展表昵称 + _boss_user_ids = [g.user_id for g in guanshi_list] + _boss_nicheng_map = { + b.user_id: b.nickname + for b in UserBoss.query.filter(user_id__in=_boss_user_ids).only('user_id', 'nickname') + } + # 构建返回数据 paihang_list = [] for index, guanshi in enumerate(guanshi_list): @@ -277,8 +284,7 @@ class DashouPaihangView(APIView): user_main = guanshi.user # 管事的昵称需要查询老板扩展表 - boss_profile = UserBoss.query.filter(user=user_main).first() - guanshi_nicheng = boss_profile.nickname if boss_profile else '未设置昵称' + guanshi_nicheng = _boss_nicheng_map.get(guanshi.user_id, '未设置昵称') # 获取头像(相对URL) touxiang_url = user_main.Avatar or '' @@ -337,6 +343,13 @@ class DashouPaihangView(APIView): # 第一名流水不足,不展示 return [], False + # 批量预取老板扩展表昵称 + _boss_user_ids = [s.user_id for s in shangjia_list] + _boss_nicheng_map = { + b.user_id: b.nickname + for b in UserBoss.query.filter(user_id__in=_boss_user_ids).only('user_id', 'nickname') + } + # 构建返回数据 paihang_list = [] for index, shangjia in enumerate(shangjia_list): @@ -344,8 +357,7 @@ class DashouPaihangView(APIView): user_main = shangjia.user # 商家的昵称需要查询老板扩展表 - boss_profile = UserBoss.query.filter(user=user_main).first() - shangjia_nicheng = boss_profile.nickname if boss_profile else '未设置昵称' + shangjia_nicheng = _boss_nicheng_map.get(shangjia.user_id, '未设置昵称') # 获取头像(相对URL) touxiang_url = user_main.Avatar or '' @@ -604,21 +616,21 @@ class GetXiugaiJiluView(APIView): records = cursor.fetchall() # 🔥 6. 处理查询结果(严格按照字段类型处理) + # 预查询修改者账号信息(循环外执行一次,避免循环内重复查询同一 SQL) + xiugaizhe_zhanghao = '' + with connection.cursor() as cursor2: + cursor2.execute( + "SELECT phone, yonghuid FROM user_main WHERE phone = %s OR yonghuid = %s LIMIT 1", + [xiugaizhe_zhanghaoid, xiugaizhe_zhanghaoid] + ) + xiugaizhe_info = cursor2.fetchone() + if xiugaizhe_info: + xiugaizhe_zhanghao = str(xiugaizhe_info[0]) if xiugaizhe_info[0] else str(xiugaizhe_info[1]) if \ + xiugaizhe_info[1] else xiugaizhe_zhanghaoid + jilu_list = [] for record in records: - # 获取修改者账号信息(都是字符串) - xiugaizhe_zhanghao = '' - with connection.cursor() as cursor2: - # 🔥 phone和yonghuid都是CharField,用字符串查询 - cursor2.execute( - "SELECT phone, yonghuid FROM user_main WHERE phone = %s OR yonghuid = %s LIMIT 1", - [xiugaizhe_zhanghaoid, xiugaizhe_zhanghaoid] - ) - xiugaizhe_info = cursor2.fetchone() - if xiugaizhe_info: - # 🔥 都是字符串 - xiugaizhe_zhanghao = str(xiugaizhe_info[0]) if xiugaizhe_info[0] else str(xiugaizhe_info[1]) if \ - xiugaizhe_info[1] else xiugaizhe_zhanghaoid + # 修改者账号信息已预查询,直接使用 # 获取会员名称(如果有) huiyuan_mingzi = '' diff --git a/config/views/merchant_links.py b/config/views/merchant_links.py index d5f3af4..7071ee8 100644 --- a/config/views/merchant_links.py +++ b/config/views/merchant_links.py @@ -1,4 +1,4 @@ -import io +import io import os import re import time @@ -508,7 +508,7 @@ class KehuGetDingdanLianjieView(APIView): # 获取商家昵称 shangjia_nicheng = '' try: - user_main = User.query.get(UserUID=lianjie_obj.UserID) + user_main = User.query.select_related('ShopProfile').get(UserUID=lianjie_obj.UserID) shangjia_obj = user_main.ShopProfile shangjia_nicheng = shangjia_obj.nicheng except (User.DoesNotExist, UserShangjia.DoesNotExist): @@ -816,7 +816,7 @@ class KehuTianxieDingdanView(APIView): # 验证打手是否存在且有效 try: # 先查询用户主表 - dashou_user = User.query.get(UserUID=zhiding_dashou) + dashou_user = User.query.select_related('DashouProfile').get(UserUID=zhiding_dashou) logger.info(f"找到打手用户 - 用户ID: {zhiding_dashou}") # 通过反向关系获取打手扩展信息 @@ -922,7 +922,7 @@ class KehuTianxieDingdanView(APIView): # 获取商家信息用于返回 try: - user_main = User.query.get(UserUID=lianjie_obj.UserID) + user_main = User.query.select_related('ShopProfile').get(UserUID=lianjie_obj.UserID) shangjia_obj = user_main.ShopProfile shangjia_mingcheng = shangjia_obj.nicheng except: diff --git a/config/views/merchant_templates.py b/config/views/merchant_templates.py index f3d3a03..fd84a92 100644 --- a/config/views/merchant_templates.py +++ b/config/views/merchant_templates.py @@ -1,4 +1,4 @@ -import io +import io import os import re import time @@ -142,17 +142,21 @@ class ShangjiaMobanListView(APIView): return Response({'code': 400, 'msg': '页码超出范围', 'data': {}}, status=400) current_page = paginator.page(page) + # 批量预取所有称号,避免循环内 N+1 + _mobans_list = list(current_page.object_list) + _title_ids = [m.TitleID for m in _mobans_list if m.TitleID] + _ch_map = { + ch.id: ch + for ch in Chenghao.query.filter(id__in=_title_ids).only('id', 'mingcheng', 'texiao_miaoshu') + } if _title_ids else {} formatted = [] - for moban in current_page.object_list: + for moban in _mobans_list: texiao_json = '' label_name = '' - if moban.TitleID: - try: - ch = Chenghao.query.get(id=moban.TitleID) - label_name = ch.mingcheng - texiao_json = ch.texiao_miaoshu or '' - except Chenghao.DoesNotExist: - pass + ch = _ch_map.get(moban.TitleID) + if ch: + label_name = ch.mingcheng + texiao_json = ch.texiao_miaoshu or '' formatted.append({ 'mobanId': moban.id, 'shangpinTypeId': moban.ProductTypeID, diff --git a/create_rbac_tables.sql b/create_rbac_tables.sql deleted file mode 100644 index aff10cd..0000000 --- a/create_rbac_tables.sql +++ /dev/null @@ -1,82 +0,0 @@ --- ============================================================ --- RBAC 表创建脚本 --- 仅创建生产数据库中缺失的表,已存在的表会被跳过 --- 执行方式: mysql -u root -p xaio_cheng_xu < create_rbac_tables.sql --- 注意: 索引和外键单独处理,避免重复创建报错 --- ============================================================ - --- ============================================================ --- Part 1: backend RBAC 表(小写表名,verify_kefu_permission 使用) --- ============================================================ - -CREATE TABLE IF NOT EXISTS `role` ( - `role_id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, - `role_code` varchar(50) NOT NULL UNIQUE, - `role_name` varchar(50) NOT NULL, - `description` varchar(200) NOT NULL, - `create_time` datetime(6) NOT NULL, - `update_time` datetime(6) NOT NULL -); - -CREATE TABLE IF NOT EXISTS `permission` ( - `perm_id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, - `perm_code` varchar(100) NOT NULL UNIQUE, - `perm_name` varchar(100) NOT NULL, - `description` varchar(200) NOT NULL, - `create_time` datetime(6) NOT NULL, - `update_time` datetime(6) NOT NULL -); - -CREATE TABLE IF NOT EXISTS `user_role` ( - `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, - `account_id` varchar(11) NOT NULL, - `create_time` datetime(6) NOT NULL, - `update_time` datetime(6) NOT NULL, - `role_id` integer NOT NULL, - UNIQUE KEY `user_role_account_id_role_id_uniq` (`account_id`, `role_id`) -); - -CREATE TABLE IF NOT EXISTS `role_permission` ( - `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, - `create_time` datetime(6) NOT NULL, - `permission_id` integer NOT NULL, - `role_id` integer NOT NULL, - UNIQUE KEY `role_permission_role_id_permission_id_uniq` (`role_id`, `permission_id`) -); - --- backend 表索引(忽略已存在的错误) -CREATE INDEX `user_role_account_id_idx` ON `user_role` (`account_id`); - --- backend 外键约束 -ALTER TABLE `user_role` ADD CONSTRAINT `user_role_role_fk` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`); -ALTER TABLE `role_permission` ADD CONSTRAINT `role_permission_permission_fk` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`perm_id`); -ALTER TABLE `role_permission` ADD CONSTRAINT `role_permission_role_fk` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`); - --- ============================================================ --- Part 2: gvsdsdk RBAC 表(大写表名,UUID 主键) --- ============================================================ - -CREATE TABLE IF NOT EXISTS `AccountActivation` (`AccountActivationCode` BINARY(6) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `EphemeralToken` (`ELTUUID` BINARY(16) NOT NULL PRIMARY KEY, `ELTUserUUID` BINARY(16) NOT NULL, `ELTCreateTime` datetime(6) NOT NULL, `ELTEndTime` datetime(6) NOT NULL, `ELTType` smallint NOT NULL, `ELTAllowIP` json NOT NULL, `ELTStatus` smallint NOT NULL); -CREATE TABLE IF NOT EXISTS `Password` (`UserUUID` BINARY(16) NOT NULL PRIMARY KEY, `Password` varchar(1024) NOT NULL); -CREATE TABLE IF NOT EXISTS `Permission` (`PermUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `PermName` varchar(128) NOT NULL, `PermCode` varchar(128) NOT NULL, `PermCategory` varchar(64) NOT NULL, `PermStatus` smallint NOT NULL, `PermDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `Position` (`PositionUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `PositionName` varchar(128) NOT NULL, `PositionCode` varchar(64) NOT NULL, `PositionLevel` smallint NOT NULL, `PositionStatus` smallint NOT NULL, `PositionDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL, `UpdateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `PositionTemplate` (`PositionTemplateUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `TemplateName` varchar(128) NOT NULL, `TemplateCode` varchar(64) NOT NULL, `TemplateStatus` smallint NOT NULL, `TemplateDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL, `UpdateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `PositionTemplatePermission` (`PTPermUUID` BINARY(16) NOT NULL PRIMARY KEY, `PositionTemplateUUID` BINARY(16) NOT NULL, `PermUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `Role` (`RoleUUID` BINARY(16) NOT NULL PRIMARY KEY, `TenantUUID` BINARY(16) NOT NULL, `RoleName` varchar(128) NOT NULL, `RoleType` smallint NOT NULL, `RoleStatus` smallint NOT NULL, `AssignScope` smallint NOT NULL, `AssignScopeUUID` BINARY(16) NULL, `RoleDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `RolePermission` (`RolePermUUID` BINARY(16) NOT NULL PRIMARY KEY, `RoleUUID` BINARY(16) NOT NULL, `PermUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `RoleShop` (`RoleShopUUID` BINARY(16) NOT NULL PRIMARY KEY, `RoleUUID` BINARY(16) NOT NULL, `ShopUUID` BINARY(16) NOT NULL, `IsMainShop` smallint NOT NULL, `CreateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `Shop` (`ShopUUID` BINARY(16) NOT NULL PRIMARY KEY, `CompanyUUID` BINARY(16) NOT NULL, `ShopName` varchar(128) NOT NULL, `ShopCode` varchar(64) NOT NULL, `ShopType` smallint NOT NULL, `ShopStatus` smallint NOT NULL, `ShopAddress` varchar(512) NOT NULL, `ShopContact` varchar(128) NOT NULL, `ShopPhone` varchar(32) NOT NULL, `ShopDesc` varchar(512) NOT NULL, `CreateTime` datetime(6) NOT NULL, `UpdateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `User2FA` (`User2FAUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `SecretKey` varchar(64) NOT NULL, `IsEnabled` smallint NOT NULL, `Verified` smallint NOT NULL, `LastUsedAt` datetime(6) NULL, `CreateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `UserABAC` (`UserABACUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `HierarchicalPermissionIdentifiers` varchar(256) NOT NULL); -CREATE TABLE IF NOT EXISTS `UserAvatar` (`UserUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserAvatar` longblob NOT NULL, `AvatarUploadTime` datetime(6) NOT NULL, `AvatarFileType` varchar(16) NOT NULL); -CREATE TABLE IF NOT EXISTS `UserDept` (`UserDeptUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `DeptUUID` BINARY(16) NOT NULL, `PositionUUID` BINARY(16) NULL, `IsMainDept` smallint NOT NULL, `CreateTime` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `UserLoginLog` (`LogUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `LoginIP` varchar(39) NOT NULL, `LoginPlatform` varchar(32) NOT NULL, `LoginStatus` smallint NOT NULL, `LoginTime` datetime(6) NOT NULL, `UserAgent` varchar(512) NOT NULL); -CREATE TABLE IF NOT EXISTS `UserPending2FA` (`PendingUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL UNIQUE, `SecretKey` varchar(64) NOT NULL, `ExpireAt` datetime(6) NOT NULL); -CREATE TABLE IF NOT EXISTS `UserRole` (`UserRoleUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `RoleUUID` BINARY(16) NOT NULL, `CompanyUUID` BINARY(16) NOT NULL, `CreateTime` datetime(6) NOT NULL); - --- gvsdsdk 索引 -CREATE INDEX `EphemELTUserUUID_idx` ON `EphemeralToken` (`ELTUserUUID`); -CREATE INDEX `EphemELTStatus_idx` ON `EphemeralToken` (`ELTStatus`); -CREATE INDEX `User2FA_UserUUID_idx` ON `User2FA` (`UserUUID`); -CREATE INDEX `UserLoginLog_UserUUID_idx` ON `UserLoginLog` (`UserUUID`); diff --git a/jituan/services/caiwu_stats.py b/jituan/services/caiwu_stats.py index de1b008..37af4b3 100644 --- a/jituan/services/caiwu_stats.py +++ b/jituan/services/caiwu_stats.py @@ -1,7 +1,7 @@ """俱乐部维度财务统计(供 /jituan/houtai/caiwu,旧 /houtai/caiwu 不变)。""" from datetime import date, datetime, timedelta -from django.db.models import Sum +from django.db.models import Count, Q, Sum from jituan.constants import DATA_SCOPE_ALL from jituan.services.club_context import ( @@ -61,31 +61,35 @@ def build_caiwu_payload(request): today_income_amount, today_income_count = today_income_for_dashboard(today, request) today_payout_amount, today_payout_count = sum_payout_stat_for_date(today, request) - today_orders = order_qs.filter( + # 合并订单统计:6 次独立 count/aggregate → 1 次 aggregate + _today_order_agg = order_qs.filter( CreateTime__gte=today_start, CreateTime__lt=today_end, Status__in=[1, 2, 3, 4, 5, 6, 7, 8], + ).aggregate( + today_order_count=Count('id'), + today_order_amount=Sum('Amount'), + today_completed_count=Count('id', filter=Q(Status=3)), + today_completed_amount=Sum('Amount', filter=Q(Status=3)), + today_tuikuan_count=Count('id', filter=Q(Status=5)), + today_tuikuan_amount=Sum('Amount', filter=Q(Status=5)), ) - today_order_count = today_orders.count() - today_order_amount = float(today_orders.aggregate(total=Sum('Amount'))['total'] or 0.00) + today_order_count = _today_order_agg['today_order_count'] or 0 + today_order_amount = float(_today_order_agg['today_order_amount'] or 0.00) + today_completed_count = _today_order_agg['today_completed_count'] or 0 + today_completed_amount = float(_today_order_agg['today_completed_amount'] or 0.00) + today_tuikuan_count = _today_order_agg['today_tuikuan_count'] or 0 + today_tuikuan_amount = float(_today_order_agg['today_tuikuan_amount'] or 0.00) - today_completed = order_qs.filter( - CreateTime__gte=today_start, CreateTime__lt=today_end, Status=3, - ) - today_completed_count = today_completed.count() - today_completed_amount = float(today_completed.aggregate(total=Sum('Amount'))['total'] or 0.00) - - today_tuikuan = order_qs.filter( - CreateTime__gte=today_start, CreateTime__lt=today_end, Status=5, - ) - today_tuikuan_count = today_tuikuan.count() - today_tuikuan_amount = float(today_tuikuan.aggregate(total=Sum('Amount'))['total'] or 0.00) - - today_chongzhi = cz_qs.filter( + # 合并充值统计:2 次独立 count/aggregate → 1 次 aggregate + _today_chongzhi_agg = cz_qs.filter( CreateTime__gte=today_start, CreateTime__lt=today_end, leixing=1, zhuangtai=3, + ).aggregate( + count=Count('id'), + total=Sum('jine'), ) - today_chongzhi_count = today_chongzhi.count() - today_chongzhi_amount = float(today_chongzhi.aggregate(total=Sum('jine'))['total'] or 0.00) + today_chongzhi_count = _today_chongzhi_agg['count'] or 0 + today_chongzhi_amount = float(_today_chongzhi_agg['total'] or 0.00) new_huiyuan_user_ids = hy_qs.filter( CreateTime__gte=today_start, CreateTime__lt=today_end, diff --git a/jituan/services/club_context.py b/jituan/services/club_context.py index 7b187bc..bb8d7c5 100644 --- a/jituan/services/club_context.py +++ b/jituan/services/club_context.py @@ -141,10 +141,13 @@ def count_order_status_buckets(q_conditions, request=None): ('5', Q(Status=5)), ('6', Q(Status=6)), ) - counts = {'all': order_scope_qs(request).filter(q_conditions).count()} + # 合并 8 次独立 count → 1 次 aggregate with 条件 Count + from django.db.models import Count + _base = order_scope_qs(request).filter(q_conditions) + _agg_kwargs = {'all': Count('id')} for key, status_q in buckets: - counts[key] = order_scope_qs(request).filter(q_conditions & status_q).count() - return counts + _agg_kwargs[key] = Count('id', filter=status_q) + return _base.aggregate(**_agg_kwargs) def paginate_fluent_query(fluent_qs, page, page_size): diff --git a/jituan/services/club_member_admin.py b/jituan/services/club_member_admin.py index 0bb1b51..32c8dbb 100644 --- a/jituan/services/club_member_admin.py +++ b/jituan/services/club_member_admin.py @@ -95,7 +95,12 @@ def count_club_member_sales(club_id, huiyuan_id, formal_only=True): def build_member_list_payload(request): scope = resolve_club_scope(request) club_id = resolve_club_id_from_request(request) - members = Huiyuan.query.all().order_by('-CreateTime') + # P1 优化:限制查询字段,避免加载不必要的大字段 + members = list(Huiyuan.query.all().only( + 'huiyuan_id', 'jieshao', 'jtjieshao', 'jiage', + 'guanshifc', 'zuzhangfc', 'goumai_cishu', 'bankuai_id', + 'CreateTime', 'is_bundle', + ).order_by('-CreateTime')) member_list = [] price_map = {} @@ -105,6 +110,20 @@ def build_member_list_payload(request): for r in ClubHuiyuanPrice.query.filter(club_id=club_id) } + # P1 优化:批量预取本俱乐部各会员的已支付单数,避免循环内 N+1 + _sales_map = {} + if scope != DATA_SCOPE_ALL and members: + from products.models import Czjilu + from django.db.models import Count + _sales_rows = Czjilu.query.filter( + club_id=club_id, + huiyuan_id__in=[m.huiyuan_id for m in members], + leixing=1, + zhuangtai=3, + is_trial=False, + ).values('huiyuan_id').annotate(cnt=Count('id')) + _sales_map = {r['huiyuan_id']: r['cnt'] for r in _sales_rows} + if scope == DATA_SCOPE_ALL: for m in members: member_list.append(_format_member(m)) @@ -116,7 +135,7 @@ def build_member_list_payload(request): if row is not None and not row.is_enabled: continue item = _format_member(m, row, club_id=club_id) - item['goumai_cishu'] = count_club_member_sales(club_id, m.huiyuan_id) + item['goumai_cishu'] = _sales_map.get(m.huiyuan_id, 0) member_list.append(item) return { @@ -138,17 +157,18 @@ def build_member_catalog_payload(request): enabled_ids = set( ClubHuiyuanPrice.query.filter(club_id=club_id, is_enabled=True).values_list('huiyuan_id', flat=True) ) - all_members = Huiyuan.query.all().order_by('-CreateTime') - catalog = [] - for m in all_members: - if m.huiyuan_id in enabled_ids: - continue - catalog.append({ - 'huiyuan_id': m.huiyuan_id, - 'jieshao': m.jieshao, - 'jtjieshao': m.jtjieshao, - 'is_bundle': bool(getattr(m, 'is_bundle', False)), - }) + # P1 优化:DB 端排除已上架会员,限制字段,避免全表加载 + Python 过滤 + catalog_qs = Huiyuan.query.all() + if enabled_ids: + catalog_qs = catalog_qs.exclude(huiyuan_id__in=list(enabled_ids)) + catalog = list( + catalog_qs.only('huiyuan_id', 'jieshao', 'jtjieshao', 'is_bundle') + .order_by('-CreateTime') + .values('huiyuan_id', 'jieshao', 'jtjieshao', 'is_bundle') + ) + # values 返回 dict 列表,需补 is_bundle bool 化 + for row in catalog: + row['is_bundle'] = bool(row.get('is_bundle')) return {'club_id': club_id, 'catalog': catalog} diff --git a/jituan/services/dashou_exam.py b/jituan/services/dashou_exam.py index faffd5f..ed32871 100644 --- a/jituan/services/dashou_exam.py +++ b/jituan/services/dashou_exam.py @@ -218,14 +218,22 @@ def build_exam_bundle(request, user): return None, '题库暂无已上架题目,请至少录入一道题并上架(每题至少两个选项且标记正确答案)' selected_ids = random.sample(all_ids, draw) + # 批量预取题目和图片,避免循环内 N+1 + _questions_map = { + q.id: q for q in ClubDashouExamQuestion.query.filter(id__in=selected_ids) + } + _imgs_map = {} + for img in ClubDashouExamQuestionImage.query.filter( + club_id=club_id, question_id__in=selected_ids, + ).order_by('question_id', 'sort_order', 'id'): + _imgs_map.setdefault(img.question_id, []).append(img.image_url) + questions = [] for qid in selected_ids: - q = ClubDashouExamQuestion.query.get(id=qid) - imgs = [ - r.image_url for r in ClubDashouExamQuestionImage.query.filter( - club_id=club_id, question_id=qid, - ).order_by('sort_order', 'id') - ] + q = _questions_map.get(qid) + if q is None: + continue + imgs = _imgs_map.get(qid, []) questions.append(build_question_bundle_item(q, imgs)) return { @@ -284,8 +292,14 @@ def build_admin_config_payload(request, user, data=None): if err: return {**clubs_info, 'scope_error': err} cfg = get_or_create_config(club_id) - pool = ClubDashouExamQuestion.query.filter(club_id=club_id).count() - enabled_pool = ClubDashouExamQuestion.query.filter(club_id=club_id, is_enabled=True).count() + # 合并 2 次独立 count → 1 次 aggregate with 条件 Count + from django.db.models import Count, Q + _pool_agg = ClubDashouExamQuestion.query.filter(club_id=club_id).aggregate( + pool=Count('id'), + enabled_pool=Count('id', filter=Q(is_enabled=True)), + ) + pool = _pool_agg['pool'] or 0 + enabled_pool = _pool_agg['enabled_pool'] or 0 return { **clubs_info, 'club_id': club_id, @@ -319,14 +333,20 @@ def list_admin_questions(request, user, data=None): if err: return {**exam_admin_clubs_payload(user), 'list': [], 'scope_error': err} rows = ClubDashouExamQuestion.query.filter(club_id=club_id).order_by('-sort_order', 'id') - out = [] - for q in rows: - imgs = [ + rows_list = list(rows) + _q_ids = [q.id for q in rows_list] + # 批量预取所有题目图片,避免循环内 N+1 + _imgs_map = {} + for img in ClubDashouExamQuestionImage.query.filter( + club_id=club_id, question_id__in=_q_ids, + ).order_by('question_id', 'sort_order', 'id'): + _imgs_map.setdefault(img.question_id, []).append( {'id': img.id, 'image_url': img.image_url, 'sort_order': img.sort_order} - for img in ClubDashouExamQuestionImage.query.filter( - club_id=club_id, question_id=q.id, - ).order_by('sort_order', 'id') - ] + ) + + out = [] + for q in rows_list: + imgs = _imgs_map.get(q.id, []) out.append(question_to_admin_dict(q, imgs)) return {**exam_admin_clubs_payload(user), 'club_id': club_id, 'list': out} diff --git a/jituan/services/finance_detail_stats.py b/jituan/services/finance_detail_stats.py index 470f059..dbfcbe4 100644 --- a/jituan/services/finance_detail_stats.py +++ b/jituan/services/finance_detail_stats.py @@ -203,19 +203,31 @@ def build_order_finance_payload( }) summary_qs = filter_queryset_by_club(Order.query.filter(**filters), request, club_field='ClubID') - total_order_count = summary_qs.count() - total_order_amount = summary_qs.aggregate(s=Sum('Amount'))['s'] or 0 - total_success_count = summary_qs.filter(Status=3).count() - total_success_amount = summary_qs.filter(Status=3).aggregate(s=Sum('Amount'))['s'] or 0 - total_refund_count = summary_qs.filter(Status=5).count() - total_dashou = summary_qs.filter(Status=3).aggregate(s=Sum('PlayerCommission'))['s'] or 0 - total_dianpu_fenhong = summary_qs.filter(Status=3, Platform=1).aggregate( - s=Sum('pingtai_kuozhan__ShopIncome'), - )['s'] or 0 - platform_amount = summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('Amount'))['s'] or 0 - merchant_amount = summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('Amount'))['s'] or 0 - platform_dashou_sum = summary_qs.filter(Status=3, Platform=1).aggregate(s=Sum('PlayerCommission'))['s'] or 0 - merchant_dashou_sum = summary_qs.filter(Status=3, Platform=2).aggregate(s=Sum('PlayerCommission'))['s'] or 0 + # 合并 13 次独立 count/aggregate → 1 次 aggregate + _summary_agg = summary_qs.aggregate( + total_order_count=Count('id'), + total_order_amount=Sum('Amount'), + total_success_count=Count('id', filter=Q(Status=3)), + total_success_amount=Sum('Amount', filter=Q(Status=3)), + total_refund_count=Count('id', filter=Q(Status=5)), + total_dashou=Sum('PlayerCommission', filter=Q(Status=3)), + total_dianpu_fenhong=Sum('pingtai_kuozhan__ShopIncome', filter=Q(Status=3, Platform=1)), + platform_amount=Sum('Amount', filter=Q(Status=3, Platform=1)), + merchant_amount=Sum('Amount', filter=Q(Status=3, Platform=2)), + platform_dashou_sum=Sum('PlayerCommission', filter=Q(Status=3, Platform=1)), + merchant_dashou_sum=Sum('PlayerCommission', filter=Q(Status=3, Platform=2)), + ) + total_order_count = _summary_agg['total_order_count'] or 0 + total_order_amount = _summary_agg['total_order_amount'] or 0 + total_success_count = _summary_agg['total_success_count'] or 0 + total_success_amount = _summary_agg['total_success_amount'] or 0 + total_refund_count = _summary_agg['total_refund_count'] or 0 + total_dashou = _summary_agg['total_dashou'] or 0 + total_dianpu_fenhong = _summary_agg['total_dianpu_fenhong'] or 0 + platform_amount = _summary_agg['platform_amount'] or 0 + merchant_amount = _summary_agg['merchant_amount'] or 0 + platform_dashou_sum = _summary_agg['platform_dashou_sum'] or 0 + merchant_dashou_sum = _summary_agg['merchant_dashou_sum'] or 0 profit_platform = platform_amount - platform_dashou_sum - total_dianpu_fenhong profit_merchant = merchant_amount - merchant_dashou_sum total_profit = profit_platform + profit_merchant @@ -392,10 +404,16 @@ def build_huiyuan_bankuai_payload(request): 'zuzhangfc': float(row.zuzhangfc or 0), } + # 批量预取所有 bankuai 和 huiyuan,避免循环内 N+1 + _bankuai_qs = Bankuai.query.all().only('bankuai_id', 'mingcheng') + _members_by_bankuai = {} + for m in Huiyuan.query.values('huiyuan_id', 'jieshao', 'bankuai_id'): + _members_by_bankuai.setdefault(m['bankuai_id'], []).append(m) + bankuai_list = [] - for bk in Bankuai.query.all(): + for bk in _bankuai_qs: members = [] - for m in Huiyuan.query.filter(bankuai=bk).values('huiyuan_id', 'jieshao', 'bankuai_id'): + for m in _members_by_bankuai.get(bk.bankuai_id, []): item = dict(m) if m['huiyuan_id'] in price_map: item['club_price'] = price_map[m['huiyuan_id']] diff --git a/jituan/services/szjilu_accounting.py b/jituan/services/szjilu_accounting.py index c3d4e1e..faba6a5 100644 --- a/jituan/services/szjilu_accounting.py +++ b/jituan/services/szjilu_accounting.py @@ -188,11 +188,11 @@ def zero_all_income_statistics(): except _PLATFORM_LOG_ERRORS: pass DailyIncomeStat.objects.all().delete() - for szjilu in Szjilu.objects.select_for_update().all(): - szjilu.TotalIncome = _ZERO - szjilu.TotalFlow = _ZERO - szjilu.DailyFlow = _ZERO - szjilu.save(update_fields=['TotalIncome', 'TotalFlow', 'DailyFlow', 'UpdateTime']) + from django.utils import timezone + Szjilu.objects.update( + TotalIncome=_ZERO, TotalFlow=_ZERO, DailyFlow=_ZERO, + UpdateTime=timezone.now(), + ) return True @@ -689,13 +689,12 @@ def get_szjilu_snapshot(club_id=None): def reset_all_daily_szjilu(): """每日清零:所有俱乐部的今日流水/今日支出。""" - updated = 0 with transaction.atomic(): - for szjilu in Szjilu.objects.select_for_update().all(): - szjilu.DailyExpense = _ZERO - szjilu.DailyFlow = _ZERO - szjilu.save(update_fields=['DailyExpense', 'DailyFlow', 'UpdateTime']) - updated += 1 + from django.utils import timezone + updated = Szjilu.objects.update( + DailyExpense=_ZERO, DailyFlow=_ZERO, + UpdateTime=timezone.now(), + ) return updated diff --git a/jituan/views_shenhe_manage.py b/jituan/views_shenhe_manage.py index c5fc6d8..f2da2b9 100644 --- a/jituan/views_shenhe_manage.py +++ b/jituan/views_shenhe_manage.py @@ -71,8 +71,13 @@ class ClubKhgglAddView(APIView): if not user_belongs_to_request_club(user, request): return Response({'code': 403, 'msg': '该用户不属于当前俱乐部,无法添加为考核官'}, status=403) + # 一次性查询所有板块是否存在,避免循环内 N+1 exists + _existing_bids = set( + Bankuai.query.filter(bankuai_id__in=bankuai_ids) + .values_list('bankuai_id', flat=True) + ) for bid in bankuai_ids: - if not Bankuai.query.filter(bankuai_id=bid).exists(): + if bid not in _existing_bids: return Response({'code': 1, 'msg': f'板块不存在: {bid}'}, status=400) created_new = False diff --git a/merchant_ops/services/dispatch.py b/merchant_ops/services/dispatch.py index 3fbf928..7c5dd0d 100644 --- a/merchant_ops/services/dispatch.py +++ b/merchant_ops/services/dispatch.py @@ -109,7 +109,7 @@ def staff_dispatch_order(member, staff_user, data): zhiding_dashou = None if zhiding_uid: try: - zhiding_user = User.query.get(UserUID=zhiding_uid) + zhiding_user = User.query.select_related('DashouProfile').get(UserUID=zhiding_uid) zhiding_dashou = zhiding_user.DashouProfile if zhiding_dashou.zhanghaozhuangtai != 1: return False, {'code': 400, 'msg': '指定打手状态异常', 'data': None} diff --git a/merchant_ops/services/link.py b/merchant_ops/services/link.py index a997c1b..c314e1a 100644 --- a/merchant_ops/services/link.py +++ b/merchant_ops/services/link.py @@ -119,6 +119,7 @@ def staff_generate_link(member, staff_user, data): final_huiyuan_id = shangpin_leixing.huiyuan_id or '' label_name = '' + ch = None if chenghao_id: ch = Chenghao.query.filter(id=chenghao_id).first() if ch: @@ -155,10 +156,9 @@ def staff_generate_link(member, staff_user, data): MerchantNickname=shangjia.nicheng or f"商家{merchant_id}", ) - if chenghao_id: - ch = Chenghao.query.filter(id=chenghao_id).first() - if ch: - DingdanBiaoqian.query.create(dingdan=dingdan, chenghao=ch) + # 复用已查询的 ch 对象,避免重复查询 + if ch: + DingdanBiaoqian.query.create(dingdan=dingdan, chenghao=ch) UserShangjia.query.filter(id=shangjia.id).update( fabu=F('fabu') + 1, diff --git a/merchant_ops/views_manage.py b/merchant_ops/views_manage.py index 8e3c244..7eef8ba 100644 --- a/merchant_ops/views_manage.py +++ b/merchant_ops/views_manage.py @@ -128,8 +128,16 @@ class StaffInviteListView(APIView): qs = MerchantStaffInvite.query.filter(merchant_id=request.user.UserUID).select_related('role') if status_filter is not None: qs = qs.filter(status=int(status_filter)) + invites = list(qs.order_by('-CreateTime')[:200]) + # 批量预取已使用邀请码对应的用户,避免循环内 N+1 + from users.business_models import User + _used_uids = [inv.used_by for inv in invites if inv.used_by] + _user_map = { + u.UserUID: u + for u in User.query.filter(UserUID__in=_used_uids).only('UserUID', 'Nickname') + } if _used_uids else {} items = [] - for inv in qs.order_by('-CreateTime')[:200]: + for inv in invites: item = { 'id': inv.id, 'code': inv.invite_code, @@ -140,8 +148,7 @@ class StaffInviteListView(APIView): 'user': None, } if inv.used_by: - from users.business_models import User - u = User.query.filter(UserUID=inv.used_by).first() + u = _user_map.get(inv.used_by) item['user'] = {'nickname': getattr(u, 'Nickname', '') or inv.used_by, 'uid': inv.used_by} items.append(item) return Response({'code': 0, 'msg': '成功', 'data': {'list': items, 'gudingCode': 'XQKF2026'}}) @@ -181,21 +188,51 @@ class StaffMemberListView(APIView): merchant_id=mid, status__in=[MEMBER_STATUS_ACTIVE, MEMBER_STATUS_DISABLED], ).select_related('role') - result = [] - for m in members: - total_stats = MerchantStaffDailyStats.query.filter(member_id=m.id).aggregate( + members_list = list(members) + _member_ids = [m.id for m in members_list] + _staff_uids = [m.staff_user_id for m in members_list if m.staff_user_id] + + # 批量预取聚合统计,避免循环内 N+1 + _total_stats_map = {} + _today_stat_map = {} + if _member_ids: + from django.db.models import Count + for item in MerchantStaffDailyStats.query.filter( + member_id__in=_member_ids + ).values('member_id').annotate( dispatch_count=Sum('dispatch_count'), dispatch_amount=Sum('dispatch_amount'), settle_count=Sum('settle_count'), settle_amount=Sum('settle_amount'), refund_count=Sum('refund_count'), refund_amount=Sum('refund_amount'), - ) - today_stat = MerchantStaffDailyStats.query.filter( - member_id=m.id, stat_date=today, - ).first() - wallet = get_or_create_wallet(m) - u = User.query.filter(UserUID=m.staff_user_id).first() + ): + _total_stats_map[item['member_id']] = item + for s in MerchantStaffDailyStats.query.filter( + member_id__in=_member_ids, stat_date=today, + ): + _today_stat_map[s.member_id] = s + + # 批量预取用户头像 + _user_map = { + u.UserUID: u + for u in User.query.filter(UserUID__in=_staff_uids).only('UserUID', 'Avatar') + } if _staff_uids else {} + + # 批量预取钱包 + _wallet_map = { + w.member_id: w + for w in MerchantStaffWallet.objects.filter(member_id__in=_member_ids) + } if _member_ids else {} + + result = [] + for m in members_list: + total_stats = _total_stats_map.get(m.id, {}) + today_stat = _today_stat_map.get(m.id) + wallet = _wallet_map.get(m.id) + if wallet is None: + wallet = get_or_create_wallet(m) + u = _user_map.get(m.staff_user_id) result.append({ 'id': m.id, 'uid': m.staff_user_id, @@ -210,13 +247,13 @@ class StaffMemberListView(APIView): 'isDisabled': m.status == MEMBER_STATUS_DISABLED, 'todayDispatchCount': today_stat.dispatch_count if today_stat else int(m.today_dispatch_count or 0), 'todayDispatchAmount': str(today_stat.dispatch_amount if today_stat else (m.today_dispatch_amount or '0.00')), - 'dispatchCount': total_stats['dispatch_count'] or 0, - 'dispatchAmount': str(total_stats['dispatch_amount'] or '0.00'), - 'orderCount': total_stats['dispatch_count'] or 0, - 'settleCount': total_stats['settle_count'] or 0, - 'settleAmount': str(total_stats['settle_amount'] or '0.00'), - 'refundCount': total_stats['refund_count'] or 0, - 'refundAmount': str(total_stats['refund_amount'] or '0.00'), + 'dispatchCount': total_stats.get('dispatch_count') or 0, + 'dispatchAmount': str(total_stats.get('dispatch_amount') or '0.00'), + 'orderCount': total_stats.get('dispatch_count') or 0, + 'settleCount': total_stats.get('settle_count') or 0, + 'settleAmount': str(total_stats.get('settle_amount') or '0.00'), + 'refundCount': total_stats.get('refund_count') or 0, + 'refundAmount': str(total_stats.get('refund_amount') or '0.00'), 'quota_available': str(wallet.quota_available), 'quota_total': str(wallet.quota_total), 'quota_used': str(wallet.quota_used), @@ -618,9 +655,15 @@ class StaffRankView(APIView): sort_field = sort_by if sort_by in ('dispatch_count', 'dispatch_amount', 'settle_count', 'settle_amount') else 'dispatch_count' agg = sorted(agg, key=lambda x: x[sort_field] or 0, reverse=True)[:50] + # 批量预取 member 信息,避免循环内 N+1 + _member_ids = [row['member_id'] for row in agg] + _members_map = { + m.id: m + for m in MerchantStaffMember.query.filter(id__in=_member_ids).only('id', 'display_name') + } if _member_ids else {} result = [] for idx, row in enumerate(agg, 1): - m = MerchantStaffMember.query.filter(id=row['member_id']).first() + m = _members_map.get(row['member_id']) result.append({ 'rank': idx, 'member_id': row['member_id'], diff --git a/orders/views/admin.py b/orders/views/admin.py index 5663a9d..aff094d 100644 --- a/orders/views/admin.py +++ b/orders/views/admin.py @@ -364,7 +364,7 @@ class AdGengHuanDaShou(APIView): # 8. 验证新打手ID是否存在,并且是打手身份 try: - new_dashou_user = User.query.get( + new_dashou_user = User.query.select_related('DashouProfile').get( UserUID=new_dashou_id, #user_type='PlayerID' ) @@ -459,7 +459,7 @@ class AdGengHuanDaShou(APIView): if old_dashou_id: try: # 查询原打手主表 - old_dashou_user = User.query.get( + old_dashou_user = User.query.select_related('DashouProfile').get( UserUID=old_dashou_id, #user_type='PlayerID' ) @@ -567,7 +567,7 @@ class AdQiangZhiJieDan(APIView): # 6. 查询订单主表 try: - dingdan_obj = Order.query.get(OrderID=dingdan_id) + dingdan_obj = Order.query.select_related('shangjia_kuozhan', 'pingtai_kuozhan').get(OrderID=dingdan_id) except Order.DoesNotExist: return Response( {'code': 404, 'message': '订单不存在', 'data': None}, @@ -603,7 +603,7 @@ class AdQiangZhiJieDan(APIView): if jiedan_dashou_id: try: # 查询打手用户主表 - dashou_user = User.query.get( + dashou_user = User.query.select_related('DashouProfile').get( UserUID=jiedan_dashou_id, #user_type='PlayerID' ) @@ -646,7 +646,7 @@ class AdQiangZhiJieDan(APIView): if shangjia_id: # 查询商家用户主表 - shangjia_user = User.query.get( + shangjia_user = User.query.select_related('ShopProfile').get( UserUID=shangjia_id, #user_type='shop' ) @@ -750,7 +750,7 @@ class AdJuJueTuiKuan(APIView): # 6. 查询订单主表 try: - dingdan_obj = Order.query.get(OrderID=dingdan_id) + dingdan_obj = Order.query.select_related('shangjia_kuozhan', 'pingtai_kuozhan').get(OrderID=dingdan_id) except Order.DoesNotExist: return Response( {'code': 404, 'message': '订单不存在', 'data': None}, @@ -779,7 +779,7 @@ class AdJuJueTuiKuan(APIView): if jiedan_dashou_id: try: # 查询打手用户主表 - dashou_user = User.query.get( + dashou_user = User.query.select_related('DashouProfile').get( UserUID=jiedan_dashou_id, #user_type='PlayerID' ) @@ -822,7 +822,7 @@ class AdJuJueTuiKuan(APIView): if shangjia_id: # 查询商家用户主表 - shangjia_user = User.query.get( + shangjia_user = User.query.select_related('ShopProfile').get( UserUID=shangjia_id, #user_type='shop' ) @@ -958,7 +958,7 @@ class AdTongYiTuiKuanShangJia(APIView): # 7. 查询订单主表 try: - dingdan_obj = Order.query.get(OrderID=dingdan_id) + dingdan_obj = Order.query.select_related('shangjia_kuozhan', 'pingtai_kuozhan').get(OrderID=dingdan_id) except Order.DoesNotExist: return Response( {'code': 404, 'message': '订单不存在', 'data': None}, @@ -1010,7 +1010,7 @@ class AdTongYiTuiKuanShangJia(APIView): if jiedan_dashou_id: try: # 查询打手用户主表 - dashou_user = User.query.get( + dashou_user = User.query.select_related('DashouProfile').get( UserUID=jiedan_dashou_id, #user_type='PlayerID' ) @@ -1035,10 +1035,10 @@ class AdTongYiTuiKuanShangJia(APIView): if shangjia_id: try: # 查询商家用户主表 - shangjia_user = User.query.get( - UserUID=shangjia_id, - #user_type='shop' - ) + shangjia_user = User.query.select_related('ShopProfile').get( + UserUID=shangjia_id, + #user_type='shop' + ) # 获取商家扩展表 shangjia_profile = shangjia_user.ShopProfile @@ -1228,7 +1228,7 @@ class AdTongYiTuiKuanPingTai(APIView): # 14. 更新打手扩展表 if jiedan_dashou_id: try: - dashou_user = User.query.get(UserUID=jiedan_dashou_id) + dashou_user = User.query.select_related('DashouProfile').get(UserUID=jiedan_dashou_id) dashou_profile = dashou_user.DashouProfile dashou_profile.zhuangtai = 1 # 打手状态设为空闲 dashou_profile.tuikuanliang += 1 @@ -1239,7 +1239,7 @@ class AdTongYiTuiKuanPingTai(APIView): # 15. 更新老板扩展表 if laoban_id: try: - laoban_user = User.query.get(UserUID=laoban_id) + laoban_user = User.query.select_related('BossProfile').get(UserUID=laoban_id) laoban_profile = laoban_user.BossProfile laoban_profile.alltui += 1 laoban_profile.zonge -= jine @@ -1463,7 +1463,7 @@ class AdJuJueJieSuan(APIView): # 8. 更新打手扩展表 try: - dashou_user = User.query.get(UserUID=jiedan_dashou_id) + dashou_user = User.query.select_related('DashouProfile').get(UserUID=jiedan_dashou_id) dashou_profile = dashou_user.DashouProfile dashou_profile.zhuangtai = 1 # 打手状态设为正常 dashou_profile.tuikuanliang += 1 # 退款订单总量+1 @@ -1545,7 +1545,7 @@ class AdZhuanYiDaTing(APIView): # 如果有接单打手,更新其扩展表 if jiedan_dashou_id: try: - dashou_user = User.query.get(UserUID=jiedan_dashou_id) + dashou_user = User.query.select_related('DashouProfile').get(UserUID=jiedan_dashou_id) dashou_profile = dashou_user.DashouProfile dashou_profile.zhuangtai = 1 dashou_profile.tuikuanliang += 1 @@ -1835,7 +1835,7 @@ class ZxsjghdsView(APIView): # 6.2 释放打手 if dashou_id: try: - dashou_user = User.query.get(UserUID=dashou_id) + dashou_user = User.query.select_related('DashouProfile').get(UserUID=dashou_id) dashou_profile = dashou_user.DashouProfile dashou_profile.tuikuanliang = (dashou_profile.tuikuanliang or 0) + 1 dashou_profile.zhuangtai = 1 # 空闲 diff --git a/orders/views/boss_orders.py b/orders/views/boss_orders.py index 88155ba..dd28d95 100644 --- a/orders/views/boss_orders.py +++ b/orders/views/boss_orders.py @@ -422,11 +422,14 @@ class JiedanView(APIView): # 7. 更新打手扩展表(如果有打手ID和分成) if jiedan_dashou_id and dashou_fencheng > 0: # 查询打手用户 - dashou_user = User.query.filter(UserUID=jiedan_dashou_id).first() + dashou_user = User.query.filter(UserUID=jiedan_dashou_id).select_related('DashouProfile').first() if dashou_user: # 查询打手扩展表 - dashou_kuozhan = UserDashou.query.filter(user=dashou_user).first() + try: + dashou_kuozhan = dashou_user.DashouProfile + except UserDashou.DoesNotExist: + dashou_kuozhan = None if dashou_kuozhan: # 更新四个金额字段 @@ -585,7 +588,7 @@ class DingdanXiangqingView2(APIView): jiedan_time = '' tijiao_time = '' if jiedan_dashou_id: - dashou_user = User.query.filter(UserUID=jiedan_dashou_id).first() + dashou_user = User.query.filter(UserUID=jiedan_dashou_id).select_related('DashouProfile').first() if dashou_user: dashou_touxiang = dashou_user.Avatar or '' try: @@ -734,7 +737,7 @@ class JiedanView2(APIView): # 9. 更新打手收益与状态 if jiedan_dashou_id and dashou_fencheng > 0: - dashou_user = User.query.filter(UserUID=jiedan_dashou_id).first() + dashou_user = User.query.filter(UserUID=jiedan_dashou_id).select_related('DashouProfile').first() if dashou_user: try: dashou_ext = dashou_user.DashouProfile diff --git a/orders/views/chat.py b/orders/views/chat.py index 9cf7a3c..4bb1b04 100644 --- a/orders/views/chat.py +++ b/orders/views/chat.py @@ -193,13 +193,13 @@ def send_group_message( def check_user_permission(identity_type, uid, order): """验证用户身份、账号状态、以及是否属于该订单""" try: - user = User.query.get(UserUID=uid) + user = User.query.select_related('DashouProfile').get(UserUID=uid) except User.DoesNotExist: return False, '用户不存在' if identity_type == 'PlayerID': try: - dashou = UserDashou.query.get(user=user) + dashou = user.DashouProfile if dashou.zhanghaozhuangtai != 1 : return False, '打手账号已被封禁或状态异常' except UserDashou.DoesNotExist: @@ -516,14 +516,16 @@ class LianTongDingDanZhuangTaiPiLiangView(APIView): return Response({'code': 400, 'msg': 'order_ids 格式错误'}) uid = request.user.UserUID + # 批量查询订单,避免循环内 N+1 + oids = [str(oid).strip() for oid in order_ids[:20] if str(oid).strip()] + orders_map = { + o.OrderID: o + for o in Order.query.select_related('pingtai_kuozhan', 'shangjia_kuozhan').filter(OrderID__in=oids) + } result = {} - for oid in order_ids[:20]: - oid = str(oid).strip() - if not oid: - continue - try: - order = Order.query.select_related('pingtai_kuozhan', 'shangjia_kuozhan').get(OrderID=oid) - except Order.DoesNotExist: + for oid in oids: + order = orders_map.get(oid) + if not order: continue for identity in ('PlayerID', 'shangjia', 'boss'): valid, _ = check_user_permission(identity, uid, order) diff --git a/orders/views/merchant.py b/orders/views/merchant.py index 17df04d..5d96775 100644 --- a/orders/views/merchant.py +++ b/orders/views/merchant.py @@ -521,11 +521,13 @@ class ShangjiaShujuTongjiView(APIView): penalty_stats = None try: from orders.models import Penalty, PenaltyRecord - from django.db.models import Q, Sum - fakuan_pending = Penalty.query.filter(ApplicantID=yonghuid, Status__in=[1, 3]).count() - fakuan_pending_amt = Penalty.query.filter(ApplicantID=yonghuid, Status__in=[1, 3]).aggregate( - amt=Sum('FineAmount') - )['amt'] + from django.db.models import Q, Sum, Count + _fakuan_agg = Penalty.query.filter(ApplicantID=yonghuid, Status__in=[1, 3]).aggregate( + count=Count('id'), + amt=Sum('FineAmount'), + ) + fakuan_pending = _fakuan_agg['count'] + fakuan_pending_amt = _fakuan_agg['amt'] jifen_pending = PenaltyRecord.query.filter( ApplicantID=yonghuid, ).filter(Q(ApplyStatus=0) | Q(ApplyStatus=3)).count() @@ -1204,7 +1206,7 @@ class ShangjiaTuikuanShenqingView(APIView): try: dashou_main = User.query.filter( UserUID=dingdan.PlayerID - ).first() + ).select_related('DashouProfile').first() if dashou_main and hasattr(dashou_main, 'DashouProfile'): dashou_profile = dashou_main.DashouProfile dashou_profile.zhuangtai = 1 @@ -1285,7 +1287,7 @@ class ShangjiaChufaShenqingView(APIView): with transaction.atomic(): # 验证打手身份 try: - dashou_user = User.query.get(UserUID=dashou_id) + dashou_user = User.query.select_related('DashouProfile').get(UserUID=dashou_id) dashou = dashou_user.DashouProfile except User.DoesNotExist: return Response({ @@ -1367,13 +1369,16 @@ class ShangjiaChufaShenqingView(APIView): # 🔴【新增】保存处罚图片到数据库 if chufa_tupian_urls and isinstance(chufa_tupian_urls, list): - for tupian_url in chufa_tupian_urls: - if tupian_url and tupian_url.strip(): - PenaltyEvidenceImage.query.create( + _valid_urls = [u.strip() for u in chufa_tupian_urls if u and u.strip()] + if _valid_urls: + PenaltyEvidenceImage.objects.bulk_create([ + PenaltyEvidenceImage( OrderID=dingdan_id, UserID=current_user.UserUID, - ImageURL=tupian_url + ImageURL=url, ) + for url in _valid_urls + ]) # 更新商家订单扩展表的处罚相关字段 shangjia_kuozhan.PenaltyReason = chufa_liyou diff --git a/orders/views/merchant_penalty.py b/orders/views/merchant_penalty.py index d71dff4..eb65aea 100644 --- a/orders/views/merchant_penalty.py +++ b/orders/views/merchant_penalty.py @@ -188,13 +188,15 @@ class ShangjiaFakuanApplyView(APIView): ), ShopStaffUserID=staff_kefu_id, ) - for url in evidence_urls: - PenaltyAppealImage.query.create( + PenaltyAppealImage.objects.bulk_create([ + PenaltyAppealImage( Penalty=fadan, PenalizedUserID=dashou_id, ImageURL=url, Purpose=1, ) + for url in evidence_urls + ]) lock_penalty_bonus(fadan) logger.info(f"商家{shangjia_id}对订单{dingdan_id}打手{dashou_id}提交罚款申请(平台审核),金额{fakuanjine}") return Response({'code': 0, 'msg': '罚款申请已提交,等待平台审核', 'data': {'fadan_id': fadan.id}}) @@ -362,16 +364,17 @@ class ShangjiaFakuaiXiugaiView(APIView): existing_count = PenaltyAppealImage.query.filter(Penalty=fadan, Purpose=1).count() if existing_count + len(add_urls) > 9: return Response({'code': 400, 'msg': '证据图片最多9张'}) - for url in add_urls: - url = str(url).strip() - if not url: - continue - PenaltyAppealImage.query.create( - Penalty=fadan, - PenalizedUserID=fadan.PenalizedUserID, - ImageURL=url, - Purpose=1, - ) + _valid_add_urls = [str(u).strip() for u in add_urls if str(u).strip()] + if _valid_add_urls: + PenaltyAppealImage.objects.bulk_create([ + PenaltyAppealImage( + Penalty=fadan, + PenalizedUserID=fadan.PenalizedUserID, + ImageURL=url, + Purpose=1, + ) + for url in _valid_add_urls + ]) logger.info( f"罚单{fadan.id}证据图变更:新增{len(add_urls)}张,删除{len(remove_ids)}张" ) diff --git a/orders/views/payment.py b/orders/views/payment.py index 3e4af80..3469ed3 100644 --- a/orders/views/payment.py +++ b/orders/views/payment.py @@ -204,7 +204,9 @@ class WechatPayNotifyView(APIView): # 锁定订单防止并发 try: - dingdan = Order.query.select_for_update().filter(OrderID=out_trade_no).first() + dingdan = Order.query.select_for_update().select_related( + 'pingtai_kuozhan', 'shangjia_kuozhan' + ).filter(OrderID=out_trade_no).first() except Exception as e: logger.error(f"查询订单异常: {e}") raise @@ -263,7 +265,7 @@ class WechatPayNotifyView(APIView): if hasattr(dingdan, 'pingtai_kuozhan'): laoban_id = dingdan.pingtai_kuozhan.BossID if laoban_id: - user_main = User.query.filter(UserUID=laoban_id).first() + user_main = User.query.filter(UserUID=laoban_id).select_related('BossProfile').first() if user_main and hasattr(user_main, 'BossProfile'): boss = user_main.BossProfile boss.zonge = (boss.zonge or 0) + (dingdan.Amount or 0) @@ -537,7 +539,7 @@ class PaymentVerifyView(APIView): # 更新老板扩展表 laoban_id = dingdan.pingtai_kuozhan.BossID if hasattr(dingdan, 'pingtai_kuozhan') else None if laoban_id: - user_main = User.query.filter(UserUID=laoban_id).first() + user_main = User.query.filter(UserUID=laoban_id).select_related('BossProfile').first() if user_main and hasattr(user_main, 'BossProfile'): boss = user_main.BossProfile boss.zonge = (boss.zonge or 0) + (dingdan.Amount or 0) @@ -709,13 +711,16 @@ class CreateOrderView(APIView): zhiding_user = User.query.filter( UserUID=zhiding, #user_type='PlayerID' - ).first() + ).select_related('DashouProfile').first() if not zhiding_user: return Response({'code': 7, 'msg': '指定打手不存在', 'data': None}) # 查询打手扩展表 - dashou_profile = UserDashou.query.filter(user=zhiding_user).first() + try: + dashou_profile = zhiding_user.DashouProfile + except UserDashou.DoesNotExist: + dashou_profile = None if not dashou_profile: return Response({'code': 8, 'msg': '指定用户不存在或已封禁', 'data': None}) diff --git a/products/views/product_query.py b/products/views/product_query.py index 6dc17e3..6d72083 100644 --- a/products/views/product_query.py +++ b/products/views/product_query.py @@ -268,35 +268,98 @@ class DashouHuiyuanList(APIView): }, status=status.HTTP_400_BAD_REQUEST) # 3. 仅返回本俱乐部可售会员(club_huiyuan_price 已配置且启用) - from jituan.services.member_recharge import club_huiyuan_sellable, can_purchase_trial - - club_id = resolve_effective_club_id(request, request.user) - huiyuan_queryset = Huiyuan.query.all().only( - 'huiyuan_id', 'jieshao', 'jtjieshao', 'jiage', - ).order_by('jiage') - + from jituan.constants import CLUB_ID_DEFAULT from jituan.models import ClubHuiyuanPrice - club_rows = { - r.huiyuan_id: r - for r in ClubHuiyuanPrice.query.filter(club_id=club_id, is_enabled=True) - } + from decimal import Decimal as _Decimal + + club_id = resolve_effective_club_id(request, request.user) or CLUB_ID_DEFAULT + huiyuan_queryset = list(Huiyuan.query.all().only( + 'huiyuan_id', 'jieshao', 'jtjieshao', 'jiage', + ).order_by('jiage')) + + yonghuid = request.user.UserUID + _huiyuan_ids = [h.huiyuan_id for h in huiyuan_queryset] + + # 批量预取 1:本俱乐部所有 ClubHuiyuanPrice(启用),按 huiyuan_id 索引 + _club_price_map = { + cp.huiyuan_id: cp + for cp in ClubHuiyuanPrice.query.filter( + club_id=club_id, + huiyuan_id__in=_huiyuan_ids, + is_enabled=True, + ) + } if _huiyuan_ids else {} + + # 批量预取 2:当前用户已购 Huiyuangoumai,按 huiyuan_id 索引 + _goumai_map = { + gm.huiyuan_id: gm + for gm in Huiyuangoumai.query.filter( + yonghu_id=yonghuid, + huiyuan_id__in=_huiyuan_ids, + ) + } if _huiyuan_ids else {} + + # 批量预取 3:当前用户在本俱乐部已支付正式会员的 huiyuan_id 集合 + _formal_paid_set = set( + Czjilu.query.filter( + yonghuid=yonghuid, + huiyuan_id__in=_huiyuan_ids, + leixing=1, + zhuangtai=3, + club_id=club_id, + is_trial=False, + ).values_list('huiyuan_id', flat=True) + ) if _huiyuan_ids else set() + + def _local_sellable(cp_row, is_trial): + """单行 ClubHuiyuanPrice 计算 sellable/price/days(避免重复查询)。""" + if not cp_row: + return False, _Decimal('0'), 0 + if is_trial: + if not cp_row.trial_enabled: + return False, _Decimal('0'), 0 + price = _Decimal(str(cp_row.trial_price or 0)) + days = int(cp_row.trial_days or 0) + if price <= 0 or days <= 0: + return False, _Decimal('0'), 0 + return True, price, days + price = _Decimal(str(cp_row.jiage or 0)) + days = int(cp_row.formal_days or 30) + if price <= 0: + return False, _Decimal('0'), 0 + return True, price, days + + def _local_can_trial(huiyuan_id): + """基于已预取的数据判断体验会员是否可购。""" + cp_row = _club_price_map.get(huiyuan_id) + trial_sellable, _, _ = _local_sellable(cp_row, is_trial=True) + if not trial_sellable: + return False + goumai = _goumai_map.get(huiyuan_id) + if goumai and goumai.has_used_trial: + return False + if huiyuan_id in _formal_paid_set: + return False + return True huiyuan_list = [] - yonghuid = request.user.UserUID for huiyuan in huiyuan_queryset: - sellable, club_price, formal_days = club_huiyuan_sellable( - club_id, huiyuan.huiyuan_id, is_trial=False, - ) + cp_row = _club_price_map.get(huiyuan.huiyuan_id) + sellable, club_price, formal_days = _local_sellable(cp_row, is_trial=False) if not sellable or not club_price: continue - trial_ok, _ = can_purchase_trial(yonghuid, huiyuan.huiyuan_id, club_id) - trial_sellable, trial_price, trial_days = club_huiyuan_sellable( - club_id, huiyuan.huiyuan_id, is_trial=True, - ) - club_row = club_rows.get(huiyuan.huiyuan_id) - card_image = (club_row.card_image or '').strip() if club_row else '' - card_bg = (club_row.card_bg or '').strip() if club_row else '' + trial_ok = _local_can_trial(huiyuan.huiyuan_id) + trial_sellable, trial_price, trial_days = _local_sellable(cp_row, is_trial=True) + # 远程新增:card_image / card_bg / is_bundle 字段 + card_image = (cp_row.card_image or '').strip() if cp_row else '' + card_bg = (cp_row.card_bg or '').strip() if cp_row else '' is_bundle = bool(getattr(huiyuan, 'is_bundle', False)) + # 总会员(is_bundle)禁用体验会员 + if is_bundle: + trial_sellable = False + trial_price = _Decimal('0') + trial_days = 0 + trial_ok = False huiyuan_list.append({ 'id': huiyuan.huiyuan_id, 'mingzi': huiyuan.jieshao, @@ -304,10 +367,10 @@ class DashouHuiyuanList(APIView): 'formal_days': formal_days, 'jieshao': huiyuan.jtjieshao, 'is_bundle': is_bundle, - 'trial_enabled': False if is_bundle else bool(trial_sellable), - 'trial_price': '0.00' if is_bundle else (format_yuan(trial_price) if trial_sellable else '0.00'), - 'trial_days': 0 if is_bundle else (trial_days if trial_sellable else 0), - 'can_buy_trial': False if is_bundle else bool(trial_ok and trial_sellable), + 'trial_enabled': bool(trial_sellable), + 'trial_price': format_yuan(trial_price) if trial_sellable else '0.00', + 'trial_days': trial_days if trial_sellable else 0, + 'can_buy_trial': bool(trial_ok and trial_sellable), 'card_image': card_image, 'card_bg': card_bg, }) @@ -475,20 +538,21 @@ class AdGetAllDataView(APIView): # 6. 查询商品类型数据 try: - # 使用select_related或prefetch_related优化查询(如果需要关联查询) - # 这里直接查询所有字段,然后提取需要的字段 - shangpinleixing_queryset = ShangpinLeixing.query.all() + # 优化:使用 .values() 直接提取字段,避免模型实例化开销(字典表 <10 条) + shangpinleixing_rows = ShangpinLeixing.query.all().values( + 'id', 'yaoqiuleixing', 'huiyuan_id', 'yongjin', 'jieshao', 'tupian_url' + ) # 转换为前端需要的格式 shangpinleixing_list = [] - for leixing in shangpinleixing_queryset: + for row in shangpinleixing_rows: leixing_data = { - 'id': leixing.id, - 'yaoqiuleixing': leixing.yaoqiuleixing if leixing.yaoqiuleixing is not None else None, - 'huiyuan_id': leixing.huiyuan_id if leixing.huiyuan_id else None, - 'yongjin': str(leixing.yongjin) if leixing.yongjin is not None else None, - 'jieshao': leixing.jieshao if leixing.jieshao else '', - 'tupian_url': leixing.tupian_url if leixing.tupian_url else '' + 'id': row['id'], + 'yaoqiuleixing': row['yaoqiuleixing'], + 'huiyuan_id': row['huiyuan_id'] or None, + 'yongjin': str(row['yongjin']) if row['yongjin'] is not None else None, + 'jieshao': row['jieshao'] or '', + 'tupian_url': row['tupian_url'] or '' } shangpinleixing_list.append(leixing_data) @@ -500,16 +564,16 @@ class AdGetAllDataView(APIView): # 7. 查询商品专区数据 try: - # 查询所有商品专区 - zhuanqu_queryset = ShangpinZhuanqu.query.all() + # 优化:使用 .values() 直接提取字段(字典表 <50 条) + zhuanqu_rows = ShangpinZhuanqu.query.all().values('id', 'mingzi', 'leixing_id') # 转换为前端需要的格式 zhuanqu_list = [] - for zhuanqu in zhuanqu_queryset: + for row in zhuanqu_rows: zhuanqu_data = { - 'id': zhuanqu.id, - 'mingzi': zhuanqu.mingzi if zhuanqu.mingzi else '', - 'leixing_id': zhuanqu.leixing_id + 'id': row['id'], + 'mingzi': row['mingzi'] or '', + 'leixing_id': row['leixing_id'] } zhuanqu_list.append(zhuanqu_data) @@ -593,17 +657,20 @@ class HuiyuanTypeListView(APIView): }, status=status.HTTP_401_UNAUTHORIZED) # 🔥 2. 获取所有会员类型 - huiyuan_list = Huiyuan.query.all().order_by('CreateTime') - + # 优化:使用 .values() 直接提取字段,避免模型实例化开销 + huiyuan_rows = Huiyuan.query.all().order_by('CreateTime').values( + 'huiyuan_id', 'jieshao', 'jiage', 'goumai_cishu', 'CreateTime' + ) + # 🔥 3. 构建返回数据 huiyuan_data = [] - for huiyuan in huiyuan_list: + for row in huiyuan_rows: huiyuan_data.append({ - 'id': huiyuan.huiyuan_id, # 会员ID - 'jieshao': huiyuan.jieshao, # 会员介绍 - 'jiage': str(huiyuan.jiage), # 价格 - 'goumai_cishu': huiyuan.goumai_cishu, # 购买次数 - 'CreateTime': huiyuan.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if huiyuan.CreateTime else None + 'id': row['huiyuan_id'], # 会员ID + 'jieshao': row['jieshao'], # 会员介绍 + 'jiage': str(row['jiage']), # 价格 + 'goumai_cishu': row['goumai_cishu'], # 购买次数 + 'CreateTime': row['CreateTime'].strftime('%Y-%m-%d %H:%M:%S') if row['CreateTime'] else None }) return Response({ @@ -757,34 +824,40 @@ class HuiyuanRecordListView(APIView): has_more = (page * pagesize) < total_count # 🔥 9. 获取会员类型统计信息(所有会员类型的当前状态总数) - huiyuan_types = Huiyuan.query.all() - huiyuan_stats = [] - - for huiyuan in huiyuan_types: - # 使用COUNT查询统计 - stats_sql = """ - SELECT COUNT(*) - FROM huiyuangoumai - WHERE huiyuan_id = %s - """ - stats_params = [huiyuan.huiyuan_id] - - if jiluzhuangtai == 1: # 未过期 - stats_sql += " AND daoqi_time > %s" - stats_params.append(now) - else: # 已过期 - stats_sql += " AND daoqi_time <= %s" - stats_params.append(now) - - with connection.cursor() as cursor: - cursor.execute(stats_sql, stats_params) - count = cursor.fetchone()[0] - - huiyuan_stats.append({ - 'id': huiyuan.huiyuan_id, - 'jieshao': huiyuan.jieshao, - 'count': count - }) + # 优化:原实现为 N+1 查询(外层遍历 Huiyuan 全表 + 内层每条记录执行一次 COUNT SQL) + # 改为单条 LEFT JOIN + GROUP BY 聚合,将 N+1 次查询降为 1 次 + # 注意:Huiyuangoumai.huiyuan_id 是 CharField 而非 ForeignKey,无法用 ORM annotate + stats_sql = """ + SELECT + h.huiyuan_id, + h.jieshao, + COUNT(hg.id) AS count + FROM huiyuan h + LEFT JOIN huiyuangoumai hg + ON hg.huiyuan_id = h.huiyuan_id + """ + stats_params = [] + if jiluzhuangtai == 1: # 未过期 + # 条件放在 ON 子句而非 WHERE,确保无购买记录的会员类型仍返回 count=0 + stats_sql += " AND hg.daoqi_time > %s" + stats_params.append(now) + else: # 已过期 + stats_sql += " AND hg.daoqi_time <= %s" + stats_params.append(now) + stats_sql += " GROUP BY h.huiyuan_id, h.jieshao" + + with connection.cursor() as cursor: + cursor.execute(stats_sql, stats_params) + stats_rows = cursor.fetchall() + + huiyuan_stats = [ + { + 'id': row[0], + 'jieshao': row[1], + 'count': row[2] + } + for row in stats_rows + ] return Response({ 'code': 0, diff --git a/rank/reward_houtai_views.py b/rank/reward_houtai_views.py index cd2dec4..cb77bf1 100644 --- a/rank/reward_houtai_views.py +++ b/rank/reward_houtai_views.py @@ -35,8 +35,9 @@ def _has_perm(permissions): return any(p in permissions for p in REWARD_PERMS) -def _serialize_scheme(scheme): - tiers = RankRewardTier.query.filter(scheme_id=scheme.id).order_by('rank_from') +def _serialize_scheme(scheme, tiers=None): + if tiers is None: + tiers = RankRewardTier.query.filter(scheme_id=scheme.id).order_by('rank_from') return { 'id': scheme.id, 'club_id': scheme.club_id, @@ -78,11 +79,18 @@ class RankRewardHoutaiListView(APIView): club_id = resolve_club_id_from_request(request) schemes = RankRewardScheme.query.filter(club_id=club_id).order_by('shenfen', 'period_type') + # 批量预取所有 tiers,避免列表推导内 N+1 + schemes_list = list(schemes) + _scheme_ids = [s.id for s in schemes_list] + _tiers_map = {} + if _scheme_ids: + for t in RankRewardTier.query.filter(scheme_id__in=_scheme_ids).order_by('scheme_id', 'rank_from'): + _tiers_map.setdefault(t.scheme_id, []).append(t) return Response({ 'code': 0, 'data': { 'club_id': club_id, - 'schemes': [_serialize_scheme(s) for s in schemes], + 'schemes': [_serialize_scheme(s, _tiers_map.get(s.id, [])) for s in schemes_list], 'shenfen_options': list(SORT_FIELD_REGISTRY.keys()), 'period_options': [ {'key': RankRewardScheme.PERIOD_DAY, 'label': '日榜(昨日)'}, @@ -148,14 +156,16 @@ class RankRewardHoutaiSaveView(APIView): ) RankRewardTier.query.filter(scheme_id=scheme.id).delete() - for t in tiers: - RankRewardTier.query.create( + RankRewardTier.objects.bulk_create([ + RankRewardTier( scheme_id=scheme.id, rank_from=int(t['rank_from']), rank_to=int(t['rank_to']), reward_amount=t['reward_amount'], label=(t.get('label') or '').strip(), ) + for t in tiers + ]) return Response({'code': 0, 'msg': '保存成功', 'data': _serialize_scheme(scheme)}) diff --git a/rank/reward_services.py b/rank/reward_services.py index cbf77dc..f026644 100644 --- a/rank/reward_services.py +++ b/rank/reward_services.py @@ -574,11 +574,36 @@ def maybe_settle_club_rewards(club_id: str, shenfen: str, riqi: str) -> Optional return None min_metric = scheme.min_metric or Decimal('0') - club_map = load_user_club_map([r['yonghuid'] for r in rows]) + _all_uids = [r['yonghuid'] for r in rows] + club_map = load_user_club_map(_all_uids) expire_at = timezone.now() + timedelta(days=scheme.claim_days or 30) balance_field = BALANCE_FIELD_MAP[shenfen] claims_to_create = [] + # 批量预取活跃用户集合,避免循环内 exists 查询 + if shenfen == 'dashou': + _active_uids = set( + UserDashou.objects.filter(user__UserUID__in=_all_uids, zhanghaozhuangtai=1) + .values_list('user__UserUID', flat=True) + ) + elif shenfen == 'guanshi': + _active_uids = set( + UserGuanshi.objects.filter(user__UserUID__in=_all_uids, zhuangtai=1) + .values_list('user__UserUID', flat=True) + ) + elif shenfen == 'zuzhang': + _active_uids = set( + UserZuzhang.objects.filter(user__UserUID__in=_all_uids, zhuangtai=1) + .values_list('user__UserUID', flat=True) + ) + elif shenfen == 'shangjia': + _active_uids = set( + UserShangjia.objects.filter(user__UserUID__in=_all_uids, zhuangtai=1) + .values_list('user__UserUID', flat=True) + ) + else: + _active_uids = set() + for idx, row in enumerate(rows): mingci = idx + 1 metric = Decimal(str(row['metric'] or 0)) @@ -590,7 +615,7 @@ def maybe_settle_club_rewards(club_id: str, shenfen: str, riqi: str) -> Optional uid = row['yonghuid'] if club_map.get(uid) != club_id: continue - if not user_has_active_shenfen(uid, shenfen): + if uid not in _active_uids: continue idem = f'{club_id}|{scheme.id}|{period_key}|{uid}' claims_to_create.append(RankRewardClaim( @@ -618,14 +643,14 @@ def maybe_settle_club_rewards(club_id: str, shenfen: str, riqi: str) -> Optional sort_field=scheme.sort_field, claim_count=0, ) - created = 0 - for claim in claims_to_create: - claim.settlement_id = settlement.id - try: - claim.save() - created += 1 - except IntegrityError: - pass + # 批量创建 claims,用 ignore_conflicts 处理幂等冲突 + if claims_to_create: + for claim in claims_to_create: + claim.settlement_id = settlement.id + RankRewardClaim.objects.bulk_create(claims_to_create, ignore_conflicts=True) + created = RankRewardClaim.objects.filter(settlement_id=settlement.id).count() + else: + created = 0 settlement.claim_count = created settlement.save(update_fields=['claim_count']) return settlement diff --git a/rank/views/biaoqian.py b/rank/views/biaoqian.py index dde3b3b..50f2acd 100644 --- a/rank/views/biaoqian.py +++ b/rank/views/biaoqian.py @@ -3,7 +3,7 @@ import json import logging from decimal import Decimal from django.db import models, transaction -from django.db.models import F +from django.db.models import F, Prefetch from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated @@ -118,15 +118,26 @@ class KaoheJinpaiTagsView(APIView): .values_list('chenghao__mingcheng', flat=True) ) - # 板块列表 + # 板块列表(prefetch 一次性加载标签和费用,避免三层嵌套 N+1) plate_list = [] - for bk in Bankuai.objects.all(): - tags = Chenghao.objects.filter(bankuai=bk, leixing='dashou') + all_bankuai = Bankuai.objects.prefetch_related( + Prefetch( + 'chenghao_set', + queryset=Chenghao.objects.filter(leixing='dashou').prefetch_related( + Prefetch( + 'kaohecishufeiyong_set', + queryset=KaoheCishuFeiyong.objects.order_by('cishu'), + ) + ), + to_attr='dashou_tags' + ) + ).all() + for bk in all_bankuai: tag_list = [] - for tag in tags: - # 费用列表 - fee_objs = KaoheCishuFeiyong.objects.filter(chenghao=tag).order_by('cishu') - fee_list = [{'cishu': f.cishu, 'feiyong': float(f.feiyong)} for f in fee_objs] + for tag in getattr(bk, 'dashou_tags', []): + # 费用列表(已预加载,保持 cishu 升序) + fee_list = [{'cishu': f.cishu, 'feiyong': float(f.feiyong)} + for f in tag.kaohecishufeiyong_set.all()] # 若没有记录,补充默认费用 if not fee_list: @@ -151,12 +162,13 @@ class KaoheJinpaiTagsView(APIView): # 新增:返回所有在职考核官信息(用于指定考核官) kaoheguan_list = [] - for kg in UserShenheguan.objects.filter(zhuangtai=1).select_related('user'): + # select_related 预加载 OneToOne 反向关系 DashouProfile,避免循环内 N+1 + for kg in UserShenheguan.objects.filter(zhuangtai=1).select_related('user', 'user__DashouProfile'): uid = kg.user.UserUID avatar = kg.user.Avatar or '' - # 获取考核官的打手昵称(从打手扩展表) + # 获取考核官的打手昵称(从打手扩展表,已预加载) dashou_nick = '' - dashou_prof = UserDashou.objects.filter(user=kg.user).first() + dashou_prof = getattr(kg.user, 'DashouProfile', None) if dashou_prof: dashou_nick = dashou_prof.nicheng or '' kaoheguan_list.append({ diff --git a/rank/views/kaohe_jilu.py b/rank/views/kaohe_jilu.py index aa4294b..69c587b 100644 --- a/rank/views/kaohe_jilu.py +++ b/rank/views/kaohe_jilu.py @@ -48,20 +48,42 @@ class KaoheJiluView(APIView): total = query.count() records = query.order_by('-CreateTime')[(page-1)*page_size : page*page_size] + # 批量预取关联数据,避免循环内 N+1 + records = records.select_related('chenghao', 'bankuai') + records_list = list(records) + jilu_ids = [jl.jilu_id for jl in records_list] + + # 批量汇总缴纳总额(按 jilu_id 分组聚合) + _fee_map = {} + if jilu_ids: + _fee_agg = KaoheJiluFeiyong.objects.filter( + jilu_id__in=jilu_ids + ).values('jilu_id').annotate(total=models.Sum('jine')) + for item in _fee_agg: + _fee_map[item['jilu_id']] = float(item['total'] or 0.00) + + # 批量获取上次拒绝原因(每个 jilu_id 取最近一条) + _fail_map = {} + if jilu_ids: + _reject_qs = KaoheJujueJilu.objects.filter( + jilu_id__in=jilu_ids + ).order_by('jilu_id', '-CreateTime') + for rej in _reject_qs: + if rej.jilu_id not in _fail_map: # 只取首条(最近) + _fail_map[rej.jilu_id] = rej.yuanyin or '' + + # 批量获取考核官头像 + _avatar_map = {} + _kg_ids = {jl.shenheguan_id for jl in records_list if jl.shenheguan_id} + if _kg_ids: + for u in User.objects.filter(UserUID__in=_kg_ids): + _avatar_map[u.UserUID] = u.Avatar or '' + data = [] - for jl in records: - # 汇总缴纳总额 - total_fee = KaoheJiluFeiyong.objects.filter(jilu=jl).aggregate( - total=models.Sum('jine') - )['total'] or 0.00 - - # 上次拒绝原因(最近一条) - last_fail = KaoheJujueJilu.objects.filter(jilu=jl).order_by('-CreateTime').first() - fail_reason = last_fail.yuanyin if last_fail else '' - - # 考核官头像 - kg_user = User.objects.filter(UserUID=jl.shenheguan_id).first() - kg_avatar = kg_user.Avatar if kg_user else '' + for jl in records_list: + total_fee = _fee_map.get(jl.jilu_id, 0.00) + fail_reason = _fail_map.get(jl.jilu_id, '') + kg_avatar = _avatar_map.get(jl.shenheguan_id, '') data.append({ 'jilu_id': jl.jilu_id, diff --git a/rank/views/shenhe.py b/rank/views/shenhe.py index 3018704..6b0c636 100644 --- a/rank/views/shenhe.py +++ b/rank/views/shenhe.py @@ -3,7 +3,7 @@ import json import logging from decimal import Decimal from django.db import models, transaction -from django.db.models import F +from django.db.models import F, Count, Q from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated @@ -33,12 +33,17 @@ class ShenheDatingModuleView(APIView): .values_list('bankuai_id', flat=True)) bankuai_list = [] - for bk in Bankuai.objects.all(): - count = ShenheJilu.objects.filter(bankuai=bk, zhuangtai=3, shenheguan_id__isnull=True).count() + all_bankuai = Bankuai.objects.annotate( + pending_count=Count( + 'shenhejilu', + filter=Q(shenhejilu__zhuangtai=3, shenhejilu__shenheguan_id__isnull=True) + ) + ) + for bk in all_bankuai: bankuai_list.append({ 'bankuai_id': bk.bankuai_id, 'mingcheng': bk.mingcheng, - 'count': count, + 'count': bk.pending_count, 'is_related': bk.bankuai_id in related_ids }) @@ -67,9 +72,13 @@ class ShenheDatingDataView(APIView): jilu_list = query.select_related('bankuai', 'chenghao').order_by('-CreateTime')[ offset:offset + page_size] + # 批量加载申请人用户,避免循环内 N+1 + shenqingren_ids = [jl.shenqingren_id for jl in jilu_list if jl.shenqingren_id] + user_map = {u.UserUID: u for u in User.objects.filter(UserUID__in=shenqingren_ids)} + records = [] for jl in jilu_list: - user = User.objects.filter(UserUID=jl.shenqingren_id).first() + user = user_map.get(jl.shenqingren_id) avatar = user.Avatar if user else '' texiao_json = {} if jl.chenghao and jl.chenghao.texiao_miaoshu: @@ -202,9 +211,27 @@ class ShenheXiangqingView(APIView): offset = (page - 1) * page_size jilu_list = base_query[offset:offset + page_size] + # 批量加载申请人用户,避免循环内 N+1 + shenqingren_ids = [jl.shenqingren_id for jl in jilu_list if jl.shenqingren_id] + user_map = {u.UserUID: u for u in User.objects.filter(UserUID__in=shenqingren_ids)} + + # 批量预取费用明细和拒绝记录,避免循环内 N+1 + _jilu_ids = [jl.jilu_id for jl in jilu_list] + _fee_detail_map = {} # (jilu_id, cishu) -> jine + _fail_map = {} # (jilu_id, cishu) -> yuanyin + if _jilu_ids: + for _fee in KaoheJiluFeiyong.objects.filter(jilu_id__in=_jilu_ids): + _fee_detail_map[(_fee.jilu_id, _fee.cishu)] = float(_fee.jine) + for _rej in KaoheJujueJilu.objects.filter( + jilu_id__in=_jilu_ids + ).order_by('jilu_id', 'cishu', '-CreateTime'): + _key = (_rej.jilu_id, _rej.cishu) + if _key not in _fail_map: # 只取首条(最近) + _fail_map[_key] = _rej.yuanyin or '' + records = [] for jl in jilu_list: - user = User.objects.filter(UserUID=jl.shenqingren_id).first() + user = user_map.get(jl.shenqingren_id) avatar = user.Avatar if user else '' texiao_json = {} if jl.chenghao and jl.chenghao.texiao_miaoshu: @@ -216,25 +243,10 @@ class ShenheXiangqingView(APIView): # 是否开启金牌 is_jinpai = jl.chenghao.kaioi_jinpai if jl.chenghao else False - # 本次待收考核费(基于记录中的当前次数,从费用明细表查询) - daishou_fei = 0.00 - fee_detail = KaoheJiluFeiyong.objects.filter( - jilu=jl, - cishu=jl.cishu - ).first() - if fee_detail: - daishou_fei = float(fee_detail.jine) - else: - # 兜底:若明细表无记录,尝试用通用费用表计算 - daishou_fei = 0.00 - # 上次失败原因 - last_fail_reason = '' - last_fail = KaoheJujueJilu.objects.filter( - jilu=jl, - cishu=jl.cishu - 1 - ).order_by('-CreateTime').first() - if last_fail: - last_fail_reason = last_fail.yuanyin + # 本次待收考核费(从预取 dict 查询) + daishou_fei = _fee_detail_map.get((jl.jilu_id, jl.cishu), 0.00) + # 上次失败原因(从预取 dict 查询) + last_fail_reason = _fail_map.get((jl.jilu_id, jl.cishu - 1), '') records.append({ 'jilu_id': jl.jilu_id, diff --git a/shop/views/shop_order_views.py b/shop/views/shop_order_views.py index 2de0a08..a151196 100644 --- a/shop/views/shop_order_views.py +++ b/shop/views/shop_order_views.py @@ -15,7 +15,6 @@ import threading import xmltodict from decimal import Decimal from collections import defaultdict -from concurrent.futures import ThreadPoolExecutor, as_completed from django.conf import settings from django.db import models, transaction, IntegrityError @@ -260,38 +259,66 @@ def _fetch_images(order, oss_domain): # ================================================================ -# 辅助函数:并发获取多个订单的打手图片 +# 辅助函数:批量获取多个订单的打手图片(单次 DB 查询) # ================================================================ def _batch_images(orders, oss_domain): """ - 并发调用 _fetch_images,提升批量获取图片的速度。 + 批量获取订单的打手提交图片,使用单次 DB 查询替代循环/线程池 N 次查询。 参数: orders: Dingdan 订单对象列表 oss_domain: OSS 域名 返回: - 字典,键为订单ID (dingdan_id),值为 (images_list, is_punished, punishment_info) + 字典,键为订单ID (OrderID),值为 (images_list, is_punished, punishment_info) """ - result = {} # 存储结果 - # 使用线程池并发执行,max_workers 可根据服务器性能调整 - with ThreadPoolExecutor(max_workers=10) as executor: - # 建立 future 到订单ID的映射 - future_to_oid = { - executor.submit(_fetch_images, order, oss_domain): order.OrderID - for order in orders - } - # 等待所有任务完成 - for future in as_completed(future_to_oid): - oid = future_to_oid[future] # 对应的订单ID - try: - # 获取任务返回值 - imgs, pun, pinfo = future.result() - result[oid] = (imgs, pun, pinfo) - except Exception as e: - # 某个订单获取失败,记录日志并赋予空值,保证整体流程不中断 - logger.error(f"获取订单 {oid} 图片失败: {e}") - result[oid] = ([], False, {}) + result = {} + # 先为所有订单填充默认空值,保证调用方可拿到键 + for order in orders: + result[order.OrderID] = ([], False, {}) + + # 收集所有 (OrderID, PlayerID) 组合用于批量查询 + _order_player_pairs = [ + (order.OrderID, order.PlayerID) + for order in orders + if order.PlayerID + ] + if not _order_player_pairs: + return result + + # 单次批量查询所有相关图片记录(避免循环 N 次或线程池掩盖 N 次查询) + # PlayerDeliveryImage 表的主键是 OrderID + PlayerID,使用元组列表过滤 + _order_ids = [pair[0] for pair in _order_player_pairs] + _pair_set = set(_order_player_pairs) + + try: + rows = list( + PlayerDeliveryImage.query.filter( + OrderID__in=_order_ids + ).values_list('OrderID', 'PlayerID', 'ImageURL') + ) + except Exception as e: + logger.error(f"_batch_images 批量查询图片失败: {e}") + return result + + # 按 (OrderID, PlayerID) 分组 + _img_map = {} + for oid, pid, img_path in rows: + if not img_path: + continue + # 仅保留属于本批 orders 且 PlayerID 匹配的记录(防止跨打手串号) + if (oid, pid) not in _pair_set: + continue + if img_path.startswith('http'): + url = img_path + else: + url = oss_domain + img_path.lstrip('/') + _img_map.setdefault(oid, []).append(url) + + # 把分组结果写回 result + for oid, imgs in _img_map.items(): + if oid in result: + result[oid] = (imgs, False, {}) return result diff --git a/shop/views/shop_product_views.py b/shop/views/shop_product_views.py index 40c497d..96d84ab 100644 --- a/shop/views/shop_product_views.py +++ b/shop/views/shop_product_views.py @@ -771,10 +771,12 @@ class ShopProductModifyView1(APIView): else: # 自动从公共类型获取会员ID,或转为佣金 try: - shop_type = ShangpinLeixingDianpu.query.get( + shop_type = ShangpinLeixingDianpu.query.select_related('gonggong_leixing').get( id=product.dianpu_leixing_id, dianpu_id=dianpu.id ) - public_type = ShangpinLeixing.query.get(id=shop_type.gonggong_leixing_id) + public_type = shop_type.gonggong_leixing + if not public_type: + raise ShangpinLeixing.DoesNotExist auto_huiyuan_id = public_type.huiyuan_id if auto_huiyuan_id: product.huiyuan_id = auto_huiyuan_id @@ -893,13 +895,12 @@ class ShopProductModifyView1(APIView): return Response({'code': 400, 'msg': '必填项缺失'}) try: - shop_type = ShangpinLeixingDianpu.query.get(id=dianpu_leixing_id, dianpu_id=dianpu.id) + shop_type = ShangpinLeixingDianpu.query.select_related('gonggong_leixing').get(id=dianpu_leixing_id, dianpu_id=dianpu.id) except ShangpinLeixingDianpu.DoesNotExist: return Response({'code': 400, 'msg': '无效的店铺商品类型'}) - try: - public_type = ShangpinLeixing.query.get(id=shop_type.gonggong_leixing_id, shenhezhuangtai=1) - except ShangpinLeixing.DoesNotExist: + public_type = shop_type.gonggong_leixing + if not public_type or public_type.shenhezhuangtai != 1: return Response({'code': 400, 'msg': '关联的公共商品类型无效'}) # 商品图片上传 diff --git a/shop/views/shop_refund_views.py b/shop/views/shop_refund_views.py index 0786cba..2ceb5ce 100644 --- a/shop/views/shop_refund_views.py +++ b/shop/views/shop_refund_views.py @@ -125,7 +125,7 @@ class ShopRefundView(APIView): # 更新打手统计(退款量+1,状态置为空闲) if dashou_id: try: - dashou_user = User.query.get(UserUID=dashou_id) + dashou_user = User.query.select_related('DashouProfile').get(UserUID=dashou_id) dashou_profile = dashou_user.DashouProfile dashou_profile.tuikuanliang += 1 dashou_profile.zhuangtai = 1 @@ -368,7 +368,7 @@ class ShopForceCompleteView(APIView): # ---------- 更新打手余额和统计 ---------- try: - dashou_user = User.query.get(UserUID=dashou_id) + dashou_user = User.query.select_related('DashouProfile').get(UserUID=dashou_id) dashou_profile = dashou_user.DashouProfile dashou_profile.chengjiaozongliang += 1 dashou_profile.yue += dashou_fencheng diff --git a/users/views/__init__.py b/users/views/__init__.py index f8be391..356e275 100644 --- a/users/views/__init__.py +++ b/users/views/__init__.py @@ -88,29 +88,41 @@ from .chufa import ( ShangjiaChufaJiluHuoquView, ) -from .kefu import ( +from .kefu_base import ( + KefuGetOrderTypesView, + KefuLoginView, + KefuStatsView, +) + +from .kefu_orders import ( KefuCancelDesignationView, KefuChangeDashouView, KefuForceCompleteView, - KefuGetDashouDetailView, - KefuGetDashouListView, KefuGetOrderDetailView, KefuGetOrderListView, - KefuGetOrderTypesView, KefuGetShangjiaOrderListView, - KefuLoginView, KefuMerchantRefundView, KefuPlatformRefundView, + KefuRecoverOrderView, + KefuRejectRefundView, + KefuRejectSettlementView, + KefuTransferHallView, +) + +from .kefu_dashou import ( + KefuGetDashouDetailView, + KefuGetDashouListView, + KefuUpdateDashouView, +) + +from .kefu_punishment import ( KefuPunishView, KefuPunishmentActionView, KefuPunishmentDetailView, KefuPunishmentListView, - KefuRecoverOrderView, - KefuRejectRefundView, - KefuRejectSettlementView, - KefuStatsView, - KefuTransferHallView, - KefuUpdateDashouView, +) + +from .kefu_withdraw import ( KefuWithdrawActionView, KefuWithdrawDetailView, KefuWithdrawListView, diff --git a/users/views/admin.py b/users/views/admin.py index 288e3fd..3adef87 100644 --- a/users/views/admin.py +++ b/users/views/admin.py @@ -532,12 +532,18 @@ class AdGetOrderList(APIView): # 注意:这里统计所有订单,不考虑筛选条件 try: # 平台订单统计 (Platform=1) - platform_total = Order.query.filter(Platform=1).count() - platform_completed = Order.query.filter(Platform=1, Status=3).count() + _agg = Order.query.aggregate( + platform_total=Count('id', filter=Q(Platform=1)), + platform_completed=Count('id', filter=Q(Platform=1, Status=3)), + merchant_total=Count('id', filter=Q(Platform=2)), + merchant_completed=Count('id', filter=Q(Platform=2, Status=3)), + ) + platform_total = _agg['platform_total'] + platform_completed = _agg['platform_completed'] # 商家订单统计 (Platform=2) - merchant_total = Order.query.filter(Platform=2).count() - merchant_completed = Order.query.filter(Platform=2, Status=3).count() + merchant_total = _agg['merchant_total'] + merchant_completed = _agg['merchant_completed'] platform_stats = { 'total': platform_total, @@ -1170,9 +1176,14 @@ class CfGuanLi(APIView): ) # 7. 获取统计信息(总是需要统计) - zongshu = PenaltyRecord.query.count() - daichuli = PenaltyRecord.query.filter(ApplyStatus__in=[0, 3]).count() - yichuli = PenaltyRecord.query.filter(ApplyStatus__in=[1, 2]).count() + _agg = PenaltyRecord.query.aggregate( + zongshu=Count('id'), + daichuli=Count('id', filter=Q(ApplyStatus__in=[0, 3])), + yichuli=Count('id', filter=Q(ApplyStatus__in=[1, 2])), + ) + zongshu = _agg['zongshu'] + daichuli = _agg['daichuli'] + yichuli = _agg['yichuli'] # 8. 如果只请求统计信息,直接返回 if qingqiu_tongji: @@ -1687,6 +1698,13 @@ class AdckyhxqView(APIView): yonghu_id=uid ).order_by('-CreateTime') + # 批量预取会员详情 + _huiyuan_ids = [r.huiyuan_id for r in member_records] + _huiyuan_map = { + h.huiyuan_id: h + for h in Huiyuan.query.filter(huiyuan_id__in=_huiyuan_ids).only('huiyuan_id', 'jieshao') + } + member_list = [] for record in member_records: # 调用方法检查是否过期,并更新状态 @@ -1694,9 +1712,7 @@ class AdckyhxqView(APIView): if not is_expired: # 只返回未过期的 # 查询会员详情 - huiyuan = Huiyuan.query.filter( - huiyuan_id=record.huiyuan_id - ).first() + huiyuan = _huiyuan_map.get(record.huiyuan_id) if huiyuan: member_list.append({ @@ -2282,15 +2298,13 @@ class AddtxshView(APIView): has_more = (offset + current_count) < total_count # 获取状态统计(统计与搜索无关,依然按筛选条件统计) + _agg = Tixianjilu.query.filter(leixing=leixing).aggregate( + awaiting=Count('id', filter=Q(zhuangtai=1)), + processed=Count('id', filter=Q(zhuangtai__in=[2, 3])), + ) status_counts = { - 'awaiting': Tixianjilu.query.filter( - zhuangtai=1, - leixing=leixing - ).count(), - 'processed': Tixianjilu.query.filter( - zhuangtai__in=[2, 3], - leixing=leixing - ).count() + 'awaiting': _agg['awaiting'], + 'processed': _agg['processed'], } # 构建响应数据 diff --git a/users/views/chufa.py b/users/views/chufa.py index 4908bd8..1577c30 100644 --- a/users/views/chufa.py +++ b/users/views/chufa.py @@ -526,16 +526,16 @@ class DashouShensuView(APIView): chufa_record.save() # 6. 保存申诉图片(如果有) - saved_tupian_urls = [] - if shensu_tupian_urls: - for tupian_url in shensu_tupian_urls: - if tupian_url: # 确保URL不为空 - chufa_tupian = PenaltyEvidenceImage.query.create( - OrderID=chufa_record.OrderID, - UserID=yonghuid, - ImageURL=tupian_url - ) - saved_tupian_urls.append(tupian_url) + saved_tupian_urls = [u for u in shensu_tupian_urls if u] if shensu_tupian_urls else [] + if saved_tupian_urls: + PenaltyEvidenceImage.objects.bulk_create([ + PenaltyEvidenceImage( + OrderID=chufa_record.OrderID, + UserID=yonghuid, + ImageURL=tupian_url, + ) + for tupian_url in saved_tupian_urls + ]) # 7. 构建返回数据 response_data = { diff --git a/users/views/kefu.py b/users/views/kefu.py deleted file mode 100644 index b53ae32..0000000 --- a/users/views/kefu.py +++ /dev/null @@ -1,3794 +0,0 @@ -"""users.views.kefu - auto-generated by split script.""" -import os -import sys -import json -import re -import time -import uuid -import random -import string -import requests -import hashlib -import traceback -import decimal -import jwt -import ipaddress -import xmltodict -from datetime import datetime, timedelta -from decimal import Decimal -import defusedxml.ElementTree as ET - -from django.conf import settings -from django.db import models, transaction, IntegrityError, DatabaseError, connection -from django.db.models import Q, F, Sum, Count, Case, When, IntegerField, Prefetch -from django.core.cache import cache -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.core.exceptions import ObjectDoesNotExist -from django.shortcuts import get_object_or_404 -from django.utils import timezone -from django.http import HttpResponse -from django.views import View -from django.views.decorators.csrf import csrf_exempt -from django.contrib.auth.hashers import make_password - -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import permissions, status -from rest_framework.permissions import AllowAny, IsAuthenticated -from rest_framework.parsers import MultiPartParser, FormParser, JSONParser -from rest_framework.throttling import AnonRateThrottle -from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework_simplejwt.authentication import JWTAuthentication - -from gvsdsdk.fluent import db, func, FQ - -from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss -from utils.money import yuan_to_fen -from utils.fadan_utils import check_fadan_qiangdan_eligible -from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode -from users.fadan_fenhong_utils import process_fadan_fenhong - -from orders.utils import ( - update_daily_payout, - settle_shangjia_order_guanshi_fenhong -) -from backend.utils import ( - update_dashou_daily_by_action, update_guanshi_daily_by_action, - update_shangjia_daily, update_zuzhang_daily_by_action, - verify_kefu_permission -) -from rank.utils import get_tag_fee, create_shenhe_jilu, validate_shenheguan - -from ..models import ( - UserBoss, UserDashou, UserShangjia, UserGuanshi, - UserZuzhang, UserKefu, AdminProfile, OfficialAccountUser, - TixianAutoRecord, TixianShenheJilu, Tixianjilu, Xiugaijilu, - RankingRecord -) -from users.business_models import User -from ..tixian_shenhe_services import ( - load_audit_meta_map, refund_balance, sync_audit_and_jilu_status, - mark_transfer_success, release_collect_quota_for_record, - close_audit_wechat_failed, query_wechat_transfer_bill, - check_shijidaozhang_within_limit, - check_dashou_trial_blocks_commission_withdraw, - WX_STATE_FAIL, WX_STATE_SUCCESS, WX_STATE_WAIT, WX_STATE_CANCELING, -) -from ..tixian_shenhe_views import process_audit_collect - -from orders.models import ( - Order, PlatformOrderExt, MerchantOrderExt, CommissionRate, - PenaltyRecord, PenaltyEvidenceImage, RefundRecord, PlayerDeliveryImage, - Penalty, PenaltyAppealImage -) -from products.models import ( - ShangpinLeixing, Huiyuan, Huiyuangoumai, Gsfenhong, Czjilu -) -from config.models import Qunpeizhi -from backend.models import MerchantDailyStats -from rank.models import ( - KaohePayTemp, Chenghao, ShenheJilu, - KaoheCishuFeiyong, YonghuChenghao -) -from users.models import UserShenheguan -from products.utils import update_shangpin_daily_stat -from shop.utils import update_dianpu_daily_stat -from rank.utils import create_shenhe_jilu_from_temp - -import logging - -logger = logging.getLogger(__name__) - -class KefuLoginView(APIView): - """ - 客服/管理员登录接口(支持手机号重复) - 请求:POST /yonghu/kefujinru - 请求体:{"phone": "13800138000", "password": "xxx", "erjimima": "yyy"} - 返回: - code=0 成功,data包含token,nicheng,yonghuid - code=1 失败(msg区分:用户不存在/密码错误/二级密码错误) - code=4 账号被封禁 - - 管理员(IsSuperuser 或 UserType='admin')无需 erjimima 和 KefuProfile。 - """ - throttle_classes = [AnonRateThrottle] # 限制匿名请求频率 - permission_classes = [AllowAny] # 任何人都可访问 - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - password = request.data.get('password', '') - erjimima = request.data.get('erjimima', '') - - if not phone or not password: - return Response({ - 'code': 1, - 'msg': '请填写手机号和密码', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 2. 获取客户端真实IP - client_ip = self.get_client_ip(request) - - # 3. 查询该手机号下所有账号(客服 + 管理员) - # 先查客服(有 KefuProfile),再查管理员(IsSuperuser 或 UserType='admin') - user_mains = list( - User.query.filter( - Phone=phone, - KefuProfile__isnull=False, - ).select_related('KefuProfile') - ) - - # 如果没有客服账号,尝试查找管理员账号 - if not user_mains: - admin_candidates = User.query.filter(Phone=phone) - for admin_user in admin_candidates: - if admin_user.IsSuperuser or admin_user.UserType == 'admin': - user_mains = [admin_user] - break - - if not user_mains: - logger.warning(f"登录失败,用户不存在: {phone}, IP: {client_ip}") - return Response({ - 'code': 1, - 'msg': '用户不存在', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 4. 遍历所有匹配的账号,验证密码和二级密码 - target_user = None - target_kefu = None - password_matched = False - - for user in user_mains: - # 验证主密码(bcrypt 哈希验证) - if not user.CheckPassword(password): - continue - password_matched = True - - # 管理员:跳过二级密码和客服扩展表检查 - is_admin = user.IsSuperuser or user.UserType == 'admin' - if is_admin: - target_user = user - target_kefu = None - break - - # 客服:验证二级密码 - kefu = user.KefuProfile - stored_erji = kefu.erjimima or '' - if stored_erji.startswith(('$2b$', '$2a$')) and len(stored_erji) == 60: - import bcrypt as _bcrypt - if not _bcrypt.checkpw(erjimima.encode('utf-8'), stored_erji.encode('utf-8')): - continue - elif stored_erji != erjimima: - continue - - # 如果账号被禁用,记录但继续查找 - if kefu.zhuangtai != 1: - if target_user is None: - target_user = user - target_kefu = kefu - continue - - # 找到完全匹配且状态正常的账号,立即使用 - target_user = user - target_kefu = kefu - break - else: - # 循环结束未找到正常账号 - if target_user and target_kefu: - # 只有被禁用的账号匹配,返回封禁提示 - logger.warning(f"登录失败,客服账号已禁用: {phone}, IP: {client_ip}") - return Response({ - 'code': 4, - 'msg': '账号已被封禁', - 'data': None - }, status=status.HTTP_403_FORBIDDEN) - elif password_matched: - # 密码正确但二级密码错误 - logger.warning(f"登录失败,二级密码错误: {phone}, IP: {client_ip}") - return Response({ - 'code': 1, - 'msg': '二级密码错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - else: - # 密码错误 - logger.warning(f"登录失败,密码错误: {phone}, IP: {client_ip}") - return Response({ - 'code': 1, - 'msg': '密码错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 5. 更新IP和最后登录时间 - target_user.IP = client_ip - target_user.UserLastLoginDate = timezone.now() - target_user.save(update_fields=['IP', 'UserLastLoginDate']) - - # 6. 生成JWT token - refresh = RefreshToken.for_user(target_user) - token = str(refresh.access_token) - - # 7. 返回成功数据(管理员无 KefuProfile,nicheng 用 UserName 或 Phone 兜底) - nicheng = target_kefu.nicheng if target_kefu else (target_user.UserName or target_user.Phone) - return Response({ - 'code': 0, - 'msg': '登录成功', - 'data': { - 'token': token, - 'nicheng': nicheng, - 'yonghuid': target_user.UserUID, - } - }) - - def get_client_ip(self, request): - """ - 获取客户端真实IP - """ - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - ip = x_forwarded_for.split(',')[0].strip() - if ip: - return ip - return request.META.get('REMOTE_ADDR', '0.0.0.0') - - -class KefuStatsView(APIView): - """ - 客服统计数据接口 - 请求:POST /yonghu/kfjrhqzl - 请求体:{"phone": "13800138000"} - 认证:必须携带有效的 JWT token(放在 Authorization 头) - 返回: - code=0 成功,data 包含 jinrichuli, jinyuechuli, zongchuli - 验证失败统一返回 401,不透露具体原因 - """ - - permission_classes = [IsAuthenticated] # 必须登录 - - - def post(self, request): - # 1. 获取请求中的 phone - phone = request.data.get('phone', '').strip() - if not phone: - # 缺少参数,但按约定返回 401(可以改为 400,但为了统一,用401) - logger.warning(f"统计接口缺少 phone 参数,用户: {request.user.UserUID}") - return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 获取当前登录用户(从 JWT 中解析) - current_user = request.user - - # 3. 验证前端传来的 phone 是否与当前用户的 phone 一致 - if current_user.Phone != phone: - # 不一致,可能 token 被冒用或参数错误 - logger.warning(f"统计接口 phone 不匹配: 请求phone={phone}, 用户phone={current_user.Phone}") - return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 4. 验证是否为后台客服账号 - from jituan.services.admin_context import is_kefu_backend_account, is_system_super_admin - if not is_kefu_backend_account(current_user): - logger.warning(f"统计接口用户类型非客服: {current_user.UserUID}") - return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 5. 尝试获取客服扩展表数据(管理员跳过) - is_admin = is_system_super_admin(current_user) - if is_admin: - return Response({ - 'code': 0, - 'data': { - 'jinrichuli': 0, - 'jinyuechuli': 0, - 'zongchuli': 0, - } - }, status=status.HTTP_200_OK) - - try: - kefu_profile = current_user.KefuProfile # OneToOneField 反向关系 - except UserKefu.DoesNotExist: - logger.warning(f"统计接口客服扩展表不存在: {current_user.UserUID}") - return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 6. 返回统计数据(字段名与前端约定一致) - return Response({ - 'code': 0, - 'data': { - 'jinrichuli': kefu_profile.jinrichuli, - 'jinyuechuli': kefu_profile.jinyuechuli, - 'zongchuli': kefu_profile.zongchuli, - } - }, status=status.HTTP_200_OK) - - -class KefuGetOrderTypesView(APIView): - """ - 客服获取订单类型列表接口(平台订单用) - 请求:POST /yonghu/kfhqptddlx - 参数:{"phone": "13800138000"} - 认证:JWT - 返回:code=0 + 类型列表 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - if not phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - current_user = request.user - - # 2. 验证手机号一致性 - if getattr(current_user, 'Phone', '') != phone: - logger.warning(f"手机号不匹配: 请求phone={phone}, 用户phone={current_user.Phone}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 验证用户类型及客服状态 - if current_user.UserType not in ('kefu', 'admin'): - logger.warning(f"用户类型非客服: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 管理员跳过客服扩展表检查 - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - if not is_admin: - try: - kefu_profile = current_user.KefuProfile - except ObjectDoesNotExist: - logger.warning(f"客服扩展表不存在: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu_profile.zhuangtai != 1: - logger.warning(f"客服账号已被禁用: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 4. 查询订单类型(商品类型) - try: - # 只查询需要的字段,减少数据传输 - types_qs = ShangpinLeixing.query.all().only('id', 'jieshao', 'tupian_url') - type_list = [ - { - 'id': t.id, - 'biaoti': t.jieshao or '', # 假设商品类型的标题字段为 jieshao - 'tupian_url': t.tupian_url or '' - } - for t in types_qs - ] - return Response({'code': 0, 'data': type_list}) - except Exception as e: - logger.error(f"查询商品类型表失败: {str(e)}") - # 表不存在或查询失败,返回空列表 - return Response({'code': 0, 'data': []}) - - -class KefuGetOrderListView(APIView): - """ - 客服获取平台订单列表接口(发单平台=1,非跨平台) - 需要权限:002ab(平台订单管理) - 请求参数: - username: 客服账号(用于权限校验) - page: 页码 - pageSize: 每页条数 - dingdan_id: 订单ID(精确搜索) - laoban_id: 老板ID(搜索平台扩展表中的老板) - dashou_id: 打手ID(搜索主表中的接单打手) - leixing: 商品类型ID - zhuangtai: 状态(多个用逗号分隔,如 "1,7") - date: 日期(YYYY-MM-DD),筛选订单创建时间 - nicheng: 游戏昵称(订单主表的 nicheng 字段) - jieshao: 订单介绍(订单主表的 jieshao 字段) - clkf: 处理客服(订单主表的 clkf 字段) - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - start_total = time.time() - - # 1. 获取前端参数 - username = request.data.get('phone', '').strip() - if not username: - return Response({'code': 400, 'msg': '缺少username'}) - - # 2. 公共权限校验 - kefu, permissions = verify_kefu_permission(request, username) - if kefu is None: - return permissions - - # 3. 检查平台订单管理权限(002ab) - if '002ab' not in permissions: - return Response({'code': 403, 'msg': '您无权查看平台订单列表'}) - - # 4. 获取分页和筛选参数 - page = int(request.data.get('page', 1)) - page_size = int(request.data.get('pageSize', 20)) - - dingdan_id = request.data.get('dingdan_id', '').strip() - laoban_id = request.data.get('laoban_id', '').strip() - dashou_id = request.data.get('dashou_id', '').strip() - leixing = request.data.get('leixing') - zhuangtai = request.data.get('zhuangtai', '') - date = request.data.get('date', '').strip() - # 新增筛选条件 - nicheng = request.data.get('nicheng', '').strip() - jieshao = request.data.get('jieshao', '').strip() - clkf = request.data.get('clkf', '').strip() - - # ---------- 查询条件 ---------- - # 基础条件:平台发单 + 兼容历史 Platform 为空 - from jituan.services.club_context import platform_order_base_q - base_q = platform_order_base_q() - - if dingdan_id: - q_conditions = base_q & Q(OrderID=dingdan_id) - else: - q_conditions = base_q - if leixing is not None and str(leixing).strip() != '': - try: - leixing_int = int(leixing) - q_conditions &= Q(ProductTypeID=leixing_int) - except (ValueError, TypeError): - pass - if zhuangtai and zhuangtai != 'all': - try: - status_list = [int(s.strip()) for s in zhuangtai.split(',') if s.strip()] - if status_list: - q_conditions &= Q(Status__in=status_list) - except (ValueError, TypeError): - pass - if dashou_id: - q_conditions &= Q(PlayerID=dashou_id) - if laoban_id: - q_conditions &= Q(pingtai_kuozhan__BossID=laoban_id) - if date: - q_conditions &= Q(CreateTime__date=date) - # 新增筛选条件 - if nicheng: - q_conditions &= Q(Nickname__icontains=nicheng) - if jieshao: - q_conditions &= Q(Description__icontains=jieshao) - if clkf: - q_conditions &= Q(AssignedCS__icontains=clkf) - - from jituan.services.club_context import ( - count_order_status_buckets, - order_scope_qs, - paginate_fluent_query, - ) - from orders.models import PlatformOrderExt - - # 按俱乐部过滤订单列表 - stats_qs = order_scope_qs(request).filter(q_conditions) - stats = stats_qs.aggregate( - total_orders=Count('id'), - completed_orders=Count('id', filter=Q(Status=3)), - refund_orders=Count('id', filter=Q(Status=5)), - pending_orders=Count('id', filter=Q(Status__in=[4, 8])) - ) - status_counts = count_order_status_buckets(q_conditions, request) - - list_qs = order_scope_qs(request).filter(q_conditions).order_by('-CreateTime') - total_count, orders_page = paginate_fluent_query(list_qs, page, page_size) - if total_count > 0 and not orders_page: - start = (max(1, page) - 1) * page_size - orders_page = list(list_qs._qs[start:start + page_size]) - - order_ids = [o.OrderID for o in orders_page if o.OrderID] - ext_map = {} - if order_ids: - for ext in PlatformOrderExt.query.filter(Order__OrderID__in=order_ids).select_related('Order'): - if ext.Order: - ext_map[ext.Order.OrderID] = ext - - laoban_ids = [ - ext.BossID for ext in ext_map.values() if ext.BossID - ] - nickname_map = {} - if laoban_ids: - users = User.query.filter(UserUID__in=laoban_ids).select_related('BossProfile').only( - 'UserUID', 'BossProfile__nickname' - ) - for user in users: - nickname_map[user.UserUID] = (user.BossProfile.nickname or '') if getattr(user, 'BossProfile', None) else '' - - result_list = [] - for order in orders_page: - ext = ext_map.get(order.OrderID) - laoban_id = ext.BossID if ext else '' - youxi_nicheng = nickname_map.get(laoban_id, order.Nickname or '') - result_list.append({ - 'dingdan_id': order.OrderID or '', - 'jieshao': order.Description or '', - 'zhuangtai': order.Status, - 'leixing': order.ProductTypeID, - 'fadanpingtai': order.Platform, - 'jiage': float(order.Amount) if order.Amount else 0.00, - 'tupian_url': order.ImageURL or '', - 'fadanshijian': order.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if order.CreateTime else '', - 'youxi_nicheng': youxi_nicheng - }) - - from jituan.services.club_user_access import list_response_meta - - response_data = { - 'stats': { - 'totalOrders': stats['total_orders'] or 0, - 'completedOrders': stats['completed_orders'] or 0, - 'refundOrders': stats['refund_orders'] or 0, - 'pendingOrders': stats['pending_orders'] or 0 - }, - 'statusCounts': status_counts, - 'list': result_list, - 'total': total_count, - 'page': page, - 'pageSize': page_size, - **list_response_meta(request), - } - - logger.info(f"平台订单接口总耗时: {time.time()-start_total:.3f}s") - return Response({'code': 0, 'data': response_data}) - - -class KefuGetShangjiaOrderListView(APIView): - """ - 客服获取商家订单列表接口(发单平台=2,非跨平台) - 需要权限:002ac(商家订单管理)—— 如不区分可使用 002ab - 请求参数: - username: 客服账号(用于权限校验) - page: 页码 - pageSize: 每页条数 - dingdan_id: 订单ID(精确搜索) - shangjia_id: 商家ID(搜索商家扩展表中的商家ID) - dashou_id: 打手ID(搜索主表中的接单打手) - leixing: 商品类型ID - zhuangtai: 状态(多个用逗号分隔,如 "1,7") - date: 日期(YYYY-MM-DD),筛选订单创建时间 - nicheng: 游戏昵称(订单主表的 nicheng 字段) - jieshao: 订单介绍(订单主表的 jieshao 字段) - clkf: 处理客服(订单主表的 clkf 字段) - shangjia_nicheng: 商家昵称(商家扩展表中的昵称) - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - start_total = time.time() - - # 1. 获取前端参数(兼容 phone / username) - username = request.data.get('username', '').strip() or request.data.get('phone', '').strip() - if not username: - return Response({'code': 400, 'msg': '缺少username'}) - - # 2. 公共权限校验 - kefu, permissions = verify_kefu_permission(request, username) - if kefu is None: - return permissions - - # 3. 检查商家订单管理权限(使用 002ac,如果没有可改用 002ab) - if '002ac' not in permissions: - return Response({'code': 403, 'msg': '您无权查看商家订单列表'}) - - # 4. 获取分页和筛选参数 - page = int(request.data.get('page', 1)) - page_size = int(request.data.get('pageSize', 20)) - - dingdan_id = request.data.get('dingdan_id', '').strip() - shangjia_id = request.data.get('shangjia_id', '').strip() - dashou_id = request.data.get('dashou_id', '').strip() - leixing = request.data.get('leixing') - zhuangtai = request.data.get('zhuangtai', '') - date = request.data.get('date', '').strip() - # 新增筛选条件 - nicheng = request.data.get('nicheng', '').strip() - jieshao = request.data.get('jieshao', '').strip() - clkf = request.data.get('clkf', '').strip() - shangjia_nicheng = request.data.get('shangjia_nicheng', '').strip() - - # ---------- 查询条件 ---------- - from jituan.services.club_context import merchant_order_base_q - base_q = merchant_order_base_q() - - if dingdan_id: - q_conditions = base_q & Q(OrderID=dingdan_id) - else: - q_conditions = base_q - if leixing is not None and str(leixing).strip() != '': - try: - leixing_int = int(leixing) - q_conditions &= Q(ProductTypeID=leixing_int) - except (ValueError, TypeError): - pass - if zhuangtai and zhuangtai != 'all': - try: - status_list = [int(s.strip()) for s in zhuangtai.split(',') if s.strip()] - if status_list: - q_conditions &= Q(Status__in=status_list) - except (ValueError, TypeError): - pass - if dashou_id: - q_conditions &= Q(PlayerID=dashou_id) - if shangjia_id: - q_conditions &= Q(shangjia_kuozhan__MerchantID=shangjia_id) - if date: - q_conditions &= Q(CreateTime__date=date) - if nicheng: - q_conditions &= Q(Nickname__icontains=nicheng) - if jieshao: - q_conditions &= Q(Description__icontains=jieshao) - if clkf: - q_conditions &= Q(AssignedCS__icontains=clkf) - if shangjia_nicheng: - # 通过商家扩展表关联 UserShangjia 的昵称 - q_conditions &= Q(shangjia_kuozhan__MerchantID__in=User.query.filter( - ShopProfile__nicheng__icontains=shangjia_nicheng - ).values_list('UserUID', flat=True)) - - from jituan.services.club_context import ( - count_order_status_buckets, - order_scope_qs, - paginate_fluent_query, - ) - from orders.models import MerchantOrderExt - - stats_qs = order_scope_qs(request).filter(q_conditions) - stats = stats_qs.aggregate( - total_orders=Count('id'), - completed_orders=Count('id', filter=Q(Status=3)), - refund_orders=Count('id', filter=Q(Status=5)), - pending_orders=Count('id', filter=Q(Status__in=[4, 8])) - ) - status_counts = count_order_status_buckets(q_conditions, request) - - list_qs = order_scope_qs(request).filter(q_conditions).order_by('-CreateTime') - total_count, orders_page = paginate_fluent_query(list_qs, page, page_size) - if total_count > 0 and not orders_page: - start = (max(1, page) - 1) * page_size - orders_page = list(list_qs._qs[start:start + page_size]) - - order_ids = [o.OrderID for o in orders_page if o.OrderID] - ext_map = {} - if order_ids: - for ext in MerchantOrderExt.query.filter(Order__OrderID__in=order_ids).select_related('Order'): - if ext.Order: - ext_map[ext.Order.OrderID] = ext - - shangjia_ids = [ - ext.MerchantID for ext in ext_map.values() if ext.MerchantID - ] - nickname_map = {} - if shangjia_ids: - users = User.query.filter(UserUID__in=shangjia_ids).select_related('ShopProfile').only( - 'UserUID', 'ShopProfile__nicheng' - ) - for user in users: - nickname_map[user.UserUID] = user.ShopProfile.nicheng or '' if hasattr(user, 'ShopProfile') else '' - - result_list = [] - for order in orders_page: - ext = ext_map.get(order.OrderID) - shangjia_id = ext.MerchantID if ext else '' - youxi_nicheng = order.Nickname or '' - result_list.append({ - 'dingdan_id': order.OrderID or '', - 'jieshao': order.Description or '', - 'zhuangtai': order.Status, - 'leixing': order.ProductTypeID, - 'fadanpingtai': order.Platform, - 'jiage': float(order.Amount) if order.Amount else 0.00, - 'tupian_url': order.ImageURL or '', - 'fadanshijian': order.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if order.CreateTime else '', - 'youxi_nicheng': youxi_nicheng - }) - - from jituan.services.club_user_access import list_response_meta - - response_data = { - 'stats': { - 'totalOrders': stats['total_orders'] or 0, - 'completedOrders': stats['completed_orders'] or 0, - 'refundOrders': stats['refund_orders'] or 0, - 'pendingOrders': stats['pending_orders'] or 0 - }, - 'statusCounts': status_counts, - 'list': result_list, - 'total': total_count, - 'page': page, - 'pageSize': page_size, - **list_response_meta(request), - } - - logger.info(f"商家订单接口总耗时: {time.time()-start_total:.3f}s") - return Response({'code': 0, 'data': response_data}) - - -class KefuChangeDashouView(APIView): - """ - 客服更换打手接口 - 请求:POST /yonghu/kefughds - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID", - "new_dashou_id": "新打手ID" - } - 认证:JWT - 返回:code=0 成功 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - new_dashou_id = request.data.get('new_dashou_id', '').strip() - - if not phone or not dingdan_id or not new_dashou_id: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - logger.warning(f"手机号不匹配: {phone}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - logger.warning(f"用户类型非客服: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - logger.warning(f"客服扩展表不存在: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - logger.warning(f"客服账号已禁用: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 查询订单 - try: - order = Order.query.get(OrderID=dingdan_id) - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) - - # 4. 验证订单状态是否允许更换打手(进行中2 或 结算中8) - if order.Status not in [2, 8]: - return Response({'code': 400, 'msg': '订单状态不允许更换打手'}, status=status.HTTP_400_BAD_REQUEST) - - # 5. 查询新打手 - try: - new_dashou_user = User.query.get(UserUID=new_dashou_id) - except User.DoesNotExist: - return Response({'code': 404, 'msg': '打手不存在'}, status=status.HTTP_404_NOT_FOUND) - - # 6. 验证打手扩展表 - try: - new_dashou_profile = new_dashou_user.DashouProfile - except ObjectDoesNotExist: - return Response({'code': 404, 'msg': '打手信息不完整'}, status=status.HTTP_404_NOT_FOUND) - - # 7. 验证打手状态(打手状态和账号状态必须为1) - if new_dashou_profile.zhuangtai != 1 or new_dashou_profile.zhanghaozhuangtai != 1: - return Response({'code': 400, 'msg': '打手状态不允许接单'}, status=status.HTTP_400_BAD_REQUEST) - # 8. 验证打手积分是否足够(至少5分) - if new_dashou_profile.jifen < 5: - return Response({'code': 400, 'msg': '打手积分不足,无法接单'}, status=status.HTTP_400_BAD_REQUEST) - - # 8. 根据订单的抢单要求类型进行额外验证 - yaoqiuleixing = order.GrabRequirement - - if yaoqiuleixing == 1: # 会员抢单 - huiyuan_id = order.MembershipID - if not huiyuan_id: - return Response({'code': 400, 'msg': '订单会员ID为空'}, status=status.HTTP_400_BAD_REQUEST) - - try: - # 查询打手的会员购买记录 - huiyuan_goumai = Huiyuangoumai.query.get( - huiyuan_id=huiyuan_id, - yonghu_id=new_dashou_id # 假设 yonghu_id 字段存储用户ID - ) - except Huiyuangoumai.DoesNotExist: - return Response({'code': 400, 'msg': '打手未购买该订单要求的会员'}, status=status.HTTP_400_BAD_REQUEST) - - # 检查会员是否过期(调用模型方法) - is_expired = huiyuan_goumai.jiance_shifou_daoqi() - if is_expired or huiyuan_goumai.huiyuan_zhuangtai != 1: - return Response({'code': 400, 'msg': '打手的会员已过期'}, status=status.HTTP_400_BAD_REQUEST) - - elif yaoqiuleixing == 2: # 押金抢单 - order_yajin = order.CommissionReq # 假设订单的 yongjin 字段存储押金要求 - if order_yajin is None: - return Response({'code': 400, 'msg': '订单押金为空'}, status=status.HTTP_400_BAD_REQUEST) - - if new_dashou_profile.yajin < order_yajin: - return Response({'code': 400, 'msg': '打手押金不足'}, status=status.HTTP_400_BAD_REQUEST) - else: - return Response({'code': 400, 'msg': '订单抢单要求类型错误'}, status=status.HTTP_400_BAD_REQUEST) - - # 9. 所有验证通过,开始更换打手(使用事务) - with transaction.atomic(): - # 9.1 更新新打手扩展表 - new_dashou_profile.zhuangtai = 0 # 设为接单中 - new_dashou_profile.jiedanzongliang += 1 - new_dashou_profile.jinrijiedan += 1 - new_dashou_profile.jinyuejiedan += 1 - new_dashou_profile.save() - - # 9.2 处理原打手(如果存在) - old_dashou_id = order.PlayerID - if old_dashou_id: - try: - old_dashou_user = User.query.get(UserUID=old_dashou_id) - old_dashou_profile = old_dashou_user.DashouProfile - old_dashou_profile.zhuangtai = 1 # 恢复空闲 - old_dashou_profile.tuikuanliang += 1 # 原打手退款总量+1(业务逻辑) - old_dashou_profile.save() - logger.info(f"原打手 {old_dashou_id} 状态已更新") - except (User.DoesNotExist, ObjectDoesNotExist): - logger.warning(f"原打手 {old_dashou_id} 不存在或信息不完整,跳过更新") - except Exception as e: - logger.error(f"更新原打手时异常: {str(e)}", exc_info=True) - - # 9.3 更新订单 - order.PlayerID = new_dashou_id - order.Status = 2 # 进行中 - order.AssignedCS = current_user.UserUID # 记录处理客服ID - order.save() - - return Response({'code': 0, 'msg': '更换打手成功'}) - - -class KefuRecoverOrderView(APIView): - """ - 客服恢复订单接口(仅状态9可恢复) - 请求:POST /yonghu/kefuhf - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID" - } - 认证:JWT - 返回:code=0 成功 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 查询订单 - try: - order = Order.query.get(OrderID=dingdan_id) - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) - - # 4. 验证订单状态是否为9(未付款) - if order.Status != 9: - return Response({'code': 400, 'msg': '订单状态不允许恢复'}, status=status.HTTP_400_BAD_REQUEST) - - # 5. 根据是否有指定ID决定新状态 - if order.AssignedID: - new_status = 7 # 指定中 - else: - new_status = 1 # 下单中 - - # 6. 更新订单 - order.Status = new_status - order.AssignedCS = current_user.UserUID - order.save() - - return Response({'code': 0, 'msg': '恢复成功', 'data': {'new_status': new_status}}) - - -class KefuPlatformRefundView(APIView): - """ - 客服平台订单退款接口(调用微信支付) - 请求:POST /yonghu/kefupttk - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID", - "tuikuan_liyou": "退款理由(可选)" - } - 认证:JWT - 返回:code=0 成功,code=其他 失败 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - tuikuan_liyou = request.data.get('tuikuan_liyou', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - logger.warning(f"手机号不匹配: {phone}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - logger.warning(f"用户类型非客服: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - logger.warning(f"客服扩展表不存在: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - logger.warning(f"客服账号已禁用: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 查询订单并加锁 - try: - with transaction.atomic(): - order = Order.query.select_related('pingtai_kuozhan').select_for_update().get(OrderID=dingdan_id) - - # 4. 验证平台订单 - if order.Platform != 1: - return Response({'code': 400, 'msg': '该订单不是平台订单'}, status=status.HTTP_400_BAD_REQUEST) - - # 5. 验证状态允许退款(根据模型,状态值:1下单中,2进行中,3已完成,4退款审核,5已退款,6退款失败,7指定中,8结算中) - allowed_statuses = [1, 2, 4, 7, 8] # 允许退款的已支付或处理中状态 - if order.Status not in allowed_statuses: - return Response({'code': 400, 'msg': '订单状态不允许退款'}, status=status.HTTP_400_BAD_REQUEST) - - # 6. 获取微信交易号(如果存在) - wechat_transaction_id = getattr(order, 'WechatTransactionID', None) - - # 7. 调用微信退款接口 - refund_result = self.call_wechat_refund( - dingdan_id=order.OrderID, - jine=order.Amount, - transaction_id=wechat_transaction_id - ) - - if refund_result['code'] != 0: - # 微信退款失败,直接返回错误,数据库不更新 - logger.error(f"微信退款失败,订单 {dingdan_id},原因:{refund_result['msg']}") - return Response({'code': 400, 'msg': refund_result['msg']}, status=status.HTTP_400_BAD_REQUEST) - - # 8. 更新订单状态为5(已退款) - order.Status = 5 - order.AssignedCS = current_user.Phone # 记录处理客服手机号 - if tuikuan_liyou: - order.RefundReason = tuikuan_liyou - order.save() - - # 9. 更新打手数据 - jiedan_dashou_id = order.PlayerID - if jiedan_dashou_id: - try: - dashou_user = User.query.get(UserUID=jiedan_dashou_id) - dashou_profile = dashou_user.DashouProfile - dashou_profile.tuikuanliang += 1 - dashou_profile.zhuangtai = 1 # 打手状态设为空闲 - dashou_profile.save() - logger.info(f"平台退款:打手 {jiedan_dashou_id} 退款订单+1") - except (User.DoesNotExist, ObjectDoesNotExist): - logger.warning(f"平台退款:打手 {jiedan_dashou_id} 不存在或扩展表缺失,跳过更新") - - # ========== 新增:商品和店铺每日统计 ========== - try: - # 商品每日统计(只要有商品ID就执行) - if order.ProductID: - # 从扩展表获取已计算好的收益值(下单时已存储) - pingtai_shouyi = Decimal('0.00') - dianpu_shouyi = Decimal('0.00') - if hasattr(order, 'pingtai_kuozhan'): - pingtai_shouyi = order.pingtai_kuozhan.PlatformIncome or Decimal('0.00') - dianpu_shouyi = order.pingtai_kuozhan.ShopIncome or Decimal('0.00') - - update_shangpin_daily_stat( - shangpin_id=order.ProductID, - caozuo_leixing=3, # 3 = 退款 - dingdan_jiage=order.Amount, - pingtai_shouyi=pingtai_shouyi, - dianpu_zongshouyi=dianpu_shouyi - ) - logger.info(f"商品统计更新完成,商品ID: {order.ProductID}") - - # 店铺每日统计(仅当存在店铺ID时执行) - dianpu_id = None - if hasattr(order, 'pingtai_kuozhan'): - dianpu_id = order.pingtai_kuozhan.ShopID - if dianpu_id: - dianpu_shouyi = order.pingtai_kuozhan.ShopIncome or Decimal('0.00') - update_dianpu_daily_stat( - dianpu_id=dianpu_id, - caozuo_leixing=3, # 3 = 退款 - dingdan_jiage=order.Amount, - dianpu_shouyi=dianpu_shouyi - ) - logger.info(f"店铺统计更新完成,店铺ID: {dianpu_id}") - except Exception as e: - logger.error(f"商品/店铺统计更新失败: {e}") - logger.error(traceback.format_exc()) - # 统计失败不影响主流程 - # ============================================= - - # 10. 更新退款记录表(假设存在 RefundRecord 模型,字段需根据实际调整) - - #更新支出记录 - update_daily_payout(order.Amount, getattr(order, 'ClubID', None)) - - - try: - refund_record, created = RefundRecord.query.get_or_create( - OrderID=dingdan_id, - defaults={ - 'PlayerID': jiedan_dashou_id or '', - 'ApplicantID': '', - 'ProcessorID': phone, - 'Reason': tuikuan_liyou or '客服退款', - 'ApplyStatus': 1, # 成功 - 'Amount': order.Amount, - 'PlayerCommission': order.PlayerCommission or 0, - 'Description': order.Description or '', - 'Remark': '客服平台退款', - 'Nickname': order.Nickname or '', - } - ) - if not created: - refund_record.ApplyStatus = 1 - refund_record.ProcessorID = phone - if tuikuan_liyou: - refund_record.Reason = tuikuan_liyou - refund_record.save() - except Exception as e: - logger.error(f"平台退款更新退款记录异常: {str(e)}", exc_info=True) - - # 11. 更新客服统计数据 - kefu.jinrichuli += 1 - kefu.jinyuechuli += 1 - kefu.zongchuli += 1 - kefu.save() - - # 🔥 新增:调用打手每日统计(成交) - try: - update_dashou_daily_by_action( - yonghuid=order.PlayerID, - amount=order.PlayerCommission, - action=3 # 3 表示退款(退款) - ) - logger.info(f"打手 {order.PlayerID} 每日统计更新成功") - except Exception as e: - logger.error(f"打手每日统计更新失败: {e}") - # 不影响主流程 - - logger.info(f"平台退款成功,订单 {dingdan_id},退款单号 {refund_result.get('refund_id')}") - return Response({'code': 0, 'msg': '退款成功'}) - - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) - except Exception as e: - logger.error(f"平台退款接口异常: {str(e)}", exc_info=True) - return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - def call_wechat_refund(self, dingdan_id, jine, transaction_id=None): - """ - 调用微信支付退款接口 - :param dingdan_id: 商户订单号(我们的订单ID) - :param jine: 退款金额 - :param transaction_id: 微信支付订单号(优先使用) - :return: {'code': 0, 'msg': '成功', 'refund_id': 'xxx'} 或 {'code': 非0, 'msg': '错误信息'} - """ - # 获取配置 - appid = getattr(settings, 'WEIXIN_APPID', '') - mch_id = getattr(settings, 'WEIXIN_MCHID', '') - key = getattr(settings, 'WEIXIN_SHANGHUMIYAO', '') - cert_path = getattr(settings, 'WEIXIN_CERT_PATH', '') - key_path = getattr(settings, 'WEIXIN_KEY_PATH', '') - - if not all([appid, mch_id, key, cert_path, key_path]): - missing = [k for k, v in {'appid': appid, 'mch_id': mch_id, 'key': key, 'cert_path': cert_path, 'key_path': key_path}.items() if not v] - logger.error(f"微信支付配置缺失: {missing}") - return {'code': 500, 'msg': f'系统支付配置缺失: {missing}'} - - # 生成退款单号 - out_refund_no = self.generate_refund_no(dingdan_id) - total_fee = yuan_to_fen(jine) - refund_fee = total_fee - refund_desc = '客服退款' - - # 构造请求参数 - params = { - 'appid': appid, - 'mch_id': mch_id, - 'nonce_str': self.generate_nonce_str(), - 'out_refund_no': out_refund_no, - 'total_fee': total_fee, - 'refund_fee': refund_fee, - 'refund_desc': refund_desc, - } - if transaction_id: - params['transaction_id'] = transaction_id - else: - params['out_trade_no'] = dingdan_id # 使用订单ID作为商户订单号 - - # 生成签名 - sorted_keys = sorted(params.keys()) - stringA = '&'.join([f"{k}={params[k]}" for k in sorted_keys]) - stringSignTemp = f"{stringA}&key={key}" - sign = hashlib.md5(stringSignTemp.encode('utf-8')).hexdigest().upper() - params['sign'] = sign - - xml_data = self.dict_to_xml(params) - url = 'https://api.mch.weixin.qq.com/secapi/pay/refund' - - try: - response = requests.post( - url, - data=xml_data, - cert=(cert_path, key_path), - timeout=10 - ) - result = xmltodict.parse(response.content)['xml'] - logger.info(f"微信退款响应: {result}") - except Exception as e: - logger.error(f"微信退款请求异常: {str(e)}", exc_info=True) - return {'code': 500, 'msg': '微信退款请求异常'} - - if result.get('return_code') == 'SUCCESS' and result.get('result_code') == 'SUCCESS': - return {'code': 0, 'msg': 'success', 'refund_id': out_refund_no} - else: - err_msg = result.get('err_code_des', result.get('return_msg', '未知错误')) - logger.warning(f"微信退款失败: {err_msg}") - return {'code': 400, 'msg': err_msg} - - def generate_nonce_str(self): - return ''.join(random.choices(string.ascii_letters + string.digits, k=32)) - - def generate_refund_no(self, dingdan_id): - timestamp = str(int(time.time())) - rand = ''.join(random.choices('0123456789', k=4)) - return f"{dingdan_id}REF{timestamp}{rand}" - - def dict_to_xml(self, data): - xml = [''] - for k, v in data.items(): - xml.append(f'<{k}>{v}') - xml.append('') - return ''.join(xml) - - -class KefuRejectRefundView(APIView): - """ - 客服拒绝退款接口 - 请求:POST /yonghu/kefujjtk - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID", - "jujue_liyou": "拒绝理由(可选)" - } - 认证:JWT - 返回:code=0 成功 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - jujue_liyou = request.data.get('jujue_liyou', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 查询订单并加锁 - try: - with transaction.atomic(): - order = Order.objects.select_for_update().get(OrderID=dingdan_id) - - # 4. 验证订单状态为退款审核中(4) - if order.Status != 4: - return Response({'code': 400, 'msg': '订单状态不是退款审核中'}, status=status.HTTP_400_BAD_REQUEST) - - # 获取必要字段 - jiedan_dashou_id = order.PlayerID - dashou_fencheng = order.PlayerCommission - fadan_pingtai = order.Platform - - # 5. 验证打手分成金额是否存在 - if dashou_fencheng is None: - return Response({'code': 400, 'msg': '订单缺少打手分成金额'}, status=status.HTTP_400_BAD_REQUEST) - - # 6. 更新订单状态为退款失败(6) - order.Status = 6 - order.AssignedCS = current_user.Phone # 记录处理客服手机号 - if jujue_liyou: - order.RefundReason = jujue_liyou - order.save() - - # 7. 给打手结算(返还分成) - if jiedan_dashou_id: - try: - dashou_user = User.query.get(UserUID=jiedan_dashou_id) - dashou_profile = dashou_user.DashouProfile - # 增加打手相关统计数据 - dashou_profile.chengjiaozongliang += 1 - dashou_profile.yue += dashou_fencheng - dashou_profile.zonge += dashou_fencheng - dashou_profile.jinrishouyi += dashou_fencheng - dashou_profile.jinyueshouyi += dashou_fencheng - dashou_profile.save() - logger.info(f"拒绝退款:打手 {jiedan_dashou_id} 获得分成 {dashou_fencheng}") - except (User.DoesNotExist, AttributeError): - logger.warning(f"拒绝退款:打手 {jiedan_dashou_id} 不存在或扩展表缺失,跳过结算") - - # 8. 如果是商家发单,更新商家成交订单数量 - if fadan_pingtai == 2: - try: - # 获取商家订单扩展表 - shangjia_kuozhan = order.shangjia_kuozhan - shangjia_id = shangjia_kuozhan.MerchantID - if shangjia_id: - shangjia_user = User.query.get(UserUID=shangjia_id) - shangjia_profile = shangjia_user.ShopProfile - shangjia_profile.chengjiao += 1 - shangjia_profile.save() - logger.info(f"拒绝退款:商家 {shangjia_id} 成交订单+1") - - # ✅ 新方法(行为驱动,更安全) - update_shangjia_daily( - yonghuid=shangjia_id, - amount=Decimal(str(order.Amount)), - action=2, # 2 = 结算 - order_id=dingdan_id, - ) - except (ObjectDoesNotExist, User.DoesNotExist, AttributeError): - logger.warning(f"拒绝退款:商家信息不存在,跳过商家更新") - - # 9. 更新退款记录表 - try: - refund_record = RefundRecord.query.get(OrderID=dingdan_id) - refund_record.ApplyStatus = 2 # 2表示拒绝 - refund_record.ProcessorID = phone - refund_record.RejectReason = jujue_liyou # 记录驳回理由(假设模型有此字段) - refund_record.save() - logger.info(f"拒绝退款:更新退款记录表,订单 {dingdan_id}") - except RefundRecord.DoesNotExist: - # 如果退款记录不存在,可以创建一条记录(可选) - RefundRecord.query.create( - OrderID=dingdan_id, - PlayerID=jiedan_dashou_id or '', - ApplicantID='', - ProcessorID=phone, - Reason=jujue_liyou or '客服拒绝退款', - ApplyStatus=2, - Amount=order.Amount, - PlayerCommission=dashou_fencheng or 0, - Description=order.Description or '', - Remark='客服拒绝退款', - Nickname=order.Nickname or '', - RejectReason=jujue_liyou or '', - ) - except Exception as e: - logger.error(f"拒绝退款更新退款记录异常: {str(e)}", exc_info=True) - - # 🔥 新增:调用打手每日统计(成交) - try: - update_dashou_daily_by_action( - yonghuid=order.PlayerID, - amount=dashou_fencheng, - action=2 # 2 表示成交(结算) - ) - logger.info(f"打手 {order.PlayerID} 每日统计更新成功") - except Exception as e: - logger.error(f"打手每日统计更新失败: {e}") - # 不影响主流程 - - # 10. 更新客服统计数据 - kefu.jinrichuli += 1 - kefu.jinyuechuli += 1 - kefu.zongchuli += 1 - kefu.save() - - return Response({'code': 0, 'msg': '拒绝退款成功'}) - - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) - except Exception as e: - logger.error(f"拒绝退款接口异常: {str(e)}", exc_info=True) - return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class KefuMerchantRefundView(APIView): - """ - 客服商家订单退款接口(发单平台=2) - 请求:POST /yonghu/kefusjtk - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID", - "tuikuan_liyou": "退款理由(可选)" - } - 认证:JWT - 返回:code=0 成功 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - tuikuan_liyou = request.data.get('tuikuan_liyou', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 客服身份验证(同前,省略以节省篇幅,但实际必须完整) - current_user = request.user - # ... 验证 - if getattr(current_user, 'Phone', '') != phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - - # 查询订单,预加载商家扩展表 - try: - order = Order.query.select_related('shangjia_kuozhan').get(OrderID=dingdan_id) - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) - - # 验证商家订单 - if order.Platform != 2: - return Response({'code': 400, 'msg': '该订单不是商家订单'}, status=status.HTTP_400_BAD_REQUEST) - - # 验证状态允许退款 - allowed_statuses = [1, 7, 2, 8, 4] - if order.Status not in allowed_statuses: - return Response({'code': 400, 'msg': '订单状态不允许退款'}, status=status.HTTP_400_BAD_REQUEST) - - # 获取必要数据 - jiedan_dashou_id = order.PlayerID - jine = order.Amount - if jine is None: - return Response({'code': 400, 'msg': '订单金额缺失'}, status=status.HTTP_400_BAD_REQUEST) - - try: - shangjia_kuozhan = order.shangjia_kuozhan - shangjia_id = shangjia_kuozhan.MerchantID - except ObjectDoesNotExist: - return Response({'code': 400, 'msg': '订单商家信息不存在'}, status=status.HTTP_400_BAD_REQUEST) - - with transaction.atomic(): - # 更新订单 - order.Status = 5 - order.AssignedCS = current_user.Phone - if tuikuan_liyou: - order.RefundReason = tuikuan_liyou - order.save() - - # 更新打手 - if jiedan_dashou_id: - try: - dashou_user = User.query.get(UserUID=jiedan_dashou_id) - dashou_profile = dashou_user.DashouProfile - dashou_profile.tuikuanliang += 1 - dashou_profile.zhuangtai = 1 - dashou_profile.save() - except (User.DoesNotExist, ObjectDoesNotExist): - logger.warning(f"商家退款:打手 {jiedan_dashou_id} 不存在,跳过更新") - - # 更新商家(客服额度出资的订单不退商家余额,仅释放子客服额度) - try: - from merchant_ops.services.wallet import is_staff_quota_order - shangjia_user = User.query.get(UserUID=shangjia_id) - shangjia_profile = shangjia_user.ShopProfile - shangjia_profile.tuikuan += 1 - if not is_staff_quota_order(dingdan_id): - shangjia_profile.yue += jine - shangjia_profile.save() - except (User.DoesNotExist, ObjectDoesNotExist): - logger.warning(f"商家退款:商家 {shangjia_id} 不存在,跳过更新") - - # 更新退款记录表 - try: - refund_record = RefundRecord.query.get(OrderID=dingdan_id) - refund_record.ApplyStatus = 1 - refund_record.ProcessorID = phone - refund_record.save() - except RefundRecord.DoesNotExist: - pass - - # 更新客服统计 - update_shangjia_daily( - yonghuid=shangjia_id, - amount=Decimal(str(order.Amount)), - action=3, # 3 = 退款 - order_id=dingdan_id, - ) - - - # 调用打手每日统计(成交) - try: - update_dashou_daily_by_action( - yonghuid=order.PlayerID, - amount=order.PlayerCommission, - action=3 # 3 表示退款(退款) - ) - logger.info(f"打手 {order.PlayerID} 每日统计更新成功") - except Exception as e: - logger.error(f"打手每日统计更新失败: {e}") - # 不影响主流程 - - - - # 管理员无客服扩展表,跳过计数器更新 - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - if not is_admin: - kefu = current_user.KefuProfile - kefu.jinrichuli += 1 - kefu.jinyuechuli += 1 - kefu.zongchuli += 1 - kefu.save() - - return Response({'code': 0, 'msg': '退款成功'}) - - -class KefuRejectSettlementView(APIView): - """ - 客服拒绝结算接口(仅平台订单,状态2/8) - 请求:POST /yonghu/kefujjjs - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID", - "tkly": "拒绝结算理由(必填)" - } - 认证:JWT - 返回:code=0 成功,其他失败 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - tkly = request.data.get('tkly', '').strip() - - if not phone or not dingdan_id or not tkly: - return Response({'code': 401, 'msg': '参数不完整'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - logger.warning(f"手机号不匹配: {phone}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - logger.warning(f"用户类型非客服: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - logger.warning(f"客服扩展表不存在: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - logger.warning(f"客服账号已禁用: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 查询订单,预加载平台扩展表 - try: - order = Order.query.select_related('pingtai_kuozhan').get(OrderID=dingdan_id) - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) - - # 4. 校验订单条件:平台发单(1)且状态为2或8 - if order.Platform != 1 or order.Status not in [2, 8]: - return Response({'code': 400, 'msg': '仅平台发单且状态为进行中(2)或结算中(8)的订单可拒绝结算'}, status=status.HTTP_400_BAD_REQUEST) - - jiedan_dashou_id = order.PlayerID - if not jiedan_dashou_id: - return Response({'code': 400, 'msg': '订单暂无接单打手'}, status=status.HTTP_400_BAD_REQUEST) - - # 5. 使用事务更新 - with transaction.atomic(): - # 5.1 更新订单主表 - order.Status = 5 # 已退款 - order.RefundReason = tkly # 拒绝理由 - order.AssignedCS = phone # 记录客服手机号 - order.save() - - # 5.2 更新平台订单扩展表:标记为拒绝结算(假设字段 jjjs_biaoshi) - # 注意:需要确保 PlatformOrderExt 模型中已添加 jjjs_biaoshi 字段,IntegerField,默认0 - if hasattr(order, 'pingtai_kuozhan') and order.pingtai_kuozhan: - pingtai_ext = order.pingtai_kuozhan - pingtai_ext.RejectSettlementFlag = 1 # 1表示拒绝结算 - pingtai_ext.save() - else: - # 理论上应该存在,但若不存在则记录日志 - logger.warning(f"订单 {dingdan_id} 缺少平台扩展表,无法标记拒绝结算") - - # 5.3 更新打手扩展表 - try: - dashou_user = User.query.get(UserUID=jiedan_dashou_id) - dashou_profile = dashou_user.DashouProfile - dashou_profile.zhuangtai = 1 # 打手状态设为空闲 - dashou_profile.tuikuanliang += 1 # 退款订单总量+1 - dashou_profile.save() - logger.info(f"拒绝结算:打手 {jiedan_dashou_id} 退款订单+1,状态空闲") - except (User.DoesNotExist, ObjectDoesNotExist): - logger.warning(f"拒绝结算:打手 {jiedan_dashou_id} 不存在或扩展表缺失,跳过更新") - except Exception as e: - logger.error(f"拒绝结算更新打手异常: {str(e)}", exc_info=True) - - # 5.4 更新客服扩展表统计数据 - - - # 🔥 新增:调用打手每日统计(成交) - try: - update_dashou_daily_by_action( - yonghuid=order.PlayerID, - amount=order.PlayerCommission, - action=3 # 3 表示退款(退款) - ) - logger.info(f"打手 {order.PlayerID} 每日统计更新成功") - except Exception as e: - logger.error(f"打手每日统计更新失败: {e}") - # 不影响主流程 - - - - kefu.jinrichuli += 1 - kefu.jinyuechuli += 1 - kefu.zongchuli += 1 - kefu.save() - - return Response({'code': 0, 'msg': '拒绝结算成功'}) - - -class KefuTransferHallView(APIView): - """ - 客服恢复大厅(转移大厅)接口 - 请求:POST /yonghu/kefuzydt - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID" - } - 认证:JWT - 返回:code=0 成功 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '参数不完整'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证(同前,略,但必须完整实现) - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - if current_user.UserType not in ('kefu', 'admin'): - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - if kefu.zhuangtai != 1: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - except ObjectDoesNotExist: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 查询订单 - try: - order = Order.query.get(OrderID=dingdan_id) - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) - - # 4. 校验订单状态(允许状态:2,8,4) - if order.Status not in [2, 8, 4]: - return Response({'code': 400, 'msg': '订单状态不允许转移大厅'}, status=status.HTTP_400_BAD_REQUEST) - - - if order.PlayerID: - UserDashou.query.filter(user__UserUID=order.PlayerID).update(zhuangtai=1) - else: - pass - - # 5. 根据是否有指定ID决定新状态 - if order.AssignedID: - new_status = 7 # 指定中 - else: - new_status = 1 # 下单中 - - # 6. 使用事务更新 - with transaction.atomic(): - # 清空接单打手ID,更新状态,记录客服 - order.PlayerID = None - order.Status = new_status - order.AssignedCS = phone - order.save() - - return Response({'code': 0, 'msg': '转移大厅成功'}) - - -class KefuCancelDesignationView(APIView): - """ - 客服取消指定接口(仅状态7) - 请求:POST /yonghu/kefuqxzd - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID" - } - 认证:JWT - 返回:code=0 成功 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '参数不完整'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证(同前,略,但必须完整实现) - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - if current_user.UserType not in ('kefu', 'admin'): - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - if kefu.zhuangtai != 1: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - except ObjectDoesNotExist: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 查询订单 - try: - order = Order.query.get(OrderID=dingdan_id) - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) - - # 4. 校验订单状态为7(指定中) - - if order.Status != 7: - print(999) - return Response({'code': 400, 'msg': '仅状态为指定中(7)的订单可取消指定'}, status=status.HTTP_400_BAD_REQUEST) - - # 5. 使用事务更新 - with transaction.atomic(): - # 状态改为1(下单中),清空指定ID,记录客服 - order.Status = 1 - order.AssignedID = None - order.AssignedCS = phone - order.save() - - return Response({'code': 0, 'msg': '取消指定成功'}) - - -class KefuGetDashouListView(APIView): - """ - 客服获取打手列表接口(仅打手类型) - 请求:POST /yonghu/kefuhqdslb - 参数:{ - "phone": "客服手机号", - "keyword": "搜索关键词(可选,搜索用户ID或昵称)", - "page": "页码(从1开始)", - "page_size": "每页数量" - } - 认证:JWT - 返回:{ - "code": 0, - "data": { - "list": [打手对象], - "has_more": true/false - } - } - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('zhanghao', '').strip() - keyword = request.data.get('keyword', '').strip() - try: - page = int(request.data.get('page', 1)) - page_size = int(request.data.get('page_size', 20)) - except ValueError: - return Response({'code': 400, 'msg': '分页参数错误'}, status=status.HTTP_400_BAD_REQUEST) - - if not phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - logger.warning(f"手机号不匹配: {phone}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - logger.warning(f"用户类型非客服: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - logger.warning(f"客服扩展表不存在: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - logger.warning(f"客服账号已禁用: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 构建查询条件(只查询打手) - # 通过 UserDashou 扩展表关联 User - queryset = UserDashou.query.select_related('user').all() - - if keyword: - # 搜索用户ID或打手昵称 - queryset = queryset.filter( - Q(user__UserUID__icontains=keyword) | - Q(nicheng__icontains=keyword) - ) - - # 4. 计算总数并分页 - total = queryset.count() - offset = (page - 1) * page_size - dashou_list = queryset.order_by('-CreateTime')[offset:offset + page_size] - - # 5. 构建返回数据(只取需要的字段) - result = [] - for dashou in dashou_list: - user = dashou.user - result.append({ - 'yonghuid': user.UserUID, - 'avatar': user.Avatar or '', - 'zaixianzhuangtai': dashou.zaixianzhuangtai, - 'zhanghaozhuangtai': dashou.zhanghaozhuangtai, - 'nicheng': dashou.nicheng or '', - 'zhuangtai': dashou.zhuangtai, - }) - - # 6. 判断是否有更多数据 - has_more = (offset + len(dashou_list)) < total - - return Response({ - 'code': 0, - 'data': { - 'list': result, - 'has_more': has_more - } - }) - - -class KefuGetDashouDetailView(APIView): - """ - 客服获取打手详情接口 - 请求:POST /yonghu/kefuhqdsxq - 参数:{ - "phone": "客服手机号", - "uid": "打手ID (yonghuid)" - } - 认证:JWT - 返回:code=0 + 打手信息 + 会员列表 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - uid = request.data.get('uid', '').strip() - - if not phone or not uid: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - logger.warning(f"手机号不匹配: {phone}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - logger.warning(f"用户类型非客服: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - logger.warning(f"客服扩展表不存在: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - logger.warning(f"客服账号已禁用: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 查询打手主表和扩展表 - try: - # 使用 select_related 预加载 dashou_profile,减少数据库查询 - user_main = User.query.select_related('DashouProfile').get( - UserUID=uid - ) - except User.DoesNotExist: - return Response({'code': 404, 'msg': '打手不存在'}, status=status.HTTP_404_NOT_FOUND) - - # 验证用户类型是否为打手(可选,但建议验证) - # 如果该用户没有打手扩展表,则返回空信息(但前端会显示未注册) - try: - dashou = user_main.DashouProfile - except ObjectDoesNotExist: - # 用户存在但未注册打手身份,返回基本信息 - user_info = { - 'yonghuid': user_main.UserUID, - 'avatar': user_main.Avatar or '', - 'zaixianzhuangtai': 0, - 'zhanghaozhuangtai': 0, - 'zhuangtai': 0, - 'nicheng': '', - 'chenghao': '', - 'dianhua': '', - 'wechat': '', - 'phone': user_main.Phone or '', - 'CreateTime': user_main.UserCreateTime, - 'create_time': user_main.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user_main, 'UserCreateTime', None) else '', - 'ip': user_main.IP or '', - 'jiedanzongliang': 0, - 'chengjiaozongliang': 0, - 'tuikuanliang': 0, - 'jinrijiedan': 0, - 'yue': 0.00, - 'zonge': 0.00, - 'jifen': 0, - 'yajin': 0.00, - 'jinrishouyi': 0.00, - 'jinyueshouyi': 0.00, - 'yaoqingren': '', - 'jieshao': '', - } - member_list = [] - return Response({ - 'code': 0, - 'data': { - 'user_info': user_info, - 'member_list': member_list - } - }) - - # 4. 构建打手信息 - user_info = { - 'yonghuid': user_main.UserUID, - 'avatar': user_main.Avatar or '', - 'zaixianzhuangtai': dashou.zaixianzhuangtai, - 'zhanghaozhuangtai': dashou.zhanghaozhuangtai, - 'zhuangtai': dashou.zhuangtai, - 'nicheng': dashou.nicheng or '', - 'chenghao': dashou.chenghao or '', - 'dianhua': dashou.dianhua or '', - 'wechat': dashou.wechat or '', - 'phone': user_main.Phone or '', - 'CreateTime': user_main.UserCreateTime, - 'create_time': user_main.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user_main, 'UserCreateTime', None) else '', - 'ip': user_main.IP or '', - 'jiedanzongliang': dashou.jiedanzongliang, - 'chengjiaozongliang': dashou.chengjiaozongliang, - 'tuikuanliang': dashou.tuikuanliang, - 'jinrijiedan': dashou.jinrijiedan, - 'yue': float(dashou.yue) if dashou.yue else 0.00, - 'zonge': float(dashou.zonge) if dashou.zonge else 0.00, - 'jifen': dashou.jifen, - 'yajin': float(dashou.yajin) if dashou.yajin else 0.00, - 'jinrishouyi': float(dashou.jinrishouyi) if dashou.jinrishouyi else 0.00, - 'jinyueshouyi': float(dashou.jinyueshouyi) if dashou.jinyueshouyi else 0.00, - 'yaoqingren': dashou.yaoqingren or '', - 'jieshao': dashou.jieshao or '', - } - - # 5. 查询打手会员信息(仅未过期) - member_records = Huiyuangoumai.query.filter( - yonghu_id=uid - ).order_by('-CreateTime') - - member_list = [] - for record in member_records: - # 调用模型方法检查是否过期 - if hasattr(record, 'jiance_shifou_daoqi'): - is_expired = record.jiance_shifou_daoqi() - else: - # 如果方法不存在,简单判断 - is_expired = record.daoqi_time < timezone.now() if record.daoqi_time else True - - if not is_expired: - # 获取会员详情 - try: - huiyuan = Huiyuan.query.get(huiyuan_id=record.huiyuan_id) - huiyuan_name = huiyuan.jieshao - except Huiyuan.DoesNotExist: - huiyuan_name = '未知会员' - - member_list.append({ - 'id': record.id, - 'huiyuan_name': huiyuan_name, - 'daoqi_time': record.daoqi_time, - 'is_active': record.huiyuan_zhuangtai == 1, - }) - - return Response({ - 'code': 0, - 'data': { - 'user_info': user_info, - 'member_list': member_list - } - }) - - -class KefuUpdateDashouView(APIView): - permission_classes = [IsAuthenticated] - """ - 客服修改打手信息接口 - 支持操作: - - jian_yue / jia_yue : 减/加打手余额 (权限001aa/001bb) - - jian_yajin / jia_yajin : 减/加打手押金 (权限001aa/001bb) - - jian_jifen / jia_jifen : 减/加打手积分 (权限001cc/001dd) - - gai_zhanghaozhuangtai : 修改打手账号状态(封禁/解封) (权限001ee) - - gai_zaixianzhuangtai : 修改打手在线状态 (权限001ee) - - gai_zhuangtai : 修改打手接单状态(空闲/忙碌) (权限001ee) - - jia_huiyuan / jian_huiyuan : 加/减会员 (权限001ff/001gg) - """ - def post(self, request): - # 1. 获取前端参数 - username = request.data.get('username', '').strip() - dashou_id = request.data.get('dashou_id', '').strip() - caozuo = request.data.get('caozuo', '').strip() - value = request.data.get('value') # 金额、积分、天数等 - huiyuan_id = request.data.get('huiyuan_id', '').strip() # 会员ID(加/减会员时使用) - - if not username or not dashou_id or not caozuo: - return Response({'code': 400, 'msg': '参数不完整'}) - - # 2. 权限校验(统一方法) - kefu, permissions = verify_kefu_permission(request, username) - if kefu is None: - return permissions # 直接返回错误响应 - - # 3. 查询打手是否存在 - try: - dashou_user = User.query.get(UserUID=dashou_id) - dashou_profile = dashou_user.DashouProfile - except User.DoesNotExist: - return Response({'code': 404, 'msg': '打手不存在'}) - except UserDashou.DoesNotExist: - return Response({'code': 404, 'msg': '打手扩展信息缺失'}) - - # 4. 根据操作类型执行(事务内) - with transaction.atomic(): - try: - # 记录修改前的值(用于日志) - before = { - 'yue': dashou_profile.yue, - 'yajin': dashou_profile.yajin, - 'jifen': dashou_profile.jifen, - 'zhanghaozhuangtai': dashou_profile.zhanghaozhuangtai, - 'zaixianzhuangtai': dashou_profile.zaixianzhuangtai, - 'zhuangtai': dashou_profile.zhuangtai, - } - - # ---------- 余额操作 ---------- - if caozuo == 'jian_yue': - if '001aa' not in permissions: - return Response({'code': 403, 'msg': '无权限减打手余额'}) - amount = Decimal(str(value)) - if dashou_profile.yue < amount: - return Response({'code': 400, 'msg': '余额不足'}) - dashou_profile.yue -= amount - dashou_profile.save() - after_yue = dashou_profile.yue - # 记录日志 - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, # 2=打手 - xiugaitijiao=after_yue, - xiugaitijiaoq=before['yue'], - ) - return Response({'code': 0, 'msg': '减余额成功', 'data': {'new_yue': str(after_yue)}}) - - elif caozuo == 'jia_yue': - if '001bb' not in permissions: - return Response({'code': 403, 'msg': '无权限加打手余额'}) - from jituan.services.group_finance_gate import deny_group_finance_exec - deny = deny_group_finance_exec(request.user, request) - if deny: - return deny - amount = Decimal(str(value)) - dashou_profile.yue += amount - dashou_profile.save() - after_yue = dashou_profile.yue - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - xiugaitijiao=after_yue, - xiugaitijiaoq=before['yue'], - ) - return Response({'code': 0, 'msg': '加余额成功', 'data': {'new_yue': str(after_yue)}}) - - # ---------- 押金操作 ---------- - elif caozuo == 'jian_yajin': - if '001aa' not in permissions: - return Response({'code': 403, 'msg': '无权限减打手押金'}) - amount = Decimal(str(value)) - if dashou_profile.yajin < amount: - return Response({'code': 400, 'msg': '押金不足'}) - dashou_profile.yajin -= amount - dashou_profile.save() - after_yajin = dashou_profile.yajin - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - yajin=after_yajin, - yyajin=before['yajin'], - ) - return Response({'code': 0, 'msg': '减押金成功', 'data': {'new_yajin': str(after_yajin)}}) - - elif caozuo == 'jia_yajin': - if '001bb' not in permissions: - return Response({'code': 403, 'msg': '无权限加打手押金'}) - from jituan.services.group_finance_gate import deny_group_finance_exec - deny = deny_group_finance_exec(request.user, request) - if deny: - return deny - amount = Decimal(str(value)) - dashou_profile.yajin += amount - dashou_profile.save() - after_yajin = dashou_profile.yajin - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - yajin=after_yajin, - yyajin=before['yajin'], - ) - return Response({'code': 0, 'msg': '加押金成功', 'data': {'new_yajin': str(after_yajin)}}) - - # ---------- 积分操作 ---------- - elif caozuo == 'jian_jifen': - if '001cc' not in permissions: - return Response({'code': 403, 'msg': '无权限减打手积分'}) - jifen = int(value) - if dashou_profile.jifen < jifen: - return Response({'code': 400, 'msg': '积分不足'}) - dashou_profile.jifen -= jifen - dashou_profile.save() - after_jifen = dashou_profile.jifen - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - jifen=after_jifen, - yjifen=before['jifen'], - ) - return Response({'code': 0, 'msg': '减积分成功', 'data': {'new_jifen': after_jifen}}) - - elif caozuo == 'jia_jifen': - if '001dd' not in permissions: - return Response({'code': 403, 'msg': '无权限加打手积分'}) - jifen = int(value) - dashou_profile.jifen += jifen - dashou_profile.save() - after_jifen = dashou_profile.jifen - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - jifen=after_jifen, - yjifen=before['jifen'], - ) - return Response({'code': 0, 'msg': '加积分成功', 'data': {'new_jifen': after_jifen}}) - - # ---------- 状态修改 ---------- - elif caozuo == 'gai_zhanghaozhuangtai': - if '001ee' not in permissions: - return Response({'code': 403, 'msg': '无权限修改打手账号状态'}) - new_status = int(value) - if new_status not in [0, 1]: - return Response({'code': 400, 'msg': '状态值无效(0=封禁,1=正常)'}) - dashou_profile.zhanghaozhuangtai = new_status - dashou_profile.save() - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - qitashuoming=f'修改账号状态为{new_status}', - ) - return Response({'code': 0, 'msg': '修改账号状态成功', 'data': {'new_zhanghaozhuangtai': new_status}}) - - elif caozuo == 'gai_zaixianzhuangtai': - if '001ee' not in permissions: - return Response({'code': 403, 'msg': '无权限修改打手在线状态'}) - new_status = int(value) - if new_status not in [0, 1]: - return Response({'code': 400, 'msg': '状态值无效(0=离线,1=在线)'}) - dashou_profile.zaixianzhuangtai = new_status - dashou_profile.save() - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - qitashuoming=f'修改在线状态为{new_status}', - ) - return Response({'code': 0, 'msg': '修改在线状态成功', 'data': {'new_zaixianzhuangtai': new_status}}) - - elif caozuo == 'gai_zhuangtai': - if '001ee' not in permissions: - return Response({'code': 403, 'msg': '无权限修改打手接单状态'}) - new_status = int(value) - if new_status not in [0, 1]: - return Response({'code': 400, 'msg': '状态值无效(0=忙碌,1=空闲)'}) - dashou_profile.zhuangtai = new_status - dashou_profile.save() - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - qitashuoming=f'修改接单状态为{new_status}', - ) - return Response({'code': 0, 'msg': '修改接单状态成功', 'data': {'new_zhuangtai': new_status}}) - - # ---------- 会员操作 ---------- - elif caozuo == 'jia_huiyuan': - if '001ff' not in permissions: - return Response({'code': 403, 'msg': '无权限给打手加会员'}) - if not huiyuan_id: - return Response({'code': 400, 'msg': '缺少会员ID'}) - try: - huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id) - except Huiyuan.DoesNotExist: - return Response({'code': 404, 'msg': '会员类型不存在'}) - from jituan.services.club_user import get_user_club_id - from jituan.services.member_recharge import extend_member_entitlement_admin, get_club_huiyuan_row - player_club = get_user_club_id(dashou_user) - row = get_club_huiyuan_row(player_club, huiyuan_id, require_enabled=False) - days = int(value) if value else int(row.formal_days or 30) if row else 30 - record, err = extend_member_entitlement_admin( - dashou_id, huiyuan_id, days, player_club, - ) - if err: - return Response({'code': 400, 'msg': err}) - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - huiyuants=days, - huiyuan_id=huiyuan_id, - qitashuoming=f'加正式会员 {huiyuan_id},增加{days}天(无支付单、无分红)', - ) - return Response({'code': 0, 'msg': '加会员成功', 'data': {'new_daoqi': record.daoqi_time.strftime('%Y-%m-%d %H:%M:%S')}}) - - elif caozuo == 'jian_huiyuan': - if '001gg' not in permissions: - return Response({'code': 403, 'msg': '无权限给打手减会员'}) - if not huiyuan_id: - return Response({'code': 400, 'msg': '缺少会员ID'}) - try: - record = Huiyuangoumai.query.get(yonghu_id=dashou_id, huiyuan_id=huiyuan_id) - except Huiyuangoumai.DoesNotExist: - return Response({'code': 404, 'msg': '该打手未购买此会员'}) - # 删除会员记录(或设置为过期,根据业务要求) - record.delete() - Xiugaijilu.query.create( - yonghuid=dashou_id, - xiugaiid=kefu.user.Phone, - leixing=2, - huiyuan_id=huiyuan_id, - qitashuoming=f'移除会员 {huiyuan_id}', - ) - return Response({'code': 0, 'msg': '减会员成功'}) - - else: - return Response({'code': 400, 'msg': '未知操作类型'}) - - except Exception as e: - logger.error(f"修改打手信息异常: {str(e)}", exc_info=True) - return Response({'code': 500, 'msg': '服务器内部错误'}) - - -class KefuPunishmentListView(APIView): - """ - 客服获取处罚记录列表接口 - 请求:POST /yonghu/kefu_cfgl - 参数:{ - "phone": "客服手机号", - "status": [0,3] 或 [1,2], # 状态数组 - "dingdan_id": "订单ID(可选)", - "dashouid": "打手ID(可选)", - "page": 1, - "page_size": 20 - } - 认证:JWT - 返回:{ - "code": 0, - "data": { - "list": [处罚记录], - "total": 总数, - "stats": { - "zongshu": 总记录数, - "daichuli": 待处理数, - "yichuli": 已处理数 - } - } - } - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - @staticmethod - def _parse_status_list(raw): - """兼容 status=[0,3]、\"0,3\"、单数字;空/all 表示不过滤。""" - if raw is None: - return None - if isinstance(raw, str): - s = raw.strip().lower() - if not s or s in ('all', 'null', 'none'): - return None - if s.startswith('['): - import json - try: - raw = json.loads(s) - except (TypeError, ValueError): - return None - else: - raw = [x.strip() for x in s.split(',') if x.strip()] - if isinstance(raw, (int, float)): - return [int(raw)] - if isinstance(raw, list): - out = [] - for x in raw: - try: - out.append(int(x)) - except (TypeError, ValueError): - continue - return out or None - return None - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - status_list = self._parse_status_list(request.data.get('status')) - dingdan_id = request.data.get('dingdan_id', '').strip() - dashouid = request.data.get('dashouid', '').strip() - try: - page = int(request.data.get('page', 1)) - page_size = int(request.data.get('page_size', 20)) - except ValueError: - return Response({'code': 400, 'msg': '分页参数错误'}, status=status.HTTP_400_BAD_REQUEST) - - if not phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 客服身份验证 - current_user = request.user - # 1. 获取前端参数 - username = request.data.get('phone', '').strip() - if not username: - return Response({'code': 400, 'msg': '缺少username'}) - - # 2. 公共权限校验 - kefu, permissions = verify_kefu_permission(request, username) - if kefu is None: - return permissions - - # 3. 检查平台订单管理权限(002ab) - if '005aa' not in permissions: - return Response({'code': 403, 'msg': '您无权查看平台订单列表'}) - - # 3. 获取统计信息(不分页) - from jituan.services.club_penalty import filter_penalty_record_qs - from jituan.services.club_user_access import list_response_meta - - base_qs = filter_penalty_record_qs(PenaltyRecord.query.all(), request) - stats = { - 'zongshu': base_qs.count(), - 'daichuli': base_qs.filter(ApplyStatus__in=[0, 3]).count(), - 'yichuli': base_qs.filter(ApplyStatus__in=[1, 2]).count(), - } - - queryset = base_qs - - if status_list and isinstance(status_list, list): - queryset = queryset.filter(ApplyStatus__in=status_list) - - if dingdan_id: - queryset = queryset.filter(OrderID=dingdan_id) - - if dashouid: - queryset = queryset.filter(PlayerID=dashouid) - - # 5. 分页 - page_obj = queryset.order_by('-CreateTime').paginate(page=page, per_page=page_size) - total = page_obj.total - records = page_obj.items - - # 6. 构建返回列表 - list_data = [] - for item in records: - ct = item.CreateTime - ut = item.UpdateTime - list_data.append({ - 'dingdan_id': item.OrderID, - 'dashouid': item.PlayerID, - 'qingqiuid': item.ApplicantID, - 'cfliyou': item.PenaltyReason, - 'sqzhuangtai': item.ApplyStatus, - 'jifen': item.DeductedPoints, - 'CreateTime': ct, - 'UpdateTime': ut, - 'create_time': ct.isoformat() if ct else '', - 'update_time': ut.isoformat() if ut else '', - }) - - return Response({ - 'code': 0, - 'data': { - 'list': list_data, - 'total': total, - 'stats': stats, - **list_response_meta(request), - } - }) - - -class KefuGetOrderDetailView(APIView): - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 获取当前用户 - current_user = request.user - - # 验证手机号 - if getattr(current_user, 'Phone', '') != phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 验证用户类型 - if current_user.UserType not in ('kefu', 'admin'): - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 客服扩展表 - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - if kefu.zhuangtai != 1: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - except Exception as e: - logger.exception(f"[kefuhqddxq] 获取客服扩展表异常 user={getattr(current_user, 'yonghuid', '?')}") - return Response({'code': 500, 'msg': '服务器错误'}, status=500) - - # 3. 查询订单 - try: - dingdan_obj = Order.query.select_related( - 'pingtai_kuozhan', 'shangjia_kuozhan' - ).get(OrderID=dingdan_id) - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=404) - except Exception as e: - logger.exception(f"[kefuhqddxq] 订单查询异常 dingdan_id={dingdan_id}") - return Response({'code': 500, 'msg': '服务器错误'}, status=500) - - from jituan.services.club_user_access import forbid_if_order_out_of_scope - deny = forbid_if_order_out_of_scope(request, dingdan_obj) - if deny: - return deny - - # 4. 构建基础数据 - try: - response_data = { - 'dingdan_id': dingdan_obj.OrderID or '', - 'zhuangtai': dingdan_obj.Status or 0, - 'fadanpingtai': dingdan_obj.Platform or 0, - 'jiage': float(dingdan_obj.Amount) if dingdan_obj.Amount else 0.00, - 'dashou_fencheng': float(dingdan_obj.PlayerCommission) if dingdan_obj.PlayerCommission else 0.00, - 'jiedan_dashou_id': dingdan_obj.PlayerID or '', - 'dashou_liuyan': dingdan_obj.PlayerRemark or '', - 'zhiding_id': dingdan_obj.AssignedID or '', - 'shangpin_id': dingdan_obj.ProductID or '', - 'leixing': dingdan_obj.ProductTypeID or 0, - 'tupian_url': dingdan_obj.ImageURL or '', - 'jieshao': dingdan_obj.Description or '', - 'beizhu': dingdan_obj.Remark or '', - 'tkly': dingdan_obj.RefundReason or '', - 'nicheng': dingdan_obj.Nickname or '', - 'clkf': dingdan_obj.AssignedCS or '', - 'chuangjianshijian': dingdan_obj.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if dingdan_obj.CreateTime else '', - 'genggaishijian': dingdan_obj.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if dingdan_obj.UpdateTime else '', - } - except Exception as e: - logger.exception(f"[kefuhqddxq] 构建基础数据异常 dingdan_id={dingdan_id}") - return Response({'code': 500, 'msg': '服务器错误'}, status=500) - - # 5. 扩展表数据 - try: - fadan_pingtai = dingdan_obj.Platform - if fadan_pingtai == 1: - if hasattr(dingdan_obj, 'pingtai_kuozhan') and dingdan_obj.pingtai_kuozhan: - pingtai = dingdan_obj.pingtai_kuozhan - response_data['pingtai_kuozhan'] = { - 'laoban_id': pingtai.BossID or '', - 'laoban_pingjia': pingtai.BossReview or '', - } - else: - response_data['pingtai_kuozhan'] = {'laoban_id': '', 'laoban_pingjia': ''} - elif fadan_pingtai == 2: - if hasattr(dingdan_obj, 'shangjia_kuozhan') and dingdan_obj.shangjia_kuozhan: - shangjia = dingdan_obj.shangjia_kuozhan - response_data['shangjia_kuozhan'] = { - 'shangjia_id': shangjia.MerchantID or '', - 'sjnicheng': shangjia.MerchantNickname or '', - 'shangjia_pingjia': shangjia.MerchantReview or '', - 'cfliyou': shangjia.PenaltyReason or '', - 'sqzhuangtai': shangjia.PenaltyApplyStatus if shangjia.PenaltyApplyStatus is not None else 0, - 'bhliyou': shangjia.RejectReason or '', - } - else: - response_data['shangjia_kuozhan'] = { - 'shangjia_id': '', 'sjnicheng': '', 'shangjia_pingjia': '', - 'cfliyou': '', 'sqzhuangtai': 0, 'bhliyou': '' - } - except Exception as e: - logger.exception(f"[kefuhqddxq] 扩展数据异常 dingdan_id={dingdan_id}") - return Response({'code': 500, 'msg': '服务器错误'}, status=500) - - # 6. 打手图片 - try: - dashou_images = PlayerDeliveryImage.query.filter(OrderID=dingdan_id).values_list('ImageURL', flat=True) - response_data['dashou_images'] = [img for img in dashou_images if img] - except Exception as e: - logger.exception(f"[kefuhqddxq] 打手图片查询异常 dingdan_id={dingdan_id}") - response_data['dashou_images'] = [] - - # 7. 退款理由 - try: - tuikuan_record = RefundRecord.query.filter(OrderID=dingdan_id).order_by('-CreateTime').first() - response_data['tuikuan_liyou'] = tuikuan_record.Reason if tuikuan_record and tuikuan_record.Reason else '' - except Exception as e: - logger.exception(f"[kefuhqddxq] 退款理由查询异常 dingdan_id={dingdan_id}") - response_data['tuikuan_liyou'] = '' - - # 8. 指定打手响应 - ZHIDING_HF_TEXT = {1: '想接', 2: '不想接', 3: '等会接'} - hf = getattr(dingdan_obj, 'AssignedResponse', None) - response_data['zhiding_hf'] = hf - response_data['zhiding_hf_text'] = ZHIDING_HF_TEXT.get(hf, '') if hf else '' - response_data['zhiding_hf_sj'] = ( - dingdan_obj.AssignedResponseAt.strftime('%Y-%m-%d %H:%M:%S') - if getattr(dingdan_obj, 'AssignedResponseAt', None) else '' - ) - response_data['zhiding_dhj_sm'] = getattr(dingdan_obj, 'AssignedLaterNote', '') or '' - - # 9. 关联罚款(打手) - from utils.penalty_status import PENALTY_STATUS_LABELS as PENALTY_STATUS - try: - from orders.models import Penalty - dashou_id = dingdan_obj.PlayerID or dingdan_obj.AssignedID or '' - fakuan_qs = Penalty.query.filter(RelatedOrderID=dingdan_id) - if dashou_id: - fakuan_qs = fakuan_qs.filter(PenalizedUserID=dashou_id) - fakuan_list = [] - for p in fakuan_qs.order_by('-CreateTime')[:5]: - fakuan_list.append({ - 'amount': str(p.FineAmount or '0.00'), - 'status': p.Status, - 'status_text': PENALTY_STATUS.get(p.Status, ''), - 'reason': p.Reason or '', - 'create_time': p.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if p.CreateTime else '', - }) - response_data['fakuan_list'] = fakuan_list - latest = fakuan_list[0] if fakuan_list else None - response_data['fakuan_info'] = latest - response_data['is_fadaned'] = Penalty.query.filter( - RelatedOrderID=dingdan_id, ApplicantID=getattr(current_user, 'UserUID', '') - ).exists() - except Exception as e: - logger.exception(f"[kefuhqddxq] 罚款查询异常 dingdan_id={dingdan_id}") - response_data['fakuan_list'] = [] - response_data['fakuan_info'] = None - response_data['is_fadaned'] = False - - return Response({'code': 0, 'data': response_data}) - - -class KefuPunishmentDetailView(APIView): - """ - 客服获取处罚详情接口 - 请求:POST /yonghu/kefu_cf_detail - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID" - } - 认证:JWT - 返回:code=0 + 处罚记录详情(含图片) - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - logger.warning(f"手机号不匹配: {phone}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - logger.warning(f"用户类型非客服: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - logger.warning(f"客服扩展表不存在: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - logger.warning(f"客服账号已禁用: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 查询处罚记录(按创建时间倒序取最新一条) - try: - record = PenaltyRecord.query.filter(OrderID=dingdan_id).order_by('-CreateTime').first() - if not record: - return Response({'code': 404, 'msg': '处罚记录不存在'}, status=status.HTTP_404_NOT_FOUND) - except Exception as e: - logger.error(f"查询处罚记录失败: {e}") - return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - # 查询商家证据图片 - zhengju_images = [] - shensu_images = [] - try: - # 商家证据图片:上传者为请求人 (ApplicantID) - zhengju_qs = PenaltyEvidenceImage.query.filter(OrderID=dingdan_id, UserID=record.ApplicantID) - zhengju_images = [item.ImageURL for item in zhengju_qs if item.ImageURL] - - # 打手申诉图片:上传者为打手 (PlayerID) - shensu_qs = PenaltyEvidenceImage.query.filter(OrderID=dingdan_id, UserID=record.PlayerID) - shensu_images = [item.ImageURL for item in shensu_qs if item.ImageURL] - except Exception as e: - logger.error(f"查询图片失败: {e}") - - # 构建返回数据 - data = { - 'dingdan_id': record.OrderID, - 'dashouid': record.PlayerID, - 'qingqiuid': record.ApplicantID, - 'chuliid': record.ProcessorID, - 'cfliyou': record.PenaltyReason, - 'sqzhuangtai': record.ApplyStatus, - 'bhliyou': record.RejectReason, - 'jifen': record.DeductedPoints, - 'ssliyou': record.AppealReason or '', - 'zhengju_tupian': zhengju_images, - 'shensu_tupian': shensu_images, - 'CreateTime': record.CreateTime, - 'UpdateTime': record.UpdateTime, - } - - return Response({'code': 0, 'data': data}) - - -class KefuPunishmentActionView(APIView): - """ - 客服同意/驳回处罚接口 - 请求:POST /yonghu/kefu_cf_action - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID", - "action": 1(同意) / 2(驳回), - "reject_reason": "驳回理由(驳回时必填)" - } - 认证:JWT - 返回:code=0 成功 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - action = request.data.get('action') - reject_reason = request.data.get('reject_reason', '').strip() - - if not phone or not dingdan_id or action not in [1, 2]: - return Response({'code': 401, 'msg': '参数错误'}, status=status.HTTP_400_BAD_REQUEST) - - if action == 2 and not reject_reason: - return Response({'code': 400, 'msg': '驳回理由不能为空'}, status=status.HTTP_400_BAD_REQUEST) - - # 客服身份验证(完整验证,此处略) - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - if current_user.UserType not in ('kefu', 'admin'): - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - if kefu.zhuangtai != 1: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - except ObjectDoesNotExist: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 查询待处理的处罚记录(状态0或3) - try: - record = PenaltyRecord.query.filter( - OrderID=dingdan_id, - ApplyStatus__in=[0, 3] - ).order_by('-CreateTime').first() - if not record: - exists = PenaltyRecord.query.filter(OrderID=dingdan_id).exists() - if exists: - return Response({'code': 400, 'msg': '该处罚记录已处理'}, status=status.HTTP_400_BAD_REQUEST) - else: - return Response({'code': 404, 'msg': '处罚记录不存在'}, status=status.HTTP_404_NOT_FOUND) - except Exception as e: - logger.error(f"查询处罚记录失败: {e}") - return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - with transaction.atomic(): - # 处理重复记录:同一订单的其他待处理/申诉中记录标记为驳回 - duplicate_records = PenaltyRecord.query.filter( - OrderID=dingdan_id, - ApplyStatus__in=[0, 3] - ).exclude(id=record.id) - for dup in duplicate_records: - dup.ApplyStatus = 2 - dup.ProcessorID = phone - dup.RejectReason = "系统自动驳回:存在重复处罚记录" - dup.save() - logger.info(f"自动驳回重复处罚记录 ID={dup.id}") - - if action == 1: # 同意 - # 可选验证打手存在 - if record.PlayerID: - try: - dashou = UserDashou.query.get(user__UserUID=record.PlayerID) - except UserDashou.DoesNotExist: - return Response({'code': 400, 'msg': '被处罚打手不存在'}, status=status.HTTP_400_BAD_REQUEST) - - record.ApplyStatus = 1 - record.ProcessorID = phone - record.save() - - # 更新商家订单扩展表 - try: - dingdan = Order.query.get(OrderID=dingdan_id) - if hasattr(dingdan, 'shangjia_kuozhan'): - dingdan.shangjia_kuozhan.PenaltyApplyStatus = 1 - dingdan.shangjia_kuozhan.save() - except Exception as e: - logger.warning(f"更新商家扩展表失败: {e}") - - elif action == 2: # 驳回 - # 返还积分 - if record.DeductedPoints > 0 and record.PlayerID: - try: - dashou = UserDashou.query.get(user__UserUID=record.PlayerID) - if dashou.jifen <= 1000: # 安全限制 - dashou.jifen += record.DeductedPoints - dashou.save() - logger.info(f"驳回处罚,打手{record.PlayerID}返还积分{record.DeductedPoints}") - else: - logger.warning(f"打手积分异常({dashou.jifen}),不再返还") - except UserDashou.DoesNotExist: - logger.warning(f"打手{record.PlayerID}不存在,无法返还积分") - - record.ApplyStatus = 2 - record.ProcessorID = phone - record.RejectReason = reject_reason - record.save() - - try: - dingdan = Order.query.get(OrderID=dingdan_id) - if hasattr(dingdan, 'shangjia_kuozhan'): - dingdan.shangjia_kuozhan.PenaltyApplyStatus = 2 - dingdan.shangjia_kuozhan.RejectReason = reject_reason - dingdan.shangjia_kuozhan.save() - except Exception as e: - logger.warning(f"更新商家扩展表失败: {e}") - - return Response({'code': 0, 'msg': '处理成功'}) - - -class KefuForceCompleteView(APIView): - """ - 客服强制结单接口(仅本地订单) - 请求:POST /yonghu/kefuqiangzhijiedan - 参数:{"phone": "客服手机号", "dingdan_id": "订单ID"} - 认证:JWT,仅限状态正常的客服 - 逻辑: - 1. 验证客服身份及状态 - 2. 查询订单并加锁(事务内) - 3. 校验订单状态是否为8(结算中) - 4. 校验订单必须有接单打手且打手分成金额有效 - 5. 给打手增加余额、成交统计、今日/本月收益 - 6. 如果是商家发单,更新商家成交订单数量 - 7. 更新订单状态为3(已完成) - 8. 更新客服处理统计 - 9. 返回成功 - """ - permission_classes = [permissions.IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '参数缺失'}) - - # 1. 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - return Response({'code': 401, 'msg': '认证失败'}) - - if current_user.UserType not in ('kefu', 'admin'): - return Response({'code': 401, 'msg': '认证失败'}) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - return Response({'code': 401, 'msg': '认证失败'}) - - if kefu.zhuangtai != 1: - return Response({'code': 401, 'msg': '客服状态异常'}) - - # 2. 订单处理(事务内) - try: - with transaction.atomic(): - order = Order.objects.select_for_update().get(OrderID=dingdan_id) - - # 3. 校验订单状态 - if order.Status != 8: - return Response({'code': 400, 'msg': f'订单状态不是结算中,当前状态: {order.Status}'}) - - # 4. 校验必要字段 - dashou_id = order.PlayerID - dashou_fencheng = order.PlayerCommission - if not dashou_id: - return Response({'code': 400, 'msg': '订单无接单打手'}) - if not dashou_fencheng or dashou_fencheng <= 0: - return Response({'code': 400, 'msg': '打手分成金额无效'}) - - # 5. 给打手结算 - try: - dashou_user = User.query.get(UserUID=dashou_id) - dashou_profile = dashou_user.DashouProfile - # 原子更新(避免并发重复加) - dashou_profile.chengjiaozongliang = F('chengjiaozongliang') + 1 - dashou_profile.yue = F('yue') + dashou_fencheng - dashou_profile.zonge = F('zonge') + dashou_fencheng - dashou_profile.jinrishouyi = F('jinrishouyi') + dashou_fencheng - dashou_profile.jinyueshouyi = F('jinyueshouyi') + dashou_fencheng - dashou_profile.save(update_fields=[ - 'chengjiaozongliang', 'yue', 'zonge', - 'jinrishouyi', 'jinyueshouyi' - ]) - except (User.DoesNotExist, AttributeError): - return Response({'code': 400, 'msg': '打手不存在或扩展表缺失'}) - - # 6. 如果是商家发单,更新商家成交订单数量 - if order.Platform == 2: - try: - # 通过 MerchantOrderExt 获取商家ID - shangjia_ext = order.shangjia_kuozhan # 假设 related_name='shangjia_kuozhan' - if shangjia_ext and shangjia_ext.shangjia_id: - shangjia_user = User.query.get(UserUID=shangjia_ext.shangjia_id) - shangjia_profile = shangjia_user.ShopProfile - shangjia_profile.chengjiao = F('chengjiao') + 1 - shangjia_profile.save(update_fields=['chengjiao']) - - # (行为驱动,更安全) - update_shangjia_daily( - yonghuid=shangjia_ext.shangjia_id, - amount=Decimal(str(order.Amount)), - action=2, # 2 = 结算 - order_id=dingdan_id, - ) - - except ObjectDoesNotExist: - # 没有商家扩展记录则忽略 - pass - - # 7. 更新订单状态 - order.Status = 3 - order.AssignedCS = phone - order.save(update_fields=['Status', 'AssignedCS']) - - - if order.Platform == 1: - - # ========== 新增:商品和店铺每日统计 ========== - try: - # 商品每日统计(只要有商品ID就执行) - if order.ProductID: - # 从扩展表获取已计算好的收益值(下单时已存储) - pingtai_shouyi = Decimal('0.00') - dianpu_shouyi = Decimal('0.00') - if hasattr(order, 'pingtai_kuozhan'): - pingtai_shouyi = order.pingtai_kuozhan.PlatformIncome or Decimal('0.00') - dianpu_shouyi = order.pingtai_kuozhan.ShopIncome or Decimal('0.00') - - update_shangpin_daily_stat( - shangpin_id=order.ProductID, - caozuo_leixing=2, # 2 = 结单 - dingdan_jiage=order.Amount, - pingtai_shouyi=pingtai_shouyi, - dianpu_zongshouyi=dianpu_shouyi - ) - logger.info(f"商品统计更新完成,商品ID: {order.ProductID}") - - # 店铺每日统计(仅当存在店铺ID时执行) - dianpu_id = None - if hasattr(order, 'pingtai_kuozhan'): - dianpu_id = order.pingtai_kuozhan.ShopID - if dianpu_id: - dianpu_shouyi = order.pingtai_kuozhan.ShopIncome or Decimal('0.00') - update_dianpu_daily_stat( - dianpu_id=dianpu_id, - caozuo_leixing=2, # 2 = 结单 - dingdan_jiage=order.Amount, - dianpu_shouyi=dianpu_shouyi - ) - logger.info(f"店铺统计更新完成,店铺ID: {dianpu_id}") - except Exception as e: - logger.error(f"商品/店铺统计更新失败: {e}") - logger.error(traceback.format_exc()) - # 统计失败不影响主流程 - # ============================================= - - # 🔥 新增:调用打手每日统计(成交) - try: - update_dashou_daily_by_action( - yonghuid=order.PlayerID, - amount=dashou_fencheng, - action=2 # 2 表示成交(结算) - ) - logger.info(f"打手 {order.PlayerID} 每日统计更新成功") - except Exception as e: - logger.error(f"打手每日统计更新失败: {e}") - # 不影响主流程 - - # 商家订单管事分红 - if order.Platform == 2: - settle_shangjia_order_guanshi_fenhong(order, dashou_id) - - # 8. 更新客服统计 - kefu.jinrichuli = F('jinrichuli') + 1 - kefu.jinyuechuli = F('jinyuechuli') + 1 - kefu.zongchuli = F('zongchuli') + 1 - kefu.save(update_fields=['jinrichuli', 'jinyuechuli', 'zongchuli']) - - return Response({'code': 0, 'msg': '强制结单成功'}) - - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}) - except Exception as e: - logger.error(f"强制结单异常: {str(e)}", exc_info=True) - return Response({'code': 500, 'msg': '服务器内部错误'}) - - -kefu_txsh_list_logger = logging.getLogger('yonghu.kefu_txsh_list') -if not kefu_txsh_list_logger.handlers: - _console_handler = logging.StreamHandler(sys.stderr) - _console_handler.setFormatter(logging.Formatter( - '[%(asctime)s] %(levelname)s %(name)s: %(message)s' - )) - kefu_txsh_list_logger.addHandler(_console_handler) - kefu_txsh_list_logger.setLevel(logging.DEBUG) - kefu_txsh_list_logger.propagate = False - -_TIXIAN_LIST_BASE_FIELDS = ( - 'id', 'yonghuid', 'avatar', 'phone', 'nicheng', 'leixing', - 'zhifu', 'skzhanghao', 'jine', 'zhuangtai', 'fangshi', - 'shenheid', 'bhliyou', 'CreateTime', 'UpdateTime', -) -_TIXIAN_LIST_AUDIT_FIELDS = ('shenhe_jilu_id', 'shenhe_danhao') - - -class KefuWithdrawListView(APIView): - """ - 客服获取提现审核列表接口 - 请求:POST /yonghu/kefu_txsh_list - 参数:{ - "phone": "客服手机号", - "page": 1, - "page_size": 20, - "status": 1(待审核)/2(已处理), - "type": 1(打手佣金)/2(管事分红)/3(组长分红)/4(审核官分佣)/5(打手押金)/6(商家余额), 可选 - "payment": 1(微信)/2(支付宝) 可选, - "search_uid": "用户ID搜索" 可选, - "date": "YYYY-MM-DD" 可选, - "min_amount": 0, # 最小提现金额(元)可选 - "max_amount": 999999, # 最大提现金额(元)可选 - "dakuan_mode": 1 # 打款方式:1手动打款 2自动打款,可选 - } - 认证:JWT - 返回:{ - "code": 0, - "data": { - "list": [提现记录], - "total": 总数, - "stats": { - "total": 总记录数, - "awaiting": 待审核数, - "processed": 已处理数, - "total_amount": "总金额(元)" - } - } - } - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def _safe_request_params(self, request): - try: - return dict(request.data) - except Exception: - return {'raw': str(request.data)} - - def _shenhe_jilu_field_available(self): - """探测 shenhe_jilu_id 字段是否已在数据库中就绪""" - try: - Tixianjilu.query.values_list('shenhe_jilu_id', flat=True).first() - return True - except DatabaseError as e: - kefu_txsh_list_logger.error( - 'kefu_txsh_list: shenhe_jilu_id 字段不可用,将按旧表结构查询: %s', e, - exc_info=True, - ) - return False - - def _list_queryset(self, q, shenhe_field_ok, request): - """字段未迁移时 only 旧字段,避免 SELECT 不存在的列导致 500""" - fields = _TIXIAN_LIST_BASE_FIELDS + ( - _TIXIAN_LIST_AUDIT_FIELDS if shenhe_field_ok else () - ) - from jituan.services.tixian_club import filter_tixianjilu_by_request - qs = Tixianjilu.query.filter(q).only(*fields) - return filter_tixianjilu_by_request(qs, request) - - def _resolve_row_dakuan_mode(self, item, audit_mode_map, shenhe_field_ok): - """无关联审核或审核表未标记自动 → 默认手动(1)""" - if not shenhe_field_ok: - return 1 - sid = getattr(item, 'shenhe_jilu_id', None) - if not sid: - return 1 - if audit_mode_map.get(sid) == 2: - return 2 - return 1 - - def post(self, request): - from jituan.services.group_finance_gate import require_withdraw_audit_access - - dakuan_mode_filter = request.data.get('dakuan_mode') - req_params = self._safe_request_params(request) - kefu_txsh_list_logger.info('kefu_txsh_list 请求开始 params=%s', req_params) - - try: - try: - page = int(request.data.get('page', 1)) - page_size = int(request.data.get('page_size', 20)) - except (TypeError, ValueError): - return Response({'code': 400, 'msg': '分页参数错误'}, status=status.HTTP_400_BAD_REQUEST) - - if page < 1: - page = 1 - if page_size < 1 or page_size > 100: - page_size = min(max(page_size, 1), 100) - - status_filter = request.data.get('status') - type_filter = request.data.get('type') - payment_filter = request.data.get('payment') - search_uid = (request.data.get('search_uid') or '').strip() - date_filter = (request.data.get('date') or '').strip() - min_amount = request.data.get('min_amount') - max_amount = request.data.get('max_amount') - - phone = (request.data.get('phone') or '').strip() - if not phone: - return Response({'code': 401, 'msg': '缺少客服手机号'}, status=status.HTTP_401_UNAUTHORIZED) - - kefu, err_resp = require_withdraw_audit_access(request, phone) - if err_resp: - return err_resp - - q = Q() - - try: - status_val = int(status_filter) if status_filter is not None and str(status_filter).strip() != '' else None - except (TypeError, ValueError): - status_val = None - - try: - dakuan_mode_val = int(dakuan_mode_filter) if dakuan_mode_filter is not None and str(dakuan_mode_filter).strip() != '' else None - except (TypeError, ValueError): - dakuan_mode_val = None - - # 未传打款方式参数 → 默认手动打款(1) - effective_dakuan = dakuan_mode_val if dakuan_mode_val in (1, 2) else 1 - - if status_val == 1: - q &= Q(zhuangtai=1) - elif status_val == 2: - if effective_dakuan == 2: - q &= Q(zhuangtai__in=[2, 3, 4, 5, 6]) - else: - q &= Q(zhuangtai__in=[2, 3]) - - if type_filter is not None and str(type_filter).strip() != '': - try: - type_val = int(type_filter) - if type_val in [1, 2, 3, 4, 5, 6]: - q &= Q(leixing=type_val) - except (TypeError, ValueError): - pass - - if payment_filter is not None and str(payment_filter).strip() != '': - try: - payment_val = int(payment_filter) - if payment_val in [1, 2]: - q &= Q(fangshi=payment_val) - except (TypeError, ValueError): - pass - - if search_uid: - q &= Q(yonghuid=search_uid) - - if date_filter: - q &= Q(UpdateTime__date=date_filter) - - if min_amount is not None and str(min_amount).strip() != '': - try: - q &= Q(jine__gte=float(min_amount)) - except (TypeError, ValueError): - pass - if max_amount is not None and str(max_amount).strip() != '': - try: - q &= Q(jine__lte=float(max_amount)) - except (TypeError, ValueError): - pass - - shenhe_field_ok = self._shenhe_jilu_field_available() - if shenhe_field_ok: - if effective_dakuan == 2: - q &= Q(shenhe_jilu_id__isnull=False) - else: - q &= Q(shenhe_jilu_id__isnull=True) - elif effective_dakuan == 2: - kefu_txsh_list_logger.warning('kefu_txsh_list: 自动打款筛选不可用,返回空列表') - return Response({ - 'code': 0, - 'data': { - 'list': [], - 'total': 0, - 'stats': {'total': 0, 'awaiting': 0, 'processed': 0, 'total_amount': '0.00'}, - }, - }) - - processed_statuses = [2, 3, 4, 5, 6] if effective_dakuan == 2 else [2, 3] - - base_qs = self._list_queryset(q, shenhe_field_ok, request) - total_count = base_qs.count() - awaiting_count = self._list_queryset(q & Q(zhuangtai=1), shenhe_field_ok, request).count() - processed_count = self._list_queryset( - q & Q(zhuangtai__in=processed_statuses), shenhe_field_ok, request, - ).count() - agg = base_qs.aggregate(total=Sum('jine')) - total_amount = agg.get('total') or 0 - - stats = { - 'total': total_count, - 'awaiting': awaiting_count, - 'processed': processed_count, - 'total_amount': str(round(float(total_amount), 2)), - } - - queryset = base_qs.order_by('CreateTime') - total = queryset.count() - records = list(queryset[(page - 1) * page_size: page * page_size]) - - audit_ids = [ - getattr(item, 'shenhe_jilu_id', None) - for item in records if getattr(item, 'shenhe_jilu_id', None) - ] - audit_meta_map = {} - if audit_ids: - try: - audit_meta_map = load_audit_meta_map(audit_ids) - except Exception as e: - kefu_txsh_list_logger.error( - 'kefu_txsh_list: 查询审核表信息失败: %s', e, - exc_info=True, - ) - - audit_mode_map = { - aid: meta.get('dakuan_mode', 2) for aid, meta in audit_meta_map.items() - } - list_data = [] - for item in records: - shenhe_jilu_id = getattr(item, 'shenhe_jilu_id', None) if shenhe_field_ok else None - shenhe_danhao = (getattr(item, 'shenhe_danhao', None) or '') if shenhe_field_ok else '' - if shenhe_jilu_id and not shenhe_danhao: - shenhe_danhao = audit_meta_map.get(shenhe_jilu_id, {}).get('shenhe_danhao', '') - list_data.append({ - 'id': item.id, - 'shenhe_jilu_id': shenhe_jilu_id, - 'shenhe_danhao': shenhe_danhao, - 'yonghuid': item.yonghuid, - 'avatar': item.avatar or '', - 'phone': item.phone or '', - 'nicheng': item.nicheng or '', - 'leixing': item.leixing, - 'zhifu': item.zhifu or '', - 'skzhanghao': item.skzhanghao or '', - 'jine': str(item.jine) if item.jine is not None else '0.00', - 'zhuangtai': item.zhuangtai, - 'fangshi': item.fangshi, - 'dakuan_mode': self._resolve_row_dakuan_mode(item, audit_mode_map, shenhe_field_ok), - 'shenheid': item.shenheid or '', - 'bhliyou': item.bhliyou or '', - 'CreateTime': item.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if item.CreateTime else '', - 'UpdateTime': item.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if item.UpdateTime else '', - }) - - from jituan.services.club_user_access import list_response_meta - - return Response({ - 'code': 0, - 'data': { - 'list': list_data, - 'total': total, - 'stats': stats, - **list_response_meta(request), - }, - }) - - except DatabaseError as e: - kefu_txsh_list_logger.error( - 'kefu_txsh_list 数据库错误 dakuan_mode=%s params=%s err=%s', - dakuan_mode_filter, req_params, e, - exc_info=True, - ) - return Response({ - 'code': 500, - 'msg': '数据库查询失败,请确认已执行 python manage.py migrate yonghu', - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - except Exception as e: - kefu_txsh_list_logger.error( - 'kefu_txsh_list 接口异常 dakuan_mode=%s params=%s err=%s', - dakuan_mode_filter, req_params, e, - exc_info=True, - ) - return Response({ - 'code': 500, - 'msg': '获取提现列表失败', - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class KefuWithdrawDetailView(APIView): - """ - 客服获取提现详情接口 - 请求:POST /yonghu/kefu_txsh_detail - 参数:{ - "phone": "客服手机号", - "tixian_id": "提现记录ID" - } - 认证:JWT - 返回:code=0 + 提现记录详情 + 用户扩展信息 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - phone = request.data.get('phone', '').strip() - tixian_id = request.data.get('tixian_id') - - if not phone or not tixian_id: - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - logger.warning(f"手机号不匹配: {phone}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - logger.warning(f"用户类型非客服: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - logger.warning(f"客服扩展表不存在: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - logger.warning(f"客服账号已禁用: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - from jituan.services.group_finance_gate import require_withdraw_audit_access - _, err_resp = require_withdraw_audit_access(request, phone) - if err_resp: - return err_resp - - # 查询提现记录 - try: - record = Tixianjilu.query.get(id=tixian_id) - except Tixianjilu.DoesNotExist: - return Response({'code': 404, 'msg': '提现记录不存在'}, status=status.HTTP_404_NOT_FOUND) - - from jituan.services.tixian_club import forbid_if_tixian_out_of_scope - deny = forbid_if_tixian_out_of_scope(request, record) - if deny: - return deny - - # 查询用户主表 - try: - user = User.query.get(UserUID=record.yonghuid) - except User.DoesNotExist: - user = None - - # 构建基础返回数据(字段必须与前端一致) - data = { - 'id': record.id, - 'yonghuid': record.yonghuid, - 'avatar': record.avatar or '', - 'phone': record.phone or '', - 'nicheng': record.nicheng or '', - 'leixing': record.leixing, # 1打手 2管事 - 'zhifu': record.zhifu or '', - 'skzhanghao': record.skzhanghao or '', - 'jine': str(record.jine) if record.jine else '0.00', - 'zhuangtai': record.zhuangtai, - 'fangshi': record.fangshi, - 'shenheid': record.shenheid or '', - 'bhliyou': record.bhliyou or '', - 'CreateTime': record.CreateTime, - 'UpdateTime': record.UpdateTime, - } - - # 根据提现类型添加扩展信息 - if user: - if record.leixing == 1: # 打手 - try: - dashou = UserDashou.query.get(user=user) - # 计算成交率和退款率 - total = dashou.jiedanzongliang or 0 - completed = dashou.chengjiaozongliang or 0 - refund = dashou.tuikuanliang or 0 - completion_rate = round((completed / total * 100) if total > 0 else 0) - refund_rate = round((refund / completed * 100) if completed > 0 else 0) - - data['dashouInfo'] = { - 'zhuangtai': dashou.zhuangtai, # 工作状态 - 'zaixianzhuangtai': dashou.zaixianzhuangtai, # 在线状态 - 'zhanghaozhuangtai': dashou.zhanghaozhuangtai, # 账号状态 - 'yue': float(dashou.yue) if dashou.yue else 0.00, # 可提现余额 - 'zonge': float(dashou.zonge) if dashou.zonge else 0.00, # 接单总额 - 'jifen': dashou.jifen, # 积分 - 'jiedanzongliang': dashou.jiedanzongliang, # 接单总量 - 'chengjiaozongliang': dashou.chengjiaozongliang, # 成交总量 - 'tuikuanliang': dashou.tuikuanliang, # 退款总量 - 'completionRate': completion_rate, - 'refundRate': refund_rate, - } - except ObjectDoesNotExist: - data['dashouInfo'] = None - elif record.leixing == 2: # 管事 - try: - guanshi = UserGuanshi.query.get(user=user) - data['guanshiInfo'] = { - 'zhuangtai': guanshi.zhuangtai, # 账号状态 - 'yue': float(guanshi.yue) if guanshi.yue else 0.00, - 'yaogingshuliang': guanshi.yaogingshuliang, # 邀请打手总数 - 'jinrichongzhi': guanshi.jinrichongzhi, # 今日充值 - 'jinyuechongzhi': guanshi.jinyuechongzhi, # 今月充值 - 'chongzhifenrun': float(guanshi.chongzhifenrun) if guanshi.chongzhifenrun else 0.00, - } - except ObjectDoesNotExist: - data['guanshiInfo'] = None - - return Response({'code': 0, 'data': data}) - - -class KefuWithdrawActionView(APIView): - """ - 客服处理提现接口(同意/拒绝)—— 仅此接口处理后台审核打款 - POST /yonghu/kefu_txsh_action - - 单条:{ phone, action, tixian_id, yonghuid, leixing, reason? } - 批量:{ phone, action, batch_list: [{tixian_id, yonghuid, leixing}, ...], reason? } - - 自动打款(dakuan_mode=2):同意→6待收款;拒绝→5+驳回原因+退还可到账金额(shijidaozhang),手续费不退,不更新平台收支/每日统计 - 手动打款(dakuan_mode=1):原逻辑不变 - 状态要求:Tixianjilu.zhuangtai=1 且 TixianShenheJilu.zhuangtai=1(审核中),其他状态一律拒绝 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def _verify_kefu(self, request, phone): - if not phone: - return None, Response({'code': 401, 'msg': '手机号不能为空'}, status=status.HTTP_400_BAD_REQUEST) - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - return None, Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - if current_user.UserType not in ('kefu', 'admin'): - return None, Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - # 管理员跳过客服扩展表检查 - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - return None, Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - if kefu.zhuangtai != 1: - return None, Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - return current_user, None - - def _parse_items(self, request): - """解析单条或 batch_list,返回 [{tixian_id, yonghuid, leixing}]""" - batch_list = request.data.get('batch_list') - if batch_list is not None: - if not isinstance(batch_list, list) or len(batch_list) == 0: - return None, Response({'code': 400, 'msg': 'batch_list 不能为空'}, status=status.HTTP_400_BAD_REQUEST) - items = [] - for idx, row in enumerate(batch_list): - if not isinstance(row, dict): - return None, Response({'code': 400, 'msg': f'batch_list[{idx}] 格式错误'}, status=status.HTTP_400_BAD_REQUEST) - tid = row.get('tixian_id') - yid = (row.get('yonghuid') or '').strip() - lx = row.get('leixing') - if not tid or not yid or lx is None: - return None, Response( - {'code': 400, 'msg': f'batch_list[{idx}] 缺少 tixian_id / yonghuid / leixing'}, - status=status.HTTP_400_BAD_REQUEST, - ) - try: - items.append({ - 'tixian_id': int(tid), - 'yonghuid': yid, - 'leixing': int(lx), - }) - except (TypeError, ValueError): - return None, Response({'code': 400, 'msg': f'batch_list[{idx}] 参数格式错误'}, status=status.HTTP_400_BAD_REQUEST) - return items, None - - tixian_id = request.data.get('tixian_id') - if not tixian_id: - return None, Response({'code': 400, 'msg': '提现记录ID不能为空'}, status=status.HTTP_400_BAD_REQUEST) - yonghuid = (request.data.get('yonghuid') or '').strip() - leixing = request.data.get('leixing') - try: - item = {'tixian_id': int(tixian_id), 'yonghuid': yonghuid, 'leixing': int(leixing) if leixing is not None else None} - except (TypeError, ValueError): - return None, Response({'code': 400, 'msg': '参数格式错误'}, status=status.HTTP_400_BAD_REQUEST) - return [item], None - - def _process_auto_item(self, request, item, action, reason, kefu_user): - """自动打款单条处理(须在 transaction.atomic 内调用)""" - tixian_id = item['tixian_id'] - req_yonghuid = item['yonghuid'] - req_leixing = item['leixing'] - - if req_leixing not in [1, 2, 3, 4, 5, 6]: - raise ValueError(f'提现记录{tixian_id}:提现类型无效') - - try: - tixian = Tixianjilu.objects.select_for_update().get(id=tixian_id) - except Tixianjilu.DoesNotExist: - raise ValueError(f'提现记录{tixian_id}不存在') - - from jituan.services.tixian_club import forbid_if_tixian_out_of_scope - deny = forbid_if_tixian_out_of_scope(request, tixian) - if deny: - raise ValueError(deny.data.get('msg', '无权限处理该提现记录')) - - if tixian.yonghuid != req_yonghuid: - raise ValueError(f'提现记录{tixian_id}:用户ID不匹配') - if tixian.leixing != req_leixing: - raise ValueError(f'提现记录{tixian_id}:提现类型不匹配') - if tixian.zhuangtai != 1: - raise ValueError(f'提现记录{tixian_id}:非审核中状态,无法处理') - if not tixian.shenhe_jilu_id: - raise ValueError(f'提现记录{tixian_id}:非自动打款审核记录') - - try: - audit = TixianShenheJilu.objects.select_for_update().get(id=tixian.shenhe_jilu_id) - except TixianShenheJilu.DoesNotExist: - raise ValueError(f'提现记录{tixian_id}:审核记录不存在') - - if audit.yonghuid != req_yonghuid: - raise ValueError(f'提现记录{tixian_id}:审核记录用户ID不匹配') - if audit.leixing != req_leixing: - raise ValueError(f'提现记录{tixian_id}:审核记录提现类型不匹配') - if audit.zhuangtai != 1: - raise ValueError(f'提现记录{tixian_id}:审核记录非审核中状态') - - try: - user = User.objects.select_for_update().get(UserUID=req_yonghuid) - except User.DoesNotExist: - raise ValueError(f'用户{req_yonghuid}不存在') - - if action == 1: - limit_ok, limit_msg = check_shijidaozhang_within_limit(audit.shijidaozhang) - if not limit_ok: - sync_audit_and_jilu_status( - audit, 5, - bhliyou=limit_msg, - bo_hui_ren_id=kefu_user.UserUID, - ) - refund_balance(user, audit.leixing, audit.shijidaozhang) - tixian.shenheid = kefu_user.UserUID - tixian.bhliyou = limit_msg - tixian.save(update_fields=['shenheid', 'bhliyou', 'UpdateTime']) - raise ValueError(f'提现记录{tixian_id}:{limit_msg},已自动驳回并退款') - sync_audit_and_jilu_status(audit, 6, shenhe_ren_id=kefu_user.UserUID) - tixian.shenheid = kefu_user.UserUID - tixian.save(update_fields=['shenheid', 'UpdateTime']) - else: - sync_audit_and_jilu_status( - audit, 5, - bhliyou=reason, - bo_hui_ren_id=kefu_user.UserUID, - ) - # 退还可到账金额,申请时扣的 shenqing_jine 中的手续费(shouxufei)不退 - refund_balance(user, audit.leixing, audit.shijidaozhang) - tixian.shenheid = kefu_user.UserUID - tixian.bhliyou = reason - tixian.save(update_fields=['shenheid', 'bhliyou', 'UpdateTime']) - - def _process_manual_item(self, request, item, action, reason, phone): - """手动打款单条处理(原逻辑,须在 transaction.atomic 内调用)""" - tixian_id = item['tixian_id'] - try: - tixian = Tixianjilu.objects.select_for_update().get(id=tixian_id) - except Tixianjilu.DoesNotExist: - raise ValueError(f'提现记录{tixian_id}不存在') - - from jituan.services.tixian_club import forbid_if_tixian_out_of_scope - deny = forbid_if_tixian_out_of_scope(request, tixian) - if deny: - raise ValueError(deny.data.get('msg', '无权限处理该提现记录')) - - req_yonghuid = item.get('yonghuid') - req_leixing = item.get('leixing') - if req_yonghuid and tixian.yonghuid != req_yonghuid: - raise ValueError(f'提现记录{tixian_id}:用户ID不匹配') - if req_leixing is not None and tixian.leixing != req_leixing: - raise ValueError(f'提现记录{tixian_id}:提现类型不匹配') - if tixian.zhuangtai != 1: - raise ValueError(f'提现记录{tixian_id}:非审核中状态,无法处理') - - if getattr(tixian, 'shenhe_jilu_id', None): - raise ValueError(f'提现记录{tixian_id}:自动打款请走自动流程') - - try: - user = User.query.get(UserUID=tixian.yonghuid) - except User.DoesNotExist: - raise ValueError(f'用户{tixian.yonghuid}不存在') - - from jituan.services.club_user import get_user_club_id - - if action == 1: - tixian.zhuangtai = 2 - try: - from jituan.services.szjilu_accounting import apply_szjilu_expense - update_daily_payout(tixian.jine, get_user_club_id(user)) - apply_szjilu_expense(tixian.jine, get_user_club_id(user)) - except Exception as e: - logger.error(f'更新收支记录失败: {e}') - else: - try: - refund_balance(user, tixian.leixing, tixian.jine) - except ValueError as e: - raise ValueError(str(e)) - tixian.zhuangtai = 3 - tixian.bhliyou = reason - - tixian.shenheid = phone - tixian.UpdateTime = timezone.now() - tixian.save() - - def post(self, request): - logger.info(f'接收到提现处理请求,数据: {request.data}') - - phone = request.data.get('phone', '').strip() - action_raw = request.data.get('action') - reason = request.data.get('reason', '').strip() - - try: - action = int(action_raw) - except (TypeError, ValueError): - return Response({'code': 400, 'msg': 'action 参数必须为数字'}, status=status.HTTP_400_BAD_REQUEST) - if action not in [1, 2]: - return Response({'code': 400, 'msg': 'action 必须为1(同意)或2(拒绝)'}, status=status.HTTP_400_BAD_REQUEST) - if action == 2 and not reason: - return Response({'code': 400, 'msg': '拒绝提现时理由不能为空'}, status=status.HTTP_400_BAD_REQUEST) - - kefu_user, err_resp = self._verify_kefu(request, phone) - if err_resp: - return err_resp - - from jituan.services.group_finance_gate import require_withdraw_audit_access - _, audit_err = require_withdraw_audit_access(request, phone) - if audit_err: - return audit_err - - items, err_resp = self._parse_items(request) - if err_resp: - return err_resp - - is_batch = isinstance(request.data.get('batch_list'), list) and len(request.data.get('batch_list')) > 0 - - try: - with transaction.atomic(): - for item in items: - tixian_id = item['tixian_id'] - try: - tixian_probe = Tixianjilu.objects.select_for_update().get(id=tixian_id) - except Tixianjilu.DoesNotExist: - raise ValueError(f'提现记录{tixian_id}不存在') - - from jituan.services.tixian_club import forbid_if_tixian_out_of_scope - deny = forbid_if_tixian_out_of_scope(request, tixian_probe) - if deny: - raise ValueError(deny.data.get('msg', '无权限处理该提现记录')) - - # 有关联审核记录(shenhe_jilu_id)即为 zddksh 自动打款,不依赖 dakuan_mode 字段 - is_auto = bool(getattr(tixian_probe, 'shenhe_jilu_id', None)) - - if is_auto: - self._process_auto_item(request, item, action, reason, kefu_user) - else: - if is_batch: - raise ValueError(f'提现记录{tixian_id}:批量操作仅支持自动打款记录') - self._process_manual_item(request, item, action, reason, phone) - - except ValueError as e: - return Response({'code': 400, 'msg': str(e)}, status=status.HTTP_400_BAD_REQUEST) - except Exception as e: - logger.error(f'处理提现事务失败: {e}', exc_info=True) - return Response({'code': 500, 'msg': f'处理失败: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - msg = '批量处理成功' if is_batch and len(items) > 1 else '处理成功' - return Response({'code': 0, 'msg': msg, 'data': {'count': len(items)}}) - - -class KefuPunishView(APIView): - """ - 客服处罚打手接口 - 请求:POST /yonghu/kefuchufa - 参数:{ - "phone": "客服手机号", - "dingdan_id": "订单ID", - "reason": "处罚原因(可选)" - } - 认证:JWT - 返回:code=0 成功 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - dingdan_id = request.data.get('dingdan_id', '').strip() - reason = request.data.get('reason', '').strip() - - if not phone or not dingdan_id: - return Response({'code': 401, 'msg': '参数不完整'}, status=status.HTTP_400_BAD_REQUEST) - - # 2. 客服身份验证 - current_user = request.user - if getattr(current_user, 'Phone', '') != phone: - logger.warning(f"手机号不匹配: {phone}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if current_user.UserType not in ('kefu', 'admin'): - logger.warning(f"用户类型非客服: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser - kefu = None - if not is_admin: - try: - kefu = current_user.KefuProfile - except ObjectDoesNotExist: - logger.warning(f"客服扩展表不存在: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - if kefu.zhuangtai != 1: - logger.warning(f"客服账号已禁用: {current_user.UserUID}") - return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 3. 查询订单 - try: - order = Order.query.get(OrderID=dingdan_id) - except Order.DoesNotExist: - return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) - - # 4. 获取打手ID - dashou_id = order.PlayerID - if not dashou_id: - return Response({'code': 400, 'msg': '订单未接单,无法处罚打手'}, status=status.HTTP_400_BAD_REQUEST) - - # 5. 查询打手 - try: - dashou_user = User.query.get(UserUID=dashou_id) - dashou = dashou_user.DashouProfile - except User.DoesNotExist: - return Response({'code': 404, 'msg': '打手用户不存在'}, status=status.HTTP_404_NOT_FOUND) - except ObjectDoesNotExist: - return Response({'code': 400, 'msg': '该用户不是打手'}, status=status.HTTP_400_BAD_REQUEST) - - # 6. 使用事务 - from jituan.services.club_penalty import resolve_penalty_record_club_id - with transaction.atomic(): - # 创建处罚记录(积分扣除5分) - jifen = 5 - chufa = PenaltyRecord.query.create( - PlayerID=dashou_id, - ApplicantID=phone, # 客服手机号作为请求人 - PenaltyReason=reason or '', - ApplyStatus=0, # 待处理 - DeductedPoints=jifen, - OrderID=dingdan_id, - ClubID=resolve_penalty_record_club_id( - order_id=dingdan_id, - player_id=dashou_id, - request=request, - user=request.user, - ), - ) - - # 扣除打手积分 - if dashou.jifen >= jifen: - dashou.jifen -= jifen - else: - dashou.jifen = 0 - dashou.save() - - # 可选:更新订单的 clkf 字段记录处理客服 - order.AssignedCS = phone - order.save() - - return Response({'code': 0, 'msg': '处罚成功'}) diff --git a/users/views/kefu_base.py b/users/views/kefu_base.py new file mode 100644 index 0000000..c3f1f45 --- /dev/null +++ b/users/views/kefu_base.py @@ -0,0 +1,383 @@ +"""users.views.kefu_base - auto-generated by split script.""" +"""users.views.kefu - auto-generated by split script.""" +import os +import sys +import json +import re +import time +import uuid +import random +import string +import requests +import hashlib +import traceback +import decimal +import jwt +import ipaddress +import xmltodict +from datetime import datetime, timedelta +from decimal import Decimal +import defusedxml.ElementTree as ET + +from django.conf import settings +from django.db import models, transaction, IntegrityError, DatabaseError, connection +from django.db.models import Q, F, Sum, Count, Case, When, IntegerField, Prefetch +from django.core.cache import cache +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.http import HttpResponse +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.hashers import make_password + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import permissions, status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.authentication import JWTAuthentication + +from gvsdsdk.fluent import db, func, FQ + +from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss +from utils.money import yuan_to_fen +from utils.fadan_utils import check_fadan_qiangdan_eligible +from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode +from users.fadan_fenhong_utils import process_fadan_fenhong + +from orders.utils import ( + update_daily_payout, + settle_shangjia_order_guanshi_fenhong +) +from backend.utils import ( + update_dashou_daily_by_action, update_guanshi_daily_by_action, + update_shangjia_daily, update_zuzhang_daily_by_action, + verify_kefu_permission +) +from rank.utils import get_tag_fee, create_shenhe_jilu, validate_shenheguan + +from ..models import ( + UserBoss, UserDashou, UserShangjia, UserGuanshi, + UserZuzhang, UserKefu, AdminProfile, OfficialAccountUser, + TixianAutoRecord, TixianShenheJilu, Tixianjilu, Xiugaijilu, + RankingRecord +) +from users.business_models import User +from ..tixian_shenhe_services import ( + load_audit_meta_map, refund_balance, sync_audit_and_jilu_status, + mark_transfer_success, release_collect_quota_for_record, + close_audit_wechat_failed, query_wechat_transfer_bill, + check_shijidaozhang_within_limit, + check_dashou_trial_blocks_commission_withdraw, + WX_STATE_FAIL, WX_STATE_SUCCESS, WX_STATE_WAIT, WX_STATE_CANCELING, +) +from ..tixian_shenhe_views import process_audit_collect + +from orders.models import ( + Order, PlatformOrderExt, MerchantOrderExt, CommissionRate, + PenaltyRecord, PenaltyEvidenceImage, RefundRecord, PlayerDeliveryImage, + Penalty, PenaltyAppealImage +) +from products.models import ( + ShangpinLeixing, Huiyuan, Huiyuangoumai, Gsfenhong, Czjilu +) +from config.models import Qunpeizhi +from backend.models import MerchantDailyStats +from rank.models import ( + KaohePayTemp, Chenghao, ShenheJilu, + KaoheCishuFeiyong, YonghuChenghao +) +from users.models import UserShenheguan +from products.utils import update_shangpin_daily_stat +from shop.utils import update_dianpu_daily_stat +from rank.utils import create_shenhe_jilu_from_temp + +import logging + +logger = logging.getLogger(__name__) + + +class KefuLoginView(APIView): + """ + 客服/管理员登录接口(支持手机号重复) + 请求:POST /yonghu/kefujinru + 请求体:{"phone": "13800138000", "password": "xxx", "erjimima": "yyy"} + 返回: + code=0 成功,data包含token,nicheng,yonghuid + code=1 失败(msg区分:用户不存在/密码错误/二级密码错误) + code=4 账号被封禁 + + 管理员(IsSuperuser 或 UserType='admin')无需 erjimima 和 KefuProfile。 + """ + throttle_classes = [AnonRateThrottle] # 限制匿名请求频率 + permission_classes = [AllowAny] # 任何人都可访问 + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + password = request.data.get('password', '') + erjimima = request.data.get('erjimima', '') + + if not phone or not password: + return Response({ + 'code': 1, + 'msg': '请填写手机号和密码', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 2. 获取客户端真实IP + client_ip = self.get_client_ip(request) + + # 3. 查询该手机号下所有账号(客服 + 管理员) + # 先查客服(有 KefuProfile),再查管理员(IsSuperuser 或 UserType='admin') + user_mains = list( + User.query.filter( + Phone=phone, + KefuProfile__isnull=False, + ).select_related('KefuProfile') + ) + + # 如果没有客服账号,尝试查找管理员账号 + if not user_mains: + admin_candidates = User.query.filter(Phone=phone) + for admin_user in admin_candidates: + if admin_user.IsSuperuser or admin_user.UserType == 'admin': + user_mains = [admin_user] + break + + if not user_mains: + logger.warning(f"登录失败,用户不存在: {phone}, IP: {client_ip}") + return Response({ + 'code': 1, + 'msg': '用户不存在', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 4. 遍历所有匹配的账号,验证密码和二级密码 + target_user = None + target_kefu = None + password_matched = False + + for user in user_mains: + # 验证主密码(bcrypt 哈希验证) + if not user.CheckPassword(password): + continue + password_matched = True + + # 管理员:跳过二级密码和客服扩展表检查 + is_admin = user.IsSuperuser or user.UserType == 'admin' + if is_admin: + target_user = user + target_kefu = None + break + + # 客服:验证二级密码 + kefu = user.KefuProfile + stored_erji = kefu.erjimima or '' + if stored_erji.startswith(('$2b$', '$2a$')) and len(stored_erji) == 60: + import bcrypt as _bcrypt + if not _bcrypt.checkpw(erjimima.encode('utf-8'), stored_erji.encode('utf-8')): + continue + elif stored_erji != erjimima: + continue + + # 如果账号被禁用,记录但继续查找 + if kefu.zhuangtai != 1: + if target_user is None: + target_user = user + target_kefu = kefu + continue + + # 找到完全匹配且状态正常的账号,立即使用 + target_user = user + target_kefu = kefu + break + else: + # 循环结束未找到正常账号 + if target_user and target_kefu: + # 只有被禁用的账号匹配,返回封禁提示 + logger.warning(f"登录失败,客服账号已禁用: {phone}, IP: {client_ip}") + return Response({ + 'code': 4, + 'msg': '账号已被封禁', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + elif password_matched: + # 密码正确但二级密码错误 + logger.warning(f"登录失败,二级密码错误: {phone}, IP: {client_ip}") + return Response({ + 'code': 1, + 'msg': '二级密码错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + else: + # 密码错误 + logger.warning(f"登录失败,密码错误: {phone}, IP: {client_ip}") + return Response({ + 'code': 1, + 'msg': '密码错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 5. 更新IP和最后登录时间 + target_user.IP = client_ip + target_user.UserLastLoginDate = timezone.now() + target_user.save(update_fields=['IP', 'UserLastLoginDate']) + + # 6. 生成JWT token + refresh = RefreshToken.for_user(target_user) + token = str(refresh.access_token) + + # 7. 返回成功数据(管理员无 KefuProfile,nicheng 用 UserName 或 Phone 兜底) + nicheng = target_kefu.nicheng if target_kefu else (target_user.UserName or target_user.Phone) + return Response({ + 'code': 0, + 'msg': '登录成功', + 'data': { + 'token': token, + 'nicheng': nicheng, + 'yonghuid': target_user.UserUID, + } + }) + + def get_client_ip(self, request): + """ + 获取客户端真实IP + """ + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0].strip() + if ip: + return ip + return request.META.get('REMOTE_ADDR', '0.0.0.0') + + +class KefuStatsView(APIView): + """ + 客服统计数据接口 + 请求:POST /yonghu/kfjrhqzl + 请求体:{"phone": "13800138000"} + 认证:必须携带有效的 JWT token(放在 Authorization 头) + 返回: + code=0 成功,data 包含 jinrichuli, jinyuechuli, zongchuli + 验证失败统一返回 401,不透露具体原因 + """ + + permission_classes = [IsAuthenticated] # 必须登录 + + + def post(self, request): + # 1. 获取请求中的 phone + phone = request.data.get('phone', '').strip() + if not phone: + # 缺少参数,但按约定返回 401(可以改为 400,但为了统一,用401) + logger.warning(f"统计接口缺少 phone 参数,用户: {request.user.UserUID}") + return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 获取当前登录用户(从 JWT 中解析) + current_user = request.user + + # 3. 验证前端传来的 phone 是否与当前用户的 phone 一致 + if current_user.Phone != phone: + # 不一致,可能 token 被冒用或参数错误 + logger.warning(f"统计接口 phone 不匹配: 请求phone={phone}, 用户phone={current_user.Phone}") + return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 4. 验证是否为后台客服账号 + from jituan.services.admin_context import is_kefu_backend_account, is_system_super_admin + if not is_kefu_backend_account(current_user): + logger.warning(f"统计接口用户类型非客服: {current_user.UserUID}") + return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 5. 尝试获取客服扩展表数据(管理员跳过) + is_admin = is_system_super_admin(current_user) + if is_admin: + return Response({ + 'code': 0, + 'data': { + 'jinrichuli': 0, + 'jinyuechuli': 0, + 'zongchuli': 0, + } + }, status=status.HTTP_200_OK) + + try: + kefu_profile = current_user.KefuProfile # OneToOneField 反向关系 + except UserKefu.DoesNotExist: + logger.warning(f"统计接口客服扩展表不存在: {current_user.UserUID}") + return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 6. 返回统计数据(字段名与前端约定一致) + return Response({ + 'code': 0, + 'data': { + 'jinrichuli': kefu_profile.jinrichuli, + 'jinyuechuli': kefu_profile.jinyuechuli, + 'zongchuli': kefu_profile.zongchuli, + } + }, status=status.HTTP_200_OK) + + +class KefuGetOrderTypesView(APIView): + """ + 客服获取订单类型列表接口(平台订单用) + 请求:POST /yonghu/kfhqptddlx + 参数:{"phone": "13800138000"} + 认证:JWT + 返回:code=0 + 类型列表 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + if not phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + current_user = request.user + + # 2. 验证手机号一致性 + if getattr(current_user, 'Phone', '') != phone: + logger.warning(f"手机号不匹配: 请求phone={phone}, 用户phone={current_user.Phone}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 验证用户类型及客服状态 + if current_user.UserType not in ('kefu', 'admin'): + logger.warning(f"用户类型非客服: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 管理员跳过客服扩展表检查 + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + if not is_admin: + try: + kefu_profile = current_user.KefuProfile + except ObjectDoesNotExist: + logger.warning(f"客服扩展表不存在: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu_profile.zhuangtai != 1: + logger.warning(f"客服账号已被禁用: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 4. 查询订单类型(商品类型) + try: + # 只查询需要的字段,减少数据传输 + types_qs = ShangpinLeixing.query.all().only('id', 'jieshao', 'tupian_url') + type_list = [ + { + 'id': t.id, + 'biaoti': t.jieshao or '', # 假设商品类型的标题字段为 jieshao + 'tupian_url': t.tupian_url or '' + } + for t in types_qs + ] + return Response({'code': 0, 'data': type_list}) + except Exception as e: + logger.error(f"查询商品类型表失败: {str(e)}") + # 表不存在或查询失败,返回空列表 + return Response({'code': 0, 'data': []}) diff --git a/users/views/kefu_dashou.py b/users/views/kefu_dashou.py new file mode 100644 index 0000000..a7c24cd --- /dev/null +++ b/users/views/kefu_dashou.py @@ -0,0 +1,636 @@ +"""users.views.kefu_dashou - auto-generated by split script.""" +"""users.views.kefu - auto-generated by split script.""" +import os +import sys +import json +import re +import time +import uuid +import random +import string +import requests +import hashlib +import traceback +import decimal +import jwt +import ipaddress +import xmltodict +from datetime import datetime, timedelta +from decimal import Decimal +import defusedxml.ElementTree as ET + +from django.conf import settings +from django.db import models, transaction, IntegrityError, DatabaseError, connection +from django.db.models import Q, F, Sum, Count, Case, When, IntegerField, Prefetch +from django.core.cache import cache +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.http import HttpResponse +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.hashers import make_password + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import permissions, status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.authentication import JWTAuthentication + +from gvsdsdk.fluent import db, func, FQ + +from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss +from utils.money import yuan_to_fen +from utils.fadan_utils import check_fadan_qiangdan_eligible +from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode +from users.fadan_fenhong_utils import process_fadan_fenhong + +from orders.utils import ( + update_daily_payout, + settle_shangjia_order_guanshi_fenhong +) +from backend.utils import ( + update_dashou_daily_by_action, update_guanshi_daily_by_action, + update_shangjia_daily, update_zuzhang_daily_by_action, + verify_kefu_permission +) +from rank.utils import get_tag_fee, create_shenhe_jilu, validate_shenheguan + +from ..models import ( + UserBoss, UserDashou, UserShangjia, UserGuanshi, + UserZuzhang, UserKefu, AdminProfile, OfficialAccountUser, + TixianAutoRecord, TixianShenheJilu, Tixianjilu, Xiugaijilu, + RankingRecord +) +from users.business_models import User +from ..tixian_shenhe_services import ( + load_audit_meta_map, refund_balance, sync_audit_and_jilu_status, + mark_transfer_success, release_collect_quota_for_record, + close_audit_wechat_failed, query_wechat_transfer_bill, + check_shijidaozhang_within_limit, + check_dashou_trial_blocks_commission_withdraw, + WX_STATE_FAIL, WX_STATE_SUCCESS, WX_STATE_WAIT, WX_STATE_CANCELING, +) +from ..tixian_shenhe_views import process_audit_collect + +from orders.models import ( + Order, PlatformOrderExt, MerchantOrderExt, CommissionRate, + PenaltyRecord, PenaltyEvidenceImage, RefundRecord, PlayerDeliveryImage, + Penalty, PenaltyAppealImage +) +from products.models import ( + ShangpinLeixing, Huiyuan, Huiyuangoumai, Gsfenhong, Czjilu +) +from config.models import Qunpeizhi +from backend.models import MerchantDailyStats +from rank.models import ( + KaohePayTemp, Chenghao, ShenheJilu, + KaoheCishuFeiyong, YonghuChenghao +) +from users.models import UserShenheguan +from products.utils import update_shangpin_daily_stat +from shop.utils import update_dianpu_daily_stat +from rank.utils import create_shenhe_jilu_from_temp + +import logging + +logger = logging.getLogger(__name__) + + +class KefuGetDashouListView(APIView): + """ + 客服获取打手列表接口(仅打手类型) + 请求:POST /yonghu/kefuhqdslb + 参数:{ + "phone": "客服手机号", + "keyword": "搜索关键词(可选,搜索用户ID或昵称)", + "page": "页码(从1开始)", + "page_size": "每页数量" + } + 认证:JWT + 返回:{ + "code": 0, + "data": { + "list": [打手对象], + "has_more": true/false + } + } + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('zhanghao', '').strip() + keyword = request.data.get('keyword', '').strip() + try: + page = int(request.data.get('page', 1)) + page_size = int(request.data.get('page_size', 20)) + except ValueError: + return Response({'code': 400, 'msg': '分页参数错误'}, status=status.HTTP_400_BAD_REQUEST) + + if not phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + logger.warning(f"手机号不匹配: {phone}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + logger.warning(f"用户类型非客服: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + logger.warning(f"客服扩展表不存在: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + logger.warning(f"客服账号已禁用: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 构建查询条件(只查询打手) + # 通过 UserDashou 扩展表关联 User + queryset = UserDashou.query.select_related('user').all() + + if keyword: + # 搜索用户ID或打手昵称 + queryset = queryset.filter( + Q(user__UserUID__icontains=keyword) | + Q(nicheng__icontains=keyword) + ) + + # 4. 计算总数并分页 + total = queryset.count() + offset = (page - 1) * page_size + dashou_list = queryset.order_by('-CreateTime')[offset:offset + page_size] + + # 5. 构建返回数据(只取需要的字段) + result = [] + for dashou in dashou_list: + user = dashou.user + result.append({ + 'yonghuid': user.UserUID, + 'avatar': user.Avatar or '', + 'zaixianzhuangtai': dashou.zaixianzhuangtai, + 'zhanghaozhuangtai': dashou.zhanghaozhuangtai, + 'nicheng': dashou.nicheng or '', + 'zhuangtai': dashou.zhuangtai, + }) + + # 6. 判断是否有更多数据 + has_more = (offset + len(dashou_list)) < total + + return Response({ + 'code': 0, + 'data': { + 'list': result, + 'has_more': has_more + } + }) + + +class KefuGetDashouDetailView(APIView): + """ + 客服获取打手详情接口 + 请求:POST /yonghu/kefuhqdsxq + 参数:{ + "phone": "客服手机号", + "uid": "打手ID (yonghuid)" + } + 认证:JWT + 返回:code=0 + 打手信息 + 会员列表 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + uid = request.data.get('uid', '').strip() + + if not phone or not uid: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + logger.warning(f"手机号不匹配: {phone}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + logger.warning(f"用户类型非客服: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + logger.warning(f"客服扩展表不存在: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + logger.warning(f"客服账号已禁用: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 查询打手主表和扩展表 + try: + # 使用 select_related 预加载 dashou_profile,减少数据库查询 + user_main = User.query.select_related('DashouProfile').get( + UserUID=uid + ) + except User.DoesNotExist: + return Response({'code': 404, 'msg': '打手不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 验证用户类型是否为打手(可选,但建议验证) + # 如果该用户没有打手扩展表,则返回空信息(但前端会显示未注册) + try: + dashou = user_main.DashouProfile + except ObjectDoesNotExist: + # 用户存在但未注册打手身份,返回基本信息 + user_info = { + 'yonghuid': user_main.UserUID, + 'avatar': user_main.Avatar or '', + 'zaixianzhuangtai': 0, + 'zhanghaozhuangtai': 0, + 'zhuangtai': 0, + 'nicheng': '', + 'chenghao': '', + 'dianhua': '', + 'wechat': '', + 'phone': user_main.Phone or '', + 'CreateTime': user_main.UserCreateTime, + 'create_time': user_main.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user_main, 'UserCreateTime', None) else '', + 'ip': user_main.IP or '', + 'jiedanzongliang': 0, + 'chengjiaozongliang': 0, + 'tuikuanliang': 0, + 'jinrijiedan': 0, + 'yue': 0.00, + 'zonge': 0.00, + 'jifen': 0, + 'yajin': 0.00, + 'jinrishouyi': 0.00, + 'jinyueshouyi': 0.00, + 'yaoqingren': '', + 'jieshao': '', + } + member_list = [] + return Response({ + 'code': 0, + 'data': { + 'user_info': user_info, + 'member_list': member_list + } + }) + + # 4. 构建打手信息 + user_info = { + 'yonghuid': user_main.UserUID, + 'avatar': user_main.Avatar or '', + 'zaixianzhuangtai': dashou.zaixianzhuangtai, + 'zhanghaozhuangtai': dashou.zhanghaozhuangtai, + 'zhuangtai': dashou.zhuangtai, + 'nicheng': dashou.nicheng or '', + 'chenghao': dashou.chenghao or '', + 'dianhua': dashou.dianhua or '', + 'wechat': dashou.wechat or '', + 'phone': user_main.Phone or '', + 'CreateTime': user_main.UserCreateTime, + 'create_time': user_main.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user_main, 'UserCreateTime', None) else '', + 'ip': user_main.IP or '', + 'jiedanzongliang': dashou.jiedanzongliang, + 'chengjiaozongliang': dashou.chengjiaozongliang, + 'tuikuanliang': dashou.tuikuanliang, + 'jinrijiedan': dashou.jinrijiedan, + 'yue': float(dashou.yue) if dashou.yue else 0.00, + 'zonge': float(dashou.zonge) if dashou.zonge else 0.00, + 'jifen': dashou.jifen, + 'yajin': float(dashou.yajin) if dashou.yajin else 0.00, + 'jinrishouyi': float(dashou.jinrishouyi) if dashou.jinrishouyi else 0.00, + 'jinyueshouyi': float(dashou.jinyueshouyi) if dashou.jinyueshouyi else 0.00, + 'yaoqingren': dashou.yaoqingren or '', + 'jieshao': dashou.jieshao or '', + } + + # 5. 查询打手会员信息(仅未过期) + member_records = list(Huiyuangoumai.query.filter( + yonghu_id=uid + ).order_by('-CreateTime')) + huiyuan_ids = {r.huiyuan_id for r in member_records if r.huiyuan_id} + huiyuan_map = { + h.huiyuan_id: h.jieshao + for h in Huiyuan.query.filter(huiyuan_id__in=huiyuan_ids).only('huiyuan_id', 'jieshao') + } + + member_list = [] + for record in member_records: + # 调用模型方法检查是否过期 + if hasattr(record, 'jiance_shifou_daoqi'): + is_expired = record.jiance_shifou_daoqi() + else: + # 如果方法不存在,简单判断 + is_expired = record.daoqi_time < timezone.now() if record.daoqi_time else True + + if not is_expired: + # 获取会员详情 + huiyuan_name = huiyuan_map.get(record.huiyuan_id, '未知会员') + + member_list.append({ + 'id': record.id, + 'huiyuan_name': huiyuan_name, + 'daoqi_time': record.daoqi_time, + 'is_active': record.huiyuan_zhuangtai == 1, + }) + + return Response({ + 'code': 0, + 'data': { + 'user_info': user_info, + 'member_list': member_list + } + }) + + +class KefuUpdateDashouView(APIView): + permission_classes = [IsAuthenticated] + """ + 客服修改打手信息接口 + 支持操作: + - jian_yue / jia_yue : 减/加打手余额 (权限001aa/001bb) + - jian_yajin / jia_yajin : 减/加打手押金 (权限001aa/001bb) + - jian_jifen / jia_jifen : 减/加打手积分 (权限001cc/001dd) + - gai_zhanghaozhuangtai : 修改打手账号状态(封禁/解封) (权限001ee) + - gai_zaixianzhuangtai : 修改打手在线状态 (权限001ee) + - gai_zhuangtai : 修改打手接单状态(空闲/忙碌) (权限001ee) + - jia_huiyuan / jian_huiyuan : 加/减会员 (权限001ff/001gg) + """ + def post(self, request): + # 1. 获取前端参数 + username = request.data.get('username', '').strip() + dashou_id = request.data.get('dashou_id', '').strip() + caozuo = request.data.get('caozuo', '').strip() + value = request.data.get('value') # 金额、积分、天数等 + huiyuan_id = request.data.get('huiyuan_id', '').strip() # 会员ID(加/减会员时使用) + + if not username or not dashou_id or not caozuo: + return Response({'code': 400, 'msg': '参数不完整'}) + + # 2. 权限校验(统一方法) + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return permissions # 直接返回错误响应 + + # 3. 查询打手是否存在 + try: + dashou_user = User.query.select_related('DashouProfile').get(UserUID=dashou_id) + dashou_profile = dashou_user.DashouProfile + except User.DoesNotExist: + return Response({'code': 404, 'msg': '打手不存在'}) + except UserDashou.DoesNotExist: + return Response({'code': 404, 'msg': '打手扩展信息缺失'}) + + # 4. 根据操作类型执行(事务内) + with transaction.atomic(): + try: + # 记录修改前的值(用于日志) + before = { + 'yue': dashou_profile.yue, + 'yajin': dashou_profile.yajin, + 'jifen': dashou_profile.jifen, + 'zhanghaozhuangtai': dashou_profile.zhanghaozhuangtai, + 'zaixianzhuangtai': dashou_profile.zaixianzhuangtai, + 'zhuangtai': dashou_profile.zhuangtai, + } + + # ---------- 余额操作 ---------- + if caozuo == 'jian_yue': + if '001aa' not in permissions: + return Response({'code': 403, 'msg': '无权限减打手余额'}) + amount = Decimal(str(value)) + if dashou_profile.yue < amount: + return Response({'code': 400, 'msg': '余额不足'}) + dashou_profile.yue -= amount + dashou_profile.save() + after_yue = dashou_profile.yue + # 记录日志 + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, # 2=打手 + xiugaitijiao=after_yue, + xiugaitijiaoq=before['yue'], + ) + return Response({'code': 0, 'msg': '减余额成功', 'data': {'new_yue': str(after_yue)}}) + + elif caozuo == 'jia_yue': + if '001bb' not in permissions: + return Response({'code': 403, 'msg': '无权限加打手余额'}) + from jituan.services.group_finance_gate import deny_group_finance_exec + deny = deny_group_finance_exec(request.user, request) + if deny: + return deny + amount = Decimal(str(value)) + dashou_profile.yue += amount + dashou_profile.save() + after_yue = dashou_profile.yue + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + xiugaitijiao=after_yue, + xiugaitijiaoq=before['yue'], + ) + return Response({'code': 0, 'msg': '加余额成功', 'data': {'new_yue': str(after_yue)}}) + + # ---------- 押金操作 ---------- + elif caozuo == 'jian_yajin': + if '001aa' not in permissions: + return Response({'code': 403, 'msg': '无权限减打手押金'}) + amount = Decimal(str(value)) + if dashou_profile.yajin < amount: + return Response({'code': 400, 'msg': '押金不足'}) + dashou_profile.yajin -= amount + dashou_profile.save() + after_yajin = dashou_profile.yajin + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + yajin=after_yajin, + yyajin=before['yajin'], + ) + return Response({'code': 0, 'msg': '减押金成功', 'data': {'new_yajin': str(after_yajin)}}) + + elif caozuo == 'jia_yajin': + if '001bb' not in permissions: + return Response({'code': 403, 'msg': '无权限加打手押金'}) + from jituan.services.group_finance_gate import deny_group_finance_exec + deny = deny_group_finance_exec(request.user, request) + if deny: + return deny + amount = Decimal(str(value)) + dashou_profile.yajin += amount + dashou_profile.save() + after_yajin = dashou_profile.yajin + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + yajin=after_yajin, + yyajin=before['yajin'], + ) + return Response({'code': 0, 'msg': '加押金成功', 'data': {'new_yajin': str(after_yajin)}}) + + # ---------- 积分操作 ---------- + elif caozuo == 'jian_jifen': + if '001cc' not in permissions: + return Response({'code': 403, 'msg': '无权限减打手积分'}) + jifen = int(value) + if dashou_profile.jifen < jifen: + return Response({'code': 400, 'msg': '积分不足'}) + dashou_profile.jifen -= jifen + dashou_profile.save() + after_jifen = dashou_profile.jifen + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + jifen=after_jifen, + yjifen=before['jifen'], + ) + return Response({'code': 0, 'msg': '减积分成功', 'data': {'new_jifen': after_jifen}}) + + elif caozuo == 'jia_jifen': + if '001dd' not in permissions: + return Response({'code': 403, 'msg': '无权限加打手积分'}) + jifen = int(value) + dashou_profile.jifen += jifen + dashou_profile.save() + after_jifen = dashou_profile.jifen + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + jifen=after_jifen, + yjifen=before['jifen'], + ) + return Response({'code': 0, 'msg': '加积分成功', 'data': {'new_jifen': after_jifen}}) + + # ---------- 状态修改 ---------- + elif caozuo == 'gai_zhanghaozhuangtai': + if '001ee' not in permissions: + return Response({'code': 403, 'msg': '无权限修改打手账号状态'}) + new_status = int(value) + if new_status not in [0, 1]: + return Response({'code': 400, 'msg': '状态值无效(0=封禁,1=正常)'}) + dashou_profile.zhanghaozhuangtai = new_status + dashou_profile.save() + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + qitashuoming=f'修改账号状态为{new_status}', + ) + return Response({'code': 0, 'msg': '修改账号状态成功', 'data': {'new_zhanghaozhuangtai': new_status}}) + + elif caozuo == 'gai_zaixianzhuangtai': + if '001ee' not in permissions: + return Response({'code': 403, 'msg': '无权限修改打手在线状态'}) + new_status = int(value) + if new_status not in [0, 1]: + return Response({'code': 400, 'msg': '状态值无效(0=离线,1=在线)'}) + dashou_profile.zaixianzhuangtai = new_status + dashou_profile.save() + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + qitashuoming=f'修改在线状态为{new_status}', + ) + return Response({'code': 0, 'msg': '修改在线状态成功', 'data': {'new_zaixianzhuangtai': new_status}}) + + elif caozuo == 'gai_zhuangtai': + if '001ee' not in permissions: + return Response({'code': 403, 'msg': '无权限修改打手接单状态'}) + new_status = int(value) + if new_status not in [0, 1]: + return Response({'code': 400, 'msg': '状态值无效(0=忙碌,1=空闲)'}) + dashou_profile.zhuangtai = new_status + dashou_profile.save() + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + qitashuoming=f'修改接单状态为{new_status}', + ) + return Response({'code': 0, 'msg': '修改接单状态成功', 'data': {'new_zhuangtai': new_status}}) + + # ---------- 会员操作 ---------- + elif caozuo == 'jia_huiyuan': + if '001ff' not in permissions: + return Response({'code': 403, 'msg': '无权限给打手加会员'}) + if not huiyuan_id: + return Response({'code': 400, 'msg': '缺少会员ID'}) + try: + huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id) + except Huiyuan.DoesNotExist: + return Response({'code': 404, 'msg': '会员类型不存在'}) + from jituan.services.club_user import get_user_club_id + from jituan.services.member_recharge import extend_member_entitlement_admin, get_club_huiyuan_row + player_club = get_user_club_id(dashou_user) + row = get_club_huiyuan_row(player_club, huiyuan_id, require_enabled=False) + days = int(value) if value else int(row.formal_days or 30) if row else 30 + record, err = extend_member_entitlement_admin( + dashou_id, huiyuan_id, days, player_club, + ) + if err: + return Response({'code': 400, 'msg': err}) + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + huiyuants=days, + huiyuan_id=huiyuan_id, + qitashuoming=f'加正式会员 {huiyuan_id},增加{days}天(无支付单、无分红)', + ) + return Response({'code': 0, 'msg': '加会员成功', 'data': {'new_daoqi': record.daoqi_time.strftime('%Y-%m-%d %H:%M:%S')}}) + + elif caozuo == 'jian_huiyuan': + if '001gg' not in permissions: + return Response({'code': 403, 'msg': '无权限给打手减会员'}) + if not huiyuan_id: + return Response({'code': 400, 'msg': '缺少会员ID'}) + try: + record = Huiyuangoumai.query.get(yonghu_id=dashou_id, huiyuan_id=huiyuan_id) + except Huiyuangoumai.DoesNotExist: + return Response({'code': 404, 'msg': '该打手未购买此会员'}) + # 删除会员记录(或设置为过期,根据业务要求) + record.delete() + Xiugaijilu.query.create( + yonghuid=dashou_id, + xiugaiid=kefu.user.Phone, + leixing=2, + huiyuan_id=huiyuan_id, + qitashuoming=f'移除会员 {huiyuan_id}', + ) + return Response({'code': 0, 'msg': '减会员成功'}) + + else: + return Response({'code': 400, 'msg': '未知操作类型'}) + + except Exception as e: + logger.error(f"修改打手信息异常: {str(e)}", exc_info=True) + return Response({'code': 500, 'msg': '服务器内部错误'}) diff --git a/users/views/kefu_orders.py b/users/views/kefu_orders.py new file mode 100644 index 0000000..5116ee0 --- /dev/null +++ b/users/views/kefu_orders.py @@ -0,0 +1,1831 @@ +"""users.views.kefu_orders - auto-generated by split script.""" +"""users.views.kefu - auto-generated by split script.""" +import os +import sys +import json +import re +import time +import uuid +import random +import string +import requests +import hashlib +import traceback +import decimal +import jwt +import ipaddress +import xmltodict +from datetime import datetime, timedelta +from decimal import Decimal +import defusedxml.ElementTree as ET + +from django.conf import settings +from django.db import models, transaction, IntegrityError, DatabaseError, connection +from django.db.models import Q, F, Sum, Count, Case, When, IntegerField, Prefetch +from django.core.cache import cache +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.http import HttpResponse +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.hashers import make_password + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import permissions, status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.authentication import JWTAuthentication + +from gvsdsdk.fluent import db, func, FQ + +from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss +from utils.money import yuan_to_fen +from utils.fadan_utils import check_fadan_qiangdan_eligible +from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode +from users.fadan_fenhong_utils import process_fadan_fenhong + +from orders.utils import ( + update_daily_payout, + settle_shangjia_order_guanshi_fenhong +) +from backend.utils import ( + update_dashou_daily_by_action, update_guanshi_daily_by_action, + update_shangjia_daily, update_zuzhang_daily_by_action, + verify_kefu_permission +) +from rank.utils import get_tag_fee, create_shenhe_jilu, validate_shenheguan + +from ..models import ( + UserBoss, UserDashou, UserShangjia, UserGuanshi, + UserZuzhang, UserKefu, AdminProfile, OfficialAccountUser, + TixianAutoRecord, TixianShenheJilu, Tixianjilu, Xiugaijilu, + RankingRecord +) +from users.business_models import User +from ..tixian_shenhe_services import ( + load_audit_meta_map, refund_balance, sync_audit_and_jilu_status, + mark_transfer_success, release_collect_quota_for_record, + close_audit_wechat_failed, query_wechat_transfer_bill, + check_shijidaozhang_within_limit, + check_dashou_trial_blocks_commission_withdraw, + WX_STATE_FAIL, WX_STATE_SUCCESS, WX_STATE_WAIT, WX_STATE_CANCELING, +) +from ..tixian_shenhe_views import process_audit_collect + +from orders.models import ( + Order, PlatformOrderExt, MerchantOrderExt, CommissionRate, + PenaltyRecord, PenaltyEvidenceImage, RefundRecord, PlayerDeliveryImage, + Penalty, PenaltyAppealImage +) +from products.models import ( + ShangpinLeixing, Huiyuan, Huiyuangoumai, Gsfenhong, Czjilu +) +from config.models import Qunpeizhi +from backend.models import MerchantDailyStats +from rank.models import ( + KaohePayTemp, Chenghao, ShenheJilu, + KaoheCishuFeiyong, YonghuChenghao +) +from users.models import UserShenheguan +from products.utils import update_shangpin_daily_stat +from shop.utils import update_dianpu_daily_stat +from rank.utils import create_shenhe_jilu_from_temp + +import logging + +logger = logging.getLogger(__name__) + + +class KefuGetOrderListView(APIView): + """ + 客服获取平台订单列表接口(发单平台=1,非跨平台) + 需要权限:002ab(平台订单管理) + 请求参数: + username: 客服账号(用于权限校验) + page: 页码 + pageSize: 每页条数 + dingdan_id: 订单ID(精确搜索) + laoban_id: 老板ID(搜索平台扩展表中的老板) + dashou_id: 打手ID(搜索主表中的接单打手) + leixing: 商品类型ID + zhuangtai: 状态(多个用逗号分隔,如 "1,7") + date: 日期(YYYY-MM-DD),筛选订单创建时间 + nicheng: 游戏昵称(订单主表的 nicheng 字段) + jieshao: 订单介绍(订单主表的 jieshao 字段) + clkf: 处理客服(订单主表的 clkf 字段) + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + start_total = time.time() + + # 1. 获取前端参数 + username = request.data.get('phone', '').strip() + if not username: + return Response({'code': 400, 'msg': '缺少username'}) + + # 2. 公共权限校验 + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return permissions + + # 3. 检查平台订单管理权限(002ab) + if '002ab' not in permissions: + return Response({'code': 403, 'msg': '您无权查看平台订单列表'}) + + # 4. 获取分页和筛选参数 + page = int(request.data.get('page', 1)) + page_size = int(request.data.get('pageSize', 20)) + + dingdan_id = request.data.get('dingdan_id', '').strip() + laoban_id = request.data.get('laoban_id', '').strip() + dashou_id = request.data.get('dashou_id', '').strip() + leixing = request.data.get('leixing') + zhuangtai = request.data.get('zhuangtai', '') + date = request.data.get('date', '').strip() + # 新增筛选条件 + nicheng = request.data.get('nicheng', '').strip() + jieshao = request.data.get('jieshao', '').strip() + clkf = request.data.get('clkf', '').strip() + + # ---------- 查询条件 ---------- + # 基础条件:平台发单 + 兼容历史 Platform 为空 + from jituan.services.club_context import platform_order_base_q + base_q = platform_order_base_q() + + if dingdan_id: + q_conditions = base_q & Q(OrderID=dingdan_id) + else: + q_conditions = base_q + if leixing is not None and str(leixing).strip() != '': + try: + leixing_int = int(leixing) + q_conditions &= Q(ProductTypeID=leixing_int) + except (ValueError, TypeError): + pass + if zhuangtai and zhuangtai != 'all': + try: + status_list = [int(s.strip()) for s in zhuangtai.split(',') if s.strip()] + if status_list: + q_conditions &= Q(Status__in=status_list) + except (ValueError, TypeError): + pass + if dashou_id: + q_conditions &= Q(PlayerID=dashou_id) + if laoban_id: + q_conditions &= Q(pingtai_kuozhan__BossID=laoban_id) + if date: + q_conditions &= Q(CreateTime__date=date) + # 新增筛选条件 + if nicheng: + q_conditions &= Q(Nickname__icontains=nicheng) + if jieshao: + q_conditions &= Q(Description__icontains=jieshao) + if clkf: + q_conditions &= Q(AssignedCS__icontains=clkf) + + from jituan.services.club_context import ( + count_order_status_buckets, + order_scope_qs, + paginate_fluent_query, + ) + from orders.models import PlatformOrderExt + + # 按俱乐部过滤订单列表 + stats_qs = order_scope_qs(request).filter(q_conditions) + stats = stats_qs.aggregate( + total_orders=Count('id'), + completed_orders=Count('id', filter=Q(Status=3)), + refund_orders=Count('id', filter=Q(Status=5)), + pending_orders=Count('id', filter=Q(Status__in=[4, 8])) + ) + status_counts = count_order_status_buckets(q_conditions, request) + + list_qs = order_scope_qs(request).filter(q_conditions).order_by('-CreateTime') + total_count, orders_page = paginate_fluent_query(list_qs, page, page_size) + if total_count > 0 and not orders_page: + start = (max(1, page) - 1) * page_size + orders_page = list(list_qs._qs[start:start + page_size]) + + order_ids = [o.OrderID for o in orders_page if o.OrderID] + ext_map = {} + if order_ids: + for ext in PlatformOrderExt.query.filter(Order__OrderID__in=order_ids).select_related('Order'): + if ext.Order: + ext_map[ext.Order.OrderID] = ext + + laoban_ids = [ + ext.BossID for ext in ext_map.values() if ext.BossID + ] + nickname_map = {} + if laoban_ids: + users = User.query.filter(UserUID__in=laoban_ids).select_related('BossProfile').only( + 'UserUID', 'BossProfile__nickname' + ) + for user in users: + nickname_map[user.UserUID] = (user.BossProfile.nickname or '') if getattr(user, 'BossProfile', None) else '' + + result_list = [] + for order in orders_page: + ext = ext_map.get(order.OrderID) + laoban_id = ext.BossID if ext else '' + youxi_nicheng = nickname_map.get(laoban_id, order.Nickname or '') + result_list.append({ + 'dingdan_id': order.OrderID or '', + 'jieshao': order.Description or '', + 'zhuangtai': order.Status, + 'leixing': order.ProductTypeID, + 'fadanpingtai': order.Platform, + 'jiage': float(order.Amount) if order.Amount else 0.00, + 'tupian_url': order.ImageURL or '', + 'fadanshijian': order.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if order.CreateTime else '', + 'youxi_nicheng': youxi_nicheng + }) + + from jituan.services.club_user_access import list_response_meta + + response_data = { + 'stats': { + 'totalOrders': stats['total_orders'] or 0, + 'completedOrders': stats['completed_orders'] or 0, + 'refundOrders': stats['refund_orders'] or 0, + 'pendingOrders': stats['pending_orders'] or 0 + }, + 'statusCounts': status_counts, + 'list': result_list, + 'total': total_count, + 'page': page, + 'pageSize': page_size, + **list_response_meta(request), + } + + logger.info(f"平台订单接口总耗时: {time.time()-start_total:.3f}s") + return Response({'code': 0, 'data': response_data}) + + +class KefuGetShangjiaOrderListView(APIView): + """ + 客服获取商家订单列表接口(发单平台=2,非跨平台) + 需要权限:002ac(商家订单管理)—— 如不区分可使用 002ab + 请求参数: + username: 客服账号(用于权限校验) + page: 页码 + pageSize: 每页条数 + dingdan_id: 订单ID(精确搜索) + shangjia_id: 商家ID(搜索商家扩展表中的商家ID) + dashou_id: 打手ID(搜索主表中的接单打手) + leixing: 商品类型ID + zhuangtai: 状态(多个用逗号分隔,如 "1,7") + date: 日期(YYYY-MM-DD),筛选订单创建时间 + nicheng: 游戏昵称(订单主表的 nicheng 字段) + jieshao: 订单介绍(订单主表的 jieshao 字段) + clkf: 处理客服(订单主表的 clkf 字段) + shangjia_nicheng: 商家昵称(商家扩展表中的昵称) + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + start_total = time.time() + + # 1. 获取前端参数(兼容 phone / username) + username = request.data.get('username', '').strip() or request.data.get('phone', '').strip() + if not username: + return Response({'code': 400, 'msg': '缺少username'}) + + # 2. 公共权限校验 + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return permissions + + # 3. 检查商家订单管理权限(使用 002ac,如果没有可改用 002ab) + if '002ac' not in permissions: + return Response({'code': 403, 'msg': '您无权查看商家订单列表'}) + + # 4. 获取分页和筛选参数 + page = int(request.data.get('page', 1)) + page_size = int(request.data.get('pageSize', 20)) + + dingdan_id = request.data.get('dingdan_id', '').strip() + shangjia_id = request.data.get('shangjia_id', '').strip() + dashou_id = request.data.get('dashou_id', '').strip() + leixing = request.data.get('leixing') + zhuangtai = request.data.get('zhuangtai', '') + date = request.data.get('date', '').strip() + # 新增筛选条件 + nicheng = request.data.get('nicheng', '').strip() + jieshao = request.data.get('jieshao', '').strip() + clkf = request.data.get('clkf', '').strip() + shangjia_nicheng = request.data.get('shangjia_nicheng', '').strip() + + # ---------- 查询条件 ---------- + from jituan.services.club_context import merchant_order_base_q + base_q = merchant_order_base_q() + + if dingdan_id: + q_conditions = base_q & Q(OrderID=dingdan_id) + else: + q_conditions = base_q + if leixing is not None and str(leixing).strip() != '': + try: + leixing_int = int(leixing) + q_conditions &= Q(ProductTypeID=leixing_int) + except (ValueError, TypeError): + pass + if zhuangtai and zhuangtai != 'all': + try: + status_list = [int(s.strip()) for s in zhuangtai.split(',') if s.strip()] + if status_list: + q_conditions &= Q(Status__in=status_list) + except (ValueError, TypeError): + pass + if dashou_id: + q_conditions &= Q(PlayerID=dashou_id) + if shangjia_id: + q_conditions &= Q(shangjia_kuozhan__MerchantID=shangjia_id) + if date: + q_conditions &= Q(CreateTime__date=date) + if nicheng: + q_conditions &= Q(Nickname__icontains=nicheng) + if jieshao: + q_conditions &= Q(Description__icontains=jieshao) + if clkf: + q_conditions &= Q(AssignedCS__icontains=clkf) + if shangjia_nicheng: + # 通过商家扩展表关联 UserShangjia 的昵称 + q_conditions &= Q(shangjia_kuozhan__MerchantID__in=User.query.filter( + ShopProfile__nicheng__icontains=shangjia_nicheng + ).values_list('UserUID', flat=True)) + + from jituan.services.club_context import ( + count_order_status_buckets, + order_scope_qs, + paginate_fluent_query, + ) + from orders.models import MerchantOrderExt + + stats_qs = order_scope_qs(request).filter(q_conditions) + stats = stats_qs.aggregate( + total_orders=Count('id'), + completed_orders=Count('id', filter=Q(Status=3)), + refund_orders=Count('id', filter=Q(Status=5)), + pending_orders=Count('id', filter=Q(Status__in=[4, 8])) + ) + status_counts = count_order_status_buckets(q_conditions, request) + + list_qs = order_scope_qs(request).filter(q_conditions).order_by('-CreateTime') + total_count, orders_page = paginate_fluent_query(list_qs, page, page_size) + if total_count > 0 and not orders_page: + start = (max(1, page) - 1) * page_size + orders_page = list(list_qs._qs[start:start + page_size]) + + order_ids = [o.OrderID for o in orders_page if o.OrderID] + ext_map = {} + if order_ids: + for ext in MerchantOrderExt.query.filter(Order__OrderID__in=order_ids).select_related('Order'): + if ext.Order: + ext_map[ext.Order.OrderID] = ext + + shangjia_ids = [ + ext.MerchantID for ext in ext_map.values() if ext.MerchantID + ] + nickname_map = {} + if shangjia_ids: + users = User.query.filter(UserUID__in=shangjia_ids).select_related('ShopProfile').only( + 'UserUID', 'ShopProfile__nicheng' + ) + for user in users: + nickname_map[user.UserUID] = user.ShopProfile.nicheng or '' if hasattr(user, 'ShopProfile') else '' + + result_list = [] + for order in orders_page: + ext = ext_map.get(order.OrderID) + shangjia_id = ext.MerchantID if ext else '' + youxi_nicheng = order.Nickname or '' + result_list.append({ + 'dingdan_id': order.OrderID or '', + 'jieshao': order.Description or '', + 'zhuangtai': order.Status, + 'leixing': order.ProductTypeID, + 'fadanpingtai': order.Platform, + 'jiage': float(order.Amount) if order.Amount else 0.00, + 'tupian_url': order.ImageURL or '', + 'fadanshijian': order.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if order.CreateTime else '', + 'youxi_nicheng': youxi_nicheng + }) + + from jituan.services.club_user_access import list_response_meta + + response_data = { + 'stats': { + 'totalOrders': stats['total_orders'] or 0, + 'completedOrders': stats['completed_orders'] or 0, + 'refundOrders': stats['refund_orders'] or 0, + 'pendingOrders': stats['pending_orders'] or 0 + }, + 'statusCounts': status_counts, + 'list': result_list, + 'total': total_count, + 'page': page, + 'pageSize': page_size, + **list_response_meta(request), + } + + logger.info(f"商家订单接口总耗时: {time.time()-start_total:.3f}s") + return Response({'code': 0, 'data': response_data}) + + +class KefuChangeDashouView(APIView): + """ + 客服更换打手接口 + 请求:POST /yonghu/kefughds + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID", + "new_dashou_id": "新打手ID" + } + 认证:JWT + 返回:code=0 成功 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + new_dashou_id = request.data.get('new_dashou_id', '').strip() + + if not phone or not dingdan_id or not new_dashou_id: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + logger.warning(f"手机号不匹配: {phone}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + logger.warning(f"用户类型非客服: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + logger.warning(f"客服扩展表不存在: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + logger.warning(f"客服账号已禁用: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 查询订单 + try: + order = Order.query.get(OrderID=dingdan_id) + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 4. 验证订单状态是否允许更换打手(进行中2 或 结算中8) + if order.Status not in [2, 8]: + return Response({'code': 400, 'msg': '订单状态不允许更换打手'}, status=status.HTTP_400_BAD_REQUEST) + + # 5. 查询新打手 + try: + new_dashou_user = User.query.select_related('DashouProfile').get(UserUID=new_dashou_id) + except User.DoesNotExist: + return Response({'code': 404, 'msg': '打手不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 6. 验证打手扩展表 + try: + new_dashou_profile = new_dashou_user.DashouProfile + except ObjectDoesNotExist: + return Response({'code': 404, 'msg': '打手信息不完整'}, status=status.HTTP_404_NOT_FOUND) + + # 7. 验证打手状态(打手状态和账号状态必须为1) + if new_dashou_profile.zhuangtai != 1 or new_dashou_profile.zhanghaozhuangtai != 1: + return Response({'code': 400, 'msg': '打手状态不允许接单'}, status=status.HTTP_400_BAD_REQUEST) + # 8. 验证打手积分是否足够(至少5分) + if new_dashou_profile.jifen < 5: + return Response({'code': 400, 'msg': '打手积分不足,无法接单'}, status=status.HTTP_400_BAD_REQUEST) + + # 8. 根据订单的抢单要求类型进行额外验证 + yaoqiuleixing = order.GrabRequirement + + if yaoqiuleixing == 1: # 会员抢单 + huiyuan_id = order.MembershipID + if not huiyuan_id: + return Response({'code': 400, 'msg': '订单会员ID为空'}, status=status.HTTP_400_BAD_REQUEST) + + try: + # 查询打手的会员购买记录 + huiyuan_goumai = Huiyuangoumai.query.get( + huiyuan_id=huiyuan_id, + yonghu_id=new_dashou_id # 假设 yonghu_id 字段存储用户ID + ) + except Huiyuangoumai.DoesNotExist: + return Response({'code': 400, 'msg': '打手未购买该订单要求的会员'}, status=status.HTTP_400_BAD_REQUEST) + + # 检查会员是否过期(调用模型方法) + is_expired = huiyuan_goumai.jiance_shifou_daoqi() + if is_expired or huiyuan_goumai.huiyuan_zhuangtai != 1: + return Response({'code': 400, 'msg': '打手的会员已过期'}, status=status.HTTP_400_BAD_REQUEST) + + elif yaoqiuleixing == 2: # 押金抢单 + order_yajin = order.CommissionReq # 假设订单的 yongjin 字段存储押金要求 + if order_yajin is None: + return Response({'code': 400, 'msg': '订单押金为空'}, status=status.HTTP_400_BAD_REQUEST) + + if new_dashou_profile.yajin < order_yajin: + return Response({'code': 400, 'msg': '打手押金不足'}, status=status.HTTP_400_BAD_REQUEST) + else: + return Response({'code': 400, 'msg': '订单抢单要求类型错误'}, status=status.HTTP_400_BAD_REQUEST) + + # 9. 所有验证通过,开始更换打手(使用事务) + with transaction.atomic(): + # 9.1 更新新打手扩展表 + new_dashou_profile.zhuangtai = 0 # 设为接单中 + new_dashou_profile.jiedanzongliang += 1 + new_dashou_profile.jinrijiedan += 1 + new_dashou_profile.jinyuejiedan += 1 + new_dashou_profile.save() + + # 9.2 处理原打手(如果存在) + old_dashou_id = order.PlayerID + if old_dashou_id: + try: + old_dashou_user = User.query.get(UserUID=old_dashou_id) + old_dashou_profile = old_dashou_user.DashouProfile + old_dashou_profile.zhuangtai = 1 # 恢复空闲 + old_dashou_profile.tuikuanliang += 1 # 原打手退款总量+1(业务逻辑) + old_dashou_profile.save() + logger.info(f"原打手 {old_dashou_id} 状态已更新") + except (User.DoesNotExist, ObjectDoesNotExist): + logger.warning(f"原打手 {old_dashou_id} 不存在或信息不完整,跳过更新") + except Exception as e: + logger.error(f"更新原打手时异常: {str(e)}", exc_info=True) + + # 9.3 更新订单 + order.PlayerID = new_dashou_id + order.Status = 2 # 进行中 + order.AssignedCS = current_user.UserUID # 记录处理客服ID + order.save() + + return Response({'code': 0, 'msg': '更换打手成功'}) + + +class KefuRecoverOrderView(APIView): + """ + 客服恢复订单接口(仅状态9可恢复) + 请求:POST /yonghu/kefuhf + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID" + } + 认证:JWT + 返回:code=0 成功 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 查询订单 + try: + order = Order.query.get(OrderID=dingdan_id) + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 4. 验证订单状态是否为9(未付款) + if order.Status != 9: + return Response({'code': 400, 'msg': '订单状态不允许恢复'}, status=status.HTTP_400_BAD_REQUEST) + + # 5. 根据是否有指定ID决定新状态 + if order.AssignedID: + new_status = 7 # 指定中 + else: + new_status = 1 # 下单中 + + # 6. 更新订单 + order.Status = new_status + order.AssignedCS = current_user.UserUID + order.save() + + return Response({'code': 0, 'msg': '恢复成功', 'data': {'new_status': new_status}}) + + +class KefuPlatformRefundView(APIView): + """ + 客服平台订单退款接口(调用微信支付) + 请求:POST /yonghu/kefupttk + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID", + "tuikuan_liyou": "退款理由(可选)" + } + 认证:JWT + 返回:code=0 成功,code=其他 失败 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + tuikuan_liyou = request.data.get('tuikuan_liyou', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + logger.warning(f"手机号不匹配: {phone}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + logger.warning(f"用户类型非客服: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + logger.warning(f"客服扩展表不存在: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + logger.warning(f"客服账号已禁用: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 查询订单并加锁 + try: + with transaction.atomic(): + order = Order.query.select_related('pingtai_kuozhan').select_for_update().get(OrderID=dingdan_id) + + # 4. 验证平台订单 + if order.Platform != 1: + return Response({'code': 400, 'msg': '该订单不是平台订单'}, status=status.HTTP_400_BAD_REQUEST) + + # 5. 验证状态允许退款(根据模型,状态值:1下单中,2进行中,3已完成,4退款审核,5已退款,6退款失败,7指定中,8结算中) + allowed_statuses = [1, 2, 4, 7, 8] # 允许退款的已支付或处理中状态 + if order.Status not in allowed_statuses: + return Response({'code': 400, 'msg': '订单状态不允许退款'}, status=status.HTTP_400_BAD_REQUEST) + + # 6. 获取微信交易号(如果存在) + wechat_transaction_id = getattr(order, 'WechatTransactionID', None) + + # 7. 调用微信退款接口 + refund_result = self.call_wechat_refund( + dingdan_id=order.OrderID, + jine=order.Amount, + transaction_id=wechat_transaction_id + ) + + if refund_result['code'] != 0: + # 微信退款失败,直接返回错误,数据库不更新 + logger.error(f"微信退款失败,订单 {dingdan_id},原因:{refund_result['msg']}") + return Response({'code': 400, 'msg': refund_result['msg']}, status=status.HTTP_400_BAD_REQUEST) + + # 8. 更新订单状态为5(已退款) + order.Status = 5 + order.AssignedCS = current_user.Phone # 记录处理客服手机号 + if tuikuan_liyou: + order.RefundReason = tuikuan_liyou + order.save() + + # 9. 更新打手数据 + jiedan_dashou_id = order.PlayerID + if jiedan_dashou_id: + try: + dashou_user = User.query.get(UserUID=jiedan_dashou_id) + dashou_profile = dashou_user.DashouProfile + dashou_profile.tuikuanliang += 1 + dashou_profile.zhuangtai = 1 # 打手状态设为空闲 + dashou_profile.save() + logger.info(f"平台退款:打手 {jiedan_dashou_id} 退款订单+1") + except (User.DoesNotExist, ObjectDoesNotExist): + logger.warning(f"平台退款:打手 {jiedan_dashou_id} 不存在或扩展表缺失,跳过更新") + + # ========== 新增:商品和店铺每日统计 ========== + try: + # 商品每日统计(只要有商品ID就执行) + if order.ProductID: + # 从扩展表获取已计算好的收益值(下单时已存储) + pingtai_shouyi = Decimal('0.00') + dianpu_shouyi = Decimal('0.00') + if hasattr(order, 'pingtai_kuozhan'): + pingtai_shouyi = order.pingtai_kuozhan.PlatformIncome or Decimal('0.00') + dianpu_shouyi = order.pingtai_kuozhan.ShopIncome or Decimal('0.00') + + update_shangpin_daily_stat( + shangpin_id=order.ProductID, + caozuo_leixing=3, # 3 = 退款 + dingdan_jiage=order.Amount, + pingtai_shouyi=pingtai_shouyi, + dianpu_zongshouyi=dianpu_shouyi + ) + logger.info(f"商品统计更新完成,商品ID: {order.ProductID}") + + # 店铺每日统计(仅当存在店铺ID时执行) + dianpu_id = None + if hasattr(order, 'pingtai_kuozhan'): + dianpu_id = order.pingtai_kuozhan.ShopID + if dianpu_id: + dianpu_shouyi = order.pingtai_kuozhan.ShopIncome or Decimal('0.00') + update_dianpu_daily_stat( + dianpu_id=dianpu_id, + caozuo_leixing=3, # 3 = 退款 + dingdan_jiage=order.Amount, + dianpu_shouyi=dianpu_shouyi + ) + logger.info(f"店铺统计更新完成,店铺ID: {dianpu_id}") + except Exception as e: + logger.error(f"商品/店铺统计更新失败: {e}") + logger.error(traceback.format_exc()) + # 统计失败不影响主流程 + # ============================================= + + # 10. 更新退款记录表(假设存在 RefundRecord 模型,字段需根据实际调整) + + #更新支出记录 + update_daily_payout(order.Amount, getattr(order, 'ClubID', None)) + + + try: + refund_record, created = RefundRecord.query.get_or_create( + OrderID=dingdan_id, + defaults={ + 'PlayerID': jiedan_dashou_id or '', + 'ApplicantID': '', + 'ProcessorID': phone, + 'Reason': tuikuan_liyou or '客服退款', + 'ApplyStatus': 1, # 成功 + 'Amount': order.Amount, + 'PlayerCommission': order.PlayerCommission or 0, + 'Description': order.Description or '', + 'Remark': '客服平台退款', + 'Nickname': order.Nickname or '', + } + ) + if not created: + refund_record.ApplyStatus = 1 + refund_record.ProcessorID = phone + if tuikuan_liyou: + refund_record.Reason = tuikuan_liyou + refund_record.save() + except Exception as e: + logger.error(f"平台退款更新退款记录异常: {str(e)}", exc_info=True) + + # 11. 更新客服统计数据 + kefu.jinrichuli += 1 + kefu.jinyuechuli += 1 + kefu.zongchuli += 1 + kefu.save() + + # 🔥 新增:调用打手每日统计(成交) + try: + update_dashou_daily_by_action( + yonghuid=order.PlayerID, + amount=order.PlayerCommission, + action=3 # 3 表示退款(退款) + ) + logger.info(f"打手 {order.PlayerID} 每日统计更新成功") + except Exception as e: + logger.error(f"打手每日统计更新失败: {e}") + # 不影响主流程 + + logger.info(f"平台退款成功,订单 {dingdan_id},退款单号 {refund_result.get('refund_id')}") + return Response({'code': 0, 'msg': '退款成功'}) + + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"平台退款接口异常: {str(e)}", exc_info=True) + return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def call_wechat_refund(self, dingdan_id, jine, transaction_id=None): + """ + 调用微信支付退款接口 + :param dingdan_id: 商户订单号(我们的订单ID) + :param jine: 退款金额 + :param transaction_id: 微信支付订单号(优先使用) + :return: {'code': 0, 'msg': '成功', 'refund_id': 'xxx'} 或 {'code': 非0, 'msg': '错误信息'} + """ + # 获取配置 + appid = getattr(settings, 'WEIXIN_APPID', '') + mch_id = getattr(settings, 'WEIXIN_MCHID', '') + key = getattr(settings, 'WEIXIN_SHANGHUMIYAO', '') + cert_path = getattr(settings, 'WEIXIN_CERT_PATH', '') + key_path = getattr(settings, 'WEIXIN_KEY_PATH', '') + + if not all([appid, mch_id, key, cert_path, key_path]): + missing = [k for k, v in {'appid': appid, 'mch_id': mch_id, 'key': key, 'cert_path': cert_path, 'key_path': key_path}.items() if not v] + logger.error(f"微信支付配置缺失: {missing}") + return {'code': 500, 'msg': f'系统支付配置缺失: {missing}'} + + # 生成退款单号 + out_refund_no = self.generate_refund_no(dingdan_id) + total_fee = yuan_to_fen(jine) + refund_fee = total_fee + refund_desc = '客服退款' + + # 构造请求参数 + params = { + 'appid': appid, + 'mch_id': mch_id, + 'nonce_str': self.generate_nonce_str(), + 'out_refund_no': out_refund_no, + 'total_fee': total_fee, + 'refund_fee': refund_fee, + 'refund_desc': refund_desc, + } + if transaction_id: + params['transaction_id'] = transaction_id + else: + params['out_trade_no'] = dingdan_id # 使用订单ID作为商户订单号 + + # 生成签名 + sorted_keys = sorted(params.keys()) + stringA = '&'.join([f"{k}={params[k]}" for k in sorted_keys]) + stringSignTemp = f"{stringA}&key={key}" + sign = hashlib.md5(stringSignTemp.encode('utf-8')).hexdigest().upper() + params['sign'] = sign + + xml_data = self.dict_to_xml(params) + url = 'https://api.mch.weixin.qq.com/secapi/pay/refund' + + try: + response = requests.post( + url, + data=xml_data, + cert=(cert_path, key_path), + timeout=10 + ) + result = xmltodict.parse(response.content)['xml'] + logger.info(f"微信退款响应: {result}") + except Exception as e: + logger.error(f"微信退款请求异常: {str(e)}", exc_info=True) + return {'code': 500, 'msg': '微信退款请求异常'} + + if result.get('return_code') == 'SUCCESS' and result.get('result_code') == 'SUCCESS': + return {'code': 0, 'msg': 'success', 'refund_id': out_refund_no} + else: + err_msg = result.get('err_code_des', result.get('return_msg', '未知错误')) + logger.warning(f"微信退款失败: {err_msg}") + return {'code': 400, 'msg': err_msg} + + def generate_nonce_str(self): + return ''.join(random.choices(string.ascii_letters + string.digits, k=32)) + + def generate_refund_no(self, dingdan_id): + timestamp = str(int(time.time())) + rand = ''.join(random.choices('0123456789', k=4)) + return f"{dingdan_id}REF{timestamp}{rand}" + + def dict_to_xml(self, data): + xml = [''] + for k, v in data.items(): + xml.append(f'<{k}>{v}') + xml.append('') + return ''.join(xml) + + +class KefuRejectRefundView(APIView): + """ + 客服拒绝退款接口 + 请求:POST /yonghu/kefujjtk + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID", + "jujue_liyou": "拒绝理由(可选)" + } + 认证:JWT + 返回:code=0 成功 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + jujue_liyou = request.data.get('jujue_liyou', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 查询订单并加锁 + try: + with transaction.atomic(): + order = Order.objects.select_for_update().get(OrderID=dingdan_id) + + # 4. 验证订单状态为退款审核中(4) + if order.Status != 4: + return Response({'code': 400, 'msg': '订单状态不是退款审核中'}, status=status.HTTP_400_BAD_REQUEST) + + # 获取必要字段 + jiedan_dashou_id = order.PlayerID + dashou_fencheng = order.PlayerCommission + fadan_pingtai = order.Platform + + # 5. 验证打手分成金额是否存在 + if dashou_fencheng is None: + return Response({'code': 400, 'msg': '订单缺少打手分成金额'}, status=status.HTTP_400_BAD_REQUEST) + + # 6. 更新订单状态为退款失败(6) + order.Status = 6 + order.AssignedCS = current_user.Phone # 记录处理客服手机号 + if jujue_liyou: + order.RefundReason = jujue_liyou + order.save() + + # 7. 给打手结算(返还分成) + if jiedan_dashou_id: + try: + dashou_user = User.query.get(UserUID=jiedan_dashou_id) + dashou_profile = dashou_user.DashouProfile + # 增加打手相关统计数据 + dashou_profile.chengjiaozongliang += 1 + dashou_profile.yue += dashou_fencheng + dashou_profile.zonge += dashou_fencheng + dashou_profile.jinrishouyi += dashou_fencheng + dashou_profile.jinyueshouyi += dashou_fencheng + dashou_profile.save() + logger.info(f"拒绝退款:打手 {jiedan_dashou_id} 获得分成 {dashou_fencheng}") + except (User.DoesNotExist, AttributeError): + logger.warning(f"拒绝退款:打手 {jiedan_dashou_id} 不存在或扩展表缺失,跳过结算") + + # 8. 如果是商家发单,更新商家成交订单数量 + if fadan_pingtai == 2: + try: + # 获取商家订单扩展表 + shangjia_kuozhan = order.shangjia_kuozhan + shangjia_id = shangjia_kuozhan.MerchantID + if shangjia_id: + shangjia_user = User.query.get(UserUID=shangjia_id) + shangjia_profile = shangjia_user.ShopProfile + shangjia_profile.chengjiao += 1 + shangjia_profile.save() + logger.info(f"拒绝退款:商家 {shangjia_id} 成交订单+1") + + # ✅ 新方法(行为驱动,更安全) + update_shangjia_daily( + yonghuid=shangjia_id, + amount=Decimal(str(order.Amount)), + action=2, # 2 = 结算 + order_id=dingdan_id, + ) + except (ObjectDoesNotExist, User.DoesNotExist, AttributeError): + logger.warning(f"拒绝退款:商家信息不存在,跳过商家更新") + + # 9. 更新退款记录表 + try: + refund_record = RefundRecord.query.get(OrderID=dingdan_id) + refund_record.ApplyStatus = 2 # 2表示拒绝 + refund_record.ProcessorID = phone + refund_record.RejectReason = jujue_liyou # 记录驳回理由(假设模型有此字段) + refund_record.save() + logger.info(f"拒绝退款:更新退款记录表,订单 {dingdan_id}") + except RefundRecord.DoesNotExist: + # 如果退款记录不存在,可以创建一条记录(可选) + RefundRecord.query.create( + OrderID=dingdan_id, + PlayerID=jiedan_dashou_id or '', + ApplicantID='', + ProcessorID=phone, + Reason=jujue_liyou or '客服拒绝退款', + ApplyStatus=2, + Amount=order.Amount, + PlayerCommission=dashou_fencheng or 0, + Description=order.Description or '', + Remark='客服拒绝退款', + Nickname=order.Nickname or '', + RejectReason=jujue_liyou or '', + ) + except Exception as e: + logger.error(f"拒绝退款更新退款记录异常: {str(e)}", exc_info=True) + + # 🔥 新增:调用打手每日统计(成交) + try: + update_dashou_daily_by_action( + yonghuid=order.PlayerID, + amount=dashou_fencheng, + action=2 # 2 表示成交(结算) + ) + logger.info(f"打手 {order.PlayerID} 每日统计更新成功") + except Exception as e: + logger.error(f"打手每日统计更新失败: {e}") + # 不影响主流程 + + # 10. 更新客服统计数据 + kefu.jinrichuli += 1 + kefu.jinyuechuli += 1 + kefu.zongchuli += 1 + kefu.save() + + return Response({'code': 0, 'msg': '拒绝退款成功'}) + + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"拒绝退款接口异常: {str(e)}", exc_info=True) + return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class KefuMerchantRefundView(APIView): + """ + 客服商家订单退款接口(发单平台=2) + 请求:POST /yonghu/kefusjtk + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID", + "tuikuan_liyou": "退款理由(可选)" + } + 认证:JWT + 返回:code=0 成功 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + tuikuan_liyou = request.data.get('tuikuan_liyou', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 客服身份验证(同前,省略以节省篇幅,但实际必须完整) + current_user = request.user + # ... 验证 + if getattr(current_user, 'Phone', '') != phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + + # 查询订单,预加载商家扩展表 + try: + order = Order.query.select_related('shangjia_kuozhan').get(OrderID=dingdan_id) + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 验证商家订单 + if order.Platform != 2: + return Response({'code': 400, 'msg': '该订单不是商家订单'}, status=status.HTTP_400_BAD_REQUEST) + + # 验证状态允许退款 + allowed_statuses = [1, 7, 2, 8, 4] + if order.Status not in allowed_statuses: + return Response({'code': 400, 'msg': '订单状态不允许退款'}, status=status.HTTP_400_BAD_REQUEST) + + # 获取必要数据 + jiedan_dashou_id = order.PlayerID + jine = order.Amount + if jine is None: + return Response({'code': 400, 'msg': '订单金额缺失'}, status=status.HTTP_400_BAD_REQUEST) + + try: + shangjia_kuozhan = order.shangjia_kuozhan + shangjia_id = shangjia_kuozhan.MerchantID + except ObjectDoesNotExist: + return Response({'code': 400, 'msg': '订单商家信息不存在'}, status=status.HTTP_400_BAD_REQUEST) + + with transaction.atomic(): + # 更新订单 + order.Status = 5 + order.AssignedCS = current_user.Phone + if tuikuan_liyou: + order.RefundReason = tuikuan_liyou + order.save() + + # 更新打手 + if jiedan_dashou_id: + try: + dashou_user = User.query.get(UserUID=jiedan_dashou_id) + dashou_profile = dashou_user.DashouProfile + dashou_profile.tuikuanliang += 1 + dashou_profile.zhuangtai = 1 + dashou_profile.save() + except (User.DoesNotExist, ObjectDoesNotExist): + logger.warning(f"商家退款:打手 {jiedan_dashou_id} 不存在,跳过更新") + + # 更新商家(客服额度出资的订单不退商家余额,仅释放子客服额度) + try: + from merchant_ops.services.wallet import is_staff_quota_order + shangjia_user = User.query.get(UserUID=shangjia_id) + shangjia_profile = shangjia_user.ShopProfile + shangjia_profile.tuikuan += 1 + if not is_staff_quota_order(dingdan_id): + shangjia_profile.yue += jine + shangjia_profile.save() + except (User.DoesNotExist, ObjectDoesNotExist): + logger.warning(f"商家退款:商家 {shangjia_id} 不存在,跳过更新") + + # 更新退款记录表 + try: + refund_record = RefundRecord.query.get(OrderID=dingdan_id) + refund_record.ApplyStatus = 1 + refund_record.ProcessorID = phone + refund_record.save() + except RefundRecord.DoesNotExist: + pass + + # 更新客服统计 + update_shangjia_daily( + yonghuid=shangjia_id, + amount=Decimal(str(order.Amount)), + action=3, # 3 = 退款 + order_id=dingdan_id, + ) + + + # 调用打手每日统计(成交) + try: + update_dashou_daily_by_action( + yonghuid=order.PlayerID, + amount=order.PlayerCommission, + action=3 # 3 表示退款(退款) + ) + logger.info(f"打手 {order.PlayerID} 每日统计更新成功") + except Exception as e: + logger.error(f"打手每日统计更新失败: {e}") + # 不影响主流程 + + + + # 管理员无客服扩展表,跳过计数器更新 + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + if not is_admin: + kefu = current_user.KefuProfile + kefu.jinrichuli += 1 + kefu.jinyuechuli += 1 + kefu.zongchuli += 1 + kefu.save() + + return Response({'code': 0, 'msg': '退款成功'}) + + +class KefuRejectSettlementView(APIView): + """ + 客服拒绝结算接口(仅平台订单,状态2/8) + 请求:POST /yonghu/kefujjjs + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID", + "tkly": "拒绝结算理由(必填)" + } + 认证:JWT + 返回:code=0 成功,其他失败 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + tkly = request.data.get('tkly', '').strip() + + if not phone or not dingdan_id or not tkly: + return Response({'code': 401, 'msg': '参数不完整'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + logger.warning(f"手机号不匹配: {phone}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + logger.warning(f"用户类型非客服: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + logger.warning(f"客服扩展表不存在: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + logger.warning(f"客服账号已禁用: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 查询订单,预加载平台扩展表 + try: + order = Order.query.select_related('pingtai_kuozhan').get(OrderID=dingdan_id) + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 4. 校验订单条件:平台发单(1)且状态为2或8 + if order.Platform != 1 or order.Status not in [2, 8]: + return Response({'code': 400, 'msg': '仅平台发单且状态为进行中(2)或结算中(8)的订单可拒绝结算'}, status=status.HTTP_400_BAD_REQUEST) + + jiedan_dashou_id = order.PlayerID + if not jiedan_dashou_id: + return Response({'code': 400, 'msg': '订单暂无接单打手'}, status=status.HTTP_400_BAD_REQUEST) + + # 5. 使用事务更新 + with transaction.atomic(): + # 5.1 更新订单主表 + order.Status = 5 # 已退款 + order.RefundReason = tkly # 拒绝理由 + order.AssignedCS = phone # 记录客服手机号 + order.save() + + # 5.2 更新平台订单扩展表:标记为拒绝结算(假设字段 jjjs_biaoshi) + # 注意:需要确保 PlatformOrderExt 模型中已添加 jjjs_biaoshi 字段,IntegerField,默认0 + if hasattr(order, 'pingtai_kuozhan') and order.pingtai_kuozhan: + pingtai_ext = order.pingtai_kuozhan + pingtai_ext.RejectSettlementFlag = 1 # 1表示拒绝结算 + pingtai_ext.save() + else: + # 理论上应该存在,但若不存在则记录日志 + logger.warning(f"订单 {dingdan_id} 缺少平台扩展表,无法标记拒绝结算") + + # 5.3 更新打手扩展表 + try: + dashou_user = User.query.get(UserUID=jiedan_dashou_id) + dashou_profile = dashou_user.DashouProfile + dashou_profile.zhuangtai = 1 # 打手状态设为空闲 + dashou_profile.tuikuanliang += 1 # 退款订单总量+1 + dashou_profile.save() + logger.info(f"拒绝结算:打手 {jiedan_dashou_id} 退款订单+1,状态空闲") + except (User.DoesNotExist, ObjectDoesNotExist): + logger.warning(f"拒绝结算:打手 {jiedan_dashou_id} 不存在或扩展表缺失,跳过更新") + except Exception as e: + logger.error(f"拒绝结算更新打手异常: {str(e)}", exc_info=True) + + # 5.4 更新客服扩展表统计数据 + + + # 🔥 新增:调用打手每日统计(成交) + try: + update_dashou_daily_by_action( + yonghuid=order.PlayerID, + amount=order.PlayerCommission, + action=3 # 3 表示退款(退款) + ) + logger.info(f"打手 {order.PlayerID} 每日统计更新成功") + except Exception as e: + logger.error(f"打手每日统计更新失败: {e}") + # 不影响主流程 + + + + kefu.jinrichuli += 1 + kefu.jinyuechuli += 1 + kefu.zongchuli += 1 + kefu.save() + + return Response({'code': 0, 'msg': '拒绝结算成功'}) + + +class KefuTransferHallView(APIView): + """ + 客服恢复大厅(转移大厅)接口 + 请求:POST /yonghu/kefuzydt + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID" + } + 认证:JWT + 返回:code=0 成功 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '参数不完整'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证(同前,略,但必须完整实现) + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + if current_user.UserType not in ('kefu', 'admin'): + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + if kefu.zhuangtai != 1: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + except ObjectDoesNotExist: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 查询订单 + try: + order = Order.query.get(OrderID=dingdan_id) + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 4. 校验订单状态(允许状态:2,8,4) + if order.Status not in [2, 8, 4]: + return Response({'code': 400, 'msg': '订单状态不允许转移大厅'}, status=status.HTTP_400_BAD_REQUEST) + + + if order.PlayerID: + UserDashou.query.filter(user__UserUID=order.PlayerID).update(zhuangtai=1) + else: + pass + + # 5. 根据是否有指定ID决定新状态 + if order.AssignedID: + new_status = 7 # 指定中 + else: + new_status = 1 # 下单中 + + # 6. 使用事务更新 + with transaction.atomic(): + # 清空接单打手ID,更新状态,记录客服 + order.PlayerID = None + order.Status = new_status + order.AssignedCS = phone + order.save() + + return Response({'code': 0, 'msg': '转移大厅成功'}) + + +class KefuCancelDesignationView(APIView): + """ + 客服取消指定接口(仅状态7) + 请求:POST /yonghu/kefuqxzd + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID" + } + 认证:JWT + 返回:code=0 成功 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '参数不完整'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证(同前,略,但必须完整实现) + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + if current_user.UserType not in ('kefu', 'admin'): + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + if kefu.zhuangtai != 1: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + except ObjectDoesNotExist: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 查询订单 + try: + order = Order.query.get(OrderID=dingdan_id) + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 4. 校验订单状态为7(指定中) + + if order.Status != 7: + print(999) + return Response({'code': 400, 'msg': '仅状态为指定中(7)的订单可取消指定'}, status=status.HTTP_400_BAD_REQUEST) + + # 5. 使用事务更新 + with transaction.atomic(): + # 状态改为1(下单中),清空指定ID,记录客服 + order.Status = 1 + order.AssignedID = None + order.AssignedCS = phone + order.save() + + return Response({'code': 0, 'msg': '取消指定成功'}) + + +class KefuGetOrderDetailView(APIView): + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 获取当前用户 + current_user = request.user + + # 验证手机号 + if getattr(current_user, 'Phone', '') != phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 验证用户类型 + if current_user.UserType not in ('kefu', 'admin'): + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 客服扩展表 + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + if kefu.zhuangtai != 1: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + except Exception as e: + logger.exception(f"[kefuhqddxq] 获取客服扩展表异常 user={getattr(current_user, 'yonghuid', '?')}") + return Response({'code': 500, 'msg': '服务器错误'}, status=500) + + # 3. 查询订单 + try: + dingdan_obj = Order.query.select_related( + 'pingtai_kuozhan', 'shangjia_kuozhan' + ).get(OrderID=dingdan_id) + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=404) + except Exception as e: + logger.exception(f"[kefuhqddxq] 订单查询异常 dingdan_id={dingdan_id}") + return Response({'code': 500, 'msg': '服务器错误'}, status=500) + + from jituan.services.club_user_access import forbid_if_order_out_of_scope + deny = forbid_if_order_out_of_scope(request, dingdan_obj) + if deny: + return deny + + # 4. 构建基础数据 + try: + response_data = { + 'dingdan_id': dingdan_obj.OrderID or '', + 'zhuangtai': dingdan_obj.Status or 0, + 'fadanpingtai': dingdan_obj.Platform or 0, + 'jiage': float(dingdan_obj.Amount) if dingdan_obj.Amount else 0.00, + 'dashou_fencheng': float(dingdan_obj.PlayerCommission) if dingdan_obj.PlayerCommission else 0.00, + 'jiedan_dashou_id': dingdan_obj.PlayerID or '', + 'dashou_liuyan': dingdan_obj.PlayerRemark or '', + 'zhiding_id': dingdan_obj.AssignedID or '', + 'shangpin_id': dingdan_obj.ProductID or '', + 'leixing': dingdan_obj.ProductTypeID or 0, + 'tupian_url': dingdan_obj.ImageURL or '', + 'jieshao': dingdan_obj.Description or '', + 'beizhu': dingdan_obj.Remark or '', + 'tkly': dingdan_obj.RefundReason or '', + 'nicheng': dingdan_obj.Nickname or '', + 'clkf': dingdan_obj.AssignedCS or '', + 'chuangjianshijian': dingdan_obj.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if dingdan_obj.CreateTime else '', + 'genggaishijian': dingdan_obj.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if dingdan_obj.UpdateTime else '', + } + except Exception as e: + logger.exception(f"[kefuhqddxq] 构建基础数据异常 dingdan_id={dingdan_id}") + return Response({'code': 500, 'msg': '服务器错误'}, status=500) + + # 5. 扩展表数据 + try: + fadan_pingtai = dingdan_obj.Platform + if fadan_pingtai == 1: + if hasattr(dingdan_obj, 'pingtai_kuozhan') and dingdan_obj.pingtai_kuozhan: + pingtai = dingdan_obj.pingtai_kuozhan + response_data['pingtai_kuozhan'] = { + 'laoban_id': pingtai.BossID or '', + 'laoban_pingjia': pingtai.BossReview or '', + } + else: + response_data['pingtai_kuozhan'] = {'laoban_id': '', 'laoban_pingjia': ''} + elif fadan_pingtai == 2: + if hasattr(dingdan_obj, 'shangjia_kuozhan') and dingdan_obj.shangjia_kuozhan: + shangjia = dingdan_obj.shangjia_kuozhan + response_data['shangjia_kuozhan'] = { + 'shangjia_id': shangjia.MerchantID or '', + 'sjnicheng': shangjia.MerchantNickname or '', + 'shangjia_pingjia': shangjia.MerchantReview or '', + 'cfliyou': shangjia.PenaltyReason or '', + 'sqzhuangtai': shangjia.PenaltyApplyStatus if shangjia.PenaltyApplyStatus is not None else 0, + 'bhliyou': shangjia.RejectReason or '', + } + else: + response_data['shangjia_kuozhan'] = { + 'shangjia_id': '', 'sjnicheng': '', 'shangjia_pingjia': '', + 'cfliyou': '', 'sqzhuangtai': 0, 'bhliyou': '' + } + except Exception as e: + logger.exception(f"[kefuhqddxq] 扩展数据异常 dingdan_id={dingdan_id}") + return Response({'code': 500, 'msg': '服务器错误'}, status=500) + + # 6. 打手图片 + try: + dashou_images = PlayerDeliveryImage.query.filter(OrderID=dingdan_id).values_list('ImageURL', flat=True) + response_data['dashou_images'] = [img for img in dashou_images if img] + except Exception as e: + logger.exception(f"[kefuhqddxq] 打手图片查询异常 dingdan_id={dingdan_id}") + response_data['dashou_images'] = [] + + # 7. 退款理由 + try: + tuikuan_record = RefundRecord.query.filter(OrderID=dingdan_id).order_by('-CreateTime').first() + response_data['tuikuan_liyou'] = tuikuan_record.Reason if tuikuan_record and tuikuan_record.Reason else '' + except Exception as e: + logger.exception(f"[kefuhqddxq] 退款理由查询异常 dingdan_id={dingdan_id}") + response_data['tuikuan_liyou'] = '' + + # 8. 指定打手响应 + ZHIDING_HF_TEXT = {1: '想接', 2: '不想接', 3: '等会接'} + hf = getattr(dingdan_obj, 'AssignedResponse', None) + response_data['zhiding_hf'] = hf + response_data['zhiding_hf_text'] = ZHIDING_HF_TEXT.get(hf, '') if hf else '' + response_data['zhiding_hf_sj'] = ( + dingdan_obj.AssignedResponseAt.strftime('%Y-%m-%d %H:%M:%S') + if getattr(dingdan_obj, 'AssignedResponseAt', None) else '' + ) + response_data['zhiding_dhj_sm'] = getattr(dingdan_obj, 'AssignedLaterNote', '') or '' + + # 9. 关联罚款(打手) + from utils.penalty_status import PENALTY_STATUS_LABELS as PENALTY_STATUS + try: + from orders.models import Penalty + dashou_id = dingdan_obj.PlayerID or dingdan_obj.AssignedID or '' + fakuan_qs = Penalty.query.filter(RelatedOrderID=dingdan_id) + if dashou_id: + fakuan_qs = fakuan_qs.filter(PenalizedUserID=dashou_id) + fakuan_list = [] + for p in fakuan_qs.order_by('-CreateTime')[:5]: + fakuan_list.append({ + 'amount': str(p.FineAmount or '0.00'), + 'status': p.Status, + 'status_text': PENALTY_STATUS.get(p.Status, ''), + 'reason': p.Reason or '', + 'create_time': p.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if p.CreateTime else '', + }) + response_data['fakuan_list'] = fakuan_list + latest = fakuan_list[0] if fakuan_list else None + response_data['fakuan_info'] = latest + response_data['is_fadaned'] = Penalty.query.filter( + RelatedOrderID=dingdan_id, ApplicantID=getattr(current_user, 'UserUID', '') + ).exists() + except Exception as e: + logger.exception(f"[kefuhqddxq] 罚款查询异常 dingdan_id={dingdan_id}") + response_data['fakuan_list'] = [] + response_data['fakuan_info'] = None + response_data['is_fadaned'] = False + + return Response({'code': 0, 'data': response_data}) + + +class KefuForceCompleteView(APIView): + """ + 客服强制结单接口(仅本地订单) + 请求:POST /yonghu/kefuqiangzhijiedan + 参数:{"phone": "客服手机号", "dingdan_id": "订单ID"} + 认证:JWT,仅限状态正常的客服 + 逻辑: + 1. 验证客服身份及状态 + 2. 查询订单并加锁(事务内) + 3. 校验订单状态是否为8(结算中) + 4. 校验订单必须有接单打手且打手分成金额有效 + 5. 给打手增加余额、成交统计、今日/本月收益 + 6. 如果是商家发单,更新商家成交订单数量 + 7. 更新订单状态为3(已完成) + 8. 更新客服处理统计 + 9. 返回成功 + """ + permission_classes = [permissions.IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '参数缺失'}) + + # 1. 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + return Response({'code': 401, 'msg': '认证失败'}) + + if current_user.UserType not in ('kefu', 'admin'): + return Response({'code': 401, 'msg': '认证失败'}) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + return Response({'code': 401, 'msg': '认证失败'}) + + if kefu.zhuangtai != 1: + return Response({'code': 401, 'msg': '客服状态异常'}) + + # 2. 订单处理(事务内) + try: + with transaction.atomic(): + order = Order.objects.select_for_update().get(OrderID=dingdan_id) + + # 3. 校验订单状态 + if order.Status != 8: + return Response({'code': 400, 'msg': f'订单状态不是结算中,当前状态: {order.Status}'}) + + # 4. 校验必要字段 + dashou_id = order.PlayerID + dashou_fencheng = order.PlayerCommission + if not dashou_id: + return Response({'code': 400, 'msg': '订单无接单打手'}) + if not dashou_fencheng or dashou_fencheng <= 0: + return Response({'code': 400, 'msg': '打手分成金额无效'}) + + # 5. 给打手结算 + try: + dashou_user = User.query.get(UserUID=dashou_id) + dashou_profile = dashou_user.DashouProfile + # 原子更新(避免并发重复加) + dashou_profile.chengjiaozongliang = F('chengjiaozongliang') + 1 + dashou_profile.yue = F('yue') + dashou_fencheng + dashou_profile.zonge = F('zonge') + dashou_fencheng + dashou_profile.jinrishouyi = F('jinrishouyi') + dashou_fencheng + dashou_profile.jinyueshouyi = F('jinyueshouyi') + dashou_fencheng + dashou_profile.save(update_fields=[ + 'chengjiaozongliang', 'yue', 'zonge', + 'jinrishouyi', 'jinyueshouyi' + ]) + except (User.DoesNotExist, AttributeError): + return Response({'code': 400, 'msg': '打手不存在或扩展表缺失'}) + + # 6. 如果是商家发单,更新商家成交订单数量 + if order.Platform == 2: + try: + # 通过 MerchantOrderExt 获取商家ID + shangjia_ext = order.shangjia_kuozhan # 假设 related_name='shangjia_kuozhan' + if shangjia_ext and shangjia_ext.shangjia_id: + shangjia_user = User.query.get(UserUID=shangjia_ext.shangjia_id) + shangjia_profile = shangjia_user.ShopProfile + shangjia_profile.chengjiao = F('chengjiao') + 1 + shangjia_profile.save(update_fields=['chengjiao']) + + # (行为驱动,更安全) + update_shangjia_daily( + yonghuid=shangjia_ext.shangjia_id, + amount=Decimal(str(order.Amount)), + action=2, # 2 = 结算 + order_id=dingdan_id, + ) + + except ObjectDoesNotExist: + # 没有商家扩展记录则忽略 + pass + + # 7. 更新订单状态 + order.Status = 3 + order.AssignedCS = phone + order.save(update_fields=['Status', 'AssignedCS']) + + + if order.Platform == 1: + + # ========== 新增:商品和店铺每日统计 ========== + try: + # 商品每日统计(只要有商品ID就执行) + if order.ProductID: + # 从扩展表获取已计算好的收益值(下单时已存储) + pingtai_shouyi = Decimal('0.00') + dianpu_shouyi = Decimal('0.00') + if hasattr(order, 'pingtai_kuozhan'): + pingtai_shouyi = order.pingtai_kuozhan.PlatformIncome or Decimal('0.00') + dianpu_shouyi = order.pingtai_kuozhan.ShopIncome or Decimal('0.00') + + update_shangpin_daily_stat( + shangpin_id=order.ProductID, + caozuo_leixing=2, # 2 = 结单 + dingdan_jiage=order.Amount, + pingtai_shouyi=pingtai_shouyi, + dianpu_zongshouyi=dianpu_shouyi + ) + logger.info(f"商品统计更新完成,商品ID: {order.ProductID}") + + # 店铺每日统计(仅当存在店铺ID时执行) + dianpu_id = None + if hasattr(order, 'pingtai_kuozhan'): + dianpu_id = order.pingtai_kuozhan.ShopID + if dianpu_id: + dianpu_shouyi = order.pingtai_kuozhan.ShopIncome or Decimal('0.00') + update_dianpu_daily_stat( + dianpu_id=dianpu_id, + caozuo_leixing=2, # 2 = 结单 + dingdan_jiage=order.Amount, + dianpu_shouyi=dianpu_shouyi + ) + logger.info(f"店铺统计更新完成,店铺ID: {dianpu_id}") + except Exception as e: + logger.error(f"商品/店铺统计更新失败: {e}") + logger.error(traceback.format_exc()) + # 统计失败不影响主流程 + # ============================================= + + # 🔥 新增:调用打手每日统计(成交) + try: + update_dashou_daily_by_action( + yonghuid=order.PlayerID, + amount=dashou_fencheng, + action=2 # 2 表示成交(结算) + ) + logger.info(f"打手 {order.PlayerID} 每日统计更新成功") + except Exception as e: + logger.error(f"打手每日统计更新失败: {e}") + # 不影响主流程 + + # 商家订单管事分红 + if order.Platform == 2: + settle_shangjia_order_guanshi_fenhong(order, dashou_id) + + # 8. 更新客服统计 + kefu.jinrichuli = F('jinrichuli') + 1 + kefu.jinyuechuli = F('jinyuechuli') + 1 + kefu.zongchuli = F('zongchuli') + 1 + kefu.save(update_fields=['jinrichuli', 'jinyuechuli', 'zongchuli']) + + return Response({'code': 0, 'msg': '强制结单成功'}) + + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}) + except Exception as e: + logger.error(f"强制结单异常: {str(e)}", exc_info=True) + return Response({'code': 500, 'msg': '服务器内部错误'}) diff --git a/users/views/kefu_punishment.py b/users/views/kefu_punishment.py new file mode 100644 index 0000000..224ada8 --- /dev/null +++ b/users/views/kefu_punishment.py @@ -0,0 +1,564 @@ +"""users.views.kefu_punishment - auto-generated by split script.""" +"""users.views.kefu - auto-generated by split script.""" +import os +import sys +import json +import re +import time +import uuid +import random +import string +import requests +import hashlib +import traceback +import decimal +import jwt +import ipaddress +import xmltodict +from datetime import datetime, timedelta +from decimal import Decimal +import defusedxml.ElementTree as ET + +from django.conf import settings +from django.db import models, transaction, IntegrityError, DatabaseError, connection +from django.db.models import Q, F, Sum, Count, Case, When, IntegerField, Prefetch +from django.core.cache import cache +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.http import HttpResponse +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.hashers import make_password + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import permissions, status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.authentication import JWTAuthentication + +from gvsdsdk.fluent import db, func, FQ + +from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss +from utils.money import yuan_to_fen +from utils.fadan_utils import check_fadan_qiangdan_eligible +from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode +from users.fadan_fenhong_utils import process_fadan_fenhong + +from orders.utils import ( + update_daily_payout, + settle_shangjia_order_guanshi_fenhong +) +from backend.utils import ( + update_dashou_daily_by_action, update_guanshi_daily_by_action, + update_shangjia_daily, update_zuzhang_daily_by_action, + verify_kefu_permission +) +from rank.utils import get_tag_fee, create_shenhe_jilu, validate_shenheguan + +from ..models import ( + UserBoss, UserDashou, UserShangjia, UserGuanshi, + UserZuzhang, UserKefu, AdminProfile, OfficialAccountUser, + TixianAutoRecord, TixianShenheJilu, Tixianjilu, Xiugaijilu, + RankingRecord +) +from users.business_models import User +from ..tixian_shenhe_services import ( + load_audit_meta_map, refund_balance, sync_audit_and_jilu_status, + mark_transfer_success, release_collect_quota_for_record, + close_audit_wechat_failed, query_wechat_transfer_bill, + check_shijidaozhang_within_limit, + check_dashou_trial_blocks_commission_withdraw, + WX_STATE_FAIL, WX_STATE_SUCCESS, WX_STATE_WAIT, WX_STATE_CANCELING, +) +from ..tixian_shenhe_views import process_audit_collect + +from orders.models import ( + Order, PlatformOrderExt, MerchantOrderExt, CommissionRate, + PenaltyRecord, PenaltyEvidenceImage, RefundRecord, PlayerDeliveryImage, + Penalty, PenaltyAppealImage +) +from products.models import ( + ShangpinLeixing, Huiyuan, Huiyuangoumai, Gsfenhong, Czjilu +) +from config.models import Qunpeizhi +from backend.models import MerchantDailyStats +from rank.models import ( + KaohePayTemp, Chenghao, ShenheJilu, + KaoheCishuFeiyong, YonghuChenghao +) +from users.models import UserShenheguan +from products.utils import update_shangpin_daily_stat +from shop.utils import update_dianpu_daily_stat +from rank.utils import create_shenhe_jilu_from_temp + +import logging + +logger = logging.getLogger(__name__) + + +class KefuPunishmentListView(APIView): + """ + 客服获取处罚记录列表接口 + 请求:POST /yonghu/kefu_cfgl + 参数:{ + "phone": "客服手机号", + "status": [0,3] 或 [1,2], # 状态数组 + "dingdan_id": "订单ID(可选)", + "dashouid": "打手ID(可选)", + "page": 1, + "page_size": 20 + } + 认证:JWT + 返回:{ + "code": 0, + "data": { + "list": [处罚记录], + "total": 总数, + "stats": { + "zongshu": 总记录数, + "daichuli": 待处理数, + "yichuli": 已处理数 + } + } + } + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + @staticmethod + def _parse_status_list(raw): + """兼容 status=[0,3]、\"0,3\"、单数字;空/all 表示不过滤。""" + if raw is None: + return None + if isinstance(raw, str): + s = raw.strip().lower() + if not s or s in ('all', 'null', 'none'): + return None + if s.startswith('['): + import json + try: + raw = json.loads(s) + except (TypeError, ValueError): + return None + else: + raw = [x.strip() for x in s.split(',') if x.strip()] + if isinstance(raw, (int, float)): + return [int(raw)] + if isinstance(raw, list): + out = [] + for x in raw: + try: + out.append(int(x)) + except (TypeError, ValueError): + continue + return out or None + return None + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + status_list = self._parse_status_list(request.data.get('status')) + dingdan_id = request.data.get('dingdan_id', '').strip() + dashouid = request.data.get('dashouid', '').strip() + try: + page = int(request.data.get('page', 1)) + page_size = int(request.data.get('page_size', 20)) + except ValueError: + return Response({'code': 400, 'msg': '分页参数错误'}, status=status.HTTP_400_BAD_REQUEST) + + if not phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 客服身份验证 + current_user = request.user + # 1. 获取前端参数 + username = request.data.get('phone', '').strip() + if not username: + return Response({'code': 400, 'msg': '缺少username'}) + + # 2. 公共权限校验 + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return permissions + + # 3. 检查平台订单管理权限(002ab) + if '005aa' not in permissions: + return Response({'code': 403, 'msg': '您无权查看平台订单列表'}) + + # 3. 获取统计信息(不分页) + from jituan.services.club_penalty import filter_penalty_record_qs + from jituan.services.club_user_access import list_response_meta + + base_qs = filter_penalty_record_qs(PenaltyRecord.query.all(), request) + agg = base_qs.aggregate( + zongshu=Count('id'), + daichuli=Count('id', filter=Q(ApplyStatus__in=[0, 3])), + yichuli=Count('id', filter=Q(ApplyStatus__in=[1, 2])), + ) + stats = { + 'zongshu': agg['zongshu'], + 'daichuli': agg['daichuli'], + 'yichuli': agg['yichuli'], + } + + queryset = base_qs + + if status_list and isinstance(status_list, list): + queryset = queryset.filter(ApplyStatus__in=status_list) + + if dingdan_id: + queryset = queryset.filter(OrderID=dingdan_id) + + if dashouid: + queryset = queryset.filter(PlayerID=dashouid) + + # 5. 分页 + page_obj = queryset.order_by('-CreateTime').paginate(page=page, per_page=page_size) + total = page_obj.total + records = page_obj.items + + # 6. 构建返回列表 + list_data = [] + for item in records: + ct = item.CreateTime + ut = item.UpdateTime + list_data.append({ + 'dingdan_id': item.OrderID, + 'dashouid': item.PlayerID, + 'qingqiuid': item.ApplicantID, + 'cfliyou': item.PenaltyReason, + 'sqzhuangtai': item.ApplyStatus, + 'jifen': item.DeductedPoints, + 'CreateTime': ct, + 'UpdateTime': ut, + 'create_time': ct.isoformat() if ct else '', + 'update_time': ut.isoformat() if ut else '', + }) + + return Response({ + 'code': 0, + 'data': { + 'list': list_data, + 'total': total, + 'stats': stats, + **list_response_meta(request), + } + }) + + +class KefuPunishmentDetailView(APIView): + """ + 客服获取处罚详情接口 + 请求:POST /yonghu/kefu_cf_detail + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID" + } + 认证:JWT + 返回:code=0 + 处罚记录详情(含图片) + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + logger.warning(f"手机号不匹配: {phone}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + logger.warning(f"用户类型非客服: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + logger.warning(f"客服扩展表不存在: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + logger.warning(f"客服账号已禁用: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 查询处罚记录(按创建时间倒序取最新一条) + try: + record = PenaltyRecord.query.filter(OrderID=dingdan_id).order_by('-CreateTime').first() + if not record: + return Response({'code': 404, 'msg': '处罚记录不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"查询处罚记录失败: {e}") + return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # 查询商家证据图片 + zhengju_images = [] + shensu_images = [] + try: + # 商家证据图片:上传者为请求人 (ApplicantID) + zhengju_qs = PenaltyEvidenceImage.query.filter(OrderID=dingdan_id, UserID=record.ApplicantID) + zhengju_images = [item.ImageURL for item in zhengju_qs if item.ImageURL] + + # 打手申诉图片:上传者为打手 (PlayerID) + shensu_qs = PenaltyEvidenceImage.query.filter(OrderID=dingdan_id, UserID=record.PlayerID) + shensu_images = [item.ImageURL for item in shensu_qs if item.ImageURL] + except Exception as e: + logger.error(f"查询图片失败: {e}") + + # 构建返回数据 + data = { + 'dingdan_id': record.OrderID, + 'dashouid': record.PlayerID, + 'qingqiuid': record.ApplicantID, + 'chuliid': record.ProcessorID, + 'cfliyou': record.PenaltyReason, + 'sqzhuangtai': record.ApplyStatus, + 'bhliyou': record.RejectReason, + 'jifen': record.DeductedPoints, + 'ssliyou': record.AppealReason or '', + 'zhengju_tupian': zhengju_images, + 'shensu_tupian': shensu_images, + 'CreateTime': record.CreateTime, + 'UpdateTime': record.UpdateTime, + } + + return Response({'code': 0, 'data': data}) + + +class KefuPunishmentActionView(APIView): + """ + 客服同意/驳回处罚接口 + 请求:POST /yonghu/kefu_cf_action + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID", + "action": 1(同意) / 2(驳回), + "reject_reason": "驳回理由(驳回时必填)" + } + 认证:JWT + 返回:code=0 成功 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + action = request.data.get('action') + reject_reason = request.data.get('reject_reason', '').strip() + + if not phone or not dingdan_id or action not in [1, 2]: + return Response({'code': 401, 'msg': '参数错误'}, status=status.HTTP_400_BAD_REQUEST) + + if action == 2 and not reject_reason: + return Response({'code': 400, 'msg': '驳回理由不能为空'}, status=status.HTTP_400_BAD_REQUEST) + + # 客服身份验证(完整验证,此处略) + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + if current_user.UserType not in ('kefu', 'admin'): + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + if kefu.zhuangtai != 1: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + except ObjectDoesNotExist: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 查询待处理的处罚记录(状态0或3) + try: + record = PenaltyRecord.query.filter( + OrderID=dingdan_id, + ApplyStatus__in=[0, 3] + ).order_by('-CreateTime').first() + if not record: + exists = PenaltyRecord.query.filter(OrderID=dingdan_id).exists() + if exists: + return Response({'code': 400, 'msg': '该处罚记录已处理'}, status=status.HTTP_400_BAD_REQUEST) + else: + return Response({'code': 404, 'msg': '处罚记录不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"查询处罚记录失败: {e}") + return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + with transaction.atomic(): + # 处理重复记录:同一订单的其他待处理/申诉中记录标记为驳回 + duplicate_records = PenaltyRecord.query.filter( + OrderID=dingdan_id, + ApplyStatus__in=[0, 3] + ).exclude(id=record.id) + for dup in duplicate_records: + dup.ApplyStatus = 2 + dup.ProcessorID = phone + dup.RejectReason = "系统自动驳回:存在重复处罚记录" + dup.save() + logger.info(f"自动驳回重复处罚记录 ID={dup.id}") + + if action == 1: # 同意 + # 可选验证打手存在 + if record.PlayerID: + try: + dashou = UserDashou.query.get(user__UserUID=record.PlayerID) + except UserDashou.DoesNotExist: + return Response({'code': 400, 'msg': '被处罚打手不存在'}, status=status.HTTP_400_BAD_REQUEST) + + record.ApplyStatus = 1 + record.ProcessorID = phone + record.save() + + # 更新商家订单扩展表 + try: + dingdan = Order.query.select_related('shangjia_kuozhan').get(OrderID=dingdan_id) + if hasattr(dingdan, 'shangjia_kuozhan'): + dingdan.shangjia_kuozhan.PenaltyApplyStatus = 1 + dingdan.shangjia_kuozhan.save() + except Exception as e: + logger.warning(f"更新商家扩展表失败: {e}") + + elif action == 2: # 驳回 + # 返还积分 + if record.DeductedPoints > 0 and record.PlayerID: + try: + dashou = UserDashou.query.get(user__UserUID=record.PlayerID) + if dashou.jifen <= 1000: # 安全限制 + dashou.jifen += record.DeductedPoints + dashou.save() + logger.info(f"驳回处罚,打手{record.PlayerID}返还积分{record.DeductedPoints}") + else: + logger.warning(f"打手积分异常({dashou.jifen}),不再返还") + except UserDashou.DoesNotExist: + logger.warning(f"打手{record.PlayerID}不存在,无法返还积分") + + record.ApplyStatus = 2 + record.ProcessorID = phone + record.RejectReason = reject_reason + record.save() + + try: + dingdan = Order.query.select_related('shangjia_kuozhan').get(OrderID=dingdan_id) + if hasattr(dingdan, 'shangjia_kuozhan'): + dingdan.shangjia_kuozhan.PenaltyApplyStatus = 2 + dingdan.shangjia_kuozhan.RejectReason = reject_reason + dingdan.shangjia_kuozhan.save() + except Exception as e: + logger.warning(f"更新商家扩展表失败: {e}") + + return Response({'code': 0, 'msg': '处理成功'}) + + +class KefuPunishView(APIView): + """ + 客服处罚打手接口 + 请求:POST /yonghu/kefuchufa + 参数:{ + "phone": "客服手机号", + "dingdan_id": "订单ID", + "reason": "处罚原因(可选)" + } + 认证:JWT + 返回:code=0 成功 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + dingdan_id = request.data.get('dingdan_id', '').strip() + reason = request.data.get('reason', '').strip() + + if not phone or not dingdan_id: + return Response({'code': 401, 'msg': '参数不完整'}, status=status.HTTP_400_BAD_REQUEST) + + # 2. 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + logger.warning(f"手机号不匹配: {phone}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + logger.warning(f"用户类型非客服: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + logger.warning(f"客服扩展表不存在: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + logger.warning(f"客服账号已禁用: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 3. 查询订单 + try: + order = Order.query.get(OrderID=dingdan_id) + except Order.DoesNotExist: + return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 4. 获取打手ID + dashou_id = order.PlayerID + if not dashou_id: + return Response({'code': 400, 'msg': '订单未接单,无法处罚打手'}, status=status.HTTP_400_BAD_REQUEST) + + # 5. 查询打手 + try: + dashou_user = User.query.select_related('DashouProfile').get(UserUID=dashou_id) + dashou = dashou_user.DashouProfile + except User.DoesNotExist: + return Response({'code': 404, 'msg': '打手用户不存在'}, status=status.HTTP_404_NOT_FOUND) + except ObjectDoesNotExist: + return Response({'code': 400, 'msg': '该用户不是打手'}, status=status.HTTP_400_BAD_REQUEST) + + # 6. 使用事务 + from jituan.services.club_penalty import resolve_penalty_record_club_id + with transaction.atomic(): + # 创建处罚记录(积分扣除5分) + jifen = 5 + chufa = PenaltyRecord.query.create( + PlayerID=dashou_id, + ApplicantID=phone, # 客服手机号作为请求人 + PenaltyReason=reason or '', + ApplyStatus=0, # 待处理 + DeductedPoints=jifen, + OrderID=dingdan_id, + ClubID=resolve_penalty_record_club_id( + order_id=dingdan_id, + player_id=dashou_id, + request=request, + user=request.user, + ), + ) + + # 扣除打手积分 + if dashou.jifen >= jifen: + dashou.jifen -= jifen + else: + dashou.jifen = 0 + dashou.save() + + # 可选:更新订单的 clkf 字段记录处理客服 + order.AssignedCS = phone + order.save() + + return Response({'code': 0, 'msg': '处罚成功'}) diff --git a/users/views/kefu_withdraw.py b/users/views/kefu_withdraw.py new file mode 100644 index 0000000..6761ccf --- /dev/null +++ b/users/views/kefu_withdraw.py @@ -0,0 +1,786 @@ +"""users.views.kefu_withdraw - auto-generated by split script.""" +"""users.views.kefu - auto-generated by split script.""" +import os +import sys +import json +import re +import time +import uuid +import random +import string +import requests +import hashlib +import traceback +import decimal +import jwt +import ipaddress +import xmltodict +from datetime import datetime, timedelta +from decimal import Decimal +import defusedxml.ElementTree as ET + +from django.conf import settings +from django.db import models, transaction, IntegrityError, DatabaseError, connection +from django.db.models import Q, F, Sum, Count, Case, When, IntegerField, Prefetch +from django.core.cache import cache +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.http import HttpResponse +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.hashers import make_password + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import permissions, status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.authentication import JWTAuthentication + +from gvsdsdk.fluent import db, func, FQ + +from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss +from utils.money import yuan_to_fen +from utils.fadan_utils import check_fadan_qiangdan_eligible +from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode +from users.fadan_fenhong_utils import process_fadan_fenhong + +from orders.utils import ( + update_daily_payout, + settle_shangjia_order_guanshi_fenhong +) +from backend.utils import ( + update_dashou_daily_by_action, update_guanshi_daily_by_action, + update_shangjia_daily, update_zuzhang_daily_by_action, + verify_kefu_permission +) +from rank.utils import get_tag_fee, create_shenhe_jilu, validate_shenheguan + +from ..models import ( + UserBoss, UserDashou, UserShangjia, UserGuanshi, + UserZuzhang, UserKefu, AdminProfile, OfficialAccountUser, + TixianAutoRecord, TixianShenheJilu, Tixianjilu, Xiugaijilu, + RankingRecord +) +from users.business_models import User +from ..tixian_shenhe_services import ( + load_audit_meta_map, refund_balance, sync_audit_and_jilu_status, + mark_transfer_success, release_collect_quota_for_record, + close_audit_wechat_failed, query_wechat_transfer_bill, + check_shijidaozhang_within_limit, + check_dashou_trial_blocks_commission_withdraw, + WX_STATE_FAIL, WX_STATE_SUCCESS, WX_STATE_WAIT, WX_STATE_CANCELING, +) +from ..tixian_shenhe_views import process_audit_collect + +from orders.models import ( + Order, PlatformOrderExt, MerchantOrderExt, CommissionRate, + PenaltyRecord, PenaltyEvidenceImage, RefundRecord, PlayerDeliveryImage, + Penalty, PenaltyAppealImage +) +from products.models import ( + ShangpinLeixing, Huiyuan, Huiyuangoumai, Gsfenhong, Czjilu +) +from config.models import Qunpeizhi +from backend.models import MerchantDailyStats +from rank.models import ( + KaohePayTemp, Chenghao, ShenheJilu, + KaoheCishuFeiyong, YonghuChenghao +) +from users.models import UserShenheguan +from products.utils import update_shangpin_daily_stat +from shop.utils import update_dianpu_daily_stat +from rank.utils import create_shenhe_jilu_from_temp + +import logging + +logger = logging.getLogger(__name__) + + +kefu_txsh_list_logger = logging.getLogger('yonghu.kefu_txsh_list') + + +_TIXIAN_LIST_BASE_FIELDS = ( + 'id', 'yonghuid', 'avatar', 'phone', 'nicheng', 'leixing', + 'zhifu', 'skzhanghao', 'jine', 'zhuangtai', 'fangshi', + 'shenheid', 'bhliyou', 'CreateTime', 'UpdateTime', +) + + +_TIXIAN_LIST_AUDIT_FIELDS = ('shenhe_jilu_id', 'shenhe_danhao') + + +class KefuWithdrawListView(APIView): + """ + 客服获取提现审核列表接口 + 请求:POST /yonghu/kefu_txsh_list + 参数:{ + "phone": "客服手机号", + "page": 1, + "page_size": 20, + "status": 1(待审核)/2(已处理), + "type": 1(打手佣金)/2(管事分红)/3(组长分红)/4(审核官分佣)/5(打手押金)/6(商家余额), 可选 + "payment": 1(微信)/2(支付宝) 可选, + "search_uid": "用户ID搜索" 可选, + "date": "YYYY-MM-DD" 可选, + "min_amount": 0, # 最小提现金额(元)可选 + "max_amount": 999999, # 最大提现金额(元)可选 + "dakuan_mode": 1 # 打款方式:1手动打款 2自动打款,可选 + } + 认证:JWT + 返回:{ + "code": 0, + "data": { + "list": [提现记录], + "total": 总数, + "stats": { + "total": 总记录数, + "awaiting": 待审核数, + "processed": 已处理数, + "total_amount": "总金额(元)" + } + } + } + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def _safe_request_params(self, request): + try: + return dict(request.data) + except Exception: + return {'raw': str(request.data)} + + def _shenhe_jilu_field_available(self): + """探测 shenhe_jilu_id 字段是否已在数据库中就绪""" + try: + Tixianjilu.query.values_list('shenhe_jilu_id', flat=True).first() + return True + except DatabaseError as e: + kefu_txsh_list_logger.error( + 'kefu_txsh_list: shenhe_jilu_id 字段不可用,将按旧表结构查询: %s', e, + exc_info=True, + ) + return False + + def _list_queryset(self, q, shenhe_field_ok, request): + """字段未迁移时 only 旧字段,避免 SELECT 不存在的列导致 500""" + fields = _TIXIAN_LIST_BASE_FIELDS + ( + _TIXIAN_LIST_AUDIT_FIELDS if shenhe_field_ok else () + ) + from jituan.services.tixian_club import filter_tixianjilu_by_request + qs = Tixianjilu.query.filter(q).only(*fields) + return filter_tixianjilu_by_request(qs, request) + + def _resolve_row_dakuan_mode(self, item, audit_mode_map, shenhe_field_ok): + """无关联审核或审核表未标记自动 → 默认手动(1)""" + if not shenhe_field_ok: + return 1 + sid = getattr(item, 'shenhe_jilu_id', None) + if not sid: + return 1 + if audit_mode_map.get(sid) == 2: + return 2 + return 1 + + def post(self, request): + from jituan.services.group_finance_gate import require_withdraw_audit_access + + dakuan_mode_filter = request.data.get('dakuan_mode') + req_params = self._safe_request_params(request) + kefu_txsh_list_logger.info('kefu_txsh_list 请求开始 params=%s', req_params) + + try: + try: + page = int(request.data.get('page', 1)) + page_size = int(request.data.get('page_size', 20)) + except (TypeError, ValueError): + return Response({'code': 400, 'msg': '分页参数错误'}, status=status.HTTP_400_BAD_REQUEST) + + if page < 1: + page = 1 + if page_size < 1 or page_size > 100: + page_size = min(max(page_size, 1), 100) + + status_filter = request.data.get('status') + type_filter = request.data.get('type') + payment_filter = request.data.get('payment') + search_uid = (request.data.get('search_uid') or '').strip() + date_filter = (request.data.get('date') or '').strip() + min_amount = request.data.get('min_amount') + max_amount = request.data.get('max_amount') + + phone = (request.data.get('phone') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服手机号'}, status=status.HTTP_401_UNAUTHORIZED) + + kefu, err_resp = require_withdraw_audit_access(request, phone) + if err_resp: + return err_resp + + q = Q() + + try: + status_val = int(status_filter) if status_filter is not None and str(status_filter).strip() != '' else None + except (TypeError, ValueError): + status_val = None + + try: + dakuan_mode_val = int(dakuan_mode_filter) if dakuan_mode_filter is not None and str(dakuan_mode_filter).strip() != '' else None + except (TypeError, ValueError): + dakuan_mode_val = None + + # 未传打款方式参数 → 默认手动打款(1) + effective_dakuan = dakuan_mode_val if dakuan_mode_val in (1, 2) else 1 + + if status_val == 1: + q &= Q(zhuangtai=1) + elif status_val == 2: + if effective_dakuan == 2: + q &= Q(zhuangtai__in=[2, 3, 4, 5, 6]) + else: + q &= Q(zhuangtai__in=[2, 3]) + + if type_filter is not None and str(type_filter).strip() != '': + try: + type_val = int(type_filter) + if type_val in [1, 2, 3, 4, 5, 6]: + q &= Q(leixing=type_val) + except (TypeError, ValueError): + pass + + if payment_filter is not None and str(payment_filter).strip() != '': + try: + payment_val = int(payment_filter) + if payment_val in [1, 2]: + q &= Q(fangshi=payment_val) + except (TypeError, ValueError): + pass + + if search_uid: + q &= Q(yonghuid=search_uid) + + if date_filter: + q &= Q(UpdateTime__date=date_filter) + + if min_amount is not None and str(min_amount).strip() != '': + try: + q &= Q(jine__gte=float(min_amount)) + except (TypeError, ValueError): + pass + if max_amount is not None and str(max_amount).strip() != '': + try: + q &= Q(jine__lte=float(max_amount)) + except (TypeError, ValueError): + pass + + shenhe_field_ok = self._shenhe_jilu_field_available() + if shenhe_field_ok: + if effective_dakuan == 2: + q &= Q(shenhe_jilu_id__isnull=False) + else: + q &= Q(shenhe_jilu_id__isnull=True) + elif effective_dakuan == 2: + kefu_txsh_list_logger.warning('kefu_txsh_list: 自动打款筛选不可用,返回空列表') + return Response({ + 'code': 0, + 'data': { + 'list': [], + 'total': 0, + 'stats': {'total': 0, 'awaiting': 0, 'processed': 0, 'total_amount': '0.00'}, + }, + }) + + processed_statuses = [2, 3, 4, 5, 6] if effective_dakuan == 2 else [2, 3] + + base_qs = self._list_queryset(q, shenhe_field_ok, request) + agg = base_qs.aggregate( + total=Count('id'), + awaiting=Count('id', filter=Q(zhuangtai=1)), + processed=Count('id', filter=Q(zhuangtai__in=processed_statuses)), + total_amount=Sum('jine'), + ) + + stats = { + 'total': agg['total'], + 'awaiting': agg['awaiting'], + 'processed': agg['processed'], + 'total_amount': str(round(float(agg.get('total_amount') or 0), 2)), + } + + queryset = base_qs.order_by('CreateTime') + total = queryset.count() + records = list(queryset[(page - 1) * page_size: page * page_size]) + + audit_ids = [ + getattr(item, 'shenhe_jilu_id', None) + for item in records if getattr(item, 'shenhe_jilu_id', None) + ] + audit_meta_map = {} + if audit_ids: + try: + audit_meta_map = load_audit_meta_map(audit_ids) + except Exception as e: + kefu_txsh_list_logger.error( + 'kefu_txsh_list: 查询审核表信息失败: %s', e, + exc_info=True, + ) + + audit_mode_map = { + aid: meta.get('dakuan_mode', 2) for aid, meta in audit_meta_map.items() + } + list_data = [] + for item in records: + shenhe_jilu_id = getattr(item, 'shenhe_jilu_id', None) if shenhe_field_ok else None + shenhe_danhao = (getattr(item, 'shenhe_danhao', None) or '') if shenhe_field_ok else '' + if shenhe_jilu_id and not shenhe_danhao: + shenhe_danhao = audit_meta_map.get(shenhe_jilu_id, {}).get('shenhe_danhao', '') + list_data.append({ + 'id': item.id, + 'shenhe_jilu_id': shenhe_jilu_id, + 'shenhe_danhao': shenhe_danhao, + 'yonghuid': item.yonghuid, + 'avatar': item.avatar or '', + 'phone': item.phone or '', + 'nicheng': item.nicheng or '', + 'leixing': item.leixing, + 'zhifu': item.zhifu or '', + 'skzhanghao': item.skzhanghao or '', + 'jine': str(item.jine) if item.jine is not None else '0.00', + 'zhuangtai': item.zhuangtai, + 'fangshi': item.fangshi, + 'dakuan_mode': self._resolve_row_dakuan_mode(item, audit_mode_map, shenhe_field_ok), + 'shenheid': item.shenheid or '', + 'bhliyou': item.bhliyou or '', + 'CreateTime': item.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if item.CreateTime else '', + 'UpdateTime': item.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if item.UpdateTime else '', + }) + + from jituan.services.club_user_access import list_response_meta + + return Response({ + 'code': 0, + 'data': { + 'list': list_data, + 'total': total, + 'stats': stats, + **list_response_meta(request), + }, + }) + + except DatabaseError as e: + kefu_txsh_list_logger.error( + 'kefu_txsh_list 数据库错误 dakuan_mode=%s params=%s err=%s', + dakuan_mode_filter, req_params, e, + exc_info=True, + ) + return Response({ + 'code': 500, + 'msg': '数据库查询失败,请确认已执行 python manage.py migrate yonghu', + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: + kefu_txsh_list_logger.error( + 'kefu_txsh_list 接口异常 dakuan_mode=%s params=%s err=%s', + dakuan_mode_filter, req_params, e, + exc_info=True, + ) + return Response({ + 'code': 500, + 'msg': '获取提现列表失败', + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class KefuWithdrawDetailView(APIView): + """ + 客服获取提现详情接口 + 请求:POST /yonghu/kefu_txsh_detail + 参数:{ + "phone": "客服手机号", + "tixian_id": "提现记录ID" + } + 认证:JWT + 返回:code=0 + 提现记录详情 + 用户扩展信息 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = request.data.get('phone', '').strip() + tixian_id = request.data.get('tixian_id') + + if not phone or not tixian_id: + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 客服身份验证 + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + logger.warning(f"手机号不匹配: {phone}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if current_user.UserType not in ('kefu', 'admin'): + logger.warning(f"用户类型非客服: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + kefu = None + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + logger.warning(f"客服扩展表不存在: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + if kefu.zhuangtai != 1: + logger.warning(f"客服账号已禁用: {current_user.UserUID}") + return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + from jituan.services.group_finance_gate import require_withdraw_audit_access + _, err_resp = require_withdraw_audit_access(request, phone) + if err_resp: + return err_resp + + # 查询提现记录 + try: + record = Tixianjilu.query.get(id=tixian_id) + except Tixianjilu.DoesNotExist: + return Response({'code': 404, 'msg': '提现记录不存在'}, status=status.HTTP_404_NOT_FOUND) + + from jituan.services.tixian_club import forbid_if_tixian_out_of_scope + deny = forbid_if_tixian_out_of_scope(request, record) + if deny: + return deny + + # 查询用户主表 + try: + user = User.query.get(UserUID=record.yonghuid) + except User.DoesNotExist: + user = None + + # 构建基础返回数据(字段必须与前端一致) + data = { + 'id': record.id, + 'yonghuid': record.yonghuid, + 'avatar': record.avatar or '', + 'phone': record.phone or '', + 'nicheng': record.nicheng or '', + 'leixing': record.leixing, # 1打手 2管事 + 'zhifu': record.zhifu or '', + 'skzhanghao': record.skzhanghao or '', + 'jine': str(record.jine) if record.jine else '0.00', + 'zhuangtai': record.zhuangtai, + 'fangshi': record.fangshi, + 'shenheid': record.shenheid or '', + 'bhliyou': record.bhliyou or '', + 'CreateTime': record.CreateTime, + 'UpdateTime': record.UpdateTime, + } + + # 根据提现类型添加扩展信息 + if user: + if record.leixing == 1: # 打手 + try: + dashou = UserDashou.query.get(user=user) + # 计算成交率和退款率 + total = dashou.jiedanzongliang or 0 + completed = dashou.chengjiaozongliang or 0 + refund = dashou.tuikuanliang or 0 + completion_rate = round((completed / total * 100) if total > 0 else 0) + refund_rate = round((refund / completed * 100) if completed > 0 else 0) + + data['dashouInfo'] = { + 'zhuangtai': dashou.zhuangtai, # 工作状态 + 'zaixianzhuangtai': dashou.zaixianzhuangtai, # 在线状态 + 'zhanghaozhuangtai': dashou.zhanghaozhuangtai, # 账号状态 + 'yue': float(dashou.yue) if dashou.yue else 0.00, # 可提现余额 + 'zonge': float(dashou.zonge) if dashou.zonge else 0.00, # 接单总额 + 'jifen': dashou.jifen, # 积分 + 'jiedanzongliang': dashou.jiedanzongliang, # 接单总量 + 'chengjiaozongliang': dashou.chengjiaozongliang, # 成交总量 + 'tuikuanliang': dashou.tuikuanliang, # 退款总量 + 'completionRate': completion_rate, + 'refundRate': refund_rate, + } + except ObjectDoesNotExist: + data['dashouInfo'] = None + elif record.leixing == 2: # 管事 + try: + guanshi = UserGuanshi.query.get(user=user) + data['guanshiInfo'] = { + 'zhuangtai': guanshi.zhuangtai, # 账号状态 + 'yue': float(guanshi.yue) if guanshi.yue else 0.00, + 'yaogingshuliang': guanshi.yaogingshuliang, # 邀请打手总数 + 'jinrichongzhi': guanshi.jinrichongzhi, # 今日充值 + 'jinyuechongzhi': guanshi.jinyuechongzhi, # 今月充值 + 'chongzhifenrun': float(guanshi.chongzhifenrun) if guanshi.chongzhifenrun else 0.00, + } + except ObjectDoesNotExist: + data['guanshiInfo'] = None + + return Response({'code': 0, 'data': data}) + + +class KefuWithdrawActionView(APIView): + """ + 客服处理提现接口(同意/拒绝)—— 仅此接口处理后台审核打款 + POST /yonghu/kefu_txsh_action + + 单条:{ phone, action, tixian_id, yonghuid, leixing, reason? } + 批量:{ phone, action, batch_list: [{tixian_id, yonghuid, leixing}, ...], reason? } + + 自动打款(dakuan_mode=2):同意→6待收款;拒绝→5+驳回原因+退还可到账金额(shijidaozhang),手续费不退,不更新平台收支/每日统计 + 手动打款(dakuan_mode=1):原逻辑不变 + 状态要求:Tixianjilu.zhuangtai=1 且 TixianShenheJilu.zhuangtai=1(审核中),其他状态一律拒绝 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def _verify_kefu(self, request, phone): + if not phone: + return None, Response({'code': 401, 'msg': '手机号不能为空'}, status=status.HTTP_400_BAD_REQUEST) + current_user = request.user + if getattr(current_user, 'Phone', '') != phone: + return None, Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + if current_user.UserType not in ('kefu', 'admin'): + return None, Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + # 管理员跳过客服扩展表检查 + is_admin = current_user.UserType == 'admin' or current_user.IsSuperuser + if not is_admin: + try: + kefu = current_user.KefuProfile + except ObjectDoesNotExist: + return None, Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + if kefu.zhuangtai != 1: + return None, Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + return current_user, None + + def _parse_items(self, request): + """解析单条或 batch_list,返回 [{tixian_id, yonghuid, leixing}]""" + batch_list = request.data.get('batch_list') + if batch_list is not None: + if not isinstance(batch_list, list) or len(batch_list) == 0: + return None, Response({'code': 400, 'msg': 'batch_list 不能为空'}, status=status.HTTP_400_BAD_REQUEST) + items = [] + for idx, row in enumerate(batch_list): + if not isinstance(row, dict): + return None, Response({'code': 400, 'msg': f'batch_list[{idx}] 格式错误'}, status=status.HTTP_400_BAD_REQUEST) + tid = row.get('tixian_id') + yid = (row.get('yonghuid') or '').strip() + lx = row.get('leixing') + if not tid or not yid or lx is None: + return None, Response( + {'code': 400, 'msg': f'batch_list[{idx}] 缺少 tixian_id / yonghuid / leixing'}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + items.append({ + 'tixian_id': int(tid), + 'yonghuid': yid, + 'leixing': int(lx), + }) + except (TypeError, ValueError): + return None, Response({'code': 400, 'msg': f'batch_list[{idx}] 参数格式错误'}, status=status.HTTP_400_BAD_REQUEST) + return items, None + + tixian_id = request.data.get('tixian_id') + if not tixian_id: + return None, Response({'code': 400, 'msg': '提现记录ID不能为空'}, status=status.HTTP_400_BAD_REQUEST) + yonghuid = (request.data.get('yonghuid') or '').strip() + leixing = request.data.get('leixing') + try: + item = {'tixian_id': int(tixian_id), 'yonghuid': yonghuid, 'leixing': int(leixing) if leixing is not None else None} + except (TypeError, ValueError): + return None, Response({'code': 400, 'msg': '参数格式错误'}, status=status.HTTP_400_BAD_REQUEST) + return [item], None + + def _process_auto_item(self, request, item, action, reason, kefu_user): + """自动打款单条处理(须在 transaction.atomic 内调用)""" + tixian_id = item['tixian_id'] + req_yonghuid = item['yonghuid'] + req_leixing = item['leixing'] + + if req_leixing not in [1, 2, 3, 4, 5, 6]: + raise ValueError(f'提现记录{tixian_id}:提现类型无效') + + try: + tixian = Tixianjilu.objects.select_for_update().get(id=tixian_id) + except Tixianjilu.DoesNotExist: + raise ValueError(f'提现记录{tixian_id}不存在') + + from jituan.services.tixian_club import forbid_if_tixian_out_of_scope + deny = forbid_if_tixian_out_of_scope(request, tixian) + if deny: + raise ValueError(deny.data.get('msg', '无权限处理该提现记录')) + + if tixian.yonghuid != req_yonghuid: + raise ValueError(f'提现记录{tixian_id}:用户ID不匹配') + if tixian.leixing != req_leixing: + raise ValueError(f'提现记录{tixian_id}:提现类型不匹配') + if tixian.zhuangtai != 1: + raise ValueError(f'提现记录{tixian_id}:非审核中状态,无法处理') + if not tixian.shenhe_jilu_id: + raise ValueError(f'提现记录{tixian_id}:非自动打款审核记录') + + try: + audit = TixianShenheJilu.objects.select_for_update().get(id=tixian.shenhe_jilu_id) + except TixianShenheJilu.DoesNotExist: + raise ValueError(f'提现记录{tixian_id}:审核记录不存在') + + if audit.yonghuid != req_yonghuid: + raise ValueError(f'提现记录{tixian_id}:审核记录用户ID不匹配') + if audit.leixing != req_leixing: + raise ValueError(f'提现记录{tixian_id}:审核记录提现类型不匹配') + if audit.zhuangtai != 1: + raise ValueError(f'提现记录{tixian_id}:审核记录非审核中状态') + + try: + user = User.objects.select_for_update().get(UserUID=req_yonghuid) + except User.DoesNotExist: + raise ValueError(f'用户{req_yonghuid}不存在') + + if action == 1: + limit_ok, limit_msg = check_shijidaozhang_within_limit(audit.shijidaozhang) + if not limit_ok: + sync_audit_and_jilu_status( + audit, 5, + bhliyou=limit_msg, + bo_hui_ren_id=kefu_user.UserUID, + ) + refund_balance(user, audit.leixing, audit.shijidaozhang) + tixian.shenheid = kefu_user.UserUID + tixian.bhliyou = limit_msg + tixian.save(update_fields=['shenheid', 'bhliyou', 'UpdateTime']) + raise ValueError(f'提现记录{tixian_id}:{limit_msg},已自动驳回并退款') + sync_audit_and_jilu_status(audit, 6, shenhe_ren_id=kefu_user.UserUID) + tixian.shenheid = kefu_user.UserUID + tixian.save(update_fields=['shenheid', 'UpdateTime']) + else: + sync_audit_and_jilu_status( + audit, 5, + bhliyou=reason, + bo_hui_ren_id=kefu_user.UserUID, + ) + # 退还可到账金额,申请时扣的 shenqing_jine 中的手续费(shouxufei)不退 + refund_balance(user, audit.leixing, audit.shijidaozhang) + tixian.shenheid = kefu_user.UserUID + tixian.bhliyou = reason + tixian.save(update_fields=['shenheid', 'bhliyou', 'UpdateTime']) + + def _process_manual_item(self, request, item, action, reason, phone): + """手动打款单条处理(原逻辑,须在 transaction.atomic 内调用)""" + tixian_id = item['tixian_id'] + try: + tixian = Tixianjilu.objects.select_for_update().get(id=tixian_id) + except Tixianjilu.DoesNotExist: + raise ValueError(f'提现记录{tixian_id}不存在') + + from jituan.services.tixian_club import forbid_if_tixian_out_of_scope + deny = forbid_if_tixian_out_of_scope(request, tixian) + if deny: + raise ValueError(deny.data.get('msg', '无权限处理该提现记录')) + + req_yonghuid = item.get('yonghuid') + req_leixing = item.get('leixing') + if req_yonghuid and tixian.yonghuid != req_yonghuid: + raise ValueError(f'提现记录{tixian_id}:用户ID不匹配') + if req_leixing is not None and tixian.leixing != req_leixing: + raise ValueError(f'提现记录{tixian_id}:提现类型不匹配') + if tixian.zhuangtai != 1: + raise ValueError(f'提现记录{tixian_id}:非审核中状态,无法处理') + + if getattr(tixian, 'shenhe_jilu_id', None): + raise ValueError(f'提现记录{tixian_id}:自动打款请走自动流程') + + try: + user = User.query.get(UserUID=tixian.yonghuid) + except User.DoesNotExist: + raise ValueError(f'用户{tixian.yonghuid}不存在') + + from jituan.services.club_user import get_user_club_id + + if action == 1: + tixian.zhuangtai = 2 + try: + from jituan.services.szjilu_accounting import apply_szjilu_expense + update_daily_payout(tixian.jine, get_user_club_id(user)) + apply_szjilu_expense(tixian.jine, get_user_club_id(user)) + except Exception as e: + logger.error(f'更新收支记录失败: {e}') + else: + try: + refund_balance(user, tixian.leixing, tixian.jine) + except ValueError as e: + raise ValueError(str(e)) + tixian.zhuangtai = 3 + tixian.bhliyou = reason + + tixian.shenheid = phone + tixian.UpdateTime = timezone.now() + tixian.save() + + def post(self, request): + logger.info(f'接收到提现处理请求,数据: {request.data}') + + phone = request.data.get('phone', '').strip() + action_raw = request.data.get('action') + reason = request.data.get('reason', '').strip() + + try: + action = int(action_raw) + except (TypeError, ValueError): + return Response({'code': 400, 'msg': 'action 参数必须为数字'}, status=status.HTTP_400_BAD_REQUEST) + if action not in [1, 2]: + return Response({'code': 400, 'msg': 'action 必须为1(同意)或2(拒绝)'}, status=status.HTTP_400_BAD_REQUEST) + if action == 2 and not reason: + return Response({'code': 400, 'msg': '拒绝提现时理由不能为空'}, status=status.HTTP_400_BAD_REQUEST) + + kefu_user, err_resp = self._verify_kefu(request, phone) + if err_resp: + return err_resp + + from jituan.services.group_finance_gate import require_withdraw_audit_access + _, audit_err = require_withdraw_audit_access(request, phone) + if audit_err: + return audit_err + + items, err_resp = self._parse_items(request) + if err_resp: + return err_resp + + is_batch = isinstance(request.data.get('batch_list'), list) and len(request.data.get('batch_list')) > 0 + + try: + with transaction.atomic(): + for item in items: + tixian_id = item['tixian_id'] + try: + tixian_probe = Tixianjilu.objects.select_for_update().get(id=tixian_id) + except Tixianjilu.DoesNotExist: + raise ValueError(f'提现记录{tixian_id}不存在') + + from jituan.services.tixian_club import forbid_if_tixian_out_of_scope + deny = forbid_if_tixian_out_of_scope(request, tixian_probe) + if deny: + raise ValueError(deny.data.get('msg', '无权限处理该提现记录')) + + # 有关联审核记录(shenhe_jilu_id)即为 zddksh 自动打款,不依赖 dakuan_mode 字段 + is_auto = bool(getattr(tixian_probe, 'shenhe_jilu_id', None)) + + if is_auto: + self._process_auto_item(request, item, action, reason, kefu_user) + else: + if is_batch: + raise ValueError(f'提现记录{tixian_id}:批量操作仅支持自动打款记录') + self._process_manual_item(request, item, action, reason, phone) + + except ValueError as e: + return Response({'code': 400, 'msg': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + logger.error(f'处理提现事务失败: {e}', exc_info=True) + return Response({'code': 500, 'msg': f'处理失败: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + msg = '批量处理成功' if is_batch and len(items) > 1 else '处理成功' + return Response({'code': 0, 'msg': msg, 'data': {'count': len(items)}}) diff --git a/users/views/shangjia.py b/users/views/shangjia.py index dd9e835..15268d1 100644 --- a/users/views/shangjia.py +++ b/users/views/shangjia.py @@ -719,13 +719,15 @@ class FaKuanShenSuView(APIView): PenaltyAppealImage.query.filter(Penalty=fadan, Purpose=2).delete() # 插入新上传的申诉图片 - for url in tupian_urls: - PenaltyAppealImage.query.create( + PenaltyAppealImage.objects.bulk_create([ + PenaltyAppealImage( Penalty=fadan, PenalizedUserID=yonghuid, ImageURL=url, - Purpose=2 + Purpose=2, ) + for url in tupian_urls + ]) return Response({'code': 0, 'msg': '申诉提交成功'}) diff --git a/users/views/tixian.py b/users/views/tixian.py index 6364d08..757a237 100644 --- a/users/views/tixian.py +++ b/users/views/tixian.py @@ -1052,7 +1052,9 @@ class TixianAssetView(APIView): permission_classes = [IsAuthenticated] def post(self, request): - user = request.user + user = User.query.select_related( + 'DashouProfile', 'ShopProfile', 'GuanshiProfile', 'ZuzhangProfile', 'ShenheguanProfile' + ).get(UserUID=request.user.UserUID) # 初始化返回数据,默认全部为0 data = { 'dashou_yue': '0.00',