《游戏服务端架构·核心系统与契约》11.2 退款、对账与差异处理

《群岛纪元》一名玩家被渠道强制退款时,游戏内金币早已转给其他角色;财务对账又发现三笔渠道已扣款、内部却没有订单。本节说明退款状态机、资产回收顺序、渠道账单与内部订单的匹配键,给出漏单、重复、金额不符、状态不同步的分类与冲正/复核/挂账边界,并附可运行的 Go 对账分类片段。

退款不是反向的支付

《群岛纪元》的一名玩家在渠道侧发起拒付,渠道没有等待游戏确认,直接从商户结算款里扣回了 648 元。此时玩家已经把充值得来的金币转给另一个角色,金币又被用来购买联盟物资。游戏侧收到退款通知时,钱已经退了,资产却不在原账户上。

这和 11.1 的支付回调是两种问题。支付的难点是“同一笔钱只发一次货”;退款的难点是“钱退回去之后,已经发出去的资产怎么办”。资产可能被消耗、转移、合成、过期,也可能被工作室快速洗走。退款系统不能假设“原路退回资产”一定成功。

三款产品的退款压力不同:

产品主要退款来源资产状态关键约束
《裂隙战场》未成年人误充、渠道拒付点券买皮肤,通常不可交易皮肤回收或封禁账号
《群岛纪元》渠道拒付、玩家申诉金币可交易,可能已洗走负余额、债务记录、交易追踪
《泡泡工坊》家长投诉、重复扣款金币与体力,轻量消耗快速退款、限额拦截

三种退款触发

触发发起方证据初始状态风险
玩家申请玩家/客服订单号、支付凭证、申诉理由requested恶意退款、资产已消耗
渠道强制支付渠道拒付/chargeback 通知approved钱已被扣回,游戏被动
风控冻结风控系统异常充值、盗刷、套现模型held误伤正常玩家

三种触发不能走同一条审批链。玩家申请需要客服判断理由和资产状态;渠道强制退款没有“是否同意”的选项,只能尽快回收资产并记录债务;风控冻结则要先冻结资产、再人工复核,避免在调查期间资产被转移。

退款状态机

requested ──approve──> approved ──start──> processing ──succeed──> succeeded
    │                     │                    │
    ├──reject──> rejected │                    └──fail──> failed ──start──┐
    └──hold────> held ────┘                                            │
                    │                                                   │
                    └──reject──> rejected        processing <───────────┘

channel_forced ──> approved        risk ──> held

两条规则决定了状态机的形状:

  • 金额在 requested 时就预留。 不是成功后才占用可退余额。否则同一订单可以同时发起多笔退款,每笔都认为自己没超限。
  • 资产回收成功才进入 succeeded。 processing -> succeeded 之前调用回收接口;回收失败就停在 processing,不能先把退款标成功再慢慢补回收。

退款金额的上限是“已支付 - 已成功退款 - 所有未终结退款预留”。渠道强制退款也受这个上限约束,但可以绕过“玩家必须与订单一致”的校验,因为发起方不是玩家。

决策:钱、资产与差异

决策点方案 A方案 B北辰选择理由
退款金额玩家填写服务端按可退余额计算B玩家可填任意金额;渠道强制退款也必须封顶
余额预留成功后才占用申请即预留B防止并发退款超退
资产回收先退钱,后回收回收成功才提交退款B钱和资产至少有一边可控
回收失败放弃并人工处理保持 processing 重试B回收接口必须幂等,重试成本低
对账差异自动改金额人工复核或挂账B钱不能被脚本静默改写
匹配键内部订单号渠道单号优先,内部订单号兜底B账单通常只带渠道单号
状态不同步全部自动同步只自动同步安全子集B已关闭订单不能被账单“改活”
差异截止无限期追日切后固定窗口B无限期差异会污染每一天的报表

这里的核心取舍是:自动化只处理状态,不处理金额。金额差异一律进入人工复核或挂账,因为任何自动改金额的规则都会在边界样本上出错,而错的代价是真实的钱。

对账:渠道账单与内部订单

对账的输入是两份事实:

  • 渠道账单:按结算日导出,通常只包含渠道单号、金额、币种、状态(已支付/已退款)、手续费。
  • 内部订单:包含内部订单号、渠道单号、金额、币种、订单状态、退款状态。

匹配顺序是“渠道单号优先,内部订单号兜底”。账单可能缺少内部订单号,但渠道单号在支付回调时已经绑定;只有历史迁移或渠道补单才需要兜底。

差异分五类:

