Skip to content

Sequelize 数据验证与约束 - 保证数据质量的最佳实践

发布时间:2024-03-05
作者:一介布衣
标签:Sequelize, 数据验证, 约束, 数据质量

前言

上一篇文章我们学习了关联关系,今天咱们来学习数据验证和约束。说实话,数据验证是保证应用数据质量的第一道防线,也是最重要的一道防线。

我记得刚开始做项目的时候,总是觉得前端验证就够了,后端验证是多余的。结果线上经常出现脏数据,用户直接调用 API 绕过前端验证,或者前端验证被篡改,导致各种奇怪的问题。后来才明白,后端验证是必不可少的。

今天我就把 Sequelize 的验证和约束功能详细讲解一下,让大家能够构建健壮的数据层。

验证 vs 约束

在开始之前,我们先理解验证和约束的区别:

  • 验证(Validations):在 JavaScript 层面进行的检查,发生在数据保存到数据库之前
  • 约束(Constraints):在数据库层面进行的检查,由数据库引擎强制执行

两者结合使用,能够提供最全面的数据保护。

内置验证器

1. 基本验证器

javascript
const User = sequelize.define('User', {
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    validate: {
      notNull: {
        msg: '邮箱不能为空'
      },
      notEmpty: {
        msg: '邮箱不能为空字符串'
      },
      isEmail: {
        msg: '请输入有效的邮箱地址'
      }
    }
  },
  
  age: {
    type: DataTypes.INTEGER,
    validate: {
      isInt: {
        msg: '年龄必须是整数'
      },
      min: {
        args: [0],
        msg: '年龄不能小于0'
      },
      max: {
        args: [120],
        msg: '年龄不能大于120'
      }
    }
  },
  
  username: {
    type: DataTypes.STRING,
    validate: {
      len: {
        args: [3, 20],
        msg: '用户名长度必须在3-20个字符之间'
      },
      isAlphanumeric: {
        msg: '用户名只能包含字母和数字'
      },
      notIn: {
        args: [['admin', 'root', 'system']],
        msg: '用户名不能是保留字'
      }
    }
  },
  
  website: {
    type: DataTypes.STRING,
    allowNull: true,
    validate: {
      isUrl: {
        msg: '请输入有效的网址'
      }
    }
  },
  
  phone: {
    type: DataTypes.STRING,
    validate: {
      is: {
        args: /^1[3-9]\d{9}$/,
        msg: '请输入有效的手机号码'
      }
    }
  }
});

2. 数值验证器

javascript
const Product = sequelize.define('Product', {
  price: {
    type: DataTypes.DECIMAL(10, 2),
    validate: {
      isDecimal: {
        msg: '价格必须是有效的小数'
      },
      min: {
        args: [0],
        msg: '价格不能为负数'
      }
    }
  },
  
  stock: {
    type: DataTypes.INTEGER,
    validate: {
      isInt: true,
      min: 0
    }
  },
  
  rating: {
    type: DataTypes.FLOAT,
    validate: {
      isFloat: {
        min: 0,
        max: 5,
        msg: '评分必须在0-5之间'
      }
    }
  }
});

3. 字符串验证器

javascript
const Article = sequelize.define('Article', {
  title: {
    type: DataTypes.STRING,
    validate: {
      len: {
        args: [5, 100],
        msg: '标题长度必须在5-100个字符之间'
      },
      notEmpty: true
    }
  },
  
  slug: {
    type: DataTypes.STRING,
    validate: {
      is: {
        args: /^[a-z0-9-]+$/,
        msg: 'URL别名只能包含小写字母、数字和连字符'
      }
    }
  },
  
  content: {
    type: DataTypes.TEXT,
    validate: {
      len: {
        args: [10, 10000],
        msg: '内容长度必须在10-10000个字符之间'
      }
    }
  },
  
  status: {
    type: DataTypes.ENUM('draft', 'published', 'archived'),
    validate: {
      isIn: {
        args: [['draft', 'published', 'archived']],
        msg: '状态值无效'
      }
    }
  }
});

4. 日期验证器

javascript
const Event = sequelize.define('Event', {
  startDate: {
    type: DataTypes.DATE,
    validate: {
      isDate: {
        msg: '开始日期格式无效'
      },
      isAfter: {
        args: new Date().toISOString(),
        msg: '开始日期不能早于今天'
      }
    }
  },
  
  endDate: {
    type: DataTypes.DATE,
    validate: {
      isDate: true,
      isAfterStartDate(value) {
        if (value <= this.startDate) {
          throw new Error('结束日期必须晚于开始日期');
        }
      }
    }
  },
  
  birthDate: {
    type: DataTypes.DATEONLY,
    validate: {
      isBefore: {
        args: new Date().toISOString().split('T')[0],
        msg: '出生日期不能是未来日期'
      }
    }
  }
});

