PAYATHON 2026

如何设计 recurring subscription 与 complimentary access

支付阿杰

结论

建议将“付费订阅”和“免费授权”建模为两种独立的访问资格来源:

  • subscription 只记录真实的 recurring subscription。
  • 新建通用的 access_grant 表,用于记录 complimentary access、人工补偿、活动赠送等非付费授权。
  • user 表只保存用户身份和角色,不增加 is_complimentary、complimentary_expires_date 或通用的 expires 字段。
  • 不要用“特殊 subscription”模拟免费授权,否则会混淆计费、续费、退款和报表的含义。

判断 member 是否可以访问时,应检查:

存在有效的 paid subscription
OR
存在尚未过期的 access grant

如果 admin 默认拥有访问权限,可以在授权逻辑中单独处理,无需伪造一条 subscription。

为什么这样设计

付费订阅与免费授权不是同一种业务对象

subscription 通常包含计费周期、支付渠道、外部订阅 ID、续费状态和取消状态等属性。Complimentary access 没有实际 billing,也不会产生 recurring payment。

如果把免费授权伪装成 subscription,会带来一些问题:

  • 订阅数量和付费用户数量失真。
  • 统计 MRR、续费率等指标时,需要额外排除“特殊订阅”。
  • 支付失败、退款和取消等状态不适用于免费授权。
  • 接入多个支付渠道后,判断逻辑会越来越复杂。

两者提供的访问权限虽然相同,但用户获得权限的原因不同,因此应该分别记录。

不应把临时授权状态放进 user

user 表适合存放账号本身相对稳定的属性,例如姓名、邮箱和角色。免费授权则是一种有生命周期的业务关系,可能存在以下情况:

  • 同一用户先后获得多次授权。
  • 授权被提前撤销。
  • 永久授权没有过期时间。
  • 系统需要记录授权原因、操作人和审计信息。
  • 将来可能增加试用、促销或合作伙伴授权等来源。

is_complimentary 和 complimentary_expires_date 只能表示一个当前状态,难以自然地保留历史。通用的 user.expires 更不合适,因为它无法说明过期的究竟是账号、角色、订阅,还是某项权限。

推荐的数据结构

下面以 PostgreSQL 为例,字段可以根据实际数据库调整。

CREATE TABLE access_grant (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES "user"(id),
    grant_type VARCHAR(50) NOT NULL,
    starts_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMPTZ NULL,
    revoked_at TIMESTAMPTZ NULL,
    reason TEXT NULL,
    granted_by_user_id BIGINT NULL REFERENCES "user"(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT access_grant_valid_period
        CHECK (expires_at IS NULL OR expires_at > starts_at)
);

CREATE INDEX access_grant_active_lookup
    ON access_grant (user_id, starts_at, expires_at, revoked_at);

各字段的含义如下:

  • grant_type 可以使用 complimentary、trial、promotion、support_credit 等值。
  • expires_at IS NULL 表示永久有效。
  • revoked_at 用于提前撤销授权,同时保留历史记录。
  • reason 记录赠送原因。
  • granted_by_user_id 记录执行授权的管理员,方便审计。

如果系统确定永远只会有 complimentary access,也可以把表命名为 complimentary_access。不过,access_grant 更方便承载后续需求。

创建免费授权

例如,为用户赠送 30 天访问资格:

INSERT INTO access_grant (
    user_id,
    grant_type,
    starts_at,
    expires_at,
    reason,
    granted_by_user_id
)
VALUES (
    123,
    'complimentary',
    CURRENT_TIMESTAMP,
    CURRENT_TIMESTAMP + INTERVAL '30 days',
    'Customer support compensation',
    7
);

创建永久授权:

INSERT INTO access_grant (
    user_id,
    grant_type,
    starts_at,
    expires_at,
    reason
)
VALUES (
    123,
    'complimentary',
    CURRENT_TIMESTAMP,
    NULL,
    'Lifetime complimentary access'
);

撤销授权时,不要直接删除记录:

UPDATE access_grant
SET revoked_at = CURRENT_TIMESTAMP
WHERE id = 456
  AND revoked_at IS NULL;

判断用户是否拥有访问权限

可以用下面的查询判断用户是否存在有效的 access grant:

SELECT EXISTS (
    SELECT 1
    FROM access_grant ag
    WHERE ag.user_id = :user_id
      AND ag.starts_at <= CURRENT_TIMESTAMP
      AND (ag.expires_at IS NULL OR ag.expires_at > CURRENT_TIMESTAMP)
      AND ag.revoked_at IS NULL
) AS has_active_grant;

完整的授权判断可以集中放在应用层的统一函数中:

if user.role == "admin":
    allow

if user.role == "member"
   and (hasActiveSubscription(user.id) or hasActiveAccessGrant(user.id)):
    allow

deny

不要把这套判断分散到各个 Controller 或 API endpoint。最好统一放在 authorization service、policy 或 middleware 中,避免不同接口使用不同的授权规则。

订阅状态的处理

只根据 subscription_event 中“最近一条 event 是否为 successful payment”来判断当前订阅是否有效,通常并不可靠。例如:

  • 用户成功付款后立即取消,但仍可以使用到当前周期结束。
  • 支付失败后可能存在宽限期。
  • Event 可能乱序到达或被重复投递。
  • 退款、撤销和人工调整也会影响访问资格。
  • 最近一次事件未必能完整表示当前订阅状态。

更合适的处理方式是:

  • subscription_event 保存不可变的支付事件,作为审计记录。
  • subscription 保存当前的归一化状态,例如 status、current_period_end 和 cancel_at_period_end。
  • 处理支付平台 webhook 时,以幂等方式更新订阅的当前状态。
  • 授权判断直接读取 subscription 的当前状态,不要每次都重新分析所有事件。

例如:

SELECT EXISTS (
    SELECT 1
    FROM subscription s
    WHERE s.user_id = :user_id
      AND s.status IN ('active', 'trialing')
      AND s.current_period_end > CURRENT_TIMESTAMP
) AS has_active_subscription;

具体的状态名称取决于实际使用的支付系统,不能假定所有支付平台都采用相同语义。是否允许 past_due 用户继续访问,也应该由明确的宽限期规则决定。

进一步抽象:Entitlement

如果以后会出现多个套餐或不同的功能权限,可以在 subscription 和 access grant 之上增加 entitlement 层。例如:

subscription ─┐
              ├─> entitlement: premium_access
access_grant ─┘

在这个模型中,subscription 表示“如何付费”,access grant 表示“为什么可以免费获得”,entitlement 则表示“最终可以使用什么”。

如果当前系统只有一种 member access,没有必要一开始就引入完整的 entitlement 模型。使用独立的 access_grant 表和统一的授权函数已经足够清晰。

注意事项

  • 时间字段建议使用带时区的类型,并统一按 UTC 处理。
  • 需要明确 expires_at 的边界语义。通常可以采用半开区间,也就是仅当 expires_at > now() 时有效,到达该时间点后立即失效。
  • 免费授权应保留原因、操作人和撤销记录,以便追踪人工操作。
  • 同一用户可以拥有多个相互重叠的 grant。判断访问权限时,只需确认至少存在一条有效记录。
  • 如果业务不允许同类授权重叠,应通过事务或数据库约束处理,不能只依赖前端校验。
  • role 和付费资格是两个不同的概念。角色描述用户身份,subscription 和 access grant 描述访问资格,不能互相替代。

备注:内容仅供参考。