From 1dfb46c38cc83ccfe106239cc0057182ba981118 Mon Sep 17 00:00:00 2001 From: XingQue Date: Mon, 6 Jul 2026 17:59:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E4=BF=B1=E4=B9=90=E9=83=A8?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=9D=83=E9=99=90=E4=B8=8E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E7=94=A8=E6=88=B7=E4=BF=B1=E4=B9=90=E9=83=A8=E5=88=86?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/utils.py | 20 +++++ backend/views/roles.py | 110 +++++++++++++++++++++++---- jituan/services/admin_assignments.py | 21 +++++ jituan/services/admin_context.py | 53 ++++++++++++- jituan/services/club_rbac.py | 56 +++++++++++++- jituan/services/kefu_menu.py | 9 ++- jituan/views.py | 7 +- 7 files changed, 253 insertions(+), 23 deletions(-) diff --git a/backend/utils.py b/backend/utils.py index c79b6e1..852d5ec 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -166,12 +166,32 @@ def verify_kefu_permission(request, username_from_frontend=None): # ---------- 4. 获取用户的所有权限 ---------- # 使用 gvsdsdk RBAC 表(UserRole → RolePermission → Permission) + from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_SINGLE + from jituan.services.admin_context import resolve_admin_effective_club, get_allowed_club_ids, is_system_super_admin + from jituan.services.club_rbac import filter_role_uuids_for_club_scope + + eff_club = getattr(request, 'club_id', None) or CLUB_ID_DEFAULT + eff_scope = getattr(request, 'club_scope', None) or DATA_SCOPE_SINGLE + allowed_club_ids = None + if not is_system_super_admin(current_user): + eff_club, eff_scope, club_err = resolve_admin_effective_club(current_user, request) + if club_err: + return None, Response({'code': 403, 'msg': club_err}, status=403) + request.club_id = eff_club + request.club_scope = eff_scope + allowed_club_ids = list(get_allowed_club_ids(current_user)) + try: user_uuid = current_user.UserUUID role_uuids = list(SdkUserRole.objects.filter( UserUUID=user_uuid ).values_list('RoleUUID', flat=True)) + if role_uuids and not is_system_super_admin(current_user): + role_uuids = filter_role_uuids_for_club_scope( + role_uuids, eff_club, eff_scope, allowed_club_ids, + ) + if role_uuids: perm_uuids = list(SdkRolePermission.objects.filter( RoleUUID__in=role_uuids diff --git a/backend/views/roles.py b/backend/views/roles.py index 38a75a9..78bb765 100644 --- a/backend/views/roles.py +++ b/backend/views/roles.py @@ -420,8 +420,10 @@ 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) + from jituan.models import AdminAssignment, Club + from jituan.constants import ADMIN_ROLE_LABELS + from jituan.services.club_rbac import filter_kefu_users_by_admin_scope + users = filter_kefu_users_by_admin_scope(users, request) if phone: users = users.filter(Phone__icontains=phone) @@ -452,17 +454,37 @@ class GetAdminUserListView(APIView): .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 + _role_info_by_uuid = { + r.RoleUUID: {'role_name': r.RoleName, 'club_id': getattr(r, 'ClubID', '') or ''} 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} - ) + info = _role_info_by_uuid.get(role_uuid) + if info is not None: + _roles_by_user.setdefault(user_uuid, []).append({ + 'role_code': info['role_name'], + 'role_name': info['role_name'], + 'club_id': info['club_id'], + }) + + _page_yonghuids = [u.UserUID for u in _page_users] + _club_name_map = {c.club_id: c.name for c in Club.query.filter(status=1)} + _assignments_by_user = {} + if _page_yonghuids: + for a in AdminAssignment.query.filter(yonghuid__in=_page_yonghuids, status=1): + if a.club_id: + club_label = _club_name_map.get(a.club_id, a.club_id) + else: + club_label = '集团(全部子公司)' + _assignments_by_user.setdefault(a.yonghuid, []).append({ + 'id': a.id, + 'club_id': a.club_id, + 'club_label': club_label, + 'role_code': a.role_code, + 'role_name': ADMIN_ROLE_LABELS.get(a.role_code, a.role_code), + 'is_primary': a.is_primary, + }) data_list = [] for user in _page_users: @@ -473,6 +495,7 @@ class GetAdminUserListView(APIView): 'nicheng': user.KefuProfile.nicheng, 'club_id': getattr(user, 'ClubID', '') or '', 'roles': roles_data, + 'assignments': _assignments_by_user.get(user.UserUID, []), 'status': user.KefuProfile.zhuangtai, 'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '', 'update_time': user.KefuProfile.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user.KefuProfile, 'UpdateTime', None) else '', @@ -512,7 +535,11 @@ class GetAdminRolesView(APIView): return Response({'code': 403, 'msg': '您无权访问此页面'}) from jituan.services.club_rbac import filter_roles_for_request - roles = filter_roles_for_request(request).values('RoleName', 'ClubID') + all_clubs = request.data.get('all_clubs') in (True, 1, '1', 'true', 'yes') + if all_clubs: + roles = Role.objects.filter(RoleStatus=1).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID') + else: + roles = filter_roles_for_request(request).values('RoleName', 'ClubID') return Response({ 'code': 0, 'data': { @@ -660,10 +687,13 @@ class ModifyAdminUserView(APIView): if not role_codes: return Response({'code': 400, 'msg': '请选择角色'}) from jituan.services.club_rbac import resolve_role_by_name - club_id = getattr(target_user, 'ClubID', '') or '' + from jituan.services.club_context import resolve_club_id_from_request + bind_club_id = (request.data.get('target_club_id') or '').strip() + if not bind_club_id: + bind_club_id = resolve_club_id_from_request(request) added, missing = [], [] for rc in role_codes: - role = resolve_role_by_name(rc, club_id) + role = resolve_role_by_name(rc, bind_club_id) if not role: missing.append(rc) continue @@ -696,13 +726,52 @@ class ModifyAdminUserView(APIView): if not role_code: return Response({'code': 400, 'msg': '缺少角色编码'}) from jituan.services.club_rbac import resolve_role_by_name - club_id = getattr(target_user, 'ClubID', '') or '' - role = resolve_role_by_name(role_code, club_id) + from jituan.services.club_context import resolve_club_id_from_request + bind_club_id = (request.data.get('target_club_id') or '').strip() + if not bind_club_id: + bind_club_id = resolve_club_id_from_request(request) + role = resolve_role_by_name(role_code, bind_club_id) if not role: - return Response({'code': 404, 'msg': '角色不存在或与用户俱乐部不匹配'}) + return Response({'code': 404, 'msg': '角色不存在或与所选俱乐部不匹配'}) UserRole.objects.filter(UserUUID=target_user.UserUUID, RoleUUID=role.RoleUUID).delete() return Response({'code': 0, 'msg': '角色移除成功'}) + elif action == 'add_club_assignment': + club_id = (request.data.get('club_id') or '').strip() + if not club_id: + return Response({'code': 400, 'msg': '请选择俱乐部'}) + from jituan.services.admin_assignments import create_or_reactivate_assignment + from jituan.constants import DATA_SCOPE_SINGLE + role_code = (request.data.get('role_code') or 'CLUB_ADMIN').strip() + is_primary = bool(request.data.get('is_primary', False)) + _, err = create_or_reactivate_assignment( + yonghuid=target_user.UserUID, + club_id=club_id, + role_code=role_code, + data_scope=DATA_SCOPE_SINGLE, + is_primary=is_primary, + granted_by=request.user.UserUID, + ) + if err: + return Response({'code': 400, 'msg': err}) + return Response({'code': 0, 'msg': '俱乐部任职已添加'}) + + elif action == 'remove_club_assignment': + assignment_id = request.data.get('assignment_id') + if not assignment_id: + return Response({'code': 400, 'msg': '缺少任职 id'}) + from jituan.services.admin_assignments import deactivate_assignment + from jituan.models import AdminAssignment + row = AdminAssignment.query.filter( + id=assignment_id, yonghuid=target_user.UserUID, status=1, + ).first() + if not row: + return Response({'code': 404, 'msg': '任职记录不存在'}) + ok, err = deactivate_assignment(assignment_id) + if not ok: + return Response({'code': 404, 'msg': err or '停用失败'}) + return Response({'code': 0, 'msg': '俱乐部任职已移除'}) + else: return Response({'code': 400, 'msg': '无效的action'}) @@ -816,6 +885,17 @@ class AddAdminUserView(APIView): }, ) + from jituan.services.admin_assignments import create_or_reactivate_assignment + from jituan.constants import DATA_SCOPE_SINGLE + create_or_reactivate_assignment( + yonghuid=yonghuid, + club_id=target_club_id, + role_code='CLUB_ADMIN', + data_scope=DATA_SCOPE_SINGLE, + is_primary=True, + granted_by=request.user.UserUID, + ) + return Response({'code': 0, 'msg': '添加成功'}) diff --git a/jituan/services/admin_assignments.py b/jituan/services/admin_assignments.py index cd21905..6323c37 100644 --- a/jituan/services/admin_assignments.py +++ b/jituan/services/admin_assignments.py @@ -22,6 +22,22 @@ def find_active_assignment(yonghuid, club_id, role_code): return _club_filter(qs, club_id).first() +def clear_other_primary_assignments(yonghuid, except_id=None): + qs = AdminAssignment.query.filter(yonghuid=yonghuid, status=1, is_primary=True) + if except_id: + qs = qs.exclude(id=except_id) + ids = list(qs.values_list('id', flat=True)) + if ids: + AdminAssignment.objects.filter(pk__in=ids).update( + is_primary=False, + UpdateTime=timezone.now(), + ) + + +def _clear_other_primary(yonghuid, except_id=None): + clear_other_primary_assignments(yonghuid, except_id=except_id) + + def create_or_reactivate_assignment( yonghuid, club_id, @@ -34,8 +50,13 @@ def create_or_reactivate_assignment( if active: return None, '该任职记录已存在且有效' + if is_primary: + _clear_other_primary(yonghuid) + inactive = find_assignment(yonghuid, club_id, role_code) if inactive and inactive.status != 1: + if is_primary: + _clear_other_primary(yonghuid) AdminAssignment.objects.filter(pk=inactive.id).update( status=1, data_scope=data_scope, diff --git a/jituan/services/admin_context.py b/jituan/services/admin_context.py index 4c1f872..3d3bd01 100644 --- a/jituan/services/admin_context.py +++ b/jituan/services/admin_context.py @@ -14,6 +14,50 @@ GROUP_MANAGE_ROLES = frozenset({ }) +def get_allowed_club_ids(user): + """用户可切换/访问的子公司 club_id 集合(不含集团汇总)。""" + if is_system_super_admin(user): + return {c.club_id for c in Club.query.filter(status=1)} + assignments = AdminAssignment.query.filter(yonghuid=user.UserUID, status=1) + has_group = assignments.filter( + club_id__isnull=True + ).exists() or assignments.filter(data_scope=DATA_SCOPE_ALL).exists() + if has_group: + return {c.club_id for c in Club.query.filter(status=1)} + return { + a.club_id for a in assignments if a.club_id + } + + +def resolve_admin_effective_club(user, request): + """ + 校验后台用户请求的俱乐部/范围,返回 (club_id, scope, error_msg)。 + 无权限访问请求的俱乐部时,回退到主任职俱乐部(避免 localStorage 脏数据串俱乐部)。 + """ + from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope + + requested_club = resolve_club_id_from_request(request) + requested_scope = resolve_club_scope(request) + + if is_system_super_admin(user): + return requested_club, requested_scope, None + + ctx = build_admin_club_context(user) + allowed = get_allowed_club_ids(user) + + if requested_scope == DATA_SCOPE_ALL: + if not ctx.get('is_group_admin'): + primary = ctx.get('club_id') or CLUB_ID_DEFAULT + return primary, DATA_SCOPE_SINGLE, None + return ctx.get('club_id') or CLUB_ID_DEFAULT, DATA_SCOPE_ALL, None + + if requested_club not in allowed: + primary = ctx.get('club_id') or CLUB_ID_DEFAULT + return primary, DATA_SCOPE_SINGLE, None + + return requested_club, DATA_SCOPE_SINGLE, None + + def build_admin_club_context(user): """ 根据 admin_assignment + 超管推断后台登录后的俱乐部上下文。 @@ -129,16 +173,21 @@ def can_manage_admin_assignments(user, permissions=None): def _pack(scope, club_id, is_group_admin, assignments, clubs, can_switch_club, role_code, role_name): + allowed_club_ids = sorted({ + c['club_id'] for c in clubs if c.get('club_id') + }) return { 'scope': scope, 'club_id': club_id, 'is_group_admin': is_group_admin, 'role_code': role_code, 'role_name': role_name, + 'allowed_club_ids': allowed_club_ids, 'perm_source': 'gvsdsdk', 'perm_note': ( - '【功能权限】在「角色管理」绑 gvsdsdk 角色(如 caiwu、dingdan、000001);' - '【数据范围】在「数据范围配置」维护任职(能看/管哪个俱乐部)。两套独立。' + '【功能权限】在「角色管理」为各俱乐部创建角色,在「管理员用户」绑定;' + '切换俱乐部后菜单按该俱乐部角色生效。' + '【数据范围】在本页添加任职,可分配多个俱乐部,无需集团权限即可切换。' ), 'assignments': [ { diff --git a/jituan/services/club_rbac.py b/jituan/services/club_rbac.py index e1dea07..fe5bf12 100644 --- a/jituan/services/club_rbac.py +++ b/jituan/services/club_rbac.py @@ -30,8 +30,32 @@ def filter_roles_for_request(request): return qs.filter(ClubID=club_id) +def filter_kefu_users_by_admin_scope(qs, request): + """ + 后台用户列表:按任职俱乐部过滤,而非仅 User.ClubID。 + 同一账号有多俱乐部任职时,在任一已分配俱乐部视图下均可见。 + """ + from jituan.models import AdminAssignment + + scope = resolve_club_scope(request) + if scope == DATA_SCOPE_ALL: + return qs + club_id = resolve_club_id_from_request(request) + assigned_uids = list( + AdminAssignment.query.filter(status=1, club_id=club_id).values_list('yonghuid', flat=True) + ) + if club_id == CLUB_ID_DEFAULT: + return qs.filter( + Q(UserUID__in=assigned_uids) + | Q(ClubID=club_id) + | Q(ClubID__isnull=True) + | Q(ClubID='') + ) + return qs.filter(Q(UserUID__in=assigned_uids) | Q(ClubID=club_id)) + + def filter_kefu_users_qs(qs, request): - """后台客服账号列表按俱乐部过滤(User.ClubID)。""" + """后台客服账号列表按 User.ClubID 过滤(角色统计等场景)。""" scope = resolve_club_scope(request) if scope == DATA_SCOPE_ALL: return qs @@ -108,19 +132,43 @@ def resolve_roles_for_binding(role_names, club_id=None): return resolved, missing -def user_bound_role_names(user): +def filter_role_uuids_for_club_scope(role_uuids, club_id, scope, allowed_club_ids=None): + """ + 按当前俱乐部上下文过滤 UserRole 绑定的 RoleUUID。 + SINGLE_CLUB:本俱乐部角色 + 全局角色(ClubID 为空)。 + ALL_CLUBS:全局角色 + 各已授权俱乐部的角色(并集)。 + """ + if not role_uuids: + return [] + qs = Role.objects.filter(RoleUUID__in=role_uuids, RoleStatus=1) + if scope == DATA_SCOPE_ALL: + club_ids = [c for c in (allowed_club_ids or []) if c] + if club_ids: + return list( + qs.filter(Q(ClubID='') | Q(ClubID__in=club_ids)).values_list('RoleUUID', flat=True) + ) + return list(qs.filter(ClubID='').values_list('RoleUUID', flat=True)) + cid = (club_id or CLUB_ID_DEFAULT).strip() + return list(qs.filter(Q(ClubID=cid) | Q(ClubID='')).values_list('RoleUUID', flat=True)) + + +def user_bound_role_names(user, club_id=None, scope=None, allowed_club_ids=None): from backend.utils import _legacy_backend_role_permissions uuids = list( UserRole.objects.filter(UserUUID=user.UserUUID).values_list('RoleUUID', flat=True) ) + if club_id is not None and scope is not None: + uuids = filter_role_uuids_for_club_scope(uuids, club_id, scope, allowed_club_ids) names = [] if uuids: names = list( Role.objects.filter(RoleUUID__in=uuids, RoleStatus=1).values_list('RoleName', flat=True) ) - _, legacy_names = _legacy_backend_role_permissions(getattr(user, 'Phone', '')) - return sorted(set(names + legacy_names)) + if club_id is None: + _, legacy_names = _legacy_backend_role_permissions(getattr(user, 'Phone', '')) + return sorted(set(names + legacy_names)) + return sorted(set(names)) def resolve_kefu_admin_user(yonghuid=None, phone=None): diff --git a/jituan/services/kefu_menu.py b/jituan/services/kefu_menu.py index 79999da..a048cc7 100644 --- a/jituan/services/kefu_menu.py +++ b/jituan/services/kefu_menu.py @@ -7,8 +7,10 @@ from jituan.models import KefuMenuPage from jituan.services.admin_context import ( build_admin_club_context, can_manage_admin_assignments, + get_allowed_club_ids, is_system_super_admin, ) +from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope from jituan.services.club_rbac import user_bound_role_names from jituan.services.kefu_menu_definitions import KEFU_MENU_ROW_DEFS @@ -129,14 +131,19 @@ def build_menu_access_payload(request, username=None): visible_ids.append(page.page_id) club_ctx = build_admin_club_context(request.user) + club_id = resolve_club_id_from_request(request) + scope = resolve_club_scope(request) + allowed = list(get_allowed_club_ids(request.user)) path_by_id = {p.page_id: p.path for p in pages if p.path} visible_paths = [path_by_id[pid] for pid in visible_ids if pid in path_by_id] return { 'visible_page_ids': sorted(set(visible_ids)), 'visible_paths': visible_paths, 'permission_codes': _permission_codes_list(permissions), - 'role_names': user_bound_role_names(request.user), + 'role_names': user_bound_role_names(request.user, club_id, scope, allowed), 'yonghuid': getattr(request.user, 'UserUID', '') or '', + 'club_id': club_id, + 'club_scope': scope, 'can_switch_club': bool(club_ctx.get('can_switch_club')), 'is_group_admin': bool(club_ctx.get('is_group_admin')), 'menu_ready': True, diff --git a/jituan/views.py b/jituan/views.py index 7f772f4..3d094ff 100644 --- a/jituan/views.py +++ b/jituan/views.py @@ -154,8 +154,10 @@ class ClubKefuLoginView(APIView): token = str(refresh.access_token) nicheng = target_kefu.nicheng if target_kefu else (target_user.UserName or target_user.Phone) club_context = build_admin_club_context(target_user) - # 登录请求尚未带 JWT,需用本次登录用户计算菜单 + # 登录请求尚未带 JWT,需用本次登录用户 + 主任职俱乐部计算菜单 request.user = target_user + request.club_id = club_context.get('club_id') or CLUB_ID_DEFAULT + request.club_scope = club_context.get('scope') or DATA_SCOPE_SINGLE menu_access, _menu_err = build_menu_access_payload(request, phone) return Response({ @@ -337,6 +339,9 @@ class ClubAdminAssignmentListView(APIView): row.data_scope = request.data.get('data_scope') or row.data_scope if 'is_primary' in request.data: row.is_primary = bool(request.data.get('is_primary')) + if row.is_primary: + from jituan.services.admin_assignments import clear_other_primary_assignments + clear_other_primary_assignments(row.yonghuid, except_id=row.id) if 'club_id' in request.data: cid = request.data.get('club_id') row.club_id = None if cid is None or str(cid).strip() == '' else str(cid).strip()