BodySense API 设计规范

BodySense 项目的 API 设计规范:RESTful 风格、版本管理、错误码体系、分页、排序、过滤。

#type / concept #status / growing #tech / dev / backend

[!info] related notes

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

参数定义

参数类型默认值说明
pageint1页码,从 1 开始
per_pageint20每页数量,最大 100
sortstringcreated_at排序字段
orderstringdesc排序方向

实现

// 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(&params); 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:

  1. RESTful 风格:资源用名词,操作用 HTTP 方法
  2. 版本管理:URL 路径版本 /api/v1/
  3. 统一响应格式:成功 {data: ...},失败 {error: {code, message}}
  4. 分页排序?page=1&per_page=20&sort=created_at&order=desc

Q: 为什么用 PATCH 而不是 PUT?

A:

  • PATCH:部分更新,只发送需要修改的字段
  • PUT:全量更新,需要发送完整资源
  • 对于大部分场景,PATCH 更灵活

Q: 怎么处理 API 版本兼容?

A:

  1. URL 版本/api/v1//api/v2/
  2. 渐进迁移:新版本开发中,旧版本继续维护
  3. 废弃通知:响应头 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()
    }
}
创建于 2026/6/25 更新于 2026/6/25