成为全栈·Go 后端篇·文章查询与展示投影:安全分页、排序和软删除
列表接口不只是拼几个 WHERE 条件。它还要隔离未发布内容、稳定排序、限制重查询成本,并确保摘要查询不会顺手加载整篇正文。

本文代码快照:提交 ceac4e0。查询行为以契约和
internal/article/query.go、internal/article/view.go为准。
前言:一条列表 SQL 对应多条产品规则
文章列表的查询条件,我最初散在各个 handler 里。写到第三个接口时发现自己在重复同一段"只看 published"的判断,而且已经漏掉了一个软删除分支。
内容站文章列表会支持分页、关键词、分类、标签、作者、状态和排序。后台需要看到不同状态,公开页面只能看到 published;文章软删除后不能再出现在任何公开列表;同一时间戳的文章还要有稳定次序。关键词统计若无上限,可能让一个搜索请求对整张内容表做昂贵扫描。
M6 将常见条件组合放在 article.Page,固定排序字段通过白名单映射到 SQL,追加 ID 作为次级排序;所有文章查询使用显式 ActiveArticles scope 过滤软删除数据。列表只选择 SummaryColumns,不包含 Markdown 全文。这样,安全性、可见性和传输成本都在同一查询路径上可读。
本文关注列表、详情和展示投影之间的边界,也说明为什么 ORM 链式查询仍需要熟悉 SQL。
一、分页参数要规范化,空结果也要稳定
values.Paging 解析 page 与 pageSize:页码至少为 1,pageSize 使用默认值并限制最大值,offset 由规范化页码计算。响应包含 list 与 pagination,空列表的 totalPages 保持至少为 1,以匹配冻结契约的约定。
这套规范集中在 internal/values/values.go 的一个函数里,默认 20、上限 100,页码至少为 1:
go
// internal/values/values.go(节选):默认 20、上限 100,页码至少为 1。
func PagingWith(q url.Values, defaultSize, maxSize int) Page {
p, _ := strconv.Atoi(q.Get("page"))
s, _ := strconv.Atoi(q.Get("pageSize"))
if p < 1 {
p = 1
}
if s == 0 {
s = defaultSize
}
if s < 1 {
s = 1
}
if s > maxSize {
s = maxSize
}
return Page{Page: p, Size: s}
}
// Offset 计算已规范化页码对应的行偏移。
func (p Page) Offset() int { return (p.Page - 1) * p.Size }
把三个边界收在一处很重要:页码下限、每页大小的默认值与上限、以及由此推导的偏移。因为 Limit 和 Offset 最终直接进入数据库,page 与 pageSize 必须先经过这一层,才能交给 ORM。也就是说,分页数字不是原样转发,而是先被规范化成一组受限值。
分页数值来自 URL,因此不能直接作为无限 Limit 或 Offset。过大的页码也可能导致整数偏移溢出或数据库执行代价过高;相关 helper 要对最大范围作防护。不同接口有自己的默认值和上限时使用 PagingWith,不要为了复用把契约差异抹掉。
空结果的 JSON 形状也有意义。返回 [] 和 null 对客户端不是同一种状态。Go 要初始化空切片,以免 nil slice 被序列化为 null。Page.Result 在组装分页元数据时把 totalPages 抬到至少 1:
go
// internal/values/values.go(节选):空集合的总页数仍为 1。
func (p Page) Result(list any, total int64) map[string]any {
p.Total = total
p.Pages = (total + int64(p.Size) - 1) / int64(p.Size)
if p.Pages < 1 {
p.Pages = 1
}
return map[string]any{"list": list, "pagination": p}
}
Result 是列表与分页元数据的唯一出口。分页接口只要走它,就不会出现 totalPages 为 0 或 list 为 null 的形状漂移;反过来,绕过它自己拼 map,就很可能忘记空集合这一格。M6 在分类、评论和文章列表 mapper 中保持同样的空数组语义。
二、排序字段必须由服务端白名单控制
请求可以选择 publishedAt、createdAt、viewCount 等支持的排序键,并用前缀表达方向。Sort 会将键映射到固定 SQL 表达式,未知项回退到默认排序,不让用户输入列名直接进入 ORDER BY。所有排序最后追加 article ID,给时间或计数相同的记录建立稳定次序。
白名单是一张包内只读的映射表,Sort 只在这张表里查值,查不到就回退:
go
// internal/article/query.go(节选):包内只读排序白名单。
var sortColumns = map[string]string{
"publishedAt": "COALESCE(articles.published_at,articles.created_at)",
"createdAt": "articles.created_at",
"viewCount": "articles.view_count",
}
// Sort 将允许的排序字段映射为固定 SQL,并追加 ID 保证顺序稳定。
func Sort(raw string) string {
desc := strings.HasPrefix(raw, "-")
field := strings.TrimPrefix(raw, "-")
col, ok := sortColumns[field]
if !ok {
col = sortColumns["publishedAt"]
desc = true
}
dir := "ASC"
if desc {
dir = "DESC"
}
return col + " " + dir + ", articles.id DESC"
}
请求输入与最终 SQL 的对应关系可以列成一张表:
请求 sort |
命中 SQL 表达式 | 排序方向 |
|---|---|---|
publishedAt |
COALESCE(articles.published_at,articles.created_at) |
ASC |
-publishedAt |
同上 | DESC |
viewCount |
articles.view_count |
ASC |
| 未知字段 | 回退 publishedAt |
DESC |
| 任意合法值 | 统一追加 , articles.id DESC |
--- |
稳定次序不是视觉细节。偏移分页依赖同一集合有确定排序;若边界行排序相同且数据库自由排列,下一页可能重复或漏掉文章。即使有 ID 次级排序,列表期间发生新文章插入仍可能改变 offset 位置;若产品需要强一致快照,应考虑游标分页或快照版本,这超出当前契约。
SQL 参数化保护的是值,不能把任意字段名变成安全表达式。排序方向、表名和 SQL fragment 都属于结构,不应直接拼客户端输入。这里 map 中的表达式固定在源代码,用户只能选择允许项------即使请求传来一个形如 articles.id; DROP TABLE 的字符串,它也只是在 map 里查不到,于是回退默认值,而不是进入 SQL。

