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

GORM 文件与查询增强实战:封面上传、分页搜索与评论数聚合

一、图片字段:封面上传与静态服务

目标: 给书加封面图:一个可用的上传接口,图片存磁盘、路径存数据库、静态目录对外可访问。

1.1 先定命名:为什么叫 cover_path 而不是 cover_url

字段名应当反映存的是什么:

存的是什么 更贴切的名字
完整 URL(http://host/uploads/x.jpg) cover_url
相对路径(/uploads/x.jpg)——本教程方案 cover_path
对象存储 key(S3 等) cover_key

本教程把图片落在 uploads/ 磁盘目录,数据库存相对路径:换域名、接 CDN、挪目录时数据库一行都不用改——改的只是读取时拼接的 base。

所以字段名叫 cover_path(存的是路径)。但对外,前端要的是”能直接放进 <img src> 的地址”,所以 JSON 键用 coverUrl:今天同源时它是 /uploads/x.jpg,将来接上 CDN 或对象存储就是完整的 https://...——键名描述的是对外契约(可展示地址),不是列里当下存的字面量。

落到 Go 上就是一行:字段 CoverPath,加 json:"coverUrl" 把两层名字分开(列名不用写 tag,默认命名策略会把 CoverPath 转成 cover_path,入门篇第三章讲过)。具体追加位置见 1.2。

两个注意点:

  • json:"coverUrl" 是唯一必要的那个 tag:不写它,JSON 里会输出 Go 字段名 CoverPath。同时记住 JSON 键是对外契约,发布后改名的成本比改列名高得多(前端、缓存、文档、已有客户端都要跟),想清楚再定;
  • 相对路径的代价:前端拿到的不是绝对地址。同源页面可以直接 <img src="/uploads/x.jpg">;跨域部署(比如前端是独立域名的 Nuxt 站点)必须用 API base 拼:apiBase + coverUrl。想让前端”拿到就能用”,就在响应时把相对路径拼成绝对地址(配置里的 public base + 路径)——这也正是键名保持 coverUrl 的正当理由。

1.2 模型加字段

models/book.go 在 Price 之后追加一行(1.1 的命名落到代码上就是它;Comments 等原有字段保持不变):

CoverPath string `json:"coverUrl"` // 相对路径,可空;列名 cover_path 由默认命名策略生成

AutoMigrate 会给 books 补一列可空的 cover_path,已有行不受影响(空值)。

1.3 上传接口

handlers/cover.go:

package handlers

import (
    "fmt"
    "gin-demo/db"
    "gin-demo/models"
    "net/http"
    "path/filepath"
    "strings"
    "time"

    "github.com/gin-gonic/gin"
)

// UploadCover 上传图书封面:限制类型与大小,存 uploads/,路径写回 cover_path
func UploadCover(c *gin.Context) {
    // 1. 父资源校验已由 2.4 的 requireBook 中间件完成:书不存在时根本进不到这里。
    //    直接取中间件放进 context 的实体,书的数据也不用再查一次
    book := c.MustGet("book").(*models.Book)

    // 2. 取文件并校验
    file, err := c.FormFile("cover")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "缺少文件字段 cover"})
        return
    }
    ext := strings.ToLower(filepath.Ext(file.Filename))
    switch ext {
    case ".jpg", ".jpeg", ".png", ".webp":
    default:
        c.JSON(http.StatusBadRequest, gin.H{"error": "仅允许 jpg/png/webp"})
        return
    }
    if file.Size > 2<<20 { // 2<<20 字节 = 2MB。注意这是"事后检查":FormFile 已把整个文件读入,2MB 不是请求级硬上限(教学够用,真正挡大请求要在更外层做限制)
        c.JSON(http.StatusBadRequest, gin.H{"error": "文件过大(上限 2MB)"})
        return
    }

    // 3. 落盘:随机文件名,防覆盖、防路径注入
    filename := fmt.Sprintf("%d_%d%s", book.ID, time.Now().UnixNano(), ext)
    if err := c.SaveUploadedFile(file, filepath.Join("uploads", filename)); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
        return
    }

    // 4. 路径写回数据库(只更新这一个字段)
    // coverPath 存的是 URL 路径,用正斜杠拼接(为什么别用 filepath.Join 见下方要点)
    coverPath := "/uploads/" + filename
    if err := db.DB.WithContext(c.Request.Context()).
        Model(book).Update("cover_path", coverPath).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "更新图书失败"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"coverUrl": coverPath})
}

