如何设计 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 描述访问资格,不能互相替代。
备注:内容仅供参考。
