《游戏服务端架构·平台化与架构治理》4.2 处置、申诉与复核

封禁只是处置阶梯的倒数第二级,但很多团队把它当成唯一按钮。本节把处置拆成提示、限流、隔离、临封、永封与追缴六级,给出每一级的玩家可见文案、申诉时限与证据要求,并用可运行的 Go 夹具演示状态机、复核独立性与改判补偿。文末给出常见失败路径与检查表。

封号不是一个按钮

《裂隙战场》的一次排位赛事故里,值班同学收到“疑似透视”的举报,直接在后台点了永久封禁。玩家第二天在社区发帖,附上自己的比赛回放和硬件配置,证明那段“穿墙视角”是观战系统回放插值造成的。帖子在半天内获得上千回复,运营才意识到:处罚已经执行,但没有申诉入口,也没有人能证明当时看到了什么证据。

这类问题的根因通常不是某个值班同学判断失误,而是系统只提供了“封”和“不封”两个选项。没有中间档,就没有试错空间;没有申诉流程,错误就无法被纠正;没有复核记录,下一次还会犯同样的错。处置系统的目标不是“更快地封号”,而是让每一级处罚都可解释、可申诉、可撤销。这一节按处置阶梯、申诉、复核、补偿、SLA 的顺序展开。

处置阶梯与玩家可见文案

处罚应该像调音台,而不是开关。北辰工作室把处置分成六级,每级对应不同的证据门槛和玩家感知:

级别触发条件典型时长玩家可见文案可逆性谁可以发起
提示首次轻微异常即时“检测到异常操作,请检查网络或客户端环境”高自动
限流高频请求或疑似脚本10 分钟至 24 小时“当前操作过于频繁,请稍后再试”高自动
隔离对局数据异常,需进一步观察单局或数小时“本局数据正在校验,暂时无法参与匹配”高自动或值班
临时封禁跨层证据成立1 至 7 天“账号因违反用户协议被临时限制,可在游戏内申诉”中值班 + 复核
永久封禁严重或反复违规永久“账号因严重违规被永久限制,可在 7 天内申诉”低双人复核
追缴确认非法所得一次性“异常获得的道具与货币将被回收”低双人复核

文案不是装饰。它决定了玩家是否知道发生了什么、去哪里申诉、还剩多少时间。最糟糕的文案是“账号异常,请联系客服”:玩家不知道异常是什么,也不知道联系哪个客服,最后只能去社区发帖。

每一级都要有明确的证据门槛。提示和限流可以只依赖单层信号;隔离要求至少一个服务端行为信号;临时封禁要求跨层佐证;永久封禁要求证据可复现,并且经过独立复核。追缴还要额外确认资产流向,避免把正常交易一起回收。

阶梯还规定了升级路径。原则上一次只升一级,跳过中间档需要紧急标记和书面理由。批量脚本、资金大规模转移这类正在发生的攻击可以跳级,但跳级记录必须进入审计,事后能回答“为什么这次没有先隔离”。处罚命令本身的幂等与补偿复用3.2 GM 命令的幂等与补偿 的模型,本节不重复实现。

申诉:入口、时限、证据

申诉流程的第一个问题是入口在哪里。只放在官网表单里,等于过滤掉大部分移动端玩家;只放在游戏内,又会让已被封禁、无法登录的玩家找不到入口。可用的组合是:

  • 游戏内客服中心:账号还能登录时使用,自动带入角色 ID、区服和最近对局。
  • 官网申诉页:账号无法登录时使用,要求填写账号标识和联系方式。
  • 邮件通道:面向海外或特殊支付渠道,作为兜底,不承诺同等 SLA。

时限要写清楚,并且从最后一次处置生效时间起算,不是从玩家发现的时间起算。示例政策是临时封禁 7 天、永久封禁 30 天、追缴 7 天。超过时限的申诉仍然接收,但进入普通工单队列,不保证复核;这样既守住流程,也不把玩家彻底关在门外。

证据要求要分层,不能一律要求“提供录像”:

证据类型获取难度适用场景系统能否自动带出
对局 ID 与时间低所有对局类处罚能
战斗回放低透视、脚本、加速能,保留期内
充值或交易记录中追缴、误封付费账号部分
设备与网络信息中多账号关联误判能,需脱敏
第三方录像或截图高补充证据不能

系统能自动带出的证据不要让玩家手填。玩家填得越少,申诉质量越稳定,客服也越容易比对。玩家自述仍然重要,但它的作用是解释上下文,不是替代系统证据。

复核人独立性

复核最容易形式化的地方,是让处罚发起人自己点“维持”。这不是复核,是二次确认。

