Files
jiang13-forum/service/message.go

649 lines
17 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
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(&notify).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),
)
}