自定义验证器

1. 简单自定义验证

javascript
const User = sequelize.define('User', {
  password: {
    type: DataTypes.STRING,
    validate: {
      // 密码强度验证
      isStrongPassword(value) {
        const strongRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#\$%\^&\*])/;
        if (!strongRegex.test(value)) {
          throw new Error('密码必须包含大小写字母、数字和特殊字符');
        }
        if (value.length < 8) {
          throw new Error('密码长度至少8位');
        }
      },
      
      // 不能包含用户名
      notContainUsername(value) {
        if (this.username && value.toLowerCase().includes(this.username.toLowerCase())) {
          throw new Error('密码不能包含用户名');
        }
      }
    }
  },
  
  idCard: {
    type: DataTypes.STRING,
    validate: {
      // 身份证号验证
      isValidIdCard(value) {
        if (!value) return;
        
        const idCardRegex = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
        if (!idCardRegex.test(value)) {
          throw new Error('身份证号格式不正确');
        }
        
        // 校验位验证
        const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
        const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
        
        let sum = 0;
        for (let i = 0; i < 17; i++) {
          sum += parseInt(value[i]) * weights[i];
        }
        
        const checkCode = checkCodes[sum % 11];
        if (value[17].toUpperCase() !== checkCode) {
          throw new Error('身份证号校验位不正确');
        }
      }
    }
  }
});

2. 异步验证器

javascript
const User = sequelize.define('User', {
  email: {
    type: DataTypes.STRING,
    validate: {
      // 异步验证邮箱唯一性
      async isUniqueEmail(value) {
        const existingUser = await User.findOne({
          where: { 
            email: value,
            id: { [Op.ne]: this.id || 0 }  // 排除自己
          }
        });
        
        if (existingUser) {
          throw new Error('邮箱已被使用');
        }
      },
      
      // 验证邮箱域名
      async isAllowedDomain(value) {
        const allowedDomains = ['company.com', 'partner.com'];
        const domain = value.split('@')[1];
        
        if (!allowedDomains.includes(domain)) {
          throw new Error('只允许公司邮箱注册');
        }
      }
    }
  },
  
  username: {
    type: DataTypes.STRING,
    validate: {
      // 检查用户名是否被禁用
      async isNotBanned(value) {
        const bannedUsernames = await BannedUsername.findAll({
          where: { username: value },
          raw: true
        });
        
        if (bannedUsernames.length > 0) {
          throw new Error('该用户名已被禁用');
        }
      }
    }
  }
});

3. 条件验证器

javascript
const User = sequelize.define('User', {
  userType: {
    type: DataTypes.ENUM('individual', 'company'),
    allowNull: false
  },
  
  companyName: {
    type: DataTypes.STRING,
    validate: {
      // 只有企业用户需要填写公司名称
      requiredForCompany(value) {
        if (this.userType === 'company' && !value) {
          throw new Error('企业用户必须填写公司名称');
        }
      }
    }
  },
  
  taxNumber: {
    type: DataTypes.STRING,
    validate: {
      // 企业用户的税号验证
      validTaxNumber(value) {
        if (this.userType === 'company') {
          if (!value) {
            throw new Error('企业用户必须填写税号');
          }
          
          // 统一社会信用代码验证
          const taxRegex = /^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$/;
          if (!taxRegex.test(value)) {
            throw new Error('税号格式不正确');
          }
        }
      }
    }
  }
});

模型级验证

1. 跨字段验证

javascript
const User = sequelize.define('User', {
  firstName: DataTypes.STRING,
  lastName: DataTypes.STRING,
  fullName: DataTypes.STRING,
  password: DataTypes.STRING,
  confirmPassword: DataTypes.VIRTUAL  // 虚拟字段,不存储到数据库
}, {
  validate: {
    // 姓名一致性验证
    nameConsistency() {
      if (this.fullName && this.firstName && this.lastName) {
        const expectedFullName = `${this.firstName} ${this.lastName}`;
        if (this.fullName !== expectedFullName) {
          throw new Error('全名与姓氏不匹配');
        }
      }
    },
    
    // 密码确认验证
    passwordMatch() {
      if (this.password && this.confirmPassword) {
        if (this.password !== this.confirmPassword) {
          throw new Error('两次输入的密码不一致');
        }
      }
    },
    
    // 至少填写一个联系方式
    hasContact() {
      if (!this.email && !this.phone) {
        throw new Error('至少需要填写邮箱或手机号');
      }
    }
  }
});

