《游戏服务端架构·平台化与架构治理》3.3 客服工具与数据修复流程

玩家说背包少了三件道具,客服的第一反应往往是直接改库;这一改就绕过了校验、审计和幂等。本文讨论客服该看到什么、只读查询与受控导出怎么划线,拆解从工单到脚本、评审、灰度、证据和回滚的修复流程,并给出 internal/csrepair 的状态机与跨系统修复顺序实现。

一张“背包少了三件道具”的工单

玩家提交工单,说昨晚领取的活动奖励没有进背包。客服在后台查到了订单,也查到了邮件已读记录,但背包里确实没有对应道具。最直接的做法是打开数据库,执行一条 UPDATE inventory SET count = count + 3,然后把工单标记为完成。

这条 SQL 会同时绕过四件事:物品数量上限校验、背包变更事件、审计日志和幂等保护。如果玩家其实已经领取,只是查错了角色,这次修改就变成了多发;如果修复脚本被执行两次,就会多发六件。客服没有恶意,问题在于工具把“改库”做成了一个按钮。

这一节讨论客服工具的边界和数据修复的工程流程。客服话术、工单系统选型和排班不在范围内;账号安全与登录凭据属于卷二;审计日志的留存与取证在卷三已经讨论过,这里只引用结论:修复必须留下可关联到工单、脚本、执行人和前后值的证据。

客服能看到什么

客服需要判断问题,但判断不需要“什么都能看、什么都能改”。工具的能力应当按“读”和“写”分开,读里再按脱敏程度分开。

能力默认说明
查看角色基础资料允许昵称、等级、区服、在线状态
查看订单状态允许订单号、金额档位、支付状态,不显示完整渠道流水
查看背包与邮件允许物品 ID、数量、领取状态,不显示内部定价
查看敏感字段受控设备号、支付明细、聊天原文需临时授权
只读查询允许预置查询模板,不接受任意 SQL
受控导出审批导出范围、用途、到期时间、水印
直接改库禁止只能提交修复工单,由脚本执行

“只读查询”不等于“给客服一个 SQL 框”。任意 SQL 既能绕过脱敏视图,也能拖垮主库。正确做法是预置查询模板:按角色 ID 查订单、按订单号查发放记录、按时间段查登录异常。模板有参数、有权限、有超时,结果列也经过脱敏。

“受控导出”要回答四个问题:导出了哪些字段、给谁、用来做什么、什么时候删除。导出文件带水印和到期时间,接收方要回执;到期未删除的导出要能被追踪。导出本身是双人复核动作,权限模型已在 3.1 讨论过。

客服工具不应该持有写库凭据。它调用的是修复服务,修复服务再调用各业务系统的受控接口。这样“谁能改什么”由服务层判定,而不是由数据库账号判定;客服工具被攻破,攻击者也拿不到直接写库的能力。

从工单到脚本

数据修复不是“写一条 SQL”,而是一条有状态、有证据、可回滚的流水线。工单只是入口,真正的执行单元是经过评审的修复脚本。

状态进入条件产出
草稿客服描述问题、附角色与订单修复范围与预期结果
已复核另一名客服或运营确认复现复现步骤、影响面
已批准负责人确认脚本与回滚方案脚本哈希、执行窗口
执行中灰度环境通过、快照就绪逐步执行记录
已验证前后值对比通过验证证据
已回滚执行失败或验证不通过回滚证据
失败执行中断且无法继续失败原因、残留状态

状态机的作用不是“多几个按钮”,而是让每个状态都有明确的进入条件和产出。草稿不能直接跳到执行中;已批准的脚本不能在没有快照的情况下执行;执行失败必须留下残留状态,不能简单标记为完成。

修复脚本要满足五个条件:

  1. 只处理工单声明的目标,不接受“顺便修一下”的额外范围。
  2. 执行前校验目标状态,例如订单已支付、道具未发放、邮件未领取。
  3. 支持 dry-run,输出将要变更的行和前后值,不真正写入。
  4. 有回滚方案:快照、反向脚本,或者明确标注“不可回滚”并升级审批。
  5. 执行后验证:对比预期值与实际值,验证不通过自动进入回滚或失败。

