Files
jiang13-bbs/backend/service/chat.go
freefire 42c395a864 feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 22:58:16 +08:00

993 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service 群聊业务(二期)。
//
// 铁律:消息先落库再由 handler 经 WS 广播,WS 不承担可靠投递;
// 未读数以成员行 last_read_message_id 为水位做 SQL 对账,离线期间不丢消息。
package service
import (
"errors"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
var (
ErrChatNotFound = errors.New("会话不存在")
ErrChatNotMember = errors.New("不是会话成员")
ErrChatNameInvalid = errors.New("群名称需为 2-32 个字符")
ErrChatDescInvalid = errors.New("群简介不能超过 200 个字符")
ErrChatContentEmpty = errors.New("消息内容不能为空")
ErrChatContentTooLong = errors.New("单条消息不能超过 2000 个字符")
ErrChatMuted = errors.New("你已被群主禁言")
ErrChatOwnerOnly = errors.New("仅群主可执行该操作")
ErrChatOwnerLeave = errors.New("群主不能直接退出,请先解散该群")
ErrChatPrivateInvite = errors.New("私密群需邀请才能加入")
ErrChatDefaultLeave = errors.New("默认大厅不可退出")
ErrChatDefaultDissolve = errors.New("默认大厅不可解散")
ErrChatRecallDenied = errors.New("无权撤回该消息")
ErrChatMsgNotFound = errors.New("消息不存在")
ErrChatDMSelf = errors.New("不能与自己私聊")
ErrChatDMDissolve = errors.New("私聊会话不可解散")
ErrChatUserGone = errors.New("用户不存在或已封禁")
ErrChatHallPinFixed = errors.New("大厅已置顶且不可取消")
)
// CanOverseeChat 站长/超管可旁路查看全部群并撤回任意消息
func CanOverseeChat(role model.Role) bool {
return role == model.RoleOwner || role == model.RoleSuperAdmin
}
const (
chatNameMinRune = 2
chatNameMaxRune = 32
chatDescMaxRune = 200
chatMsgMaxRune = 2000
chatMentionMax = 20
)
// chatMentionRe @提醒匹配:用户名 3-32 位字母/数字/下划线/连字符;
// 是否有效最终以"群成员精确匹配"为准,名单外的 @ 原样当普通文本
var chatMentionRe = regexp.MustCompile(`@([A-Za-z0-9_-]{3,32})`)
// ChatService 群聊服务
type ChatService struct {
db *gorm.DB
notif *NotificationService
}
func NewChatService(db *gorm.DB, notif *NotificationService) *ChatService {
return &ChatService{db: db, notif: notif}
}
// RoomView 会话列表/详情聚合视图
type RoomView struct {
model.ChatRoom
Joined bool `json:"joined"`
MyRole string `json:"my_role"`
UnreadCount int64 `json:"unread_count"`
Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制)
PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消
Peer *model.User `json:"peer,omitempty"` // 私聊对方(仅 direct)
}
// UnreadRoom 单个会话的未读计数
type UnreadRoom struct {
RoomID uint `gorm:"column:room_id" json:"room_id"`
Count int64 `gorm:"column:cnt" json:"count"`
}
func directKey(a, b uint) string {
if a > b {
a, b = b, a
}
return strconv.FormatUint(uint64(a), 10) + ":" + strconv.FormatUint(uint64(b), 10)
}
func (s *ChatService) getRoom(roomID uint) (*model.ChatRoom, error) {
var room model.ChatRoom
err := s.db.First(&room, roomID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrChatNotFound
}
if err != nil {
return nil, err
}
return &room, nil
}
func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatRoomMember, error) {
var m model.ChatRoomMember
err := tx.Where("room_id = ? AND user_id = ?", roomID, userID).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrChatNotMember
}
if err != nil {
return nil, err
}
return &m, nil
}
// Membership 供 WS 握手后动态订阅房间鉴权
func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, error) {
return s.membership(s.db, roomID, userID)
}
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位即未读)
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
out := make(map[uint]int64)
if userID == 0 || len(roomIDs) == 0 {
return out
}
var rows []UnreadRoom
s.db.Table("chat_messages AS m").
Select("m.room_id AS room_id, count(*) AS cnt").
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs).
Group("m.room_id").
Scan(&rows)
for _, r := range rows {
out[r.RoomID] = r.Count
}
return out
}
// toViews 批量填充 joined/角色/未读(避免 N+1)
func (s *ChatService) toViews(rooms []model.ChatRoom, userID uint) []RoomView {
ids := make([]uint, 0, len(rooms))
for i := range rooms {
ids = append(ids, rooms[i].ID)
}
memberMap := make(map[uint]model.ChatRoomMember)
if userID != 0 && len(ids) > 0 {
var ms []model.ChatRoomMember
s.db.Where("user_id = ? AND room_id IN ?", userID, ids).Find(&ms)
for _, m := range ms {
memberMap[m.RoomID] = m
}
}
unread := s.unreadCounts(userID, ids)
views := make([]RoomView, 0, len(rooms))
for _, r := range rooms {
v := RoomView{ChatRoom: r, UnreadCount: unread[r.ID]}
if r.IsDefault {
v.Pinned = true
v.PinForced = true
}
if m, ok := memberMap[r.ID]; ok {
v.Joined = true
v.MyRole = m.Role
if m.PinnedAt != nil {
v.Pinned = true
}
}
views = append(views, v)
}
s.attachPeers(views, userID)
sortRoomViews(views, memberMap)
return views
}
// sortRoomViews:大厅强制置顶 > 个人置顶(pinned_at DESC) > last_message_id DESC
func sortRoomViews(views []RoomView, memberMap map[uint]model.ChatRoomMember) {
sort.SliceStable(views, func(i, j int) bool {
a, b := views[i], views[j]
if a.IsDefault != b.IsDefault {
return a.IsDefault
}
aPin, bPin := !a.IsDefault && a.Pinned, !b.IsDefault && b.Pinned
if aPin != bPin {
return aPin
}
if aPin && bPin {
var at, bt time.Time
if m, ok := memberMap[a.ID]; ok && m.PinnedAt != nil {
at = *m.PinnedAt
}
if m, ok := memberMap[b.ID]; ok && m.PinnedAt != nil {
bt = *m.PinnedAt
}
if !at.Equal(bt) {
return at.After(bt)
}
}
if a.LastMessageID != b.LastMessageID {
return a.LastMessageID > b.LastMessageID
}
return a.ID > b.ID
})
}
// attachPeers 为私聊会话填充对方用户信息
func (s *ChatService) attachPeers(views []RoomView, viewerID uint) {
directIDs := make([]uint, 0)
for i := range views {
if views[i].RoomType == model.ChatRoomTypeDirect {
directIDs = append(directIDs, views[i].ID)
}
}
if len(directIDs) == 0 {
return
}
var members []model.ChatRoomMember
s.db.Where("room_id IN ? AND user_id <> ?", directIDs, viewerID).Preload("User").Find(&members)
peerByRoom := make(map[uint]*model.User, len(members))
for i := range members {
u := members[i].User
peerByRoom[members[i].RoomID] = &u
}
for i := range views {
if p, ok := peerByRoom[views[i].ID]; ok {
views[i].Peer = p
if views[i].Name == "" && p != nil {
views[i].Name = p.Nickname
if views[i].Name == "" {
views[i].Name = p.Username
}
}
}
}
}
// CreateRoom 建群:任意登录用户可建;isPrivate 时仅邀请可进;memberIDs 为初始成员(不含创建者)
func (s *ChatService) CreateRoom(creatorID uint, name, description string, isPrivate bool, memberIDs []uint) (*RoomView, error) {
name = strings.TrimSpace(name)
description = strings.TrimSpace(description)
if l := len([]rune(name)); l < chatNameMinRune || l > chatNameMaxRune {
return nil, ErrChatNameInvalid
}
if len([]rune(description)) > chatDescMaxRune {
return nil, ErrChatDescInvalid
}
// 去重、排除自己
seen := map[uint]struct{}{creatorID: {}}
cleanIDs := make([]uint, 0, len(memberIDs))
for _, id := range memberIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
cleanIDs = append(cleanIDs, id)
}
room := model.ChatRoom{
Name: name,
Description: description,
OwnerID: creatorID,
RoomType: model.ChatRoomTypeGroup,
IsPrivate: isPrivate,
MemberCount: 1 + len(cleanIDs),
}
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&room).Error; err != nil {
return err
}
m := model.ChatRoomMember{RoomID: room.ID, UserID: creatorID, Role: model.ChatRoleOwner}
if err := tx.Create(&m).Error; err != nil {
return err
}
for _, uid := range cleanIDs {
mm := model.ChatRoomMember{RoomID: room.ID, UserID: uid, Role: model.ChatRoleMember}
if err := tx.Create(&mm).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
var full model.ChatRoom
if err := s.db.Preload("Owner").First(&full, room.ID).Error; err != nil {
return nil, err
}
v := RoomView{ChatRoom: full, Joined: true, MyRole: model.ChatRoleOwner}
return &v, nil
}
// ListRooms 会话列表。joinedOnly=true 时仅已加入(IM 侧栏);否则公开群 + 已加入私密(站长旁路可见全部)
func (s *ChatService) ListRooms(userID uint, oversee, joinedOnly bool, q string, page, size int) ([]RoomView, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Model(&model.ChatRoom{})
if joinedOnly && userID > 0 {
query = query.Where("id IN (SELECT room_id FROM chat_room_members WHERE user_id = ?)", userID)
} else if !oversee {
if userID == 0 {
query = query.Where("is_private = ? AND room_type = ?", false, model.ChatRoomTypeGroup)
} else {
query = query.Where(
"(is_private = ? AND room_type = ?) OR id IN (SELECT room_id FROM chat_room_members WHERE user_id = ?)",
false, model.ChatRoomTypeGroup, userID,
)
}
}
if q = strings.TrimSpace(q); q != "" {
esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(q)
query = query.Where(`name ILIKE ? ESCAPE '\'`, "%"+esc+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var rooms []model.ChatRoom
// 已加入列表需按个人置顶排序:先全量取出再内存排序分页(会话量通常很小)
if joinedOnly && userID > 0 {
if err := query.
Preload("Owner").Preload("LastMessage.Sender").
Find(&rooms).Error; err != nil {
return nil, 0, err
}
views := s.toViews(rooms, userID)
start := (page - 1) * size
if start >= len(views) {
return []RoomView{}, total, nil
}
end := start + size
if end > len(views) {
end = len(views)
}
return views[start:end], total, nil
}
if err := query.
Order("is_default DESC, last_message_id DESC, id DESC").
Offset((page - 1) * size).Limit(size).
Preload("Owner").Preload("LastMessage.Sender").
Find(&rooms).Error; err != nil {
return nil, 0, err
}
return s.toViews(rooms, userID), total, nil
}
// SetRoomPinned 当前用户置顶/取消置顶(大厅强制置顶不可取消)
func (s *ChatService) SetRoomPinned(userID, roomID uint, pinned bool) (*RoomView, error) {
room, err := s.getRoom(roomID)
if err != nil {
return nil, err
}
if room.IsDefault {
return nil, ErrChatHallPinFixed
}
m, err := s.membership(s.db, roomID, userID)
if err != nil {
return nil, err
}
if pinned {
now := time.Now()
if err := s.db.Model(m).Update("pinned_at", now).Error; err != nil {
return nil, err
}
} else {
if err := s.db.Model(m).Update("pinned_at", nil).Error; err != nil {
return nil, err
}
}
return s.RoomDetail(userID, roomID, false)
}
// GetOrCreateDM 获取或创建与对方的一对一私聊(幂等)
func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) {
if peerID == 0 || peerID == meID {
return nil, ErrChatDMSelf
}
var peer model.User
if err := s.db.Where("id = ? AND banned = ? AND deleted_at IS NULL", peerID, false).First(&peer).Error; err != nil {
return nil, ErrChatUserGone
}
key := directKey(meID, peerID)
var existing model.ChatRoom
err := s.db.Where("room_type = ? AND direct_key = ?", model.ChatRoomTypeDirect, key).First(&existing).Error
if err == nil {
_ = s.ensureMember(existing.ID, meID)
_ = s.ensureMember(existing.ID, peerID)
return s.RoomDetail(meID, existing.ID, false)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
room := model.ChatRoom{
Name: "",
OwnerID: meID,
RoomType: model.ChatRoomTypeDirect,
DirectKey: key,
IsPrivate: true,
MemberCount: 2,
}
err = s.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&room).Error; e != nil {
return e
}
ms := []model.ChatRoomMember{
{RoomID: room.ID, UserID: meID, Role: model.ChatRoleMember},
{RoomID: room.ID, UserID: peerID, Role: model.ChatRoleMember},
}
return tx.Create(&ms).Error
})
if err != nil {
if e2 := s.db.Where("room_type = ? AND direct_key = ?", model.ChatRoomTypeDirect, key).First(&existing).Error; e2 == nil {
return s.RoomDetail(meID, existing.ID, false)
}
return nil, err
}
return s.RoomDetail(meID, room.ID, false)
}
func (s *ChatService) ensureMember(roomID, userID uint) error {
_, err := s.membership(s.db, roomID, userID)
if err == nil {
return nil
}
if !errors.Is(err, ErrChatNotMember) {
return err
}
m := model.ChatRoomMember{RoomID: roomID, UserID: userID, Role: model.ChatRoleMember}
if e := s.db.Create(&m).Error; e != nil {
return e
}
return s.db.Model(&model.ChatRoom{}).Where("id = ?", roomID).
UpdateColumn("member_count", gorm.Expr("GREATEST(member_count + 1, 0)")).Error
}
// ListMemberIDs 房间全部成员 ID(发消息后推未读角标用)
func (s *ChatService) ListMemberIDs(roomID uint) ([]uint, error) {
var ids []uint
err := s.db.Model(&model.ChatRoomMember{}).Where("room_id = ?", roomID).Pluck("user_id", &ids).Error
return ids, err
}
// SearchUsersForChat 按用户名/昵称搜索可发起私聊的用户(排除自己、封禁)
func (s *ChatService) SearchUsersForChat(meID uint, q string, limit int) ([]model.User, error) {
q = strings.TrimSpace(q)
if q == "" {
return nil, nil
}
if limit < 1 || limit > 20 {
limit = 10
}
esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(q)
like := "%" + esc + "%"
var users []model.User
err := s.db.Select("id", "username", "nickname", "avatar", "role").
Where("id <> ? AND banned = ? AND deleted_at IS NULL AND (username ILIKE ? ESCAPE '\\' OR nickname ILIKE ? ESCAPE '\\')",
meID, false, like, like).
Order("username ASC").Limit(limit).Find(&users).Error
return users, err
}
// RoomDetail 群详情:成员或监管者可读;公开非成员返回 joined=false(可预览元数据)
func (s *ChatService) RoomDetail(userID, roomID uint, oversee bool) (*RoomView, error) {
room, err := s.getRoom(roomID)
if err != nil {
return nil, err
}
_, memErr := s.membership(s.db, roomID, userID)
isMember := memErr == nil
if !isMember && !oversee {
if room.IsPrivate {
return nil, ErrChatNotFound // 私密非成员伪装不存在
}
// 公开非成员:允许预览
} else if memErr != nil && !oversee && !errors.Is(memErr, ErrChatNotMember) {
return nil, memErr
}
if err := s.db.Preload("Owner").Preload("LastMessage.Sender").First(room, roomID).Error; err != nil {
return nil, err
}
views := s.toViews([]model.ChatRoom{*room}, userID)
v := views[0]
if oversee && !v.Joined {
v.Joined = true // 监管旁路按已加入展示,可看历史
v.MyRole = "overseer"
}
return &v, nil
}
// Join 加入公开群(私密/私聊拒绝;幂等)
func (s *ChatService) Join(userID, roomID uint) (member *model.ChatRoomMember, joined bool, err error) {
room, err := s.getRoom(roomID)
if err != nil {
return nil, false, err
}
if room.RoomType == model.ChatRoomTypeDirect {
return nil, false, ErrChatNotFound
}
if room.IsPrivate {
return nil, false, ErrChatPrivateInvite
}
if m, mErr := s.membership(s.db, roomID, userID); mErr == nil {
return m, false, nil
} else if !errors.Is(mErr, ErrChatNotMember) {
return nil, false, mErr
}
m := model.ChatRoomMember{RoomID: roomID, UserID: userID, Role: model.ChatRoleMember}
err = s.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&m).Error; e != nil {
return e
}
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
UpdateColumn("member_count", gorm.Expr("GREATEST(member_count + 1, 0)")).Error
})
if err != nil {
if existing, qErr := s.membership(s.db, roomID, userID); qErr == nil {
return existing, false, nil
}
return nil, false, err
}
return &m, true, nil
}
// InviteMembers 群主(或监管)拉人入群;返回实际新加入的用户 ID
func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, oversee bool) ([]uint, error) {
room, err := s.getRoom(roomID)
if err != nil {
return nil, err
}
if room.RoomType == model.ChatRoomTypeDirect {
return nil, ErrChatOwnerOnly
}
if !oversee {
op, err := s.membership(s.db, roomID, operatorID)
if err != nil {
return nil, err
}
if op.Role != model.ChatRoleOwner {
return nil, ErrChatOwnerOnly
}
}
added := make([]uint, 0)
for _, uid := range userIDs {
if uid == 0 {
continue
}
if _, mErr := s.membership(s.db, roomID, uid); mErr == nil {
continue
}
var u model.User
if err := s.db.Select("id").Where("id = ? AND banned = ? AND deleted_at IS NULL", uid, false).First(&u).Error; err != nil {
continue
}
m := model.ChatRoomMember{RoomID: roomID, UserID: uid, Role: model.ChatRoleMember}
if room.OwnerID == uid {
m.Role = model.ChatRoleOwner
}
err := s.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&m).Error; e != nil {
return e
}
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
UpdateColumn("member_count", gorm.Expr("GREATEST(member_count + 1, 0)")).Error
})
if err == nil {
added = append(added, uid)
}
}
return added, nil
}
// EnsureDefaultMembership 将用户加入默认大厅(注册后调用,幂等)
func (s *ChatService) EnsureDefaultMembership(userID uint) error {
var room model.ChatRoom
if err := s.db.Where("is_default = ?", true).First(&room).Error; err != nil {
return nil // 尚无大厅则跳过
}
if _, err := s.membership(s.db, room.ID, userID); err == nil {
return nil
}
m := model.ChatRoomMember{RoomID: room.ID, UserID: userID, Role: model.ChatRoleMember}
return s.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&m).Error; e != nil {
return e
}
return tx.Model(&model.ChatRoom{}).Where("id = ?", room.ID).
UpdateColumn("member_count", gorm.Expr("GREATEST(member_count + 1, 0)")).Error
})
}
// Leave 普通成员退出群聊(群主不可退出;默认大厅不可退出;私聊可退出)
func (s *ChatService) Leave(userID, roomID uint) error {
room, err := s.getRoom(roomID)
if err != nil {
return err
}
if room.IsDefault {
return ErrChatDefaultLeave
}
m, err := s.membership(s.db, roomID, userID)
if err != nil {
return err
}
if room.RoomType != model.ChatRoomTypeDirect && m.Role == model.ChatRoleOwner {
return ErrChatOwnerLeave
}
return s.removeMember(roomID, userID)
}
// removeMember 删除成员行并把群人数 -1(不小于 0)
func (s *ChatService) removeMember(roomID, userID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
res := tx.Where("room_id = ? AND user_id = ?", roomID, userID).Delete(&model.ChatRoomMember{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return nil
}
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
UpdateColumn("member_count", gorm.Expr("GREATEST(member_count - 1, 0)")).Error
})
}
// Dissolve 群主解散群(默认大厅/私聊不可解散)
func (s *ChatService) Dissolve(userID, roomID uint, oversee bool) error {
room, err := s.getRoom(roomID)
if err != nil {
return err
}
if room.IsDefault {
return ErrChatDefaultDissolve
}
if room.RoomType == model.ChatRoomTypeDirect {
return ErrChatDMDissolve
}
if !oversee {
m, err := s.membership(s.db, roomID, userID)
if err != nil {
return err
}
if m.Role != model.ChatRoleOwner {
return ErrChatOwnerOnly
}
}
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(&model.ChatRoom{}, roomID).Error; err != nil {
return err
}
return tx.Where("room_id = ?", roomID).Delete(&model.ChatRoomMember{}).Error
})
}
// UpdateRoom 群主修改群名称/简介
func (s *ChatService) UpdateRoom(userID, roomID uint, name, description string) (*model.ChatRoom, error) {
name = strings.TrimSpace(name)
description = strings.TrimSpace(description)
if l := len([]rune(name)); l < chatNameMinRune || l > chatNameMaxRune {
return nil, ErrChatNameInvalid
}
if len([]rune(description)) > chatDescMaxRune {
return nil, ErrChatDescInvalid
}
room, err := s.getRoom(roomID)
if err != nil {
return nil, err
}
m, err := s.membership(s.db, roomID, userID)
if err != nil {
return nil, err
}
if m.Role != model.ChatRoleOwner {
return nil, ErrChatOwnerOnly
}
if err := s.db.Model(room).Updates(map[string]interface{}{
"name": name,
"description": description,
}).Error; err != nil {
return nil, err
}
return s.getRoom(roomID)
}
// ListMembers 成员名单:成员或监管者可查看
func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.ChatRoomMember, error) {
if _, err := s.getRoom(roomID); err != nil {
return nil, err
}
if !oversee {
if _, err := s.membership(s.db, roomID, userID); err != nil {
return nil, err
}
}
var list []model.ChatRoomMember
err := s.db.Where("room_id = ?", roomID).
Order("CASE WHEN role = 'owner' THEN 0 ELSE 1 END, created_at ASC").
Preload("User").
Find(&list).Error
if err != nil {
return nil, err
}
return list, nil
}
// Kick 群主踢人(不能踢群主)
func (s *ChatService) Kick(operatorID, roomID, targetID uint) 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
}
if target.Role == model.ChatRoleOwner {
return ErrChatOwnerOnly
}
return s.removeMember(roomID, targetID)
}
// SendMessage 发送群消息:成员且未被禁言;可选引用回复;解析@并落提醒通知。
func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyToID uint) (*model.ChatMessage, []uint, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, nil, ErrChatContentEmpty
}
if len([]rune(content)) > chatMsgMaxRune {
return nil, nil, ErrChatContentTooLong
}
if _, err := s.getRoom(roomID); err != nil {
return nil, nil, err
}
m, err := s.membership(s.db, roomID, senderID)
if err != nil {
return nil, nil, err
}
if m.Muted {
return nil, nil, ErrChatMuted
}
replySnap := ""
if replyToID > 0 {
var quoted model.ChatMessage
if err := s.db.Where("id = ? AND room_id = ?", replyToID, roomID).
Preload("Sender").First(&quoted).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrChatMsgNotFound
}
return nil, nil, err
}
name := quoted.Sender.Nickname
if name == "" {
name = quoted.Sender.Username
}
if quoted.RecalledAt != nil {
replySnap = name + ": (已撤回的消息)"
} else {
snippet := quoted.Content
rs := []rune(snippet)
if len(rs) > 80 {
snippet = string(rs[:80]) + "…"
}
replySnap = name + ": " + snippet
}
}
mentionIDs := s.resolveMentions(roomID, senderID, content)
msg := &model.ChatMessage{
RoomID: roomID,
SenderID: senderID,
Content: content,
ReplyToID: replyToID,
ReplySnap: replySnap,
MentionIDs: joinMentionIDs(mentionIDs),
}
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(msg).Error; err != nil {
return err
}
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
Updates(map[string]interface{}{
"last_message_id": msg.ID,
"updated_at": time.Now(),
}).Error
})
if err != nil {
return nil, nil, err
}
if err := s.db.Preload("Sender").First(msg, msg.ID).Error; err != nil {
return nil, nil, err
}
for _, uid := range mentionIDs {
s.notif.CreateMention(uid, senderID, roomID, msg.ID, content)
}
return msg, mentionIDs, nil
}
// resolveMentions 文本中 @用户名 → 群成员用户 ID(去重、排除自己、上限 20)
func (s *ChatService) resolveMentions(roomID, senderID uint, content string) []uint {
matches := chatMentionRe.FindAllStringSubmatch(content, -1)
if len(matches) == 0 {
return nil
}
nameSet := make(map[string]struct{}, len(matches))
names := make([]string, 0, len(matches))
for _, mt := range matches {
name := strings.ToLower(mt[1])
if _, ok := nameSet[name]; ok {
continue
}
nameSet[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return nil
}
// 用户名大小写不敏感匹配(注册名唯一但比较时统一 lower)
var users []model.User
s.db.Where("LOWER(username) IN ? AND banned = ?", names, false).Find(&users)
if len(users) == 0 {
return nil
}
userIDs := make([]uint, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
var memberIDs []uint
s.db.Model(&model.ChatRoomMember{}).
Where("room_id = ? AND user_id IN ?", roomID, userIDs).
Pluck("user_id", &memberIDs)
seen := make(map[uint]struct{}, len(memberIDs))
out := make([]uint, 0, len(memberIDs))
for _, id := range memberIDs {
if id == senderID {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
if len(out) >= chatMentionMax {
break
}
}
return out
}
func joinMentionIDs(ids []uint) string {
if len(ids) == 0 {
return ""
}
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatUint(uint64(id), 10)
}
return strings.Join(parts, ",")
}
// ListMessages 历史消息分页:成员或监管者可取;已撤回消息仍返回(前端显示占位)
func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, oversee bool) ([]model.ChatMessage, bool, error) {
if size < 1 || size > 50 {
size = 30
}
if _, err := s.getRoom(roomID); err != nil {
return nil, false, err
}
if !oversee {
if _, err := s.membership(s.db, roomID, userID); err != nil {
return nil, false, err
}
}
q := s.db.Where("room_id = ?", roomID)
if beforeID > 0 {
q = q.Where("id < ?", beforeID)
}
var msgs []model.ChatMessage
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Find(&msgs).Error; err != nil {
return nil, false, err
}
hasMore := len(msgs) > size
if hasMore {
msgs = msgs[:size]
}
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 {
for i := range msgs {
if msgs[i].RecalledAt != nil {
msgs[i].Content = ""
}
}
}
return msgs, hasMore, nil
}
// RecallMessage 撤回消息:群主可撤自己的;监管者可撤任意;标记 RecalledAt 不清行
func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee bool) (*model.ChatMessage, error) {
if _, err := s.getRoom(roomID); err != nil {
return nil, err
}
var msg model.ChatMessage
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrChatMsgNotFound
}
return nil, err
}
if msg.RecalledAt != nil {
return &msg, nil // 幂等
}
if !oversee {
m, err := s.membership(s.db, roomID, operatorID)
if err != nil {
return nil, err
}
// 群主/成员仅可撤回自己的消息;监管者可撤任意
if msg.SenderID != operatorID {
_ = m // 成员校验已通过
return nil, ErrChatRecallDenied
}
}
now := time.Now()
if err := s.db.Model(&msg).Updates(map[string]interface{}{
"recalled_at": now,
"recalled_by": operatorID,
"content": "",
}).Error; err != nil {
return nil, err
}
msg.RecalledAt = &now
msg.RecalledBy = operatorID
msg.Content = ""
_ = s.db.Preload("Sender").First(&msg, msg.ID)
return &msg, nil
}
// MarkRead 把已读水位推进到指定消息(单调不减;messageID=0 或超过最新则取最新)
func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
room, err := s.getRoom(roomID)
if err != nil {
return err
}
if _, err := s.membership(s.db, roomID, userID); err != nil {
return err
}
if messageID == 0 || messageID > room.LastMessageID {
messageID = room.LastMessageID
}
return s.db.Model(&model.ChatRoomMember{}).
Where("room_id = ? AND user_id = ? AND last_read_message_id < ?", roomID, userID, messageID).
Update("last_read_message_id", messageID).Error
}
// UnreadSummary 当前用户所有群的未读明细与总数
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
var rows []UnreadRoom
err := s.db.Table("chat_messages AS m").
Select("m.room_id AS room_id, count(*) AS cnt").
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
Where("m.id > rm.last_read_message_id AND m.deleted_at IS NULL AND m.recalled_at IS NULL").
Group("m.room_id").
Scan(&rows).Error
if err != nil {
return 0, nil, err
}
var total int64
for _, r := range rows {
total += r.Count
}
return total, rows, nil
}
// TotalUnread 供 /me SSR 一次调用(Header 红点直出)
func (s *ChatService) TotalUnread(userID uint) int64 {
total, _, _ := s.UnreadSummary(userID)
return total
}