《互联网产品的服务端架构·交易、增长与平台化》5.2 权益发放与回收

退款只退了钱,会员折扣却还在:权益发放和回收如果没有幂等键、原因码和审计,财务、客服与审计都说不清。文章区分发放、到期、回收三种语义,说明退款后如何回收权益,并给出 Active 与 Revoke 的 Go 实现、不可变切片测试与真实运行输出。

退款成功了,会员价还在

「刚好」的会员权益里有一项“全场九折”。权益不是跟着等级自动算的,而是用户购买会员卡后单独发放一条记录,到期时间就是会员卡到期时间。

有一次大促,用户下单后发现买错规格,申请退款。订单退款流程走得很顺:支付网关原路退回,订单状态改成已退款,库存回补。但会员价的差额没有追回——因为下单时系统按九折计价,退款时只退了实付金额;更关键的是,用户购买的会员卡对应的“九折权益”仍然有效,他继续用会员价买别的商品。

财务月底对账才发现,退款订单里有一批用户既拿回了钱,又保留了会员价能力。运营问研发:退款不是应该把权益一起回收吗?研发说,退款流程只处理订单和支付,权益是另一个服务,没接。

这个回答暴露了两个问题:第一,权益的生命周期没有和资金来源绑定;第二,系统里只有“发放”,没有“回收”这个一等公民。很多团队做会员时只想着怎么发权益,等到退款、风控、用户注销时才发现,收回一条权益比发出去难得多。

权益不是一个孤立对象,它总要回答“为什么给这个人”。来源可能是订单、订阅、等级、活动或人工补偿。发放时不记录来源,回收时就只能按“用户 + 权益名”模糊匹配,既可能漏掉,也可能误伤。来源不仅是审计字段,也是回收的定位条件。

从架构上看,权益服务应该对外提供三个明确动作:发放、查询是否有效、回收。发放和回收都接受幂等键与来源信息,查询只读。不要把“修改到期时间”暴露成通用接口,否则调用方会用改时间来模拟续期、暂停、回收,最终没人说得清一条记录到底经历了什么。

回收还有传播问题。下单链路为了性能,通常会在网关或商品服务里缓存“该用户是否会员”几分钟;权益服务里记录已经删了,缓存还在,用户仍能按会员价下单。回收接口成功返回后,必须同时失效相关缓存,或者让缓存 key 带上权益版本号,版本一变旧缓存自然不再命中。只改数据库不改缓存,是退款后权益“看起来还在”的另一个常见原因。

查询接口也要区分“当前是否有效”和“历史是否发放过”。客服处理投诉时,前者回答“现在能不能用”,后者回答“这个权益曾经有没有给过、什么时候没的”。两个问题对应两种查询,不能用一个 Active 函数包打天下。

发放、到期、回收不是一回事

权益的结束方式至少有三种,它们的语义、触发方和审计要求完全不同。把它们都叫“失效”,会导致对账、客服和风控全部说不清。

动作语义触发方是否可逆审计要求实现要点
发放用户获得某项能力购买、活动、等级映射、人工补偿通常可再发记录来源与幂等键同一来源重复调用不能产生多条
到期时间自然走到有效期之外时钟可通过续期延长记录有效期判断用 now < ExpiresAt
回收主动撤销尚未到期的权益退款、风控、注销、运营可重新发放必须记录原因、操作人、关联单号返回新记录,不篡改历史
暂停临时冻结,保留恢复可能风控、欠款可恢复记录暂停原因与回收分开建模
续期延长到期时间续费、补偿可再次延长记录延长前后时间从 max(now, 原到期) 起算

“到期”是被动发生的,不需要有人做决定;“回收”是主动决策,必须有人负责。把回收做成“把到期时间改成昨天”,短期看代码最少,长期看会丢失原因:三个月后客服问“这个用户的权益为什么没了”,数据库里只有一条被改过的到期时间,没有任何解释。

发放必须幂等。支付回调、消息重投、用户重复点击都会让同一个发放请求到达多次。幂等键通常由“业务来源 + 业务单号 + 权益名称”组成,例如 order:20261029001:member_discount。没有幂等键,重试就会产生重复权益;如果权益支持叠加,重复发放会直接放大成本。