要点:

  • 路由必须挂在 /books/:id 组上(2.4 的 bookGroup):c.MustGet("book") 依赖 requireBook 已经 c.Set 过,漏挂中间件会直接 panic。若你跳过了 2.4,就把第 1 步换回 First(&book, id) 的那段存在性检查;
  • 文件名不信任用户输入:filepath.Ext 只取扩展名,主名用 book.ID + 时间戳 拼——既防覆盖也防 ../../ 这类路径注入;
  • 两种路径别混:coverPath 是 URL 路径,永远正斜杠 /(存数据库、给前端),用 path.Join 或直接拼接;落盘才用 filepath.Join(按 OS 分隔符)。Windows 反斜杠恰恰是反例——用 filepath.Join 拼 URL 会在 Windows 下产出 \uploads\ 存库,前端永远匹配不上;
  • 类型校验用的是扩展名白名单(简单教学版;严格做法要嗅探文件头 http.DetectContentType,落地见《GORM 工程化实战(二):可靠性与生产化》第二节);
  • Model(&book).Update("cover_path", ...) 单字段更新,不会误动其它字段(与入门篇 Updates(struct) 语义呼应)。

1.4 静态服务与目录准备

main.go 三处小改动:

import (
    "os"
    // ...其余不变
)

func main() {
    db.InitDB()
    if err := db.DB.AutoMigrate(&models.Book{}, &models.Comment{}); err != nil {
        log.Fatal("迁移失败:", err)
    }

    // 磁盘目录:本地默认相对「启动时的工作目录」的 uploads/;生产用 UPLOAD_DIR 指到绝对路径
    uploadDir := "uploads"
    _ = os.MkdirAll(uploadDir, 0o755) // 确保目录存在

    r := gin.Default()
    r.Use(requestTimeout(5 * time.Second))
    r.Static("/uploads", uploadDir) // URL 前缀写死正斜杠 /uploads;目录走 uploadDir
    // /uploads/xxx.jpg → ./uploads/xxx.jpg

    // ...路由区:封面路由挂到 2.4 的 bookGroup 上(组路径已是 /books/:id)
    bookGroup.POST("/cover", handlers.UploadCover)
}

uploads 是相对路径,解析基准是进程启动时的当前工作目录——本地从项目根目录 go run 没问题,但用 systemd / Docker / IDE 指定了别的工作目录启动时,图片会写到(和读到)另一个目录。生产请用 UPLOAD_DIR 之类的环境变量指到绝对路径。

r.Static("/uploads", uploadDir) 让 /uploads/<文件名> 直接映射到磁盘目录:同源页面直接 <img src="/uploads/x.jpg"> 就能显示;跨域前端要拿 API base 拼(见 1.1 的两个注意点)。

测试:

curl -X POST http://localhost:8080/books/1/cover \
  -F "cover=@/path/to/cover.jpg"
# {"coverUrl":"/uploads/1_1734xxxx.jpg"}   ← 相对路径;跨域前端需 apiBase + 该值

curl -I http://localhost:8080/uploads/1_1734xxxx.jpg   # 200,Content-Type image/jpeg

# 反例:非图片 / 超 2MB 应返回 400
curl -X POST http://localhost:8080/books/1/cover -F "cover=@/etc/hosts"
# {"error":"仅允许 jpg/png/webp"}

二、查询增强:分页、搜索、排序

目标: 把 GetBooks 从”全量列表”升级为”分页 + 搜索 + 排序”的通用接口,并给列表加上每本书的评论数(JOIN + GROUP BY)。多表关联篇(《多表关联实战》)学过的分页骨架,这里在更大的表上复用。

2.1 先收拢帮助函数

多表关联篇的 ListComments 里,分页解析是内联写的(先看完整形态是正确的教学顺序)。现在原样搬进 handlers/pagination.go,供所有分页接口复用——行为不变,只是从内联变成一处可复用:

// handlers/pagination.go —— 分页参数解析 + 校验(防御式,非法值回落到默认)
package handlers

import (
    "strconv"

    "github.com/gin-gonic/gin"
)

