// Created At 2026-09-04
// Go · PostgreSQL · ORM

GORM 数据工程实战:批量导入、请求 DTO 与校验错误翻译

一、批量导入:从数据文件到数据库

CreateInBatches 的真实使用场景是后台批量导入、数据初始化、测试造数——不是日常 CRUD。所以教学也用真实形态:数据放在 seed/books.json,接口读文件、解析、分批插入。

seed/books.json(项目根目录新建):

[
  {
    "title": "The Go Programming Language",
    "author": "Donovan & Kernighan",
    "price": 4990
  },
  { "title": "Go in Action", "author": "William Kennedy", "price": 5900 },
  {
    "title": "Concurrency in Go",
    "author": "Katherine Cox-Buday",
    "price": 4600
  },
  { "title": "Cloud Native Go", "author": "Matthew Titmus", "price": 5200 },
  { "title": "100 Go Mistakes", "author": "Teiva Harsanyi", "price": 4800 }
]

handlers/book.go 新增(该文件的 import 补 encoding/json、os、fmt,以及 github.com/go-playground/validator/v10):

// SeedBooksFromFile 从 seed/books.json 批量导入图书(后台/数据初始化场景)
func SeedBooksFromFile(c *gin.Context) {
    // 1. 读取数据文件
    data, err := os.ReadFile("seed/books.json")
    if err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "读取数据文件失败"})
        return
    }

    // 2. 先解析进 DTO(而不是模型):文件里的键只能命中 DTO 声明过的字段
    var inputs []createBookInput
    if err := json.Unmarshal(data, &inputs); err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusBadRequest, gin.H{"error": "数据文件格式错误"})
        return
    }
    if len(inputs) == 0 {
        c.JSON(http.StatusBadRequest, gin.H{"error": "数据文件为空"})
        return
    }

    // 3. 文件数据也要守同一套规则:json.Unmarshal 不跑 binding 标签,得手动过一遍 validator
    v := validator.New()
    books := make([]models.Book, 0, len(inputs))
    for i, in := range inputs {
        if err := v.Struct(in); err != nil {
            _ = c.Error(err)
            c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("第 %d 条数据不合法", i+1)})
            return
        }
        books = append(books, models.Book{Title: in.Title, Author: in.Author, Price: in.Price})
    }

    // 4. 分批插入(每批 100 条)
    if err := db.DB.WithContext(c.Request.Context()).CreateInBatches(books, 100).Error; err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "批量插入失败"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("已导入 %d 本书", len(books))})
}

CreateInBatches(slice, 100) 按每批 100 条分批插入——数据再多也不会是一条巨型 INSERT。

json.Unmarshal 不跑 binding 标签。 binding 是 gin 那一层的校验器,只在 ShouldBindJSON 里生效;文件数据想守同一套规则,就得像第 3 步那样手动 validator.Struct(...)。顺带说明为什么错误里带”第几条”——种子文件动辄上百条,不定位到序号等于没报错。

为什么不能直接用 c.ShouldBindJSON? 它只从 c.Request.Body 读数据,而这里的数据在磁盘文件里(POST /books/bulk 的请求体是空的)。把文件塞进 c.Request.Body 再调用确实能跑,但会破坏后续中间件对 body 的读取,还会把”文件内容错误”误报成”请求体格式错误”。

注意:validator.New() 和 gin 用的不是同一个实例。 两者都是 go-playground/validator/v10,规则相同;但项目里若注册过自定义规则或字段名(binding.Validator.Engine().(*validator.Validate).RegisterValidation(...)),validator.New() 会把这些配置全部丢掉——于是”请求体校验”和”文件校验”两条路径悄悄分叉。要共用就取 gin 的实例:v := binding.Validator.Engine().(*validator.Validate)。

一个顺带的启示:数据文件的字段名就是 DTO 的 json 标签(title / author / price),所以”加载文件”本质是反序列化到 DTO、再映射成模型——这和第二节「收什么和存什么分开」是同一条规则。金额在文件里直接写分(4990 = 49.90 元),与入门篇的金额约定保持一致。

注意: os.ReadFile("seed/books.json") 是相对当前工作目录的——服务必须从项目根目录启动(go run main.go),否则找不到文件。生产环境这类路径应改为可配置。

路由注册: main.go 路由区加一行:

r.POST("/books/bulk", handlers.SeedBooksFromFile)

分层重构后这个 handler 叫 Seed(文件读取移进 Service)——见《工程化(一)》。

测试:

curl -X POST http://localhost:8080/books/bulk
# {"message":"已导入 5 本书"}

