package service import ( "fmt" "strings" "unicode/utf8" "git.iioio.com/freefire/jiang13-forum/model" ) // NotifyService 站内消息 + 邮件提醒编排 type NotifyService struct { messages *MessageService mail *MailService settings *ForumSettingsService } func NewNotifyService(messages *MessageService, mail *MailService, settings *ForumSettingsService) *NotifyService { return &NotifyService{messages: messages, mail: mail, settings: settings} } // 后台执行通知,不阻塞 HTTP 响应;panic 仅记日志 func (s *NotifyService) goNotify(fn func()) { if s == nil || fn == nil { return } go func() { defer func() { if r := recover(); r != nil { fmt.Printf("notify: 后台任务异常: %v\n", r) } }() fn() }() } // AsyncNotifyCommentPublished 异步:评论公开后通知被回复者或楼主 func (s *NotifyService) AsyncNotifyCommentPublished(comment *model.Comment) { if s == nil || comment == nil { return } cp := *comment s.goNotify(func() { s.NotifyCommentPublished(&cp) }) } // AsyncNotifyPendingPost 异步:待审帖通知管理员 func (s *NotifyService) AsyncNotifyPendingPost(post *model.Post) { if s == nil || post == nil { return } cp := *post s.goNotify(func() { s.NotifyPendingPost(&cp) }) } // AsyncNotifyPendingComment 异步:待审评论通知管理员 func (s *NotifyService) AsyncNotifyPendingComment(comment *model.Comment) { if s == nil || comment == nil { return } cp := *comment s.goNotify(func() { s.NotifyPendingComment(&cp) }) } // NotifyCommentPublished 评论公开后通知被回复者或楼主 func (s *NotifyService) NotifyCommentPublished(comment *model.Comment) { if s == nil || comment == nil || comment.Status != model.ContentStatusPublished { return } post, err := s.loadPost(comment.PostID) if err != nil { return } toUserID, err := s.resolveReplyRecipient(comment, post) if err != nil || toUserID == 0 || toUserID == comment.UserID { return } authorName := s.commentAuthorName(comment) title := post.Title if title == "" { title = "未知帖子" } displayFloor := s.resolveDisplayFloor(comment) isNested := comment.ReplyTo != nil && *comment.ReplyTo > 0 subject := "收到新回复" content := FormatReplyContent(authorName, title, displayFloor, isNested) pid := comment.PostID _, _ = s.messages.SendSystem(toUserID, subject, content, model.MessageKindReply, &pid, nil) s.sendReplyMail(toUserID, authorName, title, comment.PostID, displayFloor, isNested, comment.Content) } // NotifyPendingPost 新帖进入待审时通知全部管理员 func (s *NotifyService) NotifyPendingPost(post *model.Post) { if s == nil || post == nil || post.Status != model.ContentStatusPending { return } title := strings.TrimSpace(post.Title) if title == "" { title = "无标题" } authorName := s.userDisplayName(post.UserID) subject := "新的待审核帖子" content := FormatPendingPostContent(authorName, title, post.ID) pid := post.ID s.notifyAdmins(subject, content, model.MessageKindModeration, &pid, func(siteName, baseURL string) (string, string, string) { return BuildModerationMail(siteName, "帖子", authorName, title, post.ID, 0, false, AbsoluteURL(baseURL, "/admin/posts")) }) } // NotifyPendingComment 新评论进入待审时通知全部管理员 func (s *NotifyService) NotifyPendingComment(comment *model.Comment) { if s == nil || comment == nil || comment.Status != model.ContentStatusPending { return } post, err := s.loadPost(comment.PostID) if err != nil { return } title := strings.TrimSpace(post.Title) if title == "" { title = "未知帖子" } authorName := s.commentAuthorName(comment) subject := "新的待审核评论" displayFloor := s.resolveDisplayFloor(comment) isNested := comment.ReplyTo != nil && *comment.ReplyTo > 0 content := FormatPendingCommentContent(authorName, title, displayFloor, isNested) pid := comment.PostID s.notifyAdmins(subject, content, model.MessageKindModeration, &pid, func(siteName, baseURL string) (string, string, string) { return BuildModerationMail(siteName, "评论", authorName, title, comment.PostID, displayFloor, isNested, AbsoluteURL(baseURL, "/admin/comments")) }) } func (s *NotifyService) notifyAdmins( subject, content, kind string, relatedPostID *uint, buildMail func(siteName, baseURL string) (subj, text, html string), ) { admins, err := s.listAdmins() if err != nil || len(admins) == 0 { return } seenEmail := make(map[string]struct{}) siteName := s.siteName() baseURL := s.settings.SitePublicBaseURL("") mailSubj, mailText, mailHTML := "", "", "" if s.mail != nil && s.settings.MailReady() { mailSubj, mailText, mailHTML = buildMail(siteName, baseURL) } for _, admin := range admins { _, _ = s.messages.SendSystem(admin.ID, subject, content, kind, relatedPostID, nil) email := strings.TrimSpace(admin.Email) if email == "" || mailSubj == "" { continue } key := strings.ToLower(email) if _, ok := seenEmail[key]; ok { continue } seenEmail[key] = struct{}{} _ = s.mail.SendHTML(email, mailSubj, mailText, mailHTML) } } func (s *NotifyService) sendReplyMail(toUserID uint, authorName, postTitle string, postID uint, displayFloor int, isNested bool, rawContent string) { if s.mail == nil || !s.settings.MailReady() { return } var user model.User if err := model.DB.Select("id", "email", "nickname", "username").First(&user, toUserID).Error; err != nil { return } email := strings.TrimSpace(user.Email) if email == "" { return } siteName := s.siteName() baseURL := s.settings.SitePublicBaseURL("") postPath := s.settings.Permalink().PostPath(postID) link := AbsoluteURL(baseURL, postPath) excerpt := truncateNotifyExcerpt(rawContent, 120) subj, text, html := BuildReplyMail(siteName, authorName, postTitle, displayFloor, isNested, excerpt, link) _ = s.mail.SendHTML(email, subj, text, html) } func (s *NotifyService) resolveReplyRecipient(comment *model.Comment, post *model.Post) (uint, error) { if comment.ReplyTo != nil && *comment.ReplyTo > 0 { var target model.Comment if err := model.DB.Select("id", "user_id", "post_id"). Where("id = ? AND post_id = ?", *comment.ReplyTo, comment.PostID). First(&target).Error; err != nil { return 0, err } if target.UserID > 0 { return target.UserID, nil } // 游客评论无用户账号,回退到楼主 } return post.UserID, nil } // resolveDisplayFloor 解析页面可见的顶层楼号(子回复沿 reply_to 上溯) func (s *NotifyService) resolveDisplayFloor(comment *model.Comment) int { if comment == nil { return 0 } if comment.ReplyTo == nil || *comment.ReplyTo == 0 { return comment.Floor } curID := *comment.ReplyTo seen := make(map[uint]struct{}, 8) for i := 0; i < 64; i++ { if _, ok := seen[curID]; ok { break } seen[curID] = struct{}{} var ancestor model.Comment if err := model.DB.Select("id", "floor", "reply_to"). Where("id = ? AND post_id = ?", curID, comment.PostID). First(&ancestor).Error; err != nil { return comment.Floor } if ancestor.ReplyTo == nil || *ancestor.ReplyTo == 0 { return ancestor.Floor } curID = *ancestor.ReplyTo } return comment.Floor } func (s *NotifyService) loadPost(postID uint) (*model.Post, error) { var post model.Post if err := model.DB.Select("id", "user_id", "title", "status").First(&post, postID).Error; err != nil { return nil, err } return &post, nil } func (s *NotifyService) listAdmins() ([]model.User, error) { var admins []model.User err := model.DB.Select("id", "email", "nickname", "username"). Where("role = ? AND banned = ?", model.RoleAdmin, false). Find(&admins).Error return admins, err } func (s *NotifyService) siteName() string { name := strings.TrimSpace(s.settings.SiteBranding().Name) if name == "" { return "姜十三论坛" } return name } func (s *NotifyService) commentAuthorName(comment *model.Comment) string { if comment.UserID > 0 { if comment.User.ID == comment.UserID { if n := DisplayName(&comment.User); n != "" { return n } } return s.userDisplayName(comment.UserID) } if nick := strings.TrimSpace(comment.GuestNick); nick != "" { return nick } return "游客" } func (s *NotifyService) userDisplayName(userID uint) string { if userID == 0 { return "用户" } var u model.User if err := model.DB.Select("id", "nickname", "username").First(&u, userID).Error; err != nil { return fmt.Sprintf("用户 #%d", userID) } if n := DisplayName(&u); n != "" { return n } return fmt.Sprintf("用户 #%d", userID) } // FormatReplyContent 回复站内私信正文(floor 为可见顶层楼号) func FormatReplyContent(authorName, postTitle string, displayFloor int, isNested bool) string { if isNested { return fmt.Sprintf("%s 在《%s》#%d 楼下回复了你。", authorName, postTitle, displayFloor) } return fmt.Sprintf("%s 在《%s》发表了 #%d 楼。", authorName, postTitle, displayFloor) } // FormatPendingPostContent 待审帖站内私信正文 func FormatPendingPostContent(authorName, postTitle string, postID uint) string { return fmt.Sprintf( "用户 %s 提交了待审核帖子《%s》(#%d),请前往管理后台处理。", authorName, postTitle, postID, ) } // FormatPendingCommentContent 待审评论站内私信正文(floor 为可见顶层楼号) func FormatPendingCommentContent(authorName, postTitle string, displayFloor int, isNested bool) string { if isNested { return fmt.Sprintf( "用户 %s 在《%s》#%d 楼下提交了待审核回复,请前往管理后台处理。", authorName, postTitle, displayFloor, ) } return fmt.Sprintf( "用户 %s 在《%s》提交了待审核 #%d 楼评论,请前往管理后台处理。", authorName, postTitle, displayFloor, ) } func truncateNotifyExcerpt(raw string, maxRunes int) string { plain := strings.TrimSpace(StripHTMLForSearch(raw)) plain = strings.Join(strings.Fields(plain), " ") if plain == "" { return "" } if maxRunes <= 0 || utf8.RuneCountInString(plain) <= maxRunes { return plain } runes := []rune(plain) return string(runes[:maxRunes]) + "…" }