diff --git a/backend/view.py b/backend/view.py index e440b76..cb1df07 100644 --- a/backend/view.py +++ b/backend/view.py @@ -151,17 +151,15 @@ class GetRolePermissionView(APIView): all_perms = Permission.objects.filter(PermStatus=1).values('PermCode', 'PermName', 'PermDesc') permissions_list = [_serialize_permission_row(p) for p in all_perms] - # 4. 获取所有角色,并统计每个角色下的用户数(仅后台客服账号) + from jituan.services.club_rbac import count_role_users_in_scope, filter_roles_for_request + kefu_user_uuids = list( User.objects.filter(KefuProfile__isnull=False).values_list('UserUUID', flat=True) ) - roles = Role.objects.filter(RoleStatus=1) + roles = filter_roles_for_request(request) roles_data = [] for role in roles: - user_count = UserRole.objects.filter( - RoleUUID=role.RoleUUID, - UserUUID__in=kefu_user_uuids, - ).count() + 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) @@ -175,6 +173,7 @@ class GetRolePermissionView(APIView): 'role_code': role.RoleName, 'role_name': role.RoleName, 'description': role.RoleDesc or '', + 'club_id': getattr(role, 'ClubID', '') or '', 'user_count': user_count, 'permissions': perms_of_role }) @@ -339,9 +338,13 @@ class AddRoleView(APIView): if '000001' not in permissions: return Response({'code': 403, 'msg': '您无权进行此操作'}) - # 检查角色名称是否已存在 - if Role.objects.filter(RoleName=role_name).exists(): - return Response({'code': 400, 'msg': '角色名称已存在'}) + # 检查角色名称是否已存在(同俱乐部内唯一) + from jituan.services.club_rbac import resolve_role_club_id, role_name_exists + target_club_id = resolve_role_club_id( + request, (request.data.get('target_club_id') or '').strip() or None, + ) + if role_name_exists(role_name, target_club_id): + return Response({'code': 400, 'msg': '该俱乐部下角色名称已存在'}) # 生成唯一角色编码(格式:ROLE_ + 8位随机字母数字) role_code = f"ROLE_{''.join(random.choices(string.ascii_uppercase + string.digits, k=8))}" @@ -356,6 +359,7 @@ class AddRoleView(APIView): RoleStatus=1, AssignScope=0, RoleDesc=description, + ClubID=target_club_id, CreateTime=timezone.now(), ) # 绑定权限(过滤掉不存在的权限编码) @@ -405,6 +409,9 @@ class GetAdminUserListView(APIView): KefuProfile__isnull=False ).select_related('KefuProfile') + from jituan.services.club_rbac import filter_kefu_users_qs + users = filter_kefu_users_qs(users, request) + if phone: users = users.filter(Phone__icontains=phone) if nicheng: @@ -442,6 +449,7 @@ class GetAdminUserListView(APIView): 'phone': user.Phone or '', 'yonghuid': user.UserUID or '', 'nicheng': user.KefuProfile.nicheng, + 'club_id': getattr(user, 'ClubID', '') or '', 'roles': roles_data, 'status': user.KefuProfile.zhuangtai, 'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '', @@ -481,11 +489,19 @@ class GetAdminRolesView(APIView): if '000001' not in permissions: return Response({'code': 403, 'msg': '您无权访问此页面'}) - roles = Role.objects.filter(RoleStatus=1).values('RoleName') + from jituan.services.club_rbac import filter_roles_for_request + roles = filter_roles_for_request(request).values('RoleName', 'ClubID') return Response({ 'code': 0, 'data': { - 'roles': [{'role_code': r['RoleName'], 'role_name': r['RoleName']} for r in roles] + 'roles': [ + { + 'role_code': r['RoleName'], + 'role_name': r['RoleName'], + 'club_id': r.get('ClubID') or '', + } + for r in roles + ] } }) diff --git a/config/models.py b/config/models.py index a78504c..a924d81 100644 --- a/config/models.py +++ b/config/models.py @@ -55,7 +55,7 @@ class Tupianpeizhi(QModel): db_table = 'tupianpeizhi' verbose_name = '配置图片' verbose_name_plural = '配置图片' - unique_together = [['club_id', 'ImageType']] + # ImageType 1/2 每俱乐部唯一;3(关注快手) 允许多张 class Szjilu(QModel): diff --git a/config/views.py b/config/views.py index 04189ea..0f86432 100644 --- a/config/views.py +++ b/config/views.py @@ -2700,12 +2700,10 @@ class GuanZhuAListView(APIView): def post(self, request): try: - from jituan.services.club_context import resolve_club_id_from_request + from jituan.services.club_context import resolve_effective_club_id - club_id = resolve_club_id_from_request(request) + club_id = resolve_effective_club_id(request, request.user) qs = Tupianpeizhi.query.filter(ImageType=3, club_id=club_id) - if not qs.exists() and club_id != 'xq': - qs = Tupianpeizhi.query.filter(ImageType=3, club_id='xq') if not qs.exists(): return Response({ 'code': 0, diff --git a/gvsdsdk/models.py b/gvsdsdk/models.py index 8133f06..30f1bb6 100644 --- a/gvsdsdk/models.py +++ b/gvsdsdk/models.py @@ -480,6 +480,10 @@ class Role(QModel): AssignScope = models.SmallIntegerField(default=0, db_column='AssignScope', verbose_name='分配可见范围') AssignScopeUUID = UUIDField(null=True, blank=True, db_column='AssignScopeUUID', verbose_name='分配范围UUID') RoleDesc = models.CharField(max_length=512, default='', db_column='RoleDesc', verbose_name='角色描述') + ClubID = models.CharField( + max_length=16, default='', blank=True, db_column='club_id', + db_index=True, verbose_name='所属俱乐部(空=集团全局角色)', + ) CreateTime = models.DateTimeField(default=_datetime.datetime.utcnow, verbose_name='创建时间') class Meta: diff --git a/jituan/migrations/0003_role_club_id.py b/jituan/migrations/0003_role_club_id.py new file mode 100644 index 0000000..a9620f8 --- /dev/null +++ b/jituan/migrations/0003_role_club_id.py @@ -0,0 +1,40 @@ +# Generated manually — Role.club_id + 放宽 tupianpeizhi 唯一约束(关注快手可多图) + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('jituan', '0002_admin_audit_operator_widen'), + ('config', '0006_display_club_id'), + ] + + operations = [ + migrations.RunSQL( + sql=""" + ALTER TABLE Role ADD COLUMN club_id VARCHAR(16) NOT NULL DEFAULT ''; + CREATE INDEX role_club_id_idx ON Role (club_id); + UPDATE Role SET club_id = '' WHERE RoleName = '管理员'; + UPDATE Role SET club_id = 'xq' WHERE club_id = '' AND RoleName <> '管理员'; + """, + reverse_sql="ALTER TABLE Role DROP COLUMN club_id;", + ), + migrations.RunSQL( + sql=""" + SET @idx_exists := ( + SELECT COUNT(1) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tupianpeizhi' + AND index_name = 'tupianpeizhi_club_id_ImageType_uniq' + ); + SET @sql := IF(@idx_exists > 0, + 'ALTER TABLE tupianpeizhi DROP INDEX tupianpeizhi_club_id_ImageType_uniq', + 'SELECT 1'); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + """, + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/jituan/services/club_rbac.py b/jituan/services/club_rbac.py new file mode 100644 index 0000000..79ee728 --- /dev/null +++ b/jituan/services/club_rbac.py @@ -0,0 +1,62 @@ +"""角色/后台客服按俱乐部隔离(权限码全局共用,角色实体按 club 分开)。""" +from django.db.models import Q + +from gvsdsdk.models import Role, UserRole + +from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_ALL +from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope + + +def resolve_role_club_id(request, explicit=None): + """创建/筛选角色时使用的俱乐部;集团视图可显式指定 target_club_id。""" + if explicit: + return (explicit or '').strip() + scope = resolve_club_scope(request) + if scope == DATA_SCOPE_ALL: + return '' + return resolve_club_id_from_request(request) + + +def filter_roles_for_request(request): + """ + 子公司视图:仅本俱乐部角色。 + 集团汇总视图:全部角色(含各俱乐部 + 全局 club_id 为空)。 + """ + qs = Role.objects.filter(RoleStatus=1) + scope = resolve_club_scope(request) + if scope == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + return qs.filter(ClubID=club_id) + + +def filter_kefu_users_qs(qs, request): + """后台客服账号列表按俱乐部过滤(User.ClubID)。""" + scope = resolve_club_scope(request) + if scope == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + if club_id == CLUB_ID_DEFAULT: + return qs.filter(Q(ClubID=club_id) | Q(ClubID__isnull=True) | Q(ClubID='')) + return qs.filter(ClubID=club_id) + + +def count_role_users_in_scope(role, request, kefu_user_uuids): + """统计角色下客服数(仅计当前数据范围内的客服)。""" + from users.business_models import User + + uuids = list( + UserRole.objects.filter(RoleUUID=role.RoleUUID) + .values_list('UserUUID', flat=True) + ) + if not uuids: + return 0 + qs = User.objects.filter(UserUUID__in=uuids, KefuProfile__isnull=False) + qs = filter_kefu_users_qs(qs, request) + if kefu_user_uuids is not None: + qs = qs.filter(UserUUID__in=kefu_user_uuids) + return qs.count() + + +def role_name_exists(role_name, club_id): + return Role.objects.filter(RoleName=role_name, ClubID=club_id or '').exists() diff --git a/jituan/services/display_config.py b/jituan/services/display_config.py index 8117de3..341673e 100644 --- a/jituan/services/display_config.py +++ b/jituan/services/display_config.py @@ -87,6 +87,35 @@ def get_tupianpeizhi_url(request, image_type): return (row.ImageURL or '').strip() if row else '' +TUPIAN_TYPE_LABELS = { + 1: '打手规则图', + 2: '默认头像', + 3: '关注快手', +} + + +def list_tupianpeizhi_by_type(club_id, image_type): + from config.models import Tupianpeizhi + rows = Tupianpeizhi.query.filter(club_id=club_id, ImageType=image_type).order_by('id') + return [ + {'id': r.id, 'image_url': r.ImageURL or '', 'access_count': r.AccessCount or 0} + for r in rows if r.ImageURL + ] + + +def upsert_tupianpeizhi_single(club_id, image_type, image_url): + """ImageType 1/2 每俱乐部仅一条。""" + from config.models import Tupianpeizhi + row = Tupianpeizhi.query.filter(club_id=club_id, ImageType=image_type).first() + if row: + row.ImageURL = image_url + row.save(update_fields=['ImageURL', 'UpdateTime']) + else: + Tupianpeizhi.query.create( + club_id=club_id, ImageType=image_type, ImageURL=image_url, AccessCount=0, + ) + + def copy_display_config(template_club_id, new_club_id): """从模板俱乐部复制轮播/公告/弹窗/图片配置到新俱乐部。""" from config.models import Gonggao, Lunbo, PopupConfig, PopupImage, PopupPage, Tupianpeizhi diff --git a/jituan/urls.py b/jituan/urls.py index ed333d2..9ea56b3 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -19,7 +19,10 @@ from backend.view import ( UpdateWithdrawSettingsView, ) from users.views import KefuPunishmentListView -from jituan.views_display import ClubGonggaoView, ClubLunboListView, ClubLunboManageView +from jituan.views_display import ( + ClubGonggaoView, ClubLunboListView, ClubLunboManageView, + ClubTupianListView, ClubTupianManageView, +) from jituan.views import ( ClubAdminAssignmentListView, ClubAuditLogListView, @@ -66,6 +69,8 @@ urlpatterns = [ path('houtai/lunbo-list', ClubLunboListView.as_view(), name='jituan_lunbo_list'), path('houtai/lunbo-manage', ClubLunboManageView.as_view(), name='jituan_lunbo_manage'), path('houtai/gonggao', ClubGonggaoView.as_view(), name='jituan_gonggao'), + path('houtai/tupian-list', ClubTupianListView.as_view(), name='jituan_tupian_list'), + path('houtai/tupian-manage', ClubTupianManageView.as_view(), name='jituan_tupian_manage'), path('houtai/hthqtcxx', PopupNoticeListAPIView.as_view(), name='jituan_popup_list'), path('houtai/htxgtcxx', PopupNoticeModifyAPIView.as_view(), name='jituan_popup_modify'), path('houtai/htglyhqcfsltj', FaKuanTongJiView.as_view(), name='jituan_penalty_stats'), diff --git a/jituan/views_display.py b/jituan/views_display.py index 66edb28..3e17985 100644 --- a/jituan/views_display.py +++ b/jituan/views_display.py @@ -9,17 +9,20 @@ from rest_framework.response import Response from rest_framework.views import APIView from backend.utils import verify_kefu_permission -from config.models import Gonggao, Lunbo -from jituan.services.club_context import resolve_club_id_from_request +from jituan.services.club_context import resolve_effective_club_id from jituan.services.display_config import ( forbid_display_write_in_all_scope, get_gonggao_content, get_lunbo_urls, list_response_meta, + list_tupianpeizhi_by_type, LUNBO_PAGE_DASHOU_CENTER, LUNBO_PAGE_OPTIONS, normalize_page_key, + TUPIAN_TYPE_LABELS, + upsert_tupianpeizhi_single, ) +from config.models import Gonggao, Lunbo, Tupianpeizhi from products.views import delete_from_oss, upload_to_oss, validate_image POPUP_PERM = '8080a' @@ -74,7 +77,7 @@ class ClubLunboManageView(APIView): return deny action = (request.data.get('action') or '').strip() - club_id = resolve_club_id_from_request(request) + club_id = resolve_effective_club_id(request, request.user) page_key = normalize_page_key(request.data.get('page_key'), image_type=1) image_type = 2 if page_key == LUNBO_PAGE_DASHOU_CENTER else 1 @@ -130,7 +133,7 @@ class ClubGonggaoView(APIView): return Response({'code': 403, 'msg': '无权限管理公告'}) notice_type = int(request.data.get('notice_type', 1) or 1) - club_id = resolve_club_id_from_request(request) + club_id = resolve_effective_club_id(request, request.user) content = request.data.get('content') if content is None: @@ -155,3 +158,105 @@ class ClubGonggaoView(APIView): row.Content = content row.save(update_fields=['Content', 'UpdateTime']) return Response({'code': 0, 'msg': '保存成功'}) + + +class ClubTupianListView(APIView): + """杂项图片配置列表 POST /jituan/houtai/tupian-list""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理小程序图片'}) + + club_id = resolve_effective_club_id(request, request.user) + data = {} + for t, label in TUPIAN_TYPE_LABELS.items(): + data[str(t)] = { + 'label': label, + 'multi': t == 3, + 'list': list_tupianpeizhi_by_type(club_id, t), + } + return Response({ + 'code': 0, + 'data': { + 'types': data, + **list_response_meta(request), + }, + }) + + +class ClubTupianManageView(APIView): + """杂项图片上传/删除 POST /jituan/houtai/tupian-manage""" + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理小程序图片'}) + + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + + action = (request.data.get('action') or '').strip() + image_type = int(request.data.get('image_type') or 0) + if image_type not in TUPIAN_TYPE_LABELS: + return Response({'code': 400, 'msg': 'image_type 无效'}) + + club_id = resolve_effective_club_id(request, request.user) + + if action == 'delete': + row_id = request.data.get('id') + image_url = (request.data.get('image_url') or '').strip() + qs = Tupianpeizhi.query.filter(club_id=club_id, ImageType=image_type) + if row_id: + row = qs.filter(id=row_id).first() + elif image_url: + row = qs.filter(ImageURL=image_url).first() + else: + return Response({'code': 400, 'msg': '缺少 id 或 image_url'}) + if not row: + return Response({'code': 404, 'msg': '图片不存在'}) + if row.ImageURL: + delete_from_oss(row.ImageURL) + row.delete() + return Response({'code': 0, 'msg': '删除成功'}) + + if action == 'add': + file_obj = request.FILES.get('file') + if not file_obj: + return Response({'code': 400, 'msg': '请上传图片'}) + ok, msg = validate_image(file_obj) + if not ok: + return Response({'code': 400, 'msg': msg}) + ext = os.path.splitext(file_obj.name)[1].lower() or '.jpg' + random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) + folder = 'guanzhu' if image_type == 3 else 'misc' + oss_file_path = f"beijing/{folder}/{club_id}_{random_str}{ext}" + if not upload_to_oss(file_obj, oss_file_path): + return Response({'code': 500, 'msg': '图片上传失败'}) + if image_type in (1, 2): + upsert_tupianpeizhi_single(club_id, image_type, oss_file_path) + else: + Tupianpeizhi.query.create( + club_id=club_id, + ImageType=image_type, + ImageURL=oss_file_path, + AccessCount=0, + ) + return Response({'code': 0, 'msg': '上传成功', 'data': {'image_url': oss_file_path}}) + + return Response({'code': 400, 'msg': 'action 必须为 add 或 delete'})