回收也要幂等。同一笔退款可能触发多次回收消息,第一次移除记录,第二次找不到目标应当视为成功,而不是报错。Revoke 对未知目标直接忽略,就是为了让重试安全。

权益还要按来源分层,因为“能不能回收”取决于来源,而不是权益名称:

  • 交易类权益来自某笔订单或订阅,退款时可以精确回收到那一笔,不影响用户通过其他订单获得的同名权益。
  • 等级类权益由成长值实时推导,不应该单独发放,也不应该被单笔退款回收;等级下降时它自然消失。
  • 活动类权益有独立的活动规则和有效期,回收要遵循活动协议,不能套用退款逻辑。
  • 补偿类权益是客服或运营人工发放的,回收需要工单号和审批人,不能由自动化任务随意删除。

分层的意义在于回收范围。用户买了两张会员卡,第一张退款,只应回收第一张对应的权益;如果记录里只有 user_id + name,没有来源单号,系统只能把同名权益全部删掉,第二张卡也跟着失效。发放时写入 source_type 和 source_id,回收时按来源精确匹配,才能避免误伤。

同名权益是否允许叠加也要提前定义。有的产品允许多张卡叠加延长有效期,有的只保留到期时间最晚的一条。前者需要保留多条记录并按时间合并,后者需要发放时做“取最大到期时间”的合并。两种模型对回收的影响不同:叠加模型可以精确移除一张,合并模型一旦合并就丢失了来源,回收时只能整条撤销。没有想清楚之前,不要急着把多条记录压成一条。

幂等键的粒度决定了重试会不会误伤。只用用户 ID 做键,会把“用户第二次购买”当成重复请求;只用订单号做键,同一订单赠送多项权益时又会互相覆盖。通常用“来源类型 + 来源单号 + 权益名称”三段式,例如 order:20261029001:member_discount,既能让同一笔订单的重投命中同一条记录,也能让同一订单的不同权益各自独立。

幂等键还要落成数据库唯一约束,而不是只在应用层查一次。两个并发请求同时查不到记录、同时插入,应用层判断会双双通过;唯一索引才能保证最终只有一条。捕获唯一键冲突后返回已存在的记录,而不是把错误抛给调用方。这样支付回调重投时,发放接口表现为“成功且未产生新记录”。

失败场景:退款只退了钱,权益没回收

回到开头。订单退款和权益回收是两个服务,退款流程没有发回收消息,权益服务自然不知道要做什么。用户继续享受九折,直到会员卡自然到期。假设这批退款订单有 200 笔,平均每笔后续又下了 3 单、每单便宜 15 元,一个月就多让利约 9000 元。这是案例假设值,用于说明量级,不是线上实测。

即使接上了回收消息,还有四个坑:

第一,回收没有原因码。权益记录被删掉后,客服只能看到“没有这条权益”,无法区分是退款回收、风控回收还是用户自己注销。建议保留一条不可变的权益流水,记录 grant、revoke、expire 事件,当前是否有效由流水推导,或由流水维护一张可重建的快照。

第二,回收把历史记录改坏了。有的实现直接在原记录上把 ExpiresAt 改成当前时间,导致审计日志和用户账单对不上。正确做法是新增一条回收事件,或者从有效集合中移除并保留原始发放记录。

第三,过期和回收混用。运营报表统计“本月回收权益数”时,把自然到期的也算进去,数字会虚高;反过来,风控回收如果只改到期时间,报表又看不到。两个口径必须分开。

第四,时间边界不一致。Active 用 now < ExpiresAt,回收任务却用 now <= ExpiresAt,同一时刻一个说有效、一个说过期,跨系统对账时会出现“已过期但还能用”的窗口。边界只能有一个定义。

第五,回收没有考虑已经消费的权益。用户用九折权益下过三单,退款的是会员卡,不是那三单。回收权益只能阻止未来继续使用,已经产生的优惠通常不追回;如果业务要求追回,必须走单独的追偿流程,而不是删一条记录假装钱回来了。回收动作和资金追偿是两件事,不能混在一个数据库事务里。

第六,回收消息和发放消息乱序。发放消息因为重试晚到,在回收之后才被消费,权益又出现了。解决办法不是“保证消息不重试”,而是给每条权益事件带上单调递增的版本或时间戳,消费端只接受比当前状态更新的事件;或者用业务状态机,已回收的来源不允许再次发放。

