如何在 MongoDB 和 Express.js 中限制用户每月发帖数?
结论
Express middleware 可以用于身份验证和加载套餐信息,但不能只在 middleware 中通过普通查询完成剩余额度检查、额度占用和帖子创建。
建议这样处理:
- 在数据库中记录用户当前计费周期的发帖数量。
- 通过 MongoDB 原子更新占用额度。
- 在同一个事务中更新计数并创建帖子。
- 为计数记录建立唯一索引,防止并发请求绕过限制。
不要使用“先统计帖子数量,再判断是否允许发布”的写法。两个并发请求可能同时发现额度尚未用完,随后各自成功创建帖子。
数据模型
可以单独建立一个集合,按计费周期记录用量:
import mongoose from 'mongoose';
const postUsageSchema = new mongoose.Schema(
{
userId: {
type: mongoose.Schema.Types.ObjectId,
required: true,
index: true
},
periodStart: {
type: Date,
required: true
},
periodEnd: {
type: Date,
required: true
},
used: {
type: Number,
required: true,
default: 0,
min: 0
}
},
{ timestamps: true }
);
postUsageSchema.index(
{ userId: 1, periodStart: 1 },
{ unique: true }
);
export const PostUsage = mongoose.model('PostUsage', postUsageSchema);
periodStart 和 periodEnd 最好直接采用订阅系统保存的计费周期,不要根据服务器时间简单计算自然月。例如,某个用户的订阅周期可能是 8 月 15 日至 9 月 15 日。
套餐额度必须由服务端配置或数据库确定,不能使用客户端传入的 plan 或 limit:
const PLAN_LIMITS = {
free: 3,
basic: 20,
pro: 100
};
middleware 负责什么
middleware 适合验证登录状态,并将用户信息写入 req.user:
export async function requireAuth(req, res, next) {
try {
const user = await User.findById(req.auth.userId);
if (!user) {
return res.status(401).json({ message: 'Unauthorized' });
}
req.user = user;
next();
} catch (error) {
next(error);
}
}
不要在这里直接查询数量并创建帖子。middleware 和后续 controller 分开执行时,很难保证计数更新与帖子创建处于同一个原子操作中。
在事务中占用额度并创建帖子
下面的服务函数会先确保本周期的计数记录存在,再在事务中原子递增计数并创建帖子:
import mongoose from 'mongoose';
import { Post } from './models/Post.js';
import { PostUsage } from './models/PostUsage.js';
class QuotaExceededError extends Error {}
async function ensureUsageRecord(userId, periodStart, periodEnd) {
try {
await PostUsage.updateOne(
{ userId, periodStart },
{
$setOnInsert: {
userId,
periodStart,
periodEnd,
used: 0
}
},
{ upsert: true }
);
} catch (error) {
// 两个首次请求并发创建记录时,其中一个可能触发唯一索引冲突。
// 只忽略 duplicate key,其他错误继续抛出。
if (error?.code !== 11000) {
throw error;
}
}
}
export async function createPostWithQuota(user, postData) {
const limit = PLAN_LIMITS[user.plan];
if (!Number.isInteger(limit)) {
throw new Error(`Unknown subscription plan: ${user.plan}`);
}
const periodStart = user.subscription.currentPeriodStart;
const periodEnd = user.subscription.currentPeriodEnd;
if (!periodStart || !periodEnd) {
throw new Error('Subscription period is not available');
}
await ensureUsageRecord(user._id, periodStart, periodEnd);
const session = await mongoose.startSession();
let createdPost;
try {
await session.withTransaction(async () => {
const usage = await PostUsage.findOneAndUpdate(
{
userId: user._id,
periodStart,
used: { $lt: limit }
},
{
$inc: { used: 1 }
},
{
new: true,
session
}
);
if (!usage) {
throw new QuotaExceededError('Monthly post limit reached');
}
const posts = await Post.create(
[
{
...postData,
authorId: user._id
}
],
{ session }
);
createdPost = posts[0];
});
return createdPost;
} finally {
await session.endSession();
}
}
路由只需要调用这个服务:
router.post('/posts', requireAuth, async (req, res, next) => {
try {
const post = await createPostWithQuota(req.user, {
title: req.body.title,
content: req.body.content
});
res.status(201).json(post);
} catch (error) {
if (error instanceof QuotaExceededError) {
return res.status(429).json({
code: 'POST_QUOTA_EXCEEDED',
message: 'You have reached your post limit for this billing period.'
});
}
next(error);
}
});
findOneAndUpdate() 会在同一次原子更新中执行 used: { $lt: limit } 条件判断和 $inc 操作。多个请求同时到达时,只有额度尚未用完的请求能够成功占用名额。
事务还可以保证计数与帖子创建的一致性。如果帖子创建失败,计数递增也会回滚,不会发生帖子没有创建却扣除了额度的情况。
需要注意的问题
MongoDB 事务环境
MongoDB 的多文档事务要求数据库运行在 replica set 或 sharded cluster 环境中。生产环境通常能满足这个条件。本地开发如果使用单节点 MongoDB,需要将其配置为单节点 replica set,或者采用不依赖事务的补偿方案。
套餐信息不能只从 JWT 获取
JWT 中的套餐信息可能已经过期。用户升级、降级或取消订阅后,应以数据库中的有效订阅状态为准。支付平台的 webhook 也应及时更新本地订阅记录。
明确“每月”的定义
需要提前确定额度按哪种周期计算:
- 每个自然月;
- 每位用户各自的订阅账期;
- 支付平台提供的
current_period_start和current_period_end。
订阅制产品通常采用第三种方式,续费、延期和试用期会更容易处理。
编辑帖子不应重复扣额度
只有成功新建帖子才应占用额度。编辑帖子、保存草稿或审核失败是否计数,需要按照业务规则明确处理。如果删除帖子后允许恢复额度,也应在事务中递减 used,并确保其不会小于 0。
middleware 不是安全边界
限制逻辑必须统一放在服务函数或数据访问层中,否则后台任务、管理接口或以后新增的路由可能绕过 middleware。middleware 可以完成前置验证,但在并发场景下,真正可靠的限制仍然依赖数据库原子更新。
备注:内容仅供参考。