func parsePagination(c *gin.Context) (page, pageSize int) {
    page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) // Atoi 出错返回 0,交给下面的 < 1 兜住
    if page < 1 {
        page = 1
    }
    pageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "10"))
    if pageSize < 1 {
        pageSize = 10
    }
    if pageSize > 100 {
        pageSize = 100 // 上限 100:防止客户端一次拉取过量数据
    }
    return page, pageSize
}

2.2 改造 GetBooks

⚠️ 破坏性契约变更: GET /books 的响应从入门篇的裸数组变为 {items,total,page,pageSize} 对象——已有前端需要同步适配(第 4 篇的路由总表把它记为”1 → 3 改造”)。

handlers/book.go:

// GetBooks 图书列表:支持 q(标题/作者模糊搜索)、page/pageSize 分页;排序固定为创建时间倒序(外部排序字段需先白名单化,见[《GORM 工程化实战(二)》](./gorm-gin-engineering-reliability)§2.1)。
// 返回 {items, total, page, pageSize}。
func GetBooks(c *gin.Context) {
    // 1. 解析查询参数
    // 1.1 q:标题/作者模糊搜索关键词
    q := c.Query("q")
    // 1.2 分页参数解析 + 校验(在 handlers/pagination.go 里的 parsePagination)
    page, pageSize := parsePagination(c)

    // 2. 组装查询:可选标题/作者模糊搜索
    query := db.DB.WithContext(c.Request.Context()).Model(&models.Book{})
    if q != "" {
        like := "%" + q + "%"
        query = query.Where("title ILIKE ? OR author ILIKE ?", like, like)
    }

    // 3. 先 Count 过滤后的总条数
    var total int64
    if err := query.Count(&total).Error; err != nil {
        _ = c.Error(err) // 具体错误交给 Gin 日志;客户端消息保持统一
        c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
        return
    }

    // 4. 再取当前页(按创建时间倒序)
    var books []models.Book
    if err := query.
        Order("created_at DESC").
        Offset((page - 1) * pageSize).
        Limit(pageSize).
        Find(&books).Error; err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
        return
    }

    // 5. 返回分页结果
    c.JSON(http.StatusOK, gin.H{
        "items":    books,
        "total":    total,
        "page":     page,
        "pageSize": pageSize,
    })
}

测试:

curl "http://localhost:8080/books?q=Go&page=1&pageSize=10"
# {"items":[...],"total":1,"page":1,"pageSize":10}

curl "http://localhost:8080/books?page=2&pageSize=5"
# 翻页:total 不变,items 为第二页

要点(都是 GORM 查询的骨架级知识):

  • ILIKE 是 PG 专用:LIKE 分大小写、ILIKE 不分。教程用 PG,写 ILIKE;换 MySQL 用 LIKE,换 SQLite 用 LIKE(SQLite LIKE 对 ASCII 不区分大小写)——驱动差异的一个具体例子。另注意 q 若含 %/_ 会被当成通配符:参数化只防 SQL 注入、不防通配符语义,按字面搜索需先转义或加 ESCAPE,教程不展开;
  • 在篇 2 的骨架上加搜索:Count → Order/Offset/Limit 的顺序没变,只是取数前多了这层 Where;Count 在分页条件之前执行,拿到的自然是过滤后的总数——这是分页 + 搜索接口的标准姿势;
  • ORDER 注入:如果 sort 参数来自用户,千万别直接拼进 Order()("/books?sort=created_at;DROP...")——教程固定排序即可,接受外部排序字段要先做白名单(落地见《GORM 工程化实战(二)》§2.1)。

2.3 给列表加评论数:JOIN + GROUP BY

Preload 只能取”评论对象数组”,取不了”评论条数”。要”每本书带评论数”,用聚合——需要一个承载结果的结构:

// 列表响应结构:Book 本体 + 评论计数(不进数据库表,只做查询载体)
type BookListItem struct {
    models.Book
    CommentCount int64 `json:"commentCount"`
}

然后在 GetBooks 基础上只改第 4 步(第 1/2/3/5 步原样不动:分页解析仍走 parsePagination,总数仍用纯 books 表 Count)——把第 4 步的 var books []models.Book + Find 换成下面的 var items []BookListItem + Scan(BookListItem 见上方声明):