独立性至少包含三条规则:

  1. 发起人不能复核自己的案子。同一个人可以在紧急情况下先处置,但复核必须换人。
  2. 规则作者不能单独复核规则命中。规则作者最了解规则的意图,也最容易忽略规则的盲区。
  3. 永久封禁和追缴需要双人复核。两个人分别看证据,结论一致才执行;结论不一致时升级到风控负责人,而不是少数服从多数。

轮转和利益冲突也要考虑。如果复核人长期固定看某一类案子,会形成惯性;如果复核人与被处罚玩家有社交关系,应该主动回避。系统层面可以记录复核人、复核时间、结论和备注,但不应允许复核人修改原始证据。

复核不是重新跑一遍规则。规则已经跑过了,复核要看的是:证据是否完整、归因是否唯一、处置是否与阶梯匹配、有没有更轻的替代方案。一个常见的改判理由是“证据成立,但处罚过重”,这类改判不需要推翻事实,只需要调整级别。

新证据如何推翻旧结论

申诉过程中经常出现新证据:玩家提供了另一角度录像,客服找到了误报的版本 bug,或者技术团队发现回放插值会导致视觉穿墙。新证据到达时,系统不能悄悄改掉原结论,而应该重开案件。

重开的语义要明确:

  • 原结论保留在历史里,不删除、不覆盖。
  • 案件状态回到“待复核”,原复核人清空,重新分配独立复核人。
  • 新证据追加到证据链,标注来源和时间。
  • 如果新结论与原结论相反,必须记录改判理由,并触发补偿流程。

这样做的好处是审计可追溯。半年后回看,能知道当时为什么封、为什么改、谁改的、依据是什么。坏处是流程变长,但这正是高风险处置应该付出的成本。

有一种情况不需要重开:玩家只是重复提交相同证据、表达不满,没有新增事实。这类申诉应该合并到原案,避免同一案件被反复排队,挤占真正需要复核的案例。

补偿与信誉修复

改判之后,仅仅解封是不够的。玩家在封禁期间损失的时长、错过的活动、被清空的排名,都需要补偿。补偿至少覆盖三类:

补偿类型例子注意点
货币与道具返还被追缴的资源,补发活动保底按邮件发放,保留领取记录
时间与进度补发赛季积分、延长通行证按封禁天数折算,不搞模糊表述
信誉修复恢复匹配信誉分、移除违规标记与对外展示的信用体系联动

补偿不是“封口费”。文案要说明这是误判纠正,而不是施舍;补偿范围要写清楚,避免玩家预期无限扩大。信誉修复尤其重要:很多游戏的匹配、交易、公会功能都依赖信誉分,解封但信誉不恢复,等于处罚还在继续。

补偿也要防止被滥用。申诉改判率异常高的规则,应该回到4.1 信号采集与误伤控制 重新评估,而不是靠补偿把问题抹平。

SLA 与队列

申诉如果没有时限,就会变成无限期的情绪消耗。SLA 要分两段:首次响应和最终结论。

案件类型首次响应最终结论超时动作
临时封禁24 小时72 小时自动延长观察期,不自动解封
永久封禁48 小时7 天升级到风控负责人
追缴24 小时72 小时暂停追缴执行

示例值来自北辰工作室当前阶段,不是行业标准。关键是超时动作要预先定义:超时不是“再等等”,而是触发一个明确的兜底路径。临时封禁到期而申诉未决时,先解封再继续复核,通常比让玩家一直等着更安全;永久封禁则不能因为超时自动解封,但必须升级。

队列要按风险和时限排序,而不是按提交时间。永久封禁、付费账号、赛事相关账号优先级更高;同一玩家的重复申诉合并处理。队列长度和超时率要进入6.1 游戏服务端怎么定义 SLO 的运营指标,而不是只存在于客服主管的表格里。

数据模型与可运行片段

internal/enforcementappeal 把处置做成状态机:Escalate 控制阶梯与跳级,FileAppeal 校验时限,AssignReviewer 强制独立性,Review 与 Reopen 处理改判和新证据,Compensate 约束补偿不能为空,SLABreached 判断是否超时。

// file: internal/enforcementappeal/enforcementappeal.go
// tier: L1
// verify: go test ./internal/enforcementappeal/
package enforcementappeal

import (
	"errors"
	"fmt"
	"time"
)

// Stage is the player-visible enforcement step, ordered from lightest to
// heaviest. Operations may skip a rung only as a documented emergency.
type Stage string

const (
	StageNotice   Stage = "notice"
	StageThrottle Stage = "throttle"
	StageIsolate  Stage = "isolate"
	StageTempBan  Stage = "temp_ban"
	StagePermBan  Stage = "perm_ban"
	StageClawback Stage = "clawback"
)