这类问题不应该只靠事后客服发现。财务每天可以把“已退款订单”和“仍有效的权益”做一次对账:按退款单号反查权益来源,如果权益还在有效集合里,就生成一条待处理任务。对账不需要实时,但必须可重复执行,且输出差异明细。很多遗漏不是因为没有回收能力,而是因为没有人定期问“还有哪些该回收却没回收”。

用 Active 与 Revoke 收口判断和回收

权益判断应该是纯函数:给定一条权益和当前时间,返回是否有效。回收也应该是纯函数:给定一组权益和目标,返回移除后的新切片。存储、消息和审计由调用方负责。

// file: internal/member/entitlement.go
// tier: L1
// verify: go test ./internal/member/

package member

// Grant 是一条权益记录。UserID 是权益持有人,Name 是权益名称,例如
// "free_shipping" 或 "video_1080p"。ExpiresAt 是 Unix 秒;0 表示永久
// 有效,非 0 表示在该时刻到期。
//
// 同一用户同一权益通常只保留一条记录。发放与回收都应该带幂等键,
// 具体存储由调用方负责;本文件只定义判断和过滤规则。
type Grant struct {
	UserID    string
	Name      string
	ExpiresAt int64
}

// Active 判断权益在 now 时刻是否仍然有效。
//
// ExpiresAt 为 0 表示永久有效;否则只有 now < ExpiresAt 才有效。
// now 等于 ExpiresAt 时已经过期,这样“到期时刻”和“失效时刻”是同一个
// 点,不会多给一秒。now 一般传当前时间,测试里传固定值。
func Active(g Grant, now int64) bool {
	if g.ExpiresAt == 0 {
		return true
	}
	return now < g.ExpiresAt
}

// Revoke 从 grants 中移除指定用户在指定权益上的记录,返回一个新切片。
//
// 入参不会被修改,返回的切片与原切片不共享底层数组,调用方可以安全地
// 继续持有旧快照。没有匹配记录时返回一个等价的副本,而不是 nil,
// 这样调用方不需要区分“没有权益”和“回收失败”。重复记录会全部移除。
//
// Revoke 只表达“这条权益不该再生效”,不解释原因。线上应该在调用前
// 写入审计记录,说明是退款、风控、用户注销还是运营调整;否则事后无法
// 回答“为什么这个用户的权益没了”。
func Revoke(grants []Grant, userID, name string) []Grant {
	out := make([]Grant, 0, len(grants))
	for _, g := range grants {
		if g.UserID == userID && g.Name == name {
			continue
		}
		out = append(out, g)
	}
	return out
}

实现很短,但每个选择都有原因:

  • ExpiresAt == 0 表示永久有效。用 0 而不是很大的整数,是为了让“永久”在数据库里可读、可索引,也不会在几十年后突然到期。
  • 判断用 now < ExpiresAt,所以 now == ExpiresAt 时已经过期。到期时刻和失效时刻是同一个点,不会多给一秒。
  • Revoke 用 make 新建切片,逐个追加不匹配的记录。入参不会被修改,返回的切片也不和原切片共享底层数组,调用方可以安全地持有旧快照做对比或审计。
  • 未知目标返回等价副本而不是 nil。重试回收时,调用方不需要区分“第一次成功”和“第二次找不到”,两种都算成功。
  • 重复记录会全部移除。历史上如果因为缺幂等键发重了,回收时应该一次清干净,而不是只删第一条。
  • 时间字段用 Unix 秒而不是 time.Time。秒是整数,跨服务传输和数据库比较都不会带时区歧义;展示层再按用户时区格式化。

存储上推荐“事件流水 + 可重建快照”的组合。每次发放、回收、到期都写一条不可变事件,事件包含 source_type、source_id、reason、operator 和发生时间;当前有效集合可以落一张快照表加速查询,但快照必须能由流水重放出来。这样即使 Revoke 的调用方漏记了原因,流水里仍然保留了发放和移除的时间线,至少能还原发生了什么。

如果只保留当前状态,回收就变成了“删除一行”。删除之后,用户的历史账单、客服工单和财务对账都失去了依据。很多团队在事故后才补 deleted_at 字段,但那时已经无法区分自然到期、主动回收和数据误删。事件模型不是过度设计,而是权益这种“有资金属性”的数据的最低审计要求。

