Skip to content

第40章:实战项目二 —— 博客系统 ​

本章目标 ​

学完本章后,你将能够:

  1. 设计多表关联的博客数据库模型
  2. 使用 Preload 解决关联查询的 N+1 问题
  3. 在事务中处理文章发布(文章+标签)
  4. 实现文章、评论、标签的完整 CRUD

40.1 数据库设计 ​

┌──────────┐     ┌──────────┐     ┌──────────┐
│  users   │     │ articles │     │ comments │
├──────────┤     ├──────────┤     ├──────────┤
│ id       │────>│ user_id  │     │ article_id│──┐
│ username │     │ title    │<────│ user_id  │  │
│ ...      │     │ content  │     │ content  │  │
└──────────┘     │ status   │     │ ...      │  │
                 └──────────┘     └──────────┘  │
                      │                         │
                      │ many2many               │
                      ▼                         │
                 ┌──────────┐                   │
                 │   tags   │                   │
                 ├──────────┤                   │
                 │ id       │                   │
                 │ name     │                   │
                 └──────────┘                   │
                      ▲                         │
                      │ article_tags (中间表)    │
                      └─────────────────────────┘

40.2 模型定义 ​

go
package models

import (
	"time"
	"gorm.io/gorm"
)

type Article struct {
	ID        uint           `gorm:"primaryKey" json:"id"`
	Title     string         `gorm:"type:varchar(200);not null" json:"title"`
	Slug      string         `gorm:"type:varchar(200);uniqueIndex" json:"slug"`
	Content   string         `gorm:"type:text" json:"content"`
	Status    string         `gorm:"type:varchar(20);default:'draft'" json:"status"`
	ViewCount int            `gorm:"default:0" json:"view_count"`
	UserID    uint           `json:"user_id"`
	User      User           `gorm:"foreignKey:UserID" json:"user,omitempty"`
	Comments  []Comment      `gorm:"foreignKey:ArticleID" json:"comments,omitempty"`
	Tags      []Tag          `gorm:"many2many:article_tags" json:"tags,omitempty"`
	CreatedAt time.Time      `json:"created_at"`
	UpdatedAt time.Time      `json:"updated_at"`
	DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}

type Comment struct {
	ID        uint           `gorm:"primaryKey" json:"id"`
	Content   string         `gorm:"type:text;not null" json:"content"`
	ArticleID uint           `json:"article_id"`
	Article   Article        `gorm:"foreignKey:ArticleID" json:"-"`
	UserID    uint           `json:"user_id"`
	User      User           `gorm:"foreignKey:UserID" json:"user,omitempty"`
	CreatedAt time.Time      `json:"created_at"`
}

type Tag struct {
	ID       uint      `gorm:"primaryKey" json:"id"`
	Name     string    `gorm:"type:varchar(50);uniqueIndex;not null" json:"name"`
	Articles []Article `gorm:"many2many:article_tags" json:"-"`
}

// SEO 友好的 URL Slug 自动生成
func (a *Article) BeforeCreate(tx *gorm.DB) error {
	// 简单处理:标题转拼音 + 连字符(实际项目中用专业库)
	// 这里仅示意
	if a.Slug == "" {
		a.Slug = fmt.Sprintf("%s-%d", 
			strings.ReplaceAll(strings.ToLower(a.Title), " ", "-"),
			time.Now().Unix())
	}
	return nil
}

40.3 核心业务实现 ​

发布文章(事务:文章 + 标签) ​

go
func (h *ArticleHandler) Publish(c *gin.Context) {
	var input struct {
		Title   string   `json:"title" binding:"required"`
		Content string   `json:"content" binding:"required"`
		TagIDs  []uint   `json:"tag_ids"`
	}
	c.ShouldBindJSON(&input)

	article := Article{
		Title:   input.Title,
		Content: input.Content,
		Status:  "published",
		UserID:  c.GetUint("user_id"), // 从认证中间件获取
	}

	err := h.db.Transaction(func(tx *gorm.DB) error {
		// 1. 创建文章
		if err := tx.Create(&article).Error; err != nil {
			return err
		}

		// 2. 关联标签(多对多)
		if len(input.TagIDs) > 0 {
			var tags []Tag
			tx.Find(&tags, input.TagIDs)
			if err := tx.Model(&article).Association("Tags").Append(&tags); err != nil {
				return err
			}
		}
		return nil
	})

	if err != nil {
		c.JSON(500, gin.H{"error": err.Error()})
		return
	}
	c.JSON(201, gin.H{"data": article})
}

文章列表(Preload 解决 N+1) ​

go
func (h *ArticleHandler) List(c *gin.Context) {
	var articles []Article
	var total int64

	page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
	pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))

	query := h.db.Model(&Article{}).Where("status = ?", "published")

	// 标签筛选
	if tagID := c.Query("tag_id"); tagID != "" {
		query = query.Joins("JOIN article_tags ON articles.id = article_tags.article_id").
			Where("article_tags.tag_id = ?", tagID)
	}

	query.Count(&total)

	// Preload 加载关联数据(解决 N+1)
	query.Preload("User").
		Preload("Tags").
		Preload("Comments", func(db *gorm.DB) *gorm.DB {
			return db.Order("created_at desc").Limit(5)  // 只取最新 5 条评论
		}).
		Scopes(Paginate(page, pageSize)).
		Order("created_at desc").
		Find(&articles)

	c.JSON(200, gin.H{
		"data": articles,
		"meta": gin.H{"total": total, "page": page, "page_size": pageSize},
	})
}

文章详情(含评论和标签) ​

go
func (h *ArticleHandler) Get(c *gin.Context) {
	id := c.Param("id")

	var article Article
	err := h.db.Preload("User").
		Preload("Tags").
		Preload("Comments.User").  // 嵌套 Preload:评论 → 评论者
		First(&article, id).Error

	if err != nil {
		c.JSON(404, gin.H{"error": "文章不存在"})
		return
	}

	// 阅读量 +1(用 UpdateColumn 跳过 Hook)
	h.db.Model(&article).UpdateColumn("view_count", gorm.Expr("view_count + ?", 1))

	c.JSON(200, gin.H{"data": article})
}

40.4 路由注册 ​

go
func main() {
	db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Info),
	})
	db.AutoMigrate(&User{}, &Article{}, &Comment{}, &Tag{})

	r := gin.Default()

	articleH := &ArticleHandler{db: db}
	api := r.Group("/api")
	{
		api.GET("/articles", articleH.List)
		api.GET("/articles/:id", articleH.Get)
		api.POST("/articles", articleH.Publish)
		api.PUT("/articles/:id", articleH.Update)
		api.DELETE("/articles/:id", articleH.Delete)
	}

	r.Run(":8080")
}

本章小结 ​

本项目综合运用了:HasMany(文章→评论)、Many2Many(文章↔标签)、BelongsTo(文章→用户)、Preload(解决 N+1)、事务(文章+标签发布)、软删除、嵌套 Preload。

练习题 ​

  1. 实现"热门文章"接口:按阅读量排序返回前 10 篇文章。
  2. 实现"相关文章"接口:根据标签相似度推荐文章。
  3. 实现"评论"的 CRUD 接口。
  4. 添加全文搜索:用 LIKE 或 MySQL 全文索引搜索文章标题和内容。

把零散知识,整理成可以反复走的路。