FlexPrice 订阅暂停(Subscription Pause)功能设计解析:从 PRD 到源码落地
FlexPrice 订阅暂停(Subscription Pause)功能设计解析:从 PRD 到源码落地
FlexPrice 是面向开发者的用量计费(Usage-based billing)平台。本文将围绕仓库中的产品需求文档 docs/prds/subscription_pause.md 及其配套的 Phase 0 实施文档 docs/prds/subscription_pause_phase0.md,完整讲解"订阅暂停/恢复"功能的需求背景、数据模型、API 契约、计费影响计算、实现阶段划分与测试方案,并结合 internal/domain/subscription/pause.go、internal/api/dto/subscription_pause.go、internal/api/v1/subscription_pause.go 等源码,验证该功能在 FlexPrice 中的真实落地形态。读完本文,你将掌握暂停功能的状态机设计、PauseMode/ResumeMode 语义、billing_impact 的计算逻辑,以及如何在 FlexPrice 中调用暂停与恢复 API。
一、需求背景:为什么订阅需要"暂停"而不是只有"取消"
1.1 问题陈述
在传统的订阅计费系统中,客户面对暂时不需要服务的情况只有两个选择:
- 继续为未使用的服务付费;
- 直接取消订阅,未来需要时再重新订阅。
这两种选择都会带来负面影响:前者降低客户满意度,后者直接导致客户流失(churn)与客户终身价值(LTV)下降。暂停功能正是为了填补"继续付费"与"彻底取消"之间的空档,让客户在财务受限、季节性业务波动、长期出差或暂时无需求等场景下,能够保留订阅记录但停止计费。
1.2 现状与目标
PRD 指出,在引入暂停功能前,FlexPrice 的订阅状态机只支持三种状态:
active:订阅激活且正常计费;cancelled:订阅已取消;trialing:订阅处于试用期。
虽然在类型定义中已经存在 SubscriptionStatusPaused("paused")常量,但没有任何功能实现去支撑它。这一点在 internal/types/subscription.go 中可以得到印证:该文件里订阅状态枚举实际包含了 active、paused、cancelled、incomplete、trialing、draft 六种状态,paused 早已就位,只是缺少配套的业务逻辑。
暂停功能的目标是:
- 提高客户留存率(retention);
- 提升客户满意度;
- 提供更灵活的计费方式;
- 减少因财务约束导致的非自愿流失(involuntary churn)。
二、核心功能与状态机设计
2.1 四大核心能力
PRD 定义了暂停功能的四项核心能力:
- Pause Subscription(暂停):临时暂停订阅,停止计费但保留订阅记录;
- Resume Subscription(恢复):将已暂停的订阅恢复为激活状态,重新开始计费;
- Scheduled Pause/Resume(定时暂停/恢复):支持在未来日期定时执行暂停与恢复;
- Pause History(暂停历史):记录每一次暂停/恢复操作,用于报告与分析。
2.2 PauseStatus:订阅的暂停状态
PRD 提出在 Subscription 模型上新增 PauseStatus 字段,取值范围为 none、active、scheduled。源码 internal/types/subscription.go 中该类型已实际落地,并且比 PRD 设计得更完整,额外增加了两个终态:
PauseStatusNone("none"):未暂停;PauseStatusActive("active"):当前处于暂停中;PauseStatusScheduled("scheduled"):已安排在未来暂停;PauseStatusCompleted("completed"):暂停已结束(订阅已恢复);PauseStatusCancelled("cancelled"):暂停请求被取消。
PauseStatus 类型带有 String() 与 Validate() 方法,Validate() 会在非法值传入时通过 internal/errors 返回带 ErrValidation 标记的错误,这保证了状态字段在 API 层与服务层都能被一致校验。
2.3 PauseMode 与 ResumeMode:暂停/恢复的触发时机
PRD 定义了 PauseMode(immediate / period_end / scheduled)与 ResumeMode(immediate / scheduled / auto)两种模式。这两类枚举在仓库中有独立的定义文件 internal/types/pause_mode.go,其含义如下:
| 枚举 | 取值 | 语义 |
|---|---|---|
PauseMode |
immediate |
立即暂停,从当前时刻停止计费 |
PauseMode |
scheduled |
在指定时间(pause_start)开始暂停 |
PauseMode |
period_end |
在当前计费周期结束时暂停,周期内继续正常计费 |
ResumeMode |
immediate |
立即恢复订阅 |
ResumeMode |
scheduled |
在指定日期恢复 |
ResumeMode |
auto |
依据 pause_end 自动恢复 |
两种枚举均实现了 Validate(),只接受上表列出的合法值。
三、数据模型:Subscription 扩展与 SubscriptionPause 表
3.1 Subscription 模型新增字段
PRD 要求在 Subscription 结构体上增加两个字段。这两个字段在 internal/domain/subscription/model.go 中均已存在:
PauseStatus types.PauseStatus `db:"pause_status" json:"pause_status"`
// ActivePauseID references the current active pause configuration
// This will be null if no pause is active or scheduled
ActivePauseID *string `db:"active_pause_id" json:"active_pause_id,omitempty"`
PauseStatus:跟踪订阅当前的暂停状态;ActivePauseID:指向当前生效的暂停配置记录(subscription_pauses表的主键),无暂停或仅排定暂停时为空指针。
同时 Subscription 模型还新增了关联字段 Pauses <a href="https://link.gitcode.com/i/1f99ff0d3b43f956594fe1d378bebff7" target="_blank">]*SubscriptionPause(见 [internal/domain/subscription/model.go),用于在查询时一次性加载该订阅的全部暂停记录;GetSubscriptionFromEnt 中通过 sub.Edges.Pauses 完成这一关联映射(internal/domain/subscription/model.go)。
3.2 SubscriptionPause 表结构
PRD 给出了 SubscriptionPause 结构体草案。仓库中实际实现的版本位于 internal/domain/subscription/pause.go,与 PRD 相比做了以下调整:
- 字段精简:移除了
Status string、PauseDays *int字段——暂停时长改由PauseStart与PauseEnd两个时间点推导; - 字段升级为类型安全枚举:
PauseStatus、PauseMode、ResumeMode分别使用types.PauseStatus、types.PauseMode、types.ResumeMode而非裸字符串; - 新增多租户字段:
EnvironmentID(环境隔离)以及继承自types.BaseModel的TenantID、Status、CreatedAt、CreatedBy、UpdatedAt、UpdatedBy——这与 FlexPrice 的多租户架构一致。
最终字段清单:
| 字段 | 类型 | 说明 |
|---|---|---|
ID |
string | 暂停记录唯一标识 |
SubscriptionID |
string | 所属订阅 ID |
PauseStatus |
types.PauseStatus |
none / active / scheduled / completed / cancelled |
PauseMode |
types.PauseMode |
immediate / scheduled / period_end |
ResumeMode |
types.ResumeMode |
immediate / scheduled / auto |
PauseStart |
time.Time | 暂停实际开始时间 |
PauseEnd |
*time.Time | 暂停结束时间(无限期暂停时为 nil) |
ResumedAt |
*time.Time | 实际恢复时间(手动恢复时写入) |
OriginalPeriodStart |
time.Time | 暂停发生时所在计费周期的起始时间 |
OriginalPeriodEnd |
time.Time | 暂停发生时所在计费周期的结束时间 |
Reason |
string | 暂停原因 |
Metadata |
types.Metadata |
附加元数据 |
EnvironmentID |
string | 环境标识 |
BaseModel |
types.BaseModel |
租户/状态/审计时间字段 |
其中 OriginalPeriodStart 与 OriginalPeriodEnd 是"恢复时还原计费周期"的关键:恢复操作会以这两个原始周期为基准,叠加暂停时长,重新推算调整后的计费周期。
3.3 ORM 与迁移
SubscriptionPause 领域模型与 ORM 层(ent 框架)之间有 SubscriptionPauseFromEnt / SubscriptionPauseListFromEnt 两个转换函数(internal/domain/subscription/pause.go),说明该表已经纳入 ent 生成代码体系(ent/subscriptionpause 与 ent/schema/subscription_pause.go),配套的数据库迁移可直接通过 ent 迁移机制生成。
四、API 契约:暂停、恢复与历史查询
4.1 Pause Subscription Request
PRD 给出的暂停请求体包含 pause_mode、pause_start、pause_end、pause_days、reason、dry_run、metadata。实际 DTO 实现在 internal/api/dto/subscription_pause.go,字段语义与校验规则如下:
{
"pause_mode": "immediate",
"pause_start": "2023-10-15T00:00:00Z",
"pause_end": "2023-12-31T00:00:00Z",
"pause_days": 30,
"reason": "Customer traveling",
"dry_run": false,
"metadata": {
"requested_by": "customer"
}
}
字段约束(DTO 内 Validate() 方法实现,见 internal/api/dto/subscription_pause.go):
pause_mode必填,且必须是types.PauseMode枚举中的合法值;- 当
pause_mode = scheduled时,pause_start必填(PRD 中原先的pause_start字段在实现版中为可选指针,仅在 scheduled 模式下强制要求); pause_end与pause_days二选一,同时指定会返回校验错误;pause_days必须为正整数(validate:"omitempty,gt=0");pause_end必须在未来(实现中还额外要求pause_end晚于pause_start,见validate:"omitempty,gtfield=PauseStart");reason最长 255 字符;dry_run默认为 false。
4.2 Pause Subscription Response
暂停响应由三部分组成:更新后的 subscription、新建的 pause 记录、以及 billing_impact 计费影响明细(internal/api/dto/subscription_pause.go):
{
"subscription": {
"id": "sub_123",
"status": "paused",
"pause_status": "active"
},
"pause": {
"id": "pause_456",
"subscription_id": "sub_123",
"pause_mode": "immediate",
"pause_start": "2023-10-15T14:30:00Z",
"pause_end": "2023-12-31T00:00:00Z",
"original_period_start": "2023-10-01T00:00:00Z",
"original_period_end": "2023-10-31T23:59:59Z",
"reason": "Customer traveling"
},
"billing_impact": {
"current_period_adjustment": -50.00,
"next_billing_date": "2023-12-31T00:00:00Z",
"next_billing_amount": 100.00,
"pause_duration_days": 77
},
"dry_run": false
}
注意 PRD 草案中的 pause_days 字段在响应里已不直接返回(时长由 pause_start/pause_end 推导并反映在 billing_impact.pause_duration_days 中)。
4.3 Resume Subscription Request / Response
恢复请求体(internal/api/dto/subscription_pause.go):
{
"resume_mode": "immediate",
"dry_run": false,
"metadata": {
"requested_by": "customer"
}
}
实现版 DTO 的校验相对轻量:resume_mode 必填且必须是合法枚举;PRD 中提及的 resume_date(scheduled 恢复所需)在当前实现版 DTO 中尚未出现,属于后续阶段能力。恢复响应结构与暂停响应对称,区别在于:
subscription.status回到active,pause_status回到none;pause.status变为completed,并写入resumed_at;billing_impact中的adjusted_period_start/adjusted_period_end展示按暂停时长平移后的新计费周期。
4.4 暂停历史查询
PRD 规划的 GetPauseHistory 在实现中演变为 ListPauses,对应响应类型 ListSubscriptionPausesResponse(items + total,见 internal/api/dto/subscription_pause.go)。同时 PRD 提出的"每次暂停/恢复写入订阅历史事件"(subscription.paused / subscription.resumed)仍然成立,作为审计与分析的数据来源。
4.5 HTTP 层实现
HTTP 处理器位于 internal/api/v1/subscription_pause.go,基于 Gin 框架实现了三个接口:
POST /subscriptions/{id}/pause→PauseSubscription;POST /subscriptions/{id}/resume→ResumeSubscription;GET /subscriptions/{id}/pauses→ListPauses。
关键实现细节:当请求携带 dry_run = true 时,处理器不会调用 PauseSubscription / ResumeSubscription,而是改调 CalculatePauseImpact / CalculateResumeImpact,返回形如 {"impact": {...}} 的纯计算结果,不产生任何副作用(internal/api/v1/subscription_pause.go)。这与 PRD 对 dry run 模式的要求完全一致:不修改订阅、返回计费影响、响应标记 dry_run: true。
五、服务层:暂停/恢复的业务流程
5.1 服务接口
PRD 要求为 SubscriptionService 增加 PauseSubscription、ResumeSubscription、GetPauseHistory 三个方法。仓库实现位于 internal/interfaces/service.go 与 internal/ee/service/subscription.go(企业版服务实现),实际方法集为:
PauseSubscription(ctx, id, req)ResumeSubscription(ctx, id, req)CalculatePauseImpact(ctx, id, req)CalculateResumeImpact(ctx, id, req)ListPauses(ctx, id)
5.2 暂停流程(Immediate Pause)
PRD 与 Phase 0 文档共同描述的暂停处理流程如下:
- 校验请求:
pause_mode必须合法,pause_end与pause_days不能同时指定; - 校验订阅状态:订阅当前必须是
active(Phase 0 实现中,状态不是 active 或已处于暂停状态时直接报错); - 计算暂停结束时间:若指定
pause_days,则按pauseStart + pauseDays推导pauseEnd; - 计算计费影响:调用
calculateBillingImpact,得到billing_impact; - dry run 提前返回:若是预览请求,直接返回影响结果,不写库;
- 创建暂停记录:生成
pause_xxx前缀 ID(types.GenerateUUIDWithPrefix("pause")),写入PauseStart = now、OriginalPeriodStart/End = 订阅当前计费周期; - 更新订阅:
PauseStatus = active、ActivePauseID = pauseID、SubscriptionStatus = paused; - 事务提交:暂停记录创建与订阅更新在同一个事务(
WithTx)中完成,保证原子性。
5.3 恢复流程(Immediate Resume)
恢复流程同样由 Phase 0 文档给出伪代码,逻辑对称:
- 校验状态:订阅必须是
paused,且PauseStatus = active、ActivePauseID非空; - 读取活跃暂停记录:通过
ActivePauseID获取pause; - 计算暂停时长:
now - PauseStart换算为天数; - 计算恢复计费影响:以
OriginalPeriodStart/End与订阅当前周期为基准构造billing_impact; - 更新暂停记录:
PauseStatus = completed、写入ResumedAt; - 更新订阅:
PauseStatus = none、ActivePauseID = nil、SubscriptionStatus = active; - 平移计费周期:
CurrentPeriodStart/End各加上暂停时长(subscription.CurrentPeriodStart.Add(pauseDuration)),实现"账单顺延"; - 事务提交:暂停记录更新与订阅更新在同一事务中完成。
这套"按暂停时长平移计费周期"的策略正是 PRD 中"Resume Billing 示例"的实现方式:原周期 Oct 1–Oct 31,暂停 Oct 15–Nov 15(31 天),恢复后调整周期为 Nov 15–Dec 15,下次账单日为 Dec 15。
5.4 与计费周期 Cron 的集成
PRD 明确指出,处理订阅计费周期的定时任务需要更新三件事:跳过 paused 状态的订阅、处理定时暂停/恢复、处理基于 pause_end 的自动恢复。Phase 0 文档给出的 UpdateBillingPeriods 示例即是在批处理循环开头增加:
// Skip paused subscriptions
if sub.SubscriptionStatus == types.SubscriptionStatusPaused {
s.Logger.Infow("skipping paused subscription", "subscription_id", sub.ID)
continue
}
这一改动保证暂停期间不会生成账单、不会推进计费周期。定时(scheduled)暂停/恢复与自动恢复属于后续阶段能力,但架构上都是对这一批处理逻辑的扩展。
六、计费与开票影响:billing_impact 详解
6.1 BillingImpactDetails 结构
PRD 定义了 BillingImpactDetails 结构体,用于向用户透明展示暂停/恢复的财务影响。实现中该结构体位于 internal/types(types.BillingImpactDetails),核心字段:
| 字段 | 类型 | 语义 |
|---|---|---|
CurrentPeriodAdjustment |
float64 | 当期调整额;负值表示退款/贷记给客户(预付费模式中未使用部分) |
NextBillingDate |
*time.Time | 下一张发票生成日期;暂停期间为空 |
NextBillingAmount |
float64 | 下一计费日应扣金额,可能因中途恢复而按比例折算 |
OriginalPeriodStart / OriginalPeriodEnd |
*time.Time | 暂停前的原始计费周期 |
AdjustedPeriodStart / AdjustedPeriodEnd |
*time.Time | 暂停后调整的计费周期 |
PauseDurationDays |
int | 总暂停天数 |
6.2 计费影响的计算因素
billing_impact 的计算结果取决于四个因素:
- 计费模型:预付费(advance)还是后付费(arrears);
- 暂停模式:immediate 还是 period_end;
- 订阅的计费周期(月/周/年等);
- 暂停时长。
6.3 预付费(Advance Billing)下的立即暂停
预付费模式下,客户已为整个周期付费,立即暂停时应退还未使用部分:
- 月度订阅:$100/月;
- 计费周期:10 月 1 日—10 月 31 日;
- 暂停日期:10 月 15 日;
- 未使用天数:16 天(10 月 16 日—31 日);
- 贷记金额:$100 × (16/31) = $51.61;
- 下次计费日期:暂停结束后。
6.4 后付费(Arrears Billing)下的立即暂停
后付费模式下,暂停只影响"本期已使用部分"的结算:
- 月度订阅:$100/月;
- 计费周期:10 月 1 日—10 月 31 日;
- 暂停日期:10 月 15 日;
- 已用天数:15 天(10 月 1 日—15 日);
- 应结金额:$100 × (15/31) = $48.39;
- 暂停期间不再计费。
6.5 Period-End 暂停与恢复
- Period-End 暂停:无需按比例折算——本周期正常结账,暂停期间不产生账单;
- 恢复计费:将计费周期按暂停时长整体顺延后恢复正常计费(示例见 5.3)。
6.6 Phase 0 的简化计算
Phase 0 文档提供了 calculateBillingImpact 参考实现(docs/prds/subscription_pause_phase0.md),其计算逻辑为:
- 用
CurrentPeriodEnd - CurrentPeriodStart求周期总天数; - 用
CurrentPeriodEnd - pauseStart求剩余天数; - 剩余天数 / 总天数 × 月度金额得出
CurrentPeriodAdjustment(预付费场景为负值即贷记); - 根据
pauseEnd/pauseDays推导NextBillingDate与PauseDurationDays; - 将
CurrentPeriodStart/End平移pauseDurationDays天得到AdjustedPeriodStart/End。
同时文档明确标注了该阶段的两处简化:无限期暂停用 30 天占位值、金额计算使用占位常量 100.0,并注明"未来阶段将基于订阅真实金额、区分固定价格与用量价格进行计算"。生产实现中这些能力由服务层结合订阅的实际行项目金额计算。
七、Dry Run 模式:先算账、再动手
PRD 将 dry run 定义为一个关键的用户体验能力:客户在确认暂停/恢复前,可以先预览财务影响。规则如下:
- 不修改订阅与暂停记录;
- 响应中返回完整的
billing_impact; - 响应中不包含更新后的
subscription与pause对象; - 响应中
dry_run字段为true。
实际 HTTP 实现严格遵循这一契约:处理器检测到 dry_run: true 后立即转入 CalculatePauseImpact / CalculateResumeImpact 分支,只返回 {"impact": ...}(internal/api/v1/subscription_pause.go)。由于 dry run 完全跳过持久化路径,其计算结果与真实执行时的 billing_impact 一致性由测试用例保障(见第八节)。
八、边界情况、租户配置与测试策略
8.1 边界情况清单
PRD 列出的边界情况对实现提出了明确的约束,其中"暂停期间禁止计划变更"这一点已在服务端体现——subscription_plan_change.go 的 OpenAPI 注释中明确将 paused 列为计划变更接口不可操作的状态(internal/api/v1/subscription_plan_change.go)。其余边界规则汇总如下:
| 边界场景 | 规则 |
|---|---|
| 试用期暂停 | 允许暂停;恢复时试用结束日期按暂停时长顺延 |
| 多次暂停 | 同一时刻只允许一个活跃暂停;累计统计暂停次数与时长 |
| 暂停期间的用量 | 继续记录 usage 事件,但不计入账单;报告中标记"paused" |
| 暂停期间的订阅变更 | 不允许计划变更;变更请求排队,恢复后应用 |
| 暂停期间的取消 | 允许取消,状态直接置为 cancelled |
| 暂停前的欠费 | 要求先结清未付发票;或允许暂停但仅在付款后恢复 |
8.2 租户级配置项
PRD 规划了 5 项租户配置,属于后期阶段(Phase 2)能力:
- Allow Pause:是否启用暂停功能;
- Maximum Pause Duration:最大允许暂停时长;
- Minimum Time Between Pauses:两次暂停之间的最小活跃间隔;
- Proration Settings:贷记/扣费的计算策略;
- Pause Limits:每个订阅允许的暂停次数上限。
8.3 测试策略与仓库内测试佐证
PRD 规划的测试场景包括:暂停激活订阅、恢复暂停订阅、dry run 一致性、定时暂停/恢复、自动恢复、以及各类边界(试用期暂停、暂停中取消、多种计费模型)。
仓库中已有对应的服务层测试 internal/ee/service/subscription_pause_test.go,其中 TestSubscriptionPauseService 覆盖了多种 pause_mode 组合(PauseModeImmediate 等),并断言暂停响应的 Pause.PauseMode 与请求一致(internal/ee/service/subscription_pause_test.go),同时验证了暂停后订阅状态、billing_impact 等输出。API 测试 SDK 中也有对应的 pause/resume 调用示例(api/tests/go/test_sdk.go、api/tests/ts/test_sdk.ts),可作为客户端接入的参考。
九、实施路线图:从 Phase 0 到 Phase 2
PRD 将暂停功能拆分为三个阶段:
Phase 0(最小可行实现)
- 数据库字段与
subscription_pauses表; - 基本立即暂停/恢复;
- 计费周期 Cron 跳过
paused订阅; - dry run 预览支持;
- 简单按比例(proration)计算。
Phase 1(核心功能)
- period_end 暂停;
- 定时暂停与恢复;
- 基于
pause_end的自动恢复; - 暂停历史 API。
Phase 2(高级特性)
- 租户配置项;
- 暂停期间的用量处理;
- 报告与分析;
- 部分暂停(仅暂停特定行项目)。
迁移与上线策略:
- 数据库迁移:为 subscription 表加字段、新建 subscription_pauses 表、存量订阅初始化
pause_status = none; - API 版本:在现有 API 版本内新增端点并补充文档;
- 灰度发布:先上线数据库变更 → 以功能开关(feature flag)发布代码 → 面向 beta 客户 → 逐步全量。
九·补 监控指标
PRD 建议上线后追踪以下业务指标,用于评估暂停功能对留存与收入的影响:
| 指标 | 定义 |
|---|---|
| Pause Rate | 暂停订阅占比 |
| Pause Duration | 暂停时长的均值与分布 |
| Resume Rate | 已暂停订阅中恢复的比例 |
| Churn After Pause | 恢复后 X 天内取消的比例 |
| Revenue Impact | 因暂停累计发放的贷记总额 |
十、未来方向
PRD 展望了 5 个演进方向,可作为后续产品迭代的输入:
- Partial Pauses:按行项目暂停而非整订阅暂停;
- Pause Tiers:不同档位的暂停选项(如带广告免费暂停 vs. 无广告付费暂停);
- Pause Suggestions:基于用量模式主动建议暂停;
- Pause Incentives:以激励促使客户提前恢复;
- Seasonal Pause Programs:为季节性业务提供预定义的暂停套餐。
十一、小结:PRD 到实现的对照
从 docs/prds/subscription_pause.md 到当前仓库源码,暂停功能的核心设计几乎完整落地,同时实现也对 PRD 做了若干务实修正:
- 状态枚举更完整:
PauseStatus增加了completed/cancelled两个终态(internal/types/subscription.go); - 字段类型更安全:暂停记录的
PauseMode/ResumeMode/PauseStatus均使用强类型枚举(internal/domain/subscription/pause.go); - 字段有取舍:实现版去掉了
Status、PauseDays裸字段,暂停时长由时间点推导; - 历史查询更直接:
GetPauseHistory演化为ListPauses,通过GET /subscriptions/{id}/pauses暴露(internal/api/v1/subscription_pause.go); - dry run 独立成方法:
CalculatePauseImpact/CalculateResumeImpact将预览与执行彻底解耦。
对于想深入研究的读者,建议按以下路径阅读源码:先看 PRD 了解需求全貌 → 阅读 internal/domain/subscription/pause.go 与 internal/domain/subscription/model.go 掌握数据模型 → 阅读 internal/api/dto/subscription_pause.go 理解 API 契约 → 阅读 internal/api/v1/subscription_pause.go 与 internal/ee/service/subscription.go 追踪请求处理链路 → 最后用 internal/ee/service/subscription_pause_test.go 验证行为。