import hmac import threading import traceback import uuid import hashlib import xmltodict import time import logging import requests import os import secrets import random import string from calendar import monthrange from decimal import Decimal from datetime import date, datetime, timedelta from django.utils import timezone from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator from django.db import models, transaction, IntegrityError from django.db.models import Q, Count, Sum, F, Exists, OuterRef from gvsdsdk.fluent import db, func, FQ from django.db.models.functions import TruncDate, TruncMonth, TruncYear from django.contrib.auth.hashers import make_password from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.parsers import JSONParser, MultiPartParser, FormParser # 工具类导入 from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss from backend.utils import ( update_dashou_daily_by_action, update_shangjia_daily ) from jituan.services.club_context import filter_club_char_field, filter_queryset_by_club, filter_user_related_by_club, filter_user_qs_by_club, orders_for_request from jituan.services.catalog_scope import block_non_group_catalog_scope from jituan.services.club_penalty import ( filter_penalty_qs, resolve_penalty_club_id, filter_gsfenhong_qs, forbid_penalty_out_of_scope, resolve_penalty_record_club_id, ) from jituan.services.club_user_access import forbid_if_user_out_of_scope, list_response_meta from shop.utils import update_dianpu_daily_stat from products.utils import update_shangpin_daily_stat from orders.notice_tasks import dingdan_guangbo from ..utils import ( verify_kefu_permission, PERMISSION_TO_SHENFEN, SHENFEN_PROFILE_MAP, has_fadan_view_permission, has_merchant_order_permission, check_fadan_permission, pick_order_id, pick_penalty_create_params, write_xiugai_log, XIUGAI_LEIXING_DASHOU, XIUGAI_LEIXING_GUANSHI, XIUGAI_LEIXING_SHANGJIA, XIUGAI_LEIXING_ZUZHANG, XIUGAI_LEIXING_LABEL, ) # models 集中导入 ## gvsdsdk RBAC 模型(使用 PascalCase 字段名,匹配实际数据库表结构) from gvsdsdk.models import Role, Permission, RolePermission, UserRole ## backend from backend.models import WithdrawalDailyStats ## users from users.models import ( UserGuanshi, UserBoss, UserZuzhang, UserKefu, UserDashou, Xiugaijilu, UserShangjia, UserShenheguan ) from users.business_models import User ## orders from orders.models import ( CommissionRate, PlayerDeliveryImage, PenaltyRecord, OrderPlayerHistory, Order, RefundRecord, MerchantOrderExt, Penalty, PenaltyAppealImage, PenaltyBonus ) ## shop from shop.models import Dianpu, DianpuMorenPeizhi, YonghuPingzheng, ShangpinLeixingDianpu, DianpuShangpinShenheShezhi ## products from products.models import ( Shangpin, ShangpinLeixing, ShangpinZhuanqu, Huiyuan, DuociFenhong, Czjilu, Huiyuangoumai, Gsfenhong ) ## config from config.models import ( WithdrawConfig, ShangjiaLianjie, AccountPermission, TixianQuotaDefault, DailyIncomeStat, DailyPayoutStat, PopupPage, PopupConfig, PopupImage ) ## rank from rank.models import ( KaoheguanBankuai, Bankuai, Chenghao, KaoheCishuFeiyong, YonghuChenghao, ShenheJilu, KaoheJiluFeiyong, KaoheJujueJilu ) # 序列化器 from ..serializers import PopupPageSerializer # 全局常量、日志对象 logger = logging.getLogger('houtai') SHOP_PRODUCT_PERMS = ['1199ab', '1199abc', '1199abd'] class GetOperationLogListView(APIView): """ 后台操作日志列表(xiugaijilu) POST /houtai/hqczrz 权限:000001 超级权限 或 czrz666 """ permission_classes = [] parser_classes = [JSONParser] def post(self, request): username = request.data.get('username', '').strip() if not username: return Response({'code': 401, 'msg': '缺少username'}) kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return permissions if '000001' not in permissions and 'czrz666' not in permissions: return Response({'code': 403, 'msg': '无权限查看操作日志(需要000001或czrz666)'}) yonghuid = request.data.get('yonghuid', '').strip() xiugaiid = request.data.get('xiugaiid', '').strip() leixing = request.data.get('leixing') keyword = request.data.get('keyword', '').strip() try: page = max(1, int(request.data.get('page', 1))) except (ValueError, TypeError): page = 1 try: page_size = min(100, max(1, int(request.data.get('page_size', 20)))) except (ValueError, TypeError): page_size = 20 qs = Xiugaijilu.query.all().order_by('-CreateTime') from jituan.services.admin_audit import filter_xiugaijilu_by_request qs = filter_xiugaijilu_by_request(qs, request) if yonghuid: qs = qs.filter(yonghuid=yonghuid) if xiugaiid: qs = qs.filter(xiugaiid__icontains=xiugaiid) if leixing not in (None, ''): try: qs = qs.filter(leixing=int(leixing)) except (ValueError, TypeError): pass if keyword: qs = qs.filter(qitashuoming__icontains=keyword) total = qs.count() start = (page - 1) * page_size records = qs[start:start + page_size] def _fmt_decimal(val): if val is None: return None return str(val) data_list = [] for r in records: data_list.append({ 'id': r.id, 'yonghuid': r.yonghuid, 'xiugaiid': r.xiugaiid, 'leixing': r.leixing, 'leixing_label': XIUGAI_LEIXING_LABEL.get(r.leixing, f'类型{r.leixing}'), 'xiugaitijiao': _fmt_decimal(r.xiugaitijiao), 'xiugaitijiaoq': _fmt_decimal(r.xiugaitijiaoq), 'jifen': r.jifen, 'yjifen': r.yjifen, 'yajin': _fmt_decimal(r.yajin), 'yyajin': _fmt_decimal(r.yyajin), 'shangjiayue': _fmt_decimal(r.shangjiayue), 'shangjiayueq': _fmt_decimal(r.shangjiayueq), 'guanshiyue': _fmt_decimal(r.guanshiyue), 'guanshiyueq': _fmt_decimal(r.guanshiyueq), 'huiyuants': r.huiyuants, 'huiyuan_id': r.huiyuan_id, 'qitashuoming': r.qitashuoming or '', 'create_time': r.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if r.CreateTime else '', }) from jituan.services.club_user_access import list_response_meta return Response({ 'code': 0, 'data': { 'list': data_list, 'total': total, 'page': page, 'page_size': page_size, **list_response_meta(request), }, }) class GetProductTypeZoneView(APIView): """ 获取商品类型、专区及会员列表 权限:需要拥有 7007a 请求:POST /houtai/hthqsplxzq 参数:{"username": "客服手机号"} 返回: code: 0 data: { type_list: [{id, jieshao, tupian_url, yaoqiuleixing, huiyuan_id, yongjin, shenhezhuangtai, paixu, product_count}, ...], zone_list: [{id, mingzi, leixing_id, shenhezhuangtai, paixu}, ...], member_list: [{huiyuan_id, jieshao, jiage}, ...] } """ permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): username = request.data.get('username', '').strip() if not username: return Response({'code': 400, 'msg': '缺少username'}) # 权限校验 kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return permissions if '7007a' not in permissions: return Response({'code': 403, 'msg': '您无权访问此页面'}) from jituan.constants import DATA_SCOPE_ALL from jituan.services.club_context import resolve_club_scope, resolve_club_id_from_request from jituan.services.club_shangpin_leixing import build_club_leixing_admin_list club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL club_id = resolve_club_id_from_request(request) try: zone_qs_for_count = filter_club_char_field( ShangpinZhuanqu.query.all(), request, field='club_id' ) zone_count_map = { row['leixing_id']: row['cnt'] for row in zone_qs_for_count.values('leixing_id').annotate(cnt=Count('id')) } if club_scoped: # 具体俱乐部:仅本俱乐部已上架类型(与抢单端一致) admin_payload = build_club_leixing_admin_list(request) type_list = [] for item in (admin_payload.get('list') or []): type_list.append({ **item, 'product_count': zone_count_map.get(item['id'], 0), 'club_enabled': True, }) else: type_list = [] for t in ShangpinLeixing.query.all().order_by('-paixu'): type_list.append({ 'id': t.id, 'jieshao': t.jieshao or '', 'tupian_url': t.tupian_url or '', 'yaoqiuleixing': t.yaoqiuleixing, 'huiyuan_id': t.huiyuan_id or '', 'yongjin': float(t.yongjin) if t.yongjin is not None else None, 'shenhezhuangtai': t.shenhezhuangtai, 'paixu': t.paixu, 'product_count': zone_count_map.get(t.id, 0), }) zones = filter_club_char_field( ShangpinZhuanqu.query.all().order_by('-paixu'), request, field='club_id' ) zone_list = [ { 'id': z.id, 'mingzi': z.mingzi or '', 'leixing_id': z.leixing_id, 'shenhezhuangtai': z.shenhezhuangtai, 'paixu': z.paixu, 'club_id': getattr(z, 'club_id', '') or '', } for z in zones ] member_list = [ { 'huiyuan_id': m['huiyuan_id'], 'jieshao': m['jieshao'] or '', 'jiage': float(m['jiage'] or 0), } for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage') ] return Response({ 'code': 0, 'data': { 'type_list': type_list, 'zone_list': zone_list, 'member_list': member_list, 'club_scoped': club_scoped, 'club_id': club_id if club_scoped else '', } }) except Exception: logging.getLogger(__name__).exception('GetProductTypeZoneView 失败') return Response({'code': 500, 'msg': '获取类型专区失败,请稍后重试'}) class ModifyProductTypeZoneView(APIView): """ 统一修改商品类型/专区(增、删、改) 权限:需要拥有 7007a """ permission_classes = [IsAuthenticated] parser_classes = [JSONParser, MultiPartParser, FormParser] def post(self, request): username = request.data.get('username', '').strip() obj_type = request.data.get('type') # 'leixing' 或 'zhuanqu' action = request.data.get('action') # 'add', 'update', 'delete' if not username or not obj_type or not action: return Response({'code': 400, 'msg': '缺少必要参数'}) # 权限校验 kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return permissions if '7007a' not in permissions: return Response({'code': 403, 'msg': '您无权进行此操作'}) # 类型仍须集团汇总;专区允许在具体俱乐部操作 if obj_type == 'leixing': scope_err = block_non_group_catalog_scope(request) if scope_err: return Response({'code': 400, 'msg': scope_err}) return self.handle_leixing(request, action) elif obj_type == 'zhuanqu': return self.handle_zhuanqu(request, action) else: return Response({'code': 400, 'msg': '无效的type参数'}) # ---------- 商品类型处理 ---------- def handle_leixing(self, request, action): if action == 'add': return self.add_leixing(request) elif action == 'update': return self.update_leixing(request) elif action == 'delete': return self.delete_leixing(request) else: return Response({'code': 400, 'msg': '无效的action'}) def add_leixing(self, request): # 获取并校验字段 jieshao = request.data.get('jieshao', '').strip() if not jieshao: return Response({'code': 400, 'msg': '类型名称不能为空'}) # 强制转为整数 try: yaoqiuleixing = int(request.data.get('yaoqiuleixing', 0)) if yaoqiuleixing not in [1, 2]: raise ValueError except (TypeError, ValueError): return Response({'code': 400, 'msg': '抢单要求类型无效(必须为1或2)'}) try: shenhezhuangtai = int(request.data.get('shenhezhuangtai', 1)) if shenhezhuangtai not in [1, 2]: raise ValueError except (TypeError, ValueError): return Response({'code': 400, 'msg': '审核状态无效(必须为1或2)'}) huiyuan_id = request.data.get('huiyuan_id', '').strip() or None yongjin = request.data.get('yongjin') if yongjin is not None and yongjin != '': try: yongjin = Decimal(str(yongjin)) except: return Response({'code': 400, 'msg': '佣金金额格式错误'}) else: yongjin = None # 图片校验 image_file = request.FILES.get('file') if not image_file: return Response({'code': 400, 'msg': '请上传类型图片'}) # 验证图片 try: valid, msg = validate_image(image_file) if not valid: return Response({'code': 400, 'msg': msg}) except ImportError: pass # 上传图片 ext = image_file.name.split('.')[-1].lower() new_filename = f"{uuid.uuid4().hex}.{ext}" oss_path = f"a_long/shangpin/shangpinleixing/{new_filename}" url = upload_to_oss(image_file, oss_path) if not url: return Response({'code': 500, 'msg': '图片上传失败'}) # 创建类型 try: with transaction.atomic(): leixing = ShangpinLeixing.query.create( jieshao=jieshao, tupian_url=oss_path, yaoqiuleixing=yaoqiuleixing, huiyuan_id=huiyuan_id, yongjin=yongjin, shenhezhuangtai=shenhezhuangtai, paixu=0 ) return Response({'code': 0, 'msg': '添加成功', 'data': {'id': leixing.id}}) except Exception as e: logger.error(f"添加类型失败: {e}", exc_info=True) delete_from_oss(oss_path) return Response({'code': 500, 'msg': '数据库错误'}) def update_leixing(self, request): type_id = request.data.get('id') if not type_id: return Response({'code': 400, 'msg': '缺少类型ID'}) try: leixing = ShangpinLeixing.query.get(id=type_id) except ShangpinLeixing.DoesNotExist: return Response({'code': 404, 'msg': '类型不存在'}) changes = {} # 名称 jieshao = request.data.get('jieshao') if jieshao is not None and jieshao.strip() != leixing.jieshao: changes['jieshao'] = jieshao.strip() # 抢单要求类型(强制转整数) yaoqiuleixing = request.data.get('yaoqiuleixing') if yaoqiuleixing is not None: try: new_val = int(yaoqiuleixing) if new_val not in [1, 2]: raise ValueError if new_val != leixing.yaoqiuleixing: changes['yaoqiuleixing'] = new_val except (TypeError, ValueError): return Response({'code': 400, 'msg': '抢单要求类型无效(必须为1或2)'}) # 会员ID huiyuan_id = request.data.get('huiyuan_id', '').strip() or None if huiyuan_id != leixing.huiyuan_id: changes['huiyuan_id'] = huiyuan_id # 佣金金额 yongjin = request.data.get('yongjin') if yongjin is not None: if yongjin == '': new_val = None else: try: new_val = Decimal(str(yongjin)) except: return Response({'code': 400, 'msg': '佣金金额格式错误'}) if new_val != leixing.yongjin: changes['yongjin'] = new_val # 审核状态 shenhezhuangtai = request.data.get('shenhezhuangtai') if shenhezhuangtai is not None: try: new_val = int(shenhezhuangtai) if new_val not in [1, 2]: raise ValueError if new_val != leixing.shenhezhuangtai: changes['shenhezhuangtai'] = new_val except (TypeError, ValueError): return Response({'code': 400, 'msg': '审核状态无效(必须为1或2)'}) # 图片处理 image_file = request.FILES.get('file') old_image_path = leixing.tupian_url if image_file: try: valid, msg = validate_image(image_file) if not valid: return Response({'code': 400, 'msg': msg}) except ImportError: pass ext = image_file.name.split('.')[-1].lower() new_filename = f"{uuid.uuid4().hex}.{ext}" oss_path = f"a_long/shangpin/shangpinleixing/{new_filename}" url = upload_to_oss(image_file, oss_path) if not url: return Response({'code': 500, 'msg': '图片上传失败'}) changes['tupian_url'] = oss_path if not changes and not image_file: return Response({'code': 400, 'msg': '未做任何修改'}) sync_to_products = request.data.get('sync_to_products') in [True, 'true', '1', 'on'] try: with transaction.atomic(): for field, value in changes.items(): setattr(leixing, field, value) leixing.save() # 同步商品 if sync_to_products and ('yaoqiuleixing' in changes or 'huiyuan_id' in changes or 'yongjin' in changes): product_updates = {} if 'yaoqiuleixing' in changes: product_updates['yaoqiuleixing'] = changes['yaoqiuleixing'] if 'huiyuan_id' in changes: product_updates['huiyuan_id'] = changes['huiyuan_id'] if 'yongjin' in changes: product_updates['yongjin'] = changes['yongjin'] if product_updates: Shangpin.query.filter(leixing_id=type_id).update(**product_updates) # 删除旧图片 if image_file and old_image_path: delete_from_oss(old_image_path) return Response({'code': 0, 'msg': '修改成功'}) except Exception as e: logger.error(f"更新类型失败: {e}", exc_info=True) if image_file and 'oss_path' in locals(): delete_from_oss(oss_path) return Response({'code': 500, 'msg': '修改失败'}) def delete_leixing(self, request): type_id = request.data.get('id') if not type_id: return Response({'code': 400, 'msg': '缺少类型ID'}) delete_products = request.data.get('delete_products') in [True, 'true', '1', 'on'] try: leixing = ShangpinLeixing.query.get(id=type_id) except ShangpinLeixing.DoesNotExist: return Response({'code': 404, 'msg': '类型不存在'}) with transaction.atomic(): # 删除该类型下的所有专区 zones = ShangpinZhuanqu.query.filter(leixing_id=type_id) if delete_products: # 收集 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: zones.delete() # 仅清除商品的类型关联 Shangpin.query.filter(leixing_id=type_id).update(leixing_id=None) leixing.delete() if leixing.tupian_url: delete_from_oss(leixing.tupian_url) return Response({'code': 0, 'msg': '删除成功'}) # ---------- 商品专区处理 ---------- def handle_zhuanqu(self, request, action): if action == 'add': return self.add_zhuanqu(request) elif action == 'update': return self.update_zhuanqu(request) elif action == 'delete': return self.delete_zhuanqu(request) else: return Response({'code': 400, 'msg': '无效的action'}) def add_zhuanqu(self, request): mingzi = request.data.get('mingzi', '').strip() leixing_id = request.data.get('leixing_id') if not mingzi: return Response({'code': 400, 'msg': '专区名称不能为空'}) if not leixing_id: return Response({'code': 400, 'msg': '请选择所属类型'}) try: leixing_id = int(leixing_id) if not ShangpinLeixing.query.filter(id=leixing_id).exists(): return Response({'code': 404, 'msg': '所属类型不存在'}) except (TypeError, ValueError): return Response({'code': 400, 'msg': '所属类型ID无效'}) try: shenhezhuangtai = int(request.data.get('shenhezhuangtai', 1)) if shenhezhuangtai not in [1, 2]: raise ValueError except (TypeError, ValueError): return Response({'code': 400, 'msg': '审核状态无效(必须为1或2)'}) try: with transaction.atomic(): from jituan.services.club_write import resolve_club_id_for_write club_id = resolve_club_id_for_write(request) zhuanqu = ShangpinZhuanqu.query.create( mingzi=mingzi, leixing_id=leixing_id, shenhezhuangtai=shenhezhuangtai, paixu=0, club_id=club_id, tupian_url=(request.data.get('tupian_url') or '').strip(), ) return Response({'code': 0, 'msg': '添加成功', 'data': {'id': zhuanqu.id, 'club_id': club_id}}) except Exception as e: logger.error(f"添加专区失败: {e}", exc_info=True) return Response({'code': 500, 'msg': '数据库错误'}) def update_zhuanqu(self, request): zone_id = request.data.get('id') if not zone_id: return Response({'code': 400, 'msg': '缺少专区ID'}) try: zhuanqu = ShangpinZhuanqu.query.get(id=zone_id) except ShangpinZhuanqu.DoesNotExist: return Response({'code': 404, 'msg': '专区不存在'}) from jituan.constants import DATA_SCOPE_ALL from jituan.services.club_context import resolve_club_scope from jituan.services.club_write import resolve_club_id_for_write if resolve_club_scope(request) != DATA_SCOPE_ALL: write_club = resolve_club_id_for_write(request) zone_club = getattr(zhuanqu, 'club_id', None) or 'xq' if zone_club != write_club: return Response({'code': 403, 'msg': '专区不属于当前俱乐部'}) changes = {} mingzi = request.data.get('mingzi') if mingzi is not None and mingzi.strip() != zhuanqu.mingzi: changes['mingzi'] = mingzi.strip() leixing_id = request.data.get('leixing_id') if leixing_id is not None: try: new_id = int(leixing_id) if not ShangpinLeixing.query.filter(id=new_id).exists(): return Response({'code': 404, 'msg': '新所属类型不存在'}) if new_id != zhuanqu.leixing_id: changes['leixing_id'] = new_id except (TypeError, ValueError): return Response({'code': 400, 'msg': '所属类型ID无效'}) shenhezhuangtai = request.data.get('shenhezhuangtai') if shenhezhuangtai is not None: try: new_val = int(shenhezhuangtai) if new_val not in [1, 2]: raise ValueError if new_val != zhuanqu.shenhezhuangtai: changes['shenhezhuangtai'] = new_val except (TypeError, ValueError): return Response({'code': 400, 'msg': '审核状态无效(必须为1或2)'}) if 'tupian_url' in request.data: new_url = (request.data.get('tupian_url') or '').strip() old_url = getattr(zhuanqu, 'tupian_url', None) or '' if new_url != old_url: changes['tupian_url'] = new_url if not changes: return Response({'code': 400, 'msg': '未做任何修改'}) try: with transaction.atomic(): for field, value in changes.items(): setattr(zhuanqu, field, value) zhuanqu.save() return Response({'code': 0, 'msg': '修改成功'}) except Exception as e: logger.error(f"更新专区失败: {e}", exc_info=True) return Response({'code': 500, 'msg': '修改失败'}) def delete_zhuanqu(self, request): zone_id = request.data.get('id') if not zone_id: return Response({'code': 400, 'msg': '缺少专区ID'}) delete_products = request.data.get('delete_products') in [True, 'true', '1', 'on'] try: zhuanqu = ShangpinZhuanqu.query.get(id=zone_id) except ShangpinZhuanqu.DoesNotExist: return Response({'code': 404, 'msg': '专区不存在'}) from jituan.constants import DATA_SCOPE_ALL from jituan.services.club_context import resolve_club_scope from jituan.services.club_write import resolve_club_id_for_write if resolve_club_scope(request) != DATA_SCOPE_ALL: write_club = resolve_club_id_for_write(request) zone_club = getattr(zhuanqu, 'club_id', None) or 'xq' if zone_club != write_club: return Response({'code': 403, 'msg': '专区不属于当前俱乐部'}) with transaction.atomic(): if delete_products: Shangpin.query.filter(zhuanqu_id=zone_id).delete() else: Shangpin.query.filter(zhuanqu_id=zone_id).update(zhuanqu_id=None) zhuanqu.delete() return Response({'code': 0, 'msg': '删除成功'}) # 继续在 houtai/views_product.py 中添加 class GetRateView(APIView): """ 获取打手分成费率(平台订单和商家订单) 权限:需要拥有 7007b 请求:POST /houtai/hthqfhpz 参数:{"username": "客服手机号"} 返回:{"code":0, "data": {"platform_rate": xx, "merchant_rate": xx}} """ permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): username = request.data.get('username', '').strip() if not username: return Response({'code': 400, 'msg': '缺少username'}) kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return permissions if '7007b' not in permissions: return Response({'code': 403, 'msg': '您无权查看费率'}) try: platform_rate_obj = CommissionRate.query.get(Platform='1') merchant_rate_obj = CommissionRate.query.get(Platform='3') except CommissionRate.DoesNotExist as e: logger.error(f"费率记录缺失: {e}") return Response({'code': 500, 'msg': '费率配置缺失'}) return Response({ 'code': 0, 'data': { 'platform_rate': float(platform_rate_obj.Rate) if platform_rate_obj.Rate else 0, 'merchant_rate': float(merchant_rate_obj.Rate) if merchant_rate_obj.Rate else 0, } }) class ModifyRateView(APIView): """ 修改打手分成费率 权限:需要拥有 7007b 请求:POST /houtai/htxgfl 参数:{"username": "客服手机号", "type": "platform" / "merchant", "value": 费率(百分比数字)} 返回:{"code":0, "msg":"修改成功"} """ permission_classes = [IsAuthenticated] parser_classes = [JSONParser] def post(self, request): username = request.data.get('username', '').strip() rate_type = request.data.get('type') value = request.data.get('value') if not username or not rate_type or value is None: return Response({'code': 400, 'msg': '缺少参数'}) kefu, permissions = verify_kefu_permission(request, username) if kefu is None: return permissions if '7007b' not in permissions: return Response({'code': 403, 'msg': '您无权修改费率'}) try: rate_value = Decimal(str(value)) except: return Response({'code': 400, 'msg': '费率值格式错误'}) if rate_type == 'platform': Platform = '1' elif rate_type == 'merchant': Platform = '3' else: return Response({'code': 400, 'msg': '无效的type参数'}) try: with transaction.atomic(): rate_obj = CommissionRate.objects.select_for_update().get(Platform=Platform) rate_obj.Rate = rate_value rate_obj.save() except CommissionRate.DoesNotExist: return Response({'code': 404, 'msg': '费率记录不存在'}) except Exception as e: logger.error(f"修改费率失败: {e}", exc_info=True) return Response({'code': 500, 'msg': '修改失败'}) return Response({'code': 0, 'msg': '修改成功'}) # 权限码(根据你要求,使用 '8080a') REQUIRED_PERMISSION = '8080a' class PopupNoticeListAPIView(APIView): """ 获取所有弹窗配置(页面 + 弹窗 + 图片) URL: POST /houtai/hthqtcxx 请求参数: { "username": "客服账号" } 响应格式: { "code": 0, "data": { "pages": [...] } } 注意:所有响应均使用默认 HTTP 200 状态码,业务成功/失败通过 code 字段区分 """ permission_classes = [IsAuthenticated] def post(self, request): # 1. 获取前端传递的 username(用于防越权) username_frontend = request.data.get('username') # 2. 调用公共权限验证方法,返回 (客服对象, 权限列表) kefu, permissions = verify_kefu_permission(request, username_frontend) if kefu is None: # 身份验证失败,返回业务码 403,但 HTTP 状态码仍是 200 return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) # 3. 检查是否拥有所需权限码 '8080a' if REQUIRED_PERMISSION not in permissions: return Response({'code': 403, 'msg': '您没有权限访问弹窗管理功能'}) # 4. 查询所有页面,并预加载关联的弹窗和图片(避免N+1查询) from jituan.services.display_config import filter_popup_page_by_request, list_response_meta pages = filter_popup_page_by_request( PopupPage.query.all().prefetch_related('popups__images'), request, ).order_by('id') # 5. 序列化输出 serializer = PopupPageSerializer(pages, many=True) return Response({ 'code': 0, 'data': {'pages': serializer.data, **list_response_meta(request)}, }) class PopupNoticeModifyAPIView(APIView): """ 统一修改接口:支持页面、弹窗、图片的增删改 URL: POST /houtai/htxgtcxx 请求中必须包含 action 字段,用于区分操作类型。 所有响应均使用默认 HTTP 200 状态码,业务成功/失败通过 code 字段体现。 """ permission_classes = [IsAuthenticated] def post(self, request): # ---------- 1. 权限验证 ---------- username_frontend = request.data.get('username') kefu, permissions = verify_kefu_permission(request, username_frontend) if kefu is None: return Response({'code': 403, 'msg': '身份验证失败,请检查登录状态'}) if REQUIRED_PERMISSION not in permissions: return Response({'code': 403, 'msg': '您没有权限操作弹窗管理'}) from jituan.services.display_config import forbid_display_write_in_all_scope deny = forbid_display_write_in_all_scope(request) if deny: return deny # ---------- 2. 获取 action 并分发 ---------- action = request.data.get('action') if not action: return Response({'code': 400, 'msg': '缺少action参数'}) try: if action == 'add_page': return self.add_page(request) elif action == 'update_page': return self.update_page(request) elif action == 'delete_page': return self.delete_page(request) elif action == 'add_popup': return self.add_popup(request) elif action == 'update_popup': return self.update_popup(request) elif action == 'delete_popup': return self.delete_popup(request) elif action == 'add_image': return self.add_image(request) elif action == 'update_image': return self.update_image(request) elif action == 'delete_image': return self.delete_image(request) else: return Response({'code': 400, 'msg': f'未知的action: {action}'}) except Exception as e: # 捕获未预期的异常,返回服务器错误 traceback.print_exc() return Response({'code': 500, 'msg': f'服务器错误: {str(e)}'}) # ==================== 页面相关操作 ==================== def add_page(self, request): """添加新页面""" from jituan.services.club_context import resolve_club_id_from_request from jituan.services.display_config import forbid_display_write_in_all_scope deny = forbid_display_write_in_all_scope(request) if deny: return deny data = request.data # 必填字段校验 if not data.get('page_key') or not data.get('name'): return Response({'code': 400, 'msg': '缺少 page_key 或 name'}) club_id = resolve_club_id_from_request(request) # 唯一性校验(同俱乐部内) if PopupPage.query.filter(club_id=club_id, page_key=data['page_key']).exists(): return Response({'code': 400, 'msg': '页面标识已存在'}) page = PopupPage.query.create( club_id=club_id, page_key=data['page_key'], name=data['name'], description=data.get('description', ''), is_active=data.get('is_active', True), ignore_user_mute=data.get('ignore_user_mute', False) ) return Response({'code': 0, 'msg': '添加成功', 'data': {'id': page.id}}) def update_page(self, request): """更新页面信息""" page_id = request.data.get('id') if not page_id: return Response({'code': 400, 'msg': '缺少页面id'}) try: page = PopupPage.query.get(id=page_id) except PopupPage.DoesNotExist: return Response({'code': 404, 'msg': '页面不存在'}) from jituan.services.display_config import forbid_popup_page_out_of_scope deny = forbid_popup_page_out_of_scope(request, page) if deny: return deny # 更新允许修改的字段 page.page_key = request.data.get('page_key', page.page_key) page.name = request.data.get('name', page.name) page.description = request.data.get('description', page.description) page.is_active = request.data.get('is_active', page.is_active) page.ignore_user_mute = request.data.get('ignore_user_mute', page.ignore_user_mute) page.save() return Response({'code': 0, 'msg': '更新成功'}) def delete_page(self, request): """删除页面,可级联删除其下所有弹窗和图片""" page_id = request.data.get('id') cascade = request.data.get('cascade_delete_popups', True) # 默认级联 if not page_id: return Response({'code': 400, 'msg': '缺少页面id'}) try: page = PopupPage.query.get(id=page_id) except PopupPage.DoesNotExist: return Response({'code': 404, 'msg': '页面不存在'}) from jituan.services.display_config import forbid_popup_page_out_of_scope, forbid_display_write_in_all_scope deny = forbid_display_write_in_all_scope(request) or forbid_popup_page_out_of_scope(request, page) if deny: return deny if cascade: # 级联删除:使用事务保证原子性 with transaction.atomic(): # 预加载 images 关联,避免循环内 N+1 访问 related manager popups = page.popups.prefetch_related('images') for popup in popups: # 删除弹窗下的所有图片文件 for img in popup.images.all(): if img.image_url: delete_from_oss(img.image_url) popups.delete() page.delete() else: # 非级联删除:如果页面下还有弹窗,则拒绝删除 if page.popups.exists(): return Response({'code': 400, 'msg': '该页面下存在弹窗,请先删除弹窗或使用级联删除'}) page.delete() return Response({'code': 0, 'msg': '删除成功'}) # ==================== 弹窗相关操作 ==================== def add_popup(self, request): """为指定页面添加新弹窗""" data = request.data page_id = data.get('page_id') popup_id = data.get('popup_id') if not page_id or not popup_id: return Response({'code': 400, 'msg': '缺少 page_id 或 popup_id'}) try: page = PopupPage.query.get(id=page_id) except PopupPage.DoesNotExist: return Response({'code': 404, 'msg': '页面不存在'}) from jituan.services.display_config import forbid_popup_page_out_of_scope deny = forbid_popup_page_out_of_scope(request, page) if deny: return deny # 同一页面下 popup_id 必须唯一 if PopupConfig.query.filter(page=page, popup_id=popup_id).exists(): return Response({'code': 400, 'msg': '该页面下已存在相同的弹窗ID'}) popup = PopupConfig.query.create( page=page, popup_id=popup_id, title=data.get('title', ''), description=data.get('description', ''), strategy_type=data.get('strategy_type', 'daily'), max_count=data.get('max_count', 1), reset_interval=data.get('reset_interval', 'day'), duration=data.get('duration', 0), force_even_muted=data.get('force_even_muted', False), sort_order=data.get('sort_order', 0), is_active=data.get('is_active', True), start_time=data.get('start_time') or None, end_time=data.get('end_time') or None ) return Response({'code': 0, 'msg': '添加成功', 'data': {'id': popup.id}}) def update_popup(self, request): """更新弹窗信息""" popup_id = request.data.get('id') if not popup_id: return Response({'code': 400, 'msg': '缺少弹窗id'}) try: popup = PopupConfig.query.get(id=popup_id) except PopupConfig.DoesNotExist: return Response({'code': 404, 'msg': '弹窗不存在'}) from jituan.services.display_config import forbid_popup_page_out_of_scope deny = forbid_popup_page_out_of_scope(request, popup.page) if deny: return deny # 更新弹窗字段 popup.popup_id = request.data.get('popup_id', popup.popup_id) popup.title = request.data.get('title', popup.title) popup.description = request.data.get('description', popup.description) popup.strategy_type = request.data.get('strategy_type', popup.strategy_type) popup.max_count = request.data.get('max_count', popup.max_count) popup.reset_interval = request.data.get('reset_interval', popup.reset_interval) popup.duration = request.data.get('duration', popup.duration) popup.force_even_muted = request.data.get('force_even_muted', popup.force_even_muted) popup.sort_order = request.data.get('sort_order', popup.sort_order) popup.is_active = request.data.get('is_active', popup.is_active) popup.start_time = request.data.get('start_time') or None popup.end_time = request.data.get('end_time') or None popup.save() return Response({'code': 0, 'msg': '更新成功'}) def delete_popup(self, request): """删除弹窗,同时删除其下所有图片(包括OSS文件)""" popup_id = request.data.get('id') if not popup_id: return Response({'code': 400, 'msg': '缺少弹窗id'}) try: popup = PopupConfig.query.get(id=popup_id) except PopupConfig.DoesNotExist: return Response({'code': 404, 'msg': '弹窗不存在'}) from jituan.services.display_config import forbid_popup_page_out_of_scope deny = forbid_popup_page_out_of_scope(request, popup.page) if deny: return deny with transaction.atomic(): for img in popup.images.all(): if img.image_url: delete_from_oss(img.image_url) popup.images.all().delete() popup.delete() return Response({'code': 0, 'msg': '删除成功'}) # ==================== 图片相关操作 ==================== def _upload_image_file(self, file_obj, page_key): """ 内部方法:将图片上传到OSS指定目录 目录: a_long/tanchuang/tanchuang/ 文件名: {page_key}_{时间戳}_{uuid8}.{ext} 返回: 相对路径(例如 a_long/tanchuang/tanchuang/dashouzhongxin_20250419120000_abc12345.jpg) """ # 验证图片格式和大小 valid, msg = validate_image(file_obj) if not valid: raise ValueError(msg) # 生成唯一文件名 ext = os.path.splitext(file_obj.name)[1].lower() timestamp = datetime.now().strftime('%Y%m%d%H%M%S') unique_id = str(uuid.uuid4())[:8] filename = f"{page_key}_{timestamp}_{unique_id}{ext}" relative_path = f"a_long/tanchuang/tanchuang/{filename}" # 上传到OSS url = upload_to_oss(file_obj, relative_path) if not url: raise ValueError("图片上传失败") return relative_path def add_image(self, request): """为指定弹窗添加图片(支持上传文件)""" popup_id = request.data.get('popup_id') if not popup_id: return Response({'code': 400, 'msg': '缺少 popup_id'}) try: popup = PopupConfig.query.get(id=popup_id) except PopupConfig.DoesNotExist: return Response({'code': 404, 'msg': '弹窗不存在'}) from jituan.services.display_config import forbid_popup_page_out_of_scope deny = forbid_popup_page_out_of_scope(request, popup.page) if deny: return deny # 获取上传的文件 file_obj = request.FILES.get('file') if not file_obj: return Response({'code': 400, 'msg': '未提供图片文件'}) try: page_key = popup.page.page_key relative_path = self._upload_image_file(file_obj, page_key) except ValueError as e: return Response({'code': 400, 'msg': str(e)}) # 保存图片记录 image = PopupImage.query.create( popup_config=popup, image_url=relative_path, text=request.data.get('text', ''), sort_order=request.data.get('sort_order', 0) ) return Response({'code': 0, 'msg': '添加成功', 'data': {'id': image.id}}) def update_image(self, request): """更新图片信息(可替换图片文件、修改文字、排序)""" image_id = request.data.get('id') if not image_id: return Response({'code': 400, 'msg': '缺少图片id'}) try: image = PopupImage.query.get(id=image_id) except PopupImage.DoesNotExist: return Response({'code': 404, 'msg': '图片不存在'}) from jituan.services.display_config import forbid_popup_page_out_of_scope deny = forbid_popup_page_out_of_scope(request, image.popup_config.page) if deny: return deny # 更新文字和排序 image.text = request.data.get('text', image.text) image.sort_order = request.data.get('sort_order', image.sort_order) # 如果提供了新图片文件,则替换 file_obj = request.FILES.get('file') if file_obj: try: page_key = image.popup_config.page.page_key new_path = self._upload_image_file(file_obj, page_key) except ValueError as e: return Response({'code': 400, 'msg': str(e)}) # 删除旧图片 if image.image_url: delete_from_oss(image.image_url) image.image_url = new_path image.save() return Response({'code': 0, 'msg': '更新成功'}) def delete_image(self, request): """删除图片(同时删除OSS文件)""" image_id = request.data.get('id') if not image_id: return Response({'code': 400, 'msg': '缺少图片id'}) try: image = PopupImage.query.get(id=image_id) except PopupImage.DoesNotExist: return Response({'code': 404, 'msg': '图片不存在'}) from jituan.services.display_config import forbid_popup_page_out_of_scope deny = forbid_popup_page_out_of_scope(request, image.popup_config.page) if deny: return deny if image.image_url: delete_from_oss(image.image_url) image.delete() return Response({'code': 0, 'msg': '删除成功'})