用户模型设计:从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);
}
}
}
});
beforeUpdate钩子要配合 user.changed('password') 判断,避免每次更新都重新加密密码,浪费性能。
四、字段设计的最佳实践
总结一下我这些年积累的经验:
- 密码字段一定要加select: false,默认不返回,需要时手动调用select('+password')
- 角色字段用枚举,不要用数字。数字可读性差,0代表什么?1代表什么?过两周你自己都忘了
- 加一个isActive字段,软删除比硬删除安全得多。用户说「我要注销账号」,你直接删了?万一他后悔了呢
- timestamps: true 一定要开,createdAt和updatedAt在排查问题时太有用了
- 邮箱统一小写存储,避免大小写导致的重复注册问题
核心要点:
- 密码用bcrypt,saltRounds >= 12
- 使用pre('save')钩子自动加密,避免手动调用
- 角色用字符串枚举,预留扩展空间
- 默认隐藏密码字段,按需暴露
好了,用户模型设计这块就聊到这儿。下一章咱们会基于这个模型,实现注册和登录接口。到时候你会看到,今天打的地基有多重要。