package service import ( "errors" "fmt" "regexp" "strings" "time" "unicode/utf8" "git.iioio.com/freefire/jiang13-forum/model" "gorm.io/gorm" ) var ( ErrCannotMessageSelf = errors.New("不能给自己发私信") ) type MessageService struct { filter *SensitiveFilter settings *ForumSettingsService } func NewMessageService(filter *SensitiveFilter, settings *ForumSettingsService) *MessageService { return &MessageService{filter: filter, settings: settings} } type MessageSendInput struct { FromUserID uint ToUserID uint Subject string Content string Kind string RelatedPostID *uint RelatedReportID *uint RelatedCommentID *uint RelatedFloor *int } // Send 发送私信(用户互发或系统通知) func (s *MessageService) Send(in MessageSendInput) (*model.PrivateMessage, error) { if in.ToUserID == 0 { return nil, errors.New("收件人不存在") } if in.FromUserID > 0 && in.FromUserID == in.ToUserID { return nil, ErrCannotMessageSelf } if in.FromUserID > 0 { var to model.User if err := model.DB.Select("id", "banned").First(&to, in.ToUserID).Error; err != nil { return nil, errors.New("收件人不存在") } if to.Banned { return nil, errors.New("对方账号已被禁言,暂时无法私信") } } subject := strings.TrimSpace(in.Subject) content := strings.TrimSpace(in.Content) if content == "" { return nil, errors.New("请填写内容") } // 会话式私信可不填标题,用正文摘要兜底 if subject == "" { subject = truncateRunes(content, 40) } if utf8.RuneCountInString(subject) > 80 { return nil, errors.New("标题过长") } if utf8.RuneCountInString(content) > 4000 { return nil, errors.New("内容过长") } if s.filter != nil { subject = s.filter.Filter(subject) content = s.filter.Filter(content) } kind := in.Kind if kind == "" { if in.FromUserID == 0 { kind = model.MessageKindSystem } else { kind = model.MessageKindUser } } msg := &model.PrivateMessage{ FromUserID: in.FromUserID, ToUserID: in.ToUserID, Subject: subject, Content: content, Kind: kind, RelatedPostID: in.RelatedPostID, RelatedReportID: in.RelatedReportID, RelatedCommentID: in.RelatedCommentID, RelatedFloor: in.RelatedFloor, IsRead: false, } if err := model.DB.Create(msg).Error; err != nil { return nil, err } _ = model.DB.Preload("FromUser").Preload("ToUser").First(msg, msg.ID).Error return msg, nil } // SystemNotifyRefs 系统通知关联目标(帖子 / 评论 / 举报) type SystemNotifyRefs struct { PostID *uint ReportID *uint CommentID *uint Floor *int } // SendSystem 系统私信(管理员/系统 → 用户) func (s *MessageService) SendSystem(toUserID uint, subject, content, kind string, relatedPostID, relatedReportID *uint) (*model.PrivateMessage, error) { return s.SendSystemWithRefs(toUserID, subject, content, kind, SystemNotifyRefs{ PostID: relatedPostID, ReportID: relatedReportID, }) } // SendSystemWithRefs 系统私信(可附带评论楼层深链) func (s *MessageService) SendSystemWithRefs(toUserID uint, subject, content, kind string, refs SystemNotifyRefs) (*model.PrivateMessage, error) { if kind == "" { kind = model.MessageKindSystem } return s.Send(MessageSendInput{ FromUserID: 0, ToUserID: toUserID, Subject: subject, Content: content, Kind: kind, RelatedPostID: refs.PostID, RelatedReportID: refs.ReportID, RelatedCommentID: refs.CommentID, RelatedFloor: refs.Floor, }) } // MarkMessageRead 将单条消息标为已读(仅收件人本人) func (s *MessageService) MarkMessageRead(userID, messageID uint) error { if messageID == 0 { return errors.New("无效的消息") } res := model.DB.Model(&model.PrivateMessage{}). Where("id = ? AND to_user_id = ? AND is_read = ?", messageID, userID, false). Update("is_read", true) if res.Error != nil { return res.Error } return nil } // MarkAllRead 全部标为已读 func (s *MessageService) MarkAllRead(userID uint) error { return model.DB.Model(&model.PrivateMessage{}). Where("to_user_id = ? AND is_read = ?", userID, false). Update("is_read", true).Error } // UnreadCount 未读数 func (s *MessageService) UnreadCount(userID uint) (int64, error) { var n int64 err := model.DB.Model(&model.PrivateMessage{}). Where("to_user_id = ? AND is_read = ?", userID, false). Count(&n).Error return n, err } // UnreadCounts 未读总数,以及私信 / 系统通知分项 func (s *MessageService) UnreadCounts(userID uint) (total, dm, notify int64, err error) { err = model.DB.Model(&model.PrivateMessage{}). Where("to_user_id = ? AND is_read = ?", userID, false). Count(&total).Error if err != nil { return 0, 0, 0, err } err = model.DB.Model(&model.PrivateMessage{}). Where("to_user_id = ? AND is_read = ? AND from_user_id = 0", userID, false). Count(¬ify).Error if err != nil { return 0, 0, 0, err } dm = total - notify if dm < 0 { dm = 0 } return total, dm, notify, nil } // ListNotifications 系统通知列表(按时间倒序,非聊天气泡) func (s *MessageService) ListNotifications(userID uint, page, size int, kind string) ([]model.PrivateMessage, int64, error) { if page < 1 { page = 1 } size = s.settings.NormalizePageSize(size) db := model.DB.Model(&model.PrivateMessage{}). Where("from_user_id = 0 AND to_user_id = ?", userID) kind = strings.TrimSpace(kind) if kind != "" && kind != "all" { db = db.Where("kind = ?", kind) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var list []model.PrivateMessage err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list).Error if err != nil { return nil, 0, err } if list == nil { list = []model.PrivateMessage{} } s.enrichModerationStatus(list) return list, total, nil } // enrichModerationStatus 为待审通知回填目标当前审核状态 func (s *MessageService) enrichModerationStatus(list []model.PrivateMessage) { if len(list) == 0 { return } resolvedByIndex := enrichModerationCommentIDs(list) commentIDs := make([]uint, 0, len(list)) postIDs := make([]uint, 0, len(list)) // 历史评论通知:按帖+楼层回查(兜底) type pfKey struct { PostID uint Floor int } pfNeeded := make([]pfKey, 0, len(list)) seenC := map[uint]struct{}{} seenP := map[uint]struct{}{} seenPF := map[pfKey]struct{}{} for i := range list { m := &list[i] if m.Kind != model.MessageKindModeration { continue } if m.RelatedCommentID != nil && *m.RelatedCommentID > 0 { id := *m.RelatedCommentID if _, ok := seenC[id]; !ok { seenC[id] = struct{}{} commentIDs = append(commentIDs, id) } continue } if cid, ok := resolvedByIndex[i]; ok && cid > 0 { if _, ok := seenC[cid]; !ok { seenC[cid] = struct{}{} commentIDs = append(commentIDs, cid) } continue } if m.RelatedPostID == nil || *m.RelatedPostID == 0 { continue } pid := *m.RelatedPostID if looksLikeModerationComment(m.Subject, m.Content) { floor := 0 if m.RelatedFloor != nil && *m.RelatedFloor > 0 { floor = *m.RelatedFloor } else { floor = parseNotifyFloor(m.Content) } if floor > 0 { k := pfKey{PostID: pid, Floor: floor} if _, ok := seenPF[k]; !ok { seenPF[k] = struct{}{} pfNeeded = append(pfNeeded, k) } } continue } if _, ok := seenP[pid]; !ok { seenP[pid] = struct{}{} postIDs = append(postIDs, pid) } } commentStatus := map[uint]string{} if len(commentIDs) > 0 { type row struct { ID uint Status string DeletedAt gorm.DeletedAt } var rows []row _ = model.DB.Unscoped().Model(&model.Comment{}). Select("id", "status", "deleted_at"). Where("id IN ?", commentIDs). Find(&rows) for _, r := range rows { commentStatus[r.ID] = contentStatusOrDeleted(r.Status, r.DeletedAt) } for _, id := range commentIDs { if _, ok := commentStatus[id]; !ok { commentStatus[id] = "deleted" } } } statusByPF := map[pfKey]string{} if len(pfNeeded) > 0 { postSet := map[uint]struct{}{} for _, k := range pfNeeded { postSet[k.PostID] = struct{}{} } pids := make([]uint, 0, len(postSet)) for id := range postSet { pids = append(pids, id) } type row struct { PostID uint Floor int Status string DeletedAt gorm.DeletedAt } var rows []row _ = model.DB.Unscoped().Model(&model.Comment{}). Select("post_id", "floor", "status", "deleted_at"). Where("post_id IN ?", pids). Find(&rows) for _, r := range rows { k := pfKey{PostID: r.PostID, Floor: r.Floor} // 同楼多条时后者覆盖;正常业务一帖一楼唯一 statusByPF[k] = contentStatusOrDeleted(r.Status, r.DeletedAt) } for _, k := range pfNeeded { if _, ok := statusByPF[k]; !ok { statusByPF[k] = "deleted" } } } postStatus := map[uint]string{} if len(postIDs) > 0 { type row struct { ID uint Status string DeletedAt gorm.DeletedAt } var rows []row _ = model.DB.Unscoped().Model(&model.Post{}). Select("id", "status", "deleted_at"). Where("id IN ?", postIDs). Find(&rows) for _, r := range rows { postStatus[r.ID] = contentStatusOrDeleted(r.Status, r.DeletedAt) } for _, id := range postIDs { if _, ok := postStatus[id]; !ok { postStatus[id] = "deleted" } } } for i := range list { m := &list[i] if m.Kind != model.MessageKindModeration { continue } if m.RelatedCommentID != nil && *m.RelatedCommentID > 0 { m.RelatedStatus = commentStatus[*m.RelatedCommentID] continue } if cid, ok := resolvedByIndex[i]; ok && cid > 0 { m.RelatedStatus = commentStatus[cid] continue } if m.RelatedPostID == nil || *m.RelatedPostID == 0 { continue } pid := *m.RelatedPostID if looksLikeModerationComment(m.Subject, m.Content) { floor := 0 if m.RelatedFloor != nil && *m.RelatedFloor > 0 { floor = *m.RelatedFloor } else { floor = parseNotifyFloor(m.Content) } if floor > 0 { m.RelatedStatus = statusByPF[pfKey{PostID: pid, Floor: floor}] } continue } m.RelatedStatus = postStatus[pid] } } var notifyFloorRe = regexp.MustCompile(`#(\d+)\s*楼`) // parseNotifyFloor 从待审评论文案解析楼号(如「#2 楼评论」「#1 楼下」) func parseNotifyFloor(content string) int { m := notifyFloorRe.FindStringSubmatch(content) if len(m) < 2 { return 0 } var n int _, _ = fmt.Sscanf(m[1], "%d", &n) if n < 0 { return 0 } return n } func contentStatusOrDeleted(status string, deletedAt gorm.DeletedAt) string { if deletedAt.Valid { return "deleted" } if status != "" { return status } return model.ContentStatusPublished } // MarkNotificationsRead 将系统通知全部标为已读 func (s *MessageService) MarkNotificationsRead(userID uint) error { return s.MarkConversationRead(userID, 0) } // MessageConversation 按对方聚合的会话摘要 type MessageConversation struct { PeerUserID uint `json:"peer_user_id"` // 0 = 系统通知 PeerUser *model.User `json:"peer_user,omitempty"` IsSystem bool `json:"is_system"` LastMessage *model.PrivateMessage `json:"last_message,omitempty"` UnreadCount int64 `json:"unread_count"` UpdatedAt time.Time `json:"updated_at"` } type ConversationListQuery struct { UserID uint Page int Size int } type ConversationMessagesQuery struct { UserID uint PeerID uint // 0 = 系统通知 Page int Size int Before uint // 可选:加载更早消息(id < Before) } // ListConversations 会话列表(按对方聚合,最近消息优先) func (s *MessageService) ListConversations(q ConversationListQuery) ([]MessageConversation, int64, error) { if q.Page < 1 { q.Page = 1 } q.Size = s.settings.NormalizePageSize(q.Size) type peerRow struct { PeerID uint LastID uint } var rows []peerRow // peer_id:系统通知为 0;否则为对话另一方 err := model.DB.Raw(` SELECT CASE WHEN from_user_id = 0 THEN 0 WHEN from_user_id = ? THEN to_user_id ELSE from_user_id END AS peer_id, MAX(id) AS last_id FROM private_messages WHERE to_user_id = ? OR from_user_id = ? GROUP BY peer_id ORDER BY last_id DESC LIMIT ? OFFSET ? `, q.UserID, q.UserID, q.UserID, q.Size, (q.Page-1)*q.Size).Scan(&rows).Error if err != nil { return nil, 0, err } var total int64 err = model.DB.Raw(` SELECT COUNT(*) FROM ( SELECT CASE WHEN from_user_id = 0 THEN 0 WHEN from_user_id = ? THEN to_user_id ELSE from_user_id END AS peer_id FROM private_messages WHERE to_user_id = ? OR from_user_id = ? GROUP BY peer_id ) `, q.UserID, q.UserID, q.UserID).Scan(&total).Error if err != nil { return nil, 0, err } if len(rows) == 0 { return []MessageConversation{}, total, nil } lastIDs := make([]uint, len(rows)) peerIDs := make([]uint, 0, len(rows)) for i, r := range rows { lastIDs[i] = r.LastID if r.PeerID > 0 { peerIDs = append(peerIDs, r.PeerID) } } var lastMsgs []model.PrivateMessage if err := model.DB.Preload("FromUser").Preload("ToUser"). Where("id IN ?", lastIDs).Find(&lastMsgs).Error; err != nil { return nil, 0, err } msgByID := make(map[uint]model.PrivateMessage, len(lastMsgs)) for i := range lastMsgs { msgByID[lastMsgs[i].ID] = lastMsgs[i] } usersByID := make(map[uint]model.User) if len(peerIDs) > 0 { var users []model.User if err := model.DB.Where("id IN ?", peerIDs).Find(&users).Error; err != nil { return nil, 0, err } for i := range users { usersByID[users[i].ID] = users[i] } } type unreadRow struct { PeerID uint Cnt int64 } var unreadRows []unreadRow _ = model.DB.Raw(` SELECT CASE WHEN from_user_id = 0 THEN 0 ELSE from_user_id END AS peer_id, COUNT(*) AS cnt FROM private_messages WHERE to_user_id = ? AND is_read = 0 GROUP BY peer_id `, q.UserID).Scan(&unreadRows) unreadByPeer := make(map[uint]int64, len(unreadRows)) for _, u := range unreadRows { unreadByPeer[u.PeerID] = u.Cnt } out := make([]MessageConversation, 0, len(rows)) for _, r := range rows { msg, ok := msgByID[r.LastID] if !ok { continue } conv := MessageConversation{ PeerUserID: r.PeerID, IsSystem: r.PeerID == 0, LastMessage: &msg, UnreadCount: unreadByPeer[r.PeerID], UpdatedAt: msg.CreatedAt, } if r.PeerID > 0 { if u, ok := usersByID[r.PeerID]; ok { uu := u conv.PeerUser = &uu } } out = append(out, conv) } return out, total, nil } // ListConversationMessages 某会话内消息(时间正序,支持 Before 向上翻页) func (s *MessageService) ListConversationMessages(q ConversationMessagesQuery) ([]model.PrivateMessage, int64, error) { if q.Page < 1 { q.Page = 1 } q.Size = s.settings.NormalizePageSize(q.Size) countDB := model.DB.Model(&model.PrivateMessage{}) if q.PeerID == 0 { countDB = countDB.Where("from_user_id = 0 AND to_user_id = ?", q.UserID) } else { countDB = countDB.Where( "(from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)", q.UserID, q.PeerID, q.PeerID, q.UserID, ) } var total int64 if err := countDB.Count(&total).Error; err != nil { return nil, 0, err } qdb := model.DB.Preload("FromUser").Preload("ToUser") if q.PeerID == 0 { qdb = qdb.Where("from_user_id = 0 AND to_user_id = ?", q.UserID) } else { qdb = qdb.Where( "(from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)", q.UserID, q.PeerID, q.PeerID, q.UserID, ) } if q.Before > 0 { qdb = qdb.Where("id < ?", q.Before) } var list []model.PrivateMessage // 先按 id desc 取一页,再反转为正序(聊天从旧到新) err := qdb.Order("id desc").Limit(q.Size).Find(&list).Error if err != nil { return nil, 0, err } for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 { list[i], list[j] = list[j], list[i] } return list, total, nil } // MarkConversationRead 将会话内未读标为已读 func (s *MessageService) MarkConversationRead(userID, peerID uint) error { db := model.DB.Model(&model.PrivateMessage{}). Where("to_user_id = ? AND is_read = ?", userID, false) if peerID == 0 { db = db.Where("from_user_id = 0") } else { db = db.Where("from_user_id = ?", peerID) } return db.Update("is_read", true).Error } // FormatRejectContent 拒帖私信正文 func FormatRejectContent(postTitle string, postID uint, reason string) string { return fmt.Sprintf( "你的帖子《%s》(#%d)未通过审核。\n\n原因:\n%s\n\n如有疑问,可回复本私信联系管理员。", postTitle, postID, strings.TrimSpace(reason), ) } // FormatCommentRejectContent 拒评论私信正文 func FormatCommentRejectContent(postTitle string, postID uint, floor int, reason string) string { return fmt.Sprintf( "你在帖子《%s》(#%d)中的评论(#%d 楼)未通过审核。\n\n原因:\n%s\n\n如有疑问,可回复本私信联系管理员。", postTitle, postID, floor, strings.TrimSpace(reason), ) }