Skip to content

DTO 分离模式

概述

DTO (Data Transfer Object) 分离模式是一种将外部请求数据结构与内部业务实体分离的设计模式。适用于 Web API 开发,能有效防止参数注入攻击和数据泄漏。

核心原则

原则 1:绑定与业务结构体分离

禁止将业务实体直接暴露给外部请求绑定。

  • 使用专门的 DTO 结构体接收 HTTP 请求数据
  • ✅ 在 Handler 中手动转换 DTO 到 Service 请求结构体
  • 禁止在业务模型上直接添加绑定标签(如 jsonform

原则 2:敏感字段保护

敏感字段不应添加绑定标签,防止恶意参数注入。

敏感字段包括:

  • 权限相关:IsAdminRolePermissions
  • 状态控制:StatusIsActiveIsDeleted
  • 系统字段:CreatedAtUpdatedAtCreatedBy
  • 业务敏感:BalanceCreditInternalNotes

原则 3:显式字段映射

在 Handler 中显式转换字段,确保数据可控。

  • 明确指定每个字段的映射关系
  • 禁止使用反射自动映射整个结构体

Echo 参数绑定机制

绑定机制说明

Echo 的绑定机制自动从多个来源提取数据并映射到结构体

数据来源结构标签示例
URL 路径参数param:"id"/users/:id
查询参数query:"page"/users?page=1
请求头header:"Authorization"Authorization: Bearer xxx
JSON 请求体json:"name"{"name": "张三"}
表单数据form:"email"email=user@example.com

绑定顺序

绑定按以下顺序执行,后面的数据会覆盖前面的:

1. URL 路径参数 (param)

2. 查询参数 (query) - 仅 GET/DELETE 请求

3. 请求体 (json/form/xml)

注意:后阶段的数据会覆盖前阶段的同名字段。

安全风险

风险 1:参数注入攻击

场景:业务模型直接用于绑定时,攻击者可注入敏感字段。

错误示例

go
// models/user.go
type User struct {
    ID       uint   `gorm:"primaryKey" json:"id"`
    Name     string `json:"name"`         // ❌ 暴露给外部
    Email    string `json:"email"`        // ❌ 暴露给外部
    Password string `json:"password"`     // ❌ 暴露给外部
    IsAdmin  bool   `json:"is_admin"`     // ❌ 敏感字段暴露
}

// handlers/user.go
func (h *UserHandler) Create(c echo.Context) error {
    user := new(models.User)
    c.Bind(user)  // ❌ 直接绑定业务模型

    // 攻击者可提交: {"name":"张三","is_admin":true}
    // 导致普通用户注册为管理员
    h.db.Create(user)
    return c.JSON(200, user)
}

攻击请求

json
POST /users
{
  "name": "张三",
  "email": "user@example.com",
  "password": "123456",
  "is_admin": true  // ⚠️ 恶意注入管理员权限
}

风险 2:数据泄漏

场景:业务模型直接返回时,可能泄漏敏感信息。

错误示例

go
func (h *UserHandler) Get(c echo.Context) error {
    var user models.User
    h.db.First(&user, c.Param("id"))

    // ❌ 直接返回业务模型,泄漏密码哈希
    return c.JSON(200, user)
}

泄漏响应

json
{
  "id": 1,
  "name": "张三",
  "email": "user@example.com",
  "password": "$2a$10$...",  // ⚠️ 泄漏密码哈希
  "is_admin": true            // ⚠️ 泄漏权限信息
}

安全实现方案

方案:三层结构体分离

定义三种不同用途的结构体

  1. Handler 绑定结构体(DTO)- 接收 HTTP 请求
  2. Service 请求结构体 - 传递业务参数
  3. 数据库模型(Entity)- GORM 映射

完整实现示例

1. 数据库模型(Entity)

go
// models/user.go
package models

import "gorm.io/gorm"

// User 数据库模型
type User struct {
    ID        uint           `gorm:"primaryKey"`
    Name      string         `gorm:"size:100;not null"`
    Email     string         `gorm:"size:255;uniqueIndex;not null"`
    Password  string         `gorm:"size:255;not null"`
    IsAdmin   bool           `gorm:"default:false"`  // 敏感字段:无绑定标签
    IsActive  bool           `gorm:"default:true"`   // 敏感字段:无绑定标签
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

关键点

  • 不添加 jsonform 等绑定标签
  • 只包含 GORM 相关标签
  • ✅ 敏感字段(IsAdminIsActive无法被外部请求修改

2. Handler 绑定结构体(DTO)

go
// handlers/user.go
package handlers

// CreateUserRequest Handler 绑定结构体 - 只包含前端需要提交的字段
type CreateUserRequest struct {
    Name     string `json:"name" form:"name" validate:"required,min=2,max=50"`
    Email    string `json:"email" form:"email" validate:"required,email"`
    Password string `json:"password" form:"password" validate:"required,min=6"`
}

// UpdateUserRequest 更新请求 DTO
type UpdateUserRequest struct {
    Name  string `json:"name" form:"name" validate:"omitempty,min=2,max=50"`
    Email string `json:"email" form:"email" validate:"omitempty,email"`
}

// UserResponse 响应 DTO - 只返回安全字段
type UserResponse struct {
    ID        uint      `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    IsActive  bool      `json:"is_active"`
    CreatedAt time.Time `json:"created_at"`
}

关键点

  • 只包含外部允许提交的字段
  • 不包含敏感字段(IsAdminPassword
  • 添加验证标签(validate

3. Service 请求结构体

go
// services/user.go
package services

// CreateUserRequest Service 层请求结构体
type CreateUserRequest struct {
    Name     string
    Email    string
    Password string
}

// UpdateUserRequest Service 更新请求
type UpdateUserRequest struct {
    Name  *string  // 使用指针区分"未提供"和"空值"
    Email *string
}

关键点

  • 不包含绑定标签
  • ✅ 只包含业务逻辑需要的字段
  • ✅ 更新请求使用指针区分未提供字段

4. Handler 实现(显式转换)

go
// handlers/user.go
package handlers

import (
    "net/http"

    "your-project/models"
    "your-project/services"
    "github.com/labstack/echo/v4"
    "golang.org/x/crypto/bcrypt"
)

type UserHandler struct {
    userService *services.UserService
}

// Create 用户创建 Handler
func (h *UserHandler) Create(c echo.Context) error {
    // 1. 参数绑定 - 使用 Handler DTO
    req := new(CreateUserRequest)
    if err := c.Bind(req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "参数格式错误",
        })
    }

    // 2. 参数校验
    if err := c.Validate(req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": err.Error(),
        })
    }

    // 3. 显式转换:Handler DTO → Service 请求结构体
    serviceReq := &services.CreateUserRequest{
        Name:     req.Name,
        Email:    req.Email,
        Password: req.Password,
    }

    // 4. 调用 Service
    user, err := h.userService.Create(c.Request().Context(), serviceReq)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{
            "error": "创建用户失败",
        })
    }

    // 5. 显式转换:数据库模型 → 响应 DTO
    response := &UserResponse{
        ID:        user.ID,
        Name:      user.Name,
        Email:     user.Email,
        IsActive:  user.IsActive,
        CreatedAt: user.CreatedAt,
    }

    return c.JSON(http.StatusCreated, response)
}

// Update 用户更新 Handler
func (h *UserHandler) Update(c echo.Context) error {
    id := c.Param("id")

    // 1. 参数绑定
    req := new(UpdateUserRequest)
    if err := c.Bind(req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "参数格式错误",
        })
    }

    // 2. 参数校验
    if err := c.Validate(req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": err.Error(),
        })
    }

    // 3. 显式转换:Handler DTO → Service 请求结构体
    serviceReq := &services.UpdateUserRequest{}
    if req.Name != "" {
        serviceReq.Name = &req.Name
    }
    if req.Email != "" {
        serviceReq.Email = &req.Email
    }

    // 4. 调用 Service
    user, err := h.userService.Update(c.Request().Context(), id, serviceReq)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{
            "error": "更新用户失败",
        })
    }

    // 5. 转换响应
    response := &UserResponse{
        ID:        user.ID,
        Name:      user.Name,
        Email:     user.Email,
        IsActive:  user.IsActive,
        CreatedAt: user.CreatedAt,
    }

    return c.JSON(http.StatusOK, response)
}

5. Service 实现

go
// services/user.go
package services

import (
    "context"
    "errors"
    "fmt"

    "your-project/models"
    "golang.org/x/crypto/bcrypt"
    "gorm.io/gorm"
)

type UserService struct {
    db *gorm.DB
}

func NewUserService(db *gorm.DB) *UserService {
    return &UserService{db: db}
}

// Create 创建用户
func (s *UserService) Create(ctx context.Context, req *CreateUserRequest) (*models.User, error) {
    // 1. 业务校验
    if req.Name == "" || req.Email == "" || req.Password == "" {
        return nil, errors.New("参数不完整")
    }

    // 2. 密码加密
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        return nil, fmt.Errorf("密码加密失败: %w", err)
    }

    // 3. 构造数据库模型 - 显式赋值,确保安全字段由系统控制
    user := &models.User{
        Name:     req.Name,
        Email:    req.Email,
        Password: string(hashedPassword),
        IsAdmin:  false,  // ✅ 敏感字段由系统赋值,不受外部请求影响
        IsActive: true,   // ✅ 系统默认值
    }

    // 4. 数据库操作
    if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
        return nil, fmt.Errorf("创建用户失败: %w", err)
    }

    return user, nil
}

// Update 更新用户
func (s *UserService) Update(ctx context.Context, id string, req *UpdateUserRequest) (*models.User, error) {
    var user models.User
    if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
        return nil, fmt.Errorf("用户不存在: %w", err)
    }

    // 只更新提供的字段
    updates := make(map[string]interface{})
    if req.Name != nil {
        updates["name"] = *req.Name
    }
    if req.Email != nil {
        updates["email"] = *req.Email
    }

    if len(updates) > 0 {
        if err := s.db.WithContext(ctx).Model(&user).Updates(updates).Error; err != nil {
            return nil, fmt.Errorf("更新用户失败: %w", err)
        }
    }

    return &user, nil
}

关键设计要点

1. 三层转换流程

HTTP 请求

Handler DTO (CreateUserRequest)
   ↓ [Handler 显式转换]
Service 请求结构体 (services.CreateUserRequest)
   ↓ [Service 显式转换]
数据库模型 (models.User)
   ↓ [GORM 操作]
数据库
   ↓ [Service 返回]
数据库模型 (models.User)
   ↓ [Handler 显式转换]
响应 DTO (UserResponse)

HTTP 响应

2. 敏感字段保护策略

敏感字段的赋值方式

go
// ✅ 正确:系统内部赋值
user := &models.User{
    Name:     req.Name,        // 来自用户输入
    Email:    req.Email,       // 来自用户输入
    IsAdmin:  false,           // ✅ 系统固定值
    IsActive: true,            // ✅ 系统固定值
    CreatedBy: currentUserID,  // ✅ 从上下文获取
}

// ❌ 错误:从外部请求获取敏感字段
user := &models.User{
    Name:    req.Name,
    Email:   req.Email,
    IsAdmin: req.IsAdmin,  // ❌ 危险!允许外部控制权限
}

3. 更新操作的指针模式

使用指针区分"未提供"和"零值"

go
type UpdateUserRequest struct {
    Name   *string  // nil = 未提供, "" = 清空
    IsVIP  *bool    // nil = 未提供, false = 设为普通用户
    Age    *int     // nil = 未提供, 0 = 设为0岁
}

// Handler 转换
serviceReq := &services.UpdateUserRequest{}
if req.Name != nil {
    serviceReq.Name = req.Name  // 只有提供了才赋值
}

检查清单

实施 DTO 分离模式时,确保以下要点:

  • [ ] 数据库模型(models不包含 jsonform 等绑定标签
  • [ ] Handler 定义独立的请求 DTO 和响应 DTO
  • [ ] 敏感字段(IsAdminRole 等)不暴露在 Handler DTO 中
  • [ ] Handler 显式转换 DTO 到 Service 请求结构体
  • [ ] Service 显式赋值敏感字段,不从请求参数获取
  • [ ] 响应 DTO 只返回安全字段,隐藏敏感信息
  • [ ] 更新操作使用指针区分未提供字段

相关规范