NodeJS / Express:为用户设置默认订阅期限
结论
不要通过 subscriptionPeriod: 6 和 payed: true/false 判断用户是否有访问权限。更稳妥的做法是保存订阅状态、当前周期的开始时间和到期时间:
subscriptionStatus:trialing、active、past_due、canceled、expiredcurrentPeriodStart:当前订阅周期的开始时间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 更新,不能只靠本地定时任务推测付款结果。
注意事项
-
所有权限判断都应在服务端完成。前端隐藏按钮只能改善界面体验,无法阻止未授权访问。
-
数据库使用
Date保存 UTC 时间,向用户显示时再根据其时区格式化,避免夏令时或服务器时区造成误判。 -
到期时间的边界规则需要保持一致。通常使用:
currentPeriodEnd > now
这表示一旦到达 currentPeriodEnd,订阅就立即失效。
-
不要把付款卡号、CVV 等敏感信息保存在用户模型中。这些信息应交给合规的支付平台处理,本地只保存客户 ID、订阅 ID 和必要的状态信息。
-
Webhook 需要验证签名,并根据事件 ID 进行幂等处理。支付平台可能重复发送同一个事件,如果重复处理,订阅期限可能被延长多次。
-
如果订阅访问检查非常频繁,可以把订阅数据放在单独的
Subscription集合中,也可以继续保存在User中。如果每个用户只有一个简单订阅,保存在用户文档中通常已经足够。如果需要处理套餐变更、付款历史、退款或多个订阅,拆分模型会更容易维护。
权限判断应以明确的到期时间为准,订阅状态字段用于描述当前所处的业务阶段,付款结果则由经过验证的支付 Webhook 决定。
备注:内容仅供参考。
