实战一:待办事项 CRUD API(含校验/错误/测试要点,分步讲)

把前面的知识串成第一个完整实战:待办事项 CRUD。按「模型 → 数据层 → 处理器 → 路由」分步实现,附校验、错误与测试要点。项目布局参考第 38 章。

1. 模型与迁移

type Todo struct {
    ID        uint      `json:"id" gorm:"primaryKey"`
    Title     string    `json:"title" gorm:"size:200;not null"`
    Done      bool      `json:"done" gorm:"default:false"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

// 启动时:db.AutoMigrate(&Todo{})

2. 数据层 dao

type TodoDAO struct{ DB *gorm.DB }
func (d *TodoDAO) Create(t *Todo) error { return d.DB.Create(t).Error }
func (d *TodoDAO) ByID(id uint) (*Todo, error) {
    var t Todo
    if err := d.DB.First(&t, id).Error; err != nil {
        return nil, err // gorm.ErrRecordNotFound 说明不存在
    }
    return &t, nil
}
func (d *TodoDAO) List(page, size int) ([]Todo, int64, error) {
    var items []Todo
    var total int64
    d.DB.Model(&Todo{}).Count(&total)
    err := d.DB.Order("id DESC").
        Offset((page - 1) * size).Limit(size).Find(&items).Error
    return items, total, err
}
func (d *TodoDAO) Update(id uint, patch map[string]any) error {
    return d.DB.Model(&Todo{}).Where("id = ?", id).Updates(patch).Error
}
func (d *TodoDAO) Delete(id uint) error {
    return d.DB.Delete(&Todo{}, id).Error
}

3. handler 层

参数绑定 → 调 dao → 统一错误:

func (h *TodoHandler) Create(c *gin.Context) {
    var in struct {
        Title string `json:"title" binding:"required,min=1,max=200"`
    }
    if err := c.ShouldBindJSON(&in); err != nil {
        fail(c, http.StatusBadRequest, "INVALID_ARG", "标题必填且不超过 200 字")
        return
    }
    t := &Todo{Title: in.Title}
    if err := h.dao.Create(t); err != nil {
        fail(c, http.StatusInternalServerError, "DB_ERROR", "创建失败")
        return
    }
    c.JSON(http.StatusCreated, t)
}

更新用 PATCH 只传变更字段;删除成功返回 204;查询单个用 errors.Is(err, gorm.ErrRecordNotFound) 区分 404。

4. 路由注册

api := r.Group("/api/v1/todos")
{
    api.POST("", h.Create)
    api.GET("", h.List)          // ?page=&page_size=
    api.GET("/:id", h.Get)
    api.PATCH("/:id", h.Update)  // body: {"done": true}
    api.DELETE("/:id", h.Delete)
}

5. 测试要点

  • 表驱动覆盖:缺标题 400、查不存在 404、正常流 201/200;
  • dao 依赖用接口注入 fake,或用 SQLite 内存模式单测;
  • 断言 JSON 里的 id/done 字段,别只查状态码;
  • 更新/删除路径各补一个「目标不存在」用例。

注意点

  • Where("id = ?", id) 显式条件,防批量更新/删除误伤全表;
  • 返回给前端的 JSON 结构保持稳定,别依赖结构体字段顺序;
  • page_size 钳制上限,offset 用 int 计算防止溢出。

小结

一个 CRUD 接口的骨架是固定的:模型 → dao → handler → 路由,外加统一错误与表驱动测试。Todo 没有复杂业务,正好把套路走熟;下一章的「文章发布」会叠加鉴权与多对多关联。

笔记加载中…