Files
backend/internal/repository/post_repo.go

1275 lines
38 KiB
Go
Raw Normal View History

package repository
import (
"context"
"strings"
"time"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/cursor"
"gorm.io/gorm"
)
// PostRepository 帖子仓储接口
type PostRepository interface {
Create(post *model.Post, images []string) error
GetByID(id string) (*model.Post, error)
Update(post *model.Post) error
UpdateWithImages(post *model.Post, images *[]string) error
UpdateModerationStatus(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error
Delete(id string) error
List(page, pageSize int, userID string, includePending bool, channelID *string) ([]*model.Post, int64, error)
GetUserPosts(userID string, page, pageSize int, includePending bool) ([]*model.Post, int64, error)
GetFavorites(userID string, page, pageSize int) ([]*model.Post, int64, error)
Like(postID, userID string) error
Unlike(postID, userID string) error
IsLiked(postID, userID string) bool
IsLikedBatch(postIDs []string, userID string) map[string]bool
Favorite(postID, userID string) error
Unfavorite(postID, userID string) error
IsFavorited(postID, userID string) bool
IsFavoritedBatch(postIDs []string, userID string) map[string]bool
IncrementViews(postID string) error
IncrementShares(postID string) (int, error)
Search(keyword string, page, pageSize int) ([]*model.Post, int64, error)
GetFollowingPosts(userID string, page, pageSize int) ([]*model.Post, int64, error)
GetHotPosts(page, pageSize int) ([]*model.Post, int64, error)
ListPublishedPostIDsSince(since time.Time, limit int) ([]string, error)
ListPinnedPublishedPostIDs() ([]string, error)
GetPostHotSnapshotsByIDs(ids []string) ([]PostHotSnapshot, error)
GetByIDs(ids []string) ([]*model.Post, error)
CreateWithContext(ctx context.Context, post *model.Post, images []string) error
GetByIDWithContext(ctx context.Context, id string) (*model.Post, error)
UpdateWithContext(ctx context.Context, post *model.Post) error
DeleteWithContext(ctx context.Context, id string) error
AdminList(query AdminPostListQuery) ([]*model.Post, int64, error)
GetByIDForAdmin(id string) (*model.Post, error)
BatchDelete(ids []string) ([]string, error)
BatchUpdateStatus(ids []string, status model.PostStatus, reviewedBy string) ([]string, error)
UpdatePinStatus(postID string, isPinned bool) error
UpdateFeatureStatus(postID string, isFeatured bool) error
GetPostsByCursor(ctx context.Context, userID string, includePending bool, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
SearchPostsByCursor(ctx context.Context, keyword string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
GetUserPostsByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
GetFollowingPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
GetHotPostsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
}
// postRepository 帖子仓储实现
type postRepository struct {
db *gorm.DB
}
// NewPostRepository 创建帖子仓储
func NewPostRepository(db *gorm.DB) PostRepository {
return &postRepository{db: db}
}
// getDB 获取数据库连接(优先使用 context 中的事务)
func (r *postRepository) getDB(ctx context.Context) *gorm.DB {
if tx := GetTxFromContext(ctx); tx != nil {
return tx
}
return r.db
}
// Create 创建帖子
func (r *postRepository) Create(post *model.Post, images []string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// 创建帖子
if err := tx.Create(post).Error; err != nil {
return err
}
// 创建图片记录
// 支持格式:["url", "url|preview_url|preview_url_large", ...]
for i, imageData := range images {
parts := strings.Split(imageData, "|")
url := parts[0]
var previewURL, previewURLLarge string
if len(parts) >= 2 && parts[1] != "" {
previewURL = parts[1]
}
if len(parts) >= 3 && parts[2] != "" {
previewURLLarge = parts[2]
}
image := &model.PostImage{
PostID: post.ID,
URL: url,
PreviewURL: previewURL,
PreviewURLLarge: previewURLLarge,
SortOrder: i,
}
if err := tx.Create(image).Error; err != nil {
return err
}
}
// 新建帖子:保证 updated_at 与 created_at 一致(避免历史 NULL/库默认值导致前端误判为已编辑)
return tx.Model(post).Where("id = ?", post.ID).UpdateColumn("updated_at", post.CreatedAt).Error
})
}
// GetByID 根据ID获取帖子
func (r *postRepository) GetByID(id string) (*model.Post, error) {
var post model.Post
err := r.db.Preload("User").Preload("Images").First(&post, "id = ?", id).Error
if err != nil {
return nil, err
}
return &post, nil
}
// Update 更新帖子
func (r *postRepository) Update(post *model.Post) error {
now := time.Now()
post.UpdatedAt = now
post.ContentEditedAt = &now
return r.db.Save(post).Error
}
// UpdateWithImages 更新帖子及其图片images=nil 表示不更新图片)
func (r *postRepository) UpdateWithImages(post *model.Post, images *[]string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
post.UpdatedAt = now
post.ContentEditedAt = &now
if err := tx.Save(post).Error; err != nil {
return err
}
if images == nil {
return nil
}
if err := tx.Where("post_id = ?", post.ID).Delete(&model.PostImage{}).Error; err != nil {
return err
}
for i, url := range *images {
image := &model.PostImage{
PostID: post.ID,
URL: url,
SortOrder: i,
}
if err := tx.Create(image).Error; err != nil {
return err
}
}
return nil
})
}
// UpdateModerationStatus 更新帖子审核状态
func (r *postRepository) UpdateModerationStatus(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error {
// 审核状态更新属于管理操作不应影响帖子内容更新时间updated_at
updates := map[string]any{
"status": status,
"reviewed_at": gorm.Expr("CURRENT_TIMESTAMP"),
"reviewed_by": reviewedBy,
"reject_reason": rejectReason,
"updated_at": gorm.Expr("updated_at"), // 防止 MySQL ON UPDATE CURRENT_TIMESTAMP 误刷新
}
return r.db.Model(&model.Post{}).Where("id = ?", postID).UpdateColumns(updates).Error
}
// Delete 删除帖子(软删除,同时清理关联数据)
func (r *postRepository) Delete(id string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// 删除帖子图片
if err := tx.Where("post_id = ?", id).Delete(&model.PostImage{}).Error; err != nil {
return err
}
// 删除帖子点赞记录
if err := tx.Where("post_id = ?", id).Delete(&model.PostLike{}).Error; err != nil {
return err
}
// 删除帖子收藏记录
if err := tx.Where("post_id = ?", id).Delete(&model.Favorite{}).Error; err != nil {
return err
}
// 删除评论点赞记录子查询获取该帖子所有评论ID
if err := tx.Where("comment_id IN (SELECT id FROM comments WHERE post_id = ?)", id).Delete(&model.CommentLike{}).Error; err != nil {
return err
}
// 删除帖子评论
if err := tx.Where("post_id = ?", id).Delete(&model.Comment{}).Error; err != nil {
return err
}
// 最后删除帖子本身(软删除)
return tx.Delete(&model.Post{}, "id = ?", id).Error
})
}
// List 分页获取帖子列表
// includePending=true 时,仅在指定 userID 下额外返回 pending用于作者查看自己待审核帖子
func (r *postRepository) List(page, pageSize int, userID string, includePending bool, channelID *string) ([]*model.Post, int64, error) {
var posts []*model.Post
var total int64
query := r.db.Model(&model.Post{})
if userID != "" {
query = query.Where("user_id = ?", userID)
}
if includePending && userID != "" {
query = query.Where("status IN ?", []model.PostStatus{
model.PostStatusPublished,
model.PostStatusPending,
})
} else {
query = query.Where("status = ?", model.PostStatusPublished)
}
if channelID != nil && *channelID != "" {
query = query.Where("channel_id = ?", *channelID)
}
query.Count(&total)
offset := (page - 1) * pageSize
err := query.Preload("User").Preload("Images").Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&posts).Error
return posts, total, err
}
// GetUserPosts 获取用户帖子
func (r *postRepository) GetUserPosts(userID string, page, pageSize int, includePending bool) ([]*model.Post, int64, error) {
var posts []*model.Post
var total int64
statusQuery := r.db.Model(&model.Post{}).Where("user_id = ?", userID)
if includePending {
statusQuery = statusQuery.Where("status IN ?", []model.PostStatus{
model.PostStatusPublished,
model.PostStatusPending,
})
} else {
statusQuery = statusQuery.Where("status = ?", model.PostStatusPublished)
}
statusQuery.Count(&total)
offset := (page - 1) * pageSize
listQuery := r.db.Where("user_id = ?", userID)
if includePending {
listQuery = listQuery.Where("status IN ?", []model.PostStatus{
model.PostStatusPublished,
model.PostStatusPending,
})
} else {
listQuery = listQuery.Where("status = ?", model.PostStatusPublished)
}
err := listQuery.Preload("User").Preload("Images").Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&posts).Error
return posts, total, err
}
// GetFavorites 获取用户收藏
func (r *postRepository) GetFavorites(userID string, page, pageSize int) ([]*model.Post, int64, error) {
var posts []*model.Post
var total int64
subQuery := r.db.Model(&model.Favorite{}).Where("user_id = ?", userID).Select("post_id")
r.db.Model(&model.Post{}).Where("id IN (?) AND status = ?", subQuery, model.PostStatusPublished).Count(&total)
offset := (page - 1) * pageSize
err := r.db.Where("id IN (?) AND status = ?", subQuery, model.PostStatusPublished).Preload("User").Preload("Images").Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&posts).Error
return posts, total, err
}
// Like 点赞帖子
func (r *postRepository) Like(postID, userID string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// 检查是否已经点赞
var existing model.PostLike
err := tx.Where("post_id = ? AND user_id = ?", postID, userID).First(&existing).Error
if err == nil {
// 已经点赞,直接返回
return nil
}
if err != gorm.ErrRecordNotFound {
return err
}
// 创建点赞记录
if err := tx.Create(&model.PostLike{
PostID: postID,
UserID: userID,
}).Error; err != nil {
return err
}
// 增加帖子点赞数(热门排序由定时任务写 Redis ZSET / top_ids不写库
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumns(map[string]any{
"likes_count": gorm.Expr("likes_count + 1"),
"updated_at": gorm.Expr("updated_at"),
}).Error
})
}
// Unlike 取消点赞
func (r *postRepository) Unlike(postID, userID string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Where("post_id = ? AND user_id = ?", postID, userID).Delete(&model.PostLike{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumns(map[string]any{
"likes_count": gorm.Expr("likes_count - 1"),
"updated_at": gorm.Expr("updated_at"),
}).Error
}
return nil
})
}
// IsLiked 检查是否点赞
func (r *postRepository) IsLiked(postID, userID string) bool {
var count int64
r.db.Model(&model.PostLike{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0
}
// IsLikedBatch 批量检查是否点赞(解决 N+1 问题)
// 返回 map[postID]bool
func (r *postRepository) IsLikedBatch(postIDs []string, userID string) map[string]bool {
result := make(map[string]bool)
if len(postIDs) == 0 || userID == "" {
return result
}
// 初始化所有 postID 为 false
for _, postID := range postIDs {
result[postID] = false
}
// 一次性查询所有点赞记录
var likedPostIDs []string
r.db.Model(&model.PostLike{}).
Where("post_id IN ? AND user_id = ?", postIDs, userID).
Pluck("post_id", &likedPostIDs)
// 更新已点赞的帖子
for _, postID := range likedPostIDs {
result[postID] = true
}
return result
}
// Favorite 收藏帖子
func (r *postRepository) Favorite(postID, userID string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// 检查是否已经收藏
var existing model.Favorite
err := tx.Where("post_id = ? AND user_id = ?", postID, userID).First(&existing).Error
if err == nil {
// 已经收藏,直接返回
return nil
}
if err != gorm.ErrRecordNotFound {
return err
}
// 创建收藏记录
if err := tx.Create(&model.Favorite{
PostID: postID,
UserID: userID,
}).Error; err != nil {
return err
}
// 增加帖子收藏数(显式固定 updated_at避免 MySQL ON UPDATE 误刷新)
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumns(map[string]any{
"favorites_count": gorm.Expr("favorites_count + 1"),
"updated_at": gorm.Expr("updated_at"),
}).Error
})
}
// Unfavorite 取消收藏
func (r *postRepository) Unfavorite(postID, userID string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Where("post_id = ? AND user_id = ?", postID, userID).Delete(&model.Favorite{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
// 减少帖子收藏数
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumns(map[string]any{
"favorites_count": gorm.Expr("favorites_count - 1"),
"updated_at": gorm.Expr("updated_at"),
}).Error
}
return nil
})
}
// IsFavorited 检查是否收藏
func (r *postRepository) IsFavorited(postID, userID string) bool {
var count int64
r.db.Model(&model.Favorite{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0
}
// IsFavoritedBatch 批量检查是否收藏(解决 N+1 问题)
// 返回 map[postID]bool
func (r *postRepository) IsFavoritedBatch(postIDs []string, userID string) map[string]bool {
result := make(map[string]bool)
if len(postIDs) == 0 || userID == "" {
return result
}
// 初始化所有 postID 为 false
for _, postID := range postIDs {
result[postID] = false
}
// 一次性查询所有收藏记录
var favoritedPostIDs []string
r.db.Model(&model.Favorite{}).
Where("post_id IN ? AND user_id = ?", postIDs, userID).
Pluck("post_id", &favoritedPostIDs)
// 更新已收藏的帖子
for _, postID := range favoritedPostIDs {
result[postID] = true
}
return result
}
// IncrementViews 增加帖子观看量
func (r *postRepository) IncrementViews(postID string) error {
return r.db.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumns(map[string]any{
"views_count": gorm.Expr("views_count + 1"),
"updated_at": gorm.Expr("updated_at"),
}).Error
}
// IncrementShares 已发布帖子分享次数 +1返回新的 shares_count非已发布或不存在返回 gorm.ErrRecordNotFound
func (r *postRepository) IncrementShares(postID string) (int, error) {
var newCount int
err := r.db.Transaction(func(tx *gorm.DB) error {
res := tx.Model(&model.Post{}).
Where("id = ? AND status = ?", postID, model.PostStatusPublished).
Updates(map[string]any{
"shares_count": gorm.Expr("shares_count + 1"),
"updated_at": gorm.Expr("updated_at"),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
var p model.Post
if err := tx.Select("shares_count").Where("id = ?", postID).First(&p).Error; err != nil {
return err
}
newCount = p.SharesCount
return nil
})
return newCount, err
}
// Search 搜索帖子
func (r *postRepository) Search(keyword string, page, pageSize int) ([]*model.Post, int64, error) {
var posts []*model.Post
var total int64
query := r.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished)
// 搜索标题和内容
if keyword != "" {
if r.db.Dialector.Name() == "postgres" {
// PostgreSQL 使用全文检索表达式,为 pg_trgm/GIN 索引升级预留路径
query = query.Where(
"to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(content, '')) @@ plainto_tsquery('simple', ?)",
keyword,
)
} else {
searchPattern := "%" + keyword + "%"
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
}
}
query.Count(&total)
offset := (page - 1) * pageSize
err := query.Preload("User").Preload("Images").Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&posts).Error
return posts, total, err
}
// GetFollowingPosts 获取关注用户的帖子
func (r *postRepository) GetFollowingPosts(userID string, page, pageSize int) ([]*model.Post, int64, error) {
var posts []*model.Post
var total int64
// 子查询获取当前用户关注的所有用户ID
subQuery := r.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Select("following_id")
// 统计总数
r.db.Model(&model.Post{}).Where("user_id IN (?) AND status = ?", subQuery, model.PostStatusPublished).Count(&total)
offset := (page - 1) * pageSize
err := r.db.Where("user_id IN (?) AND status = ?", subQuery, model.PostStatusPublished).
Preload("User").Preload("Images").
Offset(offset).Limit(pageSize).
Order("created_at DESC").
Find(&posts).Error
return posts, total, err
}
// GetHotPosts 热门榜降级Redis 未就绪时按最新发布排序
func (r *postRepository) GetHotPosts(page, pageSize int) ([]*model.Post, int64, error) {
var posts []*model.Post
var total int64
r.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished).Count(&total)
offset := (page - 1) * pageSize
err := r.db.Where("status = ?", model.PostStatusPublished).Preload("User").Preload("Images").
Offset(offset).Limit(pageSize).
Order("created_at DESC").
Find(&posts).Error
return posts, total, err
}
// PostHotSnapshot 热门重算用字段
type PostHotSnapshot struct {
ID string `gorm:"column:id"`
LikesCount int `gorm:"column:likes_count"`
CommentsCount int `gorm:"column:comments_count"`
FavoritesCount int `gorm:"column:favorites_count"`
SharesCount int `gorm:"column:shares_count"`
ViewsCount int `gorm:"column:views_count"`
CreatedAt time.Time `gorm:"column:created_at"`
}
// ListPublishedPostIDsSince 按发布时间倒序取 ID热门候选池避免全表扫快照
func (r *postRepository) ListPublishedPostIDsSince(since time.Time, limit int) ([]string, error) {
if limit <= 0 {
return nil, nil
}
type row struct {
ID string `gorm:"column:id"`
}
var rows []row
err := r.db.Model(&model.Post{}).
Select("id").
Where("status = ? AND created_at >= ?", model.PostStatusPublished, since).
Order("created_at DESC").
Limit(limit).
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]string, 0, len(rows))
for _, x := range rows {
if x.ID != "" {
out = append(out, x.ID)
}
}
return out, nil
}
// ListPinnedPublishedPostIDs 已发布且置顶的帖子 ID越新发布的置顶越靠前
func (r *postRepository) ListPinnedPublishedPostIDs() ([]string, error) {
type row struct {
ID string `gorm:"column:id"`
}
var rows []row
err := r.db.Model(&model.Post{}).
Select("id").
Where("status = ? AND is_pinned = ?", model.PostStatusPublished, true).
Order("created_at DESC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]string, 0, len(rows))
for _, x := range rows {
if x.ID != "" {
out = append(out, x.ID)
}
}
return out, nil
}
// GetPostHotSnapshotsByIDs 仅拉取候选 ID 对应的热度字段(分批 IN 查询)
func (r *postRepository) GetPostHotSnapshotsByIDs(ids []string) ([]PostHotSnapshot, error) {
if len(ids) == 0 {
return nil, nil
}
const chunkSize = 400
var all []PostHotSnapshot
for i := 0; i < len(ids); i += chunkSize {
end := i + chunkSize
if end > len(ids) {
end = len(ids)
}
batch := ids[i:end]
var rows []PostHotSnapshot
err := r.db.Model(&model.Post{}).
Select("id", "likes_count", "comments_count", "favorites_count", "shares_count", "views_count", "created_at").
Where("status = ? AND id IN ?", model.PostStatusPublished, batch).
Find(&rows).Error
if err != nil {
return nil, err
}
all = append(all, rows...)
}
return all, nil
}
// GetByIDs 根据ID列表获取帖子保持传入顺序
func (r *postRepository) GetByIDs(ids []string) ([]*model.Post, error) {
if len(ids) == 0 {
return []*model.Post{}, nil
}
var posts []*model.Post
err := r.db.Preload("User").Preload("Images").
Where("id IN ? AND status = ?", ids, model.PostStatusPublished).
Find(&posts).Error
if err != nil {
return nil, err
}
// 按传入ID顺序排序
postMap := make(map[string]*model.Post)
for _, post := range posts {
postMap[post.ID] = post
}
ordered := make([]*model.Post, 0, len(ids))
for _, id := range ids {
if post, ok := postMap[id]; ok {
ordered = append(ordered, post)
}
}
return ordered, nil
}
// ========== Context-aware methods for transaction support ==========
// CreateWithContext 创建帖子(支持事务)
func (r *postRepository) CreateWithContext(ctx context.Context, post *model.Post, images []string) error {
db := r.getDB(ctx)
// 如果 context 中有事务,直接使用
if tx := GetTxFromContext(ctx); tx != nil {
return r.createWithTx(tx, post, images)
}
// 否则开启新事务
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return r.createWithTx(tx, post, images)
})
}
// createWithTx 使用指定事务创建帖子
func (r *postRepository) createWithTx(tx *gorm.DB, post *model.Post, images []string) error {
// 创建帖子
if err := tx.Create(post).Error; err != nil {
return err
}
// 创建图片记录
for i, url := range images {
image := &model.PostImage{
PostID: post.ID,
URL: url,
SortOrder: i,
}
if err := tx.Create(image).Error; err != nil {
return err
}
}
return tx.Model(post).Where("id = ?", post.ID).UpdateColumn("updated_at", post.CreatedAt).Error
}
// GetByIDWithContext 根据ID获取帖子支持事务
func (r *postRepository) GetByIDWithContext(ctx context.Context, id string) (*model.Post, error) {
var post model.Post
err := r.getDB(ctx).WithContext(ctx).Preload("User").Preload("Images").First(&post, "id = ?", id).Error
if err != nil {
return nil, err
}
return &post, nil
}
// UpdateWithContext 更新帖子(支持事务)
func (r *postRepository) UpdateWithContext(ctx context.Context, post *model.Post) error {
now := time.Now()
post.UpdatedAt = now
post.ContentEditedAt = &now
return r.getDB(ctx).WithContext(ctx).Save(post).Error
}
// DeleteWithContext 删除帖子(支持事务)
func (r *postRepository) DeleteWithContext(ctx context.Context, id string) error {
db := r.getDB(ctx)
// 如果 context 中有事务,直接使用
if tx := GetTxFromContext(ctx); tx != nil {
return r.deleteWithTx(tx, id)
}
// 否则开启新事务
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return r.deleteWithTx(tx, id)
})
}
// deleteWithTx 使用指定事务删除帖子
func (r *postRepository) deleteWithTx(tx *gorm.DB, id string) error {
// 删除帖子图片
if err := tx.Where("post_id = ?", id).Delete(&model.PostImage{}).Error; err != nil {
return err
}
// 删除帖子点赞记录
if err := tx.Where("post_id = ?", id).Delete(&model.PostLike{}).Error; err != nil {
return err
}
// 删除帖子收藏记录
if err := tx.Where("post_id = ?", id).Delete(&model.Favorite{}).Error; err != nil {
return err
}
// 删除评论点赞记录子查询获取该帖子所有评论ID
if err := tx.Where("comment_id IN (SELECT id FROM comments WHERE post_id = ?)", id).Delete(&model.CommentLike{}).Error; err != nil {
return err
}
// 删除帖子评论
if err := tx.Where("post_id = ?", id).Delete(&model.Comment{}).Error; err != nil {
return err
}
// 最后删除帖子本身(软删除)
return tx.Delete(&model.Post{}, "id = ?", id).Error
}
// ========== Admin methods ==========
// AdminPostListQuery 管理端帖子列表查询参数
type AdminPostListQuery struct {
Page int
PageSize int
Keyword string
Status string // 为空表示所有状态
AuthorID string
StartDate string // 格式: 2006-01-02
EndDate string // 格式: 2006-01-02
}
// AdminList 管理端分页获取帖子列表(支持多条件筛选)
func (r *postRepository) AdminList(query AdminPostListQuery) ([]*model.Post, int64, error) {
var posts []*model.Post
var total int64
db := r.db.Model(&model.Post{})
// 关键词搜索(标题或内容)
if query.Keyword != "" {
if r.db.Dialector.Name() == "postgres" {
db = db.Where(
"to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(content, '')) @@ plainto_tsquery('simple', ?)",
query.Keyword,
)
} else {
searchPattern := "%" + query.Keyword + "%"
db = db.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
}
}
// 状态筛选
if query.Status != "" {
db = db.Where("status = ?", query.Status)
}
// 作者筛选
if query.AuthorID != "" {
db = db.Where("user_id = ?", query.AuthorID)
}
// 日期范围筛选
if query.StartDate != "" {
db = db.Where("created_at >= ?", query.StartDate+" 00:00:00")
}
if query.EndDate != "" {
db = db.Where("created_at <= ?", query.EndDate+" 23:59:59")
}
// 统计总数
db.Count(&total)
// 分页查询
offset := (query.Page - 1) * query.PageSize
err := db.Preload("User").Preload("Images").
Offset(offset).Limit(query.PageSize).
Order("created_at DESC").
Find(&posts).Error
return posts, total, err
}
// GetByIDForAdmin 管理端根据ID获取帖子包含所有状态
func (r *postRepository) GetByIDForAdmin(id string) (*model.Post, error) {
var post model.Post
err := r.db.Preload("User").Preload("Images").First(&post, "id = ?", id).Error
if err != nil {
return nil, err
}
return &post, nil
}
// BatchDelete 批量删除帖子(使用单次事务批量删除,避免 N+1 问题)
func (r *postRepository) BatchDelete(ids []string) ([]string, error) {
if len(ids) == 0 {
return nil, nil
}
err := r.db.Transaction(func(tx *gorm.DB) error {
// 批量删除帖子图片
if err := tx.Where("post_id IN ?", ids).Delete(&model.PostImage{}).Error; err != nil {
return err
}
// 批量删除帖子点赞记录
if err := tx.Where("post_id IN ?", ids).Delete(&model.PostLike{}).Error; err != nil {
return err
}
// 批量删除帖子收藏记录
if err := tx.Where("post_id IN ?", ids).Delete(&model.Favorite{}).Error; err != nil {
return err
}
// 批量删除评论点赞记录子查询获取所有评论ID
if err := tx.Where("comment_id IN (SELECT id FROM comments WHERE post_id IN ?)", ids).Delete(&model.CommentLike{}).Error; err != nil {
return err
}
// 批量删除帖子评论
if err := tx.Where("post_id IN ?", ids).Delete(&model.Comment{}).Error; err != nil {
return err
}
// 批量删除投票选项
if err := tx.Where("post_id IN ?", ids).Delete(&model.VoteOption{}).Error; err != nil {
return err
}
// 批量删除用户投票记录
if err := tx.Where("post_id IN ?", ids).Delete(&model.UserVote{}).Error; err != nil {
return err
}
// 最后批量删除帖子本身(软删除)
return tx.Where("id IN ?", ids).Delete(&model.Post{}).Error
})
if err != nil {
return ids, err
}
return nil, nil
}
// BatchUpdateStatus 批量更新帖子审核状态(使用单条 SQL 批量更新,避免 N+1 问题)
func (r *postRepository) BatchUpdateStatus(ids []string, status model.PostStatus, reviewedBy string) ([]string, error) {
if len(ids) == 0 {
return nil, nil
}
// 使用单条 SQL 批量更新
result := r.db.Model(&model.Post{}).
Where("id IN ?", ids).
Updates(map[string]any{
"status": status,
"reviewed_at": gorm.Expr("CURRENT_TIMESTAMP"),
"reviewed_by": reviewedBy,
"reject_reason": "",
"updated_at": gorm.Expr("updated_at"),
})
if result.Error != nil {
return ids, result.Error
}
// 返回实际更新的帖子数量
if result.RowsAffected < int64(len(ids)) {
// 部分帖子可能不存在,查询实际更新的 ID
var updatedIDs []string
r.db.Model(&model.Post{}).Where("id IN ?", ids).Pluck("id", &updatedIDs)
// 找出未更新的 ID
updatedMap := make(map[string]bool, len(updatedIDs))
for _, id := range updatedIDs {
updatedMap[id] = true
}
var failedIDs []string
for _, id := range ids {
if !updatedMap[id] {
failedIDs = append(failedIDs, id)
}
}
return failedIDs, nil
}
return nil, nil
}
// UpdatePinStatus 更新帖子置顶状态
func (r *postRepository) UpdatePinStatus(postID string, isPinned bool) error {
// 置顶状态属于管理操作不应影响帖子内容更新时间updated_at
return r.db.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumns(map[string]any{
"is_pinned": isPinned,
"updated_at": gorm.Expr("updated_at"),
}).Error
}
// UpdateFeatureStatus 更新帖子加精状态
func (r *postRepository) UpdateFeatureStatus(postID string, isFeatured bool) error {
// 加精状态属于管理操作不应影响帖子内容更新时间updated_at
return r.db.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumns(map[string]any{
"is_featured": isFeatured,
"updated_at": gorm.Expr("updated_at"),
}).Error
}
// ========== Cursor Pagination Methods ==========
// GetPostsByCursor 游标分页获取帖子列表
// includePending=true 时,仅在指定 userID 下额外返回 pending用于作者查看自己待审核帖子
func (r *postRepository) GetPostsByCursor(ctx context.Context, userID string, includePending bool, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
db := r.getDB(ctx).WithContext(ctx)
// 构建基础查询
query := db.Model(&model.Post{})
if userID != "" {
query = query.Where("user_id = ?", userID)
}
if includePending && userID != "" {
query = query.Where("status IN ?", []model.PostStatus{
model.PostStatusPublished,
model.PostStatusPending,
})
} else {
query = query.Where("status = ?", model.PostStatusPublished)
}
if channelID != nil && *channelID != "" {
query = query.Where("channel_id = ?", *channelID)
}
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil
}
// 执行查询
var posts []*model.Post
query = builder.Build().Preload("User").Preload("Images")
if err := query.Find(&posts).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(posts), pageSize)
if hasMore {
posts = posts[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(posts) > 0 {
// 下一页游标
if hasMore {
lastPost := posts[len(posts)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastPost.CreatedAt),
lastPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
// 上一页游标
firstPost := posts[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstPost.CreatedAt),
firstPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
}
// SearchPostsByCursor 游标分页搜索帖子
func (r *postRepository) SearchPostsByCursor(ctx context.Context, keyword string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
db := r.getDB(ctx).WithContext(ctx)
query := db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished)
// 搜索标题和内容
if keyword != "" {
if r.db.Dialector.Name() == "postgres" {
// PostgreSQL 使用全文检索表达式
query = query.Where(
"to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(content, '')) @@ plainto_tsquery('simple', ?)",
keyword,
)
} else {
searchPattern := "%" + keyword + "%"
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
}
}
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil
}
// 执行查询
var posts []*model.Post
query = builder.Build().Preload("User").Preload("Images")
if err := query.Find(&posts).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(posts), pageSize)
if hasMore {
posts = posts[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(posts) > 0 {
if hasMore {
lastPost := posts[len(posts)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastPost.CreatedAt),
lastPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
firstPost := posts[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstPost.CreatedAt),
firstPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
}
// GetUserPostsByCursor 游标分页获取用户帖子
func (r *postRepository) GetUserPostsByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
db := r.getDB(ctx).WithContext(ctx)
query := db.Model(&model.Post{}).Where("user_id = ?", userID)
if includePending {
query = query.Where("status IN ?", []model.PostStatus{
model.PostStatusPublished,
model.PostStatusPending,
})
} else {
query = query.Where("status = ?", model.PostStatusPublished)
}
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil
}
// 执行查询
var posts []*model.Post
query = builder.Build().Preload("User").Preload("Images")
if err := query.Find(&posts).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(posts), pageSize)
if hasMore {
posts = posts[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(posts) > 0 {
if hasMore {
lastPost := posts[len(posts)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastPost.CreatedAt),
lastPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
firstPost := posts[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstPost.CreatedAt),
firstPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
}
// GetFollowingPostsByCursor 游标分页获取关注用户的帖子
func (r *postRepository) GetFollowingPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
db := r.getDB(ctx).WithContext(ctx)
// 子查询获取当前用户关注的所有用户ID
subQuery := db.Model(&model.Follow{}).Where("follower_id = ?", userID).Select("following_id")
// 构建基础查询
query := db.Model(&model.Post{}).Where("user_id IN (?) AND status = ?", subQuery, model.PostStatusPublished)
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil
}
// 执行查询
var posts []*model.Post
query = builder.Build().Preload("User").Preload("Images")
if err := query.Find(&posts).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(posts), pageSize)
if hasMore {
posts = posts[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(posts) > 0 {
if hasMore {
lastPost := posts[len(posts)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastPost.CreatedAt),
lastPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
firstPost := posts[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstPost.CreatedAt),
firstPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
}
// GetHotPostsByCursor 游标分页获取热门帖子
func (r *postRepository) GetHotPostsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
db := r.getDB(ctx).WithContext(ctx)
// 热门游标降级:与 GetHotPosts 一致,按最新发布排序
query := db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished).
Order("created_at DESC")
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil
}
// 执行查询
var posts []*model.Post
query = builder.Build().Preload("User").Preload("Images")
if err := query.Find(&posts).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(posts), pageSize)
if hasMore {
posts = posts[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(posts) > 0 {
if hasMore {
lastPost := posts[len(posts)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastPost.CreatedAt),
lastPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
firstPost := posts[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstPost.CreatedAt),
firstPost.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
}