PAYATHON 2026

NodeJS / Express:为用户设置默认订阅期限

支付阿杰

结论

不要通过 subscriptionPeriod: 6 和 payed: true/false 判断用户是否有访问权限。更稳妥的做法是保存订阅状态、当前周期的开始时间和到期时间:

  • subscriptionStatus:trialing、active、past_due、canceled、expired
  • currentPeriodStart:当前订阅周期的开始时间
  • currentPeriodEnd:当前订阅周期的到期时间
  • paymentProvider、providerSubscriptionId:可选的支付平台信息

用户注册时,将状态设为 trialing,开始时间设为注册时间,到期时间设为注册时间之后的 6 个自然月。用户每次访问受保护资源时,服务端都要比较当前时间与 currentPeriodEnd。

paid 只能说明某次付款是否成功,无法准确表示试用、续费失败、取消后仍可使用等情况,因此不能单独作为访问权限的判断依据。

推荐的数据模型

const mongoose = require('mongoose');

const subscriptionStatuses = [
  'trialing',
  'active',
  'past_due',
  'canceled',
  'expired'
];

const schema = new mongoose.Schema(
  {
    email: {
      type: String,
      unique: true,
      required: true
    },
    passwordHash: {
      type: String,
      required: true
    },
    title: {
      type: String,
      required: true
    },
    firstName: {
      type: String,
      required: true
    },
    lastName: {
      type: String,
      required: true
    },
    phone: {
      type: String,
      required: true
    },
    acceptTerms: Boolean,

    subscriptionStatus: {
      type: String,
      enum: subscriptionStatuses,
      default: 'trialing',
      required: true,
      index: true
    },
    currentPeriodStart: {
      type: Date,
      required: true
    },
    currentPeriodEnd: {
      type: Date,
      required: true,
      index: true
    },
    paymentProvider: String,
    providerCustomerId: String,
    providerSubscriptionId: String,

    role: {
      type: String,
      required: true
    },
    verificationToken: String,
    verified: Date,
    resetToken: {
      token: String,
      expires: Date
    },
    passwordReset: Date
  },
  {
    timestamps: {
      createdAt: 'created',
      updatedAt: 'updated'
    }
  }
);

schema.virtual('isVerified').get(function () {
  return !!(this.verified || this.passwordReset);
});

schema.virtual('hasSubscriptionAccess').get(function () {
  const allowedStatuses = ['trialing', 'active'];

  return (
    allowedStatuses.includes(this.subscriptionStatus) &&
    this.currentPeriodEnd.getTime() > Date.now()
  );
});

module.exports = mongoose.model('User', schema);

这里通过 Mongoose 的 timestamps 自动维护 created 和 updated,不必在每次修改用户数据时手动更新 updated。

虚拟字段可以用来显示状态,但不应作为唯一的权限检查方式。查询时可能没有加载完整文档,而且虚拟字段也不能替代服务端中间件。

注册时设置 6 个月试用期

首先要明确业务中的“6 个月”具体指什么:

  • 如果是 180 天,可以直接增加固定的毫秒数。
  • 如果是 6 个自然月,则应按照日历月份计算。

按自然月计算时需要处理月末日期。例如,从 8 月 31 日增加 6 个月,目标月份可能没有 31 日。下面的函数会将结果限制在目标月份的最后一天:

function addMonthsClamped(date, months) {
  const result = new Date(date);
  const originalDay = result.getUTCDate();

  result.setUTCDate(1);
  result.setUTCMonth(result.getUTCMonth() + months);

  const lastDayOfTargetMonth = new Date(
    Date.UTC(
      result.getUTCFullYear(),
      result.getUTCMonth() + 1,
      0
    )
  ).getUTCDate();

  result.setUTCDate(Math.min(originalDay, lastDayOfTargetMonth));

  return result;
}

创建用户时,明确写入订阅周期的开始时间和到期时间:

const User = require('./user.model');

async function registerUser(input) {
  const now = new Date();
  const trialEnd = addMonthsClamped(now, 6);

  const user = await User.create({
    email: input.email,
    passwordHash: input.passwordHash,
    title: input.title,
    firstName: input.firstName,
    lastName: input.lastName,
    phone: input.phone,
    acceptTerms: input.acceptTerms,
    role: input.role,

    subscriptionStatus: 'trialing',
    currentPeriodStart: now,
    currentPeriodEnd: trialEnd
  });

  return user;
}

不建议为 currentPeriodStart 和 currentPeriodEnd 分别设置相互独立的默认函数。两个函数的执行时间可能略有差异,也不方便统一处理自然月规则。在注册服务中一次计算并写入会更清楚。

使用 Express 中间件检查订阅

所有受保护的接口都应在服务端检查订阅状态和到期时间:

const User = require('./user.model');

async function requireSubscription(req, res, next) {
  try {
    const user = await User.findById(req.user.id)
      .select('subscriptionStatus currentPeriodEnd');

    if (!user) {
      return res.status(401).json({
        message: 'User not found'
      });
    }

    const now = new Date();
    const allowedStatuses = ['trialing', 'active'];
    const statusAllowsAccess = allowedStatuses.includes(
      user.subscriptionStatus
    );
    const periodIsValid =
      user.currentPeriodEnd &&
      user.currentPeriodEnd.getTime() > now.getTime();

    if (!statusAllowsAccess || !periodIsValid) {
      if (
        user.subscriptionStatus === 'trialing' &&
        user.currentPeriodEnd &&
        user.currentPeriodEnd.getTime() <= now.getTime()
      ) {
        await User.updateOne(
          {
            _id: user._id,
            subscriptionStatus: 'trialing',
            currentPeriodEnd: { $lte: now }
          },
          {
            $set: {
              subscriptionStatus: 'expired'
            }
          }
        );
      }

      return res.status(403).json({
        code: 'SUBSCRIPTION_REQUIRED',
        message: 'Your subscription has expired'
      });
    }

    req.subscription = {
      status: user.subscriptionStatus,
      currentPeriodEnd: user.currentPeriodEnd
    };

    next();
  } catch (error) {
    next(error);
  }
}

