《MMO 服务器开发》7.3 客服工具与数据修复流程

客服每天都在处理「我的号卡住了」「我的道具不见了」这类工单,而修复线上数据是所有运营动作里风险最高的一种。这一节把客服修复做成一条有闸门的流水线:工单走提交、审核、执行、回执、归档五态状态机,修复脚本只允许白名单操作并需二次确认,修复前后各留一份快照用于对账与回滚,并给出可跑的工单与修复断言。

7.3 客服工具与数据修复流程

前两节把 GM 后台的两块基石立住了:7.1 权限与审计 保证「谁在什么时候做了什么」有据可查,7.2 命令、补偿与重试 保证命令重复执行只生效一次、失败能补回来。但这两节处理的大多是「正常」的运营动作——发奖、调元宝、推配置。真正让人半夜爬起来的是另一类:玩家的数据坏了,要直接改。

客服收到的工单千奇百怪:「我的任务卡在第三步做不下去」「我充值到账了但道具没给」「公会仓库里少了一件装备」。这些问题的共同点是:答案不在代码里,而在某个玩家的存档数据里。修,就得直接动线上数据;而直接动线上数据,是所有运维动作里风险最高的一种——改错一个字段,可能让玩家损失付费道具,也可能让一个刷子账号白赚一堆资源。这一节把「修数据」这件危险的事,做成一条有闸门、有留痕、可对账的流水线。

工单状态机:提交 → 审核 → 执行 → 回执 → 归档

修复流程的第一步是不允许跳过审核。客服不能自己决定改什么数据,只能提交一张工单,由有权限的人审核。所以修复天然是一个多步骤流程,适合用状态机来约束:

submitted ──> reviewed ──> executed ──> receipted ──> archived
   提交          审核          执行          回执          归档

这五个状态是单向的:只能往前走,不能回退,也不能跳步。为什么强调「不能跳过 reviewed 直接 executed」?因为那等于让修复脚本在没有审核的情况下跑,等于把 7.1 的双人审批架空。为什么强调「不能回退」?因为一旦 executed 之后还能退回 reviewed,就意味着同一条工单可以反复触发修复——那是重复扣钱/重复发奖的经典来源。把合法迁移写进一张表,任何表外的迁移一律拒绝,状态机就成了一道结构性的护栏,而不是靠人记得别点错。

修复脚本的护栏:白名单、二次确认、可回滚

状态机管的是「流程」,护栏管的是「内容」。一个修复脚本要真正执行,必须过三关:

  • 白名单:脚本只允许使用一小撮预定义操作——发道具、调元宝、重置任务。一个能执行任意写操作的「修复工具」不是修复工具,是后门。白名单是封闭的,表外的操作在校验阶段就被拒,连读都不读。
  • 二次确认:脚本必须带 Confirmed 标记,代表有第二个人复核过。这是 7.1 双人审批在数据修复这一层的落地。
  • 可回滚:ApplyRepair 在返回新快照的同时,返回一个回滚闭包,能在不改数据库的前提下把状态还原回 before。修复一旦对账不过,回滚是最后一道保险。

这三关的检查顺序也很重要:先校验,后应用。ApplyRepair 第一件事就是 Validate(),不合法就直接返回错误,绝不触碰任何数据。

修复前后快照与对账

修完不代表结束,还要对账。做法是:执行前抓一份快照 before,执行后抓一份 after,然后用 Reconcile 检查「实际变化」是否等于「脚本声明的变化」。

对账要防两类错。第一类是改错量:脚本声明加 250 元宝,结果只加了 25,或者手滑加了 2500——按字段的差值一比对就露馅。第二类更隐蔽:改错字段。脚本说是调元宝,结果把道具数量也顺手动了。所以 Reconcile 不只检查目标字段的差值,还检查其它字段有没有被碰。只有目标字段变了声明值、其它字段纹丝不动,才算对账通过,工单才允许走到回执。

下面这段是 L1,go test 可以直接跑。它把工单状态机与修复护栏装进同一个文件 internal/gm/repair.go:

// file: internal/gm/repair.go
// tier: L1
// verify: go test ./internal/gm/
package gm

import (
	"errors"
	"fmt"
)

// TicketState is a step in the customer-service repair workflow.
type TicketState string

const (
	StateSubmitted TicketState = "submitted"
	StateReviewed  TicketState = "reviewed"
	StateExecuted  TicketState = "executed"
	StateReceipted TicketState = "receipted"
	StateArchived  TicketState = "archived"
)

// transitions is the workflow's state machine: submit -> review -> execute ->
// receipt -> archive. It is forward-only by construction, and any edge not in
// this map is rejected, so a ticket can never silently skip the review step or
// jump back to re-run a repair.
var transitions = map[TicketState][]TicketState{
	StateSubmitted: {StateReviewed},
	StateReviewed:  {StateExecuted},
	StateExecuted:  {StateReceipted},
	StateReceipted: {StateArchived},
	StateArchived:  {},
}

