一张“背包少了三件道具”的工单
玩家提交工单,说昨晚领取的活动奖励没有进背包。客服在后台查到了订单,也查到了邮件已读记录,但背包里确实没有对应道具。最直接的做法是打开数据库,执行一条 UPDATE inventory SET count = count + 3,然后把工单标记为完成。
这条 SQL 会同时绕过四件事:物品数量上限校验、背包变更事件、审计日志和幂等保护。如果玩家其实已经领取,只是查错了角色,这次修改就变成了多发;如果修复脚本被执行两次,就会多发六件。客服没有恶意,问题在于工具把“改库”做成了一个按钮。
这一节讨论客服工具的边界和数据修复的工程流程。客服话术、工单系统选型和排班不在范围内;账号安全与登录凭据属于卷二;审计日志的留存与取证在卷三已经讨论过,这里只引用结论:修复必须留下可关联到工单、脚本、执行人和前后值的证据。
客服能看到什么
客服需要判断问题,但判断不需要“什么都能看、什么都能改”。工具的能力应当按“读”和“写”分开,读里再按脱敏程度分开。
| 能力 | 默认 | 说明 |
|---|---|---|
| 查看角色基础资料 | 允许 | 昵称、等级、区服、在线状态 |
| 查看订单状态 | 允许 | 订单号、金额档位、支付状态,不显示完整渠道流水 |
| 查看背包与邮件 | 允许 | 物品 ID、数量、领取状态,不显示内部定价 |
| 查看敏感字段 | 受控 | 设备号、支付明细、聊天原文需临时授权 |
| 只读查询 | 允许 | 预置查询模板,不接受任意 SQL |
| 受控导出 | 审批 | 导出范围、用途、到期时间、水印 |
| 直接改库 | 禁止 | 只能提交修复工单,由脚本执行 |
“只读查询”不等于“给客服一个 SQL 框”。任意 SQL 既能绕过脱敏视图,也能拖垮主库。正确做法是预置查询模板:按角色 ID 查订单、按订单号查发放记录、按时间段查登录异常。模板有参数、有权限、有超时,结果列也经过脱敏。
“受控导出”要回答四个问题:导出了哪些字段、给谁、用来做什么、什么时候删除。导出文件带水印和到期时间,接收方要回执;到期未删除的导出要能被追踪。导出本身是双人复核动作,权限模型已在 3.1 讨论过。
客服工具不应该持有写库凭据。它调用的是修复服务,修复服务再调用各业务系统的受控接口。这样“谁能改什么”由服务层判定,而不是由数据库账号判定;客服工具被攻破,攻击者也拿不到直接写库的能力。
从工单到脚本
数据修复不是“写一条 SQL”,而是一条有状态、有证据、可回滚的流水线。工单只是入口,真正的执行单元是经过评审的修复脚本。
| 状态 | 进入条件 | 产出 |
|---|---|---|
| 草稿 | 客服描述问题、附角色与订单 | 修复范围与预期结果 |
| 已复核 | 另一名客服或运营确认复现 | 复现步骤、影响面 |
| 已批准 | 负责人确认脚本与回滚方案 | 脚本哈希、执行窗口 |
| 执行中 | 灰度环境通过、快照就绪 | 逐步执行记录 |
| 已验证 | 前后值对比通过 | 验证证据 |
| 已回滚 | 执行失败或验证不通过 | 回滚证据 |
| 失败 | 执行中断且无法继续 | 失败原因、残留状态 |
状态机的作用不是“多几个按钮”,而是让每个状态都有明确的进入条件和产出。草稿不能直接跳到执行中;已批准的脚本不能在没有快照的情况下执行;执行失败必须留下残留状态,不能简单标记为完成。
修复脚本要满足五个条件:
- 只处理工单声明的目标,不接受“顺便修一下”的额外范围。
- 执行前校验目标状态,例如订单已支付、道具未发放、邮件未领取。
- 支持 dry-run,输出将要变更的行和前后值,不真正写入。
- 有回滚方案:快照、反向脚本,或者明确标注“不可回滚”并升级审批。
- 执行后验证:对比预期值与实际值,验证不通过自动进入回滚或失败。
跨系统修复最容易出错。账号、订单、背包、邮件四个系统的数据可能互相矛盾:订单显示已支付,背包没有道具,邮件显示已领取。修复顺序应当是“账号 → 订单 → 背包 → 邮件”:先确认身份,再确认权益,再修复承载权益的背包,最后处理投递渠道。反过来先补邮件,可能在背包修复后又发一次,造成重复。
修复前要做一次对账:以哪个系统为准、差异是多少、修复后各系统应该是什么值。对账结果写进工单,执行后再对一次。没有对账的“修复”只是把一处错误搬到另一处。
代码:状态机与执行前校验
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 |
| 跨系统顺序错误 | 邮件补了,背包又补一次 | 按账号到邮件的顺序执行 |
| 证据缺失 | 事后说不清改了什么 | 记录前后值、脚本哈希、执行人 |
| 无法回滚 | 执行失败后残留脏数据 | 快照或反向脚本,否则升级审批 |
上线前至少确认六件事:
- 客服工具只有只读查询和工单入口,没有直接写库能力。
- 查询走预置模板,结果列经过脱敏。
- 导出有审批、范围、水印、到期时间和回执。
- 修复脚本有前置校验、dry-run、回滚方案和脚本哈希。
- 跨系统修复按账号、订单、背包、邮件排序,并做前后对账。
- 每个状态都有证据,验证不通过能自动进入回滚或失败。
小结
客服工具的价值是“让客服快速判断”,不是“让客服快速改库”。读的能力可以宽,但要脱敏、可审计;写的能力必须窄,统一走修复服务。数据修复则要把工单、脚本、评审、灰度、证据和回滚串成一条状态机,让每一次修改都能解释、能验证、能撤销。
本章三节分别回答了三个问题:谁能做(权限与复核)、重复做怎么办(幂等与补偿)、做错了怎么修(客服工具与修复流程)。三者合起来,才是高权限后台“不闯祸”的完整边界。
阅读导航:上一节:3.2 GM 命令的幂等与补偿 · 下一节:4.1 信号采集与误伤控制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。