# 用文件与查询增强篇的分页/搜索确认真实数据进来了
curl "http://localhost:8080/books?q=Go&pageSize=5"
# items 里应出现《The Go Programming Language》《Go in Action》等

⚠️ 批量导入非幂等: 重复 POST 会重复插入同一批书(代码没有去重/清表逻辑)。它定位是”数据初始化/测试造数”工具——重复执行前,先想好要不要清空 books(如 TRUNCATE books RESTART IDENTITY)。


二、请求 DTO 的最后一公里:required 与零值、错误翻译

入门篇第五章已经把「校验放 DTO 不放模型」讲透,多表关联篇又用评论的 createCommentInput 复了一遍——这里不再重讲 DTO 本身,只补前文没做的两件事:Price 为什么要变成 *int(required 与零值的关系),以及怎么把 400 翻译成字段级错误(2.1)。更新接口的那一半见本篇 2.2 节。

// 请求 DTO:只声明接口"愿意收"的字段 + 校验规则。
// Price 用 *int:binding:"required" 对 int 的 0 会判"缺失"
// (validator 把零值视为未提供),指针才能区分"没传"和"传了 0"。
type createBookInput struct {
    Title  string `json:"title" binding:"required"`
    Author string `json:"author" binding:"required"`
    Price  *int   `json:"price" binding:"required,gte=0,lte=1000000"` // 单位:分,必填,0–10000 元
}

// CreateBook 改造后的绑定部分:
var input createBookInput
if err := c.ShouldBindJSON(&input); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
    return
}
book := models.Book{Title: input.Title, Author: input.Author, Price: *input.Price}
  • binding:"required,gte=0,lte=1000000":价格必填、0 <= price <= 1000000(即 0–10000 元,单位:分),越界直接 400。为什么 Price 用 *int? required 对 int 的零值 0 也会判”字段缺失”,{"price":0} 会被 400 拦下;改成 *int 后”没传”是 nil(400)、”传了 0” 是 &0(通过)——入门篇心智模型 2 里”nil 表示没传”的指针语义,在这里第一次真正派上用场;
  • 行为变更提示: 入门篇第五章用的是 Price int(缺省按 0 创建、201),本篇收紧为 *int + required:缺 price 的旧请求变成 400。这是刻意的语义收紧,不是笔误。

测试:

⚠️ 下面第 2、3 条 curl 断言的翻译消息要到 2.1 小节才会出现——本节的 handler 还只返回通用文案「请发送合法的 JSON」(见上方绑定代码)。先在这里理解指针语义,翻译后的输出 2.1 再兑现。

# 传 0:指针字段放行,201 创建成功
curl -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":"x","author":"y","price":0}'          # 201

# 缺 price:required 命中(指针为 nil),400
curl -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":"x","author":"y"}'                    # 400:{"error":"缺少必填字段 Price"}

2.1 校验错误翻译:从「400」到「有意义的 400」

ShouldBindJSON 校验失败时返回的错误不是普通字符串,而是 validator.ValidationErrors(Gin 底层用 go-playground/validator)——每个条目都带 .Field()(字段名)、.Tag()(命中的规则:required / gte / lte)、.Param()(规则参数,如 1000000)。把它拆开,就能按字段/规则返回真正有用的消息:

// 写法一:只报第一个校验错误(一次说清一个)
if err := c.ShouldBindJSON(&input); err != nil {
    var ve validator.ValidationErrors
    if errors.As(err, &ve) && len(ve) > 0 { // 解包 validator 错误(经典写法,任何版本都成立)
        e := ve[0] // 既然只取第一个,就不需要循环
        switch e.Tag() {
        case "required":
            c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必填字段 " + e.Field()})
        case "gte":
            c.JSON(http.StatusBadRequest, gin.H{"error": e.Field() + " 不能小于 " + e.Param()})
        case "lte":
            c.JSON(http.StatusBadRequest, gin.H{"error": e.Field() + " 不能大于 " + e.Param()})
        default:
            c.JSON(http.StatusBadRequest, gin.H{"error": "参数不合法"})
        }
        return
    }
    c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"}) // 非校验错误
    return
}
// 写法二:聚合全部校验错误,一次告诉客户端所有字段问题
if err := c.ShouldBindJSON(&input); err != nil {
    var ve validator.ValidationErrors
    if errors.As(err, &ve) && len(ve) > 0 {
        msgs := make([]string, 0, len(ve))
        for _, e := range ve {
            switch e.Tag() {
            case "required":
                msgs = append(msgs, "缺少必填字段 "+e.Field())
            case "gte":
                msgs = append(msgs, e.Field()+" 不能小于 "+e.Param())
            case "lte":
                msgs = append(msgs, e.Field()+" 不能大于 "+e.Param())
            default:
                msgs = append(msgs, "参数不合法")
            }
        }
        c.JSON(http.StatusBadRequest, gin.H{"errors": msgs}) // 循环外统一返回
        return
    }
    c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
    return
}