回收接口的返回值也要克制。Revoke 不返回错误,就无法区分“成功移除”和“目标不存在”,调用方只能把两种都当成功;它也不返回被移除的记录,调用方如果想记审计,需要自己先查一遍。更好的接口通常是返回 (kept []Grant, removed []Grant),让审计日志直接拿到被移除的记录和数量。本文保留单返回值,是为了突出“重试友好”这个核心语义;生产实现可以在外层包一层,把移除结果和原因码一起写入流水。

Revoke 不接收原因参数,是因为它只负责集合运算。原因、操作人、关联退款单号应该由调用方写入审计流水,再把需要移除的目标交给 Revoke。这样纯函数保持简单,审计也不会因为换存储而丢失。

测试:有效期边界、不可变与未知目标

权益的 bug 很少出在“正常发放”,更多出在边界和重试:刚好到期、永久有效、回收不存在的东西、回收后旧切片还能不能用。

// file: internal/member/entitlement_test.go
// tier: L1
// verify: go test ./internal/member/

package member

import (
	"reflect"
	"testing"
)

func TestActivePermanentAndTimed(t *testing.T) {
	permanent := Grant{UserID: "u1", Name: "free_shipping", ExpiresAt: 0}
	if !Active(permanent, 1<<40) {
		t.Fatal("permanent grant should stay active")
	}
	timed := Grant{UserID: "u1", Name: "video_1080p", ExpiresAt: 1000}
	if !Active(timed, 999) {
		t.Fatal("grant should be active before ExpiresAt")
	}
	if Active(timed, 1000) {
		t.Fatal("grant must expire exactly at ExpiresAt")
	}
	if Active(timed, 1001) {
		t.Fatal("grant must stay expired after ExpiresAt")
	}
}

func TestRevokeRemovesOnlyTarget(t *testing.T) {
	grants := []Grant{
		{UserID: "u1", Name: "free_shipping", ExpiresAt: 100},
		{UserID: "u1", Name: "video_1080p", ExpiresAt: 0},
		{UserID: "u2", Name: "free_shipping", ExpiresAt: 0},
	}
	got := Revoke(grants, "u1", "free_shipping")
	want := []Grant{
		{UserID: "u1", Name: "video_1080p", ExpiresAt: 0},
		{UserID: "u2", Name: "free_shipping", ExpiresAt: 0},
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Revoke = %+v, want %+v", got, want)
	}
}

func TestRevokeDoesNotMutateInput(t *testing.T) {
	grants := []Grant{
		{UserID: "u1", Name: "free_shipping", ExpiresAt: 100},
		{UserID: "u1", Name: "video_1080p", ExpiresAt: 0},
	}
	before := append([]Grant(nil), grants...)
	got := Revoke(grants, "u1", "free_shipping")
	if !reflect.DeepEqual(grants, before) {
		t.Fatalf("input mutated: %+v, want %+v", grants, before)
	}
	got[0].Name = "tampered"
	if !reflect.DeepEqual(grants, before) {
		t.Fatalf("returned slice shares storage with input: %+v", grants)
	}
}

func TestRevokeUnknownTargetReturnsCopy(t *testing.T) {
	grants := []Grant{{UserID: "u1", Name: "free_shipping", ExpiresAt: 0}}
	got := Revoke(grants, "u2", "free_shipping")
	if got == nil {
		t.Fatal("Revoke returned nil, want a copy")
	}
	if !reflect.DeepEqual(got, grants) {
		t.Fatalf("Revoke = %+v, want %+v", got, grants)
	}
	if len(Revoke(nil, "u1", "free_shipping")) != 0 {
		t.Fatal("Revoke(nil) should return an empty slice")
	}
}

func TestRevokeRemovesDuplicateRecords(t *testing.T) {
	grants := []Grant{
		{UserID: "u1", Name: "free_shipping", ExpiresAt: 0},
		{UserID: "u1", Name: "free_shipping", ExpiresAt: 200},
		{UserID: "u1", Name: "video_1080p", ExpiresAt: 0},
	}
	got := Revoke(grants, "u1", "free_shipping")
	if len(got) != 1 || got[0].Name != "video_1080p" {
		t.Fatalf("Revoke = %+v, want only video_1080p", got)
	}
}

