退款不是反向的支付
《群岛纪元》的一名玩家在渠道侧发起拒付,渠道没有等待游戏确认,直接从商户结算款里扣回了 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 防沉迷、实名与未成年人保护 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。