package repository import ( "context" "strings" "time" "with_you/internal/model" "with_you/internal/pkg/cursor" "with_you/internal/pkg/utils" "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, channelID *string) ([]*model.Post, int64, error) GetHotPosts(page, pageSize int, channelID *string) ([]*model.Post, int64, error) ListPublishedPostIDsSince(since time.Time, limit int) ([]string, error) ListPublishedPostIDsByChannel(channelID string, recentHours int, limit int) ([]string, error) ListPinnedPublishedPostIDs() ([]string, error) ListPinnedPublishedPostIDsByChannel(channelID string) ([]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, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) GetHotPostsByCursor(ctx context.Context, channelID *string, 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, 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 } } 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" { searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%" query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern) } else { searchPattern := "%" + utils.EscapeLikeWildcard(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, channelID *string) ([]*model.Post, int64, error) { var posts []*model.Post var total int64 subQuery := r.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Select("following_id") baseQuery := r.db.Model(&model.Post{}).Where("user_id IN (?) AND status = ?", subQuery, model.PostStatusPublished) if channelID != nil && *channelID != "" { baseQuery = baseQuery.Where("channel_id = ?", *channelID) } baseQuery.Count(&total) offset := (page - 1) * pageSize err := baseQuery. 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, channelID *string) ([]*model.Post, int64, error) { var posts []*model.Post var total int64 baseQuery := r.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished) if channelID != nil && *channelID != "" { baseQuery = baseQuery.Where("channel_id = ?", *channelID) } baseQuery.Count(&total) offset := (page - 1) * pageSize err := baseQuery.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 } func (r *postRepository) ListPublishedPostIDsByChannel(channelID string, recentHours int, limit int) ([]string, error) { if limit <= 0 { return nil, nil } type row struct { ID string `gorm:"column:id"` } var rows []row q := r.db.Model(&model.Post{}). Select("id"). Where("status = ? AND channel_id = ?", model.PostStatusPublished, channelID) if recentHours > 0 { since := time.Now().Add(-time.Duration(recentHours) * time.Hour) q = q.Where("created_at >= ?", since) } err := q.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 } func (r *postRepository) ListPinnedPublishedPostIDsByChannel(channelID string) ([]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 = ? AND channel_id = ?", model.PostStatusPublished, true, channelID). 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 } // 创建图片记录 // 支持格式:["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 } } 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" { searchPattern := "%" + utils.EscapeLikeWildcard(query.Keyword) + "%" db = db.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern) } else { searchPattern := "%" + utils.EscapeLikeWildcard(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" { searchPattern := "%" + utils.EscapeLikeWildcard(keyword) + "%" query = query.Where("title ILIKE ? OR content ILIKE ?", searchPattern, searchPattern) } else { searchPattern := "%" + utils.EscapeLikeWildcard(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, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) { db := r.getDB(ctx).WithContext(ctx) 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) 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 } // GetHotPostsByCursor 游标分页获取热门帖子 func (r *postRepository) GetHotPostsByCursor(ctx context.Context, channelID *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 channelID != nil && *channelID != "" { query = query.Where("channel_id = ?", *channelID) } query = query.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 }