Files
jiang13-forum/services/notify.go
freefire 3f50316ad0 fix: 完成 Gitea 目录改组收尾(import、构建与 LICENSE)
同步包路径与路由,去掉 SPA 构建步骤,对齐 Gitea 式 LICENSE,并更新规格/规则与占位 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 04:01:20 +08:00

381 lines
12 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 services
import (
"fmt"
"strings"
"unicode/utf8"
"git.iioio.com/freefire/jiang13-forum/models"
)
// 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 *models.Comment) {
if s == nil || comment == nil {
return
}
cp := *comment
s.goNotify(func() { s.NotifyCommentPublished(&cp) })
}
// AsyncNotifyCommentMentions 异步:评论公开后通知被 @ 的用户
func (s *NotifyService) AsyncNotifyCommentMentions(comment *models.Comment) {
if s == nil || comment == nil {
return
}
cp := *comment
s.goNotify(func() { s.NotifyCommentMentions(&cp) })
}
// AsyncNotifyPendingPost 异步:待审帖通知管理员
func (s *NotifyService) AsyncNotifyPendingPost(post *models.Post) {
if s == nil || post == nil {
return
}
cp := *post
s.goNotify(func() { s.NotifyPendingPost(&cp) })
}
// AsyncNotifyPendingComment 异步:待审评论通知管理员
func (s *NotifyService) AsyncNotifyPendingComment(comment *models.Comment) {
if s == nil || comment == nil {
return
}
cp := *comment
s.goNotify(func() { s.NotifyPendingComment(&cp) })
}
// NotifyCommentPublished 评论公开后通知被回复者或楼主
func (s *NotifyService) NotifyCommentPublished(comment *models.Comment) {
if s == nil || comment == nil || comment.Status != models.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, models.MessageKindReply, &pid, nil)
s.sendReplyMail(toUserID, authorName, title, comment.PostID, displayFloor, isNested, comment.Content)
}
// NotifyCommentMentions 评论公开后通知被 @提及的用户(跳过已收到回复通知的人)
func (s *NotifyService) NotifyCommentMentions(comment *models.Comment) {
if s == nil || comment == nil || comment.Status != models.ContentStatusPublished {
return
}
names := ExtractMentionNames(comment.Content)
ids := ResolveMentionUserIDs(names, comment.UserID)
if len(ids) == 0 {
return
}
post, err := s.loadPost(comment.PostID)
if err != nil {
return
}
// 已作为回复对象收到通知的用户不再重复发 mention
replyTo, _ := s.resolveReplyRecipient(comment, post)
authorName := s.commentAuthorName(comment)
title := post.Title
if title == "" {
title = "未知帖子"
}
displayFloor := s.resolveDisplayFloor(comment)
pid := comment.PostID
subject := "有人 @了你"
content := FormatMentionContent(authorName, title, displayFloor)
for _, uid := range ids {
if uid == 0 || uid == comment.UserID || uid == replyTo {
continue
}
_, _ = s.messages.SendSystem(uid, subject, content, models.MessageKindMention, &pid, nil)
}
}
// NotifyPendingPost 新帖进入待审时通知全部管理员
func (s *NotifyService) NotifyPendingPost(post *models.Post) {
if s == nil || post == nil || post.Status != models.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, models.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 *models.Comment) {
if s == nil || comment == nil || comment.Status != models.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, models.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 models.User
if err := models.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 *models.Comment, post *models.Post) (uint, error) {
if comment.ReplyTo != nil && *comment.ReplyTo > 0 {
var target models.Comment
if err := models.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 *models.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 models.Comment
if err := models.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) (*models.Post, error) {
var post models.Post
if err := models.DB.Select("id", "user_id", "title", "status").First(&post, postID).Error; err != nil {
return nil, err
}
return &post, nil
}
func (s *NotifyService) listAdmins() ([]models.User, error) {
var admins []models.User
err := models.DB.Select("id", "email", "nickname", "username").
Where("role = ? AND banned = ?", models.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 *models.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 models.User
if err := models.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)
}
// FormatMentionContent @提及站内通知正文
func FormatMentionContent(authorName, postTitle string, displayFloor int) string {
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]) + "…"
}