2. 业务逻辑验证

javascript
const Order = sequelize.define('Order', {
  userId: DataTypes.INTEGER,
  productId: DataTypes.INTEGER,
  quantity: DataTypes.INTEGER,
  totalAmount: DataTypes.DECIMAL(10, 2),
  status: DataTypes.ENUM('pending', 'paid', 'shipped', 'completed', 'cancelled')
}, {
  validate: {
    // 订单金额验证
    async validOrderAmount() {
      if (this.productId && this.quantity) {
        const product = await Product.findByPk(this.productId);
        if (product) {
          const expectedAmount = product.price * this.quantity;
          if (Math.abs(this.totalAmount - expectedAmount) > 0.01) {
            throw new Error('订单金额计算错误');
          }
        }
      }
    },
    
    // 库存验证
    async checkStock() {
      if (this.productId && this.quantity) {
        const product = await Product.findByPk(this.productId);
        if (product && product.stock < this.quantity) {
          throw new Error('库存不足');
        }
      }
    },
    
    // 状态变更验证
    validStatusTransition() {
      if (!this.isNewRecord && this.changed('status')) {
        const validTransitions = {
          'pending': ['paid', 'cancelled'],
          'paid': ['shipped', 'cancelled'],
          'shipped': ['completed'],
          'completed': [],
          'cancelled': []
        };
        
        const previousStatus = this.previous('status');
        const newStatus = this.status;
        
        if (!validTransitions[previousStatus]?.includes(newStatus)) {
          throw new Error(`不能从 ${previousStatus} 状态变更为 ${newStatus} 状态`);
        }
      }
    }
  }
});

数据库约束

1. 基本约束

javascript
const User = sequelize.define('User', {
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  
  email: {
    type: DataTypes.STRING,
    allowNull: false,        // NOT NULL 约束
    unique: true,            // UNIQUE 约束
    validate: {
      isEmail: true
    }
  },
  
  username: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: {
      name: 'username_unique',
      msg: '用户名已存在'
    }
  },
  
  age: {
    type: DataTypes.INTEGER,
    allowNull: false,
    validate: {
      min: 0,
      max: 120
    }
  }
});

2. 外键约束

javascript
const Post = sequelize.define('Post', {
  title: DataTypes.STRING,
  content: DataTypes.TEXT,
  
  userId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: {
      model: User,
      key: 'id'
    },
    onUpdate: 'CASCADE',     // 更新时级联
    onDelete: 'RESTRICT'     // 删除时限制
  },
  
  categoryId: {
    type: DataTypes.INTEGER,
    references: {
      model: 'categories',
      key: 'id'
    },
    onUpdate: 'CASCADE',
    onDelete: 'SET NULL'     // 删除时设为空
  }
});

3. 检查约束

javascript
// 注意:检查约束不是所有数据库都支持
const Product = sequelize.define('Product', {
  price: {
    type: DataTypes.DECIMAL(10, 2),
    allowNull: false,
    validate: {
      min: 0
    }
  },
  
  discountPrice: {
    type: DataTypes.DECIMAL(10, 2),
    validate: {
      isLessThanPrice(value) {
        if (value && value >= this.price) {
          throw new Error('折扣价必须小于原价');
        }
      }
    }
  }
}, {
  // 表级检查约束(PostgreSQL)
  indexes: [
    {
      fields: ['price', 'discountPrice'],
      where: {
        discountPrice: {
          [Op.lt]: sequelize.col('price')
        }
      }
    }
  ]
});

验证错误处理

1. 捕获验证错误

javascript
async function createUser(userData) {
  try {
    const user = await User.create(userData);
    return { success: true, user };
    
  } catch (error) {
    if (error.name === 'SequelizeValidationError') {
      // 验证错误
      const errors = error.errors.map(err => ({
        field: err.path,
        message: err.message,
        value: err.value
      }));
      
      return { 
        success: false, 
        type: 'validation',
        errors 
      };
      
    } else if (error.name === 'SequelizeUniqueConstraintError') {
      // 唯一约束错误
      const field = error.errors[0].path;
      return {
        success: false,
        type: 'unique',
        message: `${field} 已存在`,
        field
      };
      
    } else if (error.name === 'SequelizeForeignKeyConstraintError') {
      // 外键约束错误
      return {
        success: false,
        type: 'foreign_key',
        message: '关联数据不存在'
      };
      
    } else {
      // 其他错误
      console.error('创建用户失败:', error);
      return {
        success: false,
        type: 'unknown',
        message: '服务器内部错误'
      };
    }
  }
}

2. 自定义错误处理

