《互联网产品的服务端架构·交易、增长与平台化》5.3 订阅与自动续费

用户只是不想下个月继续扣款,点了取消续费,会员权益却当场消失,剩余十八天也用不了。文章拆解取消、到期、续费失败与退款四种语义,说明续费基准为什么取 max(now, PeriodEnd),并给出 Renew 与 Cancel 的 Go 实现、边界测试与真实运行输出。

用户点了“取消续费”,会员立刻没了

「刚好」的月度会员支持自动续费。用户购买时勾选“到期自动续费”,系统在 PeriodEnd 当天扣下一期。产品上线三个月后,客服接到投诉:用户只是不想下个月继续扣款,点了“取消自动续费”,结果会员权益当场消失,剩余 18 天也用不了。

排查发现,Cancel 的实现是这样的:把 AutoRenew 置为 false,同时把 PeriodEnd 改成当前时间。研发的解释是“取消续费不就是取消会员吗”。但对用户来说,取消的是“下一期自动扣款”,不是“已经付过钱的这一期”。用户已经为剩余 18 天付了钱,平台单方面切断权益,既不合理,也可能违反消费者保护规则。

更隐蔽的问题在权益服务。权益记录的有效期是从订阅服务同步过来的,同步逻辑看到 AutoRenew == false 就认为“订阅已结束”,直接把权益标记为失效。两个服务各自都“没错”,合在一起却把一个未来意图当成了即时事实。

订阅的本质是周期性权益:用户买的是一个时间段,自动续费只是让下一个时间段自动开始。取消自动续费,只影响下一个时间段是否开始,不应该影响当前时间段。

取消、到期、续费失败、退款是四件事

订阅系统里最容易被合并的四个状态是:取消自动续费、自然到期、续费失败、退款终止。它们的触发方、对当前周期的影响和对用户的通知完全不同。

动作触发方AutoRenewPeriodEnd当前周期权益用户通知
取消自动续费用户主动置为 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 秒,展示时再转本地时区。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查