然后把这个中间件添加到需要订阅权限的路由中:

router.get(
  '/premium-content',
  authenticate,
  requireSubscription,
  async (req, res) => {
    res.json({
      message: 'Premium content'
    });
  }
);

身份认证中间件 authenticate 应先执行,以确保 req.user.id 来自可信的登录凭证,而不是请求参数。

付款成功后的处理

付款结果必须以支付平台发送的服务端通知为准,例如支付成功 Webhook。不能仅凭前端跳转到了“付款成功”页面,就把订阅状态改为有效。

首次购买成功后,可以开启一个新的订阅周期:

async function activateSubscription(userId, providerData) {
  const now = new Date();
  const periodEnd = addMonthsClamped(now, 1);

  return User.findByIdAndUpdate(
    userId,
    {
      $set: {
        subscriptionStatus: 'active',
        currentPeriodStart: now,
        currentPeriodEnd: periodEnd,
        paymentProvider: providerData.provider,
        providerCustomerId: providerData.customerId,
        providerSubscriptionId: providerData.subscriptionId
      }
    },
    {
      new: true,
      runValidators: true
    }
  );
}

如果允许提前续费,新周期通常应从当前到期时间开始,而不是从付款时间开始。否则,用户可能会损失尚未使用的剩余天数:

async function renewSubscription(userId, months = 1) {
  const user = await User.findById(userId);

  if (!user) {
    throw new Error('User not found');
  }

  const now = new Date();
  const currentEnd = user.currentPeriodEnd;
  const renewalStart =
    currentEnd && currentEnd.getTime() > now.getTime()
      ? currentEnd
      : now;

  user.subscriptionStatus = 'active';
  user.currentPeriodStart = renewalStart;
  user.currentPeriodEnd = addMonthsClamped(renewalStart, months);

  return user.save();
}

实际接入支付平台时,应优先使用平台 Webhook 提供的订阅周期开始和结束时间。如果支付平台是订阅状态的权威来源,本地数据库应直接保存平台返回的时间,不要自行推算。

订阅状态的含义

建议事先明确每个状态对应的业务规则:

  • trialing:正在免费试用,且试用期尚未结束。
  • active:已经付款,当前订阅周期有效。
  • past_due:续费失败。是否允许短期继续访问,要看业务是否提供宽限期。
  • canceled:已经取消续费,但可能仍能使用到 currentPeriodEnd。
  • expired:当前订阅周期已经结束,不能继续访问。

如果用户取消订阅后仍可使用到当前周期结束,就不能直接拒绝所有状态为 canceled 的用户。可以增加 cancelAtPeriodEnd 字段,用来区分“已取消自动续费”和“订阅已经失效”:

cancelAtPeriodEnd: {
  type: Boolean,
  default: false
}

这种情况下,用户的状态仍为 active,只需将 cancelAtPeriodEnd 设为 true,等当前周期到期后再改为 expired。

是否需要定时任务

阻止过期用户访问不一定需要依赖定时任务。只要每次请求都检查 currentPeriodEnd,即使数据库中的状态暂时仍是 trialing,过期用户也无法访问受保护接口。

定时任务可以作为辅助,用于批量同步状态、发送到期提醒或者生成报表:

await User.updateMany(
  {
    subscriptionStatus: { $in: ['trialing', 'active'] },
    currentPeriodEnd: { $lte: new Date() }
  },
  {
    $set: {
      subscriptionStatus: 'expired'
    }
  }
);

如果采用自动续费,续费成功或失败应主要由支付平台的 Webhook 更新,不能只靠本地定时任务推测付款结果。

注意事项

  1. 所有权限判断都应在服务端完成。前端隐藏按钮只能改善界面体验,无法阻止未授权访问。

  2. 数据库使用 Date 保存 UTC 时间,向用户显示时再根据其时区格式化,避免夏令时或服务器时区造成误判。

  3. 到期时间的边界规则需要保持一致。通常使用:

currentPeriodEnd > now

这表示一旦到达 currentPeriodEnd,订阅就立即失效。

  1. 不要把付款卡号、CVV 等敏感信息保存在用户模型中。这些信息应交给合规的支付平台处理,本地只保存客户 ID、订阅 ID 和必要的状态信息。

  2. Webhook 需要验证签名,并根据事件 ID 进行幂等处理。支付平台可能重复发送同一个事件,如果重复处理,订阅期限可能被延长多次。

  3. 如果订阅访问检查非常频繁,可以把订阅数据放在单独的 Subscription 集合中,也可以继续保存在 User 中。如果每个用户只有一个简单订阅,保存在用户文档中通常已经足够。如果需要处理套餐变更、付款历史、退款或多个订阅,拆分模型会更容易维护。

权限判断应以明确的到期时间为准,订阅状态字段用于描述当前所处的业务阶段,付款结果则由经过验证的支付 Webhook 决定。

备注:内容仅供参考。