javascript
class ValidationError extends Error {
  constructor(errors) {
    super('Validation failed');
    this.name = 'ValidationError';
    this.errors = errors;
  }
  
  toJSON() {
    return {
      name: this.name,
      message: this.message,
      errors: this.errors
    };
  }
}

// 统一验证函数
async function validateAndCreate(Model, data) {
  try {
    // 先进行验证,不保存
    const instance = Model.build(data);
    await instance.validate();
    
    // 验证通过,保存到数据库
    return await instance.save();
    
  } catch (error) {
    if (error.name === 'SequelizeValidationError') {
      const formattedErrors = error.errors.reduce((acc, err) => {
        acc[err.path] = err.message;
        return acc;
      }, {});
      
      throw new ValidationError(formattedErrors);
    }
    throw error;
  }
}

性能优化

1. 验证优化

javascript
// 跳过验证(谨慎使用)
const user = await User.create(userData, {
  validate: false  // 跳过验证,提高性能
});

// 只验证特定字段
await user.save({
  fields: ['name', 'email'],  // 只保存和验证这些字段
  validate: true
});

// 批量操作时的验证
await User.bulkCreate(userList, {
  validate: true,           // 启用验证
  individualHooks: true     // 启用单个钩子
});

2. 约束优化

javascript
// 延迟约束检查(PostgreSQL)
const transaction = await sequelize.transaction({
  deferrable: Sequelize.Deferrable.SET_DEFERRED
});

try {
  // 在事务中进行可能违反约束的操作
  await User.create(userData, { transaction });
  await transaction.commit();
} catch (error) {
  await transaction.rollback();
  throw error;
}

实战案例

用户注册验证

javascript
const User = sequelize.define('User', {
  username: {
    type: DataTypes.STRING(50),
    allowNull: false,
    unique: true,
    validate: {
      len: [3, 50],
      isAlphanumeric: true,
      async isAvailable(value) {
        const existing = await User.findOne({ where: { username: value } });
        if (existing) {
          throw new Error('用户名已被使用');
        }
      }
    }
  },
  
  email: {
    type: DataTypes.STRING(100),
    allowNull: false,
    unique: true,
    validate: {
      isEmail: true,
      async isUniqueEmail(value) {
        const existing = await User.findOne({ where: { email: value } });
        if (existing) {
          throw new Error('邮箱已被注册');
        }
      }
    }
  },
  
  password: {
    type: DataTypes.STRING,
    allowNull: false,
    validate: {
      len: [8, 100],
      isStrongPassword(value) {
        const strongRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/;
        if (!strongRegex.test(value)) {
          throw new Error('密码必须包含大小写字母、数字和特殊字符');
        }
      }
    }
  },
  
  phone: {
    type: DataTypes.STRING(20),
    validate: {
      is: /^1[3-9]\d{9}$/,
      async isUniquePhone(value) {
        if (value) {
          const existing = await User.findOne({ where: { phone: value } });
          if (existing) {
            throw new Error('手机号已被注册');
          }
        }
      }
    }
  }
}, {
  validate: {
    // 至少填写邮箱或手机号
    hasContactInfo() {
      if (!this.email && !this.phone) {
        throw new Error('请至少填写邮箱或手机号');
      }
    }
  }
});

// 注册服务
class UserService {
  static async register(userData) {
    try {
      const user = await User.create(userData);
      return { success: true, user: user.toSafeObject() };
      
    } catch (error) {
      return this.handleValidationError(error);
    }
  }
  
  static handleValidationError(error) {
    if (error.name === 'SequelizeValidationError') {
      const errors = {};
      error.errors.forEach(err => {
        errors[err.path] = err.message;
      });
      
      return {
        success: false,
        type: 'validation',
        errors
      };
    }
    
    if (error.name === 'SequelizeUniqueConstraintError') {
      const field = error.errors[0].path;
      return {
        success: false,
        type: 'unique',
        message: `${field} 已存在`
      };
    }
    
    throw error;
  }
}

总结

今天我们深入学习了 Sequelize 的数据验证和约束:

  • ✅ 内置验证器的使用方法
  • ✅ 自定义验证器的编写技巧
  • ✅ 模型级验证和业务逻辑验证
  • ✅ 数据库约束的配置
  • ✅ 验证错误的处理方式
  • ✅ 性能优化和实战案例

掌握了这些知识,你就能够:

  • 构建健壮的数据验证体系
  • 保证应用数据的质量和一致性
  • 处理各种验证错误场景
  • 优化验证性能

下一篇文章,我们将学习 Sequelize 的事务处理,这是保证数据一致性的重要机制。


相关文章推荐:

有问题欢迎留言讨论,我会及时回复大家!