《游戏服务端架构·平台化与架构治理》2.3 活动状态、回滚与审计

活动出错后,团队最容易混淆三件事:回滚配置、追回已发奖励、给玩家补偿。本文用七个状态描述活动生命周期,区分配置回滚与发奖回滚的边界,说明已发奖励的四种处理策略,并把字段级 diff、工单依据和哈希链审计做成可运行实现 internal/eventaudit,附表驱动测试。

活动结束后的三个问题

《裂隙战场》的限时兑换活动配置错了倍率。运营在开服不久后发现,先暂停了活动入口,然后问了三句话:已经发出去的奖励能不能收回?是谁把倍率改高了一个数量级的?改的时候有没有工单?

第一个问题没有统一答案。奖励已经进了玩家背包,有的被消耗,有的被交易,有的被丢弃。直接扣回会把玩家余额扣成负数;不扣回则预算超支。第二个问题查了半小时:配置平台只有“最后修改人”,没有字段级记录,最后修改人还是共用账号。第三个问题更麻烦:工单系统里有几张相近的工单,没有一张写明了具体字段。

这三个问题分别对应状态、回滚语义和审计。它们经常被当成一件事——“出事了就回滚”——但实际是三套不同的动作,失败后果也不同。本文讨论活动生命周期的状态机、配置回滚与发奖回滚的边界,以及怎样把“谁改了什么、依据什么”做成可验证的记录。GM 权限与审批放在下一章,发布节奏与错误预算放在第 6 章,这里不重复。

状态机:七个状态,少一个就说不清

活动不是“开”和“关”两个状态。草稿、待审、已排期、进行中、暂停、结束、回滚,每个状态对应不同的可执行动作和不同的对外可见性。

状态含义允许进入的下一状态对外表现
草稿编辑中,未提交待审不可见
待审等待审批,字段锁定草稿、已排期不可见
已排期审批通过,等待开始时间待审、进行中、回滚通常不可见,可预告
进行中时间窗内,正在发奖暂停、结束、回滚入口可见,奖励可领
暂停临时停止发奖,保留状态进行中、结束、回滚入口可显示“暂停”
结束时间窗结束,停止新发奖回滚入口关闭,历史可查
回滚已撤销,保留事实无入口关闭,标记异常

状态机的价值在于拒绝不合法的跳转。草稿直接到进行中,意味着审批记录缺失;已排期直接到结束,意味着活动从未真正开始,却留下了一条“结束”记录;回滚之后还能回到进行中,意味着同一个活动可以反复复活,审计链会出现多个互相矛盾的“最终状态”。

回滚被设计成终态,不是因为它永远不能恢复,而是因为恢复应该创建新活动,而不是让旧活动在状态机里来回跳。新活动有新的 ID 和新的配置版本,旧活动保留“曾经回滚”的事实。这样复盘时不会出现“这个活动到底开过几次”的歧义。

结束状态允许进入回滚,是为了处理活动结束后的补偿。玩家在活动期间多领了奖励,活动结束后才发现,仍然需要把这次发放标记为异常并进入补偿流程;但此时活动本身已经结束,回滚的是发奖记录,不是活动入口。

回滚语义:配置、发奖、补偿是三件事

“回滚”在不同团队嘴里经常指三件不同的事。把它们分开,才能回答“已发奖励能不能追回”。

对象回滚什么已发生的数据能否自动玩家感知
配置可见版本退回上一版不影响已经发出的奖励可以入口或数值变化
发奖停止继续发,标记异常批次已发奖励仍在玩家账上部分可以邮件、客服、补偿
状态活动从进行中转为暂停或回滚状态变化本身被记录可以活动停止或标记异常

配置回滚只影响未来的读取,这一点在上一节已经讨论过。发奖回滚复杂得多,因为奖励已经离开系统。常见的处理策略有四种,选择依据不是“哪个最正确”,而是“哪种代价可以接受”。

策略做法适用代价
直接扣回从背包或余额扣除多发部分奖励未消耗、可定位玩家可能变成负余额,客诉高
差额补偿少发的补足,多发的记录不追误差小、影响面广预算超支,但体验稳定
冻结后续停止该活动继续发奖,已发不追错误仍在扩大已发部分成为沉没成本
人工审核按批次抽样,逐单处理高价值道具、疑似欺诈处理慢,需要客服与风控

