feat(conversations): add notification mute functionality for conversations
Some checks failed
Build Backend / build-docker (push) Has been cancelled
Build Backend / build (push) Has been cancelled

- Rename Muted field to NotificationMuted across model, DTOs and converters
- Add UpdateNotificationMuted repository method with upsert support
- Add SetConversationNotificationMuted service method
- Add PUT /:id/notification_muted route for setting mute status
- Improve S3 storage resilience by skipping bucket check on access denied
This commit is contained in:
lafay
2026-04-25 21:22:52 +08:00
parent 52f62ef230
commit d8b0825ac0
9 changed files with 178 additions and 75 deletions

View File

@@ -35,10 +35,8 @@ func NewS3(cfg *S3Config) (*minio.Client, error) {
exists, err := client.BucketExists(ctx, cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("failed to check bucket: %w", err)
}
if !exists {
// Access Denied 或网络不通时,跳过 bucket 检查,假定 bucket 已存在
} else if !exists {
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{
Region: cfg.Region,
}); err != nil {

View File

@@ -350,26 +350,27 @@ type MessageResponse struct {
// ConversationResponse 会话响应
type ConversationResponse struct {
ID string `json:"id"`
Type string `json:"type"`
IsPinned bool `json:"is_pinned"`
Group *GroupResponse `json:"group,omitempty"`
LastSeq int64 `json:"last_seq"`
LastMessage *MessageResponse `json:"last_message"`
LastMessageAt string `json:"last_message_at"`
UnreadCount int `json:"unread_count"`
Participants []*UserResponse `json:"participants,omitempty"` // 私聊时使用
MemberCount int `json:"member_count,omitempty"` // 聊时使用
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
Type string `json:"type"`
IsPinned bool `json:"is_pinned"`
NotificationMuted bool `json:"notification_muted"`
Group *GroupResponse `json:"group,omitempty"`
LastSeq int64 `json:"last_seq"`
LastMessage *MessageResponse `json:"last_message"`
LastMessageAt string `json:"last_message_at"`
UnreadCount int `json:"unread_count"`
Participants []*UserResponse `json:"participants,omitempty"` // 聊时使用
MemberCount int `json:"member_count,omitempty"` // 群聊时使用
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ConversationParticipantResponse 会话参与者响应
type ConversationParticipantResponse struct {
UserID string `json:"user_id"`
LastReadSeq int64 `json:"last_read_seq"`
Muted bool `json:"muted"`
IsPinned bool `json:"is_pinned"`
UserID string `json:"user_id"`
LastReadSeq int64 `json:"last_read_seq"`
NotificationMuted bool `json:"notification_muted"`
IsPinned bool `json:"is_pinned"`
}
// ==================== Auth DTOs ====================
@@ -453,18 +454,19 @@ type ConversationListResponse struct {
// ConversationDetailResponse 会话详情响应
type ConversationDetailResponse struct {
ID string `json:"id"`
Type string `json:"type"`
IsPinned bool `json:"is_pinned"`
LastSeq int64 `json:"last_seq"`
LastMessage *MessageResponse `json:"last_message"`
LastMessageAt string `json:"last_message_at"`
UnreadCount int64 `json:"unread_count"`
Participants []*UserResponse `json:"participants"`
MyLastReadSeq int64 `json:"my_last_read_seq"` // 当前用户的已读位置
OtherLastReadSeq int64 `json:"other_last_read_seq"` // 对方用户的已读位置
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
Type string `json:"type"`
IsPinned bool `json:"is_pinned"`
NotificationMuted bool `json:"notification_muted"`
LastSeq int64 `json:"last_seq"`
LastMessage *MessageResponse `json:"last_message"`
LastMessageAt string `json:"last_message_at"`
UnreadCount int64 `json:"unread_count"`
Participants []*UserResponse `json:"participants"`
MyLastReadSeq int64 `json:"my_last_read_seq"` // 当前用户的已读位置
OtherLastReadSeq int64 `json:"other_last_read_seq"` // 对方用户的已读位置
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// UnreadCountResponse 未读数响应

View File

@@ -38,7 +38,7 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
// participants: 会话参与者列表(用户信息)
// unreadCount: 当前用户的未读消息数
// lastMessage: 最后一条消息
func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool) *ConversationResponse {
func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool, notificationMuted bool) *ConversationResponse {
if conv == nil {
return nil
}
@@ -55,22 +55,23 @@ func ConvertConversationToResponse(conv *model.Conversation, participants []*mod
}
return &ConversationResponse{
ID: conv.ID,
Type: string(conv.Type),
IsPinned: isPinned,
Group: groupResponse,
LastSeq: conv.LastSeq,
LastMessage: ConvertMessageToResponse(lastMessage),
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
UnreadCount: unreadCount,
Participants: participantResponses,
CreatedAt: FormatTime(conv.CreatedAt),
UpdatedAt: FormatTime(conv.UpdatedAt),
ID: conv.ID,
Type: string(conv.Type),
IsPinned: isPinned,
NotificationMuted: notificationMuted,
Group: groupResponse,
LastSeq: conv.LastSeq,
LastMessage: ConvertMessageToResponse(lastMessage),
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
UnreadCount: unreadCount,
Participants: participantResponses,
CreatedAt: FormatTime(conv.CreatedAt),
UpdatedAt: FormatTime(conv.UpdatedAt),
}
}
// ConvertConversationToDetailResponse 将Conversation转换为ConversationDetailResponse
func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool) *ConversationDetailResponse {
func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool, notificationMuted bool) *ConversationDetailResponse {
if conv == nil {
return nil
}
@@ -81,18 +82,19 @@ func ConvertConversationToDetailResponse(conv *model.Conversation, participants
}
return &ConversationDetailResponse{
ID: conv.ID,
Type: string(conv.Type),
IsPinned: isPinned,
LastSeq: conv.LastSeq,
LastMessage: ConvertMessageToResponse(lastMessage),
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
UnreadCount: unreadCount,
Participants: participantResponses,
MyLastReadSeq: myLastReadSeq,
OtherLastReadSeq: otherLastReadSeq,
CreatedAt: FormatTime(conv.CreatedAt),
UpdatedAt: FormatTime(conv.UpdatedAt),
ID: conv.ID,
Type: string(conv.Type),
IsPinned: isPinned,
NotificationMuted: notificationMuted,
LastSeq: conv.LastSeq,
LastMessage: ConvertMessageToResponse(lastMessage),
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
UnreadCount: unreadCount,
Participants: participantResponses,
MyLastReadSeq: myLastReadSeq,
OtherLastReadSeq: otherLastReadSeq,
CreatedAt: FormatTime(conv.CreatedAt),
UpdatedAt: FormatTime(conv.UpdatedAt),
}
}
@@ -109,7 +111,7 @@ func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse {
func ConvertConversationsToResponse(convs []*model.Conversation) []*ConversationResponse {
result := make([]*ConversationResponse, 0, len(convs))
for _, conv := range convs {
result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false))
result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false, false))
}
return result
}

View File

@@ -97,16 +97,17 @@ func (h *MessageHandler) GetConversations(c *gin.Context) {
var resp *dto.ConversationResponse
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
// 群聊:实时计算群成员数量
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
// 创建响应并设置member_count
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned)
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
resp.MemberCount = memberCount
} else {
// 私聊:获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
}
result[i] = resp
}
@@ -153,8 +154,9 @@ func (h *MessageHandler) CreateConversation(c *gin.Context) {
participants := []*model.User{targetUser}
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned))
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
}
// GetConversationByID 获取会话详情
@@ -188,18 +190,20 @@ func (h *MessageHandler) GetConversationByID(c *gin.Context) {
// 获取当前用户的已读位置
myLastReadSeq := int64(0)
isPinned := false
notificationMuted := false
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
for _, p := range allParticipants {
if p.UserID == userID {
myLastReadSeq = p.LastReadSeq
isPinned = p.IsPinned
notificationMuted = p.NotificationMuted
break
}
}
// 获取对方用户的已读位置
otherLastReadSeq := int64(0)
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned))
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
}
// GetMessages 获取消息列表
@@ -451,16 +455,17 @@ func (h *MessageHandler) HandleGetConversationList(c *gin.Context) {
var resp *dto.ConversationResponse
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
// 群聊:实时计算群成员数量
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
// 创建响应并设置member_count
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned)
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
resp.MemberCount = memberCount
} else {
// 私聊:获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
}
result[i] = resp
}
@@ -704,8 +709,9 @@ func (h *MessageHandler) HandleCreateConversation(c *gin.Context) {
participants := []*model.User{targetUser}
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned))
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
}
// HandleGetConversation 获取会话详情
@@ -738,18 +744,20 @@ func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
// 获取当前用户的已读位置
myLastReadSeq := int64(0)
isPinned := false
notificationMuted := false
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
for _, p := range allParticipants {
if p.UserID == userID {
myLastReadSeq = p.LastReadSeq
isPinned = p.IsPinned
notificationMuted = p.NotificationMuted
break
}
}
// 获取对方用户的已读位置
otherLastReadSeq := int64(0)
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned))
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
}
// HandleGetMessages 获取会话消息
@@ -903,6 +911,40 @@ func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
})
}
// HandleSetConversationNotificationMuted 设置会话免打扰状态
// PUT /api/v1/conversations/:id/notification_muted
func (h *MessageHandler) HandleSetConversationNotificationMuted(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "")
return
}
conversationID := getIDParam(c, "id")
if conversationID == "" {
response.BadRequest(c, "conversation id is required")
return
}
var req struct {
NotificationMuted bool `json:"notification_muted"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.chatService.SetConversationNotificationMuted(c.Request.Context(), conversationID, userID, req.NotificationMuted); err != nil {
response.BadRequest(c, err.Error())
return
}
response.SuccessWithMessage(c, "conversation notification_muted status updated", gin.H{
"conversation_id": conversationID,
"notification_muted": req.NotificationMuted,
})
}
// ==================== Cursor Pagination Handlers ====================
// GetMessagesByCursor 游标分页获取会话消息
@@ -993,15 +1035,16 @@ func (h *MessageHandler) GetConversationsByCursor(c *gin.Context) {
var resp *dto.ConversationResponse
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
isPinned := myParticipant != nil && myParticipant.IsPinned
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
// 群聊:实时计算群成员数量
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned)
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
resp.MemberCount = memberCount
} else {
// 私聊:获取参与者信息
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
}
items[i] = resp
}

View File

@@ -56,7 +56,7 @@ type ConversationParticipant struct {
ConversationID string `gorm:"not null;size:20;uniqueIndex:idx_conversation_user,priority:1;index:idx_cp_conversation_user,priority:1" json:"conversation_id"` // 雪花算法IDstring类型
UserID string `gorm:"column:user_id;type:varchar(50);not null;uniqueIndex:idx_conversation_user,priority:2;index:idx_cp_conversation_user,priority:2;index:idx_cp_user_hidden_pinned_updated,priority:1" json:"user_id"` // UUID格式与JWT中user_id保持一致
LastReadSeq int64 `gorm:"default:0" json:"last_read_seq"` // 已读到的seq位置
Muted bool `gorm:"default:false" json:"muted"` // 是否免打扰
NotificationMuted bool `gorm:"column:muted;default:false" json:"notification_muted"` // 是否免打扰
IsPinned bool `gorm:"default:false;index:idx_cp_user_hidden_pinned_updated,priority:3" json:"is_pinned"` // 是否置顶会话(用户维度)
HiddenAt *time.Time `gorm:"index:idx_cp_user_hidden_pinned_updated,priority:2" json:"hidden_at,omitempty"` // 仅自己删除会话时使用,收到新消息后自动恢复
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`

View File

@@ -32,13 +32,12 @@ func New(cfg *config.S3Config) (*Client, error) {
return nil, fmt.Errorf("failed to create S3 client: %w", err)
}
// 检查bucket是否存在
// 检查bucket是否存在,权限不足时跳过检查(不阻塞启动)
exists, err := client.BucketExists(ctx, cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("failed to check bucket: %w", err)
}
if !exists {
// Access Denied 或网络不通时,跳过 bucket 检查,假定 bucket 已存在
// 后续上传时会暴露具体错误
} else if !exists {
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{
Region: cfg.Region,
}); err != nil {

View File

@@ -28,6 +28,7 @@ type MessageRepository interface {
GetParticipant(conversationID string, userID string) (*model.ConversationParticipant, error)
UpdateLastReadSeq(conversationID string, userID string, lastReadSeq int64) error
UpdatePinned(conversationID string, userID string, isPinned bool) error
UpdateNotificationMuted(conversationID string, userID string, notificationMuted bool) error
GetUnreadCount(conversationID string, userID string) (int64, error)
UpdateConversationLastSeq(conversationID string, seq int64) error
GetNextSeq(conversationID string) (int64, error)
@@ -337,6 +338,36 @@ func (r *messageRepository) UpdatePinned(conversationID string, userID string, i
return nil
}
// UpdateNotificationMuted 更新会话免打扰状态(用户维度)
func (r *messageRepository) UpdateNotificationMuted(conversationID string, userID string, notificationMuted bool) error {
result := r.db.Model(&model.ConversationParticipant{}).
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
Update("muted", notificationMuted)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "conversation_id"},
{Name: "user_id"},
},
DoUpdates: clause.Assignments(map[string]any{
"muted": notificationMuted,
"updated_at": gorm.Expr("CURRENT_TIMESTAMP"),
}),
}).Create(&model.ConversationParticipant{
ConversationID: conversationID,
UserID: userID,
NotificationMuted: notificationMuted,
}).Error
}
return nil
}
// GetUnreadCount 获取未读消息数
// userID 参数为 string 类型UUID格式与JWT中user_id保持一致
func (r *messageRepository) GetUnreadCount(conversationID string, userID string) (int64, error) {

View File

@@ -373,6 +373,7 @@ func (r *Router) setupRoutes() {
conversations.POST("/:id/messages", middleware.RequireVerified(r.userRepo), r.messageHandler.HandleSendMessage) // 发送消息
conversations.POST("/:id/read", r.messageHandler.HandleMarkRead) // 标记已读
conversations.PUT("/:id/pinned", r.messageHandler.HandleSetConversationPinned) // 置顶设置
conversations.PUT("/:id/notification_muted", r.messageHandler.HandleSetConversationNotificationMuted) // 免打扰设置
conversations.GET("/unread/count", r.messageHandler.GetUnreadCount) // 未读数
conversations.POST("/:id/typing", r.messageHandler.HandleTyping) // 输入状态
conversations.DELETE("/:id/self", r.messageHandler.HandleDeleteConversationForSelf) // 删除会话(仅自己)

View File

@@ -27,7 +27,8 @@ type ChatService interface {
GetConversationList(ctx context.Context, userID string, page, pageSize int) ([]*model.Conversation, int64, error)
GetConversationByID(ctx context.Context, conversationID string, userID string) (*model.Conversation, error)
DeleteConversationForSelf(ctx context.Context, conversationID string, userID string) error
SetConversationPinned(ctx context.Context, conversationID string, userID string, isPinned bool) error
SetConversationPinned(ctx context.Context, conversationID string, userID string, isPinned bool) error
SetConversationNotificationMuted(ctx context.Context, conversationID string, userID string, notificationMuted bool) error
// 消息操作
SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error)
@@ -215,6 +216,32 @@ func (s *chatServiceImpl) SetConversationPinned(ctx context.Context, conversatio
return nil
}
// SetConversationNotificationMuted 设置会话免打扰状态(用户维度)
func (s *chatServiceImpl) SetConversationNotificationMuted(ctx context.Context, conversationID string, userID string, notificationMuted bool) error {
participant, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("conversation not found or no permission")
}
return fmt.Errorf("failed to get participant: %w", err)
}
if participant.ConversationID == "" {
return errors.New("conversation not found or no permission")
}
if err := s.repo.UpdateNotificationMuted(conversationID, userID, notificationMuted); err != nil {
return fmt.Errorf("failed to update notification_muted status: %w", err)
}
// 失效缓存
if s.conversationCache != nil {
s.conversationCache.InvalidateParticipant(conversationID, userID)
s.conversationCache.InvalidateConversationList(userID)
}
return nil
}
// SendMessage 发送消息(使用 segments
func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) {
// 首先验证会话是否存在