跨系统修复最容易出错。账号、订单、背包、邮件四个系统的数据可能互相矛盾:订单显示已支付,背包没有道具,邮件显示已领取。修复顺序应当是“账号 → 订单 → 背包 → 邮件”:先确认身份,再确认权益,再修复承载权益的背包,最后处理投递渠道。反过来先补邮件,可能在背包修复后又发一次,造成重复。

修复前要做一次对账:以哪个系统为准、差异是多少、修复后各系统应该是什么值。对账结果写进工单,执行后再对一次。没有对账的“修复”只是把一处错误搬到另一处。

代码:状态机与执行前校验

internal/csrepair 把工单状态、前置检查、证据记录和跨系统顺序放在一个小包里。状态迁移是显式的白名单;复核人不能是工单发起人;没有 dry-run 证据不能进入执行;验证和回滚都必须有对应证据。

// file: internal/csrepair/ticket.go
// tier: L1
// verify: go test ./internal/csrepair/
package csrepair

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

// State is the repair ticket's position in a deliberately boring workflow.
// Boring is the point: every step has a name, an actor, and a record.
type State string

const (
	StateDraft      State = "draft"
	StateReviewed   State = "reviewed"
	StateApproved   State = "approved"
	StateExecuting  State = "executing"
	StateVerified   State = "verified"
	StateRolledBack State = "rolled_back"
	StateFailed     State = "failed"
)

var (
	ErrBadTransition = errors.New("csrepair: illegal state transition")
	ErrSelfReview    = errors.New("csrepair: ticket owner cannot review own repair")
	ErrChecksPending = errors.New("csrepair: prechecks have not all passed")
	ErrNoDryRun      = errors.New("csrepair: dry-run evidence required")
	ErrEvidence      = errors.New("csrepair: evidence missing")
	ErrUnknownSystem = errors.New("csrepair: unknown system")
	ErrDuplicate     = errors.New("csrepair: duplicate system")
	ErrBadState      = errors.New("csrepair: operation not allowed in current state")
)

var transitions = map[State]map[State]bool{
	StateDraft:      {StateReviewed: true},
	StateReviewed:   {StateApproved: true, StateDraft: true},
	StateApproved:   {StateExecuting: true, StateDraft: true},
	StateExecuting:  {StateVerified: true, StateFailed: true, StateRolledBack: true},
	StateFailed:     {StateRolledBack: true, StateDraft: true},
	StateVerified:   {},
	StateRolledBack: {},
}

// Check is one precondition. Name is stable; Detail is for the reviewer.
type Check struct {
	Name   string
	Passed bool
	Detail string
}

// Evidence is one recorded action. Kind distinguishes dry-run from apply and
// rollback so a verifier can require the right proof at the right step.
type Evidence struct {
	Kind   string
	System string
	Actor  string
	At     time.Time
	Detail string
}

// Ticket is the repair case. Owner prepares, Reviewer approves, and the
// evidence slice is append-only by convention.
type Ticket struct {
	ID       string
	State    State
	Owner    string
	Reviewer string
	Checks   []Check
	Evidence []Evidence
}

// CanTransition reports whether the state edge exists.
func CanTransition(from, to State) bool { return transitions[from][to] }

// AllPassed is intentionally strict: a skipped check is a failed check.
func AllPassed(checks []Check) bool {
	if len(checks) == 0 {
		return false
	}
	for _, c := range checks {
		if !c.Passed {
			return false
		}
	}
	return true
}

// Move advances the ticket and appends a state evidence row. Guards are
// evaluated before the state changes, so a rejected move leaves no trace.
func (t *Ticket) Move(to State, actor string, now time.Time) error {
	if !CanTransition(t.State, to) {
		return fmt.Errorf("%w: %s -> %s", ErrBadTransition, t.State, to)
	}
	switch to {
	case StateReviewed:
		if actor == "" || actor == t.Owner {
			return ErrSelfReview
		}
		t.Reviewer = actor
	case StateApproved:
		if t.Reviewer == "" || actor != t.Reviewer {
			return ErrSelfReview
		}
		if !AllPassed(t.Checks) {
			return ErrChecksPending
		}
	case StateExecuting:
		if !hasEvidence(t.Evidence, "dry-run", "") {
			return ErrNoDryRun
		}
	case StateVerified:
		if !hasEvidence(t.Evidence, "apply", "") {
			return ErrEvidence
		}
	case StateRolledBack:
		if !hasEvidence(t.Evidence, "rollback", "") {
			return ErrEvidence
		}
	}
	t.State = to
	t.Evidence = append(t.Evidence, Evidence{Kind: string(to), Actor: actor, At: now})
	return nil
}