响应形态提示: {"errors": [...]}(数组)是系列第一次出现的错误响应形态——此前所有接口的错误体都是 {"error": string}。主线接口按写法一(单条消息)即可,聚合版留给”一次想告诉客户端所有字段问题”的接口自选;若要暴露多条字段错误,客户端需适配 errors 数组。

import 需增补 github.com/go-playground/validator/v10(gin 的依赖,直接复用;errors 已在 book.go 用到,无需重复引入);.Field() 返回的是 Go 字段名(Price),要对应前端约定可再映射成小写。

踩坑提示:return 别放在”遍历全部”的循环里——那会让循环只执行第一次就返回,根本”聚”不起来(语义退化成”只报第一个”,和写法一重复)。写法一用 ve[0] 明示”只要第一个”;写法二把返回移到循环外,循环才真正遍历完。两种语义选一种,别混成”循环里 return 却以为在聚合”。

测试:

curl -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":"x","author":"y","price":-1}'        # 400:{"error":"Price 不能小于 0"}
curl -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":"x","author":"y","price":1000001}'    # 400:{"error":"Price 不能大于 1000000"}
curl -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":"x","author":"y"}'                    # 400:{"error":"缺少必填字段 Price"}

2.2 更新 DTO:PUT / PATCH 与零值陷阱

创建 DTO 只决定「收哪些字段」;更新 DTO 还要多回答一个问题:这个接口是整体替换(PUT)还是部分更新(PATCH)。答案不同,零值陷阱的解法也不同。

坑本身(入门篇第七章只点了个题,这里是完整解法):Updates 传入结构体时 GORM 忽略零值字段(0、""、false),所以 {"price":0} 会被静默跳过。根源不在 GORM 而在绑定层——非指针字段收 JSON 时,「没传」和「传了零值」落进同一个 0,信息在绑定时就丢了。三条路按语义选一条,下面每行 SQL 都是 GORM v1.31.2 用 DryRun 实测出来的(改完记得打开 SQL 日志自己复核一次):

写法(传 {"price":0}) 实际生成的 SQL 适用
Updates(models.Book{Price: 0}) SET author=...(零值被丢) ✗ 就是那个坑
Select("title","author","price").Updates(models.Book{...}) SET title="",author=...,price=0,updated_at=... PUT
Updates(updateBookInput{Price: &0}) SET price=0(nil 字段跳过,但 updated_at 不补) PATCH
Updates(updates(&in))(反射助手转 map) SET price=0,updated_at=... PATCH + 自动时间戳

路子一:真 PUT —— Select 白名单(最简)

PUT 的语义本来就是「整体替换」:客户端传全量、服务端整列写回,零值自然是合法值,于是不需要指针,一行 Select 就够:

// 非指针:PUT 要求全量提交,所以 required 在这里是合理的
type updateBookInput struct {
    Title  string `json:"title" binding:"required"`
    Author string `json:"author" binding:"required"`
    Price  int    `json:"price" binding:"gte=0,lte=1000000"`
}

var in updateBookInput
if err := c.ShouldBindJSON(&in); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON(title、author 必填)"})
    return
}

// Select 列出参与更新的列:列出的列零值照写,同时它本身就是白名单
result := db.DB.WithContext(c.Request.Context()).Model(&book).
    Select("title", "author", "price").
    Updates(models.Book{Title: in.Title, Author: in.Author, Price: in.Price})
  • 一行 Select 同时收口两件事:零值照写 + 只有白名单里的列能被写(deletedAt / coverUrl 这类键进不来,批量赋值被堵住)。
  • 代价是 PUT 语义本身要求的:客户端漏传 title 等于「清空标题」,不是「不改」——所以 required 在这里必须加。
  • Select 里的字符串是列名,写错不会报错、只是静默不生效;改完看一次 SQL 日志。

路子二:真 PATCH —— 指针 DTO 直接交给 Updates

PATCH 要表达「没传就不动」,这个信息只能靠指针留住(nil = 没传,&0 = 传了 0,正是本节创建 DTO 用 *int 的同一套指针语义)。不需要手写 map,把结构体直接交给 Updates 即可:

