Files
jiang13-forum/services/notify.go
freefire 9fe299a45f refactor: Gitea 式目录改组,移除本分支 SPA 与杂项产物
将 model/service/handler/middleware 迁至 models/services/routers/api/modules/auth,并删除 frontend、embed_static、scripts 及误入库缓存/二进制。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:54:56 +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 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) })
}
// AsyncNotifyCommentMentions 异步:评论公开后通知被 @ 的用户
func (s *NotifyService) AsyncNotifyCommentMentions(comment *model.Comment) {
if s == nil || comment == nil {
return
}
cp := *comment
s.goNotify(func() { s.NotifyCommentMentions(&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)
}
// NotifyCommentMentions 评论公开后通知被 @提及的用户(跳过已收到回复通知的人)
func (s *NotifyService) NotifyCommentMentions(comment *model.Comment) {
if s == nil || comment == nil || comment.Status != model.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, model.MessageKindMention, &pid, nil)
}
}
// 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)
}
// 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]) + "…"
}