移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:37:11 +08:00
parent 060b7707cb
commit 48db333272
121 changed files with 11147 additions and 3225 deletions

View File

@@ -2,11 +2,11 @@ package service
import (
"errors"
"net/mail"
"net/url"
"strings"
"time"
"gorm.io/gorm"
"git.iioio.com/freefire/jiang13-forum/model"
)
@@ -72,6 +72,16 @@ func (s *CommentService) fillReplyTargets(comments []model.Comment, loadMissing
}
}
func canViewComment(c model.Comment, viewerID uint, isAdmin bool) bool {
if isAdmin || c.Status == model.ContentStatusPublished || c.Status == "" {
return true
}
if c.Status == model.ContentStatusPending || c.Status == model.ContentStatusRejected {
return viewerID > 0 && c.UserID == viewerID
}
return false
}
func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAuthorID uint, visibleGuestIDs []uint) ([]model.Comment, error) {
var comments []model.Comment
err := model.DB.Preload("User").Where("post_id = ?", postID).Order("floor asc").Find(&comments).Error
@@ -84,15 +94,64 @@ func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAut
guestSet[id] = struct{}{}
}
allByID := make(map[uint]model.Comment, len(comments))
for _, c := range comments {
allByID[c.ID] = c
}
visible := make([]model.Comment, 0, len(comments))
visibleIDs := make(map[uint]struct{}, len(comments))
for i := range comments {
if !canViewComment(comments[i], viewerID, isAdmin) {
continue
}
if comments[i].IsPrivate && !s.canViewPrivate(comments[i], viewerID, isAdmin, postAuthorID, guestSet) {
comments[i].ContentHidden = true
comments[i].Content = ""
}
visibleIDs[comments[i].ID] = struct{}{}
visible = append(visible, comments[i])
}
s.fillReplyTargets(comments, false)
return comments, nil
// 父评论不可见时,回挂到最近可见祖先,避免回复在游客侧变成独立顶层评论
for i := range visible {
visible[i].ThreadParentID = resolveThreadParent(visible[i].ReplyTo, visibleIDs, allByID)
}
s.fillReplyTargets(visible, true)
for i := range visible {
if rt := visible[i].ReplyTarget; rt != nil && !canViewComment(*rt, viewerID, isAdmin) {
// 不可见父评论仅保留昵称供 @,不泄露正文
rt.Content = ""
rt.ContentHidden = true
}
}
return visible, nil
}
// resolveThreadParent 计算嵌套展示父节点:优先直接父评论,否则沿 reply_to 向上找到最近可见祖先
func resolveThreadParent(replyTo *uint, visibleIDs map[uint]struct{}, allByID map[uint]model.Comment) *uint {
if replyTo == nil {
return nil
}
if _, ok := visibleIDs[*replyTo]; ok {
id := *replyTo
return &id
}
cur := *replyTo
for hops := 0; hops < 32; hops++ {
parent, ok := allByID[cur]
if !ok || parent.ReplyTo == nil {
return nil
}
next := *parent.ReplyTo
if _, ok := visibleIDs[next]; ok {
id := next
return &id
}
cur = next
}
return nil
}
func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
@@ -109,32 +168,21 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
return nil, ErrPostNotFound
}
if in.UserID > 0 {
var user model.User
if err := model.DB.First(&user, in.UserID).Error; err != nil {
return nil, errors.New("用户不存在")
}
if user.Banned {
return nil, errors.New("账号已被禁言")
}
} else {
nick := strings.TrimSpace(in.GuestNick)
if nick == "" {
return nil, errors.New("请填写昵称")
}
if len([]rune(nick)) > 32 {
return nil, errors.New("昵称过长")
}
if email := strings.TrimSpace(in.GuestEmail); email != "" {
if _, err := mail.ParseAddress(email); err != nil {
return nil, errors.New("邮箱格式不正确")
}
}
if rawURL := strings.TrimSpace(in.GuestURL); rawURL != "" {
u, err := url.ParseRequestURI(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, errors.New("网址格式不正确")
}
if in.UserID == 0 {
return nil, errors.New("请登录后评论")
}
var user model.User
if err := model.DB.First(&user, in.UserID).Error; err != nil {
return nil, errors.New("用户不存在")
}
if user.Banned {
return nil, errors.New("账号已被禁言")
}
// 未公开帖仅作者/管理员可评论
if post.Status != model.ContentStatusPublished && post.Status != "" {
if user.Role != model.RoleAdmin && post.UserID != in.UserID {
return nil, errors.New("帖子审核中,暂不可评论")
}
}
@@ -146,6 +194,14 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
if err := model.DB.Where("id = ? AND post_id = ?", *in.ReplyTo, in.PostID).First(&target).Error; err != nil {
return nil, ErrCommentNotFound
}
if !canViewComment(target, in.UserID, user.Role == model.RoleAdmin) {
return nil, ErrCommentNotFound
}
}
status := model.ContentStatusPending
if user.Role == model.RoleAdmin {
status = model.ContentStatusPublished
}
comment := &model.Comment{
@@ -158,19 +214,49 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
GuestEmail: strings.TrimSpace(in.GuestEmail),
GuestURL: strings.TrimSpace(in.GuestURL),
IsPrivate: in.IsPrivate,
Status: status,
}
return comment, model.DB.Create(comment).Error
}
func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
var comment model.Comment
if err := model.DB.First(&comment, commentID).Error; err != nil {
// SetStatus 设置评论审核状态
func (s *CommentService) SetStatus(commentID uint, status string) error {
switch status {
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected:
default:
return errors.New("无效的审核状态")
}
res := model.DB.Model(&model.Comment{}).Where("id = ?", commentID).Update("status", status)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrCommentNotFound
}
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
return nil
}
// GetByID 获取评论
func (s *CommentService) GetByID(id uint) (*model.Comment, error) {
var c model.Comment
if err := model.DB.Preload("User").Preload("Post").First(&c, id).Error; err != nil {
return nil, ErrCommentNotFound
}
return &c, nil
}
// PendingCommentCount 待审评论数
func (s *CommentService) PendingCommentCount() (int64, error) {
var n int64
err := model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPending).Count(&n).Error
return n, err
}
func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
if !isAdmin {
return ErrPermissionDenied
}
return model.DB.Delete(&comment).Error
return s.AdminDelete(commentID)
}
func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content string) (string, error) {
@@ -182,7 +268,7 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st
return "", ErrPermissionDenied
}
if !isAdmin {
window := s.settings.PostEditWindowHours()
window := s.settings.CommentEditWindowHours()
if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Hour {
return "", errors.New("已超过可编辑时限")
}
@@ -195,20 +281,63 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
return "", err
}
if err := model.DB.Model(&comment).Update("content", content).Error; err != nil {
if content == comment.Content {
return content, nil
}
err := model.DB.Transaction(func(tx *gorm.DB) error {
rev := model.CommentRevision{
CommentID: commentID,
EditorID: userID,
Content: comment.Content,
}
if err := tx.Create(&rev).Error; err != nil {
return err
}
updates := map[string]interface{}{"content": content}
if !isAdmin {
updates["status"] = model.ContentStatusPending
}
return tx.Model(&comment).Updates(updates).Error
})
if err != nil {
return "", err
}
return content, nil
}
func (s *CommentService) AdminDelete(commentID uint) error {
return model.DB.Delete(&model.Comment{}, commentID).Error
return model.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("comment_id = ?", commentID).Delete(&model.CommentRevision{}).Error; err != nil {
return err
}
return tx.Delete(&model.Comment{}, commentID).Error
})
}
// ListRevisions 评论编辑历史(管理员查看)
func (s *CommentService) ListRevisions(commentID uint) ([]model.CommentRevision, error) {
if _, err := s.GetByID(commentID); err != nil {
return nil, err
}
var revs []model.CommentRevision
err := model.DB.Preload("Editor").
Where("comment_id = ?", commentID).
Order("id desc").Find(&revs).Error
if err != nil {
return nil, err
}
if revs == nil {
revs = []model.CommentRevision{}
}
return revs, nil
}
// RecentCommentItem 右栏「最新评论」条目
type RecentCommentItem struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
Floor int `json:"floor"`
UserID uint `json:"user_id,omitempty"`
Author string `json:"author"`
Avatar string `json:"avatar"`
@@ -224,7 +353,7 @@ func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error
}
var comments []model.Comment
err := model.DB.Preload("User").Preload("Post").
Where("is_private = ?", false).
Where("is_private = ? AND status = ?", false, model.ContentStatusPublished).
Order("id desc").Limit(limit * 2). // 多取一些以跳过已删帖
Find(&comments).Error
if err != nil {
@@ -252,6 +381,7 @@ func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error
out = append(out, RecentCommentItem{
ID: c.ID,
PostID: c.PostID,
Floor: c.Floor,
UserID: c.UserID,
Author: author,
Avatar: avatar,
@@ -278,18 +408,24 @@ func truncateRunes(s string, n int) string {
}
// ListRecent 管理员查看最近评论
func (s *CommentService) ListRecent(page, size int) ([]model.Comment, int64, error) {
func (s *CommentService) ListRecent(page, size int, status string) ([]model.Comment, int64, error) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
db := model.DB.Model(&model.Comment{})
switch status {
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected:
db = db.Where("status = ?", status)
}
var total int64
model.DB.Model(&model.Comment{}).Count(&total)
db.Count(&total)
var comments []model.Comment
err := model.DB.Preload("User").Preload("Post").
Order("id desc").Offset((page-1)*size).Limit(size).Find(&comments).Error
err := db.Preload("User").Preload("Post").
Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC").
Offset((page - 1) * size).Limit(size).Find(&comments).Error
if err != nil {
return nil, 0, err
}