实战二:文章发布 API(用户鉴权 + 标签多对多 + 分页列表)

第二个实战叠加三块:JWT 鉴权(复用鉴权章节)、文章与标签多对多、分页列表规范。目标:登录用户发文、标签自动建并关联、公开接口分页浏览。

1. 模型

type Article struct {
    ID        uint      `json:"id" gorm:"primaryKey"`
    Title     string    `json:"title" gorm:"size:200;not null"`
    Content   string    `json:"content" gorm:"type:text"`
    AuthorID  uint      `json:"author_id" gorm:"index"`
    Tags      []Tag     `json:"tags" gorm:"many2many:article_tags;"`
    CreatedAt time.Time `json:"created_at"`
}
type Tag struct {
    ID   uint   `json:"id"`
    Name string `json:"name" gorm:"size:32;uniqueIndex"`
}

AuthorID 必须来自 Token 而不是客户端传参——这是防「冒充他人发文」的关键。

2. 发布:事务内建标签并关联

标签「有则用、无则建」,两步放进一个事务:

func (s *ArticleService) Create(a *Article, tagNames []string) error {
    return s.db.Transaction(func(tx *gorm.DB) error {
        if err := tx.Create(a).Error; err != nil { return err }
        var tags []Tag
        for _, name := range tagNames {
            name = strings.TrimSpace(name)
            if name == "" { continue }
            var t Tag
            if err := tx.Where("name = ?", name).
                FirstOrCreate(&t, Tag{Name: name}).Error; err != nil { return err }
            tags = append(tags, t)
        }
        if len(tags) == 0 { return nil }
        return tx.Model(a).Association("Tags").Append(&tags)
    })
}

3. 鉴权接入 handler

路由:api.Group("/articles", AuthRequired()).POST("", h.Create);作者取自中间件注入的 userID:

func (h *ArticleHandler) Create(c *gin.Context) {
    var in struct {
        Title string   `json:"title" binding:"required,max=200"`
        Body  string   `json:"content" binding:"required,min=10"`
        Tags  []string `json:"tags"`
    }
    if err := c.ShouldBindJSON(&in); err != nil { fail(c, 400, "INVALID_ARG", "参数有误"); return }
    art := &model.Article{Title: in.Title, Content: in.Body, AuthorID: c.GetUint("userID")}
    if err := h.svc.Create(art, in.Tags); err != nil { fail(c, 500, "CREATE_FAIL", "发布失败"); return }
    c.JSON(http.StatusCreated, art)
}

4. 分页列表带标签

列表公开,读时 Preload("Tags") 避免 N+1:

func (s *ArticleService) List(page, size int) ([]Article, int64, error) {
    var arts []Article
    var total int64
    s.db.Model(&Article{}).Count(&total)
    err := s.db.Preload("Tags").
        Order("created_at DESC").
        Offset((page - 1) * size).Limit(size).
        Find(&arts).Error
    return arts, total, err
}

按标签过滤可追加 JOIN:Joinsarticle_tagstags 表后 Where("tags.name = ?", tag),细节以 gorm.io 关联文档为准。

5. 接口清单与测试要点

  • POST /api/v1/articles(需登录)创建;列表 GET 公开(?page=&page_size=&tag=);GET /:id 详情带 Tags。
  • 测试要点:未带 Token 401;标签重复创建不报错;分页 total 正确;非作者更新/删除 403。

注意点

  • 更新/删除前校验 AuthorID == 当前 userID,否则 403。
  • 事务里先 Create 再 Append 关联,顺序不能反;分页 Count 与 Find 各自独立执行。

小结

实战二把无状态鉴权、多对多关联、分页与事务拼在一起:中间件给身份,service 管事务,Preload 解 N+1。文件上传实战在下一章继续。

笔记加载中…