refactor: Gitea 式目录改组,移除本分支 SPA 与杂项产物

将 model/service/handler/middleware 迁至 models/services/routers/api/modules/auth,并删除 frontend、embed_static、scripts 及误入库缓存/二进制。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 03:54:56 +08:00
parent 1414c71dec
commit 9fe299a45f
449 changed files with 0 additions and 52779 deletions

380
services/notify.go Normal file
View File

@@ -0,0 +1,380 @@
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]) + "…"
}