Files
jiang13-forum/service/post.go
freefire 822eef96be 新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:58:22 +08:00

518 lines
14 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 (
"errors"
"sort"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
type PostService struct {
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *PostService {
return &PostService{filter: filter, settings: settings}
}
type PostListQuery struct {
BoardID uint
Page int
Size int
Keyword string
Sort string // latest | reply | hot
}
// PostListItem 帖子列表项(含评论数等扩展字段)
type PostListItem struct {
model.Post
CommentCount int `json:"comment_count"`
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
}
func (s *PostService) ListItems(q PostListQuery) ([]PostListItem, int64, error) {
posts, total, err := s.List(q)
if err != nil {
return nil, 0, err
}
if len(posts) == 0 {
return []PostListItem{}, total, nil
}
ids := make([]uint, len(posts))
for i, p := range posts {
ids[i] = p.ID
}
countMap := s.commentCountMap(ids)
replyMap := s.lastReplyMap(ids)
items := make([]PostListItem, len(posts))
for i, p := range posts {
items[i] = PostListItem{
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: replyMap[p.ID],
}
}
return items, total, nil
}
func (s *PostService) commentCountMap(postIDs []uint) map[uint]int {
type row struct {
PostID uint
Count int
}
var rows []row
model.DB.Model(&model.Comment{}).Select("post_id, count(*) as count").
Where("post_id IN ?", postIDs).Group("post_id").Scan(&rows)
m := make(map[uint]int)
for _, r := range rows {
m[r.PostID] = r.Count
}
return m
}
func (s *PostService) lastReplyMap(postIDs []uint) map[uint]*time.Time {
type row struct {
PostID uint
LastReply string
}
var rows []row
model.DB.Model(&model.Comment{}).
Select("post_id, MAX(created_at) as last_reply").
Where("post_id IN ?", postIDs).
Group("post_id").
Scan(&rows)
m := make(map[uint]*time.Time, len(rows))
for _, r := range rows {
if t, ok := parseSQLiteTime(r.LastReply); ok {
m[r.PostID] = &t
}
}
return m
}
// parseSQLiteTime 解析 SQLite 聚合查询返回的时间字符串
func parseSQLiteTime(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{
"2006-01-02 15:04:05.999999999-07:00",
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
if limit <= 0 {
limit = 10
}
var posts []model.Post
err := model.DB.Preload("User").Preload("Board").
Order("like_count desc, view_count desc").Limit(limit).Find(&posts).Error
if err != nil {
return nil, err
}
ids := make([]uint, len(posts))
for i, p := range posts {
ids[i] = p.ID
}
countMap := s.commentCountMap(ids)
items := make([]PostListItem, len(posts))
for i, p := range posts {
items[i] = PostListItem{Post: p, CommentCount: countMap[p.ID]}
}
return items, nil
}
// TagCount 标签及其出现次数
type TagCount struct {
Name string `json:"name"`
Count int `json:"count"`
}
// PopularTags 聚合帖子标签,按热度降序返回
func (s *PostService) PopularTags(limit int) ([]TagCount, error) {
if limit <= 0 {
limit = 40
}
var rows []struct{ Tags string }
if err := model.DB.Model(&model.Post{}).
Select("tags").
Where("tags <> '' AND tags IS NOT NULL").
Find(&rows).Error; err != nil {
return nil, err
}
counts := make(map[string]int)
// 保留首次出现的原始大小写作为展示名
display := make(map[string]string)
for _, row := range rows {
for _, part := range strings.FieldsFunc(row.Tags, func(r rune) bool {
return r == ',' || r == ''
}) {
name := strings.TrimSpace(part)
if name == "" {
continue
}
key := strings.ToLower(name)
counts[key]++
if _, ok := display[key]; !ok {
display[key] = name
}
}
}
list := make([]TagCount, 0, len(counts))
for key, n := range counts {
list = append(list, TagCount{Name: display[key], Count: n})
}
sort.Slice(list, func(i, j int) bool {
if list[i].Count != list[j].Count {
return list[i].Count > list[j].Count
}
return strings.ToLower(list[i].Name) < strings.ToLower(list[j].Name)
})
if len(list) > limit {
list = list[:limit]
}
return list, nil
}
func (s *PostService) CommentCount(postID uint) int {
var count int64
model.DB.Model(&model.Comment{}).Where("post_id = ?", postID).Count(&count)
return int(count)
}
func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
if q.Page < 1 {
q.Page = 1
}
q.Size = s.settings.NormalizePageSize(q.Size)
if q.Keyword != "" {
kw, err := s.settings.NormalizeSearchKeyword(q.Keyword)
if err != nil {
return nil, 0, err
}
q.Keyword = kw
}
db := model.DB.Model(&model.Post{}).Preload("User").Preload("Board")
if q.BoardID > 0 {
db = db.Where("board_id = ?", q.BoardID)
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
}
var total int64
db.Count(&total)
var posts []model.Post
db = db.Order("pinned desc")
switch normalizePostSort(q.Sort) {
case "reply":
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底
db = db.Order(`(
SELECT COUNT(*) FROM comments
WHERE comments.post_id = posts.id AND comments.deleted_at IS NULL
) > 0 DESC`)
db = db.Order(`(
SELECT MAX(created_at) FROM comments
WHERE comments.post_id = posts.id AND comments.deleted_at IS NULL
) DESC`)
db = db.Order("posts.created_at DESC")
case "hot":
db = db.Order("like_count desc, view_count desc")
default:
db = db.Order("id desc")
}
err := db.Order("id desc").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&posts).Error
return posts, total, err
}
func normalizePostSort(sort string) string {
switch sort {
case "reply", "hot":
return sort
default:
return "latest"
}
}
func (s *PostService) FindByID(id uint) (*model.Post, error) {
var post model.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
if err != nil {
return nil, ErrPostNotFound
}
return &post, nil
}
func (s *PostService) RecordView(id uint) {
model.DB.Model(&model.Post{}).Where("id = ?", id).
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
}
func (s *PostService) GetByID(id uint) (*model.Post, error) {
post, err := s.FindByID(id)
if err != nil {
return nil, err
}
s.RecordView(id)
return post, nil
}
func (s *PostService) Create(userID, boardID uint, title, content, tags string) (*model.Post, error) {
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(content)
tags = s.filter.Filter(strings.TrimSpace(tags))
if title == "" || content == "" {
return nil, errors.New("标题和内容不能为空")
}
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
return nil, err
}
if err := s.settings.ValidateTextLength(tags, s.settings.PostTagsMax(), ErrPostTagsTooLong); err != nil {
return nil, err
}
if err := s.settings.ValidateTextLength(content, s.settings.PostContentMax(), ErrPostContentTooLong); err != nil {
return nil, err
}
if _, err := NewBoardService().GetByID(boardID); err != nil {
return nil, err
}
post := &model.Post{
BoardID: boardID,
UserID: userID,
Title: title,
Content: content,
ContentPlain: StripHTMLForSearch(content),
Tags: tags,
}
return post, model.DB.Create(post).Error
}
func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content, tags string) error {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
if !isAdmin && post.UserID != userID {
return ErrPermissionDenied
}
if err := s.checkEditable(&post, isAdmin); err != nil {
return err
}
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(content)
tags = s.filter.Filter(strings.TrimSpace(tags))
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
return err
}
if err := s.settings.ValidateTextLength(tags, s.settings.PostTagsMax(), ErrPostTagsTooLong); err != nil {
return err
}
if err := s.settings.ValidateTextLength(content, s.settings.PostContentMax(), ErrPostContentTooLong); err != nil {
return err
}
return model.DB.Transaction(func(tx *gorm.DB) error {
rev := model.PostRevision{
PostID: postID, EditorID: userID,
Title: post.Title, Content: post.Content, Tags: post.Tags,
}
if err := tx.Create(&rev).Error; err != nil {
return err
}
return tx.Model(&post).Updates(map[string]interface{}{
"title": title,
"content": content,
"content_plain": StripHTMLForSearch(content),
"tags": tags,
}).Error
})
}
// CanEdit 判断当前用户是否可编辑帖子
func (s *PostService) CanEdit(post *model.Post, isAdmin bool) bool {
return s.checkEditable(post, isAdmin) == nil
}
// EditBlockReason 返回不可编辑的原因(可编辑时返回空字符串)
func (s *PostService) EditBlockReason(post *model.Post, isAdmin bool) string {
if err := s.checkEditable(post, isAdmin); err != nil {
return err.Error()
}
return ""
}
func (s *PostService) checkEditable(post *model.Post, isAdmin bool) error {
if isAdmin {
return nil
}
if post.EditLocked {
return ErrPostEditLocked
}
window := s.settings.PostEditWindowHours()
if window > 0 && time.Since(post.CreatedAt) > time.Duration(window)*time.Hour {
return ErrPostEditExpired
}
return nil
}
// CanUserEdit 判断指定用户是否可编辑帖子
func (s *PostService) CanUserEdit(post *model.Post, userID uint, isAdmin bool) bool {
if userID == 0 {
return false
}
if !isAdmin && post.UserID != userID {
return false
}
return s.CanEdit(post, isAdmin)
}
// UserEditBlockReason 返回用户不可编辑的原因
func (s *PostService) UserEditBlockReason(post *model.Post, userID uint, isAdmin bool) string {
if userID == 0 {
return "请先登录"
}
if !isAdmin && post.UserID != userID {
return ErrPermissionDenied.Error()
}
return s.EditBlockReason(post, isAdmin)
}
func (s *PostService) SetEditLocked(postID uint, locked bool) error {
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("edit_locked", locked)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrPostNotFound
}
return nil
}
func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) {
var revs []model.PostRevision
err := model.DB.Preload("Editor").Where("post_id = ?", postID).
Order("id desc").Find(&revs).Error
if err != nil {
return nil, err
}
if revs == nil {
revs = []model.PostRevision{}
}
return revs, nil
}
func (s *PostService) GetRevision(postID, revID uint) (*model.PostRevision, error) {
var rev model.PostRevision
err := model.DB.Preload("Editor").
Where("id = ? AND post_id = ?", revID, postID).First(&rev).Error
if err != nil {
return nil, ErrRevisionNotFound
}
return &rev, nil
}
func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
if !isAdmin && post.UserID != userID {
return ErrPermissionDenied
}
return model.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("post_id = ?", postID).Delete(&model.Comment{}).Error; err != nil {
return err
}
if err := tx.Where("post_id = ?", postID).Delete(&model.PostLike{}).Error; err != nil {
return err
}
if err := tx.Where("post_id = ?", postID).Delete(&model.PostFavorite{}).Error; err != nil {
return err
}
return tx.Delete(&post).Error
})
}
func (s *PostService) SetPinned(postID uint, pinned bool) error {
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("pinned", pinned).Error
}
func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
var like model.PostLike
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like)
if result.Error != nil {
return false, result.Error
}
if result.RowsAffected > 0 {
model.DB.Delete(&like)
model.DB.Model(&model.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count - 1"))
return false, nil
}
like = model.PostLike{PostID: postID, UserID: userID}
if err := model.DB.Create(&like).Error; err != nil {
return false, err
}
model.DB.Model(&model.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count + 1"))
return true, nil
}
func (s *PostService) IsLiked(userID, postID uint) bool {
var count int64
model.DB.Model(&model.PostLike{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0
}
func (s *PostService) ToggleFavorite(userID, postID uint) (faved bool, err error) {
var fav model.PostFavorite
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&fav)
if result.Error != nil {
return false, result.Error
}
if result.RowsAffected > 0 {
if err := model.DB.Delete(&fav).Error; err != nil {
return false, err
}
return false, nil
}
fav = model.PostFavorite{PostID: postID, UserID: userID}
if err := model.DB.Create(&fav).Error; err != nil {
return false, err
}
return true, nil
}
func (s *PostService) IsFavorited(userID, postID uint) bool {
var count int64
model.DB.Model(&model.PostFavorite{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0
}
func (s *PostService) ListFavorites(userID uint, page, size int) ([]model.PostFavorite, int64, error) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
var total int64
model.DB.Model(&model.PostFavorite{}).Where("user_id = ?", userID).Count(&total)
var favs []model.PostFavorite
err := model.DB.Preload("Post.User").Preload("Post.Board").
Where("user_id = ?", userID).Order("id desc").
Offset((page - 1) * size).Limit(size).Find(&favs).Error
return favs, total, err
}