类型定义识别处置
漏单 missing_internal渠道有扣款,内部无订单账单行匹配不到订单挂账,查回调日志与预下单记录
漏账 missing_channel内部已支付,账单无记录订单状态 paid/delivered/refunded 但账单缺失人工复核,可能是账单延迟或渠道丢单
金额不符 amount_mismatch同一订单两边金额不同金额或币种比较人工复核,冻结退款
状态不同步 status_mismatch渠道已支付,内部仍 created/closed;或退款状态不一致状态映射比较仅安全子集自动同步,其余人工
重复账单 duplicate_bill同一渠道单号出现多次账单键去重人工复核,避免重复计入收入

“冲正”和“挂账”是两种不同动作。冲正是用一笔新的双分录交易抵消错误记账,必须保留原交易和冲正交易的关联;挂账是把无法立即解释的金额放进一个待查科目,等查清后再转出。两者都不能靠 UPDATE balance 完成,否则审计时说不清钱从哪里来到哪里去。双分录的具体做法见 6.1 货币、产出与消耗 。

可运行片段:对账分类

下面的 internal/refundrecon/recon.go 只做差异分类,不直接改钱。退款状态机在同包的 refund.go 里,包含申请、审批、处理、成功、失败、挂起和资产回收重试;本文贴出对账部分,因为它是“差异如何处置”的决策入口。

// file: internal/refundrecon/recon.go
// tier: L1
// verify: go test ./internal/refundrecon/
package refundrecon

import (
	"fmt"
	"sort"
)

type BillLine struct {
	ChannelOrderID string
	OrderID        string
	AmountCents    int64
	Currency       string
	Status         string // paid | refunded
}

type OrderRecord struct {
	OrderID        string
	ChannelOrderID string
	AmountCents    int64
	Currency       string
	Status         string // created | paid | delivered | refunded | closed
}

type DiscrepancyKind string

const (
	KindMissingInternal DiscrepancyKind = "missing_internal"
	KindMissingChannel  DiscrepancyKind = "missing_channel"
	KindAmountMismatch  DiscrepancyKind = "amount_mismatch"
	KindStatusMismatch  DiscrepancyKind = "status_mismatch"
	KindDuplicateBill   DiscrepancyKind = "duplicate_bill"
)

type Resolution string

const (
	ResolveAuto     Resolution = "auto"
	ResolveManual   Resolution = "manual"
	ResolveSuspense Resolution = "suspense"
)

type Discrepancy struct {
	Kind       DiscrepancyKind
	Key        string
	Detail     string
	Resolution Resolution
}

type Policy struct {
	// StatusAutoSync lets a paid bill line move a still-created order forward.
	// It never adjusts money and never overrides a closed order.
	StatusAutoSync bool
}

type Report struct {
	Matched       int
	BillCents     int64
	InternalCents int64
	Discrepancies []Discrepancy
}

// Reconcile compares one day's channel bill with internal orders. Money
// differences are never auto-corrected: they go to manual review or suspense.
func Reconcile(bill []BillLine, orders []OrderRecord, policy Policy) Report {
	var report Report
	byChannel := make(map[string]OrderRecord, len(orders))
	byID := make(map[string]OrderRecord, len(orders))
	for _, o := range orders {
		byID[o.OrderID] = o
		if o.ChannelOrderID != "" {
			byChannel[o.ChannelOrderID] = o
		}
	}
	seen := make(map[string]bool, len(bill))
	for _, line := range bill {
		key := line.ChannelOrderID
		if key == "" {
			key = line.OrderID
		}
		if key == "" {
			report.BillCents += line.AmountCents
			report.add(KindMissingInternal, "<empty>", "bill line has no order key", ResolveSuspense)
			continue
		}
		if seen[key] {
			report.add(KindDuplicateBill, key, "bill line repeated", ResolveManual)
			continue
		}
		seen[key] = true
		report.BillCents += line.AmountCents
		o, ok := byChannel[line.ChannelOrderID]
		if !ok {
			o, ok = byID[line.OrderID]
		}
		if !ok {
			report.add(KindMissingInternal, key, "channel took money without an internal order", ResolveSuspense)
			continue
		}
		if o.AmountCents != line.AmountCents {
			report.add(KindAmountMismatch, key, fmt.Sprintf("bill %d, internal %d", line.AmountCents, o.AmountCents), ResolveManual)
			continue
		}
		if !statusCompatible(line.Status, o.Status) {
			res := ResolveManual
			if policy.StatusAutoSync && line.Status == "paid" && o.Status == "created" {
				res = ResolveAuto
			}
			report.add(KindStatusMismatch, key, fmt.Sprintf("bill %s, internal %s", line.Status, o.Status), res)
			continue
		}
		report.Matched++
	}
	for _, o := range orders {
		switch o.Status {
		case "paid", "delivered", "refunded":
			report.InternalCents += o.AmountCents
		default:
			continue
		}
		key := o.ChannelOrderID
		if key == "" {
			key = o.OrderID
		}
		if !seen[key] {
			report.add(KindMissingChannel, key, "internal order absent from bill", ResolveManual)
		}
	}
	sort.Slice(report.Discrepancies, func(i, j int) bool {
		if report.Discrepancies[i].Kind != report.Discrepancies[j].Kind {
			return report.Discrepancies[i].Kind < report.Discrepancies[j].Kind
		}
		return report.Discrepancies[i].Key < report.Discrepancies[j].Key
	})
	return report
}