配置错误导致的超发,通常不应该直接扣回。玩家没有做错任何事,扣回会把平台的操作失误转嫁成玩家的损失;更合理的做法是停止扩大、评估预算、对少发部分补偿,把超发记为一次明确的成本。只有奖励仍在托管、未领取或可精确定位时,直接扣回才是可接受的。

如果涉及欺诈,例如玩家利用配置错误反复刷取,处理依据应该是风控规则和证据链,而不是“配置错了所以全部收回”。这两种情况的审计记录、通知口径和申诉流程都不同,不能共用一套话术。

审计:不是操作日志,而是证据

很多团队有一张 operator_log 表,记录了“谁在什么时候调用了哪个接口”。这只能证明接口被调用过,不能证明改了哪个字段、从什么改成什么、依据哪张工单。活动事故复盘需要的是后者。

必须记录为什么缺失后果
操作者与账号区分个人、共用账号、服务账号无法追责到人
工单号证明变更有依据无法判断是授权操作还是误操作
活动 ID 与配置版本定位到具体发布物同名活动之间无法区分
状态迁移证明活动何时进入暂停或回滚时间线只能靠聊天记录拼
字段级 before/after回答“改了什么”只能看到“保存过”
时间戳(UTC)跨时区复盘本地时间造成一小时或一天偏差

字段级记录要注意敏感信息。密钥、令牌、玩家隐私字段不应该以明文进入审计库,但完全省略又会让 diff 失去意义。折中做法是记录字段名和值的哈希,或者只记录“已变更”并保留受控的加密副本。审计的目的是证明变更发生过、可复核,不是把所有明文集中到一个更危险的地方。

不可篡改不是一句口号。单机数据库里的审计表,管理员仍然可以改;哈希链能发现改动,但不能阻止改动。生产环境通常需要三层:应用层只追加不更新、存储层使用写一次或对象锁、审计副本定期同步到独立账号。哈希链负责发现“有人绕过了前两层”,不负责替代它们。

哈希链的结构很简单:每条记录包含上一条记录的哈希,自己的哈希覆盖全部字段。改动任何一条旧记录,它自己的哈希对不上;删除一条,后续记录的前置哈希对不上;插入一条,序号和前置哈希同时暴露。验证时返回第一个断点,运维可以隔离断点之后的记录,而不是丢弃整条链。

审计实现:audit.go

下面的实现把状态迁移、字段 diff 和哈希链放在一个包里。Append 在写入前校验身份、工单、时间戳和状态迁移;Verify 重新计算每条记录的哈希并检查链接。存储适配器不在这个包里,生产环境需要把它换成只追加的存储。

// file: internal/eventaudit/audit.go
// tier: L1
// verify: go test ./internal/eventaudit/
package eventaudit

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"sort"
	"time"
)

// State is the lifecycle position of one activity. RolledBack is a state,
// not a deletion: the activity happened, and the record must say so.
type State string

const (
	StateDraft         State = "draft"
	StatePendingReview State = "pending_review"
	StateScheduled     State = "scheduled"
	StateRunning       State = "running"
	StatePaused        State = "paused"
	StateEnded         State = "ended"
	StateRolledBack    State = "rolled_back"
)

var allowedTransitions = map[State][]State{
	StateDraft:         {StatePendingReview},
	StatePendingReview: {StateDraft, StateScheduled},
	StateScheduled:     {StatePendingReview, StateRunning, StateRolledBack},
	StateRunning:       {StatePaused, StateEnded, StateRolledBack},
	StatePaused:        {StateRunning, StateEnded, StateRolledBack},
	StateEnded:         {StateRolledBack},
	StateRolledBack:    {},
}

func ValidState(s State) bool {
	_, ok := allowedTransitions[s]
	return ok
}

func CanTransition(from, to State) bool {
	for _, candidate := range allowedTransitions[from] {
		if candidate == to {
			return true
		}
	}
	return false
}

// Transition rejects an edge that the lifecycle does not define. A direct
// draft-to-running jump is not a shortcut; it means the review record is
// missing and the audit trail would have a hole.
func Transition(from, to State) error {
	if !ValidState(from) {
		return fmt.Errorf("eventaudit: unknown source state %q", from)
	}
	if !ValidState(to) {
		return fmt.Errorf("eventaudit: unknown target state %q", to)
	}
	if !CanTransition(from, to) {
		return fmt.Errorf("eventaudit: transition %s -> %s is not allowed", from, to)
	}
	return nil
}

