实现 Subscription model:如何确定 User 当前订阅?
结论
不要只看最新一笔成功付款。应先将用户所有 status = 'ok' 的 Payment 按付款成功时间从早到晚排序,再依次计算每个 Subscription 的实际生效区间:
start_at = max(payment.paid_at, 上一个 Subscription 的 end_at)
end_at = start_at + Subscription.duration
符合以下条件的记录就是当前订阅:
start_at <= now < end_at
这种计算方式可以处理连续购买、排队生效,以及全部订阅过期后再次购买的情况,也不需要额外维护 UserCurrentSubscription 表。
为什么不能查询最新 Payment
假设用户同时购买了三份订阅:
- 1 周订阅
- 1 个月订阅
- 6 个月订阅
三笔 Payment 的状态都是 ok。最新一笔 Payment 对应第三份订阅,但它还在排队,当前生效的是第一笔成功付款对应的订阅。
当前订阅由此前所有成功订阅累计形成的时间区间决定,不能只根据某一笔付款判断。因此,必须按照购买顺序逐笔计算。
需要的数据
Payment 至少应包含以下字段:
id
user_id
subscription_id
status
paid_at
其中:
paid_at表示付款实际成功的时间,不建议使用 Payment 的创建时间。- Payment 必须能关联到购买时对应的 Subscription。
- 付款成功后,
paid_at最好不要再发生变化。 - 多笔付款的
paid_at相同时,需要使用id等稳定字段作为第二排序条件。
可以使用下面的查询:
SELECT
p.id,
p.paid_at,
s.id AS subscription_id,
s.name,
s.duration,
s.type
FROM payment AS p
JOIN subscription AS s ON s.id = p.subscription_id
WHERE p.user_id = :user_id
AND p.status = 'ok'
ORDER BY p.paid_at ASC, p.id ASC;
计算步骤
按顺序处理查询结果:
- 忽略
failed和pendingPayment。 - 第一份订阅从它的
paid_at开始生效。 - 如果用户购买时仍有有效或排队中的订阅,新订阅从整个队列的结束时间开始。
- 如果此前的订阅在购买时已经全部过期,新订阅从自己的
paid_at开始。 - 根据
duration和type计算结束时间。 - 找出包含当前时间的区间。
通用伪代码如下:
queueEnd = null
for payment in successfulPayments ordered by paid_at, id:
if queueEnd is null:
startAt = payment.paid_at
else:
startAt = max(payment.paid_at, queueEnd)
endAt = addDuration(
startAt,
payment.subscription.duration,
payment.subscription.type
)
if startAt <= now and now < endAt:
return payment.subscription
queueEnd = endAt
return null
关键是 max(payment.paid_at, queueEnd)。它覆盖了两种情况:
之前仍有订阅:startAt = queueEnd
之前已全部过期:startAt = payment.paid_at
TypeScript 示例
下面的示例统一使用 UTC 时间。周按 7 天计算,月按日历月计算,不直接换算成 30 天。
type DurationType = "week" | "month";
interface Subscription {
id: number;
name: string;
duration: number;
type: DurationType;
}
interface SuccessfulPayment {
id: number;
paidAt: Date;
subscription: Subscription;
}
interface SubscriptionPeriod {
paymentId: number;
subscription: Subscription;
startAt: Date;
endAt: Date;
status: "active" | "waiting" | "expired";
}
function addDuration(
startAt: Date,
duration: number,
type: DurationType
): Date {
const result = new Date(startAt);
switch (type) {
case "week":
result.setUTCDate(result.getUTCDate() + duration * 7);
return result;
case "month":
return addCalendarMonths(result, duration);
default:
throw new Error(`Unsupported duration type: ${type}`);
}
}
function addCalendarMonths(date: Date, months: number): Date {
const result = new Date(date);
const originalDay = result.getUTCDate();
// 先移动到月初,避免 1 月 31 日直接加一个月时发生跨月溢出。
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;
}
function calculateSubscriptionPeriods(
payments: SuccessfulPayment[],
now: Date
): SubscriptionPeriod[] {
const orderedPayments = [...payments].sort((a, b) => {
const timeDifference = a.paidAt.getTime() - b.paidAt.getTime();
return timeDifference !== 0 ? timeDifference : a.id - b.id;
});
const periods: SubscriptionPeriod[] = [];
let queueEnd: Date | null = null;
for (const payment of orderedPayments) {
const startAt =
queueEnd !== null && queueEnd > payment.paidAt
? new Date(queueEnd)
: new Date(payment.paidAt);
const endAt = addDuration(
startAt,
payment.subscription.duration,
payment.subscription.type
);
let status: SubscriptionPeriod["status"];
if (now < startAt) {
status = "waiting";
} else if (now < endAt) {
status = "active";
} else {
status = "expired";
}
periods.push({
paymentId: payment.id,
subscription: payment.subscription,
startAt,
endAt,
status
});
queueEnd = endAt;
}
return periods;
}
function getCurrentSubscription(
payments: SuccessfulPayment[],
now = new Date()
): SubscriptionPeriod | null {
return (
calculateSubscriptionPeriods(payments, now).find(
period => period.status === "active"
) ?? null
);
}
有效期使用左闭右开区间:
[start_at, end_at)
当前时间恰好等于前一个订阅的 end_at 时,前一个订阅已经失效,下一个会立即生效,因此不会同时出现两个 active 订阅。
示例
假设三笔成功付款都发生在 2026-01-01 10:00:00 UTC,排序结果依次是:
1 week
1 month
6 months
计算后得到以下区间:
1 week:
2026-01-01 10:00:00
至 2026-01-08 10:00:00
1 month:
2026-01-08 10:00:00
至 2026-02-08 10:00:00
6 months:
2026-02-08 10:00:00
至 2026-08-08 10:00:00
如果下一笔成功付款发生在 2026-09-01,此前的队列已经于 2026-08-08 结束,因此新订阅应从 2026-09-01 开始,而不是从旧队列的结束时间开始。
注意事项
月份不能一律换算成固定天数
1 month 通常指一个日历月,不一定等于 30 天。例如,从 1 月 15 日开始计算,一个月后通常是 2 月 15 日。
对于 1 月 29、30、31 日这样的日期,还要明确具体的业务规则。常见做法是落到目标月份的最后一天,例如:
2026-01-31 + 1 month = 2026-02-28
应使用编程语言或数据库提供的日历日期运算,并通过测试确定边界行为。
明确付款排序时间
如果 Payment 最初是 pending,两天后才变为 ok,通常应按确认成功时的 paid_at 排序,而不是按最初的 created_at 排序。否则,尚未付款成功的订阅可能被插入过去的队列,进而改变后续所有区间。
Subscription 配置变更会影响历史计算
如果计算时直接读取 Subscription 表中当前的 duration,以后将某个套餐从 1 个月改成 2 个月,系统就会按新配置重新解释历史付款。
因此,付款成功时最好把购买时的关键条款保存到 Payment 或订单明细中,例如:
duration = 1
duration_type = month
这些字段不是当前订阅的冗余状态,而是交易快照,用于保证历史计算结果稳定。
退款和撤销需要单独定义
如果系统只有 ok、failed 和 pending 三种状态,上述算法已经足够。如果还存在 refunded、cancelled、拒付或人工补偿,就需要明确这些状态是否会:
- 从队列中删除;
- 立即终止当前订阅;
- 保留已使用时长;
- 将剩余时长转移给下一个订阅。
仅凭 Payment status 无法推导出这些处理规则。
性能优化不等于增加真值来源
直接计算的复杂度是 O(n),其中 n 是该用户的成功付款数量。可以先排除明显早于当前时间的历史记录,但不能随意只取最近几笔,因为较长的订阅仍可能影响当前区间。
数据量很大时,可以缓存计算结果,也可以保存预计算的 start_at 和 end_at。不过,Payment 及其购买快照仍应是最终事实来源,以免系统同时维护两个相互冲突的”当前订阅”状态。
备注:内容仅供参考。
