《游戏服务端架构·核心系统与契约》6.3 经济风控与价格指数

《群岛纪元》的工作室用脚本刷矿、在交易所拉价,等运营发现时银币已经扩散。本节把经济风控拆成来源暴增、价格异常、集中转移三类可观测信号,说明价格指数的用途与局限、处置阶梯和误伤申诉,并给出基于滑动窗口、可完整运行的 Go 告警包与阈值标定口径。

风控不是封号,是排序

《群岛纪元》的工作室脚本在凌晨集中开采同一种矿石,再通过交易所把银币转移给主账号。运营第二天看到银币总量上涨,但单个玩家的余额都不算夸张;等人工排查完,金币已经通过跨服交易扩散。

经济风控要解决的不是“立刻抓住坏人”,而是把有限的人工审核时间排到最可疑的对象上。它输出的是告警、排序和处置建议,不是封号裁决。把这两件事混在一起,团队很快会陷入误封和申诉的泥潭。

本节讨论三类可观测信号、价格指数的边界,以及从观察到回滚的处置阶梯。具体阈值必须按产品和赛季标定,文中出现的数字都是示例值。

四类异常信号

信号观测指标窗口常见误报
来源暴增单一来源的净产出、人均产出、峰值分钟到小时活动开启、补偿发放
价格异常成交价相对中位数的偏离、成交量小时到天新版本、稀有掉落、低流动性
集中转移单一对手方占玩家交易额的比例小时到天固定队、商人、亲友赠送
工作室行为登录时段、操作间隔、同 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 属性、成长与状态机 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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