From c3a0143effb151a2ada36e8482f62947a7f879ba Mon Sep 17 00:00:00 2001 From: XingQue Date: Wed, 29 Jul 2026 06:53:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=9B=86=E5=9B=A2=E4=BB=BB=E8=81=8C?= =?UTF-8?q?=E7=A1=AC=E5=88=A0=E9=99=A4=EF=BC=8C=E6=9D=9C=E7=BB=9D=20NULL?= =?UTF-8?q?=20=E9=87=8D=E5=A4=8D=E5=86=92=E5=87=BA=E5=A4=9A=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- jituan/management/commands/nuke_kefu_admin.py | 43 ++-- jituan/services/admin_assignments.py | 192 +++++++++--------- jituan/views.py | 13 +- 3 files changed, 122 insertions(+), 126 deletions(-) diff --git a/jituan/management/commands/nuke_kefu_admin.py b/jituan/management/commands/nuke_kefu_admin.py index 879250d..1c0fda6 100644 --- a/jituan/management/commands/nuke_kefu_admin.py +++ b/jituan/management/commands/nuke_kefu_admin.py @@ -1,6 +1,6 @@ -"""紧急封杀后台账号:摘超管、停全部任职、禁用客服、改密、清角色。 +"""紧急封杀后台账号:硬删任职、摘超管、禁用客服、改密、清角色。 -用法(服务器): +用法: python manage.py nuke_kefu_admin 13837714110 """ from django.core.management.base import BaseCommand @@ -8,14 +8,14 @@ from django.utils import timezone class Command(BaseCommand): - help = '紧急封杀指定手机号的后台权限(非交互)' + help = '紧急封杀指定手机号的后台权限(硬删除任职)' def add_arguments(self, parser): parser.add_argument('phone', type=str, help='要封杀的手机号') parser.add_argument( '--also-whitelist', action='store_true', - help='连代码白名单手机号也允许封(默认拒绝封白名单)', + help='连代码白名单手机号也允许封', ) def handle(self, *args, **options): @@ -27,40 +27,33 @@ class Command(BaseCommand): from jituan.constants import SUPER_ADMIN_PHONES if phone in SUPER_ADMIN_PHONES and not options['also_whitelist']: self.stderr.write( - f'{phone} 在 SUPER_ADMIN_PHONES 白名单,拒绝封杀。' - f'确要封再加 --also-whitelist' + f'{phone} 在白名单,拒绝。确要封加 --also-whitelist' ) return from users.business_models import User from jituan.models import AdminAssignment + from gvsdsdk.models import UserRole as SdkUserRole + import bcrypt users = list(User.objects.filter(Phone=phone)) if not users: - self.stderr.write(f'未找到手机号 {phone}') + self.stderr.write(f'未找到 {phone}') return - import bcrypt - from gvsdsdk.models import UserRole as SdkUserRole - new_pwd = f'LOCKED_{phone}_REVOKED' for u in users: - self.stdout.write( - f'处理 UserUID={u.UserUID} IsSuperuser={u.IsSuperuser} IsStaff={u.IsStaff}' - ) + self.stdout.write(f'HIT uid={u.UserUID} super={u.IsSuperuser}') u.IsSuperuser = False u.IsStaff = False u.SetPassword(new_pwd) u.save() - n_asg = AdminAssignment.objects.filter(yonghuid=u.UserUID).update( - status=0, - UpdateTime=timezone.now(), - ) - self.stdout.write(f' 任职停用行数={n_asg}') + n, _ = AdminAssignment.objects.filter(yonghuid=u.UserUID).delete() + self.stdout.write(f' 硬删任职={n}') n_role = SdkUserRole.objects.filter(UserUUID=u.UserUUID).delete() - self.stdout.write(f' 功能角色删除={n_role}') + self.stdout.write(f' 删功能角色={n_role}') try: kefu = u.KefuProfile @@ -69,11 +62,15 @@ class Command(BaseCommand): b'LOCKED_REVOKED', bcrypt.gensalt(rounds=12) ).decode('utf-8') kefu.save(update_fields=['zhuangtai', 'erjimima', 'UpdateTime']) - self.stdout.write(' 客服状态已禁用 + 二级密码已废') + self.stdout.write(' 客服已禁用') except Exception as e: - self.stdout.write(f' 无客服扩展或跳过: {e}') + self.stdout.write(f' 无客服扩展: {e}') + + # 顺手清全库 NULL 俱乐部垃圾重复 + from jituan.services.admin_assignments import dedupe_active_assignments + d = dedupe_active_assignments() + self.stdout.write(f'全库去重硬删重复={d}') self.stdout.write(self.style.SUCCESS( - f'已封杀 {phone}(共 {len(users)} 个账号)。对方旧 token 需重新登录才会彻底失效;' - f'主密码已改为 LOCKED_{phone}_REVOKED,勿外传,事后请再改。' + f'已封杀 {phone}。密码={new_pwd}。请 restart gunicorn。' )) diff --git a/jituan/services/admin_assignments.py b/jituan/services/admin_assignments.py index c4709bd..3f1b8aa 100644 --- a/jituan/services/admin_assignments.py +++ b/jituan/services/admin_assignments.py @@ -1,4 +1,8 @@ -"""admin_assignment 任职 CRUD(修复停用不生效、重复插入、IsSuperuser 绕过)。""" +"""admin_assignment 任职 CRUD。 + +集团任职用 club_id=''(禁止 NULL):MySQL 对 NULL 唯一约束失效,会无限复制, +导致「停一条冒出五六条」。停用改为硬删除。 +""" from django.db.models import Q from django.utils import timezone @@ -14,10 +18,19 @@ GROUP_ROLE_CODES = frozenset({ }) +def normalize_club_id(club_id): + """集团级统一成空串,禁止 NULL(避免唯一索引失效)。""" + if club_id is None: + return '' + s = str(club_id).strip() + return s + + def _club_filter(qs, club_id): - if club_id is None or club_id == '': + cid = normalize_club_id(club_id) + if not cid: return qs.filter(Q(club_id__isnull=True) | Q(club_id='')) - return qs.filter(club_id=club_id) + return qs.filter(club_id=cid) def find_assignment(yonghuid, club_id, role_code): @@ -46,56 +59,7 @@ 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, - role_code, - data_scope, - is_primary, - granted_by, -): - active = find_active_assignment(yonghuid, club_id, role_code) - if active: - return None, '该任职记录已存在且有效' - - if is_primary: - _clear_other_primary(yonghuid) - - inactive_qs = _club_filter( - AdminAssignment.objects.filter(yonghuid=yonghuid, role_code=role_code).exclude(status=1), - club_id, - ).order_by('-id') - inactive = inactive_qs.first() - if inactive: - if is_primary: - _clear_other_primary(yonghuid) - inactive_qs.exclude(pk=inactive.pk).update(status=0, UpdateTime=timezone.now()) - AdminAssignment.objects.filter(pk=inactive.id).update( - status=1, - data_scope=data_scope, - is_primary=is_primary, - granted_by=granted_by, - UpdateTime=timezone.now(), - ) - return inactive.id, None - - row = AdminAssignment.query.create( - yonghuid=yonghuid, - club_id=club_id, - role_code=role_code, - data_scope=data_scope, - is_primary=is_primary, - granted_by=granted_by, - status=1, - ) - return row.id, None - - def _strip_system_super_if_needed(yonghuid): - """ - 停用集团权限后:非白名单账号必须摘掉 IsSuperuser。 - 否则 build_admin_club_context 仍把 is_group_admin=True,停用等于没停。 - """ yonghuid = (yonghuid or '').strip() if not yonghuid: return @@ -108,31 +72,62 @@ def _strip_system_super_if_needed(yonghuid): return if (user.Phone or '') in SUPER_ADMIN_PHONES: return - if user.IsSuperuser: + if user.IsSuperuser or user.IsStaff: user.IsSuperuser = False - user.save(update_fields=['IsSuperuser']) + user.IsStaff = False + user.save(update_fields=['IsSuperuser', 'IsStaff']) -def deactivate_all_group_assignments_for_user(yonghuid): - """停用该用户全部集团级任职(空俱乐部 / ALL_CLUBS / GROUP_* 角色)。""" - yonghuid = (yonghuid or '').strip() - if not yonghuid: - return 0 - qs = AdminAssignment.objects.filter(yonghuid=yonghuid, status=1).filter( - Q(club_id__isnull=True) - | Q(club_id='') - | Q(data_scope=DATA_SCOPE_ALL) - | Q(role_code__in=list(GROUP_ROLE_CODES)) +def create_or_reactivate_assignment( + yonghuid, + club_id, + role_code, + data_scope, + is_primary, + granted_by, +): + club_id = normalize_club_id(club_id) + if not club_id: + data_scope = DATA_SCOPE_ALL + + # 创建前先清同键垃圾(含 NULL/空串/status=0 重复) + junk = _club_filter( + AdminAssignment.objects.filter(yonghuid=yonghuid, role_code=role_code), + club_id, + ) + active = junk.filter(status=1).order_by('-id') + if active.exists(): + keep = active.first() + junk.exclude(pk=keep.pk).delete() + return None, '该任职记录已存在且有效' + + junk.delete() + + if is_primary: + _clear_other_primary(yonghuid) + + row = AdminAssignment.objects.create( + yonghuid=yonghuid, + club_id=club_id, + role_code=role_code, + data_scope=data_scope, + is_primary=is_primary, + granted_by=granted_by or '', + status=1, + ) + return row.id, None + + +def _is_group_level(row): + return ( + not normalize_club_id(row.club_id) + or (row.data_scope or '') == DATA_SCOPE_ALL + or (row.role_code or '') in GROUP_ROLE_CODES ) - return qs.update(status=0, UpdateTime=timezone.now()) def deactivate_assignment(assignment_id): - """ - 停用任职。 - - 同人同角色同俱乐部(含 NULL 重复行)全部停 - - 若是集团级任职:再停掉该人全部集团任职,并摘掉非白名单 IsSuperuser - """ + """停用=硬删除。集团级则删光该人全部集团任职并摘 IsSuperuser。""" try: pk = int(assignment_id) except (TypeError, ValueError): @@ -140,45 +135,49 @@ def deactivate_assignment(assignment_id): row = AdminAssignment.objects.filter(pk=pk).first() if not row: - return False, '任职记录不存在' + # 可能已被删,幂等成功 + return True, None - qs = AdminAssignment.objects.filter( - yonghuid=row.yonghuid, - role_code=row.role_code, - status=1, - ) - qs = _club_filter(qs, row.club_id) - qs.update(status=0, UpdateTime=timezone.now()) + yonghuid = row.yonghuid + role_code = row.role_code + club_id = row.club_id + group_level = _is_group_level(row) - is_group_level = ( - not (row.club_id or '').strip() - or (row.data_scope or '') == DATA_SCOPE_ALL - or (row.role_code or '') in GROUP_ROLE_CODES - ) - if is_group_level: - deactivate_all_group_assignments_for_user(row.yonghuid) - _strip_system_super_if_needed(row.yonghuid) + # 同人同角色同俱乐部(含 NULL/'')全部硬删 + _club_filter( + AdminAssignment.objects.filter(yonghuid=yonghuid, role_code=role_code), + club_id, + ).delete() + + if group_level: + AdminAssignment.objects.filter(yonghuid=yonghuid).filter( + Q(club_id__isnull=True) + | Q(club_id='') + | Q(data_scope=DATA_SCOPE_ALL) + | Q(role_code__in=list(GROUP_ROLE_CODES)) + ).delete() + _strip_system_super_if_needed(yonghuid) return True, None def deactivate_all_assignments_for_user(yonghuid): - """账号禁用时:停用该用户全部有效任职,并摘掉非白名单 IsSuperuser。""" + """账号禁用:硬删全部任职 + 摘超管。""" yonghuid = (yonghuid or '').strip() if not yonghuid: return 0 - n = AdminAssignment.objects.filter(yonghuid=yonghuid, status=1).update( - status=0, - UpdateTime=timezone.now(), - ) + deleted, _ = AdminAssignment.objects.filter(yonghuid=yonghuid).delete() _strip_system_super_if_needed(yonghuid) - return n + return deleted def dedupe_active_assignments(): - """停用重复的有效任职,每组 (yonghuid, club_id, role_code) 只保留一条。""" + """硬删重复任职;并把 club_id=NULL 归一成 ''。""" + # 归一 NULL -> '' + AdminAssignment.objects.filter(club_id__isnull=True).update(club_id='') + rows = list( - AdminAssignment.query.filter(status=1).order_by('yonghuid', 'club_id', 'role_code', 'id') + AdminAssignment.objects.filter(status=1).order_by('yonghuid', 'club_id', 'role_code', 'id') ) seen = {} dup_ids = [] @@ -189,8 +188,5 @@ def dedupe_active_assignments(): else: seen[key] = row.id if dup_ids: - AdminAssignment.objects.filter(pk__in=dup_ids).update( - status=0, - UpdateTime=timezone.now(), - ) + AdminAssignment.objects.filter(pk__in=dup_ids).delete() return len(dup_ids) diff --git a/jituan/views.py b/jituan/views.py index 090b362..e3acbf7 100644 --- a/jituan/views.py +++ b/jituan/views.py @@ -382,13 +382,13 @@ class ClubAdminAssignmentListView(APIView): if not yonghuid: return Response({'code': 400, 'msg': '需要 yonghuid 或 target_phone'}) club_id = request.data.get('club_id') - if club_id is not None and str(club_id).strip() == '': - club_id = None - elif club_id is not None: + if club_id is None or str(club_id).strip() == '': + club_id = '' + else: club_id = str(club_id).strip() role_code = (request.data.get('role_code') or 'CLUB_ADMIN').strip() data_scope = (request.data.get('data_scope') or DATA_SCOPE_SINGLE).strip() - if club_id is None: + if not club_id: data_scope = DATA_SCOPE_ALL is_primary = bool(request.data.get('is_primary', False)) _, err = create_or_reactivate_assignment( @@ -401,6 +401,8 @@ class ClubAdminAssignmentListView(APIView): ) if err: return Response({'code': 400, 'msg': err}) + from jituan.services.admin_assignments import dedupe_active_assignments + dedupe_active_assignments() return Response({'code': 0, 'msg': '任职已添加'}) if action == 'delete': @@ -410,8 +412,9 @@ class ClubAdminAssignmentListView(APIView): ok, err = deactivate_assignment(assignment_id) if not ok: return Response({'code': 404, 'msg': err or '停用失败'}) + from jituan.services.admin_assignments import dedupe_active_assignments dedupe_active_assignments() - return Response({'code': 0, 'msg': '任职已停用'}) + return Response({'code': 0, 'msg': '任职已删除'}) if action == 'update': assignment_id = request.data.get('id')