全栈工程课 · 一个相册系统的上线之路
课程平台Chapter 5 · 接口设计

CHAPTER 5 · API DESIGN

05

接口能返回数据,
就算设计好了吗?

一个可用的接口,还要考虑参数、分页、错误和前后端约定。

先看一个能运行的接口

它能返回数据,但问题很多。

bad_handler.go点击高亮查看问题
func ListAlbums(c *gin.Context) {
	var albums []Album


	if result.Error != nil {

		return
	}


}
没有分页默认查询所有字段数据库错误直接暴露返回格式不统一没有排序Handler 直接操作数据库没有超时空结果格式可能不稳定

设计一个真正可用的列表接口

请求要有参数,响应要有结构。

Request

GET /api/albums?page=1&pageSize=20
response.json
{
  "code": "OK",
  "message": "success",
  "data": {
    "items": [
      {
        "id": 1,
        "name": "2026 暑假",
        "visibility": 1,
        "createdAt": "2026-07-17T09:30:00+08:00"
      }
    ],
    "page": 1,
    "pageSize": 20,
    "total": 86
  }
}
设计点结论原因

列表数据放在哪里?

items

让分页元信息和数据列表分开,前端读取更稳定。

没有数据时返回什么?

[]

空列表应该是 items: [],不要返回 null

是否一定返回 total?

看场景

需要分页器时通常要返回;无限滚动或高性能场景可以重新评估。

分页参数校验

前端负责体验,后端负责边界和安全。

参数结构

query.go
type ListAlbumsQuery struct {
	Page     int `form:"page"`
	PageSize int `form:"pageSize"`
}

归一化规则

pagination.go
func normalizePagination(query *ListAlbumsQuery) {
	if query.Page <= 0 {
		query.Page = 1
	}

	if query.PageSize <= 0 {
		query.PageSize = 20
	}

	if query.PageSize > 100 {
		query.PageSize = 100
	}
}

为什么不能完全相信前端参数?

为什么要限制最大 pageSize

前端不展示大数量,用户还能伪造请求吗?

默认值应该由前端还是后端决定?

用 GORM 实现分页

查询列表时,同时返回 items 和 total。

album_repository.go
func (r *AlbumRepository) List(
	ctx context.Context,
	page int,
	pageSize int,
) ([]Album, int64, error) {
	var albums []Album
	var total int64

	query := r.db.WithContext(ctx).Model(&Album{})

	if err := query.Count(&total).Error; err != nil {
		return nil, 0, err
	}

	offset := (page - 1) * pageSize

	err := query.
		Select("id", "user_id", "name", "visibility", "created_at").
		Order("created_at DESC").
		Limit(pageSize).
		Offset(offset).
		Find(&albums).
		Error

	if err != nil {
		return nil, 0, err
	}

	return albums, total, nil
}

这里先使用 LIMIT + OFFSET。当页码达到几万页后,深分页会不会变慢,留到性能章节继续处理。

统一响应结构

前端需要稳定的接口约定。

成功响应

response.go
type APIResponse struct {
	Code    string `json:"code"`
	Message string `json:"message"`
	Data    any    `json:"data,omitempty"`
}

失败响应

error.json
{
  "code": "INVALID_ARGUMENT",
  "message": "pageSize 不能超过 100"
}
HTTP 400INVALID_ARGUMENT

参数错误,例如 pageSize=10000

HTTP 404NOT_FOUND

资源不存在,例如相册 ID 不存在。

HTTP 500INTERNAL_ERROR

服务内部错误,例如数据库连接失败。

HTTP 状态码表达协议层结果,业务错误码表达业务含义。但有些项目会约定业务错误也返回 HTTP 200,再通过 code 区分成功和失败。选择哪种方式,要按项目已有规范执行,或者提前和前端约定清楚。

错误不能直接暴露

对用户返回稳定信息,对开发者记录完整上下文。

错误做法

bad.go
c.JSON(500, gin.H{
	"error": result.Error.Error(),
})

可能泄露表名、字段名、SQL、数据库地址、约束名称和内部实现细节。

推荐做法

good.go
log.Printf("query albums failed: %v", err)

c.JSON(http.StatusInternalServerError, APIResponse{
	Code:    "INTERNAL_ERROR",
	Message: "服务暂时不可用",
})

这里用 HTTP 500 表达服务内部错误,是一种常见设计。也可以按业务规范返回 HTTP 200,并在响应体中使用 INTERNAL_ERROR。关键不是固定使用哪一种,而是前后端必须遵守同一套约定。

Handler 不要越来越胖

Handler 负责 HTTP,业务和数据访问逐步拆分。

职责堆在一起

Handler
├── 参数校验
├── 数据库查询
├── 权限判断
├── 业务规则
├── 错误转换
└── 返回 JSON

推荐分层

Handler

Service

Repository

GORM

  • Handler:处理 HTTP
  • Service:处理业务逻辑
  • Repository:处理数据访问
  • GORM:执行数据库操作

状态码结论

常见场景应该这样返回。

场景HTTP业务含义

pageSize=10000

400

INVALID_ARGUMENT:参数不合法。

相册不存在

404

NOT_FOUND:资源不存在。

数据库连接失败

500

INTERNAL_ERROR:内部依赖故障。

用户未登录

401

没有有效身份。

用户访问别人的私有相册

403

身份有效,但没有权限。

这一章最终记住

  1. 接口能返回数据,不代表接口设计合理。
  2. 所有持续增长的列表都应考虑分页和数量限制。
  3. 后端必须校验参数,不能依赖前端约束。
  4. HTTP 状态码和业务错误码承担不同职责,但具体返回规范要和前端约定一致。
  5. 数据库内部错误不能直接暴露给前端。
  6. Handler 负责 HTTP,业务和数据访问应该逐步拆分。
  7. 普通 CRUD 可以使用 GORM,但仍要明确查询字段、排序和分页。
用户登录了,为什么还不能访问这个相册?
下一章进入认证、身份和数据权限。