feat(Talk): 群支持最多 10 个管理员,改群名收紧到群主/管理员

成员角色新增 2=管理员(MAX_GROUP_ADMINS=10);新增 POST API/Talk/Conversation/Member/Role 设置/取消管理员(仅群主或 Talk 管理员,不能变更群主);会话列表返回 IsGroupAdmin/MyRole/AdminCount/CanManage 与成员 MemberRole;RenameConversation 增加 IsTalkAdmin 参数并把群聊改名权限收紧到群主/群管理员;Entry 下发 GroupAdminLimit。
This commit is contained in:
TermiNexus committed 2026-10-13 16:07:52 +08:00
1 parent aa56281493
commit 90b20f3c94
4 files changed
+145 -17

No files matched your search

+111 -11
View File
@@ -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
+2 -2
View File
@@ -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')
+1
View File
@@ -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()),
+31 -4
View File
@@ -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})