// Change is one field-level difference. Before and After stay strings so the
// audit log can store numbers, booleans and timestamps without a schema
// migration for every new field.
type Change struct {
	Field  string `json:"field"`
	Before string `json:"before"`
	After  string `json:"after"`
}

// Diff reports changed fields in stable order and optionally masks fields
// whose values must not be copied into the audit store.
func Diff(before, after map[string]string, redacted map[string]bool) []Change {
	keys := make(map[string]bool, len(before)+len(after))
	for k := range before {
		keys[k] = true
	}
	for k := range after {
		keys[k] = true
	}
	ordered := make([]string, 0, len(keys))
	for k := range keys {
		ordered = append(ordered, k)
	}
	sort.Strings(ordered)

	var out []Change
	for _, field := range ordered {
		b, a := before[field], after[field]
		if b == a {
			continue
		}
		if redacted[field] {
			b, a = "***", "***"
		}
		out = append(out, Change{Field: field, Before: b, After: a})
	}
	return out
}

// Entry is one append-only audit record. Hash covers every field except
// itself and PrevHash links to the previous entry, so removing or editing an
// old record breaks verification at that point and every point after it.
type Entry struct {
	Seq      int64     `json:"seq"`
	EventID  string    `json:"event_id"`
	Actor    string    `json:"actor"`
	Ticket   string    `json:"ticket"`
	At       time.Time `json:"at"`
	From     State     `json:"from"`
	To       State     `json:"to"`
	Changes  []Change  `json:"changes"`
	PrevHash string    `json:"prev_hash"`
	Hash     string    `json:"hash"`
}

// Log is an in-memory chain. Production stores the same entries in an
// append-only table or object lock; the hash chain makes a later rewrite
// detectable even when storage permissions are weaker than intended.
type Log struct {
	entries []Entry
}

func NewLog() *Log { return &Log{} }

func (l *Log) Len() int { return len(l.entries) }

// Append validates identity, ticket and lifecycle before adding the record.
// An audit row without a ticket is not evidence; it is an anonymous edit.
func (l *Log) Append(e Entry) (Entry, error) {
	if e.Seq == 0 {
		e.Seq = int64(len(l.entries)) + 1
	}
	if e.Seq != int64(len(l.entries))+1 {
		return Entry{}, fmt.Errorf("eventaudit: sequence %d does not follow %d", e.Seq, len(l.entries))
	}
	if e.EventID == "" || e.Actor == "" || e.Ticket == "" {
		return Entry{}, fmt.Errorf("eventaudit: event id, actor and ticket are required")
	}
	if e.At.IsZero() {
		return Entry{}, fmt.Errorf("eventaudit: timestamp is required")
	}
	if e.From == "" {
		if e.To != StateDraft {
			return Entry{}, fmt.Errorf("eventaudit: a new event must start in draft")
		}
	} else {
		if e.From == e.To {
			if len(e.Changes) == 0 {
				return Entry{}, fmt.Errorf("eventaudit: no-op entry has no field changes")
			}
			if !ValidState(e.From) {
				return Entry{}, fmt.Errorf("eventaudit: unknown state %q", e.From)
			}
		} else if err := Transition(e.From, e.To); err != nil {
			return Entry{}, err
		}
	}

	e.At = e.At.UTC()
	e.Changes = append([]Change(nil), e.Changes...)
	sort.Slice(e.Changes, func(i, j int) bool { return e.Changes[i].Field < e.Changes[j].Field })
	e.PrevHash = ""
	if n := len(l.entries); n > 0 {
		e.PrevHash = l.entries[n-1].Hash
	}
	e.Hash = hashEntry(e)
	l.entries = append(l.entries, e)
	return cloneEntry(e), nil
}

// Verify recomputes every hash and transition. It returns the first broken
// position so an operator can quarantine the suffix instead of the whole log.
func (l *Log) Verify() error {
	prev := ""
	for i, e := range l.entries {
		if e.Seq != int64(i)+1 {
			return fmt.Errorf("eventaudit: entry %d has sequence %d", i, e.Seq)
		}
		if e.PrevHash != prev {
			return fmt.Errorf("eventaudit: entry %d breaks the hash chain", i)
		}
		if hashEntry(e) != e.Hash {
			return fmt.Errorf("eventaudit: entry %d content does not match its hash", i)
		}
		if e.From == "" {
			if e.To != StateDraft {
				return fmt.Errorf("eventaudit: entry %d starts outside draft", i)
			}
		} else if e.From != e.To {
			if err := Transition(e.From, e.To); err != nil {
				return fmt.Errorf("eventaudit: entry %d: %w", i, err)
			}
		}
		prev = e.Hash
	}
	return nil
}

