风控不是封号,是排序
《群岛纪元》的工作室脚本在凌晨集中开采同一种矿石,再通过交易所把银币转移给主账号。运营第二天看到银币总量上涨,但单个玩家的余额都不算夸张;等人工排查完,金币已经通过跨服交易扩散。
经济风控要解决的不是“立刻抓住坏人”,而是把有限的人工审核时间排到最可疑的对象上。它输出的是告警、排序和处置建议,不是封号裁决。把这两件事混在一起,团队很快会陷入误封和申诉的泥潭。
本节讨论三类可观测信号、价格指数的边界,以及从观察到回滚的处置阶梯。具体阈值必须按产品和赛季标定,文中出现的数字都是示例值。
四类异常信号
| 信号 | 观测指标 | 窗口 | 常见误报 |
|---|---|---|---|
| 来源暴增 | 单一来源的净产出、人均产出、峰值 | 分钟到小时 | 活动开启、补偿发放 |
| 价格异常 | 成交价相对中位数的偏离、成交量 | 小时到天 | 新版本、稀有掉落、低流动性 |
| 集中转移 | 单一对手方占玩家交易额的比例 | 小时到天 | 固定队、商人、亲友赠送 |
| 工作室行为 | 登录时段、操作间隔、同 IP/设备聚集 | 天到周 | 网吧、共享设备、代练 |
单看任何一个指标都不够。脚本可以控制单次产出,但很难同时控制来源、价格和社交网络;反过来,正常玩家也可能因为活动或固定队触发单项阈值。风控的价值在于多个弱信号的组合,而不是把某个阈值调得更低。
价格指数:观测工具,不是裁决依据
价格指数通常有三种算法:
- 成交量加权均价(VWAP):
Σ(价格×数量) / Σ数量,反映实际成交重心。 - 中位数:抗极端值,适合样本少、价格跳变大的品类。
- 分位数区间:给出正常价格带,而不是一个点。
它们都只能回答“最近成交大致在什么水平”,不能回答“这个价格是否合理”。原因有四:第一,低流动性商品几笔成交就能拉偏指数;第二,工作室可以自买自卖制造成交量;第三,新版本会永久改变供需;第四,不同品质、强化等级的物品不能简单平均。
因此价格指数适合做触发复核,不适合做自动定价或自动没收。真正要处罚时,仍要回到交易记录、产出记录和账号关联证据。
处置阶梯
| 处置 | 触发条件 | 影响面 | 可逆性 | 证据要求 |
|---|---|---|---|---|
| 观察 | 单项弱信号 | 无 | 完全 | 无 |
| 延迟到账 | 来源暴增且金额较大 | 单玩家、小时级 | 可撤销 | 交易与产出记录 |
| 人工复核 | 多信号组合 | 单玩家或小群体 | 完全 | 窗口内明细 |
| 冻结核查 | 高风险且可能扩散 | 账号资产 | 可解冻 | 多信号加人工确认 |
| 回滚 | 确认复制或漏洞产出 | 批次资产 | 难,需补偿方案 | 完整因果链 |
阶梯的原则是先限制扩散,再确认责任,最后才回滚。直接回滚误伤成本最高,因为玩家可能已经把资产换成其他物品或消耗掉;延迟到账和冻结则保留了纠正空间。
可运行片段:滑动窗口与告警
下面的 internal/economyrisk 用固定大小的按 tick 分桶实现滑动窗口,输出来源暴增、价格异常和对手方集中三类告警,以及成交量加权价格指数。它只给建议,不执行任何封禁。
// file: internal/economyrisk/economyrisk.go
// tier: L1
// verify: go test ./internal/economyrisk/
package economyrisk
import (
"errors"
"fmt"
"sort"
)
var (
ErrWindow = errors.New("economyrisk: window ticks must be positive")
ErrPolicy = errors.New("economyrisk: thresholds must be positive")
ErrEmptyPlayer = errors.New("economyrisk: event player is empty")
ErrBadAmount = errors.New("economyrisk: amount must be positive")
ErrBadTick = errors.New("economyrisk: tick must be non-negative")
)
// Severity says how urgent an alert is, not what to do about it.
type Severity string
const (
SeverityInfo Severity = "info"
SeverityWarn Severity = "warn"
SeverityHigh Severity = "high"
)
// Action is a recommendation for a human or a policy engine. No alert directly
// bans a player or deletes currency.
type Action string
const (
ActionObserve Action = "observe"
ActionDelay Action = "delay_credit"
ActionReview Action = "review"
ActionFreeze Action = "freeze"
ActionRollback Action = "rollback"
)
// Alert is one evaluated signal. Detail carries the numbers that produced it
// so an appeal can be reviewed without replaying the whole window.
type Alert struct {
Code string
Severity Severity
Action Action
Detail string
}
// Event is one economy observation. Price is zero for non-trade events; Amount
// is the quantity moved or the trade volume.
type Event struct {
Tick int
Player string
Source string
Item string
Amount int64
Price int64
Counterparty string
}
// Policy holds the thresholds. They are configuration, not constants: a new
// season or a different product should retune them from observed baselines.
type Policy struct {
WindowTicks int
SourceSpike int64
PriceBandBPS int
ConcentrationBPS int
MinPriceSamples int
}
type bucket struct {
tick int
events []Event
}
// Monitor is a fixed-size ring of per-tick buckets. Record assumes ticks are
// non-decreasing; a late event from an expired tick is dropped by Evaluate
// anyway, and reusing its slot would corrupt a newer bucket.
type Monitor struct {
policy Policy
buckets []bucket
}
// New validates the policy. MinPriceSamples defaults to 3 because two samples
// cannot tell a spike from a normal price change.
func New(p Policy) (*Monitor, error) {
if p.WindowTicks <= 0 {
return nil, ErrWindow
}
if p.SourceSpike <= 0 || p.PriceBandBPS <= 0 || p.ConcentrationBPS <= 0 {
return nil, ErrPolicy
}
if p.MinPriceSamples <= 0 {
p.MinPriceSamples = 3
}
return &Monitor{policy: p, buckets: make([]bucket, p.WindowTicks)}, nil
}
// Record appends an event to the bucket for its tick, replacing any bucket
// left over from an older window lap.
func (m *Monitor) Record(e Event) error {
if e.Player == "" {
return ErrEmptyPlayer
}
if e.Amount <= 0 {
return ErrBadAmount
}
if e.Tick < 0 {
return ErrBadTick
}
idx := e.Tick % len(m.buckets)
if m.buckets[idx].tick != e.Tick {
m.buckets[idx] = bucket{tick: e.Tick}
}
m.buckets[idx].events = append(m.buckets[idx].events, e)
return nil
}
// Evaluate returns the alerts for one player at a given tick. It is a read
// path: calling it twice does not change the window.
func (m *Monitor) Evaluate(player string, now int) []Alert {
events := m.window(player, now)
if len(events) == 0 {
return nil
}
alerts := make([]Alert, 0, 3)
if alert, ok := m.sourceSpike(events); ok {
alerts = append(alerts, alert)
}
if alert, ok := m.priceAnomaly(events); ok {
alerts = append(alerts, alert)
}
if alert, ok := m.concentration(events); ok {
alerts = append(alerts, alert)
}
sort.Slice(alerts, func(i, j int) bool { return alerts[i].Code < alerts[j].Code })
return alerts
}
// PriceIndex returns the volume-weighted average price and sample count for an
// item over the window. It is an observation, never a settlement price.
func (m *Monitor) PriceIndex(item string, now int) (int64, int) {
cutoff := now - m.policy.WindowTicks
var sum, volume int64
var samples int
for _, b := range m.buckets {
if b.tick <= cutoff || b.tick > now {
continue
}
for _, e := range b.events {
if e.Item != item || e.Price <= 0 {
continue
}
sum += e.Price * e.Amount
volume += e.Amount
samples++
}
}
if volume == 0 {
return 0, 0
}
return sum / volume, samples
}
func (m *Monitor) window(player string, now int) []Event {
cutoff := now - m.policy.WindowTicks
var out []Event
for _, b := range m.buckets {
if b.tick <= cutoff || b.tick > now {
continue
}
for _, e := range b.events {
if e.Player == player {
out = append(out, e)
}
}
}
return out
}
func (m *Monitor) sourceSpike(events []Event) (Alert, bool) {
perSource := make(map[string]int64)
for _, e := range events {
perSource[e.Source] += e.Amount
}
bestSource, best := "", int64(0)
for source, total := range perSource {
if total > best || (total == best && source < bestSource) {
bestSource, best = source, total
}
}
if best <= m.policy.SourceSpike {
return Alert{}, false
}
return Alert{
Code: "source_spike",
Severity: SeverityHigh,
Action: ActionDelay,
Detail: fmt.Sprintf("source %s produced %d in window", bestSource, best),
}, true
}
func (m *Monitor) priceAnomaly(events []Event) (Alert, bool) {
byItem := make(map[string][]Event)
for _, e := range events {
if e.Price > 0 {
byItem[e.Item] = append(byItem[e.Item], e)
}
}
worstItem, worstDev := "", int64(-1)
var worstPrice, worstMedian int64
for item, itemEvents := range byItem {
if len(itemEvents) < m.policy.MinPriceSamples {
continue
}
sort.Slice(itemEvents, func(i, j int) bool { return itemEvents[i].Tick < itemEvents[j].Tick })
latest := itemEvents[len(itemEvents)-1].Price
previous := make([]int64, 0, len(itemEvents)-1)
for _, e := range itemEvents[:len(itemEvents)-1] {
previous = append(previous, e.Price)
}
baseline := median(previous)
if baseline <= 0 {
continue
}
deviation := abs64(latest-baseline) * 10000 / baseline
if deviation > int64(m.policy.PriceBandBPS) &&
(deviation > worstDev || (deviation == worstDev && item < worstItem)) {
worstItem, worstDev, worstPrice, worstMedian = item, deviation, latest, baseline
}
}
if worstDev < 0 {
return Alert{}, false
}
return Alert{
Code: "price_anomaly",
Severity: SeverityWarn,
Action: ActionReview,
Detail: fmt.Sprintf("item %s price %d vs median %d (%d bps)", worstItem, worstPrice, worstMedian, worstDev),
}, true
}
func (m *Monitor) concentration(events []Event) (Alert, bool) {
byParty := make(map[string]int64)
var total int64
for _, e := range events {
if e.Counterparty == "" {
continue
}
byParty[e.Counterparty] += e.Amount
total += e.Amount
}
if total == 0 {
return Alert{}, false
}
bestParty, best := "", int64(0)
for party, volume := range byParty {
if volume > best || (volume == best && party < bestParty) {
bestParty, best = party, volume
}
}
bps := best * 10000 / total
if bps < int64(m.policy.ConcentrationBPS) {
return Alert{}, false
}
return Alert{
Code: "counterparty_concentration",
Severity: SeverityWarn,
Action: ActionFreeze,
Detail: fmt.Sprintf("counterparty %s took %d/%d volume (%d bps)", bestParty, best, total, bps),
}, true
}
func median(xs []int64) int64 {
if len(xs) == 0 {
return 0
}
sort.Slice(xs, func(i, j int) bool { return xs[i] < xs[j] })
return xs[len(xs)/2]
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
几个实现取舍:
- 窗口用环形桶而不是全量事件表,内存只与窗口长度相关;过期事件自然被覆盖。
- 告警只输出代码、严重度、建议动作和明细,具体文案与审批流留给运营平台。
- 价格异常用前序样本的中位数做基线,最新一笔只参与比较,避免自己拉高自己的基线。
- 集中转移按对手方聚合成交量,空对手方(非交易事件)不参与,减少噪声。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 窗口边界错误 | 刚发生的事件被漏掉或重复计入 | 用半开区间并写边界测试 |
| 样本不足 | 新物品价格被误判 | 最小样本数,不足只观察 |
| 指标被污染 | 自买自卖抬高成交量 | 结合账号关联与对手方集中度 |
| 阈值过严 | 正常活动被大量延迟 | 分产品、分赛季配置,灰度 |
| 处置过重 | 误冻结核心玩家 | 人工复核前置,保留申诉通道 |
误伤与申诉
误伤不可避免,关键是让误伤可发现、可解释、可恢复。每条告警要保留窗口内的原始事件和计算明细;冻结要给出原因代码和解冻条件;申诉入口要能直接看到被判定的事件,而不是只收到一句“数据异常”。同一类告警连续误判时,应优先调整规则或增加组合条件,而不是让客服逐条解释。
常见错误
第一,把价格指数当指导价。指数反映历史成交,不保证下一笔成交合理。
第二,只用单指标封号。来源暴增可能只是活动配置,集中转移可能只是固定队。
第三,窗口越长越好。窗口越长,越难定位具体批次,也越容易把正常波动算成异常。
第四,告警不带明细。没有明细就无法复核,最后只能凭感觉放行或封禁。
第五,处置与证据脱节。先冻结再找证据,会把运营推入被动解释的位置。
小结
经济风控的产出是排序,不是判决:用来源、价格、集中度和行为信号把可疑对象排到前面,用延迟到账和冻结控制扩散,再用人工复核和完整证据决定是否回滚。价格指数是观测工具,样本不足、流动性低和被操纵时都不可靠。
账本、托管和风控构成经济系统的三层:账本保证记得清,托管保证换得安全,风控保证异常能被发现。下一章转向角色属性与成长,讨论这些经济结果如何落到角色状态机上。
阅读导航:上一节:6.2 交易托管、冻结与回滚 · 下一节:7.1 属性、成长与状态机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。