CHAPTER 5 · API DESIGN
05
接口能返回数据,
就算设计好了吗?
一个可用的接口,还要考虑参数、分页、错误和前后端约定。
先看一个能运行的接口
它能返回数据,但问题很多。
func ListAlbums(c *gin.Context) {
var albums []Album
if result.Error != nil {
return
}
}设计一个真正可用的列表接口
请求要有参数,响应要有结构。
Request
GET /api/albums?page=1&pageSize=20
{
"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?
看场景需要分页器时通常要返回;无限滚动或高性能场景可以重新评估。
分页参数校验
前端负责体验,后端负责边界和安全。
参数结构
type ListAlbumsQuery struct {
Page int `form:"page"`
PageSize int `form:"pageSize"`
}归一化规则
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。
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。当页码达到几万页后,深分页会不会变慢,留到性能章节继续处理。
统一响应结构
前端需要稳定的接口约定。
成功响应
type APIResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}失败响应
{
"code": "INVALID_ARGUMENT",
"message": "pageSize 不能超过 100"
}参数错误,例如 pageSize=10000。
资源不存在,例如相册 ID 不存在。
服务内部错误,例如数据库连接失败。
HTTP 状态码表达协议层结果,业务错误码表达业务含义。但有些项目会约定业务错误也返回 HTTP 200,再通过 code 区分成功和失败。选择哪种方式,要按项目已有规范执行,或者提前和前端约定清楚。
错误不能直接暴露
对用户返回稳定信息,对开发者记录完整上下文。
错误做法
c.JSON(500, gin.H{
"error": result.Error.Error(),
})可能泄露表名、字段名、SQL、数据库地址、约束名称和内部实现细节。
推荐做法
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:执行数据库操作
状态码结论
常见场景应该这样返回。
pageSize=10000
INVALID_ARGUMENT:参数不合法。
相册不存在
404NOT_FOUND:资源不存在。
数据库连接失败
500INTERNAL_ERROR:内部依赖故障。
用户未登录
401没有有效身份。
用户访问别人的私有相册
403身份有效,但没有权限。
这一章最终记住
- 接口能返回数据,不代表接口设计合理。
- 所有持续增长的列表都应考虑分页和数量限制。
- 后端必须校验参数,不能依赖前端约束。
- HTTP 状态码和业务错误码承担不同职责,但具体返回规范要和前端约定一致。
- 数据库内部错误不能直接暴露给前端。
- Handler 负责 HTTP,业务和数据访问应该逐步拆分。
- 普通 CRUD 可以使用 GORM,但仍要明确查询字段、排序和分页。
用户登录了,为什么还不能访问这个相册?
下一章进入认证、身份和数据权限。