三、可见性和软删除必须进入每条查询链
ActiveArticles 是显式 scope,过滤 deleted_at。公开页面还要要求 status=published;未发布文章对匿名统一视为不存在,返回 404,而不是泄漏"存在一篇草稿"。文章作者或管理员对未发布文章的预览属于另外的授权路径。
软删除谓词只有一句,但它必须出现在每一条文章查询上:
go
// internal/platform/database/scopes.go:软删除的唯一谓词。
// 固定 articles 表名避免 JOIN 时列名歧义。
func ActiveArticles(db *gorm.DB) *gorm.DB {
return db.Where("articles.deleted_at IS NULL")
}
公开详情则在此基础上再叠一层状态判断:未发布与不存在对匿名返回同一个错误,避免用状态码区分出"这里其实有一篇稿子":
go
// internal/article/query.go(节选):未发布状态与不存在统一返回 404。
func Published(db *gorm.DB, id int64) (model.Article, error) {
a, err := Get(db, strconv.FormatInt(id, 10))
if err != nil {
return a, err
}
if a.Status != "published" {
return a, fault.New(fault.NotFound)
}
return a, nil
}
不要只在一个首页 handler 加 status 条件。搜索、相关内容、相邻文章、标签列表、作者文章和计数聚合都必须使用相同的可见性规则。遗漏一个聚合接口,可能暴露私稿标题、作者或数量。scope 能降低重复,但每个查询仍要审查 join、子查询与统计路径是否保留条件。
软删除对查询的隐藏需要明确应用。GORM model 使用自定义删除时间而非 gorm.Model 自动规则,因此数据库 scope 是可见性边界。写查询时若使用 raw table 或手写 JOIN,要确认 scope 真的应用于目标 articles 表,而不只是主查询别名------ActiveArticles 写死 articles. 前缀正是为此。
四、分类、标签与关键词条件
标签筛选通过 EXISTS 子查询关联 article_tags 与 tags,并以标签 slug 匹配。使用 EXISTS 而不是直接 JOIN 可以避免一篇文章因多条关系在分页结果中重复。分类筛选读取分类目录,从指定 slug 开始扩展到最多四层子分类,再以文章冗余 category_slug 集合过滤。分类树最大层级来自契约,不是假设任意无限递归。
关键词搜索检查标题和摘要,为限制总数统计开销,搜索 count 只对最多 2000 条匹配 ID 做计数:
go
// internal/article/query.go(节选):标签 EXISTS 与关键词 LIKE 的条件构造。
if tag := q.Get("tag"); tag != "" {
filter = filter.Where("EXISTS (SELECT 1 FROM article_tags atg JOIN tags tg ON tg.id=atg.tag_id WHERE atg.article_id=articles.id AND tg.slug = ?)", tag)
}
if kw := q.Get("keyword"); kw != "" {
filter = filter.Where("(LOWER(articles.title) LIKE LOWER(?) OR LOWER(articles.summary) LIKE LOWER(?))", "%"+kw+"%", "%"+kw+"%")
}
搜索的计数单独加护栏,只在一段有界 ID 上做聚合:
go
// internal/article/query.go(节选):关键词 count 限制在 2000 条以内。
countQuery := filter.Session(&gorm.Session{})
if q.Get("keyword") != "" {
bounded := countQuery.Select("articles.id").Limit(2000)
if err := db.Table("(?) AS bounded_articles", bounded).Count(&total).Error; err != nil {
return nil, err
}
} else if err := countQuery.Count(&total).Error; err != nil {
return nil, err
}
这个护栏意味着分页 total 是有界计数策略的结果,接口语义和测试需要与契约保持一致。全文内容搜索是独立 discovery 搜索,不应和普通列表关键词过滤混为一谈。
分类扩展的循环也值得看清楚:它从命中的分类出发,最多再向下四层,把子孙 slug 收进一个集合,最后用 IN 过滤:
go
// internal/article/query.go(节选):分类从命中 slug 向下扩展最多四层。
slugs := []string{}
ids := map[int64]bool{}
for _, c := range all {
if c.Slug == cat {
ids[c.ID] = true
slugs = append(slugs, c.Slug)
}
}
for d := 0; d < 4; d++ {
for _, c := range all {
if c.ParentID != nil && ids[*c.ParentID] && !ids[c.ID] {
ids[c.ID] = true
slugs = append(slugs, c.Slug)
}
}
}
查询链式组合时,要关注 count 与 list 是否应用了完全相同的筛选条件。若 count 漏了 tag 条件,前端会看到错误总数;若 list 漏 status,未发布内容就可能泄漏。对每个组合至少做一组不同条件能区分结果的 fixture。
五、SummaryColumns 是性能与隐私投影
Article 持久化模型包含 content 正文,而摘要卡片通常只需要有限字段。SummaryColumns 明确选择列表展示所需列,减少数据库读入、Go 内存持有和 JSON 组装负担。大正文会影响数据库 I/O、连接占用和垃圾回收;把文章列表也查全量内容不仅浪费资源,还可能无意把内部内容带入 response mapper。
列表列白名单写在 internal/article/view.go 的常量里,按行排版以便和 DTO 字段逐项对照:
go
// internal/article/view.go:SummaryColumns 集中保留列表列白名单。
const SummaryColumns = `
articles.id,
articles.title,
articles.slug,
articles.summary,
articles.cover_image,
articles.author_id,
articles.author_name,
articles.category_id,
articles.category_name,
articles.tags,
articles.status,
articles.view_count,
articles.like_count,
articles.published_at,
articles.created_at,
articles.updated_at`
列表 DTO 与详情 DTO 也可表达不同可见范围。Summary 映射作者昵称、分类展示字段和统计计数;Detail 在需要时才包含正文及其它字段:
go
// internal/article/view.go(节选):Detail 只在 Summary 之上加正文。
func Detail(a model.Article) map[string]any { r := Summary(a); r["content"] = a.Content; return r }
// List 转换文章行列表,空结果也保持 JSON 数组语义。
func List(rows []model.Article) []map[string]any {
r := []map[string]any{}
for _, a := range rows {
r = append(r, Summary(a))
}
return r
}
两个投影的字段差可以列成一张表:
| 字段组 | Summary(列表) | Detail(详情) |
|---|---|---|
| 标识 id / slug / status | 有 | 有 |
| 展示 title / summary / coverImage | 有 | 有 |
| 冗余 authorName / categoryName / tags | 有 | 有 |
| 计数 viewCount / likeCount | 有 | 有 |
| 时间 publishedAt / createdAt / updatedAt | 有 | 有 |
| 正文 content | 无 | 有 |
显式 mapper 避免 GORM model 自动序列化把新加列默默公开。展示字段如 category_name、author_name 是冗余投影,它们让列表在常见场景少做 join。冗余需要在写入时同步:修改文章分类时更新 category_id/name/slug;改名分类时需要同步被引用文章投影。真实外键关系和冗余文本承担不同任务,不能把一个当作另一个的唯一来源。

