Files
nl-blogs/server/repositories/post_repository.go
2026-06-24 16:50:04 +08:00

807 lines
21 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 repositories
import (
"encoding/json"
"fmt"
"log"
"strconv"
"time"
"github.com/niangaodev/art-code/config"
"github.com/niangaodev/art-code/models"
"github.com/sergi/go-diff/diffmatchpatch"
"gorm.io/gorm"
)
// TrendData 趋势数据
type TrendData struct {
Date string `json:"date"`
Count int `json:"value"`
YoY float64 `json:"yoy"`
MoM float64 `json:"mom"`
}
// GetPosts 获取所有博客文章(支持搜索、分类、标签筛选,支持分页)
func GetPosts(keyword string, categoryID uint, tagID uint, columnID uint, page int, pageSize int) ([]models.Post, int64, error) {
var posts []models.Post
var total int64
// 构建基础查询
query := config.DB.Model(&models.Post{}).
Where("is_published = ? AND deleted_at = ?", 1, 0)
if tagID > 0 {
query = query.Joins("JOIN post_tags pt ON posts.id = pt.post_id").
Where("pt.tag_id = ?", tagID)
}
if categoryID > 0 {
query = query.Where("category_id = ?", categoryID)
}
if columnID > 0 {
query = query.Where("column_id = ?", columnID)
}
if keyword != "" {
likeKeyword := "%" + keyword + "%"
query = query.Where("(MATCH(title, content) AGAINST(? IN BOOLEAN MODE) OR title LIKE ? OR content LIKE ?)",
keyword, likeKeyword, likeKeyword)
}
// 计算总数
countQuery := query
err := countQuery.Count(&total).Error
if err != nil {
log.Printf("Error counting posts: %v", err)
return nil, 0, err
}
// 应用分页
offset := (page - 1) * pageSize
err = query.
Preload("Category").
Preload("Column").
Preload("Tags").
Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&posts).Error
if err != nil {
log.Printf("Error querying posts: %v", err)
return nil, 0, err
}
return posts, total, nil
}
// GetPostByID 根据ID获取博客文章
func GetPostByID(id uint) (*models.Post, error) {
var post models.Post
// 显式选择所有字段,确保 content 字段被加载
err := config.DB.Model(&models.Post{}).
Select("id", "title", "category_id", "column_id", "excerpt", "content", "read_count", "is_published", "created_at", "updated_at", "deleted_at").
Preload("Category").
Preload("Column").
Preload("Tags").
Where("id = ? AND is_published = ? AND deleted_at = ?", id, 1, 0).
First(&post).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
log.Printf("Error getting post by ID: %v", err)
return nil, err
}
// 不再在此处更新阅读量,由 handler 按小时去重后递增
return &post, nil
}
// IncrementReadCount 递增文章阅读量
func IncrementReadCount(id uint) error {
return config.DB.Model(&models.Post{}).
Where("id = ?", id).
UpdateColumn("read_count", gorm.Expr("read_count + ?", 1)).Error
}
// GetAllPosts 获取所有博客文章(包括未发布的,后台用,支持搜索)
func GetAllPosts(page, pageSize int, keyword string) ([]models.Post, int64, error) {
offset := (page - 1) * pageSize
var posts []models.Post
var total int64
// 构建查询
query := config.DB.Model(&models.Post{}).
Where("deleted_at = ?", 0)
// 如果有关键词,添加搜索条件
if keyword != "" {
likeKeyword := "%" + keyword + "%"
query = query.Where("(MATCH(title, content) AGAINST(? IN BOOLEAN MODE) OR title LIKE ? OR content LIKE ?)",
keyword, likeKeyword, likeKeyword)
}
// Count total
err := query.Count(&total).Error
if err != nil {
log.Printf("Error counting posts: %v", err)
return nil, 0, err
}
// Get posts
err = query.
Preload("Category").
Preload("Column").
Preload("Tags").
Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&posts).Error
if err != nil {
log.Printf("Error querying all posts: %v", err)
return nil, 0, err
}
return posts, total, nil
}
// CreatePost 创建博客文章
func CreatePost(post *models.Post) error {
err := config.DB.Create(post).Error
if err != nil {
log.Printf("Error creating post: %v", err)
return err
}
// Insert Tags
if len(post.Tags) > 0 {
err = config.DB.Model(post).Association("Tags").Replace(post.Tags)
if err != nil {
log.Printf("Error associating tags: %v", err)
return err
}
}
// Handle column association if ColumnID is set
if post.ColumnID != nil && *post.ColumnID > 0 {
// Add to column_posts table
err = AddPostToColumn(*post.ColumnID, post.ID, 0)
if err != nil {
log.Printf("Error adding post to column: %v", err)
// Don't fail the whole operation, just log
}
}
return nil
}
// UpdatePost 更新博客文章
func UpdatePost(post *models.Post) error {
// 使用 map 更新,并明确指定要更新的字段,确保即使字段是空字符串也会被更新
updateData := map[string]interface{}{
"title": post.Title,
"category_id": post.CategoryID,
"excerpt": post.Excerpt,
"content": post.Content, // 明确包含 content即使为空字符串也会更新
"is_published": post.IsPublished,
"updated_at": time.Now().Unix(),
}
// Update column_id if provided
if post.ColumnID != nil {
updateData["column_id"] = post.ColumnID
} else {
// Set to NULL if explicitly set to nil
updateData["column_id"] = nil
}
// 使用 Select 明确指定要更新的字段,确保所有字段都被更新
err := config.DB.Model(&models.Post{}).
Where("id = ? AND deleted_at = ?", post.ID, 0).
Select("title", "category_id", "column_id", "excerpt", "content", "is_published", "updated_at").
Updates(updateData).Error
if err != nil {
log.Printf("Error updating post: %v", err)
return err
}
// Update Tags
if len(post.Tags) > 0 {
err = config.DB.Model(&models.Post{ID: post.ID}).Association("Tags").Replace(post.Tags)
if err != nil {
log.Printf("Error updating tags: %v", err)
return err
}
} else {
// Clear all tags
err = config.DB.Model(&models.Post{ID: post.ID}).Association("Tags").Clear()
if err != nil {
log.Printf("Error clearing tags: %v", err)
return err
}
}
// Handle column association
// Always sync column_posts table with column_id
// First, remove from all columns
err = config.DB.Where("post_id = ?", post.ID).Delete(&models.ColumnPost{}).Error
if err != nil {
log.Printf("Error removing post from columns: %v", err)
// Don't fail the whole operation
}
// Then add to new column if specified
if post.ColumnID != nil && *post.ColumnID > 0 {
err = AddPostToColumn(*post.ColumnID, post.ID, 0)
if err != nil {
log.Printf("Error adding post to column: %v", err)
// Don't fail the whole operation
}
}
return nil
}
// UpdatePostRelations 只更新文章的关联关系(分类、专栏、标签),不更新内容
func UpdatePostRelations(postID uint, categoryID uint, columnID *uint, tagIDs []uint) error {
// 更新分类和专栏
updateData := map[string]interface{}{
"category_id": categoryID,
"updated_at": time.Now().Unix(),
}
if columnID != nil {
updateData["column_id"] = columnID
} else {
updateData["column_id"] = nil
}
err := config.DB.Model(&models.Post{}).
Where("id = ? AND deleted_at = ?", postID, 0).
Updates(updateData).Error
if err != nil {
log.Printf("Error updating post relations: %v", err)
return err
}
// 更新标签关联
post := &models.Post{ID: postID}
if len(tagIDs) > 0 {
// 构建标签对象
tags := make([]models.Tag, 0, len(tagIDs))
for _, tagID := range tagIDs {
tags = append(tags, models.Tag{ID: tagID})
}
err = config.DB.Model(post).Association("Tags").Replace(tags)
if err != nil {
log.Printf("Error updating tags: %v", err)
return err
}
} else {
// 清除所有标签
err = config.DB.Model(post).Association("Tags").Clear()
if err != nil {
log.Printf("Error clearing tags: %v", err)
return err
}
}
// 处理专栏关联
// 先移除所有专栏关联
err = config.DB.Where("post_id = ?", postID).Delete(&models.ColumnPost{}).Error
if err != nil {
log.Printf("Error removing post from columns: %v", err)
// Don't fail the whole operation
}
// 然后添加新的专栏关联
if columnID != nil && *columnID > 0 {
err = AddPostToColumn(*columnID, postID, 0)
if err != nil {
log.Printf("Error adding post to column: %v", err)
// Don't fail the whole operation
}
}
return nil
}
// UpdatePostStatus 更新文章状态
func UpdatePostStatus(id uint, status int) error {
err := config.DB.Model(&models.Post{}).
Where("id = ? AND deleted_at = ?", id, 0).
Updates(map[string]interface{}{
"is_published": status,
"updated_at": time.Now().Unix(),
}).Error
return err
}
// DeletePost 删除博客文章 (Soft Delete)
func DeletePost(id uint) error {
err := config.DB.Model(&models.Post{}).
Where("id = ?", id).
Update("deleted_at", time.Now().Unix()).Error
if err != nil {
log.Printf("Error deleting post: %v", err)
return err
}
return nil
}
// GetPostCount 获取文章总数
func GetPostCount() (int, error) {
var count int64
err := config.DB.Model(&models.Post{}).
Where("deleted_at = ?", 0).
Count(&count).Error
if err != nil {
log.Printf("Error getting post count: %v", err)
return 0, err
}
return int(count), nil
}
// BuildPostResponse 构建博客文章响应
func BuildPostResponse(post *models.Post, includeContent bool) *models.PostResponse {
// Format CreatedAt to Date string
dateStr := time.Unix(post.CreatedAt, 0).Format("2006-01-02")
catName := ""
catSlug := ""
if post.Category != nil {
catName = post.Category.Name
catSlug = post.Category.Slug
}
colName := ""
colSlug := ""
if post.Column != nil {
colName = post.Column.Name
// 如果 Column 有 Slug 字段,可以在这里添加
// colSlug = post.Column.Slug
}
response := &models.PostResponse{
ID: post.ID,
Title: post.Title,
CategoryID: post.CategoryID,
CategoryName: catName,
CategorySlug: catSlug,
ColumnID: post.ColumnID,
ColumnName: colName,
ColumnSlug: colSlug,
Date: dateStr,
Excerpt: post.Excerpt,
Tags: post.Tags,
IsPublished: post.IsPublished,
ReadCount: post.ReadCount,
}
if includeContent {
// 使用指针类型,确保即使内容为空字符串也会出现在 JSON 中
content := post.Content
response.Content = &content
}
return response
}
// BuildPostsResponse 构建博客文章列表响应
func BuildPostsResponse(posts []models.Post) []models.PostResponse {
var responses []models.PostResponse
for _, post := range posts {
responses = append(responses, *BuildPostResponse(&post, false))
}
return responses
}
// SavePostHistory 保存文章历史记录
func SavePostHistory(post *models.Post, modifiedBy uint) error {
// 获取当前最大版本号
var maxVersion int
err := config.DB.Model(&models.PostHistory{}).
Where("post_id = ?", post.ID).
Select("COALESCE(MAX(version), 0)").
Scan(&maxVersion).Error
if err != nil {
log.Printf("Error getting max version: %v", err)
return err
}
tagIDs := make([]uint, 0, len(post.Tags))
for _, tag := range post.Tags {
tagIDs = append(tagIDs, tag.ID)
}
tagJSON, _ := json.Marshal(tagIDs)
history := &models.PostHistory{
PostID: post.ID,
Version: maxVersion + 1,
Title: post.Title,
CategoryID: post.CategoryID,
ColumnID: post.ColumnID,
TagIDs: string(tagJSON),
Excerpt: post.Excerpt,
Content: post.Content,
IsPublished: post.IsPublished,
ModifiedBy: modifiedBy,
}
err = config.DB.Create(history).Error
if err != nil {
log.Printf("Error saving post history: %v", err)
return err
}
return nil
}
// GetTopPosts 获取热门文章 (按阅读量)
func GetTopPosts(limit int) ([]models.Post, error) {
var posts []models.Post
err := config.DB.Model(&models.Post{}).
Preload("Category").
Preload("Tags").
Where("is_published = ? AND deleted_at = ?", 1, 0).
Order("read_count DESC").
Limit(limit).
Find(&posts).Error
if err != nil {
return nil, err
}
return posts, nil
}
// GetNewPostsTrend 获取新增文章趋势
func GetNewPostsTrend(startDate, endDate string) ([]TrendData, error) {
// 确定日期范围
var startTime, endTime time.Time
if startDate != "" {
startUnix := parseDateToUnix(startDate, false)
startTime = time.Unix(startUnix, 0)
} else {
startTime = time.Now().AddDate(0, 0, -6)
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, time.Local)
}
if endDate != "" {
endUnix := parseDateToUnix(endDate, true)
endTime = time.Unix(endUnix, 0)
} else {
endTime = time.Now()
endTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), 23, 59, 59, 0, time.Local)
}
// 查询数据库
query := config.DB.Model(&models.Post{}).
Select("FROM_UNIXTIME(created_at, '%Y-%m-%d') as date, COUNT(*) as count").
Where("deleted_at = ?", 0).
Where("created_at >= ?", startTime.Unix()).
Where("created_at <= ?", endTime.Unix())
var results []TrendData
err := query.Group("date").
Order("date ASC").
Scan(&results).Error
if err != nil {
return nil, err
}
// 创建日期到数据的映射
resultMap := make(map[string]TrendData)
for _, r := range results {
resultMap[r.Date] = r
}
// 生成完整日期列表
var fullResults []TrendData
current := time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, time.Local)
endDay := time.Date(endTime.Year(), endTime.Month(), endTime.Day(), 0, 0, 0, 0, time.Local)
for !current.After(endDay) {
dateStr := current.Format("2006-01-02")
if data, exists := resultMap[dateStr]; exists {
data.YoY = 0
data.MoM = 0
fullResults = append(fullResults, data)
} else {
// 填充0值
fullResults = append(fullResults, TrendData{
Date: dateStr,
Count: 0,
YoY: 0,
MoM: 0,
})
}
current = current.AddDate(0, 0, 1)
}
return fullResults, nil
}
// GetPostHistory 获取文章修改历史
func GetPostHistory(postID uint) ([]models.PostHistory, error) {
var history []models.PostHistory
err := config.DB.Model(&models.PostHistory{}).
Where("post_id = ?", postID).
Order("version DESC").
Find(&history).Error
if err != nil {
return nil, err
}
return history, nil
}
// GetPostHistoryByVersion 获取特定版本的历史记录
func GetPostHistoryByVersion(postID uint, version uint) (*models.PostHistory, error) {
var h models.PostHistory
err := config.DB.Model(&models.PostHistory{}).
Where("post_id = ? AND version = ?", postID, version).
First(&h).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &h, nil
}
// BuildPostHistoryResponse 构建历史记录响应
func BuildPostHistoryResponse(h *models.PostHistory, includeFull bool) *models.PostHistoryResponse {
resp := &models.PostHistoryResponse{
ID: h.ID,
PostID: h.PostID,
Version: h.Version,
Title: h.Title,
CategoryID: h.CategoryID,
ColumnID: h.ColumnID,
TagIDs: h.GetTagIDList(),
Date: time.Unix(h.CreatedAt, 0).Format("2006-01-02"),
IsPublished: h.IsPublished,
ModifiedBy: h.ModifiedBy,
ModifiedAt: time.Unix(h.ModifiedAt, 0).Format("2006-01-02 15:04:05"),
CreatedAt: time.Unix(h.CreatedAt, 0).Format("2006-01-02 15:04:05"),
}
if includeFull {
resp.Excerpt = h.Excerpt
resp.Content = h.Content
}
return resp
}
// BuildPostHistoryResponses 构建历史记录列表响应
func BuildPostHistoryResponses(history []models.PostHistory) []models.PostHistoryResponse {
var responses []models.PostHistoryResponse
for _, h := range history {
responses = append(responses, *BuildPostHistoryResponse(&h, false))
}
return responses
}
func buildFieldDiff(from, to string, withLineDiff bool) models.PostHistoryFieldDiff {
diff := models.PostHistoryFieldDiff{
From: from,
To: to,
Changed: from != to,
}
if withLineDiff && from != to {
dmp := diffmatchpatch.New()
patches := dmp.PatchMake(from, to)
diff.Diff = dmp.PatchToText(patches)
}
return diff
}
// GetPostHistoryDiff compares two history versions.
func GetPostHistoryDiff(postID uint, fromVersion, toVersion uint) (*models.PostHistoryDiffResponse, error) {
fromHistory, err := GetPostHistoryByVersion(postID, fromVersion)
if err != nil {
return nil, err
}
if fromHistory == nil {
return nil, fmt.Errorf("from version not found")
}
toHistory, err := GetPostHistoryByVersion(postID, toVersion)
if err != nil {
return nil, err
}
if toHistory == nil {
return nil, fmt.Errorf("to version not found")
}
fromTags, _ := json.Marshal(fromHistory.GetTagIDList())
toTags, _ := json.Marshal(toHistory.GetTagIDList())
fields := map[string]models.PostHistoryFieldDiff{
"title": buildFieldDiff(fromHistory.Title, toHistory.Title, false),
"excerpt": buildFieldDiff(fromHistory.Excerpt, toHistory.Excerpt, false),
"content": buildFieldDiff(fromHistory.Content, toHistory.Content, true),
"categoryId": buildFieldDiff(strconv.FormatUint(uint64(fromHistory.CategoryID), 10), strconv.FormatUint(uint64(toHistory.CategoryID), 10), false),
"columnId": buildFieldDiff(formatOptionalUint(fromHistory.ColumnID), formatOptionalUint(toHistory.ColumnID), false),
"tagIds": buildFieldDiff(string(fromTags), string(toTags), false),
"isPublished": buildFieldDiff(strconv.Itoa(fromHistory.IsPublished), strconv.Itoa(toHistory.IsPublished), false),
}
return &models.PostHistoryDiffResponse{
FromVersion: int(fromVersion),
ToVersion: int(toVersion),
Fields: fields,
}, nil
}
func formatOptionalUint(v *uint) string {
if v == nil {
return ""
}
return strconv.FormatUint(uint64(*v), 10)
}
// RestorePostFromHistory restores a post from a history snapshot and saves a new history entry.
func RestorePostFromHistory(postID, version, modifiedBy uint) (*models.Post, error) {
history, err := GetPostHistoryByVersion(postID, version)
if err != nil {
return nil, err
}
if history == nil {
return nil, fmt.Errorf("history version not found")
}
var post models.Post
if err := config.DB.Preload("Tags").Where("id = ? AND deleted_at = ?", postID, 0).First(&post).Error; err != nil {
return nil, err
}
post.Title = history.Title
post.CategoryID = history.CategoryID
post.ColumnID = history.ColumnID
post.Excerpt = history.Excerpt
post.Content = history.Content
post.IsPublished = history.IsPublished
if err := UpdatePost(&post); err != nil {
return nil, err
}
tagIDs := history.GetTagIDList()
tags := make([]models.Tag, 0, len(tagIDs))
for _, id := range tagIDs {
tags = append(tags, models.Tag{ID: id})
}
if err := config.DB.Model(&post).Association("Tags").Replace(tags); err != nil {
return nil, err
}
post.Tags = tags
if err := SavePostHistory(&post, modifiedBy); err != nil {
return nil, err
}
reloaded, err := GetPostByIDAdmin(postID)
if err != nil {
return nil, err
}
return reloaded, nil
}
// GetPostByIDAdmin loads a post for admin use without incrementing read count.
func GetPostByIDAdmin(id uint) (*models.Post, error) {
var post models.Post
err := config.DB.Model(&models.Post{}).
Preload("Category").
Preload("Column").
Preload("Tags").
Where("id = ? AND deleted_at = ?", id, 0).
First(&post).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &post, nil
}
// GetRecommendedPostsByIP 基于IP的协同过滤推荐算法
// 查找相同IP访问过的其他文章按访问频率排序返回
func GetRecommendedPostsByIP(currentPostID uint, userIP string, limit int) ([]models.Post, error) {
if userIP == "" {
// 如果IP为空返回热门文章作为备选
return GetTopPosts(limit)
}
// 1. 查找相同IP访问过的其他文章ID及其访问次数
type PostAccessCount struct {
ArticleID uint
Count int64
}
var accessCounts []PostAccessCount
err := config.DB.Model(&models.UserAccessLog{}).
Select("article_id, COUNT(*) as count").
Where("user_ip = ? AND article_id != ? AND deleted_at = ?", userIP, currentPostID, 0).
Group("article_id").
Order("count DESC").
Limit(limit * 2). // 多查询一些,因为后面还要过滤未发布的文章
Scan(&accessCounts).Error
if err != nil {
log.Printf("Error querying recommended posts by IP: %v", err)
// 如果查询失败,返回热门文章作为备选
return GetTopPosts(limit)
}
if len(accessCounts) == 0 {
// 如果没有访问记录,返回热门文章作为备选
return GetTopPosts(limit)
}
// 2. 提取文章ID列表
articleIDs := make([]uint, 0, len(accessCounts))
for _, ac := range accessCounts {
articleIDs = append(articleIDs, ac.ArticleID)
}
// 3. 查询这些文章的详细信息(排除未发布的文章)
var posts []models.Post
err = config.DB.Model(&models.Post{}).
Preload("Category").
Preload("Tags").
Where("id IN ? AND is_published = ? AND deleted_at = ?", articleIDs, 1, 0).
Find(&posts).Error
if err != nil {
log.Printf("Error fetching recommended posts: %v", err)
return GetTopPosts(limit)
}
// 4. 按照访问次数排序(保持与 accessCounts 的顺序一致)
postMap := make(map[uint]models.Post)
for _, post := range posts {
postMap[post.ID] = post
}
var sortedPosts []models.Post
for _, ac := range accessCounts {
if post, exists := postMap[ac.ArticleID]; exists {
sortedPosts = append(sortedPosts, post)
if len(sortedPosts) >= limit {
break
}
}
}
// 5. 如果推荐的文章数量不足,用热门文章补充
if len(sortedPosts) < limit {
topPosts, err := GetTopPosts(limit - len(sortedPosts))
if err == nil {
// 排除已经推荐的文章
existingIDs := make(map[uint]bool)
for _, p := range sortedPosts {
existingIDs[p.ID] = true
}
for _, p := range topPosts {
if !existingIDs[p.ID] && p.ID != currentPostID {
sortedPosts = append(sortedPosts, p)
if len(sortedPosts) >= limit {
break
}
}
}
}
}
return sortedPosts, nil
}