用户模型设计:从Schema到安全存储

好,咱们今天来聊聊用户模型设计。这是整个认证系统的地基,地基打不好,后面全是坑。我个人习惯在项目一开始就把用户模型想清楚,尤其是密码存储和角色设计这两块。

一、Mongoose vs Sequelize:我该怎么选?

先说说ORM的选择。如果你用MongoDB,那Mongoose是标配。如果你用关系型数据库,Sequelize更合适。我两个都用过,说实话,没有绝对的好坏。

我个人更倾向Mongoose,因为它的Schema设计非常灵活,尤其是嵌套文档和数组字段,写起来很舒服。但如果你团队对SQL更熟悉,或者业务需要复杂查询,Sequelize会更稳。

来看一个典型的Mongoose用户Schema:

const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
  username: {
    type: String,
    required: [true, '用户名不能为空'],
    unique: true,
    trim: true,
    minlength: [3, '用户名至少3个字符'],
    maxlength: [20, '用户名最多20个字符']
  },
  email: {
    type: String,
    required: [true, '邮箱不能为空'],
    unique: true,
    lowercase: true,
    match: [/^\S+@\S+\.\S+$/, '请提供有效的邮箱']
  },
  password: {
    type: String,
    required: [true, '密码不能为空'],
    minlength: [6, '密码至少6个字符'],
    select: false  // 查询时默认不返回密码
  },
  role: {
    type: String,
    enum: ['user', 'admin', 'superadmin'],
    default: 'user'
  },
  isActive: {
    type: Boolean,
    default: true
  },
  lastLogin: {
    type: Date,
    default: null
  }
}, {
  timestamps: true  // 自动添加 createdAt 和 updatedAt
});

嗯,这里有个细节要注意:select: false。我刚开始做项目时没加这个,结果有一次接口返回了用户密码哈希值,虽然哈希值不能直接登录,但暴露出去总归不好。从那以后,我所有密码字段都默认隐藏。

二、密码加密存储:bcrypt的正确姿势

密码绝对不能明文存,这是底线。bcrypt是目前最主流的方案,它自带盐值,还能控制计算成本。

为什么不用MD5或SHA256?说白了,那些是哈希函数,不是密码哈希函数。它们太快了,攻击者可以每秒算几亿次。bcrypt故意设计得很慢,一次计算几十毫秒,攻击者想暴力破解?成本高到他们放弃。

我在项目中遇到过一个问题:有同事把bcrypt的saltRounds设成了4,结果密码哈希一秒就算出来了。这跟没加密差不多。我建议至少用10,生产环境用12。

来看完整的密码处理逻辑:

// 保存前自动加密密码
userSchema.pre('save', async function(next) {
  // 只有密码被修改时才重新加密
  if (!this.isModified('password')) return next();
  
  try {
    const salt = await bcrypt.genSalt(12);
    this.password = await bcrypt.hash(this.password, salt);
    next();
  } catch (err) {
    next(err);
  }
});

// 实例方法:验证密码
userSchema.methods.comparePassword = async function(candidatePassword) {
  return await bcrypt.compare(candidatePassword, this.password);
};

这里有个坑:pre('save')钩子只在调用save()时触发。如果你用findByIdAndUpdate直接更新密码,钩子不会执行。我曾经因为这个踩过坑,用户改了密码,结果存进去的还是明文。

避坑指南:更新密码时一定要用 save() 方法,或者手动调用 bcrypt 加密。不要依赖中间件覆盖所有场景。

三、用户角色字段设计

角色设计看似简单,但扩展性很重要。我见过很多项目一开始只分「普通用户」和「管理员」,后来业务复杂了,又加「运营」、「编辑」、「超级管理员」,结果代码里全是if-else判断,改起来想哭。

我的建议是:用字符串枚举,但预留扩展空间。比如:

role: {
  type: String,
  enum: ['user', 'admin', 'superadmin'],
  default: 'user'
}

如果你需要更细粒度的权限控制,可以考虑用位掩码或权限数组。但大多数项目,字符串枚举就够了。你想想看,一个电商系统,用户、商家、管理员,三个角色基本能覆盖90%的场景。

来看一个更完整的Sequelize版本:

const { DataTypes } = require('sequelize');

const User = sequelize.define('User', {
  username: {
    type: DataTypes.STRING(20),
    allowNull: false,
    unique: true,
    validate: {
      len: [3, 20]
    }
  },
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: true,
    validate: {
      isEmail: true
    }
  },
  password: {
    type: DataTypes.STRING,
    allowNull: false
  },
  role: {
    type: DataTypes.ENUM('user', 'admin', 'superadmin'),
    defaultValue: 'user'
  },
  isActive: {
    type: DataTypes.BOOLEAN,
    defaultValue: true
  }
}, {
  hooks: {
    beforeCreate: async (user) => {
      const salt = await bcrypt.genSalt(12);
      user.password = await bcrypt.hash(user.password, salt);
    },
    beforeUpdate: async (user) => {
      if (user.changed('password')) {
        const salt = await bcrypt.genSalt(12);
        user.password = await bcrypt.hash(user.password, salt);
      }
    }
  }
});
小技巧:在Sequelize中,beforeUpdate钩子要配合 user.changed('password') 判断,避免每次更新都重新加密密码,浪费性能。

四、字段设计的最佳实践

总结一下我这些年积累的经验:

  • 密码字段一定要加select: false,默认不返回,需要时手动调用select('+password')
  • 角色字段用枚举,不要用数字。数字可读性差,0代表什么?1代表什么?过两周你自己都忘了
  • 加一个isActive字段,软删除比硬删除安全得多。用户说「我要注销账号」,你直接删了?万一他后悔了呢
  • timestamps: true 一定要开,createdAt和updatedAt在排查问题时太有用了
  • 邮箱统一小写存储,避免大小写导致的重复注册问题

核心要点:

  • 密码用bcrypt,saltRounds >= 12
  • 使用pre('save')钩子自动加密,避免手动调用
  • 角色用字符串枚举,预留扩展空间
  • 默认隐藏密码字段,按需暴露

好了,用户模型设计这块就聊到这儿。下一章咱们会基于这个模型,实现注册和登录接口。到时候你会看到,今天打的地基有多重要。