// Ticket is one repair request moving through the workflow.
type Ticket struct {
	ID      string
	Player  PlayerID
	State   TicketState
	History []TicketState
}

// NewTicket opens a ticket in the submitted state.
func NewTicket(id string, p PlayerID) *Ticket {
	return &Ticket{ID: id, Player: p, State: StateSubmitted, History: []TicketState{StateSubmitted}}
}

// Transition moves the ticket if the edge is legal, and records the step.
func (t *Ticket) Transition(next TicketState) error {
	for _, ok := range transitions[t.State] {
		if ok == next {
			t.State = next
			t.History = append(t.History, next)
			return nil
		}
	}
	return fmt.Errorf("gm: ticket %s cannot move from %s to %s", t.ID, t.State, next)
}

// RepairOp is one whitelisted repair action. A repair script may only contain
// operations from this set; anything else is refused before it can touch data.
// The whitelist is the whole guardrail: a repair tool that can run arbitrary
// writes is not a repair tool, it is a backdoor.
type RepairOp string

const (
	OpGrantItem      RepairOp = "grant_item"
	OpAdjustCurrency RepairOp = "adjust_currency"
	OpResetQuest     RepairOp = "reset_quest"
)

// repairWhitelist is the closed set of operations a repair script may use.
var repairWhitelist = map[RepairOp]bool{
	OpGrantItem: true, OpAdjustCurrency: true, OpResetQuest: true,
}

// RepairScript is a single whitelisted operation with its amount and a second
// confirmation flag.
type RepairScript struct {
	Op        RepairOp
	Player    PlayerID
	Amount    int64
	Confirmed bool
}

// Validate enforces the two guardrails a repair script must pass: the operation
// is whitelisted, and a human has confirmed it a second time. Both are checked
// before any data is read, let alone written.
func (s RepairScript) Validate() error {
	if !repairWhitelist[s.Op] {
		return fmt.Errorf("gm: repair op %q is not whitelisted", s.Op)
	}
	if !s.Confirmed {
		return errors.New("gm: repair requires a second confirmation")
	}
	return nil
}

// Snapshot is a character's repair-relevant state, taken before and after a
// repair. Comparing the two is how reconciliation and rollback both work.
type Snapshot struct {
	Player   PlayerID
	Currency int64
	Items    int
	Quests   int
}

// ApplyRepair applies a validated script to a before-snapshot. It returns the
// after-snapshot and a rollback closure that restores the before-state, so a
// repair that fails reconciliation can be undone without a database restore.
func ApplyRepair(before Snapshot, s RepairScript) (Snapshot, func() Snapshot, error) {
	if err := s.Validate(); err != nil {
		return Snapshot{}, nil, err
	}
	after := before
	switch s.Op {
	case OpGrantItem:
		after.Items += int(s.Amount)
	case OpAdjustCurrency:
		after.Currency += s.Amount
	case OpResetQuest:
		after.Quests = 0
	}
	return after, func() Snapshot { return before }, nil
}

// Reconcile checks that the delta between two snapshots matches the script's
// declared effect, and that no field outside that effect moved.
func Reconcile(before, after Snapshot, s RepairScript) error {
	switch s.Op {
	case OpGrantItem:
		if got := after.Items - before.Items; got != int(s.Amount) {
			return fmt.Errorf("gm: item delta %d, want %d", got, s.Amount)
		}
		if after.Currency != before.Currency || after.Quests != before.Quests {
			return errors.New("gm: grant_item changed an unrelated field")
		}
	case OpAdjustCurrency:
		if got := after.Currency - before.Currency; got != s.Amount {
			return fmt.Errorf("gm: currency delta %d, want %d", got, s.Amount)
		}
		if after.Items != before.Items || after.Quests != before.Quests {
			return errors.New("gm: adjust_currency changed an unrelated field")
		}
	case OpResetQuest:
		if after.Quests != 0 {
			return fmt.Errorf("gm: quests not reset, still %d", after.Quests)
		}
		if after.Currency != before.Currency || after.Items != before.Items {
			return errors.New("gm: reset_quest changed an unrelated field")
		}
	}
	return nil
}

再配一个测试 internal/gm/repair_test.go,断言状态机、护栏与对账回滚:

// file: internal/gm/repair_test.go
// tier: L1
// verify: go test ./internal/gm/
package gm

import "testing"

func TestTicketWalksTheFullWorkflow(t *testing.T) {
	tk := NewTicket("CS-2026-0001", 1001)
	if tk.State != StateSubmitted {
		t.Fatalf("new ticket state = %s, want submitted", tk.State)
	}
	for _, next := range []TicketState{StateReviewed, StateExecuted, StateReceipted, StateArchived} {
		if err := tk.Transition(next); err != nil {
			t.Fatalf("transition to %s: %v", next, err)
		}
	}
	if tk.State != StateArchived || len(tk.History) != 5 {
		t.Fatalf("state=%s history=%d, want archived and 5", tk.State, len(tk.History))
	}
}

