
本文详解如何在 mongoose 中通过模型级中间件(pre-save、pre-update、pre-findoneandupdate)监听特定字段(如 status/stage)的变化,并在值满足条件时自动填充时间戳字段,无需修改业务路由代码。
本文详解如何在 mongoose 中通过模型级中间件(pre-save、pre-update、pre-findoneandupdate)监听特定字段(如 status/stage)的变化,并在值满足条件时自动填充时间戳字段,无需修改业务路由代码。
在 MERN 栈开发中,常需实现「当某字段(如 stage 或 status)从 A 变更为 B 时,自动记录变更时间」这类逻辑。理想方案应与业务路由解耦——即无论数据来自 REST API、GraphQL、后台脚本还是管理后台,只要通过 Mongoose 操作文档,该逻辑就应统一生效。这正是 Mongoose 模型级中间件(Schema Middleware) 的核心应用场景。
✅ 正确做法:在 Schema 上注册 pre 中间件(非 Model)
关键误区在于:Model.pre() 不存在(如 ModelMiddleware.pre(...) 会报错),中间件必须注册在 Schema 实例上(即 schema.pre(...)),之后再用该 schema 创建 model。如下是规范、健壮的实现:
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const LeadSchema = new Schema({
status: { type: String, enum: ['stage1', 'stage2', 'stage3', 'closed'] },
stage1Date: { type: Date }, // 首次进入 stage1 时间
stage2Date: { type: Date }, // 首次进入 stage2 时间
stage3Date: { type: Date },
closedDate: { type: Date }
});
// ✅ 1. 监听 save(新增/全量更新)
LeadSchema.pre('save', function(next) {
if (this.isModified('status')) {
const now = new Date();
switch (this.status) {
case 'stage1':
if (!this.stage1Date) this.stage1Date = now;
break;
case 'stage2':
if (!this.stage2Date) this.stage2Date = now;
break;
case 'stage3':
if (!this.stage3Date) this.stage3Date = now;
break;
case 'closed':
if (!this.closedDate) this.closedDate = now;
break;
}
}
next();
});
// ✅ 2. 监听 update(部分更新,如 router.put('/:id', ...req.body))
LeadSchema.pre('updateOne', { document: false, query: true }, function(next) {
if (this.getUpdate()?.$set?.status) {
const newStatus = this.getUpdate().$set.status;
// 注意:此时无法直接访问原文档,需手动查询(见下方 findOneAndUpdate)
// 所以更推荐统一使用 findOneAndUpdate 中间件
}
next();
});
// ✅ 3. 监听 findOneAndUpdate(最常用、最可靠的部分更新场景)
LeadSchema.pre('findOneAndUpdate', async function(next) {
try {
const update = this.getUpdate();
if (!update || !update.$set || update.$set.status === undefined) {
return next();
}
const newStatus = update.$set.status;
const doc = await this.model.findOne(this.getQuery()); // 查询当前文档
if (!doc) return next();
const oldStatus = doc.status;
const now = new Date();
// 示例:仅当 status 从 stage1 → stage2 时更新 stage2Date
if (oldStatus === 'stage1' && newStatus === 'stage2') {
update.$set.stage2Date = now;
console.log(`[AutoTimestamp] stage2Date set to ${now.toISOString()} for lead ${doc._id}`);
}
// 支持多状态跃迁(如 stage1→stage3,也触发 stage3Date)
if (['stage1', 'stage2', 'stage3'].includes(oldStatus) && newStatus === 'closed') {
update.$set.closedDate = now;
}
next();
} catch (err) {
next(err);
}
});
// ✅ 导出模型(中间件已绑定到 Schema)
const Lead = mongoose.model('Lead', LeadSchema);
module.exports = Lead;⚠️ 重要注意事项
-
save() vs updateOne() vs findOneAndUpdate():
- save() 适用于新建或调用 doc.save() 的场景;
- updateOne() / updateMany() 不触发 this 上下文中的原数据访问(this 是 Query 对象,无 _id 等文档属性),故难以判断旧值;
- findOneAndUpdate() 是最推荐的方式:它支持 this.getQuery() 获取查询条件、this.getUpdate() 获取更新内容,并可通过 this.model.findOne() 安全获取原文档,语义清晰且覆盖 90%+ 更新场景。
避免重复赋值与竞态:
使用 if (!this.stage2Date) 判断是否首次设置,防止后续多次更新 status 到同一值时反复覆盖时间戳。异步中间件务必 next() 或 next(err):
如示例中 findOneAndUpdate 中的 try/catch,未 next() 将导致请求永久挂起。生产环境建议添加日志与监控:
在中间件中加入 console.log 或集成 Winston/Pino,便于追踪时间戳写入行为,尤其在复杂工作流中快速定位问题。
✅ 总结
将业务逻辑下沉至 Mongoose Schema 中间件,是构建高内聚、低耦合后端服务的关键实践。它确保了数据一致性不依赖于开发者是否“记得”在每个路由中调用时间戳逻辑,真正实现「一处定义,全局生效」。只需三步:
1️⃣ 在 Schema 上注册 pre('save') 和 pre('findOneAndUpdate');
2️⃣ 在中间件中通过 isModified() 或 getUpdate() 判断字段变更;
3️⃣ 结合原文档与新值,按需更新时间戳字段并调用 next()。
从此,status 变更自动打标时间,再无需侵入任何 Controller 层代码。