TestActivePermanentAndTimed 把永久权益放到很远的未来仍然是有效,再验证 ExpiresAt 前一秒有效、等于时失效、之后持续失效。TestRevokeRemovesOnlyTarget 用三条记录确认只移除指定用户和指定权益,其他用户、其他权益以及顺序都保持不变。

TestRevokeDoesNotMutateInput 先拷贝入参,回收后逐项比较;再把返回切片的第一项改掉,确认原切片不受影响。这一条专门防止“原地删除”或“共享底层数组”的实现。TestRevokeUnknownTargetReturnsCopy 验证未知目标返回非 nil 的等价副本,并对 nil 输入返回空切片。TestRevokeRemovesDuplicateRecords 覆盖历史重复发放,确认一次回收全部移除。

这些测试不覆盖“到期任务是否跑成功”和“回收消息是否送达”,那属于调度与消息可靠性,需要集成测试和补偿任务。

还可以补几条性质测试:任意一组权益经过 Revoke 后,长度只减不增;被移除的记录不再出现在结果里;未被指定的记录保持原有相对顺序;对同一个目标重复调用结果不变。这些性质比逐个用例更难写错,也更容易在实现重构时继续成立。对于时间判断,可以随机生成 now 与 ExpiresAt,断言 Active 的结果与 now < ExpiresAt 完全一致,防止有人后来把边界改成 <=。

测试里不要调用 time.Now()。Active 和 Revoke 都显式接收时间或纯数据,测试可以固定 now,结果完全可重复。生产代码在调用前取一次时间,并把这个值传给同一次请求里的所有判断,避免同一请求里前半段有效、后半段过期的诡异现象。时间作为参数传入,也让跨时区和闰秒问题被隔离在边界层,而不是散落在业务逻辑里。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/member/ -run 'TestActive|TestRevoke' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestActivePermanentAndTimed
--- PASS: TestActivePermanentAndTimed (0.00s)
=== RUN   TestRevokeRemovesOnlyTarget
--- PASS: TestRevokeRemovesOnlyTarget (0.00s)
=== RUN   TestRevokeDoesNotMutateInput
--- PASS: TestRevokeDoesNotMutateInput (0.00s)
=== RUN   TestRevokeUnknownTargetReturnsCopy
--- PASS: TestRevokeUnknownTargetReturnsCopy (0.00s)
=== RUN   TestRevokeRemovesDuplicateRecords
--- PASS: TestRevokeRemovesDuplicateRecords (0.00s)
PASS
ok  	webapp/internal/member	0.333s

PASS 表示 5.2 的 5 个用例全部通过。它们验证单条判断和切片回收的语义,不验证发放幂等;发放幂等需要唯一索引或幂等表,属于存储层约束。

小结

权益的结束方式要拆开发放、到期、回收和暂停。到期是时钟驱动的被动结果,回收是有人负责的主动决策,两者不能共用一套统计口径,也不能靠修改到期时间来偷懒。

Active 把有效期判断收成一个纯函数,永久用 0 表示,边界取 now < ExpiresAt;Revoke 返回不共享底层数组的新切片,不改入参,未知目标视为成功,重复记录一次清干净。发放侧再补幂等键和不可变流水,退款、风控和注销才能真正闭环。

阅读导航:上一节:5.1 会员等级与成长值 · 下一节:5.3 订阅与自动续费 。

常见错误与边界

  • 只做发放不做回收。退款、风控、注销都会要求收回权益,事后补回收比一开始就设计难得多。
  • 发放没有幂等键。支付回调重投或用户重复点击会产生多条权益,成本直接翻倍。
  • 用修改到期时间代替回收。原因、操作人和关联单号全部丢失,客服和审计无法复盘。
  • 过期和回收用同一个统计口径。“本月失效数”会把自然到期算成主动回收,报表失真。
  • 时间边界不统一。判断、任务和报表必须都用 now < ExpiresAt,否则同一时刻会出现两种结论。
  • 回收直接原地修改切片。并发读到的旧快照会被篡改,审计对比也失去意义。
  • 永久权益用 MaxInt64 表示。可读性差,还可能在时间换算时溢出,0 更明确。
  • 回收失败就报错。重试场景下“目标不存在”应当视为成功,否则消息会无限重投。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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