var items []BookListItem
if err := query.
    Select("books.*, COUNT(comments.id) AS comment_count").
    Joins("LEFT JOIN comments ON comments.book_id = books.id AND comments.deleted_at IS NULL").
    Group("books.id").
    Order("books.created_at DESC").
    Offset((page - 1) * pageSize).
    Limit(pageSize).
    Scan(&items).Error; err != nil {
    _ = c.Error(err)
    c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
    return
}

聚合查询的四个坑:

  • LEFT JOIN + Group("books.id"):没评论的书也要出现(comment_count = 0),所以是 LEFT 不是 INNER;
  • comments.deleted_at IS NULL 条件要进 JOIN 而不是 WHERE:放进 WHERE 会把”无评论的书”整行剔除,放进 JOIN 才能保留左表 + 只数未删除的评论——聚合查询最常见的坑;
  • Scan 进自定义结构:BookListItem 嵌了 models.Book 再补一个计数字段——手写聚合 SQL 的结果装进”查询专用载体”,这正是 GORM 把”原生 SQL”接回类型世界的正规姿势(查询载体不进数据库表,只做取数);
  • 字段歧义:JOIN 之后 created_at、id 在两表都有——SQL 层要写 books.created_at、books.id(Group("books.id") 同理),不写表前缀会报”ambiguous column”。

驱动差异提示: Select("books.*") + Group("books.id") 能成立,依赖 PostgreSQL 的”函数依赖”特性——按主键分组时允许直接选其它列。MySQL 开着 only_full_group_by(默认开)时会报错,需要把 books.* 展开成完整列清单并全量分组;这是”手写聚合 SQL”跨数据库的典型差异,教程用 PG,不做兼容处理。

测试:

curl "http://localhost:8080/books?q=Go&pageSize=10"
# items[0].commentCount 应为该书的未删除评论数(无评论的书为 0)

契约分层:GET /books 返回 {items,total,page,pageSize},GET /books/:id 返回单本书 + 评论——列表轻、详情重。


本篇小结

  • 本篇新增路由:
方法 路径 handler / 作用
POST /books/:id/cover UploadCover
GET /uploads/* r.Static 静态服务
GET /books(改造) GetBooks 分页 + 搜索 + 评论数
  • 你现在的项目:books + comments 双表、封面图上传与静态服务、分页搜索列表、每本书带评论数;
  • 下一篇《数据工程实战》:批量导入真实数据、请求 DTO 与参数化校验、校验错误的友好翻译,末尾附路由速查与工程化条目清单(系列第 6⁄7 篇预告的集中对账)。

附:PostgreSQL 特性速查(MySQL / SQLite 对照)

全系列真正依赖 PostgreSQL 的写法只有下面几处,其余都是标准 SQL,三库通用:

PG 特性 出现在哪 本系列写法 换 MySQL 换 SQLite
大小写不敏感的模糊搜索 本篇 §2.2 ILIKE ? LIKE ?(大小写由 collation 决定;必要时 LOWER(col) LIKE LOWER(?)) LIKE ?(ASCII 内不区分大小写;非 ASCII 需 COLLATE NOCASE)
按主键分组后可直接选其它列(函数依赖) 本篇 §2.3 SELECT books.*, COUNT(comments.id) ... GROUP BY books.id 默认 only_full_group_by 下报错:需把 books.* 展开成完整列清单并全部 GROUP BY 同 MySQL(无函数依赖,需全列分组)
软删除 + 唯一字段的部分唯一索引 入门篇第八章 / 多对多篇 §1.1 CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL 无直接等价:用生成列((deleted_at IS NULL) 的布尔列)做部分唯一,或应用层保证 同 MySQL
前导通配搜索(ILIKE '%q%')走索引 本篇 §2.2 默认全表扫;生产可加 pg_trgm + CREATE INDEX ... USING gin (title gin_trgm_ops) LIKE '%q%' 同样扫全表,可用全文索引或外部引擎 无 trigram,前缀 LIKE 'q%' 可走索引

另外 % / _ 在 ILIKE 与 LIKE 里都是通配符(参数化只防 SQL 注入、不防通配符语义),三库一致。教程主线按 PostgreSQL 跑通;想换 MySQL / SQLite,把上面几行替换掉即可,其余代码不用动。

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