var ladder = []Stage{StageNotice, StageThrottle, StageIsolate, StageTempBan, StagePermBan, StageClawback}

func stageRank(s Stage) (int, bool) {
	for i, v := range ladder {
		if v == s {
			return i, true
		}
	}
	return 0, false
}

// Status tracks the case, not the account. A case can be active while the
// player keeps playing under a throttle.
type Status string

const (
	StatusActive     Status = "active"
	StatusAppealed   Status = "appealed"
	StatusOverturned Status = "overturned"
	StatusUpheld     Status = "upheld"
)

// Outcome is the review conclusion. It is empty until a reviewer decides.
type Outcome string

const (
	OutcomeNone       Outcome = ""
	OutcomeOverturned Outcome = "overturned"
	OutcomeUpheld     Outcome = "upheld"
)

// Appeal is the player's filing. Deadline is copied into the case so the
// decision survives a later policy change.
type Appeal struct {
	FiledAt   time.Time
	Deadline  time.Duration
	Statement string
	Evidence  []string
}

// Case is one enforcement action and everything that happened after it.
type Case struct {
	ID         string
	PlayerID   string
	Stage      Stage
	IssuedBy   string
	IssuedAt   time.Time
	ExpiresAt  time.Time
	Evidence   []string
	Status     Status
	Appeal     *Appeal
	Reviewer   string
	Outcome    Outcome
	ReopenedAt time.Time
}

// Escalate moves a case to a heavier stage. Skipping a rung requires both an
// emergency flag and a written reason; an empty evidence list never escalates.
func Escalate(c Case, target Stage, duration time.Duration, emergency bool, reason string, now time.Time) (Case, error) {
	if c.Status != StatusActive {
		return c, errors.New("enforcementappeal: only an active case can escalate")
	}
	cur, ok := stageRank(c.Stage)
	if !ok {
		return c, fmt.Errorf("enforcementappeal: unknown current stage %q", c.Stage)
	}
	next, ok := stageRank(target)
	if !ok {
		return c, fmt.Errorf("enforcementappeal: unknown target stage %q", target)
	}
	if next <= cur {
		return c, errors.New("enforcementappeal: target must be heavier than current stage")
	}
	if next-cur > 1 {
		if !emergency {
			return c, errors.New("enforcementappeal: skipping a stage requires emergency")
		}
		if reason == "" {
			return c, errors.New("enforcementappeal: emergency escalation requires a reason")
		}
	}
	if len(c.Evidence) == 0 {
		return c, errors.New("enforcementappeal: escalation requires at least one evidence item")
	}
	if target == StageTempBan && duration <= 0 {
		return c, errors.New("enforcementappeal: temporary ban requires a positive duration")
	}
	c.Stage = target
	c.IssuedAt = now
	c.ExpiresAt = time.Time{}
	if target == StageTempBan {
		c.ExpiresAt = now.Add(duration)
	}
	return c, nil
}

// FileAppeal records a player appeal inside the window measured from the last
// enforcement action. A late appeal is rejected here and must go to support.
func FileAppeal(c Case, now time.Time, statement string, evidence []string, window time.Duration) (Case, error) {
	if c.Status != StatusActive {
		return c, errors.New("enforcementappeal: case is not open for an appeal")
	}
	if statement == "" {
		return c, errors.New("enforcementappeal: appeal statement is required")
	}
	if window <= 0 {
		return c, errors.New("enforcementappeal: appeal window must be positive")
	}
	if now.Before(c.IssuedAt) {
		return c, errors.New("enforcementappeal: appeal cannot precede the action")
	}
	if now.Sub(c.IssuedAt) > window {
		return c, errors.New("enforcementappeal: appeal window has closed")
	}
	c.Appeal = &Appeal{
		FiledAt:   now,
		Deadline:  window,
		Statement: statement,
		Evidence:  append([]string(nil), evidence...),
	}
	c.Status = StatusAppealed
	return c, nil
}

// AssignReviewer enforces independence: the operator who issued the action
// cannot review it, and a case gets exactly one reviewer per round.
func AssignReviewer(c Case, reviewer string) (Case, error) {
	if c.Status != StatusAppealed || c.Appeal == nil {
		return c, errors.New("enforcementappeal: only an appealed case can be assigned")
	}
	if reviewer == "" {
		return c, errors.New("enforcementappeal: reviewer is required")
	}
	if reviewer == c.IssuedBy {
		return c, errors.New("enforcementappeal: issuer cannot review the same case")
	}
	if c.Reviewer != "" {
		return c, errors.New("enforcementappeal: case already has a reviewer")
	}
	c.Reviewer = reviewer
	return c, nil
}

