增加回复与待审提醒:站内消息与 SMTP 邮件通知。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,7 +30,6 @@ _当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| 中 | 通知动态优化 | 右栏最新评论的展示与交互 |
|
| 中 | 通知动态优化 | 右栏最新评论的展示与交互 |
|
||||||
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
|
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
|
||||||
| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -59,6 +58,8 @@ _当前无公开认领任务。_
|
|||||||
- [x] 楼层式评论、引用回复、@ 高亮
|
- [x] 楼层式评论、引用回复、@ 高亮
|
||||||
- [x] 点赞、收藏、热门帖
|
- [x] 点赞、收藏、热门帖
|
||||||
- [x] 敏感词过滤、发帖限流
|
- [x] 敏感词过滤、发帖限流
|
||||||
|
- [x] 站内私信
|
||||||
|
- [x] 回复提醒与待审提醒(站内消息 + SMTP 邮件)
|
||||||
- [x] SQLite 备份、单二进制部署
|
- [x] SQLite 备份、单二进制部署
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
|
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
|
||||||
import type { PostHeading } from '../utils/postHeadings';
|
import type { PostHeading } from '../utils/postHeadings';
|
||||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
|
import { formatShortDateTime } from '../utils/content';
|
||||||
import TagCloud from './TagCloud';
|
import TagCloud from './TagCloud';
|
||||||
import UserLink from './UserLink';
|
import UserLink from './UserLink';
|
||||||
import ArticleOutline from './ArticleOutline';
|
import ArticleOutline from './ArticleOutline';
|
||||||
@@ -213,7 +214,7 @@ export default function RightPanel({
|
|||||||
onClick={() => onPostClick(item.post_id, item.floor > 0 ? { floor: item.floor } : undefined)}
|
onClick={() => onPostClick(item.post_id, item.floor > 0 ? { floor: item.floor } : undefined)}
|
||||||
>
|
>
|
||||||
<span className="widget-item-title">{item.excerpt}</span>
|
<span className="widget-item-title">{item.excerpt}</span>
|
||||||
<span className="widget-item-time">{item.created_at}</span>
|
<span className="widget-item-time">{formatShortDateTime(item.created_at)}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ function kindLabel(kind: string) {
|
|||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'reject': return '拒帖通知';
|
case 'reject': return '拒帖通知';
|
||||||
case 'report_result': return '举报结果';
|
case 'report_result': return '举报结果';
|
||||||
|
case 'reply': return '回复提醒';
|
||||||
|
case 'moderation': return '待审提醒';
|
||||||
case 'system': return '系统通知';
|
case 'system': return '系统通知';
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export function formatDateTime(iso: string) {
|
|||||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
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 分钟 */
|
/** 判断两个 ISO 时间是否相差超过 1 分钟 */
|
||||||
export function isTimeDiffSignificant(a: string, b: string) {
|
export function isTimeDiffSignificant(a: string, b: string) {
|
||||||
const da = new Date(a).getTime();
|
const da = new Date(a).getTime();
|
||||||
|
|||||||
@@ -303,6 +303,12 @@ func (h *Handlers) APIAdminApproveComment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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})
|
c.JSON(http.StatusOK, gin.H{"message": "评论已通过审核", "status": model.ContentStatusPublished})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Handlers struct {
|
|||||||
Post *service.PostService
|
Post *service.PostService
|
||||||
Comment *service.CommentService
|
Comment *service.CommentService
|
||||||
Message *service.MessageService
|
Message *service.MessageService
|
||||||
|
Notify *service.NotifyService
|
||||||
Report *service.ReportService
|
Report *service.ReportService
|
||||||
Backup *service.BackupService
|
Backup *service.BackupService
|
||||||
Filter *service.SensitiveFilter
|
Filter *service.SensitiveFilter
|
||||||
@@ -322,6 +323,9 @@ func (h *Handlers) APICreatePost(c *gin.Context) {
|
|||||||
msg := "发帖成功"
|
msg := "发帖成功"
|
||||||
if post.Status == model.ContentStatusPending {
|
if post.Status == model.ContentStatusPending {
|
||||||
msg = "已提交审核,通过后将公开显示"
|
msg = "已提交审核,通过后将公开显示"
|
||||||
|
if h.Notify != nil {
|
||||||
|
h.Notify.AsyncNotifyPendingPost(post)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": post.ID, "status": post.Status})
|
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) {
|
func (h *Handlers) APIUpdatePost(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
boardID, _ := strconv.ParseUint(c.PostForm("board_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))
|
c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), c.PostForm("post_type"), uint(boardID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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": "帖子已更新"})
|
c.JSON(http.StatusOK, gin.H{"message": "帖子已更新"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +425,15 @@ func (h *Handlers) APICreateComment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := "评论成功"
|
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 = "评论已提交,审核通过后公开显示"
|
msg = "评论已提交,审核通过后公开显示"
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "floor": comment.Floor, "id": comment.ID, "status": comment.Status})
|
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) {
|
func (h *Handlers) APIUpdateComment(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
content := c.PostForm("content")
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -444,6 +463,9 @@ func (h *Handlers) APIUpdateComment(c *gin.Context) {
|
|||||||
if status == model.ContentStatusPending && !h.isAdmin(c) {
|
if status == model.ContentStatusPending && !h.isAdmin(c) {
|
||||||
msg = "评论已更新,审核通过后公开显示"
|
msg = "评论已更新,审核通过后公开显示"
|
||||||
}
|
}
|
||||||
|
if enteredPending && h.Notify != nil {
|
||||||
|
h.Notify.AsyncNotifyPendingComment(comment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "content": saved, "status": status})
|
c.JSON(http.StatusOK, gin.H{"message": msg, "content": saved, "status": status})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ const (
|
|||||||
MessageKindSystem = "system" // 系统通知
|
MessageKindSystem = "system" // 系统通知
|
||||||
MessageKindReject = "reject" // 帖子被拒/下架
|
MessageKindReject = "reject" // 帖子被拒/下架
|
||||||
MessageKindReportResult = "report_result" // 举报处理结果
|
MessageKindReportResult = "report_result" // 举报处理结果
|
||||||
|
MessageKindReply = "reply" // 帖子/评论被回复
|
||||||
|
MessageKindModeration = "moderation" // 新内容待审核(通知管理员)
|
||||||
)
|
)
|
||||||
|
|
||||||
// PrivateMessage 站内私信
|
// PrivateMessage 站内私信
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
captchaSvc := service.NewCaptchaService()
|
captchaSvc := service.NewCaptchaService()
|
||||||
mailSvc := service.NewMailService(settingsSvc)
|
mailSvc := service.NewMailService(settingsSvc)
|
||||||
emailCodeSvc := service.NewEmailCodeService(mailSvc)
|
emailCodeSvc := service.NewEmailCodeService(mailSvc)
|
||||||
|
notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc)
|
||||||
oidcSvc, err := service.NewOIDCService(cfg, settingsSvc)
|
oidcSvc, err := service.NewOIDCService(cfg, settingsSvc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -78,7 +79,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
|
|
||||||
h := &handler.Handlers{
|
h := &handler.Handlers{
|
||||||
Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc,
|
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,
|
Backup: backupSvc,
|
||||||
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
||||||
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
|
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
|
||||||
|
|||||||
@@ -273,32 +273,33 @@ func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
|
|||||||
return s.AdminDelete(commentID)
|
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
|
var comment model.Comment
|
||||||
if err := model.DB.First(&comment, commentID).Error; err != nil {
|
if err := model.DB.First(&comment, commentID).Error; err != nil {
|
||||||
return "", ErrCommentNotFound
|
return "", false, ErrCommentNotFound
|
||||||
}
|
}
|
||||||
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
|
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
|
||||||
return "", ErrPermissionDenied
|
return "", false, ErrPermissionDenied
|
||||||
}
|
}
|
||||||
if !isAdmin {
|
if !isAdmin {
|
||||||
window := s.settings.CommentEditWindowMinutes()
|
window := s.settings.CommentEditWindowMinutes()
|
||||||
if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Minute {
|
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))
|
content = s.filter.Filter(strings.TrimSpace(content))
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return "", errors.New("评论内容不能为空")
|
return "", false, errors.New("评论内容不能为空")
|
||||||
}
|
}
|
||||||
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
|
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
|
||||||
return "", err
|
return "", false, err
|
||||||
}
|
}
|
||||||
if content == comment.Content {
|
if content == comment.Content {
|
||||||
return content, nil
|
return content, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enteredPending := false
|
||||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
rev := model.CommentRevision{
|
rev := model.CommentRevision{
|
||||||
CommentID: commentID,
|
CommentID: commentID,
|
||||||
@@ -311,13 +312,14 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st
|
|||||||
updates := map[string]interface{}{"content": content}
|
updates := map[string]interface{}{"content": content}
|
||||||
if !isAdmin {
|
if !isAdmin {
|
||||||
updates["status"] = model.ContentStatusPending
|
updates["status"] = model.ContentStatusPending
|
||||||
|
enteredPending = true
|
||||||
}
|
}
|
||||||
return tx.Model(&comment).Updates(updates).Error
|
return tx.Model(&comment).Updates(updates).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", false, err
|
||||||
}
|
}
|
||||||
return content, nil
|
return content, enteredPending, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CommentService) AdminDelete(commentID uint) error {
|
func (s *CommentService) AdminDelete(commentID uint) error {
|
||||||
@@ -401,7 +403,8 @@ func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error
|
|||||||
Avatar: avatar,
|
Avatar: avatar,
|
||||||
Excerpt: excerpt,
|
Excerpt: excerpt,
|
||||||
PostTitle: c.Post.Title,
|
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 {
|
if len(out) >= limit {
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -92,3 +92,212 @@ func BuildRegisterCodeMail(siteName, code string, ttlMinutes int) (subject, text
|
|||||||
)
|
)
|
||||||
return subject, textBody, htmlBody
|
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(`
|
||||||
|
<p style="margin:0 0 12px;text-align:center;">
|
||||||
|
<a href="%s" style="display:inline-block;padding:10px 18px;background:#18a058;color:#ffffff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">查看讨论</a>
|
||||||
|
</p>`, safeLink)
|
||||||
|
}
|
||||||
|
excerptBlock := ""
|
||||||
|
if excerpt != "" {
|
||||||
|
excerptBlock = fmt.Sprintf(`
|
||||||
|
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;background:#f8fafc;border-radius:8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 14px;font-size:13px;line-height:1.6;color:#4b5563;">%s</td>
|
||||||
|
</tr>
|
||||||
|
</table>`, safeExcerpt)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlBody = fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>%s</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f5f7fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;color:#1f2937;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">%s</div>
|
||||||
|
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#f5f7fa;padding:28px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px;background:linear-gradient(135deg,#18a058,#138f4c);color:#ffffff;">
|
||||||
|
<div style="font-size:18px;font-weight:700;letter-spacing:0.02em;">%s</div>
|
||||||
|
<div style="margin-top:4px;font-size:13px;opacity:0.92;">回复提醒</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px;">
|
||||||
|
<p style="margin:0 0 12px;font-size:15px;line-height:1.6;">你好,</p>
|
||||||
|
<p style="margin:0 0 20px;font-size:14px;line-height:1.7;color:#4b5563;">%s</p>
|
||||||
|
%s
|
||||||
|
%s
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 28px;border-top:1px solid #f1f5f9;font-size:12px;color:#9ca3af;text-align:center;">
|
||||||
|
帖子:《%s》<br />
|
||||||
|
<span style="display:inline-block;margin-top:6px;">此邮件由 %s 自动发送,请勿直接回复</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
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(`
|
||||||
|
<p style="margin:0 0 12px;text-align:center;">
|
||||||
|
<a href="%s" style="display:inline-block;padding:10px 18px;background:#18a058;color:#ffffff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">前往审核</a>
|
||||||
|
</p>`, safeLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlBody = fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>%s</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f5f7fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;color:#1f2937;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">%s</div>
|
||||||
|
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#f5f7fa;padding:28px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px;background:linear-gradient(135deg,#18a058,#138f4c);color:#ffffff;">
|
||||||
|
<div style="font-size:18px;font-weight:700;letter-spacing:0.02em;">%s</div>
|
||||||
|
<div style="margin-top:4px;font-size:13px;opacity:0.92;">待审核提醒</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px;">
|
||||||
|
<p style="margin:0 0 12px;font-size:15px;line-height:1.6;">你好,</p>
|
||||||
|
<p style="margin:0 0 20px;font-size:14px;line-height:1.7;color:#4b5563;">%s。</p>
|
||||||
|
<p style="margin:0 0 20px;font-size:14px;line-height:1.7;color:#4b5563;">请尽快前往管理后台处理该%s。</p>
|
||||||
|
%s
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 28px;border-top:1px solid #f1f5f9;font-size:12px;color:#9ca3af;text-align:center;">
|
||||||
|
帖子:《%s》<br />
|
||||||
|
<span style="display:inline-block;margin-top:6px;">此邮件由 %s 自动发送,请勿直接回复</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
html.EscapeString(subject),
|
||||||
|
preheader,
|
||||||
|
safeSite,
|
||||||
|
safeDetail,
|
||||||
|
safeKind,
|
||||||
|
linkBlock,
|
||||||
|
safeTitle,
|
||||||
|
safeSite,
|
||||||
|
)
|
||||||
|
return subject, textBody, htmlBody
|
||||||
|
}
|
||||||
|
|||||||
331
service/notify.go
Normal file
331
service/notify.go
Normal file
@@ -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]) + "…"
|
||||||
|
}
|
||||||
@@ -32,7 +32,8 @@ func (s *ForumSettingsService) SitePublicBaseURL(requestOrigin string) string {
|
|||||||
return strings.TrimRight(root, "/")
|
return strings.TrimRight(root, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbsoluteURL 将相对路径拼成绝对 URL
|
// AbsoluteURL 将相对路径拼成绝对 URL。
|
||||||
|
// base 为空或非 http(s) 时返回空串,避免邮件等场景出现无法点击的相对路径。
|
||||||
func AbsoluteURL(base, pathOrURL string) string {
|
func AbsoluteURL(base, pathOrURL string) string {
|
||||||
pathOrURL = strings.TrimSpace(pathOrURL)
|
pathOrURL = strings.TrimSpace(pathOrURL)
|
||||||
if pathOrURL == "" {
|
if pathOrURL == "" {
|
||||||
@@ -41,7 +42,10 @@ func AbsoluteURL(base, pathOrURL string) string {
|
|||||||
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
|
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
|
||||||
return pathOrURL
|
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, "/") {
|
if !strings.HasPrefix(pathOrURL, "/") {
|
||||||
pathOrURL = "/" + pathOrURL
|
pathOrURL = "/" + pathOrURL
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user