注册就发二十元,三天预算见底
「刚好」做过一次注册拉新:新用户完成手机号验证就送 20 元券,预算 30 万元,计划跑一个月。运营的判断是注册有成本,刷子不会为 20 元养一批手机号。这些数字是案例假设值,用于说明结构,不是线上实测。
活动第三天,预算消耗超过 18 万元,新增用户数却只比平时多了两成。风控抽样发现,一批账号的注册时间集中在凌晨两点到四点,手机号段连续,设备指纹高度重复:同一台设备上登录过一百多个账号,提现时又集中到少数几个支付账户。
这些账号每一步都符合规则:手机号是真的,验证码能收到,注册接口返回成功,奖励接口也只给每个账号发了一次。问题不在单次发放,而在发放的时机和维度:奖励在注册完成时就发了,而注册这个动作本身太便宜。
复盘时团队算了一笔账:一个接码手机号的成本假设是 0.5 元,一份奖励 20 元,ROI 是 40 倍。只要奖励发生在「用户还没有付出任何真实成本」之前,批量注册就必然发生。反作弊不是加一个风险分,而是先让奖励的对价成立。
奖励发放的三条不变量
奖励系统可以很复杂,但底层必须守住三条不变量:同一笔奖励只发一次;同一用户累计不超过上限;同一活动累计不超过预算。三条都成立,最坏情况是预算被合规用户领完;任意一条失守,预算就可能被重复发放或超支。
| 不变量 | 约束键 | 解决的问题 | 缺失后的后果 |
|---|---|---|---|
| 幂等 | 奖励请求 Key | 重试、回调重复、任务重跑 | 同一笔奖励入账多次 |
| 用户上限 | UserID | 单人占用过多预算 | 少数账号吃掉大部分奖励 |
| 活动预算 | CampaignID | 整体超支 | 财务上限被突破 |
| 设备上限 | DeviceID | 批量注册、多开 | 一台设备领走上百份 |
| 支付工具上限 | PayAccount | 集中提现、套现 | 奖励快速变成现金 |
幂等键要由业务事件生成,而不是由服务端随机生成。订单号、支付单号、活动 ID 加用户 ID,都是稳定的选择;每次请求新生成一个 UUID,等于没有幂等。Key 的粒度也要想清楚:是「一笔订单一次奖励」,还是「一个用户在一个活动里一次奖励」,两者对应的键完全不同。
用户上限和活动预算是两个维度,不能合并。只做用户上限,一万个账号每人领一份,总预算仍然会被领空;只做活动预算,少数账号可以在预算耗尽前反复领。正确顺序是先做用户维度的判断,再原子占用活动预算,两层都通过才发放。
上限要按用户与活动两个维度
用户维度回答「这个人最多拿多少」,活动维度回答「这次活动最多发多少」。前者是规则,后者是财务约束,生命周期也不同:用户上限跟着活动规则走,活动预算跟着财务审批走。
用户上限通常按活动周期累计,而不是按自然日。一个为期 30 天的活动,用户上限 100 元,意味着 30 天内累计不超过 100 元;如果按日重置,用户每天都能领,30 天就是 3000 元。规则文档必须写清楚窗口,代码里的键也要带上活动 ID,否则跨活动的累计会互相污染。
活动预算要单独做原子占用。常见做法是数据库条件更新 UPDATE campaign SET used = used + ? WHERE used + ? <= budget,或者 Redis 的原子脚本。不能先查余量再写回,两个并发请求会同时看到「还剩 20 元」,然后各发一份。
设备维度和支付工具维度不是预算约束,而是风险约束。它们更适合做软限制:同一设备超过 N 个账号领奖时进入人工审核,同一支付账户绑定过多账号时延迟到账。硬拒绝会误伤家庭共享设备和正常的多账号用户,软限制则把攻击成本抬高的同时保留申诉通道。
反作弊看设备与支付
账号维度最容易被绕过。接码平台让手机号验证失去区分度,批量注册的账号在字段上看起来和真人没有区别。账号维度的价值在于历史行为:注册时长、首单时间、退款率、收货地址稳定性,这些信号比「是否新注册」更有用。
设备维度比账号难伪造。设备指纹可以识别改机、模拟器、多开和群控,但也会误伤。同一台手机上的家人账号、同一台电脑上的多个浏览器,都是正常场景。设备维度应该记录「一台设备关联了多少个领奖账号」,超过阈值时降权或转人工,而不是直接封禁。
支付工具维度最接近资金侧。奖励最终要提现或消费,同一张银行卡、同一个支付账户、同一组收货地址,往往能把换账号、换设备的攻击串起来。支付工具涉及隐私和合规,收集范围要最小化,判定也要可解释:是「共用一张卡」还是「短时间内集中提现」,两者的处理不同。
行为维度是第四层信号:注册时间是否集中在脚本可预测的时段、请求间隔是否过于均匀、领奖后是否立即卸载或不再活跃、邀请关系是否集中在少数设备上。行为信号不一定要实时拦截,可以先打标、限流、进人工审核,把处置权和证据一起留给运营。
失败场景:注册即发,批量注册领完就跑
回到开头的活动。奖励触发点是「注册成功」,发奖接口做了幂等,每个账号确实只领了一次。问题在于注册这个动作没有对价:接码平台批量收验证码,脚本批量提交,一台设备可以轮换登录上百个账号。
第一个后果是预算被低质账号吃掉。真实用户还没看到活动,预算已经消耗大半;后续投放带来的用户反而领不到奖励,活动口碑受损。第二个后果是数据失真:注册转化率很好看,7 日留存和首单率没有变化,团队却按注册数追加了下一轮预算。
第三个后果是提现侧的集中风险。奖励以券或余额形式发放,刷子通过少数支付账户集中套现;如果没有支付工具维度的限制,资金侧看到的是多笔正常提现,直到渠道风控找上门才发现异常。
修复不是把幂等做得更复杂,而是改奖励的对价和维度:把触发点从「注册」后移到「首单完成」或「7 日内留存」;给设备维度和支付工具维度加软限制;活动预算按日切片,避免第一天被领空;对凌晨集中注册、连续号段、异常设备关联的账号进入人工审核。幂等仍然要做,但它只解决重复发放,解决不了「每个账号都合规地领一次」。
用 Grant 收口幂等与用户上限
Grant 把「这个 Key 是否发过」和「这个用户累计是否超限」收成一个纯函数。账本 seen 由调用方持有,函数只负责判断和写入,存储层的原子性由数据库或 Redis 保证。
// file: internal/growth/reward.go
// tier: L1
// verify: go test ./internal/growth/
package growth
import (
"errors"
"fmt"
)
var (
// ErrInvalidReward 表示奖励请求本身不合法。
ErrInvalidReward = errors.New("growth: invalid reward")
// ErrRewardState 表示传入的发放账本已经损坏。
ErrRewardState = errors.New("growth: invalid reward state")
)
// seen 里保存两类记录:幂等键和用户累计。前缀加 NUL 是为了让两类键
// 不可能与业务传入的 Key、UserID 冲突。
const (
rewardKeyPrefix = "key\x00"
rewardUserPrefix = "user\x00"
)
// Reward 是一笔待发放的奖励。Key 是调用方生成的幂等键,同一个 Key
// 无论重试多少次都只能发放一次;AmountCents 是最小货币单位(分)。
type Reward struct {
Key string
UserID string
AmountCents int64
Reason string
}
// Grant 尝试发放一笔奖励,返回是否真正发放。
//
// seen 同时承担幂等表和用户累计账本:
//
// key\x00<Key> -> 该 Key 已发放的金额
// user\x00<UserID> -> 该用户累计已发放的金额
//
// 重复的 Key 返回 false 和 nil;用户累计加上本次金额超过 capCents
// 也返回 false 和 nil。两种拒绝都不会写入 seen,调用方可以安全重试。
// capCents 为 0 表示该用户一分钱都不能发;负数视为调用错误。
func Grant(seen map[string]int64, r Reward, capCents int64) (bool, error) {
if seen == nil {
return false, fmt.Errorf("%w: nil seen map", ErrInvalidReward)
}
if r.Key == "" || r.UserID == "" {
return false, fmt.Errorf("%w: empty key or user", ErrInvalidReward)
}
if r.AmountCents <= 0 {
return false, fmt.Errorf("%w: amount %d", ErrInvalidReward, r.AmountCents)
}
if capCents < 0 {
return false, fmt.Errorf("%w: cap %d", ErrInvalidReward, capCents)
}
key := rewardKeyPrefix + r.Key
if _, ok := seen[key]; ok {
return false, nil
}
userKey := rewardUserPrefix + r.UserID
total, ok := seen[userKey]
if !ok {
total = 0
}
if total < 0 || total > capCents {
return false, fmt.Errorf("%w: user %s total %d cap %d", ErrRewardState, r.UserID, total, capCents)
}
// 先确认 total <= capCents,再做减法,避免 total+amount 溢出。
if r.AmountCents > capCents-total {
return false, nil
}
seen[key] = r.AmountCents
seen[userKey] = total + r.AmountCents
return true, nil
}
seen 里用两个前缀区分两类记录:key 前缀保存幂等键对应的金额,user 前缀保存用户累计金额。前缀里带 NUL,业务传入的 Key 和 UserID 不可能与内部键冲突。
Grant 先校验参数,再查幂等键。重复的 Key 直接返回 false 和 nil,不报错,因为重试是正常行为。用户累计超过 capCents 时也返回 false 和 nil;capCents 为 0 表示该用户一分钱都不能发,负数视为调用错误。两种拒绝都不写 seen,调用方可以安全重试,也可以把拒绝原因记到日志里。
金额判断用 r.AmountCents > capCents-total,而不是 total+r.AmountCents > capCents。后者在 total 和 amount 都很大时可能溢出;前者先确认 total <= capCents,再做减法,不会溢出。
这个函数只覆盖用户维度。活动预算要在调用 Grant 之前原子占用,占用成功后再调用;如果 Grant 返回 false,要把预算释放掉。顺序反了会出现「预算占了但奖励没发」的悬挂,顺序对了也要保证释放逻辑幂等。
测试:重复 Key、上限与拒绝不落账
测试要固定四个行为:同一个 Key 只发一次;不同用户共用同一个 Key 也只发一次;超过用户上限时拒绝且不写账本;非法参数显式报错。
// file: internal/growth/reward_test.go
// tier: L1
// verify: go test ./internal/growth/
package growth
import (
"errors"
"testing"
)
func TestGrantOnceAndIdempotent(t *testing.T) {
seen := map[string]int64{}
r := Reward{Key: "evt-1", UserID: "u1", AmountCents: 300, Reason: "invite"}
ok, err := Grant(seen, r, 1000)
if err != nil || !ok {
t.Fatalf("Grant = %v, %v; want true, nil", ok, err)
}
if got := seen[rewardUserPrefix+"u1"]; got != 300 {
t.Fatalf("user total = %d, want 300", got)
}
ok, err = Grant(seen, r, 1000)
if err != nil || ok {
t.Fatalf("replay = %v, %v; want false, nil", ok, err)
}
if got := seen[rewardUserPrefix+"u1"]; got != 300 {
t.Fatalf("user total after replay = %d, want 300", got)
}
}
func TestGrantKeyIsGlobal(t *testing.T) {
seen := map[string]int64{}
if ok, err := Grant(seen, Reward{Key: "evt-1", UserID: "u1", AmountCents: 100}, 1000); err != nil || !ok {
t.Fatalf("first Grant = %v, %v; want true, nil", ok, err)
}
before := len(seen)
ok, err := Grant(seen, Reward{Key: "evt-1", UserID: "u2", AmountCents: 100}, 1000)
if err != nil || ok {
t.Fatalf("same key other user = %v, %v; want false, nil", ok, err)
}
if len(seen) != before {
t.Fatalf("seen size = %d, want %d", len(seen), before)
}
if _, ok := seen[rewardUserPrefix+"u2"]; ok {
t.Fatal("rejected grant wrote a user record for u2")
}
}
func TestGrantUserCap(t *testing.T) {
seen := map[string]int64{}
if ok, err := Grant(seen, Reward{Key: "k1", UserID: "u1", AmountCents: 300}, 500); err != nil || !ok {
t.Fatalf("k1 = %v, %v; want true, nil", ok, err)
}
before := len(seen)
if ok, err := Grant(seen, Reward{Key: "k2", UserID: "u1", AmountCents: 300}, 500); err != nil || ok {
t.Fatalf("k2 over cap = %v, %v; want false, nil", ok, err)
}
if len(seen) != before {
t.Fatalf("rejected grant changed seen: %d -> %d", before, len(seen))
}
if ok, err := Grant(seen, Reward{Key: "k3", UserID: "u1", AmountCents: 200}, 500); err != nil || !ok {
t.Fatalf("k3 exact cap = %v, %v; want true, nil", ok, err)
}
if got := seen[rewardUserPrefix+"u1"]; got != 500 {
t.Fatalf("user total = %d, want 500", got)
}
}
func TestGrantZeroCapRejectsEverything(t *testing.T) {
seen := map[string]int64{}
ok, err := Grant(seen, Reward{Key: "k1", UserID: "u1", AmountCents: 1}, 0)
if err != nil || ok {
t.Fatalf("Grant cap 0 = %v, %v; want false, nil", ok, err)
}
if len(seen) != 0 {
t.Fatalf("seen size = %d, want 0", len(seen))
}
}
func TestGrantRejectsInvalidInput(t *testing.T) {
cases := []struct {
name string
r Reward
cap int64
want error
}{
{"empty-key", Reward{UserID: "u1", AmountCents: 1}, 10, ErrInvalidReward},
{"empty-user", Reward{Key: "k", AmountCents: 1}, 10, ErrInvalidReward},
{"zero-amount", Reward{Key: "k", UserID: "u1"}, 10, ErrInvalidReward},
{"negative-amount", Reward{Key: "k", UserID: "u1", AmountCents: -1}, 10, ErrInvalidReward},
{"negative-cap", Reward{Key: "k", UserID: "u1", AmountCents: 1}, -1, ErrInvalidReward},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := Grant(map[string]int64{}, c.r, c.cap); !errors.Is(err, c.want) {
t.Fatalf("Grant = %v, want %v", err, c.want)
}
})
}
if _, err := Grant(nil, Reward{Key: "k", UserID: "u1", AmountCents: 1}, 10); !errors.Is(err, ErrInvalidReward) {
t.Fatalf("Grant nil map = %v, want ErrInvalidReward", err)
}
}
func TestGrantRejectsCorruptLedger(t *testing.T) {
over := map[string]int64{rewardUserPrefix + "u1": 501}
if _, err := Grant(over, Reward{Key: "k", UserID: "u1", AmountCents: 1}, 500); !errors.Is(err, ErrRewardState) {
t.Fatalf("over cap state = %v, want ErrRewardState", err)
}
negative := map[string]int64{rewardUserPrefix + "u1": -1}
if _, err := Grant(negative, Reward{Key: "k", UserID: "u1", AmountCents: 1}, 500); !errors.Is(err, ErrRewardState) {
t.Fatalf("negative state = %v, want ErrRewardState", err)
}
}
TestGrantOnceAndIdempotent 发放一笔 300 分,断言用户累计变成 300;重放同一个 Key 返回 false,累计仍然是 300。TestGrantKeyIsGlobal 用同一个 Key 发给另一个用户,断言仍然拒绝,并且没有为新用户写入记录。这说明 Key 是全局幂等键,不是「每个用户各自一次」。
TestGrantUserCap 在上限 500 分时先发 300,再发 300 被拒绝,账本长度不变;接着发 200 刚好到上限,累计变成 500。TestGrantZeroCapRejectsEverything 验证上限为 0 时任何正金额都发不出去。
TestGrantRejectsInvalidInput 覆盖空 Key、空用户、零金额、负金额和负上限。TestGrantRejectsCorruptLedger 构造累计超过上限和累计为负的脏账本,断言返回 ErrRewardState 而不是继续发放。脏数据必须显式失败,否则一次错误的负数会永久绕过上限。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/growth/ -run 'TestGrant' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestGrantOnceAndIdempotent
--- PASS: TestGrantOnceAndIdempotent (0.00s)
=== RUN TestGrantKeyIsGlobal
--- PASS: TestGrantKeyIsGlobal (0.00s)
=== RUN TestGrantUserCap
--- PASS: TestGrantUserCap (0.00s)
=== RUN TestGrantZeroCapRejectsEverything
--- PASS: TestGrantZeroCapRejectsEverything (0.00s)
=== RUN TestGrantRejectsInvalidInput
=== RUN TestGrantRejectsInvalidInput/empty-key
=== RUN TestGrantRejectsInvalidInput/empty-user
=== RUN TestGrantRejectsInvalidInput/zero-amount
=== RUN TestGrantRejectsInvalidInput/negative-amount
=== RUN TestGrantRejectsInvalidInput/negative-cap
--- PASS: TestGrantRejectsInvalidInput (0.00s)
--- PASS: TestGrantRejectsInvalidInput/empty-key (0.00s)
--- PASS: TestGrantRejectsInvalidInput/empty-user (0.00s)
--- PASS: TestGrantRejectsInvalidInput/zero-amount (0.00s)
--- PASS: TestGrantRejectsInvalidInput/negative-amount (0.00s)
--- PASS: TestGrantRejectsInvalidInput/negative-cap (0.00s)
=== RUN TestGrantRejectsCorruptLedger
--- PASS: TestGrantRejectsCorruptLedger (0.00s)
PASS
ok webapp/internal/growth 0.242s
输出里的 6 个顶层用例和 5 个子用例全部通过。它们验证的是单进程内的规则;多实例并发下的幂等,仍然需要数据库唯一索引或 Redis 的原子命令来保证。
小结
奖励发放要同时守住幂等、用户上限和活动预算三条不变量。幂等键由业务事件生成,用户上限按活动周期累计,活动预算在发放前原子占用;设备、支付工具和行为信号负责提高攻击成本,而不是替代预算约束。
Grant 把幂等和用户上限收成一个纯函数:重复 Key 和超限都返回 false,拒绝不写账本,脏账本显式报错。下一节换一个方向:奖励发出去之后,怎么判断这次增长实验到底有没有效果。
阅读导航:上一节:7.1 邀请关系与归因 · 下一节:7.3 增长实验的度量 。
常见错误与边界
- 幂等键由服务端随机生成。每次重试都是新 Key,幂等形同虚设,必须用业务事件生成。
- 只在发奖接口做幂等,不在账本做唯一约束。多实例并发时两个请求都可能查不到 Key,然后各写一次。
- 把用户上限按自然日重置。活动周期是 30 天时,用户每天都能领,累计金额会远超规则预期。
- 只做用户上限,不做活动预算。一万个账号每人领一份,总预算仍然会被领空。
- 先发奖再占预算。预算占用失败时奖励已经发出,财务账和奖励账对不上。
- 设备维度一刀切。家庭共享设备、同一台电脑的多个浏览器是正常场景,硬拒绝会误伤真实用户。
- 奖励触发点太靠前。注册就发奖,接码平台批量注册即可套利,应该后移到首单或留存。
- 拒绝时写入账本。把失败的请求记成已发放,用户重试会被幂等挡住,奖励永远发不出去。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。