PAYATHON 2026

如何在 MongoDB 和 Express.js 中限制用户每月发帖数?

支付阿杰

结论

Express middleware 可以用于身份验证和加载套餐信息,但不能只在 middleware 中通过普通查询完成剩余额度检查、额度占用和帖子创建。

建议这样处理:

  1. 在数据库中记录用户当前计费周期的发帖数量。
  2. 通过 MongoDB 原子更新占用额度。
  3. 在同一个事务中更新计数并创建帖子。
  4. 为计数记录建立唯一索引,防止并发请求绕过限制。

不要使用“先统计帖子数量,再判断是否允许发布”的写法。两个并发请求可能同时发现额度尚未用完,随后各自成功创建帖子。

数据模型

可以单独建立一个集合,按计费周期记录用量:

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 可以完成前置验证,但在并发场景下,真正可靠的限制仍然依赖数据库原子更新。

备注:内容仅供参考。