完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,9 +35,14 @@ var (
|
||||
ErrChatDMDissolve = errors.New("私聊会话不可解散")
|
||||
ErrChatUserGone = errors.New("用户不存在或已封禁")
|
||||
ErrChatHallPinFixed = errors.New("大厅已置顶且不可取消")
|
||||
ErrChatSiteOwnerOnly = errors.New("仅站长可任命群管理员")
|
||||
ErrChatInvalidRole = errors.New("无效的群成员角色")
|
||||
ErrChatMuteDenied = errors.New("无权禁言该成员")
|
||||
ErrChatCannotMuteOwner = errors.New("不能禁言群主或站长")
|
||||
ErrChatAdminAccess = errors.New("无权管理消息")
|
||||
)
|
||||
|
||||
// CanOverseeChat 站长/超管可旁路查看全部群并撤回任意消息
|
||||
// CanOverseeChat 站长/超管可旁路查看全部群(含私聊)并撤回任意消息
|
||||
func CanOverseeChat(role model.Role) bool {
|
||||
return role == model.RoleOwner || role == model.RoleSuperAdmin
|
||||
}
|
||||
@@ -530,7 +535,7 @@ func (s *ChatService) Join(userID, roomID uint) (member *model.ChatRoomMember, j
|
||||
return &m, true, nil
|
||||
}
|
||||
|
||||
// InviteMembers 群主(或监管)拉人入群;返回实际新加入的用户 ID
|
||||
// InviteMembers 群主/群管(或监管)拉人入群;返回实际新加入的用户 ID
|
||||
func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, oversee bool) ([]uint, error) {
|
||||
room, err := s.getRoom(roomID)
|
||||
if err != nil {
|
||||
@@ -544,7 +549,7 @@ func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, ove
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if op.Role != model.ChatRoleOwner {
|
||||
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
||||
return nil, ErrChatOwnerOnly
|
||||
}
|
||||
}
|
||||
@@ -702,7 +707,7 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch
|
||||
}
|
||||
var list []model.ChatRoomMember
|
||||
err := s.db.Where("room_id = ?", roomID).
|
||||
Order("CASE WHEN role = 'owner' THEN 0 ELSE 1 END, created_at ASC").
|
||||
Order("CASE WHEN role = 'owner' THEN 0 WHEN role = 'admin' THEN 1 ELSE 2 END, created_at ASC").
|
||||
Preload("User").
|
||||
Find(&list).Error
|
||||
if err != nil {
|
||||
@@ -711,18 +716,11 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// Kick 群主踢人(不能踢群主)
|
||||
func (s *ChatService) Kick(operatorID, roomID, targetID uint) error {
|
||||
// Kick 群主/群管/全站监管踢人(不能踢群主;群管不能踢其他群管)
|
||||
func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) error {
|
||||
if _, err := s.getRoom(roomID); err != nil {
|
||||
return err
|
||||
}
|
||||
op, err := s.membership(s.db, roomID, operatorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if op.Role != model.ChatRoleOwner {
|
||||
return ErrChatOwnerOnly
|
||||
}
|
||||
target, err := s.membership(s.db, roomID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -730,9 +728,158 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint) error {
|
||||
if target.Role == model.ChatRoleOwner {
|
||||
return ErrChatOwnerOnly
|
||||
}
|
||||
if !oversee {
|
||||
op, err := s.membership(s.db, roomID, operatorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
||||
return ErrChatOwnerOnly
|
||||
}
|
||||
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
|
||||
return ErrChatOwnerOnly
|
||||
}
|
||||
}
|
||||
return s.removeMember(roomID, targetID)
|
||||
}
|
||||
|
||||
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号
|
||||
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error {
|
||||
room, err := s.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if room.RoomType == model.ChatRoomTypeDirect {
|
||||
return ErrChatOwnerOnly
|
||||
}
|
||||
target, err := s.membership(s.db, roomID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Role == model.ChatRoleOwner {
|
||||
return ErrChatCannotMuteOwner
|
||||
}
|
||||
var targetUser model.User
|
||||
if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil {
|
||||
return ErrChatUserGone
|
||||
}
|
||||
if targetUser.Role == model.RoleOwner {
|
||||
return ErrChatCannotMuteOwner
|
||||
}
|
||||
if !oversee {
|
||||
op, err := s.membership(s.db, roomID, operatorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
||||
return ErrChatMuteDenied
|
||||
}
|
||||
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
|
||||
return ErrChatMuteDenied
|
||||
}
|
||||
}
|
||||
return s.db.Model(&model.ChatRoomMember{}).
|
||||
Where("room_id = ? AND user_id = ?", roomID, targetID).
|
||||
Select("muted").Update("muted", muted).Error
|
||||
}
|
||||
|
||||
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
|
||||
func (s *ChatService) SetMemberRole(operatorSiteRole model.Role, roomID, targetID uint, role string) error {
|
||||
if operatorSiteRole != model.RoleOwner {
|
||||
return ErrChatSiteOwnerOnly
|
||||
}
|
||||
if role != model.ChatRoleAdmin && role != model.ChatRoleMember {
|
||||
return ErrChatInvalidRole
|
||||
}
|
||||
room, err := s.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if room.RoomType != model.ChatRoomTypeGroup {
|
||||
return ErrChatOwnerOnly
|
||||
}
|
||||
target, err := s.membership(s.db, roomID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Role == model.ChatRoleOwner || room.OwnerID == targetID {
|
||||
return ErrChatOwnerOnly
|
||||
}
|
||||
return s.db.Model(&model.ChatRoomMember{}).
|
||||
Where("room_id = ? AND user_id = ?", roomID, targetID).
|
||||
Update("role", role).Error
|
||||
}
|
||||
|
||||
// CountAdminRooms 用户作为群管理员的群数量
|
||||
func (s *ChatService) CountAdminRooms(userID uint) (int64, error) {
|
||||
var n int64
|
||||
err := s.db.Model(&model.ChatRoomMember{}).
|
||||
Joins("JOIN chat_rooms ON chat_rooms.id = chat_room_members.room_id").
|
||||
Where("chat_room_members.user_id = ? AND chat_room_members.role = ? AND chat_rooms.deleted_at IS NULL AND chat_rooms.room_type = ?",
|
||||
userID, model.ChatRoleAdmin, model.ChatRoomTypeGroup).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ListAdminRoomIDs 用户作为群管理员的群 ID 列表
|
||||
func (s *ChatService) ListAdminRoomIDs(userID uint) ([]uint, error) {
|
||||
var ids []uint
|
||||
err := s.db.Model(&model.ChatRoomMember{}).
|
||||
Joins("JOIN chat_rooms ON chat_rooms.id = chat_room_members.room_id").
|
||||
Where("chat_room_members.user_id = ? AND chat_room_members.role = ? AND chat_rooms.deleted_at IS NULL AND chat_rooms.room_type = ?",
|
||||
userID, model.ChatRoleAdmin, model.ChatRoomTypeGroup).
|
||||
Pluck("chat_room_members.room_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// CanAccessAdminMessages 可否进入后台消息管理
|
||||
func (s *ChatService) CanAccessAdminMessages(actor *Actor) bool {
|
||||
if actor == nil {
|
||||
return false
|
||||
}
|
||||
if actor.HasSiteMessagePerm() {
|
||||
return true
|
||||
}
|
||||
n, err := s.CountAdminRooms(actor.ID)
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
// isChatRoomModerator 群主或群管理员
|
||||
func (s *ChatService) isChatRoomModerator(userID, roomID uint) bool {
|
||||
m, err := s.membership(s.db, roomID, userID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return m.Role == model.ChatRoleOwner || m.Role == model.ChatRoleAdmin
|
||||
}
|
||||
|
||||
// hasSiteGroupMessagePerm 站点消息管理 flag(不含超管/站长,调用方通常已判 oversee)
|
||||
func (s *ChatService) hasSiteGroupMessagePerm(userID uint) bool {
|
||||
var u model.User
|
||||
if err := s.db.Select("can_manage_messages").First(&u, userID).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return u.CanManageMessages
|
||||
}
|
||||
|
||||
// canModerateRoomMessages 可撤回他人消息 / 看已撤回正文:全站监管、站点消息管(仅群)、群主/群管
|
||||
func (s *ChatService) canModerateRoomMessages(userID, roomID uint, oversee bool) bool {
|
||||
if oversee {
|
||||
return true
|
||||
}
|
||||
if s.isChatRoomModerator(userID, roomID) {
|
||||
return true
|
||||
}
|
||||
if !s.hasSiteGroupMessagePerm(userID) {
|
||||
return false
|
||||
}
|
||||
room, err := s.getRoom(roomID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return room.RoomType == model.ChatRoomTypeGroup
|
||||
}
|
||||
|
||||
// SendMessage 发送群消息:成员且未被禁言;可选引用回复;解析@并落提醒通知。
|
||||
func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyToID uint) (*model.ChatMessage, []uint, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
@@ -907,8 +1054,9 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
||||
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||
}
|
||||
// 已撤回正文:仅监管者(站长/超管)保留,供聊天界面审计展示;普通成员清空
|
||||
if !oversee {
|
||||
// 已撤回正文:监管者 / 站点消息管(群)/ 群主群管保留
|
||||
seeRecalled := oversee || s.canModerateRoomMessages(userID, roomID, false)
|
||||
if !seeRecalled {
|
||||
for i := range msgs {
|
||||
if msgs[i].RecalledAt != nil {
|
||||
msgs[i].Content = ""
|
||||
@@ -918,7 +1066,7 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
||||
return msgs, hasMore, nil
|
||||
}
|
||||
|
||||
// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt,正文保留供审计。
|
||||
// RecallMessage 撤回消息:本人;或群主/群管/站点消息管(群)/全站监管可撤他人。
|
||||
// newly=true 表示本次刚打上撤回标记(已撤回的幂等调用为 false,避免重复回退角标)。
|
||||
func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, bool, error) {
|
||||
if _, err := s.getRoom(roomID); err != nil {
|
||||
@@ -934,19 +1082,16 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
||||
if msg.RecalledAt != nil {
|
||||
return &msg, false, nil // 幂等
|
||||
}
|
||||
if !oversee {
|
||||
m, err := s.membership(s.db, roomID, operatorID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// 群主/成员仅可撤回自己的消息;监管者可撤任意
|
||||
if msg.SenderID != operatorID {
|
||||
_ = m // 成员校验已通过
|
||||
return nil, false, ErrChatRecallDenied
|
||||
if msg.SenderID == operatorID {
|
||||
if !oversee {
|
||||
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
} else if !s.canModerateRoomMessages(operatorID, roomID, oversee) {
|
||||
return nil, false, ErrChatRecallDenied
|
||||
}
|
||||
now := time.Now()
|
||||
// 只打撤回标记,保留正文供站长审计;前台 ListMessages 对非监管者置空 content
|
||||
if err := s.db.Model(&msg).Updates(map[string]interface{}{
|
||||
"recalled_at": now,
|
||||
"recalled_by": operatorID,
|
||||
@@ -982,20 +1127,44 @@ type AdminChatMessageItem struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminListMessages 按房间类型分页:group=大厅+群聊,direct=私聊;支持正文/房间/参与者搜索
|
||||
func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
|
||||
// AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。
|
||||
// actor 为站长/超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。
|
||||
func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
|
||||
if actor == nil || !s.CanAccessAdminMessages(actor) {
|
||||
return nil, 0, ErrChatAdminAccess
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
fullOversee := CanOverseeChat(actor.Role)
|
||||
siteGroup := actor.HasSiteMessagePerm() && !fullOversee
|
||||
if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect {
|
||||
return nil, 0, errors.New("房间类型无效")
|
||||
}
|
||||
// 非全站监管不可看私聊
|
||||
if roomType == model.ChatRoomTypeDirect && !fullOversee {
|
||||
return []AdminChatMessageItem{}, 0, nil
|
||||
}
|
||||
const pageSize = 15
|
||||
q := s.db.Model(&model.ChatMessage{}).
|
||||
Joins("JOIN chat_rooms ON chat_rooms.id = chat_messages.room_id").
|
||||
Joins("LEFT JOIN users ON users.id = chat_messages.sender_id").
|
||||
Where("chat_rooms.deleted_at IS NULL AND chat_messages.deleted_at IS NULL").
|
||||
Where("chat_rooms.room_type = ?", roomType)
|
||||
|
||||
if !fullOversee && !siteGroup {
|
||||
ids, err := s.ListAdminRoomIDs(actor.ID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return []AdminChatMessageItem{}, 0, nil
|
||||
}
|
||||
q = q.Where("chat_messages.room_id IN ?", ids)
|
||||
} else if siteGroup {
|
||||
q = q.Where("chat_rooms.room_type = ?", model.ChatRoomTypeGroup)
|
||||
}
|
||||
|
||||
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||
like := "%" + kw + "%"
|
||||
q = q.Where(
|
||||
@@ -1037,7 +1206,6 @@ func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]A
|
||||
roomMap[r.ID] = r
|
||||
}
|
||||
}
|
||||
// 私聊:预加载双方,供前端叠放头像与「甲 · 乙」会话条
|
||||
participantsByRoom := map[uint][]AdminChatParticipant{}
|
||||
if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 {
|
||||
var members []model.ChatRoomMember
|
||||
@@ -1073,9 +1241,14 @@ func (s *ChatService) AdminListMessages(page int, keyword, roomType string) ([]A
|
||||
name = "私聊"
|
||||
}
|
||||
}
|
||||
content := m.Content
|
||||
// 后台列表:仅全站监管可见已撤回正文;群管/消息管理员在前台会话内可看
|
||||
if !fullOversee && m.RecalledAt != nil {
|
||||
content = ""
|
||||
}
|
||||
item := AdminChatMessageItem{
|
||||
ID: m.ID, RoomID: m.RoomID, RoomName: name, RoomType: r.RoomType, IsDefault: r.IsDefault,
|
||||
SenderID: m.SenderID, Content: m.Content, RecalledAt: m.RecalledAt, CreatedAt: m.CreatedAt,
|
||||
SenderID: m.SenderID, Content: content, RecalledAt: m.RecalledAt, CreatedAt: m.CreatedAt,
|
||||
Participants: parts,
|
||||
}
|
||||
if m.Sender.ID > 0 {
|
||||
|
||||
Reference in New Issue
Block a user