BodySense API 设计规范
BodySense 项目的 API 设计规范:RESTful 风格、版本管理、错误码体系、分页、排序、过滤。
#type / concept
#status / growing
#tech / dev / backend
[!info] related notes
- 知识地图: BodySense 项目 MOC
- 框架: Gin HTTP 框架
- 错误处理: 错误处理策略
BodySense API 设计规范
1. URL 设计
基础格式
https://{domain}/api/v{version}/{resource}
资源命名
| 操作 | 方法 | URL | 说明 |
|---|---|---|---|
| 创建会话 | POST | /api/v1/consultation/sessions | 复数名词 |
| 获取会话 | GET | /api/v1/consultation/sessions/:id | 路径参数 |
| 获取会话列表 | GET | /api/v1/consultation/sessions | 查询参数 |
| 更新会话 | PATCH | /api/v1/consultation/sessions/:id | 部分更新 |
| 删除会话 | DELETE | /api/v1/consultation/sessions/:id | 软删除 |
| 发送消息 | POST | /api/v1/consultation/sessions/:id/messages | 子资源 |
| 获取消息 | GET | /api/v1/consultation/sessions/:id/messages | 子资源列表 |
命名规则
// ✅ 正确
"/api/v1/consultation/sessions"
"/api/v1/body-profiles"
"/api/v1/training-plans"
// ❌ 错误
"/api/v1/consultationSession" // 驼峰
"/api/v1/consultation_session" // 下划线
"/api/v1/ConsultationSessions" // 大写
路由分组
// router/router.go
func SetupRouter(r *gin.Engine) {
api := r.Group("/api/v1")
// 公开路由
auth := api.Group("/auth")
{
auth.POST("/register", handler.Register)
auth.POST("/login", handler.Login)
auth.POST("/refresh", handler.RefreshToken)
auth.POST("/forgot-password", handler.RequestPasswordReset)
auth.POST("/reset-password", handler.ResetPassword)
}
// 需要认证的路由
protected := api.Group("")
protected.Use(middleware.AuthMiddleware(jwtConfig, redis))
{
// 用户
protected.GET("/users/me", handler.GetCurrentUser)
protected.PATCH("/users/me", handler.UpdateCurrentUser)
// 咨询会话
consultation := protected.Group("/consultation")
{
consultation.POST("/sessions", handler.CreateSession)
consultation.GET("/sessions", handler.ListSessions)
consultation.GET("/sessions/:id", handler.GetSession)
consultation.PATCH("/sessions/:id", handler.UpdateSession)
consultation.DELETE("/sessions/:id", handler.DeleteSession)
// 消息
consultation.POST("/sessions/:id/messages", handler.SendMessage)
consultation.GET("/sessions/:id/messages", handler.ListMessages)
consultation.POST("/sessions/:id/messages/stream", handler.StreamMessage)
}
// 身体档案
protected.POST("/body-profiles", handler.CreateBodyProfile)
protected.GET("/body-profiles/:id", handler.GetBodyProfile)
protected.PATCH("/body-profiles/:id", handler.UpdateBodyProfile)
// 训练计划
protected.POST("/training-plans", handler.CreateTrainingPlan)
protected.GET("/training-plans", handler.ListTrainingPlans)
protected.GET("/training-plans/:id", handler.GetTrainingPlan)
// 打卡
protected.POST("/check-ins", handler.CreateCheckIn)
protected.GET("/check-ins", handler.ListCheckIns)
}
}
2. 版本管理
为什么需要版本
- 不破坏现有客户端
- 渐进式迁移
- A/B 测试新 API
版本策略
/api/v1/... // 当前稳定版本
/api/v2/... // 新版本(开发中)
实现方式
// router/versioning.go
func SetupRouter(r *gin.Engine) {
// v1
v1 := r.Group("/api/v1")
setupV1Routes(v1)
// v2(开发中)
v2 := r.Group("/api/v2")
setupV2Routes(v2)
}
版本兼容
// v1: 返回旧格式
func (h *Handler) GetSessionV1(c *gin.Context) {
session, _ := h.service.GetSession(c, id)
c.JSON(200, gin.H{
"id": session.ID,
"status": session.Status,
"phase": session.Phase,
})
}
// v2: 返回新格式
func (h *Handler) GetSessionV2(c *gin.Context) {
session, _ := h.service.GetSession(c, id)
c.JSON(200, gin.H{
"data": gin.H{
"id": session.ID,
"status": session.Status,
"phase": session.Phase,
"metadata": session.Metadata, // 新增字段
},
"links": gin.H{
"self": fmt.Sprintf("/api/v2/sessions/%s", session.ID),
"messages": fmt.Sprintf("/api/v2/sessions/%s/messages", session.ID),
},
})
}
3. 请求/响应格式
请求格式
// POST /api/v1/consultation/sessions
{
"body_profile_id": "uuid-here",
"initial_message": "我肩膀疼"
}
响应格式
单个资源
{
"data": {
"id": "uuid-here",
"status": "in_progress",
"phase": "collecting",
"created_at": "2026-06-25T10:00:00Z"
}
}
列表
{
"data": [
{ "id": "uuid-1", "status": "in_progress" },
{ "id": "uuid-2", "status": "completed" }
],
"pagination": {
"page": 1,
"per_page": 20,
"total": 45,
"total_pages": 3
}
}
错误
{
"error": {
"code": "AUTH_001",
"message": "invalid email or password"
}
}
响应头
// 设置响应头
c.Header("Content-Type", "application/json; charset=utf-8")
c.Header("X-Request-Id", requestID)
c.Header("X-Rate-Limit-Remaining", "99")
4. 分页
查询参数
GET /api/v1/consultation/sessions?page=1&per_page=20
参数定义
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| page | int | 1 | 页码,从 1 开始 |
| per_page | int | 20 | 每页数量,最大 100 |
| sort | string | created_at | 排序字段 |
| order | string | desc | 排序方向 |
实现
// handler/pagination.go
type PaginationParams struct {
Page int `form:"page,default=1"`
PerPage int `form:"per_page,default=20"`
Sort string `form:"sort,default=created_at"`
Order string `form:"order,default=desc"`
}
func (p *PaginationParams) Validate() error {
if p.Page < 1 {
p.Page = 1
}
if p.PerPage < 1 || p.PerPage > 100 {
p.PerPage = 20
}
if p.Order != "asc" && p.Order != "desc" {
p.Order = "desc"
}
return nil
}
func (p *PaginationParams) Offset() int {
return (p.Page - 1) * p.PerPage
}
Repository 实现
// repository/session.go
func (r *SessionRepository) List(ctx context.Context, userID uuid.UUID, params PaginationParams) ([]Session, int, error) {
var sessions []Session
var total int64
query := r.db.WithContext(ctx).Where("user_id = ?", userID)
// 总数
query.Model(&Session{}).Count(&total)
// 分页查询
err := query.
Order(fmt.Sprintf("%s %s", params.Sort, params.Order)).
Offset(params.Offset()).
Limit(params.PerPage).
Find(&sessions).Error
return sessions, int(total), err
}
响应格式
// handler/session.go
func (h *Handler) ListSessions(c *gin.Context) {
var params PaginationParams
if err := c.ShouldBindQuery(¶ms); err != nil {
c.JSON(400, gin.H{"error": "invalid pagination params"})
return
}
params.Validate()
userID := c.GetString("user_id")
uid, _ := uuid.Parse(userID)
sessions, total, err := h.sessionRepo.List(c.Request.Context(), uid, params)
if err != nil {
c.JSON(500, gin.H{"error": "internal error"})
return
}
c.JSON(200, gin.H{
"data": sessions,
"pagination": gin.H{
"page": params.Page,
"per_page": params.PerPage,
"total": total,
"total_pages": (total + params.PerPage - 1) / params.PerPage,
},
})
}
5. 过滤
查询参数
GET /api/v1/consultation/sessions?status=in_progress&phase=collecting
实现
// repository/session.go
type SessionFilter struct {
Status string `form:"status"`
Phase string `form:"phase"`
}
func (r *SessionRepository) ListWithFilter(
ctx context.Context,
userID uuid.UUID,
filter SessionFilter,
params PaginationParams,
) ([]Session, int, error) {
query := r.db.WithContext(ctx).Where("user_id = ?", userID)
if filter.Status != "" {
query = query.Where("status = ?", filter.Status)
}
if filter.Phase != "" {
query = query.Where("phase = ?", filter.Phase)
}
// ... 分页查询
}
6. 排序
查询参数
GET /api/v1/consultation/sessions?sort=created_at&order=desc
多字段排序
GET /api/v1/consultation/sessions?sort=status,created_at&order=asc,desc
实现
func (p *PaginationParams) OrderBy() string {
fields := strings.Split(p.Sort, ",")
orders := strings.Split(p.Order, ",")
var clauses []string
for i, field := range fields {
order := "desc"
if i < len(orders) {
order = orders[i]
}
clauses = append(clauses, fmt.Sprintf("%s %s", field, order))
}
return strings.Join(clauses, ", ")
}
7. 字段选择
查询参数
GET /api/v1/consultation/sessions?fields=id,status,phase
实现
func (h *Handler) GetSession(c *gin.Context) {
fields := c.Query("fields")
session, err := h.sessionService.GetSession(c, id)
if err != nil {
handleServiceError(c, err)
return
}
if fields != "" {
// 过滤字段
result := filterFields(session, strings.Split(fields, ","))
c.JSON(200, gin.H{"data": result})
} else {
c.JSON(200, gin.H{"data": session})
}
}
8. 认证与授权
认证头
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
权限检查
// middleware/permission.go
func RequirePermission(permission string) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString("user_id")
hasPermission, err := checkPermission(c.Request.Context(), userID, permission)
if err != nil || !hasPermission {
c.JSON(403, gin.H{"error": "forbidden"})
c.Abort()
return
}
c.Next()
}
}
9. 速率限制
响应头
X-Rate-Limit-Limit: 100
X-Rate-Limit-Remaining: 99
X-Rate-Limit-Reset: 1624000000
实现
// middleware/ratelimit.go
func RateLimit(redis *redis.Client, limit int, window time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
key := fmt.Sprintf("ratelimit:%s:%s", c.ClientIP(), c.FullPath())
current, _ := redis.Incr(c.Request.Context(), key).Result()
if current == 1 {
redis.Expire(c.Request.Context(), key, window)
}
remaining := int64(limit) - current
if remaining < 0 {
remaining = 0
}
c.Header("X-Rate-Limit-Limit", strconv.Itoa(limit))
c.Header("X-Rate-Limit-Remaining", strconv.FormatInt(remaining, 10))
if current > int64(limit) {
c.JSON(429, gin.H{"error": "too many requests"})
c.Abort()
return
}
c.Next()
}
}
10. CORS 配置
// middleware/cors.go
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
11. 请求 ID
// middleware/requestid.go
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-Id")
if requestID == "" {
requestID = uuid.New().String()
}
c.Header("X-Request-Id", requestID)
c.Set("request_id", requestID)
c.Next()
}
}
12. API 文档
Swagger 注释
// @Summary 创建咨询会话
// @Description 创建一个新的咨询会话
// @Tags consultation
// @Accept json
// @Produce json
// @Param request body CreateSessionRequest true "请求参数"
// @Success 201 {object} Session
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Router /api/v1/consultation/sessions [post]
func (h *Handler) CreateSession(c *gin.Context) {
// ...
}
常见面试问题
Q: 你的 API 设计遵循什么规范?
A:
- RESTful 风格:资源用名词,操作用 HTTP 方法
- 版本管理:URL 路径版本
/api/v1/ - 统一响应格式:成功
{data: ...},失败{error: {code, message}} - 分页排序:
?page=1&per_page=20&sort=created_at&order=desc
Q: 为什么用 PATCH 而不是 PUT?
A:
- PATCH:部分更新,只发送需要修改的字段
- PUT:全量更新,需要发送完整资源
- 对于大部分场景,PATCH 更灵活
Q: 怎么处理 API 版本兼容?
A:
- URL 版本:
/api/v1/、/api/v2/ - 渐进迁移:新版本开发中,旧版本继续维护
- 废弃通知:响应头
Deprecation: true
常见错误
混用单复数
// ❌
"/api/v1/session" // 单数
"/api/v1/sessions" // 复数
// ✅ 统一用复数
"/api/v1/sessions"
"/api/v1/sessions/:id"
返回格式不统一
// ❌ 不同接口返回格式不同
c.JSON(200, session) // 直接返回
c.JSON(200, gin.H{"data": session}) // 包装返回
// ✅ 统一格式
c.JSON(200, gin.H{"data": session})
不处理 OPTIONS 请求
// ❌ CORS 中间件不处理 OPTIONS
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Next() // OPTIONS 请求会继续执行
}
}
// ✅ 处理 OPTIONS
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}