Files
Django/backend/views/system.py

1194 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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': '您无权访问此页面'})
# 查询专区数量(按俱乐部隔离;集团汇总看全部)
zone_qs_for_count = filter_club_char_field(
ShangpinZhuanqu.query.all(), request, field='club_id'
)
zone_count_rows = zone_qs_for_count.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:
# 从预聚合的 map 中取专区数量
zone_count = zone_count_map.get(t.id, 0)
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,
})
# 查询专区(按俱乐部隔离)
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
]
# 查询所有会员(只返回 id、介绍、价格
members = Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage')
member_list = [
{
'huiyuan_id': m['huiyuan_id'],
'jieshao': m['jieshao'],
'jiage': float(m['jiage']),
}
for m in members
]
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.club_context import resolve_club_scope
return Response({
'code': 0,
'data': {
'type_list': type_list,
'zone_list': zone_list,
'member_list': member_list,
'club_scoped': resolve_club_scope(request) != DATA_SCOPE_ALL,
}
})
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,
)
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 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': '删除成功'})