一次误封把问题摆到台面上
《群岛纪元》的赛季活动在周五晚上八点开放。活动允许玩家把资源从一个账号转运到同服的另一账号,用来给公会小号补资源。运营的风控规则只有一条:单日资源转移超过阈值即判定为工作室倒金。活动开放九十分钟后,规则命中了三百多个账号,其中大部分是正常的公会互助。
客服工单在两小时内翻了十倍。玩家贴出聊天记录、公会日志和充值记录,证明自己不是工作室;而风控后台只能给出一个数字:“资源转移量 1,240,000,超过阈值 800,000”。没有人能解释这个数字是怎么算出来的,也没有人能快速判断哪些命中是真阳性。
这次事故暴露的不是阈值设低了,而是整条信号链路缺少三样东西:分层说明信号从哪来,质量评估说明这个信号值不值得信,误伤控制说明在确认之前该做什么。封禁只是最后一步,前面每一步都可以拦住错误。
信号分五层
风控信号不是越多越好。把几十个指标塞进一个分数,最直接的结果是没人能解释为什么封号。北辰工作室后来把信号按来源分成五层,每层回答一个不同的问题:
| 层级 | 典型信号 | 回答的问题 | 可解释性 | 时效性 | 伪造成本 |
|---|---|---|---|---|---|
| 客户端上报 | 异常输入频率、非官方包特征 | 客户端是否被改动 | 中 | 秒级 | 低 |
| 服务端行为 | 移动速度、攻击间隔、路径穿墙 | 操作是否符合规则 | 高 | 秒级 | 高 |
| 经济异常 | 资源转移、拍卖价差、产出消耗比 | 经济结果是否异常 | 高 | 分钟级 | 中 |
| 社交图谱 | 多账号同设备、同公会批量行为 | 账号之间是否有关联 | 中 | 小时级 | 中 |
| 设备与网络 | 设备指纹、IP 段、模拟器特征 | 环境是否像批量账号 | 低 | 分钟级 | 低 |
分层的价值在判定阶段才显现。客户端上报和设备指纹最容易伪造,单独使用只能做初筛;服务端行为和实施后的经济结果最难伪造,适合作为确认证据。把它们混成一个不区分来源的总分,等于让最便宜的伪造信号拥有和权威信号相同的投票权。
客户端上报:只做线索,不做结论
客户端能观察到输入频率、界面状态和非官方包特征,但这些数据都经过玩家设备。修改客户端、注入脚本、重放请求都可以伪造上报。它的合理用途是触发进一步检查,而不是直接产生处罚。
一个实用原则:客户端上报的信号永远不进入封禁判定,只进入观察队列。观察队列里再叠加服务端行为或经济异常,才允许升级。
服务端行为:规则明确,但要看实现
移动速度、攻击间隔、技能冷却这些指标由服务端计算,伪造成本高。但它们仍然可能误伤:网络抖动会让移动速度出现尖峰,帧同步回滚会让攻击间隔看起来异常,跨区迁移会让路径校验失败。
所以服务端信号也要区分“硬违规”和“统计异常”。穿墙、超出最大速度上限属于硬违规,可以直接进入复核;命中率异常、资源产出偏离分布属于统计异常,必须结合时间窗口和对照人群。
经济异常:结果可信,归因困难
经济信号的优势是它描述最终结果:资源从哪来到哪去、拍卖成交价偏离多少、单位时间的产出与消耗是否平衡。劣势是它不说明原因。一个账号大量转出资源,可能是工作室,也可能是公会后勤、代练、朋友借号。
经济异常适合做“范围收窄”:先把可疑账号圈出来,再用社交图谱和服务端行为区分工作室与正常玩家。单独用经济阈值封号,就是《群岛纪元》事故的复现。
社交图谱与设备指纹:关联证据,不是独立证据
同设备多账号、同 IP 段批量注册、同公会同步操作,这些信号擅长回答“这些账号是不是一伙的”,但不擅长回答“这个账号是否作弊”。共用设备的家庭、网吧、电竞酒店都会产生合法关联。
设备指纹的另一个问题是稳定性。系统升级、浏览器更新、隐私设置变化都会让指纹漂移,把老玩家识别成新设备。它适合做风险加分和人工复核的排序依据,不适合作为封禁的唯一依据。
信号质量的三个维度
同一层的信号也有强弱。评估一个信号是否值得进入判定,至少看三个维度:
可解释性:能不能用一句话向客服和玩家说明命中原因。 “资源转移量超过阈值”可解释;“模型分数 0.87”不可解释。不可解释的信号可以用于排序和分流,不能单独用于处罚。
时效性:信号从产生到可用的延迟。实时行为信号适合拦截正在发生的作弊;离线经济核算适合事后追缴。用离线信号做实时封禁,会带来大量滞后误伤;用实时信号做资金追缴,往往证据不足。
伪造成本:黑产让这个信号变好看需要付出多少。客户端字段几乎零成本,服务端物理规则成本很高,经济结果介于两者之间。伪造成本越低的信号,权重应该越低。
一个简单的信任系数可以写成 0.5 + 0.5 × 伪造成本,而不是直接乘以伪造成本。直接乘会让“容易伪造但确实有效”的信号归零,例如举报文本和客服记录;保留 0.5 的下限,是为了让这些信号仍能参与排序,但不能单独定案。
不要用单一信号直接封号
单一信号封号的问题不是“可能错”,而是“错了无法自证”。当玩家申诉时,运营只能重复那个数字,无法回答“为什么这个数字代表作弊”。
跨层佐证是更可靠的结构。判定至少要求两个不同层级的信号同时成立,并且对单条信号和单层信号分别设上限:
| 约束 | 作用 | 示例值 |
|---|---|---|
| 单条信号封顶 | 防止一条极端数据主导总分 | 25 分 |
| 单层封顶 | 防止同一来源堆量 | 40 分 |
| 最少层数 | 强制跨层佐证 | 2 层 |
| 观察阈值 | 进入观察期,不处罚 | 30 分 |
| 处置阈值 | 进入人工复核,不自动封禁 | 60 分 |
这些数字是示例政策,不是普适标准。它们的意义是把“要不要处理”变成可复查的规则,而不是让值班同学凭感觉决定。
还有一个容易被忽略的约束:高分但单层,只能观察,不能处置。一个客户端上报可能因为版本 bug 在一夜之间命中几千人,分数很高;如果只看总分,就会造成批量误封。层数约束是这类事故的断路器。
误伤控制:阈值之外还有四道闸
阈值只能决定“分数够不够”,不能决定“现在能不能动手”。完整的误伤控制至少有四道闸:
- 观察期:命中观察阈值后,先标记、限流或隔离可疑行为,不封号。观察期内如果出现跨层确认,再升级;没有确认就自动回滚。
- 灰度处置:新规则先对 5%(示例值)流量生效,对比对照组的申诉率、留存和付费。灰度不是按账号随机,而是按规则和场景分层,避免把某一类玩家整体圈进实验。
- 白名单:赛事选手、主播、客服测试号、合作方账号可以进白名单,但必须有到期时间和负责人。永久白名单等于永久盲区。
- 人工复核:处置阈值以上的案例进入队列,由独立复核人看证据。复核人不能是规则作者,也不能是处罚发起人,这条与3.1 权限、审批与双人复核 是同一套操作安全原则。
观察期的关键设计是回滚要自动。如果观察期结束后还要人工点“解除”,值班一忙就会漏掉,误伤会一直挂着。自动回滚之后再收到确认证据,不应该追认旧处罚,而应该开新案;这条边界会在4.2 处置、申诉与复核 展开。
误伤率怎么度量
误伤率不能靠感觉,也不能只看申诉量。可用的口径是:统计周期内被推翻的处置数 ÷ 同期处置总数。分母是实际执行的处置,不是命中规则的次数;分子是经过复核或申诉被撤销的处置。
这个口径有三个陷阱:
- 样本太小:一个月只封了 20 个号,推翻 1 个就是 5%,但置信区间很宽,不能据此判断规则好坏。
- 申诉偏差:愿意申诉的玩家不代表全部误伤者。沉默的流失玩家不会进入分子,所以这个比率是下界,不是全貌。
- 分层缺失:总误伤率 2% 可能由某个规则 20% 的误伤率拉高。必须按产品、规则、场景、账号年龄分组看。
示例值是:总体误伤率控制在 2% 以下,单条规则不超过 5%,新规则灰度期不超过 8%。超过阈值的规则不是立刻下线,而是回到观察态,补充跨层证据后再放量。规则下线也要走2.3 活动状态、回滚与审计 同样的变更记录,否则下次复盘无法还原当时的判定环境。
数据模型与可运行片段
internal/risksignal 把这一节的判断落成四个动作:Score 按层聚合分数并执行两级封顶,Decide 根据分数和层数给出 none / observe / review,ResolveObservation 处理观察期的确认与自动回滚,FalsePositiveRate 计算可审计的误伤率。
// file: internal/risksignal/risksignal.go
// tier: L1
// verify: go test ./internal/risksignal/
package risksignal
import (
"errors"
"time"
)
// Layer is where a signal came from. Corroboration across layers matters
// more than stacking many signals from one layer.
type Layer string
const (
LayerClient Layer = "client_report"
LayerServer Layer = "server_behavior"
LayerEconomy Layer = "economy_anomaly"
LayerSocial Layer = "social_graph"
LayerDevice Layer = "device_network"
)
var allLayers = []Layer{LayerClient, LayerServer, LayerEconomy, LayerSocial, LayerDevice}
func layerKnown(l Layer) bool {
for _, v := range allLayers {
if v == l {
return true
}
}
return false
}
// Signal is one observation. Weight is the maximum points it can add,
// Confidence scales that weight, and ForgeCost estimates how expensive the
// signal is for a cheater to fake: 0 is trivial, 1 is costly.
type Signal struct {
ID string
Layer Layer
Weight float64
Confidence float64
ForgeCost float64
At time.Time
TTL time.Duration
}
// Validate rejects values that would let one malformed report dominate.
func Validate(s Signal) error {
switch {
case s.ID == "":
return errors.New("risksignal: signal id is required")
case !layerKnown(s.Layer):
return errors.New("risksignal: unknown signal layer")
case s.Weight < 0 || s.Weight > 100:
return errors.New("risksignal: weight must be within [0,100]")
case s.Confidence < 0 || s.Confidence > 1:
return errors.New("risksignal: confidence must be within [0,1]")
case s.ForgeCost < 0 || s.ForgeCost > 1:
return errors.New("risksignal: forge cost must be within [0,1]")
case s.TTL <= 0:
return errors.New("risksignal: ttl must be positive")
}
return nil
}
// Policy is the enforcement gate. ObserveAt opens an observation window;
// EnforceAt is the minimum score for a restriction. MinLayers stops one noisy
// source from acting alone, and the two caps stop a single signal or layer
// from carrying the whole score.
type Policy struct {
ObserveAt float64
EnforceAt float64
MinLayers int
MaxSingleContribution float64
MaxPerLayer float64
}
// ScoreResult keeps the breakdown so a reviewer can explain the number.
type ScoreResult struct {
Total float64
ByLayer map[Layer]float64
UsedLayers int
Expired int
Invalid int
}
// Score sums decayed, confidence-scaled weights, caps each layer, and reports
// which layers actually contributed. Expired and invalid signals are counted
// but never scored.
func Score(signals []Signal, now time.Time, p Policy) ScoreResult {
res := ScoreResult{ByLayer: map[Layer]float64{}}
for _, s := range signals {
if err := Validate(s); err != nil {
res.Invalid++
continue
}
if now.Before(s.At) {
res.Invalid++
continue
}
if now.Sub(s.At) > s.TTL {
res.Expired++
continue
}
// A cheap-to-fake signal still carries some weight, but never full
// weight. The 0.5 floor keeps genuine but noisy signals usable.
points := s.Weight * s.Confidence * (0.5 + 0.5*s.ForgeCost)
if p.MaxSingleContribution > 0 && points > p.MaxSingleContribution {
points = p.MaxSingleContribution
}
res.ByLayer[s.Layer] += points
}
// Iterate a fixed order so the total is stable across runs.
for _, layer := range allLayers {
v := res.ByLayer[layer]
if v <= 0 {
delete(res.ByLayer, layer)
continue
}
if p.MaxPerLayer > 0 && v > p.MaxPerLayer {
v = p.MaxPerLayer
res.ByLayer[layer] = v
}
res.UsedLayers++
res.Total += v
}
return res
}
// Action is the operational result of a score. Review means a human or a
// heavier policy must look before the account is restricted.
type Action string
const (
ActionNone Action = "none"
ActionObserve Action = "observe"
ActionReview Action = "review"
)
// Decision records the action and the reason, so operators can audit why an
// account was or was not touched.
type Decision struct {
Action Action
Score float64
Reason string
}
// Decide applies the gate. A high score from fewer layers than policy
// requires is deliberately downgraded to observation, not enforcement.
func Decide(res ScoreResult, p Policy) Decision {
switch {
case res.Total > 0 && res.UsedLayers > 0 &&
res.Total >= p.EnforceAt && res.UsedLayers >= p.MinLayers:
return Decision{Action: ActionReview, Score: res.Total,
Reason: "score and cross-layer corroboration meet the enforcement gate"}
case res.Total > 0 && res.Total >= p.EnforceAt:
return Decision{Action: ActionObserve, Score: res.Total,
Reason: "high score from fewer layers than policy requires"}
case res.Total > 0 && res.Total >= p.ObserveAt:
return Decision{Action: ActionObserve, Score: res.Total,
Reason: "score meets the observation gate"}
default:
return Decision{Action: ActionNone, Score: res.Total,
Reason: "score is below the observation gate"}
}
}
// ObservationOutcome is the result of watching an account that crossed the
// observation gate.
type ObservationOutcome string
const (
ObservationPending ObservationOutcome = "pending"
ObservationConfirmed ObservationOutcome = "confirmed"
ObservationRolledBack ObservationOutcome = "rolled_back"
)
// ObservationResult reports whether confirmation arrived late, after the
// automatic restriction had already been rolled back.
type ObservationResult struct {
Outcome ObservationOutcome
LateEvidence bool
}
// ResolveObservation closes an observation window. If nothing confirms the
// signal before the window ends, the temporary restriction is rolled back.
// Evidence that arrives later cannot retroactively justify the old action; it
// must open a new case, which is why LateEvidence is reported separately.
func ResolveObservation(startedAt time.Time, window time.Duration, now time.Time, confirmed bool) ObservationResult {
elapsed := now.Sub(startedAt)
if confirmed {
if window > 0 && elapsed <= window {
return ObservationResult{Outcome: ObservationConfirmed}
}
return ObservationResult{Outcome: ObservationRolledBack, LateEvidence: true}
}
if window > 0 && elapsed <= window {
return ObservationResult{Outcome: ObservationPending}
}
return ObservationResult{Outcome: ObservationRolledBack}
}
// Counts is the sample used to measure false positives. Actions is the number
// of enforcement actions; Overturned is the subset later reversed on appeal.
type Counts struct {
Actions int
Overturned int
}
// FalsePositiveRate returns the observed overturn ratio. It refuses a sample
// with no actions because a rate over an empty denominator is not evidence.
func FalsePositiveRate(c Counts) (float64, error) {
if c.Actions < 0 || c.Overturned < 0 {
return 0, errors.New("risksignal: counts must not be negative")
}
if c.Actions == 0 {
return 0, errors.New("risksignal: no actions to measure")
}
if c.Overturned > c.Actions {
return 0, errors.New("risksignal: overturned cannot exceed actions")
}
return float64(c.Overturned) / float64(c.Actions), nil
}
本机验证命令与结果:
cd .local/checks/gsarch-lab
go test -count=1 ./internal/risksignal/
ok gsarchlab/internal/risksignal
该包共有 5 个表驱动测试,覆盖信号校验、分层评分与封顶、判定降级、观察期回滚与迟到证据、误伤率边界;gofmt -l 无输出。
上线前的检查表
- 每条规则是否标注了信号层级、数据来源和负责人?
- 是否存在“单信号直接封号”的路径?如果有,是否已经改成观察或复核?
- 单条信号、单层信号是否都有封顶?层数门槛是否配置化?
- 观察期结束后是否自动回滚?迟到证据是否走新案而不是追认?
- 白名单是否有到期时间和负责人?
- 误伤率是否按规则、产品、场景分组?样本量是否足够支撑结论?
- 规则变更是否进入审计记录,能否还原当时的阈值和信号快照?
小结
风控信号要分层,因为不同来源的可信度不同;要评质量,因为可解释性、时效性和伪造成本决定了它能不能定案;要控误伤,因为阈值只能筛分数,观察期、灰度、白名单和人工复核才能拦住错误。
最重要的一条是:单一信号可以触发检查,不能直接封号。跨层佐证、单条与单层封顶、高分单层降级为观察,这三条规则共同保证“错了能发现、能回滚、能解释”。下一节讨论确认之后怎么处置、玩家怎么申诉、复核怎么改判。
阅读导航:上一节:3.3 客服工具与数据修复流程 · 下一节:4.2 处置、申诉与复核 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。