实战一:待办事项 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 没有复杂业务,正好把套路走熟;下一章的「文章发布」会叠加鉴权与多对多关联。