func hashEntry(e Entry) string {
	e.Hash = ""
	raw, err := json.Marshal(e)
	if err != nil {
		panic("eventaudit: marshal entry: " + err.Error())
	}
	sum := sha256.Sum256(raw)
	return hex.EncodeToString(sum[:])
}

func (l *Log) Entries() []Entry {
	out := make([]Entry, len(l.entries))
	for i, e := range l.entries {
		out[i] = cloneEntry(e)
	}
	return out
}

func cloneEntry(e Entry) Entry {
	e.Changes = append([]Change(nil), e.Changes...)
	return e
}

三个可测试的决策点

第一,迁移必须先合法。Transition 只接受状态表里定义的边,草稿直接到进行中会被拒绝。Append 在写入前调用它,非法迁移不会进入审计链,也不会留下一条“看起来发生过”的记录。

第二,diff 必须稳定且可脱敏。Diff 对字段名排序,新增、删除、修改都能表达;敏感字段用 *** 替换前后值。排序保证同一组变更在不同节点上生成相同的 JSON,哈希才不会因为 map 遍历顺序而漂移。

第三,哈希链能定位断点。Verify 同时检查序号、前置哈希和自身哈希,返回第一个不一致的位置。它不能阻止有权限的人重写整条链,但能让“只改一条旧记录”这种最常见的事故立刻暴露。

失败模式与兜底

失败直接后果兜底
共用账号操作无法定位到个人禁止共用账号,服务账号单独登记
工单号为空变更没有依据Append 拒绝,工单系统回填后才允许提交
状态跳转审批记录缺失状态机拒绝,必须走完整链路
审计表可更新记录被静默改写只追加权限 + 写一次存储 + 异地副本
敏感字段明文隐私或密钥泄露diff 脱敏,保留哈希或加密副本
哈希链断裂无法证明记录完整返回断点,隔离后续记录并告警
回滚只改配置已发奖励未处理单独建立发奖回滚批次与补偿工单

兜底的原则是“让记录比事故更持久”。配置可以回滚,活动可以结束,但审计记录必须保留到复盘、审计和申诉都结束之后;保留期限按合规要求配置,示例值为 180 天,高价值活动单独延长。

事故复盘检查表

  • 活动当前状态与状态机一致,没有跳过审批或排期。
  • 配置回滚版本、发奖回滚批次、补偿方案分别记录。
  • 已发奖励的处理策略已选定,并说明不追回或追回的依据。
  • 每条变更都有操作者、工单号、活动 ID 和字段级 diff。
  • 审计记录使用 UTC 时间,跨时区复盘不需要手工换算。
  • 敏感字段已脱敏,必要的哈希或加密副本可复核。
  • 哈希链验证通过;若有断点,已隔离并告警。
  • 对外公告、客服口径和补偿邮件使用同一套事实。

常见错误

第一,把“回滚”当成一个按钮。配置回滚、发奖回滚、状态回滚的动作和后果不同,必须分别设计。

第二,直接扣回所有超发奖励。玩家可能已经把奖励消耗或交易,扣回会造成负余额和二次事故。

第三,审计只记录接口调用。没有字段级 before/after,复盘时仍然不知道改了什么。

第四,允许草稿直接进入进行中。审批链断了,事后无法证明这次发布经过谁同意。

第五,把哈希链当成防篡改的全部。它能发现改写,但不能阻止有权限的人重写整条链,存储权限和异地副本同样重要。

第六,回滚后删除原活动。事实被抹掉,下一次复盘无法解释“为什么玩家记得有过这个活动”。

小结

活动状态、回滚和审计解决的是“出事之后还能不能讲清楚”。状态机保证每一步都有来源,回滚语义把配置、发奖和补偿分开,审计链把操作者、工单、字段变化和时间固定下来。三者合在一起,才能回答事故复盘里最难的三句话:当时是什么状态,谁改了什么,依据是什么。

下一章从单次活动转向日常运营:GM 工具怎样划分权限,审批与双人复核在什么动作上必须强制,以及一次高权限操作怎样留下可追溯的证据。

阅读导航:上一节:2.2 热更新与灰度 · 下一节:3.1 权限、审批与双人复核 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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