func (r *Report) add(kind DiscrepancyKind, key, detail string, res Resolution) {
	r.Discrepancies = append(r.Discrepancies, Discrepancy{Kind: kind, Key: key, Detail: detail, Resolution: res})
}

func statusCompatible(billStatus, internalStatus string) bool {
	switch billStatus {
	case "paid":
		return internalStatus == "paid" || internalStatus == "delivered"
	case "refunded":
		return internalStatus == "refunded"
	default:
		return billStatus == internalStatus
	}
}

几个关键点:

  • 重复账单先于匹配处理。 同一渠道单号第二次出现时直接标记重复,不再参与金额和状态比较。
  • 金额比较在状态比较之前。 金额都不一致时,讨论“状态是否同步”没有意义。
  • 金额差异永远人工。 amount_mismatch 的处置固定为 manual,策略里没有自动改金额的开关。
  • 自动同步只覆盖 paid + created。 账单说已支付、内部还停在 created,可以安全地推进;已关闭或已退款的订单不自动改活。
  • created 和 closed 订单不要求出现在账单里。 未支付和已关闭订单本来就不应产生结算记录,否则每天都会报大量假差异。
  • 报告排序固定。 按差异类型和键排序,方便跨天对比和自动 diff。

失败模式与兜底

失败表现兜底
账单延迟当日账单缺少前一日订单按结算日重跑,窗口内不重复挂账
账单重复行同一渠道单号出现两次标记 duplicate,只计一次匹配
渠道强制退款钱已扣回,内部未收到通知以渠道账单为准补建退款单,进入 approved
资产已转移回收时原账户余额不足记负余额/债务,限制交易,不静默清零
回收部分成功一部分资产已扣、接口超时回收接口按退款单幂等,整体重试
退款并发同一订单多笔申请requested 即预留,超出可退余额直接拒绝
退款风暴短时间大量拒付风控 hold、提高复核优先级、限制发货
时区/日切不一致两边统计窗口错位固定对账日历和时区,账单按渠道结算日归档
币种不一致渠道本币与内部记账币种不同以订单币种为准,汇率差单独挂账

最容易被低估的是“资产已转移”。退款系统如果只检查“订单是否已支付”,会以为回收一定成功;实际回收接口可能因为余额不足、物品已删除、角色已转服而失败。正确做法是把回收结果当成退款成功的前置条件,并把失败原因写进退款单,供客服和风控使用。

常见错误

第一,退款金额由客户端决定。可退余额必须由服务端根据支付、已退款和预留计算。

第二,先标记退款成功,再异步回收资产。中间失败会留下“钱退了、货还在”的窗口。

第三,用余额覆盖式冲正。冲正必须是新交易,保留原交易与冲正关联;否则审计断链。

第四,对账差异一律自动补单。漏单可能是渠道重复扣款,补单会放大损失;金额差异更不能自动改。

第五,只按内部订单号匹配账单。渠道账单经常只有渠道单号,匹配不上就会把正常订单误报成漏账。

第六,对账窗口无限延长。每天的新差异和三个月前的旧差异混在一起,没人知道哪些还需要处理。

小结

退款与对账的共同点是“钱和资产已经分离”:钱可以按渠道规则退回,资产却可能已经被消耗或转移。可执行的边界是:申请即预留、回收成功才提交、金额差异只人工、状态同步只走安全子集、差异按窗口归档。

支付和退款解决“钱怎么进出”。下一节换一个方向:在钱进来之前,服务端还要判断谁在玩、能玩多久、能花多少,也就是防沉迷、实名与未成年人保护。

阅读导航:上一节:11.1 支付渠道与发货幂等 · 下一节:11.3 防沉迷、实名与未成年人保护 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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