type updateBookInput struct {
    Title  *string `json:"title"`
    Author *string `json:"author"`
    Price  *int    `json:"price" binding:"omitempty,gte=0,lte=1000000"` // 更新时不能用 required
}

var in updateBookInput
if err := c.ShouldBindJSON(&in); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": "请发送合法的 JSON"})
    return
}
db.DB.WithContext(c.Request.Context()).Model(&book).Updates(in)

实测语义:{"price":0} → SET price=0;{}(全 nil)→ 一条 SQL 都不生成(RowsAffected == 0 且无错误,返回 200 还是 400 由你决定)。

两个必须知道的代价:

  1. updated_at 不再自动补:传进去的结构体不是模型类型,GORM 的自动时间戳逻辑不生效(实测 v1.31.2;跨版本请以 SQL 日志为准)。要保留就走路子三。
  2. 字段名要能落到列上:Title → title 命中默认命名策略;将来有 CoverURL 这类,需要写 gorm:"column:cover_url"(json 标签不参与 GORM 映射)。

⚠️ 指针 DTO 不能和 Select 混用:Select("title","author","price").Updates(in) 会把没传的字段写成 NULL(SET title=NULL,author=NULL,price=0),直接把数据洗掉。Select 只配非指针 DTO(路子一)。

路子三:PATCH + 自动 updated_at —— 写一次的反射助手

把「指针 DTO → map」这件重复劳动收成一个函数(reflect / strings 两个库),之后每个更新接口调用处都只剩一行:

// updates:把指针 DTO 中「传了的」字段转成 map,键取 json 名(与列名一致时可直接更新)
func updates(v any) map[string]any {
    out := map[string]any{}
    rv, rt := reflect.ValueOf(v).Elem(), reflect.TypeOf(v).Elem()
    for i := 0; i < rt.NumField(); i++ {
        if f := rv.Field(i); f.Kind() == reflect.Ptr && !f.IsNil() {
            out[strings.Split(rt.Field(i).Tag.Get("json"), ",")[0]] = f.Elem().Interface()
        }
    }
    return out
}

db.DB.WithContext(c.Request.Context()).Model(&book).Updates(updates(&in)) // SET price=0, updated_at=...

走 map 分支后 updated_at 照常自动补,类型也没丢(值来自 f.Elem().Interface(),仍是 int / string)。《工程化(一)》会把这类横切小工具收进公共层。

nil 的两种含义要分清: 创建 DTO 里 Price *int 是为了让 binding:"required" 能识别「根本没传 price」(nil → 400);更新 DTO 里指针是为了区分「没传」(跳过)和「传了 0」(写 0)。同一套指针语义,两个不同目的——别在更新 DTO 上写 required,那会把部分更新变成全量校验。

测试:

# 路子一:PUT 全量替换,0 真的写进去了
curl -X PUT http://localhost:8080/books/1 -H "Content-Type: application/json" \
  -d '{"title":"","author":"新作者","price":0}'

# 路子二/三:PATCH,只传要改的字段;再 GET 应看到 price=0(路子三的 updated_at 也会变)
curl -X PATCH http://localhost:8080/books/1 -H "Content-Type: application/json" \
  -d '{"price":0}'

三、路由速查

全系列注册过的路由,按注册顺序列出(最后一列是引入它的篇目):

方法 路径 handler 所属篇
POST /books CreateBook 1
GET /books GetBooks(分页+搜索+评论数) 1 → 3 改造
GET /books/:id GetBook(带最新 5 条 Comments 预览) 1 → 2 改造
PUT /books/:id UpdateBook 1
DELETE /books/:id DeleteBook 1
DELETE /books/:id/permanent DeleteBookPermanently(可选) 1
POST /books/:id/comments CreateComment 2
GET /books/:id/comments ListComments 2
DELETE /books/:id/comments/:cid DeleteComment 2
POST /books/:id/cover UploadCover 3
GET /uploads/* r.Static 静态服务 3
POST /books/bulk SeedBooksFromFile 4(本篇)
POST /books/:id/tags AddBookTag 5
DELETE /books/:id/tags/:tid RemoveBookTag 5
GET /tags/:name/books GetBooksByTag 5

四、工程化条目:已在工程化篇落地

前文各处的预告,已分别在《工程化(一)》(分层、注入、测试、泛型分页)与《工程化(二)》(错误中间件、排序白名单、文件头嗅探、对象存储、连接池)落地;逐条对账表见《工程化(二)》§4.2。

如果这篇文章对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay