第40章:实战项目二 —— 博客系统
本章目标
学完本章后,你将能够:
- 设计多表关联的博客数据库模型
- 使用 Preload 解决关联查询的 N+1 问题
- 在事务中处理文章发布(文章+标签)
- 实现文章、评论、标签的完整 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。
练习题
- 实现"热门文章"接口:按阅读量排序返回前 10 篇文章。
- 实现"相关文章"接口:根据标签相似度推荐文章。
- 实现"评论"的 CRUD 接口。
- 添加全文搜索:用 LIKE 或 MySQL 全文索引搜索文章标题和内容。