// Record attaches evidence without changing state. It is valid only while the
// case is approved or executing, which prevents evidence from being written
// after the ticket is closed.
func (t *Ticket) Record(kind, system, actor, detail string, now time.Time) error {
	if t.State != StateApproved && t.State != StateExecuting && t.State != StateFailed {
		return ErrBadState
	}
	if kind == "" || actor == "" || detail == "" {
		return ErrEvidence
	}
	if system != "" {
		if _, ok := systemRank[system]; !ok {
			return ErrUnknownSystem
		}
	}
	t.Evidence = append(t.Evidence, Evidence{Kind: kind, System: system, Actor: actor, At: now, Detail: detail})
	return nil
}

func hasEvidence(ev []Evidence, kind, system string) bool {
	for _, e := range ev {
		if e.Kind == kind && (system == "" || e.System == system) {
			return true
		}
	}
	return false
}

// systemRank encodes the repair order that prevents a later system from
// validating against a stale upstream value. Account is repaired first, then
// the commercial record, then the inventory derived from it, then mail.
var systemRank = map[string]int{
	"account":   0,
	"order":     1,
	"inventory": 2,
	"mail":      3,
}

// PlanSystems validates and orders a repair scope. Unknown or duplicated
// systems fail instead of being silently dropped.
func PlanSystems(systems []string) ([]string, error) {
	seen := map[string]bool{}
	plan := make([]string, 0, len(systems))
	for _, s := range systems {
		if _, ok := systemRank[s]; !ok {
			return nil, fmt.Errorf("%w: %s", ErrUnknownSystem, s)
		}
		if seen[s] {
			return nil, fmt.Errorf("%w: %s", ErrDuplicate, s)
		}
		seen[s] = true
		plan = append(plan, s)
	}
	sort.SliceStable(plan, func(i, j int) bool { return systemRank[plan[i]] < systemRank[plan[j]] })
	return plan, nil
}

PlanSystems 把跨系统修复按账号、订单、背包、邮件的顺序排好,未知系统或重复系统直接拒绝。它不替业务判断“该不该修”,只保证“修的时候顺序不会制造新的不一致”。

失败路径与检查表

失败路径典型表现处理
客服直接改库绕过校验、审计和幂等工具不持有写库凭据
跳过前置校验对已领取的奖励再发一次执行前校验目标状态
没有 dry-run不知道会改多少行批准后必须先 dry-run
跨系统顺序错误邮件补了,背包又补一次按账号到邮件的顺序执行
证据缺失事后说不清改了什么记录前后值、脚本哈希、执行人
无法回滚执行失败后残留脏数据快照或反向脚本,否则升级审批

上线前至少确认六件事:

  1. 客服工具只有只读查询和工单入口,没有直接写库能力。
  2. 查询走预置模板,结果列经过脱敏。
  3. 导出有审批、范围、水印、到期时间和回执。
  4. 修复脚本有前置校验、dry-run、回滚方案和脚本哈希。
  5. 跨系统修复按账号、订单、背包、邮件排序,并做前后对账。
  6. 每个状态都有证据,验证不通过能自动进入回滚或失败。

小结

客服工具的价值是“让客服快速判断”,不是“让客服快速改库”。读的能力可以宽,但要脱敏、可审计;写的能力必须窄,统一走修复服务。数据修复则要把工单、脚本、评审、灰度、证据和回滚串成一条状态机,让每一次修改都能解释、能验证、能撤销。

本章三节分别回答了三个问题:谁能做(权限与复核)、重复做怎么办(幂等与补偿)、做错了怎么修(客服工具与修复流程)。三者合起来,才是高权限后台“不闯祸”的完整边界。

阅读导航:上一节:3.2 GM 命令的幂等与补偿 · 下一节:4.1 信号采集与误伤控制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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