// Review closes the current appeal. Overturning restores the player and makes
// the case eligible for compensation; upholding keeps the action in force.
func Review(c Case, outcome Outcome, note string, now time.Time) (Case, error) {
	if c.Status != StatusAppealed || c.Appeal == nil {
		return c, errors.New("enforcementappeal: no open appeal to review")
	}
	if c.Reviewer == "" {
		return c, errors.New("enforcementappeal: an independent reviewer must be assigned first")
	}
	if outcome != OutcomeOverturned && outcome != OutcomeUpheld {
		return c, errors.New("enforcementappeal: outcome must be overturned or upheld")
	}
	if note == "" {
		return c, errors.New("enforcementappeal: review note is required")
	}
	c.Outcome = outcome
	if outcome == OutcomeOverturned {
		c.Status = StatusOverturned
	} else {
		c.Status = StatusUpheld
	}
	return c, nil
}

// Reopen puts a decided case back into review when new evidence arrives. The
// reviewer is cleared so the new round cannot reuse the old conclusion, and
// the previous outcome is discarded rather than overwritten silently.
func Reopen(c Case, newEvidence []string, now time.Time) (Case, error) {
	if c.Status != StatusUpheld && c.Status != StatusOverturned {
		return c, errors.New("enforcementappeal: only a decided case can be reopened")
	}
	if len(newEvidence) == 0 {
		return c, errors.New("enforcementappeal: reopening requires new evidence")
	}
	c.Evidence = append(append([]string(nil), c.Evidence...), newEvidence...)
	c.Status = StatusAppealed
	c.Outcome = OutcomeNone
	c.Reviewer = ""
	c.ReopenedAt = now
	return c, nil
}

// Compensation is what the player receives after an overturned action. An
// empty compensation is rejected because a silent overturn does not repair
// trust.
type Compensation struct {
	Currency   int64
	Items      []string
	Reputation int
	Note       string
}

// Compensate validates and stamps the compensation for an overturned case.
func Compensate(c Case, comp Compensation) (Compensation, error) {
	if c.Status != StatusOverturned {
		return comp, errors.New("enforcementappeal: compensation requires an overturned case")
	}
	if comp.Currency < 0 || comp.Reputation < 0 {
		return comp, errors.New("enforcementappeal: compensation values must not be negative")
	}
	if comp.Currency == 0 && comp.Reputation == 0 && len(comp.Items) == 0 {
		return comp, errors.New("enforcementappeal: compensation must not be empty")
	}
	if comp.Note == "" {
		comp.Note = "处罚已撤销,补偿已发放"
	}
	return comp, nil
}

// SLABreached reports whether an open appeal has waited longer than the
// published review SLA. Decided cases never breach, even if they took long.
func SLABreached(c Case, now time.Time, sla time.Duration) bool {
	if c.Appeal == nil || c.Status != StatusAppealed || sla <= 0 {
		return false
	}
	return now.Sub(c.Appeal.FiledAt) > sla
}

本机验证命令与结果:

cd .local/checks/gsarch-lab
go test -count=1 ./internal/enforcementappeal/
ok  gsarchlab/internal/enforcementappeal

该包共有 6 个表驱动测试,覆盖阶梯升级与紧急跳级、申诉时限、复核人独立性、改判与重开、补偿校验、SLA 判定;gofmt -l 无输出。

常见失败路径

失败路径现象正确做法
发起人自己复核维持率接近 100%系统强制换人,永久封禁双人复核
申诉入口只在官网移动端申诉量骤降游戏内与官网双入口
超时自动解封永久封禁黑产利用拖延战术超时升级,不自动解封
新证据直接覆盖旧结论审计无法还原重开案件,保留历史
补偿只有一句“已解封”玩家二次投诉货币、进度、信誉三类分别处理
申诉队列按时间排序高风险案件长期积压按风险与时限排序

小结

处置不是封号按钮,而是一条有层级的路径:提示、限流、隔离、临时封禁、永久封禁、追缴。每一级都要有证据门槛和玩家可见文案。申诉要有多入口、明确时限和可自动带出的证据。复核要独立,永久封禁要双人。新证据到达时重开案件,不覆盖历史。改判之后补偿货币、进度和信誉,而不是只解封。

下一节把视角从“单个玩家”拉到“黑产整体”:哪些数据必须服务端权威,哪些接口必须限频,哪些奖励必须延迟发放,以及如何让对抗成本高于黑产收益。

阅读导航:上一节:4.1 信号采集与误伤控制 · 下一节:4.3 黑产对抗的架构边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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