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。修复这件事的可信度,最终就落在这些会失败的断言上——它们把「修完要对账、对不上要能回滚」从流程文档里的要求,变成了代码里的硬约束。
本节小结
这一节把客服数据修复做成了一条有闸门的流水线:
- 工单状态机单向、不跳步:
submitted → reviewed → executed → receipted → archived,任何表外迁移都被拒绝,审核步骤无法绕过,执行过的工单无法回退重跑。 - 修复脚本三关护栏:操作白名单封闭、二次确认必填、回滚闭包随快照返回,且校验先于应用。
- 前后快照对账:
Reconcile既查目标字段的差值,也查其它字段有没有被误碰,对账不过就不允许走到回执。
到这里,GM 后台与运营控制台的权限、命令、修复三块就拼齐了。但运营控制台再稳,也挡不住机房级的灾难。下一章进入容灾与演练:从 8.1 备份与恢复 开始,回答「数据真的丢了,怎么救回来」。
阅读导航:上一节:7.2 命令、补偿与重试 · 下一节:8.1 备份与恢复 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。