用户点了“取消续费”,会员立刻没了
「刚好」的月度会员支持自动续费。用户购买时勾选“到期自动续费”,系统在 PeriodEnd 当天扣下一期。产品上线三个月后,客服接到投诉:用户只是不想下个月继续扣款,点了“取消自动续费”,结果会员权益当场消失,剩余 18 天也用不了。
排查发现,Cancel 的实现是这样的:把 AutoRenew 置为 false,同时把 PeriodEnd 改成当前时间。研发的解释是“取消续费不就是取消会员吗”。但对用户来说,取消的是“下一期自动扣款”,不是“已经付过钱的这一期”。用户已经为剩余 18 天付了钱,平台单方面切断权益,既不合理,也可能违反消费者保护规则。
更隐蔽的问题在权益服务。权益记录的有效期是从订阅服务同步过来的,同步逻辑看到 AutoRenew == false 就认为“订阅已结束”,直接把权益标记为失效。两个服务各自都“没错”,合在一起却把一个未来意图当成了即时事实。
订阅的本质是周期性权益:用户买的是一个时间段,自动续费只是让下一个时间段自动开始。取消自动续费,只影响下一个时间段是否开始,不应该影响当前时间段。
取消、到期、续费失败、退款是四件事
订阅系统里最容易被合并的四个状态是:取消自动续费、自然到期、续费失败、退款终止。它们的触发方、对当前周期的影响和对用户的通知完全不同。
| 动作 | 触发方 | AutoRenew | PeriodEnd | 当前周期权益 | 用户通知 |
|---|---|---|---|---|---|
| 取消自动续费 | 用户主动 | 置为 false | 不变 | 继续到到期 | 明确告知可用到哪天 |
| 自然到期 | 时钟 | 不变或已关闭 | 已过 | 到期后失效 | 到期前提醒 |
| 提前续费 | 用户或系统 | 保持 true | 从原到期日顺延 | 不中断 | 告知新到期日 |
| 逾期续费 | 用户补缴 | 保持 true | 从当前时间起算 | 可能已中断 | 说明空档不补 |
| 续费失败 | 支付渠道 | 保持 true | 不变 | 进入重试或宽限期 | 多次提醒并给补救入口 |
| 退款终止 | 客服或用户 | 置为 false | 可立即结束 | 按退款规则处理 | 说明权益截止时间 |
关键判断是:AutoRenew 表示“下一期要不要自动扣款”,PeriodEnd 表示“这一期已经付到什么时候”。两个字段回答不同问题,不能用一个字段推导另一个。取消续费只改前者,退款才可能改后者。
续费的起算点也需要明确。常见公式是:
newPeriodEnd = max(now, PeriodEnd) + periodSeconds
如果无条件用 PeriodEnd + periodSeconds,用户逾期 30 天再续费,新周期会从 30 天前开始,等于刚付钱就过去了一个月。如果无条件用 now + periodSeconds,用户提前 10 天续费,剩余 10 天会被吞掉。取 max(now, PeriodEnd) 是折中:提前续费不损失剩余时间,逾期续费也不补空档。
续费失败不能等同于取消。支付渠道在凌晨扣款失败很常见:余额不足、银行风控、网络超时。正确做法是按计划重试,同时在到期前和失败后通知用户;重试全部失败后,再按规则降级。直接在第一笔失败时切断权益,会产生大量本可挽回的投诉。
失败场景:扣款失败一次,会员直接降级
「刚好」的续费任务每天凌晨两点跑。用户账单日是每月 15 日,某月 15 日凌晨,支付渠道返回“银行系统维护”。任务记录了一条失败日志,没有重试,也没有通知用户。16 日零点,权益服务发现 PeriodEnd 已过,把用户降回普通会员。
用户 16 日上午打开 App,发现自己买过的课程看不了,客服解释“您的自动续费扣款失败”。用户说:“没人告诉我扣款失败,而且我卡里有钱,你们可以再扣一次。”这一单最终退款并补偿,但用户已经流失。
这个场景里有四个独立缺陷:
第一,没有重试。支付失败分可重试和不可重试:网络超时、渠道维护可以重试;卡片注销、用户主动解约不应重试。重试要有次数上限和退避间隔,不能无限重投。
第二,没有通知。扣款前提醒、失败后通知、权益即将失效提醒是三个不同节点,不能合并成一条“续费失败”。用户需要知道“什么时候会再扣”“还能用到哪天”“怎么手动续费”。
第三,没有宽限期。到期即降级太激进,常见做法是给 1 到 7 天宽限期,期间权益保留但限制新增,或者保留核心权益、暂停增值权益。宽限期长度要和财务、法务一起定。
第四,续费不幂等。任务重跑或消息重投会调用两次 Renew,PeriodEnd 被顺延两个周期,用户白拿一个月。续费必须以“订阅 ID + 计费周期序号”作为幂等键,成功一次后后续调用直接返回同一结果。
还有一个并发问题:用户点取消的同时,续费任务正在执行。两个请求都读到 AutoRenew == true,续费先写回新的 PeriodEnd,取消后写回 AutoRenew == false,结果可能正确;但如果顺序反过来,续费会把已取消的订阅又续上一期。写回必须带版本号或条件更新,例如 WHERE auto_renew = true AND period_end = ?。
用 Renew 与 Cancel 收口续费和取消
续费是“基于当前状态计算下一个状态”的纯函数,取消是“只改一个字段”的纯函数。支付、重试、通知和幂等由调用方负责。
// file: internal/member/subscription.go
// tier: L1
// verify: go test ./internal/member/
package member
import (
"errors"
"fmt"
"math"
)
var (
// ErrInvalidPeriod 表示 PeriodEnd 或 periodSeconds 不是正数。
ErrInvalidPeriod = errors.New("member: invalid period")
// ErrAutoRenewOff 表示订阅已经关闭自动续费,不能再续期。
ErrAutoRenewOff = errors.New("member: auto renew off")
// ErrPeriodOverflow 表示续期后的到期时间会超出 int64 上限。
ErrPeriodOverflow = errors.New("member: period overflow")
)
// Sub 是一条订阅记录。PeriodEnd 是当前计费周期的结束时刻(Unix 秒),
// AutoRenew 表示用户是否授权下一周期自动扣费。
//
// PeriodEnd 只表示“已付费到什么时候”,不表示“权益什么时候被切断”。
// 自动续费失败、用户取消或退款时,权益是否立即失效由业务规则决定,
// 不应该靠修改 PeriodEnd 来暗示。
type Sub struct {
UserID string
PlanID string
PeriodEnd int64
AutoRenew bool
}
// Renew 计算续费后的订阅。now 是本次续费请求到达的时间,
// periodSeconds 是一个周期的长度。新的到期时间从 max(now, PeriodEnd)
// 起算:提前续费不会吞掉剩余天数,逾期续费也不会把过去的空档补回来。
//
// AutoRenew 为 false 时返回 ErrAutoRenewOff,原订阅不变;PeriodEnd 或
// periodSeconds 非正时返回 ErrInvalidPeriod;相加溢出返回
// ErrPeriodOverflow。成功时只修改 PeriodEnd,其余字段保持不变。
func Renew(s Sub, now, periodSeconds int64) (Sub, error) {
if !s.AutoRenew {
return s, fmt.Errorf("%w: user %s plan %s", ErrAutoRenewOff, s.UserID, s.PlanID)
}
if s.PeriodEnd <= 0 || periodSeconds <= 0 {
return s, fmt.Errorf("%w: end=%d period=%d", ErrInvalidPeriod, s.PeriodEnd, periodSeconds)
}
base := s.PeriodEnd
if now > base {
base = now
}
if base > math.MaxInt64-periodSeconds {
return s, fmt.Errorf("%w: base %d + %d", ErrPeriodOverflow, base, periodSeconds)
}
s.PeriodEnd = base + periodSeconds
return s, nil
}
// Cancel 关闭自动续费,返回修改后的副本。
//
// Cancel 不会清空 PeriodEnd:用户已经付过当前周期的钱,取消续费只是
// 不再扣下一期,当前周期应当继续有效到 PeriodEnd。是否立即退款、
// 是否立即收回权益是另外的业务动作,不能和“取消续费”混为一谈。
func Cancel(s Sub) Sub {
s.AutoRenew = false
return s
}
实现里的约束对应前面几个坑:
AutoRenew为false时直接返回ErrAutoRenewOff,不做任何修改。取消后的订阅不能被续费任务偷偷续上。PeriodEnd和periodSeconds必须为正。0 或负数通常意味着上游数据坏了,静默计算会产出一个过去的时间。- 基准取
max(now, PeriodEnd):提前续费从原到期日顺延,逾期续费从当前时间起算,两种场景都不会吞掉用户已付的时间。 - 相加前用
math.MaxInt64-periodSeconds判断溢出。到期时间溢出成负数后,权益会被判为永久有效或立即过期,两种都是事故。 Cancel只把AutoRenew置为false,保留PeriodEnd。它返回值副本,不修改入参,调用方必须把结果持久化。Renew成功时也只改PeriodEnd,UserID、PlanID、AutoRenew保持不变。这样一次续费不会意外打开或关闭自动扣款。
线上还需要一层幂等:同一个订阅的同一个计费周期只能成功续费一次。可以在续费记录表上建唯一索引 (sub_id, period_index),或者用支付单号作为幂等键。纯函数本身不解决重复调用。
测试:提前、逾期、边界与取消
续费的错误集中在起算点和状态组合:提前续费、逾期续费、刚好到期、关闭自动续费、非法周期、溢出,以及取消后又被续费。
// file: internal/member/subscription_test.go
// tier: L1
// verify: go test ./internal/member/
package member
import (
"errors"
"testing"
)
func TestRenewStartsFromPeriodEndWhenEarly(t *testing.T) {
s := Sub{UserID: "u1", PlanID: "monthly", PeriodEnd: 1000, AutoRenew: true}
got, err := Renew(s, 800, 3000)
if err != nil {
t.Fatalf("Renew = %v, want nil", err)
}
if got.PeriodEnd != 4000 {
t.Fatalf("PeriodEnd = %d, want 4000", got.PeriodEnd)
}
if s.PeriodEnd != 1000 {
t.Fatalf("input mutated: PeriodEnd = %d, want 1000", s.PeriodEnd)
}
}
func TestRenewStartsFromNowWhenLapsed(t *testing.T) {
s := Sub{UserID: "u1", PlanID: "monthly", PeriodEnd: 1000, AutoRenew: true}
got, err := Renew(s, 5000, 3000)
if err != nil {
t.Fatalf("Renew = %v, want nil", err)
}
if got.PeriodEnd != 8000 {
t.Fatalf("PeriodEnd = %d, want 8000", got.PeriodEnd)
}
}
func TestRenewAtBoundaryUsesPeriodEnd(t *testing.T) {
s := Sub{UserID: "u1", PlanID: "monthly", PeriodEnd: 1000, AutoRenew: true}
got, err := Renew(s, 1000, 3000)
if err != nil {
t.Fatalf("Renew = %v, want nil", err)
}
if got.PeriodEnd != 4000 {
t.Fatalf("PeriodEnd = %d, want 4000", got.PeriodEnd)
}
}
func TestRenewRejectsAutoRenewOff(t *testing.T) {
s := Sub{UserID: "u1", PlanID: "monthly", PeriodEnd: 1000}
got, err := Renew(s, 1000, 3000)
if !errors.Is(err, ErrAutoRenewOff) {
t.Fatalf("Renew = %v, want ErrAutoRenewOff", err)
}
if got != s {
t.Fatalf("got = %+v, want original %+v", got, s)
}
}
func TestRenewRejectsInvalidPeriod(t *testing.T) {
cases := []struct {
name string
sub Sub
now int64
sec int64
}{
{"zero-end", Sub{PeriodEnd: 0, AutoRenew: true}, 100, 1000},
{"negative-end", Sub{PeriodEnd: -1, AutoRenew: true}, 100, 1000},
{"zero-period", Sub{PeriodEnd: 100, AutoRenew: true}, 100, 0},
{"negative-period", Sub{PeriodEnd: 100, AutoRenew: true}, 100, -1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := Renew(c.sub, c.now, c.sec); !errors.Is(err, ErrInvalidPeriod) {
t.Fatalf("Renew = %v, want ErrInvalidPeriod", err)
}
})
}
}
func TestRenewDetectsOverflow(t *testing.T) {
max := int64(^uint64(0) >> 1)
s := Sub{UserID: "u1", PlanID: "monthly", PeriodEnd: max, AutoRenew: true}
if _, err := Renew(s, 0, 1); !errors.Is(err, ErrPeriodOverflow) {
t.Fatalf("Renew = %v, want ErrPeriodOverflow", err)
}
}
func TestCancelKeepsPeriodEnd(t *testing.T) {
s := Sub{UserID: "u1", PlanID: "monthly", PeriodEnd: 1000, AutoRenew: true}
got := Cancel(s)
if got.AutoRenew {
t.Fatal("AutoRenew should be false after Cancel")
}
if got.PeriodEnd != 1000 {
t.Fatalf("PeriodEnd = %d, want 1000", got.PeriodEnd)
}
if !s.AutoRenew {
t.Fatal("Cancel must not mutate the input")
}
if _, err := Renew(got, 1000, 3000); !errors.Is(err, ErrAutoRenewOff) {
t.Fatalf("Renew after Cancel = %v, want ErrAutoRenewOff", err)
}
}
TestRenewStartsFromPeriodEndWhenEarly 在到期前 200 秒续费,确认新到期日从原 PeriodEnd 顺延,而不是从 now 起算,同时断言入参没有被修改。TestRenewStartsFromNowWhenLapsed 在到期后 4000 秒续费,确认从 now 起算,过去的空档不补。
TestRenewAtBoundaryUsesPeriodEnd 让 now == PeriodEnd,确认两种起算方式结果一致,边界不会出现双重标准。TestRenewRejectsAutoRenewOff 确认关闭自动续费后调用续费会报错,且原订阅不变。TestRenewRejectsInvalidPeriod 覆盖零和负的到期时间、零和负的周期长度。
TestRenewDetectsOverflow 把 PeriodEnd 设成 MaxInt64 再加一个周期,确认返回溢出错误,而不是得到一个负数。TestCancelKeepsPeriodEnd 验证取消后 AutoRenew 为 false、PeriodEnd 保持不变、入参不被修改,并且再次调用 Renew 会失败。
这些用例不覆盖支付失败重试和通知,那需要任务调度、消息队列和渠道 mock 的集成测试。纯函数只保证状态计算正确。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/member/ -run 'TestRenew|TestCancel' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRenewStartsFromPeriodEndWhenEarly
--- PASS: TestRenewStartsFromPeriodEndWhenEarly (0.00s)
=== RUN TestRenewStartsFromNowWhenLapsed
--- PASS: TestRenewStartsFromNowWhenLapsed (0.00s)
=== RUN TestRenewAtBoundaryUsesPeriodEnd
--- PASS: TestRenewAtBoundaryUsesPeriodEnd (0.00s)
=== RUN TestRenewRejectsAutoRenewOff
--- PASS: TestRenewRejectsAutoRenewOff (0.00s)
=== RUN TestRenewRejectsInvalidPeriod
=== RUN TestRenewRejectsInvalidPeriod/zero-end
=== RUN TestRenewRejectsInvalidPeriod/negative-end
=== RUN TestRenewRejectsInvalidPeriod/zero-period
=== RUN TestRenewRejectsInvalidPeriod/negative-period
--- PASS: TestRenewRejectsInvalidPeriod (0.00s)
--- PASS: TestRenewRejectsInvalidPeriod/zero-end (0.00s)
--- PASS: TestRenewRejectsInvalidPeriod/negative-end (0.00s)
--- PASS: TestRenewRejectsInvalidPeriod/zero-period (0.00s)
--- PASS: TestRenewRejectsInvalidPeriod/negative-period (0.00s)
=== RUN TestRenewDetectsOverflow
--- PASS: TestRenewDetectsOverflow (0.00s)
=== RUN TestCancelKeepsPeriodEnd
--- PASS: TestCancelKeepsPeriodEnd (0.00s)
PASS
ok webapp/internal/member 0.310s
PASS 表示 5.3 的 7 个顶层用例和 4 个子用例全部通过。它们验证续费起算、状态约束和取消语义,不验证支付渠道行为和消息可靠性。
小结
订阅是周期性权益。AutoRenew 决定下一期是否自动扣款,PeriodEnd 决定这一期付到什么时候,两个字段不能互相推导。取消续费只关自动扣款,当前周期继续有效;退款才可能提前终止权益。
Renew 从 max(now, PeriodEnd) 起算,拒绝关闭自动续费和非法周期,并在相加前检查溢出;Cancel 返回只改 AutoRenew 的副本,保留 PeriodEnd。续费任务再补幂等键、重试、宽限期和通知,才能既守住收入,也不误伤已经付过钱的用户。
阅读导航:上一节:5.2 权益发放与回收 · 下一节:6.1 活动模型与库存预热 。
常见错误与边界
- 把取消续费当成取消会员。用户已付的当前周期必须继续有效,
PeriodEnd不能改。 - 用
AutoRenew推导权益是否有效。权益有效期看PeriodEnd,自动续费只是未来意图。 - 续费无条件从
PeriodEnd起算。逾期用户会刚付钱就损失一个周期,应取max(now, PeriodEnd)。 - 续费无条件从
now起算。提前续费会吞掉剩余天数,用户会觉得被占便宜。 - 扣款失败一次就降级。要有可重试判断、退避重试、宽限期和至少一次用户通知。
- 续费任务没有幂等键。任务重跑或消息重投会把到期时间顺延两次。
- 取消和续费并发写回。必须用版本号或条件更新,否则已取消的订阅可能被续上一期。
- 到期时间用本地时区计算。存储和比较统一用 Unix 秒,展示时再转本地时区。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。