PAYATHON 2026

实现 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 周订阅
  2. 1 个月订阅
  3. 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;

计算步骤

按顺序处理查询结果:

  1. 忽略 failed 和 pending Payment。
  2. 第一份订阅从它的 paid_at 开始生效。
  3. 如果用户购买时仍有有效或排队中的订阅,新订阅从整个队列的结束时间开始。
  4. 如果此前的订阅在购买时已经全部过期,新订阅从自己的 paid_at 开始。
  5. 根据 duration 和 type 计算结束时间。
  6. 找出包含当前时间的区间。

通用伪代码如下:

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 及其购买快照仍应是最终事实来源,以免系统同时维护两个相互冲突的”当前订阅”状态。

备注:内容仅供参考。