六、排序和筛选仍然要面对数据库差异
关键词使用 LOWER 与 LIKE 组合,排序使用 COALESCE 等表达式,数据库 collation 和字符串函数可能有方言差异。PostgreSQL、MySQL、SQLite 实测结果要保持既定语义;仅依赖 ORM 能编译不代表中文大小写、字符比较和索引计划都一致。
分页查询要检查执行计划。关键词 count 有界减少成本,但列表本身仍可能扫描;标签 EXISTS、分类 IN 和 sort 组合可能需要匹配索引。不要先凭感觉添加十个索引,应该从真实数据规模和 explain 结果判断。索引写入成本、迁移风险也要考虑。
查询性能证据需说明 fixture 数据规模、数据库类型、筛选组合、缓存状态和测量范围。单元测试通过只能证明逻辑结果,不能说明真实数据量下的查询延迟。
七、如何测试查询投影
建立能区分边界的文章集:公开已发布、私有草稿、已删除文章、同发布时间文章、多分类后代、带多个标签、长正文、空摘要。分别检查:
- 匿名列表/搜索不出现草稿和软删除记录;
- 作者管理查询可按契约看到自己的待审文章;
- 标签过滤不重复文章;
- 分类查询只包含指定子树范围;
- 未知排序项安全回退且顺序稳定;
- 翻页边界没有由排序同值造成的重复;
- Summary 不含 content 正文;
- keyword count 与限制规则一致;
- 软删除文章不再参与公开计数和相关结果。
再在三种数据库上执行关键筛选,检查数据排序与执行失败。数据库方言测试在 M6-16 展开,差分契约验证在 M6-09 展开。
测试报告里最好保留两组对照:一组是同一条件在 count 与 list 上的结果,用来证明筛选一致;另一组是同排序值翻页的相邻页码,用来证明次序稳定。这样即便以后有人改了筛选分支,也能从测试直接看出是可见性、计数还是分页出了问题,而不是只看到一个总数对不上。
小结:查询质量是可见性、投影和成本的组合
M6 使用规范化分页、固定排序白名单、稳定 ID 次序、显式 ActiveArticles 过滤和 SummaryColumns 投影。分类、标签与关键词条件按契约范围组合;关键词计数有 2000 条护栏。查询层不让客户端决定 SQL 结构,也不把完整文章正文默认带入列表。
阅读 ORM query chain 时,仍应追问最终 SQL:筛选是否完整,软删除是否覆盖 join,分页是否稳定,投影有没有全文,count 是否一致,真实数据库计划能否承受。清楚的查询不是链式 API 写得短,而是行为可推理。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
