diff --git a/gvsds/apps/gerp/apps/talk/manager.py b/gvsds/apps/gerp/apps/talk/manager.py index cd152e31..7cca07cb 100644 --- a/gvsds/apps/gerp/apps/talk/manager.py +++ b/gvsds/apps/gerp/apps/talk/manager.py @@ -30,6 +30,10 @@ STATUS_DISSOLVED = 0 # 成员角色 ROLE_MEMBER = 0 ROLE_OWNER = 1 +ROLE_ADMIN = 2 + +# 单群管理员上限(群主不计入) +MAX_GROUP_ADMINS = 10 # 消息类型 MSG_TEXT = 0 @@ -363,11 +367,11 @@ class TalkManager: convs = list(TalkConversation.objects.filter( ConversationUUID__in=conv_ids, Status=STATUS_NORMAL, ).order_by('-LastMessageTime')) - # 批量取成员与用户名,避免 N+1 + # 批量取成员与用户名,避免 N+1(成员同时带上角色,供前端展示群主/管理员与权限判定) members_map = defaultdict(list) all_uuids = set() for m in TalkMember.objects.filter(ConversationUUID__in=[c.ConversationUUID for c in convs]): - members_map[_ToStr(m.ConversationUUID)].append(m.UserUUID) + members_map[_ToStr(m.ConversationUUID)].append((m.UserUUID, m.MemberRole)) all_uuids.add(_ToBytes(m.UserUUID)) name_map = TalkManager._UserNames(all_uuids) records = [ @@ -539,18 +543,82 @@ class TalkManager: return True, '', {'ConversationUUID': _ToStr(conv.ConversationUUID)} @staticmethod - def RenameConversation(ConversationUUID, OperatorUUID, Title): - """重命名会话(仅成员可操作)""" + def RenameConversation(ConversationUUID, OperatorUUID, Title, IsTalkAdmin=False): + """重命名会话 + + 群聊仅「群主 / 群管理员 / Talk 管理员」可改;单聊保持仅成员可改(无群主概念)。 + """ conv = TalkManager.GetConversation(ConversationUUID) operator = _ToBytes(OperatorUUID) if conv is None or conv.Status != STATUS_NORMAL: return False, '会话不存在或已解散', None - if not TalkManager.IsMember(conv.ConversationUUID, operator): + member = TalkManager.GetMember(conv.ConversationUUID, operator) + if member is None: return False, '您不是该会话成员', None + if conv.ConversationType == TYPE_GROUP and not IsTalkAdmin: + if not TalkManager._IsGroupManager(conv, member, operator): + return False, '仅群主或管理员可修改群名', None conv.Title = (Title or '').strip()[:128] conv.save(update_fields=['Title']) return True, '', {'ConversationUUID': _ToStr(conv.ConversationUUID), 'Title': conv.Title} + @staticmethod + def SetMemberRole(ConversationUUID, OperatorUUID, TargetUUID, Role, IsTalkAdmin=False): + """设置群成员角色(0=成员,2=管理员) + + 仅「群主 / Talk 管理员」可操作;群主与自身角色不可通过本接口变更; + 管理员数量上限 MAX_GROUP_ADMINS(群主不计入)。 + + Returns: + (ok: bool, msg: str, data: dict|None) + """ + conv = TalkManager.GetConversation(ConversationUUID) + operator = _ToBytes(OperatorUUID) + target = _ToBytes(TargetUUID) + if conv is None or conv.Status != STATUS_NORMAL: + return False, '会话不存在或已解散', None + if conv.ConversationType != TYPE_GROUP: + return False, '仅群聊可设置管理员', None + operator_member = TalkManager.GetMember(conv.ConversationUUID, operator) + if operator_member is None: + return False, '您不是该会话成员', None + is_owner = operator_member.MemberRole == ROLE_OWNER \ + or _ToBytes(conv.CreatorUUID) == operator + if not is_owner and not IsTalkAdmin: + return False, '仅群主或 Talk 管理员可设置管理员', None + if target is None: + return False, '缺少目标成员', None + target_member = TalkManager.GetMember(conv.ConversationUUID, target) + if target_member is None: + return False, '该用户不是本群成员', None + if target_member.MemberRole == ROLE_OWNER: + return False, '不能变更群主角色', None + try: + role = int(Role) + except (TypeError, ValueError): + return False, '角色参数无效', None + if role not in (ROLE_MEMBER, ROLE_ADMIN): + return False, '角色参数无效', None + if role == ROLE_ADMIN and target_member.MemberRole != ROLE_ADMIN: + admin_count = TalkMember.objects.filter( + ConversationUUID=conv.ConversationUUID, MemberRole=ROLE_ADMIN).count() + if admin_count >= MAX_GROUP_ADMINS: + return False, f'每个群最多设置 {MAX_GROUP_ADMINS} 个管理员', None + if target_member.MemberRole != role: + target_member.MemberRole = role + target_member.save(update_fields=['MemberRole']) + admin_count = TalkMember.objects.filter( + ConversationUUID=conv.ConversationUUID, MemberRole=ROLE_ADMIN).count() + name_map = TalkManager._UserNames({target}) + return True, '', { + 'ConversationUUID': _ToStr(conv.ConversationUUID), + 'UserUUID': _ToStr(target), + 'UserName': name_map.get(target, ''), + 'MemberRole': role, + 'AdminCount': admin_count, + 'MaxAdmins': MAX_GROUP_ADMINS, + } + @staticmethod def DissolveConversation(ConversationUUID, OperatorUUID, IsTalkAdmin=False): """解散会话(仅群主或 Talk 管理员)""" @@ -751,6 +819,15 @@ class TalkManager: existing = {_ToBytes(e) for e in existing} return sum(1 for u in uuids if u not in existing) + @staticmethod + def _IsGroupManager(Conversation, Member, OperatorBytes): + """群主或群管理员判定(创建人视为群主,兼容历史数据的角色缺失)""" + if Member is None: + return False + if Member.MemberRole in (ROLE_OWNER, ROLE_ADMIN): + return True + return _ToBytes(Conversation.CreatorUUID) == OperatorBytes + @staticmethod def _IsCompanyUser(CompanyUUID, UserUUIDBytes): """判断用户是否属于该公司""" @@ -803,26 +880,43 @@ class TalkManager: @staticmethod def _ConversationDict(Conversation, CurrentUserBytes, MembersMap, NameMap): - """会话 → 视图 dict(单聊标题空则用对方展示名)""" + """会话 → 视图 dict(单聊标题空则用对方展示名) + + MembersMap 的值为 [(UserUUID, MemberRole), ...],据此回填成员角色与本人在群内的 + 角色判定(IsOwner / IsGroupAdmin / CanManage),前端据此显示群主/管理员标识与入口。 + """ conv_key = _ToStr(Conversation.ConversationUUID) - member_uuids = MembersMap.get(conv_key, []) + member_rows = list(MembersMap.get(conv_key, [])) members = [] - for uid in member_uuids: + for uid, role in member_rows: ub = _ToBytes(uid) name = NameMap.get(ub, '') members.append({ 'UserUUID': _ToStr(ub), 'UserName': name, 'DisplayName': name, + 'MemberRole': role, }) title = Conversation.Title or '' if not title and Conversation.ConversationType == TYPE_SINGLE: - for uid in member_uuids: + for uid, _role in member_rows: ub = _ToBytes(uid) if ub != CurrentUserBytes: title = NameMap.get(ub, '') break + # 本人成员行(已读位点与角色一并取自该行) member = TalkManager.GetMember(Conversation.ConversationUUID, CurrentUserBytes) + my_role = next( + (role for uid, role in member_rows if _ToBytes(uid) == CurrentUserBytes), + member.MemberRole if member is not None else ROLE_MEMBER, + ) + is_owner = my_role == ROLE_OWNER + is_group_admin = my_role == ROLE_ADMIN + # 群主以成员表为准(历史数据 Role 缺失时回退创建人) + owner_bytes = next( + (_ToBytes(uid) for uid, role in member_rows if role == ROLE_OWNER), None) + if owner_bytes is None and Conversation.ConversationType == TYPE_GROUP: + owner_bytes = _ToBytes(Conversation.CreatorUUID) unread_qs = TalkMessage.objects.filter(ConversationUUID=Conversation.ConversationUUID) if member is not None and member.LastReadTime is not None: unread_qs = unread_qs.filter(CreateTime__gt=member.LastReadTime) @@ -838,8 +932,14 @@ class TalkManager: 'UnreadCount': unread_qs.count(), 'Members': members, # 群主标记:前端据此对「非管理员群主」显示解散入口(管理员另有 Entry.IsAdmin) - 'IsOwner': bool(member is not None and member.MemberRole == ROLE_OWNER), - 'OwnerUUID': _ToStr(Conversation.CreatorUUID), + 'IsOwner': bool(is_owner), + 'OwnerUUID': _ToStr(owner_bytes), + # 群内角色:本人是否为该群管理员、本人角色值、管理员数量(用于 x/10 展示) + 'IsGroupAdmin': bool(is_group_admin), + 'MyRole': my_role, + 'AdminCount': sum(1 for _uid, role in member_rows if role == ROLE_ADMIN), + # 可管理群信息(改群名等):群主或群管理员 + 'CanManage': bool(is_owner or is_group_admin), } @staticmethod diff --git a/gvsds/apps/gerp/apps/talk/models.py b/gvsds/apps/gerp/apps/talk/models.py index c7c2a0ed..7b42204d 100644 --- a/gvsds/apps/gerp/apps/talk/models.py +++ b/gvsds/apps/gerp/apps/talk/models.py @@ -52,8 +52,8 @@ class TalkMember(QModel): ConversationUUID = UUIDField(db_index=True, db_column='ConversationUUID', verbose_name='会话UUID') CompanyUUID = UUIDField(db_index=True, db_column='CompanyUUID', verbose_name='公司UUID') UserUUID = UUIDField(db_index=True, db_column='UserUUID', verbose_name='用户UUID') - # 成员角色:0=成员 1=群主 - MemberRole = models.SmallIntegerField(default=0, db_column='MemberRole', verbose_name='成员角色(0成员/1群主)') + # 成员角色:0=成员 1=群主 2=管理员(管理员上限见 manager.MAX_GROUP_ADMINS) + MemberRole = models.SmallIntegerField(default=0, db_column='MemberRole', verbose_name='成员角色(0成员/1群主/2管理员)') JoinTime = models.DateTimeField(default=datetime.datetime.utcnow, db_column='JoinTime', verbose_name='加入时间') LastReadTime = models.DateTimeField(null=True, blank=True, db_column='LastReadTime', verbose_name='最后已读时间') LastReadMessageUUID = UUIDField(null=True, blank=True, db_column='LastReadMessageUUID', verbose_name='最后已读消息UUID') diff --git a/gvsds/apps/gerp/apps/talk/urls.py b/gvsds/apps/gerp/apps/talk/urls.py index 45097fb6..ab3b91c0 100644 --- a/gvsds/apps/gerp/apps/talk/urls.py +++ b/gvsds/apps/gerp/apps/talk/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path('Conversation/Invite', views.ConversationInviteView.as_view()), path('Conversation/Leave', views.ConversationLeaveView.as_view()), path('Conversation/Rename', views.ConversationRenameView.as_view()), + path('Conversation/Member/Role', views.ConversationMemberRoleView.as_view()), path('Conversation/Dissolve', views.ConversationDissolveView.as_view()), path('Messages', views.MessageListView.as_view()), path('Send', views.SendView.as_view()), diff --git a/gvsds/apps/gerp/apps/talk/views.py b/gvsds/apps/gerp/apps/talk/views.py index e166355b..27df0a1a 100644 --- a/gvsds/apps/gerp/apps/talk/views.py +++ b/gvsds/apps/gerp/apps/talk/views.py @@ -6,7 +6,8 @@ REST 接口(统一挂在 API/Talk/ 前缀下): POST Conversation/Create 创建单聊/群聊 POST Conversation/Invite 邀请成员 POST Conversation/Leave 退出会话 - POST Conversation/Rename 重命名会话 + POST Conversation/Rename 重命名会话(群聊仅群主/管理员) + POST Conversation/Member/Role 设置/取消群管理员(仅群主或 Talk 管理员,单群上限 10) POST Conversation/Dissolve 解散会话 GET Messages 拉取消息(升序,BeforeTime 向上翻页) POST Send 发送消息(落库后广播) @@ -32,7 +33,7 @@ from rest_framework.views import APIView from gvsdsdk.auth.admin_service import IsAdmin, IsCompanyAdmin from gvsdsdk.model_utils import UUIDObj -from .manager import CheckTalkAccess, TalkManager +from .manager import CheckTalkAccess, TalkManager, MAX_GROUP_ADMINS from .permissions import IsTalkUser # 附件上传大小上限:10MB @@ -136,6 +137,7 @@ class EntryView(TalkAPIView): return Response({'code': 0, 'data': { 'CanUse': CheckTalkAccess(request), 'IsAdmin': is_admin, + 'GroupAdminLimit': MAX_GROUP_ADMINS, 'CompanyUUID': _ToStr(company), 'Quotas': { 'GroupCompanyLimit': limits['TalkGroupCompanyLimit'], @@ -212,7 +214,7 @@ class ConversationLeaveView(TalkAPIView): class ConversationRenameView(TalkAPIView): - """POST API/Talk/Conversation/Rename —— 重命名会话""" + """POST API/Talk/Conversation/Rename —— 重命名会话(群聊仅群主/管理员)""" def post(self, request): user = _UserBytes(request) @@ -220,7 +222,32 @@ class ConversationRenameView(TalkAPIView): title = request.data.get('Title', '') if not conv_uuid: return Response({'code': 1, 'msg': '缺少 ConversationUUID'}, status=400) - ok, msg, data = TalkManager.RenameConversation(conv_uuid, user, title) + is_admin = IsAdmin(request=request) or IsCompanyAdmin(request=request) + ok, msg, data = TalkManager.RenameConversation(conv_uuid, user, title, IsTalkAdmin=is_admin) + if not ok: + return Response({'code': 1, 'msg': msg}, status=400) + return Response({'code': 0, 'data': data}) + + +class ConversationMemberRoleView(TalkAPIView): + """POST API/Talk/Conversation/Member/Role —— 设置/取消群管理员 + + 请求体:{ConversationUUID, UserUUID, Role}(Role:0=成员 2=管理员) + 仅群主或 Talk 管理员可操作;每个群最多 manager.MAX_GROUP_ADMINS 个管理员。 + """ + + def post(self, request): + user = _UserBytes(request) + conv_uuid = request.data.get('ConversationUUID') + target_uuid = request.data.get('UserUUID') + role = request.data.get('Role') + if not conv_uuid: + return Response({'code': 1, 'msg': '缺少 ConversationUUID'}, status=400) + if not target_uuid: + return Response({'code': 1, 'msg': '缺少 UserUUID'}, status=400) + is_admin = IsAdmin(request=request) or IsCompanyAdmin(request=request) + ok, msg, data = TalkManager.SetMemberRole( + conv_uuid, user, target_uuid, role, IsTalkAdmin=is_admin) if not ok: return Response({'code': 1, 'msg': msg}, status=400) return Response({'code': 0, 'data': data})