From c05cc472cfdf8dc78eefb01979e115cba048839c Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 6 Aug 2026 00:34:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=9B=9E=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E5=BE=85=E5=AE=A1=E6=8F=90=E9=86=92=EF=BC=9A=E7=AB=99=E5=86=85?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=B8=8E=20SMTP=20=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- ROADMAP.md | 3 +- frontend/src/components/RightPanel.tsx | 3 +- frontend/src/pages/MessagesPage.tsx | 2 + frontend/src/utils/content.ts | 8 + handler/api.go | 6 + handler/handlers.go | 28 ++- model/models.go | 2 + router/router.go | 3 +- service/comment.go | 23 +- service/mail_template.go | 209 ++++++++++++++++ service/notify.go | 331 +++++++++++++++++++++++++ service/seo.go | 8 +- 12 files changed, 608 insertions(+), 18 deletions(-) create mode 100644 service/notify.go diff --git a/ROADMAP.md b/ROADMAP.md index d18884c..084e6af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,7 +30,6 @@ _当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_ |--------|------|------| | 中 | 通知动态优化 | 右栏最新评论的展示与交互 | | 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 | -| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) | --- @@ -59,6 +58,8 @@ _当前无公开认领任务。_ - [x] 楼层式评论、引用回复、@ 高亮 - [x] 点赞、收藏、热门帖 - [x] 敏感词过滤、发帖限流 +- [x] 站内私信 +- [x] 回复提醒与待审提醒(站内消息 + SMTP 邮件) - [x] SQLite 备份、单二进制部署 --- diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index 7c73c36..f6fca63 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -4,6 +4,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import type { PostItem, RecentComment, TagCount, User } from '../api/types'; import type { PostHeading } from '../utils/postHeadings'; import { useSiteBranding } from '../hooks/useSiteBranding'; +import { formatShortDateTime } from '../utils/content'; import TagCloud from './TagCloud'; import UserLink from './UserLink'; import ArticleOutline from './ArticleOutline'; @@ -213,7 +214,7 @@ export default function RightPanel({ onClick={() => onPostClick(item.post_id, item.floor > 0 ? { floor: item.floor } : undefined)} > {item.excerpt} - {item.created_at} + {formatShortDateTime(item.created_at)} ))} diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx index 64f5573..c741347 100644 --- a/frontend/src/pages/MessagesPage.tsx +++ b/frontend/src/pages/MessagesPage.tsx @@ -19,6 +19,8 @@ function kindLabel(kind: string) { switch (kind) { case 'reject': return '拒帖通知'; case 'report_result': return '举报结果'; + case 'reply': return '回复提醒'; + case 'moderation': return '待审提醒'; case 'system': return '系统通知'; default: return ''; } diff --git a/frontend/src/utils/content.ts b/frontend/src/utils/content.ts index 59f9587..6344393 100644 --- a/frontend/src/utils/content.ts +++ b/frontend/src/utils/content.ts @@ -43,6 +43,14 @@ export function formatDateTime(iso: string) { return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`; } +/** 短日期时间(本地时区):MM-DD HH:mm,用于右栏最新评论等 */ +export function formatShortDateTime(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + /** 判断两个 ISO 时间是否相差超过 1 分钟 */ export function isTimeDiffSignificant(a: string, b: string) { const da = new Date(a).getTime(); diff --git a/handler/api.go b/handler/api.go index ac6cd53..cdba00c 100644 --- a/handler/api.go +++ b/handler/api.go @@ -303,6 +303,12 @@ func (h *Handlers) APIAdminApproveComment(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if h.Notify != nil { + if comment, err := h.Comment.GetByID(uint(id)); err == nil { + comment.Status = model.ContentStatusPublished + h.Notify.AsyncNotifyCommentPublished(comment) + } + } c.JSON(http.StatusOK, gin.H{"message": "评论已通过审核", "status": model.ContentStatusPublished}) } diff --git a/handler/handlers.go b/handler/handlers.go index b883f03..ac77f05 100644 --- a/handler/handlers.go +++ b/handler/handlers.go @@ -24,6 +24,7 @@ type Handlers struct { Post *service.PostService Comment *service.CommentService Message *service.MessageService + Notify *service.NotifyService Report *service.ReportService Backup *service.BackupService Filter *service.SensitiveFilter @@ -322,6 +323,9 @@ func (h *Handlers) APICreatePost(c *gin.Context) { msg := "发帖成功" if post.Status == model.ContentStatusPending { msg = "已提交审核,通过后将公开显示" + if h.Notify != nil { + h.Notify.AsyncNotifyPendingPost(post) + } } c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": post.ID, "status": post.Status}) } @@ -329,12 +333,19 @@ func (h *Handlers) APICreatePost(c *gin.Context) { func (h *Handlers) APIUpdatePost(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64) - err := h.Post.Update(h.currentUserID(c), uint(id), h.isAdmin(c), + isAdmin := h.isAdmin(c) + err := h.Post.Update(h.currentUserID(c), uint(id), isAdmin, c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), c.PostForm("post_type"), uint(boardID)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // 普通用户修改后重新进入审核 + if !isAdmin && h.Notify != nil { + if post, getErr := h.Post.FindByID(uint(id)); getErr == nil { + h.Notify.AsyncNotifyPendingPost(post) + } + } c.JSON(http.StatusOK, gin.H{"message": "帖子已更新"}) } @@ -414,7 +425,15 @@ func (h *Handlers) APICreateComment(c *gin.Context) { return } msg := "评论成功" - if comment.Status == model.ContentStatusPending { + if h.Notify != nil { + switch comment.Status { + case model.ContentStatusPublished: + h.Notify.AsyncNotifyCommentPublished(comment) + case model.ContentStatusPending: + msg = "评论已提交,审核通过后公开显示" + h.Notify.AsyncNotifyPendingComment(comment) + } + } else if comment.Status == model.ContentStatusPending { msg = "评论已提交,审核通过后公开显示" } c.JSON(http.StatusOK, gin.H{"message": msg, "floor": comment.Floor, "id": comment.ID, "status": comment.Status}) @@ -432,7 +451,7 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) { func (h *Handlers) APIUpdateComment(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) content := c.PostForm("content") - saved, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), content) + saved, enteredPending, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), content) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -444,6 +463,9 @@ func (h *Handlers) APIUpdateComment(c *gin.Context) { if status == model.ContentStatusPending && !h.isAdmin(c) { msg = "评论已更新,审核通过后公开显示" } + if enteredPending && h.Notify != nil { + h.Notify.AsyncNotifyPendingComment(comment) + } } c.JSON(http.StatusOK, gin.H{"message": msg, "content": saved, "status": status}) } diff --git a/model/models.go b/model/models.go index 0d6138b..69559a3 100644 --- a/model/models.go +++ b/model/models.go @@ -166,6 +166,8 @@ const ( MessageKindSystem = "system" // 系统通知 MessageKindReject = "reject" // 帖子被拒/下架 MessageKindReportResult = "report_result" // 举报处理结果 + MessageKindReply = "reply" // 帖子/评论被回复 + MessageKindModeration = "moderation" // 新内容待审核(通知管理员) ) // PrivateMessage 站内私信 diff --git a/router/router.go b/router/router.go index 0e04f8f..bd4652d 100644 --- a/router/router.go +++ b/router/router.go @@ -54,6 +54,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { captchaSvc := service.NewCaptchaService() mailSvc := service.NewMailService(settingsSvc) emailCodeSvc := service.NewEmailCodeService(mailSvc) + notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc) oidcSvc, err := service.NewOIDCService(cfg, settingsSvc) if err != nil { return nil, err @@ -78,7 +79,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { h := &handler.Handlers{ Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc, - Post: postSvc, Comment: commentSvc, Message: messageSvc, Report: reportSvc, + Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc, Backup: backupSvc, Filter: filter, Limiter: limiter, Settings: settingsSvc, Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc, diff --git a/service/comment.go b/service/comment.go index 1df4489..8e6c2fd 100644 --- a/service/comment.go +++ b/service/comment.go @@ -273,32 +273,33 @@ func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error { return s.AdminDelete(commentID) } -func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content string) (string, error) { +func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content string) (string, bool, error) { var comment model.Comment if err := model.DB.First(&comment, commentID).Error; err != nil { - return "", ErrCommentNotFound + return "", false, ErrCommentNotFound } if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) { - return "", ErrPermissionDenied + return "", false, ErrPermissionDenied } if !isAdmin { window := s.settings.CommentEditWindowMinutes() if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Minute { - return "", errors.New("已超过可编辑时限") + return "", false, errors.New("已超过可编辑时限") } } content = s.filter.Filter(strings.TrimSpace(content)) if content == "" { - return "", errors.New("评论内容不能为空") + return "", false, errors.New("评论内容不能为空") } if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil { - return "", err + return "", false, err } if content == comment.Content { - return content, nil + return content, false, nil } + enteredPending := false err := model.DB.Transaction(func(tx *gorm.DB) error { rev := model.CommentRevision{ CommentID: commentID, @@ -311,13 +312,14 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st updates := map[string]interface{}{"content": content} if !isAdmin { updates["status"] = model.ContentStatusPending + enteredPending = true } return tx.Model(&comment).Updates(updates).Error }) if err != nil { - return "", err + return "", false, err } - return content, nil + return content, enteredPending, nil } func (s *CommentService) AdminDelete(commentID uint) error { @@ -401,7 +403,8 @@ func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error Avatar: avatar, Excerpt: excerpt, PostTitle: c.Post.Title, - CreatedAt: c.CreatedAt.Format("01-02 15:04"), + // 返回 UTC ISO,由前端按本地时区展示(避免与后台差 8 小时) + CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339), }) if len(out) >= limit { break diff --git a/service/mail_template.go b/service/mail_template.go index 337a23c..d8ed1b0 100644 --- a/service/mail_template.go +++ b/service/mail_template.go @@ -92,3 +92,212 @@ func BuildRegisterCodeMail(siteName, code string, ttlMinutes int) (subject, text ) return subject, textBody, htmlBody } + +// BuildReplyMail 生成「收到新回复」提醒邮件 +// displayFloor 为页面可见顶层楼号;底部展示帖子主题,不展示路径 URL。 +func BuildReplyMail(siteName, authorName, postTitle string, displayFloor int, isNested bool, excerpt, link string) (subject, textBody, htmlBody string) { + siteName = strings.TrimSpace(siteName) + if siteName == "" { + siteName = "姜十三论坛" + } + authorName = strings.TrimSpace(authorName) + if authorName == "" { + authorName = "有人" + } + postTitle = strings.TrimSpace(postTitle) + if postTitle == "" { + postTitle = "未知帖子" + } + + subject = fmt.Sprintf("【%s】收到新回复", siteName) + bodyLine := FormatReplyContent(authorName, postTitle, displayFloor, isNested) + + textBody = fmt.Sprintf("你好,\n\n%s\n", bodyLine) + if excerpt != "" { + textBody += "\n摘要:\n" + excerpt + "\n" + } + textBody += fmt.Sprintf("\n帖子:《%s》\n", postTitle) + if link != "" { + textBody += "链接:" + link + "\n" + } + textBody += fmt.Sprintf("\n— %s\n", siteName) + + safeSite := html.EscapeString(siteName) + safeBody := html.EscapeString(bodyLine) + safeTitle := html.EscapeString(postTitle) + safeExcerpt := html.EscapeString(excerpt) + safeLink := html.EscapeString(link) + preheader := html.EscapeString(fmt.Sprintf("%s 回复了你在《%s》中的内容", authorName, postTitle)) + + linkBlock := "" + if link != "" { + linkBlock = fmt.Sprintf(` +

+ 查看讨论 +

`, safeLink) + } + excerptBlock := "" + if excerpt != "" { + excerptBlock = fmt.Sprintf(` + + + + +
%s
`, safeExcerpt) + } + + htmlBody = fmt.Sprintf(` + + + + +%s + + +
%s
+ + + + +
+ + + + + + + + + + +
+
%s
+
回复提醒
+
+

你好,

+

%s

+ %s + %s +
+ 帖子:《%s》
+ 此邮件由 %s 自动发送,请勿直接回复 +
+
+ +`, + html.EscapeString(subject), + preheader, + safeSite, + safeBody, + excerptBlock, + linkBlock, + safeTitle, + safeSite, + ) + return subject, textBody, htmlBody +} + +// BuildModerationMail 生成「待审核」提醒邮件;kindLabel 为「帖子」或「评论」 +// displayFloor 为可见顶层楼号;评论场景 isNested 区分顶层/子回复文案。 +func BuildModerationMail(siteName, kindLabel, authorName, postTitle string, postID uint, displayFloor int, isNested bool, adminLink string) (subject, textBody, htmlBody string) { + siteName = strings.TrimSpace(siteName) + if siteName == "" { + siteName = "姜十三论坛" + } + kindLabel = strings.TrimSpace(kindLabel) + if kindLabel == "" { + kindLabel = "内容" + } + authorName = strings.TrimSpace(authorName) + if authorName == "" { + authorName = "用户" + } + postTitle = strings.TrimSpace(postTitle) + if postTitle == "" { + postTitle = "未知帖子" + } + + subject = fmt.Sprintf("【%s】新的待审核%s", siteName, kindLabel) + + var detail string + switch { + case kindLabel == "评论" && isNested && displayFloor > 0: + detail = fmt.Sprintf("用户 %s 在《%s》#%d 楼下提交了待审核回复", authorName, postTitle, displayFloor) + case kindLabel == "评论" && displayFloor > 0: + detail = fmt.Sprintf("用户 %s 在《%s》提交了待审核 #%d 楼评论", authorName, postTitle, displayFloor) + default: + detail = fmt.Sprintf("用户 %s 提交了待审核%s《%s》(#%d)", authorName, kindLabel, postTitle, postID) + } + + textBody = fmt.Sprintf("你好,\n\n%s。\n请尽快前往管理后台处理。\n", detail) + textBody += fmt.Sprintf("\n帖子:《%s》\n", postTitle) + if adminLink != "" { + textBody += "链接:" + adminLink + "\n" + } + textBody += fmt.Sprintf("\n— %s\n", siteName) + + safeSite := html.EscapeString(siteName) + safeKind := html.EscapeString(kindLabel) + safeDetail := html.EscapeString(detail) + safeTitle := html.EscapeString(postTitle) + safeLink := html.EscapeString(adminLink) + preheader := html.EscapeString(fmt.Sprintf("有新的待审核%s需要处理", kindLabel)) + + linkBlock := "" + if adminLink != "" { + linkBlock = fmt.Sprintf(` +

+ 前往审核 +

`, safeLink) + } + + htmlBody = fmt.Sprintf(` + + + + +%s + + +
%s
+ + + + +
+ + + + + + + + + + +
+
%s
+
待审核提醒
+
+

你好,

+

%s。

+

请尽快前往管理后台处理该%s。

+ %s +
+ 帖子:《%s》
+ 此邮件由 %s 自动发送,请勿直接回复 +
+
+ +`, + html.EscapeString(subject), + preheader, + safeSite, + safeDetail, + safeKind, + linkBlock, + safeTitle, + safeSite, + ) + return subject, textBody, htmlBody +} diff --git a/service/notify.go b/service/notify.go new file mode 100644 index 0000000..e35e1d9 --- /dev/null +++ b/service/notify.go @@ -0,0 +1,331 @@ +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]) + "…" +} diff --git a/service/seo.go b/service/seo.go index 6eda3cd..aab6721 100644 --- a/service/seo.go +++ b/service/seo.go @@ -32,7 +32,8 @@ func (s *ForumSettingsService) SitePublicBaseURL(requestOrigin string) string { return strings.TrimRight(root, "/") } -// AbsoluteURL 将相对路径拼成绝对 URL +// AbsoluteURL 将相对路径拼成绝对 URL。 +// base 为空或非 http(s) 时返回空串,避免邮件等场景出现无法点击的相对路径。 func AbsoluteURL(base, pathOrURL string) string { pathOrURL = strings.TrimSpace(pathOrURL) if pathOrURL == "" { @@ -41,7 +42,10 @@ func AbsoluteURL(base, pathOrURL string) string { if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") { return pathOrURL } - base = strings.TrimRight(base, "/") + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" || (!strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://")) { + return "" + } if !strings.HasPrefix(pathOrURL, "/") { pathOrURL = "/" + pathOrURL }