func TestTicketRejectsSkippingReview(t *testing.T) {
	// A ticket must never go from submitted straight to executed: that would
	// let a repair run without the review step.
	tk := NewTicket("CS-2026-0002", 1002)
	if err := tk.Transition(StateExecuted); err == nil {
		t.Fatal("skipping review must be rejected")
	}
	if tk.State != StateSubmitted {
		t.Fatal("a rejected transition must not move the ticket")
	}
	_ = tk.Transition(StateReviewed)
	_ = tk.Transition(StateExecuted)
	if err := tk.Transition(StateReviewed); err == nil {
		t.Fatal("a ticket must not move backwards")
	}
}

func TestRepairRejectsNonWhitelistedOpAndNeedsConfirmation(t *testing.T) {
	before := Snapshot{Player: 1001, Currency: 10, Items: 3, Quests: 1}
	if err := (RepairScript{Op: RepairOp("drop_table"), Player: 1001, Confirmed: true}).Validate(); err == nil {
		t.Fatal("a non-whitelisted op must be refused")
	}
	if err := (RepairScript{Op: OpGrantItem, Player: 1001, Amount: 5}).Validate(); err == nil {
		t.Fatal("an unconfirmed repair must be refused")
	}
	if _, _, err := ApplyRepair(before, RepairScript{Op: OpGrantItem, Player: 1001, Amount: 5}); err == nil {
		t.Fatal("ApplyRepair must refuse an unconfirmed script")
	}
}

func TestApplyRepairReconcilesAndRollsBack(t *testing.T) {
	before := Snapshot{Player: 1001, Currency: 10, Items: 3, Quests: 2}
	s := RepairScript{Op: OpAdjustCurrency, Player: 1001, Amount: 250, Confirmed: true}

	after, rollback, err := ApplyRepair(before, s)
	if err != nil {
		t.Fatalf("ApplyRepair: %v", err)
	}
	if after.Currency != 260 {
		t.Fatalf("after.Currency = %d, want 260", after.Currency)
	}
	if err := Reconcile(before, after, s); err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	// A repair that also touched an unrelated field must fail reconciliation.
	wrong := after
	wrong.Items += 7
	if err := Reconcile(before, wrong, s); err == nil {
		t.Fatal("a currency repair that also changed items must fail reconcile")
	}
	if restored := rollback(); restored != before {
		t.Fatalf("rollback = %+v, want %+v", restored, before)
	}
}

func TestResetQuestRepairReconciles(t *testing.T) {
	before := Snapshot{Player: 1002, Quests: 4}
	s := RepairScript{Op: OpResetQuest, Player: 1002, Confirmed: true}
	after, _, err := ApplyRepair(before, s)
	if err != nil {
		t.Fatalf("ApplyRepair: %v", err)
	}
	if after.Quests != 0 {
		t.Fatalf("after.Quests = %d, want 0", after.Quests)
	}
	if err := Reconcile(before, after, s); err != nil {
		t.Fatalf("reconcile: %v", err)
	}
}

本机实跑(go test ./internal/gm/ -v)五条断言全绿:

=== RUN   TestTicketWalksTheFullWorkflow
--- PASS: TestTicketWalksTheFullWorkflow (0.00s)
=== RUN   TestTicketRejectsSkippingReview
--- PASS: TestTicketRejectsSkippingReview (0.00s)
=== RUN   TestRepairRejectsNonWhitelistedOpAndNeedsConfirmation
--- PASS: TestRepairRejectsNonWhitelistedOpAndNeedsConfirmation (0.00s)
=== RUN   TestApplyRepairReconcilesAndRollsBack
--- PASS: TestApplyRepairReconcilesAndRollsBack (0.00s)
=== RUN   TestResetQuestRepairReconciles
--- PASS: TestResetQuestRepairReconciles (0.00s)
PASS
ok  	aegis/internal/gm	0.527s

TestApplyRepairReconcilesAndRollsBack 把三件事一次断言完:对账通过时 after.Currency 恰好等于 260;一个「顺手改了道具」的修复必须让 Reconcile 失败;rollback() 必须一字不差地还原 before。修复这件事的可信度,最终就落在这些会失败的断言上——它们把「修完要对账、对不上要能回滚」从流程文档里的要求,变成了代码里的硬约束。

本节小结

这一节把客服数据修复做成了一条有闸门的流水线:

  1. 工单状态机单向、不跳步:submitted → reviewed → executed → receipted → archived,任何表外迁移都被拒绝,审核步骤无法绕过,执行过的工单无法回退重跑。
  2. 修复脚本三关护栏:操作白名单封闭、二次确认必填、回滚闭包随快照返回,且校验先于应用。
  3. 前后快照对账:Reconcile 既查目标字段的差值,也查其它字段有没有被误碰,对账不过就不允许走到回执。

到这里,GM 后台与运营控制台的权限、命令、修复三块就拼齐了。但运营控制台再稳,也挡不住机房级的灾难。下一章进入容灾与演练:从 8.1 备份与恢复 开始,回答「数据真的丢了,怎么救回来」。

阅读导航:上一节:7.2 命令、补偿与重试 · 下一节:8.1 备份与恢复 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标