写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 15 章。上一章我们给系统装上了熔断、限流、降级的「防御装甲」,这一章把这些能力用在一个最敏感、最不容许出错的业务上——支付。内容社区的创作者需要被打赏,被打赏就要有钱进账,而钱相关的系统有两条不能踩的红线:不能重复扣款(幂等)、账目必须对得上(对账)。
你可能会想:「我又接不了真的微信支付商户号,学这个有什么用?」——本章为此专门设计了一个内存版 Mock 微信支付:不需要商户号、不联网,就能把「下单 → 支付 → 回调 → 对账 → 分账」的完整链路跑通。面试官不会问你「微信后台长什么样」,但一定会问「怎么保证一笔订单只生效一次」「回调丢了怎么办」——这些答案你都能在本章亲手跑出来。
这一版做了三件事:
- 每个概念先补课再使用。幂等、回调、验签、对账、分账这些支付黑话,全部就地补「补课胶囊」,不让你去搜。
- 用类比先让你看见。转账凭单号、快递签收、公司财务月结对账——钱的事生活里全有原型。
- 结尾有 16 道面试问答。按「基础 → 进阶 → 追问」三档,每题告诉你一句话结论、怎么展开、面试官想听什么词、新手最容易答错在哪。
如果你想要「高密度速查版」,可以看原版第 15 章 ;两版知识点完全一致,只是讲法不同。
一、本章导读
上一章末尾我们留了一个伏笔:只有幂等接口才可以安全重试。这一章就把「幂等」两个字掰开揉碎讲透——因为在支付场景里,它不是加分项,是命门。
先看一个能让你后背发凉的场景:用户给你打赏了 1 块钱,微信把「支付成功」的通知发到你的服务器。你的代码更新订单状态、给创作者分成 8 毛。一切正常。但网络是会抖的——微信发现你响应慢了,以为你没收到,把同样的通知又发了一遍。如果你的代码原样再执行一次:创作者收到 1 块 6。用户付了 1 块,你赔了 6 毛。这种事在支付行业叫「资损」,一次就能上新闻。
这一章会从微信支付的 native 扫码模式讲起,一步步搭出一条完整的支付链路:下单 → 出二维码 → 用户扫码 → 微信回调 → 状态机推进 → 落库 → 分账记账,并配上「对账」这个最后的安全网。你会学到:
- 支付的全景图:钱其实是在第三方(微信)那边完成的,我们靠回调知道结果;
- 支付系统的四大命门:幂等、对账、状态机、回调验签;
- 微信 native 扫码支付怎么接:Prepay 下单、回调处理、二维码生成;
- 金额怎么存(为什么用「分」不用「元」)、状态机为什么只能单向流转;
- 打赏功能:缓存二维码、Kafka 监听支付结果、查询的快慢路径;
- 分账与记账:余额和流水为什么必须同一个事务。
本章是微服务阶段的收官之作。从下一章起进入全书第四阶段——高阶业务(评论、搜索、Feed 流、IM)。学完这一章,你就有底气在面试里聊「金融级系统为什么对幂等和对账近乎偏执」了。
开读前先看一眼全局:支付链路是一条时序——用户发起打赏后,钱要经过"微信支付、payment、reward、account"四方接力,其中两次异步(微信回调、奖励发放)都靠幂等和对账兜底。先把这条时序刻在脑子里,正文每个小节都是在给它的某一环上保险。
sequenceDiagram
autonumber
participant U as 用户
participant P as payment-service
participant W as 微信支付
participant R as reward-service
participant A as account-service
U->>P: 发起打赏
P->>P: 生成支付单(幂等键)
P->>W: 预支付下单
W-->>U: 拉起收银台
U->>W: 完成支付
W->>P: 异步回调(验签 + 幂等判定)
P->>R: 发放奖励(本地消息表保证不丢)
R->>A: 记账入账
P->>W: 定时对账,追平差异这张图怎么读:消息从「用户」出发按编号顺序流转——「payment-service」先生成带幂等键的支付单、再到「微信支付」预支付下单,用户在微信侧完成支付,资金流从头到尾没进过我们自己的系统;第 6 步「异步回调(验签 + 幂等判定)」是全图的枢纽:回调可能丢、也可能重发,所以右侧「reward-service → account-service」的发放与入账要靠本地消息表保证不丢,而图底部「定时对账,追平差异」就是回调失灵时的兜底通道——主动去微信查单,把没送到家的支付结果找回来。记住两条主线:正向「下单 → 支付 → 回调 → 发奖 → 记账」,反向「对账修复」,正文每一节都是在给其中一环上保险。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 画出支付的完整时序图:读者 → webook → 微信支付 → 创作者,说清「钱最终在第三方完成、结果靠回调通知」这一关键认知。
- 讲清幂等的定义(同一操作执行一次和执行多次效果相同),并用「唯一索引 + 状态机条件更新」写出支付场景的两层幂等防护。
- 画出支付状态机(init → success / failed),解释为什么状态只能单向流转、为什么终态订单不能再发起任何外部调用。
- 写出「处理微信回调」的完整逻辑:验签(用官方 SDK)→ 状态转换 → 条件更新 → 返回 200,并解释每个环节省略会发生什么。
- 实现 Mock 微信支付 + 打赏的完整闭环:发起打赏拿二维码 → 模拟支付 → 消费 Kafka 事件更新状态,重复回调 / 重复消费都不产生重复记账。
理解(能讲清原理和取舍,不要求背代码)
- Prepay 超时后为什么可以放心重试(微信 Prepay 本身幂等 + BizTradeNO 去重),以及「幂等接口的关键是重复调用返回上次的结果」。
- 对账的两种形态:定时任务扫超时单主动查微信(兜底)、回调失败立刻触发单条对账(及时性)。
- 要不要统一支付渠道抽象的判断标准:用户会明确选择支付方式,所以不需要提前抽象——这是「不要为不存在的未来过度抽象」的经典案例。
- 分账方案四方对比(打赏分 / 支付分 / 账号分 / 打赏计算+支付调记账),以及「余额 + 活动记录同事务」的原因。
- 金额存储三种方案(int64 / decimal / string)的取舍,为什么本课程用 int64 存「分」。
- Kafka 保证消息有序的原理(相同 key 哈希到同一分区)以及扩分区会破坏有序性的原因。
了解(知道有这回事,面试能说出名字和一句话定位)
- 微信支付 v3 的验签 + 解密细节(平台证书、APIv3 密钥、AES-256-GCM)——知道「必须用官方 SDK」即可。
- 本地消息表模式:事务里同时写「待发消息」,后台轮询补发,保证消息必发。
- 布隆过滤器 / Redis 去重作为消息消费幂等的前置过滤手段。
- 金融行业的特殊性:本地事务扛不住时的「钞能力」方案(Oracle + 大型机)。
前置依赖与补课清单
| 需要你已掌握 | 在哪章学过 | 本章还会补 | 补在哪一节 |
|---|---|---|---|
Gin 接口与 GORM 事务(BeginTx / Commit) | 第 02 章 | ❌ 不补 | — |
| Kafka 生产 / 消费、分区有序性 | 第 07 章 | ❌ 不补(本章深化「有序」) | — |
| gRPC 服务定义与调用、protoc | 第 10 章 | ❌ 不补 | — |
| 降级、快慢路径 | 第 14 章 | ❌ 不补 | — |
| ETCD 注册、cron 定时任务 | 第 09 / 12 章 | ❌ 不补 | — |
| 什么是幂等 | 第 14 章提过一句 | ✅ 重点补 | 3.1 节 |
| 什么是回调(callback)、native 扫码支付 | 未讲过 | ✅ 补 | 3.1 节 |
| 什么是验签 / 签名 | 第 03 章讲过 JWT 签名 | ✅ 补(对称/非对称) | 3.3 节 |
| 什么是对账、分账 | 未讲过 | ✅ 补 | 3.4 / 3.7 节 |
不需要你提前懂的:微信支付商户号申请流程(企业资质门槛,本章用 Mock 绕开)、微信支付 v3 证书体系的内部细节(知道用官方 SDK 即可)、会计复式记账法(我们的记账模型是简化版)。
预计时长与学习成果
预计时长:3.5 小时(精读 + 跟着敲支付闭环);如果只求主干,1.5 小时可过一遍概念。
学完你能跑出来的东西(这是本章的验收标准,做到才算过关):
一个 payment-service 微服务(gRPC 9100 + HTTP 8100 双端口):用 grpcurl 发起打赏拿到 Mock 二维码;用 curl 模拟微信回调两次,第二次日志出现「重复回调,跳过」,Kafka 里始终只有一条支付成功事件;把对账超时参数调成 1 秒,观察「补单成功」和「超时关单」两种日志。
flowchart LR
A["3.1 支付全景
+幂等补课"] --> B["3.2 渠道抽象之争"]
B --> C["3.3 接入微信支付
Prepay/回调/状态机"]
C --> D["3.4 对账与异常"]
D --> E["3.5 打赏功能"]
E --> F["3.6 Kafka 监听"]
F --> G["3.7 分账记账"]
G --> H["第四节 跟着写
Mock 全闭环"]
H --> I["第七节 面试问答"]这张图怎么读:这是本章的学习路线,从左到右一条直线走完——先在「3.1 支付全景」补齐幂等、回调两个概念,再经「3.2 渠道抽象之争」做一个反直觉的架构决策,随后「3.3 → 3.4 → 3.5 → 3.6 → 3.7」五个小节依次打通「下单回调 → 对账兜底 → 打赏调用 → Kafka 通知 → 分账记账」五环,最后进「第四节 跟着写」用 Mock 把全链路跑起来、「第七节 面试问答」收束成答题素材。没有分支、没有跳跃,按顺序读即可——每一节都依赖前一节的产物。
三、正文
3.1 支付系统全景:钱在哪儿流动
先补课:本章最核心的概念——幂等
补课胶囊 · 幂等(idempotent) 一句话:同一个操作执行一次和执行多次,效果完全相同。 类比:电梯按钮。你按一次电梯来了,手滑连按十次——电梯也只来一趟,不会来十趟。按钮就是幂等的:重复按不产生额外效果。 最小示例:
UPDATE orders SET status = 'paid' WHERE order_no = 'A001' AND status = 'unpaid'——执行一次和执行十次,最终状态都是paid,第十一次执行影响 0 行(WHERE 条件已不满足)。用状态做条件的更新天然幂等。 回到主线:支付链路上网络抖动导致的重复请求是常态(回调重发、用户狂点、上游重试),幂等是让系统在重复冲击下不出错的唯一防线。
反例也认识一下:UPDATE account SET balance = balance + 10 WHERE uid = 1——执行两次余额就多了 20。这种「累加式」操作不幂等,必须配合唯一索引等手段防护(见 3.7 节记账设计)。
先补课:回调是什么
补课胶囊 · 回调(callback) 一句话:你预先留给对方一个网址(notify_url),对方在事件发生后主动 POST 通知你。 类比:网购填收货地址。你把地址(回调地址)留给快递(微信支付),快递送到(支付完成)就按地址给你送上门(POST 通知)。你不需要每隔五分钟问一次「到了吗」(轮询),等着收就行。 最小示例:你在微信商户后台配置
https://api.yoursite.com/api/payment/wechat/notify,用户支付成功后微信会向这个地址 POST 一段 JSON(含订单号、支付状态),你的服务收到后更新订单。 回到主线:支付的结果靠回调通知——但回调可能丢、可能重复,所以后面要有对账兜底和幂等防护。
打赏功能涉及的关键方
在内容平台(webook)上,打赏功能涉及四个角色:
- 读者:发起支付的人;
- webook:作为枢纽,记录打赏、调用第三方支付;
- 第三方支付(如微信支付):真正完成支付的地方;
- 创作者:最终收到打赏金额的人。
flowchart LR
R[读者] -->|发起打赏| W[webook 枢纽]
W -->|调用 API| P[第三方支付 微信]
P -->|创作者收到钱| A[创作者]支付的核心流程
先给直觉——支付这件事和第 05 章的微信扫码登录惊人地像:用户都是绕开我们、直接和微信打交道,我们都是靠回调拿到结果。
sequenceDiagram
participant U as 读者
participant W as webook
participant P as 微信支付
participant A as 创作者
U->>W: 点击打赏
W->>W: 创建支付订单
W->>P: Prepay 下单
P-->>W: 返回二维码 CodeURL
W-->>U: 展示二维码
U->>P: 扫码支付(钱在微信侧完成)
P->>W: 异步回调通知结果
W->>W: 更新订单状态/分账
W->>A: 创作者收到打赏用文字版再走一遍,每个环节都值得盯着看:
读者点击打赏
↓
webook 创建支付订单(本地先落一条「待支付」记录)
↓
webook 调用微信 Prepay 下单(在微信那边也创建一张订单)
↓
微信返回 code_url → 前端渲染成二维码展示给读者
↓
读者扫码支付(注意:这一步全程在微信里,webook 完全不参与)
↓
微信异步回调 webook 的 notify_url,通知支付结果
↓
webook 验签 → 更新订单状态 → 分账 → 返回 200
关键认知:支付最终在第三方支付平台上完成。 我们说的「支付」,本质是在微信那边创建了一张订单,用户付的是微信的这张订单。webook 全程没有碰过钱——我们只是「发起方」和「记账方」。
模块划分:支付和打赏分离
不能把打赏做成一个大模块,要按职责拆分:
- 支付模块(Payment):负责和不同第三方支付打交道,也叫支付网关——只管「钱进出」,不管「为什么付钱」;
- 打赏模块(Reward):利用支付模块实现具体业务——知道「这是打赏文章,作者该分 80%」;
- 账号模块(Account)(大型应用才有):记录账号金额变动、用户支付 / 收款信息;
- 反洗钱、税务等安全审计模块(更大型应用)。
flowchart TD
R[Reward 打赏业务] --> P[Payment 支付网关]
P -->|调用| WX[微信支付]
P -->|回调/消息| R
R -->|分账| A[Account 记账服务]这张图怎么读:调用关系按箭头走——正向是「Reward 打赏业务 → Payment 支付网关 → 微信支付」:Reward 只管发起打赏,把「付钱」这件事外包给 Payment,Payment 再对接具体渠道;反向有一条「Payment → 回调/消息 → Reward」的边,支付结果就是沿这条边流回业务方的(回调或 Kafka 消息,3.6 节细讲);最后 Reward 拿到结果,按业务分成调「Account 记账服务」完成入账。看懂这三段就看清了模块边界:Payment 不认识业务、Account 不关心分成比例,只有 Reward 知道「这钱为什么付」。
小收口:这一节建立了「四方角色 + 钱在第三方 + 结果靠回调」的全景认知,以及 Payment / Reward / Account 的模块边界。下一个问题几乎是条件反射:既然有多种支付渠道(微信、支付宝、银联……),要不要先设计一个统一接口?——这是本章第一个「反直觉」的答案。
3.2 要不要统一渠道抽象?——一个反直觉的答案
先补课:什么是「抽象统一接口」
补课胶囊 · 统一渠道抽象 一句话:定义一个通用接口(如
PaymentChannel),让微信、支付宝各自实现,上层代码只依赖接口、不关心具体渠道。 类比:第 04 章的短信服务商接口——SMSService接口 + 阿里云 / 腾讯云两个实现,切换服务商只改配置。这套思路在短信场景是标准答案。 回到主线:支付场景能用同一招吗?直觉说「能」,答案是「不需要」——原因见下。
答案:不需要
国内第三方支付主要是微信支付和支付宝,还有华为支付、银联、银行接口等。要不要像短信那样定义一个统一接口?不需要。
支付渠道和短信服务商有一个本质区别:所有支付场景下,用户都要明确选择支付方式。用户点了「微信支付」按钮,从前端到后端,整条链路都知道「这是微信」——既然路径上处处都知道具体渠道,藏起来反而徒增一层无意义的转换。
而短信不一样:用户发验证码时根本不关心(也不知道)用的是阿里云还是腾讯云——调用方必须屏蔽掉渠道细节,抽象是刚需。
工程哲学:不要为不存在的未来过度抽象。等到真的接入第二种支付方式再考虑抽象。
⚠️ 新手必踩的坑:为了「显得架构优雅」提前抽象。很多同学一上来就定义 PaymentChannel 接口 + 工厂,结果三个月后只接了微信支付,那层抽象全是死代码,还增加了理解成本。抽象应该发生在「真的出现第二种实现」的拐点,而不是之前。
何时该抽象:一条判断原则
不是说支付系统里一切都不抽象,判断原则是:清楚具体支付方式的部分不抽象,不清楚的部分才抽象。
| 场景 | 是否抽象 | 原因 |
|---|---|---|
| 用户选定支付方式的整条路径(下单、回调) | 不抽象 | 已经清楚是微信 / 支付宝 |
| 查询支付结果 | 抽象 | 调用方不关心这笔单子当初用的哪种支付 |
「查询支付结果」为什么抽象?比如打赏服务想知道「单子 A001 付了没」——它不该关心 A001 走的是微信还是支付宝,Payment 模块内部根据单子自己去查对应渠道就行。抽象与否,取决于调用方是否需要知道渠道细节,而不是「多个实现就该抽象」的教条。
小收口:这一节看似在讨论「一个接口怎么设计」,实际在训练一种工程判断力——抽象是有成本的(多一层间接、多一分理解负担),没有收益的抽象是负资产。下一节正式接入微信支付,开始写代码。
3.3 接入微信支付:从下单到回调
这一节是本章的「主战场」。先解决一个安全问题的补课,再走完「下单 → 收回调 → 记状态」的完整链路,最后用状态机把这条链路焊死。
先补课:验签是什么
补课胶囊 · 验签(verify signature) 一句话:用密钥验证一段数据确实是「声称的发送方」发出的、且中途没被篡改。 类比:信封上的火漆印章。只有发信人拥有那枚独一无二的印章(私钥),收信人用对应的模具(公钥)一比对——印章对得上,说明信真是他发的、没被调包。 最小示例:第 03 章你已经见过「签名」——服务器用密钥给 JWT 签名,客户端哪怕只篡改 token 里一个字符,验签立刻失败。回调验签是同一件事,只是方向反了:这次是微信用它的私钥给回调签名,我们用微信的平台证书验。 回到主线:回调到达时,第一个问题不是「订单付了没」,而是「这条回调真的是微信发的吗」——不验签,任何人 POST 一段 JSON 就能把你的订单改成「支付成功」。
准备工作:真实商户号怎么来
以企业名义走三步:① 创建小程序(或公众号);② 创建商户号并开通微信支付;③ 把商户号和小程序关联起来。
企业资质是硬门槛,个人开发者办不下来。本章不卡在这一步——3.3 节末尾和第四节会用 MockWechatPay(内存版假微信)把全链路跑通,你写代码时完全不需要商户号。真实的接入只是把 Mock 换成官方 SDK 的实现,业务代码一行不改——这正是第 04 章「面向接口」思想的价值。
native 扫码支付:我们只参与三个环节
先抛问题:接入微信支付,到底要写哪些代码?是不是微信支付页面的每个环节都要我们实现?
给直觉:native 支付(就是扫码支付)的官方流程有十几个步骤,但绝大部分发生在微信 App 和微信服务器之间——那是微信的地盘,我们插不上手。把整条链路画出来,标出「我们」出现的时刻:
sequenceDiagram
participant U as 用户
participant F as 前端页面
participant W as 我们的服务
participant X as 微信支付
U->>F: 点击打赏
F->>W: 请求支付
Note over W,X: 环节①我们调用微信下单
W->>X: Prepay(下单)
X-->>W: code_url
W-->>F: code_url
Note over F: 前端把 code_url 渲染成二维码
F-->>U: 展示二维码
U->>X: 扫码、输密码(全程在微信里)
X->>X: 扣款、记账
Note over X,W: 环节②微信主动通知我们
X->>W: POST 回调(支付结果)
W-->>X: HTTP 200
Note over W,X: 环节③(可选)主动查单
W->>X: 查询订单状态
X-->>W: 返回状态
F->>W: 轮询支付结果
W-->>F: 返回状态数一数,我们参与的只有三个环节:
- 环节 ①:调下单接口
https://api.mch.weixin.qq.com/v3/pay/transactions/native,在微信那边创建订单; - 环节 ②:接收微信发来的回调,处理支付结果;
- 环节 ③(可选):主动调查询接口,核对订单状态(对账用,3.4 节细讲)。
把这句话焊进脑子:我们说的「支付」,本质是在微信那边创建了一张订单,用户付的是微信的这张订单。下单成功后微信返回 code_url,前端用任意二维码工具把它渲染成二维码图片——渲染二维码是前端的活儿,后端只负责把 code_url 传回去。
Prepay 接口设计:调用方不该知道微信细节
先抛问题:调用微信下单接口要填一堆参数(appid、mchid、notify_url……),这些参数该由谁提供?
给直觉:回顾第 04 章短信服务的设计原则——SMSService 接口只暴露「手机号 + 验证码」,阿里云的 AccessKey 藏在实现里。支付同理,把参数分成两堆:
| 参数 | 含义 | 谁传入 |
|---|---|---|
mchid | 商户号 ID | Payment 自己填 |
appid | 小程序/公众号 ID | Payment 自己填 |
notify_url | 回调地址 | Payment 自己填 |
description | 商品描述 | 业务方传入 |
out_trade_no | 业务方订单号 | 业务方传入 |
amount | 金额 | 业务方传入 |
设计原则:调用方不该知道微信细节。appid / mchid / notify_url 都是 Payment 模块内部的事。唯一例外:公司有很多个 app / 商户号时,调用方才需要传 appid 或 mchid,用来指定「用哪个商户收钱」。
动手:定义 Payment 接口
按上面的原则,PrepayReq 只保留业务方该关心的三个字段:
// internal/payment/service/payment.go
package payment
// PrepayReq 预支付请求:只暴露业务方该关心的字段
type PrepayReq struct {
BizTradeNO string // 业务方唯一凭证,用于去重
Description string // 商品描述
Amount int64 // 金额,单位:分
}
// PrepayResp 预支付响应
type PrepayResp struct {
CodeURL string // 微信返回的二维码链接
}
// PaymentService 支付服务接口
// 如果将来接入支付宝,可以拆成 WePayment 和 AliPayment,
// 两者有公共字段可以组合 basePayment
type PaymentService interface {
Prepay(ctx context.Context, req PrepayReq) (PrepayResp, error)
}
逐行讲解:BizTradeNO 是业务方的订单号(比如打赏服务生成的 reward:123)——它是全链路去重的凭证,既是本地表的唯一索引,也透传给微信当 out_trade_no。Amount 用 int64 存「分」(为什么是分,本节末尾讲)。接口注释里「将来拆成 WePayment / AliPayment」呼应 3.2 节:不是提前抽象,而是预留拆分空间,现在只有一个实现。
动手:NativePaymentService 实现
先抛问题:调微信下单之前,为什么要先往本地数据库插一条记录?直接调微信不行吗?
给直觉:如果先调微信、后写库——调微信成功了、写库失败了,微信那边有一张订单、本地没有记录,回调来了你都不知道这是谁的单。先落本地、再调微信,本地那条「待支付」记录就是你后续一切操作的锚点。
// internal/payment/service/payment.go
// NativePaymentService 微信 native 支付实现
type NativePaymentService struct {
client *native.NativeApiService // 微信官方 SDK 的客户端
appid string // 小程序/公众号 ID
mchid string // 商户号
notifyURL string // 回调地址
db PaymentDAO
}
func (s *NativePaymentService) Prepay(ctx context.Context, req PrepayReq) (PrepayResp, error) {
// 步骤 1:先初始化一条支付记录——目的是去重
// 即便不去重,BizTradeNO 透传给微信,微信也会用它去重
err := s.db.Insert(ctx, Payment{
BizTradeNO: req.BizTradeNO,
Status: StatusInit,
Amount: req.Amount,
})
if err != nil {
// 唯一索引冲突说明已经创建过,可以直接查询返回 code_url
// 这里简化处理,实际要区分是重复还是真的 DB 错误
}
// 步骤 2:调用微信下单接口
resp, err := s.client.Prepay(ctx, native.PrepayRequest{
Appid: core.String(s.appid),
Mchid: core.String(s.mchid),
Description: core.String(req.Description),
OutTradeNo: core.String(req.BizTradeNO),
NotifyUrl: core.String(s.notifyURL),
Amount: &native.Amount{
Total: core.Int64(req.Amount),
Currency: core.String("CNY"),
},
})
if err != nil {
return PrepayResp{}, err
}
// 步骤 3:返回微信给的二维码链接
return PrepayResp{CodeURL: *resp.CodeUrl}, nil
}
逐行讲解,重点看两个「为什么」:
- 为什么步骤 1 插入失败不直接报错:
biz_trade_no上建了唯一索引,插入失败大概率是「重复请求」——同一个业务单已经创建过支付,此时应该把旧单查出来返回(幂等),而不是把错误抛给用户。上面的代码把冲突「吞掉」是简化写法,工程版会在第四节第 2 步给出完整的CreateOrGet三分支。 - 为什么
OutTradeNo传的是BizTradeNO:让微信侧也用同一个单号——将来重复下单时,微信靠它认出「这单已经建过」,返回同一个code_url(3.4 节细讲),本地和微信两侧的去重凭证是同一个值。
常见坑:
- 插入失败不区分场景:把「唯一索引冲突(重复请求)」和「数据库挂了(真故障)」当成同一种错误处理——前者该走幂等返回,后者该报错告警。新手最容易一把梭
return err,用户重复点一次打赏就报「系统异常」。 - 忘记
Currency字段:微信要求必填,漏了直接返回参数错误。
动手:支付记录表——极简设计
-- scripts/payment_simple.sql
CREATE TABLE `payment` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`biz_trade_no` VARCHAR(64) NOT NULL COMMENT '业务方唯一凭证',
`status` TINYINT NOT NULL COMMENT '支付状态:0初始 1成功 2失败',
`amount` BIGINT NOT NULL COMMENT '金额,单位分',
`txn_id` VARCHAR(64) DEFAULT '' COMMENT '微信支付订单号',
`ctime` DATETIME NOT NULL,
`utime` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_trade_no` (`biz_trade_no`) -- 关键:唯一索引保证去重
);
逐行讲解:这张表故意只存五个业务字段——biz_trade_no(谁的单)、status(到哪一步了)、amount(多少钱)、txn_id(微信侧的订单号,回调后回填)、时间戳。唯一索引 uk_biz_trade_no 是整个幂等体系的根基:同一张业务单,物理上不可能插入两条支付记录。
接收支付通知:回调处理的完整链路
先抛问题:微信的回调要发到 notify_url,这是一个公网可达的线上地址。可我还在本地开发,微信怎么找到我?
给直觉:公司通常用一个公网域名 + nginx 按子域名分发,把微信的回调「转交」到正确的环境:
test.yourcompany.com→ 测试环境;dev.yourcompany.com→ 转发到某个开发者的本地电脑(不然本地调试回调会非常麻烦);live.yourcompany.com→ 生产环境。
除了按域名分发,还有两种常见变体:在回调路径上做特殊标记(比如 /test 开头的路径打到测试环境);或者自己写一个轻量级的 HTTP 分发接口,转发规则可以做得更灵活(比如按标记路由到任意一台开发机)。
最小可运行示例——处理回调的代码。铁律:千万不要自己手动验签解密,必须用微信官方 SDK:
// internal/payment/web/notify.go
package web
// WechatHandler 微信回调处理器
type WechatHandler struct {
handler *notify.Handler // 微信官方 SDK:验签 + 解密一步到位
svc *NativePaymentService
}
func (h *WechatHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// 步骤 1:微信 SDK 帮我们验签 + 解密
transaction := new(native.Transaction)
_, err := h.handler.ParseNotifyRequest(req.Context(), req, transaction)
if err != nil {
w.WriteHeader(http.StatusInternalServerError) // 验签失败:可能是伪造请求
return
}
// 步骤 2:在 Web 层把微信状态转成内部状态
var internalStatus int8
switch *transaction.TradeState {
case native.TRADESTATE_SUCCESS: // 支付成功
internalStatus = StatusSuccess
case native.TRADESTATE_CLOSED, native.TRADESTATE_REVOKED: // 已关闭/已撤销
internalStatus = StatusFailed
default:
internalStatus = StatusInit
}
// 步骤 3:更新本地状态,并告诉微信已收到(否则微信会重试)
err = h.svc.HandleCallback(req.Context(), *transaction.OutTradeNo,
*transaction.TransactionId, internalStatus)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
逐行讲解:
- 步骤 1 一行干了两件事:验签(确认是微信发的)+ 解密(回调正文是 AES-256-GCM 加密的,解密后才能读)。这两步的细节坑极多,官方 SDK 全帮你处理了——这就是为什么铁律是「别手写」。
- 步骤 2 是「翻译」:微信的状态词表(SUCCESS / CLOSED / REVOKED……)翻译成我们自己的状态码。Web 层只做翻译,不做业务——业务在
HandleCallback里。 - 步骤 3 的 200 是对微信的「回执」:告诉微信「收到了,别再发了」。
resource 字段做 AES-256-GCM 解密,拿到订单明细。证书下载与轮换、时间戳校验、解密细节坑极多,所以铁律是:用官方 SDK,别手写。常见坑:
- 回调处理成功却不返回 200。微信没收到 200 就认为你没收到,按退避策略反复重试——短时间内打爆你的接口,还可能造成状态被重复处理。务必保证「处理成功就 return 200」。
- 处理失败返回 200。反过来也不行——返回 200 等于告诉微信「处理好了」,微信不再重试,这条支付结果就真的丢了。失败要返回 5xx,让微信重试 + 等对账兜底。
- 验签失败返回 400。验签失败说明对方可能不是微信,应该返回 5xx 拒绝处理——但也不排除是证书配置错了。新手容易在这里纠结半天,记住:先确认 SDK 初始化配置(商户号、APIv3 密钥、平台证书)是否正确。
金额怎么存:int64 / decimal / string
先抛问题:0.1 + 0.2 == 0.30000000000000004——浮点数存钱的经典事故。那金额到底该用什么类型?
| 类型 | 适用场景 | 优缺点 |
|---|---|---|
int64 | 记录货币最小面额(如人民币记录到分) | 性能好,运算快;银行级别要记 4 位小数也能用 |
decimal | 要做复杂数学运算 | 精度准确,但性能差 |
string | 仅传输/存储,不参与运算 | 不会出错,但运算时要转换 |
给直觉:类比「数硬币」——不记「1.35 元」,直接记「135 分」。整数运算没有精度问题,性能还好。公司没决定权就听老板的,有决定权随便选——效果都差不多。本章示例用 int64(分)。
处理回调要不要引入消息队列?
可以引入 Kafka,但不要提早引入。引入 MQ 的四个好处:
- 别的部门也关心支付结果,可以订阅消息(一处生产,多方消费);
- 重试机制在自己控制下(回调处理失败,消息可以重新消费);
- DEBUG 简单,可绕开 payment-web 直接在 Kafka 重发消息模拟测试;
- 削峰(聊胜于无——正向支付撑得住,回调大概率也撑得住)。
什么时候真的需要,3.6 节和第五节工程实践会给出判断标准。
支付状态机:状态只能单向流转
先抛问题:payment 表的 status 字段看起来只是个数字,为什么说它是一个「状态机」?
给直觉:把 status 的合法取值和流转方向画出来:
stateDiagram-v2
[*] --> init: Prepay 插入记录
init --> success: 回调或对账确认支付成功
init --> failed: 回调或对账确认关闭或撤销
success --> [*]
failed --> [*]这就是一个极简状态机:init(初始)→ success / failed(两个终态),每个状态只允许「向前」流转。三条使用纪律:
- 状态只从 init 往外走,绝不回退。success / failed 都是终态,到达终态的订单不再发起任何外部调用。
- 更新状态要用条件更新:
UPDATE payment SET status = ? WHERE biz_trade_no = ? AND status = 0——只有还处于 init 的记录才会被改到。重复回调来时这条 UPDATE 影响 0 行,天然不会重复处理(和工程实践里「先查终态再跳过」是同一件事的两种写法)。 - 状态机 + 唯一索引是支付幂等的两大支柱:唯一索引挡住「重复创建」,状态机挡住「重复更新」。
直接用 BizTradeNO,还是自己造一个 PaymentSN?
先抛问题:去重凭证直接用业务方传来的 BizTradeNO,还是 Payment 自己再生成一个「支付序列号」?
| 方案 | 适用 | 说明 |
|---|---|---|
直接用 BizTradeNO | 简单系统 | 少一层映射,本课程就是这么做的 |
| 自己生成 PaymentSN | 大型系统 | 一般用 UUID;想让序列号带信息,也可以在 SN 里编码商品 ID、商品类型等 |
切记:即便自己生成了 PaymentSN,去重仍然要靠业务方传来的 BizTradeNO——唯一索引还是建在它上面。否则同一个业务请求换个入口再发一次,还是可能创建出两笔支付。
小收口:这一节走完了「下单(Prepay)→ 收通知(回调 + 验签 + 200)→ 记状态(状态机)」的完整链路,其中唯一索引 + 状态机 + 条件更新是幂等的三件套。但链路上还有两个薄弱点:Prepay 超时了怎么办?回调丢了怎么办?——下一节的对账机制就是它们的「最后防线」。
3.4 对账与异常处理:给支付链路上保险
先补课:对账是什么
补课胶囊 · 对账(reconciliation) 一句话:定期把「我记的账」和「对方(微信)记的账」逐笔比对,发现不一致就修复。 类比:公司财务月结对账——每月底会计把银行流水和公司账本逐笔核对,多一笔少一笔都要找出原因、写说明、做调整。个人也一样:月底看信用卡账单,发现一笔自己没刷的钱,就是「对账发现了不一致」。 最小示例:我们库里订单 A001 状态是
init,但去微信一查,A001 早已支付成功——说明回调丢了。对账任务把 A001 更新为success,这叫「补单」。 回到主线:回调可能丢(微信重试有上限)、可能处理失败,对账是把丢失的支付结果找回来的最后防线。
与微信交互的两种异常
先抛问题:我们和微信之间只有两次网络往返(下单、收回调),能出什么异常?出了异常最可怕的是什么?
| 异常场景 | 问题 |
|---|---|
| Prepay 超时 | 不知是成功还是失败,可能已在微信创建订单 |
| 处理回调失败 | 不知用户是否支付了 |
注意两者的共性:最可怕的不是「失败」,而是「不知道结果」。失败可以明确处理,不知道结果才让人睡不着觉。
Prepay 超时:重试 + 幂等返回
给直觉:Prepay 超时(请求发出去了,响应没等到)无法确定结果,解决思路是重试。重试会遇到两种情况:
- 第一次没创建成功 → 重试时创建成功,拿到 code_url;
- 第一次已创建成功 → 重试时微信返回
OutTradeNO 重复错误,但依旧会返回 code_url(和第一次相同的那个)。
flowchart TD
A[Prepay 超时 结果未知] --> R[直接重试]
R --> S{微信那边有这单吗}
S -- 没有 --> C[创建成功 拿到 code_url]
S -- 已有 --> D[返回 OutTradeNO 重复
但依旧返回同一个 code_url]
C --> OK[重试安全结束]
D --> OK处理回调失败:微信重试 + 兜底对账
先抛问题:回调处理失败(比如代码 bug、数据库抖动),微信会怎么做?我们该怎么做?
微信会自动重试回调(你返回非 200,微信会再调)。但注意重试有次数上限——达到上限后微信就不再调用了,一直处理失败的回调,这条支付结果最终会彻底「丢」在你这边。
仅依赖微信重试不靠谱,必须有兜底对账:用 BizTradeNO 主动查微信订单状态,更新本地。
// internal/payment/service/payment.go
// SyncOrderStatus 主动查询微信订单状态,更新本地
func (s *NativePaymentService) SyncOrderStatus(ctx context.Context, bizTradeNO string) error {
// 步骤 1:调用微信查询接口
resp, err := s.client.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(bizTradeNO),
Mchid: core.String(s.mchid),
})
if err != nil {
return err
}
// 步骤 2:转换状态并更新本地(和回调处理共用同一个转换逻辑)
var status int8
switch *resp.TradeState {
case native.TRADESTATE_SUCCESS:
status = StatusSuccess
case native.TRADESTATE_CLOSED, native.TRADESTATE_REVOKED:
status = StatusFailed
default:
status = StatusInit
}
return s.db.UpdateStatus(ctx, bizTradeNO, *resp.TransactionId, status)
}
逐行讲解:QueryOrderByOutTradeNo 就是 3.3 节流程图里的「环节 ③ 主动查单」。注意状态转换的 switch 和回调处理里那个一模一样——对账和回调本质是同一件事(拿到微信状态、更新本地),只是触发方式不同:回调是微信推,对账是我们拉。
常见坑:对账更新也必须走条件更新(只允许从非终态推进),否则对账任务和迟到的回调「赛跑」,可能把已经 success 的订单又改一遍。
对账定时任务
对账不挑时间也可以,但一般用 cron 定时触发:
// internal/payment/service/reconcile.go
// ScheduleReconcile 定时对账任务
// 借助分布式锁可以让任务全局唯一,但没特别大必要
func (s *NativePaymentService) ScheduleReconcile() {
// 步骤 1:找 2 小时 1 分钟前的订单(Prepay 超时 2 小时,多留 1 分钟缓冲)
orders, err := s.db.FindTimeoutOrders(context.Background(), 121*time.Minute)
if err != nil {
return
}
// 步骤 2:逐单主动去微信查状态并更新本地
for _, o := range orders {
_ = s.SyncOrderStatus(context.Background(), o.BizTradeNO)
}
}
逐行讲解:为什么是 2 小时 1 分钟?微信的支付二维码默认 2 小时过期——2 小时还没到终态的订单,大概率是「回调丢了」或「用户根本没付」,都该主动去微信问一次。多留 1 分钟是缓冲,避免和还在正常进行的支付抢跑。
进阶:回调失败立刻触发对账
不必傻等定时任务——处理回调失败时立刻触发单条对账:直接调用对账接口,或发消息到 Kafka 异步对账。这样能更及时地发现支付结果。
flowchart TD
CB[微信回调到达] --> OK{处理成功?}
OK -- 是 --> R[更新本地状态 + 返回200]
OK -- 否 --> S[立即单条对账 查微信状态]
S --> U[更新本地状态]小收口:对账和幂等是支付系统的「安全气囊 + 安全带」——幂等保证正常链路不出错,对账保证异常链路能自愈。到这里,Payment 模块的骨架已经完整。接下来回到业务侧:打赏功能怎么用这套骨架。
3.5 打赏功能:把 Payment 用起来
Payment 是「水电工」,现在请它来给「打赏」这间房布线。这一节站在**业务方(Reward / 打赏服务)**的视角,看它怎么调 Payment、怎么把结果告诉用户。
打赏要做的事
先抛问题:用户视角的打赏是什么样的?后端要配合做什么?
其实只有三步:
- 调用 Payment 服务获得支付二维码;
- 前端展示二维码;
- 用户扫码支付(然后等结果)。
「等结果」是第 05 章 SSO 似曾相识的场景——用户扫码后,我们的系统并不能立刻知道结果,要么等回调、要么轮询。本节末尾会给出「快慢路径」的答案。
动手:发起打赏的接口
打赏的是文章,所以接口放在 ArticleHandler 下(接口跟着「用户的动作」走,用户是在文章页点打赏,不是在「打赏页」):
// internal/post/web/article.go
// Reward 打赏接口(放在 ArticleHandler 下,因为是打赏文章)
func (h *ArticleHandler) Reward(ctx *gin.Context) {
// 步骤 1:查询文章信息,获得作者、标题
art, err := h.articleSvc.GetById(ctx, req.Aid)
// 步骤 2:构建打赏请求
resp, err := h.rewardSvc.Reward(ctx, reward.RewardReq{
Biz: "article",
BizId: req.Aid,
Uid: req.Uid,
Amount: 1, // 打赏一分钱(抠门示例,正常应是用户输入金额)
Title: art.Title,
Author: art.AuthorId,
})
// 步骤 3:返回二维码 + rid
ctx.JSON(http.StatusOK, gin.H{
"codeURL": resp.CodeURL,
"rid": resp.Rid,
})
}
逐行讲解:rid(reward id)是打赏记录的主键——前端拿到它,之后查询打赏结果全靠它。Biz: "article" 表明打赏的对象类型是文章(将来打赏评论、打赏直播就是别的 biz),配合 BizId 就能唯一定位「打赏的是什么」。
动手:打赏服务的 proto 定义
打赏是独立微服务(Reward),对外暴露 gRPC 接口:
// api/proto/reward/v1/reward.proto
service RewardService {
// 打赏接口:返回支付二维码和打赏记录 ID
rpc Reward(RewardReq) returns (RewardResp);
// 查询打赏结果
rpc GetReward(GetRewardReq) returns (GetRewardResp);
}
message RewardReq {
string biz = 1; // 业务标识,如 "article"
int64 biz_id = 2; // 业务 ID,如文章 ID
int64 uid = 3; // 打赏者 UID
int64 amount = 4; // 金额,单位分
string title = 5; // 文章标题(用于微信商品描述)
int64 author = 6; // 创作者 UID
}
message RewardResp {
string code_url = 1; // 支付二维码链接
int64 rid = 2; // 打赏记录 ID
}
动手:打赏服务实现——四步骤
先抛问题:用户「点了打赏又反悔、过一会再点」是高频操作。每次都调 Payment 重新下单吗?
给直觉:先看代码,注意 Step 1 的「缓存命中直接返回」:
// internal/reward/service/reward.go
func (s *RewardService) Reward(ctx context.Context, req *RewardReq) (*RewardResp, error) {
// Step 1: 查询是否有缓存的二维码
// 如果有,说明用户之前准备打赏但没支付,直接复用
codeURL, err := s.cache.GetCodeURL(ctx, req.Uid, req.Biz, req.BizId)
if err == nil && codeURL != "" {
rid, _ := s.cache.GetRid(ctx, req.Uid, req.Biz, req.BizId)
return &RewardResp{CodeUrl: codeURL, Rid: rid}, nil
}
// Step 2: 创建打赏记录
rid, err := s.dao.Create(ctx, Reward{
Biz: req.Biz,
BizId: req.BizId,
Uid: req.Uid,
Amount: req.Amount,
Status: StatusPending,
Author: req.Author,
})
if err != nil {
return nil, err
}
// Step 3: 调用 Payment 服务获得二维码
// BizTradeNO 用 rid 编码,保证唯一性
bizTradeNO := fmt.Sprintf("reward:%d", rid)
prepay, err := s.paymentSvc.Prepay(ctx, payment.PrepayReq{
BizTradeNO: bizTradeNO,
Description: fmt.Sprintf("打赏文章:%s", req.Title),
Amount: req.Amount,
})
if err != nil {
return nil, err
}
// Step 4: 缓存二维码,避免重复调用支付
_ = s.cache.SetCodeURL(ctx, req.Uid, req.Biz, req.BizId, prepay.CodeURL, 2*time.Hour)
_ = s.cache.SetRid(ctx, req.Uid, req.Biz, req.BizId, rid, 2*time.Hour)
return &RewardResp{CodeUrl: prepay.CodeURL, Rid: rid}, nil
}
逐行讲解:
- Step 3 的
reward:%d是全链路的「户口本」:rid 是 123 的打赏,BizTradeNO 就是reward:123。将来 Payment 发支付结果消息(3.6 节),Reward 消费时从reward:123里解析出 123,就知道该更新哪条打赏记录——一次编码约定,串起两个服务。 - Step 4 的缓存有效期 2 小时:和微信二维码的过期时间对齐——二维码都过期了,缓存自然也该过期。
常见坑:Step 1 的缓存 key 是 Uid + Biz + BizId(「谁给哪篇文章」)。新手容易漏掉 Uid,变成「所有人共用一张二维码」——A 用户发起的打赏,B 用户也复用了它,回调成功后钱记在了 A 头上。
为什么要缓存二维码
场景回放:用户点击打赏 → 拿到二维码 → 放弃支付 → 一会又点打赏。此时直接返回缓存的二维码,减少对支付服务的调用。
注意定位:缓存只是一种优化,不缓存、每次都调支付也完全正确(反正 Prepay 是幂等的,重复调用返回同一结果)——这是「优化」和「正确性」的边界:缓存丢了、过期了,系统照样正确,只是多调一次微信。
告知用户支付结果:轮询 + 快慢路径
先抛问题:用户扫码支付了,前端怎么知道「付成功了」?
用户支付时只和微信打交道,微信异步回调给我们。前端来查询时,我们这边可能是三种状态:
- 我们没收到回调(还在路上 / 丢了);
- 收到了但处理失败;
- 收到了且处理成功。
关键设计:让前端轮询——查询失败(还没结果)就立刻开始下一次查询,轮询一定时间/次数还没结果就返回错误。而服务端每次被轮询时,走「快慢路径」:
// internal/reward/service/reward.go
// GetReward 查询打赏结果
// uid 往后传是为了让 Reward 服务校验:rid 是不是这个登录用户创建的
// 打赏的人和查询的人必须是同一个人
func (s *RewardService) GetReward(ctx context.Context, req *GetRewardReq) (*GetRewardResp, error) {
// 步骤 1:查本地打赏记录
r, err := s.dao.GetById(ctx, req.Rid)
if err != nil {
return nil, err
}
// 步骤 2:权限校验——rid 必须属于该 uid
if r.Uid != req.Uid {
return nil, ErrPermissionDenied
}
// 步骤 3:快慢路径。pending 时主动查 Payment(慢路径)
if r.Status == StatusPending {
// 正常可以触发降级时跳过此查询
status, err := s.paymentSvc.GetStatus(ctx, r.BizTradeNO)
if err == nil && status != StatusPending {
_ = s.dao.UpdateStatus(ctx, r.Id, status)
r.Status = status
}
}
return &GetRewardResp{Status: r.Status}, nil
}
逐行讲解:
- 快路径:本地状态已确定(success / failed),直接返回,一个外部调用都不发——绝大多数轮询都走这条路,便宜。
- 慢路径:本地还是 pending,说明回调还没到——借这次查询的机会主动问一次 Payment(Payment 再去问微信),问到新状态就顺手更新本地。下次前端再来轮询,就走快路径了。
- 步骤 2 的权限校验不是摆设:没有它,任何登录用户都能用别人的 rid 查打赏记录——信息泄露。
flowchart TD
Q[前端轮询查询] --> L[查本地打赏记录]
L -->|状态已确定| R[返回结果 快路径]
L -->|仍是 pending 慢路径| P[主动查微信支付状态]
P --> U[更新本地并返回]小收口:这一节 Reward 作为业务方调用了 Payment(发起打赏),也定义了自己被前端调用的姿势(轮询 + 快慢路径)。但「Payment → Reward」这个方向还没打通——支付成功了,Reward 怎么第一时间知道?靠 Kafka。
3.6 通过 Kafka 监听支付结果
为什么需要 Kafka
先抛问题:Payment 处理完微信回调、更新完自己的库,这件事就完了吗?
没有。业务方(Reward)还需要知道支付结果,才能更新打赏记录、给创作者分账。如果 Payment 直接 RPC 调 Reward——今天加一个「会员充值」业务,Payment 就要多调一个服务;明天再加一个,再多调一个。Payment 被业务方「绑架」了。
引入 Kafka 之后,方向反转:Payment 把支付结果发到 topic,谁关心谁订阅——Reward 订阅、积分服务订阅、风控也订阅,Payment 一个都不用认识。
flowchart LR
CB[微信回调] --> H[HandleCallback 更新本地]
H --> K[Kafka 支付结果消息]
K --> R[Reward 消费者]
K --> O[其他业务方]动手:Payment 生产消息
// internal/payment/service/callback.go
func (s *NativePaymentService) HandleCallback(ctx context.Context,
bizTradeNO, txnID string, status int8) error {
// 步骤 1:更新本地状态
err := s.db.UpdateStatus(ctx, bizTradeNO, txnID, status)
if err != nil {
return err
}
// 步骤 2:发送消息到 Kafka
err = s.producer.Produce(ctx, PaymentEvent{
BizTradeNO: bizTradeNO,
Status: status,
TxnID: txnID,
})
// 关键问题:处理结果成功,但发送消息失败怎么办?
// 解决思路:重试 + 监控 + 告警 + 异步补偿(本地消息表)
return err
}
逐行讲解:这段代码里藏着一个经典的部分失败——步骤 1 成功、步骤 2 失败:本地状态已经是 success,但消息没发出去,Reward 永远不知道这单支付成功了(除非用户主动来查询触发慢路径)。解决思路是「重试 + 监控 + 告警 + 异步补偿」,其中最完整的方案叫本地消息表——在更新状态的同一个事务里写一条「待发消息」,后台轮询补发,消息必达(第五节工程实践展开讲)。
消息有序性
先抛问题:如果消息包含「支付 → 退款」这种有顺序要求的事件,怎么保证消费端先收到支付、后收到退款?
Kafka 在初始化 producer 时指定用哈希负载均衡算法,相同 key 的消息会进同一个分区,同一分区的消息天然有序——好比同一个快递柜格口,先放进去的先取出来。
hash(key) % 分区数,分区数一变映射就变,新旧消息可能落到不同分区导致乱序。常见对策:提前规划好分区数不再扩;或用自定义分区器把 key 固定映射到某个分区;或业务侧带版本号/序列号,消费时检测乱序并处理。② 保证有序的同时消息积压怎么办? 同一个 key 只能被单分区串行消费,并发度天然受限。对策:优化消费逻辑、按 key 维度再拆 topic、或放宽为「业务容忍乱序 + 补偿修复」。动手:Reward 监听消息
// internal/reward/consumer/payment_event.go
func (c *RewardConsumer) Consume(msg *PaymentEvent) error {
// 步骤 1:从 bizTradeNO 解析出 rid,例如 "reward:123" → 123
parts := strings.Split(msg.BizTradeNO, ":")
if len(parts) != 2 || parts[0] != "reward" {
return nil // 不是打赏的消息,跳过
}
rid, _ := strconv.ParseInt(parts[1], 10, 64)
// 步骤 2:更新本地状态——这个操作是幂等的
return c.dao.UpdateStatus(context.Background(), rid, msg.Status)
}
逐行讲解:步骤 1 是「认领」——topic 上什么业务的消息都有(reward:123、vip:456……),每个消费者按前缀认领自己的,别人的直接跳过(return nil,不是 return err——跳过 ≠ 处理失败,返回 err 会触发 Kafka 重试,消息会被反复消费)。步骤 2 的更新是条件更新,天然幂等。
小收口:到这里,「读者点打赏 → 拿二维码 → 支付 → 回调 → Kafka → Reward 更新打赏记录」的完整链路已经打通。最后一环:创作者的钱怎么到账——分账与记账。
3.7 分账与记账:钱最终落在谁头上
先补课:分账是什么
补课胶囊 · 分账(split) 一句话:把一笔钱按规则拆成几份,分别记到不同账号上。 类比:合租打车回家——总车费 100,三个人按人头摊,每人转各自的份;平台也一样:用户打赏 1 元,作者分 8 毛、平台抽 2 毛,各记各的账。 最小示例:
Split(biz="reward", bizId=123, total=100, items=[{uid:作者, amount:80}, {uid:平台, amount:20}])——一个接口,两笔入账。 回到主线:打赏金额进来了,谁来执行这个拆分?拆完记到哪?这就是账号服务(Account)的职责。
谁来分账:四方对比
先抛问题:拆账这个动作,可以放在打赏服务、支付服务、账号服务……到底放哪?
每笔打赏要分成两笔(作者应得 + 平台抽成),四个方案各有取舍:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 打赏分 | 业务才知道分成比例 | 打赏耦合了记账逻辑 |
| 支付分 | 分账是支付流程的一部分 | 支付要知道业务分成比例 |
| 账号分 | 分成是记账业务的一部分 | 账号要知道业务分成比例 |
| 打赏计算 + 支付调记账 | 解耦:打赏算比例,支付记账 | 调用链变长 |
动手:账号服务 proto
// api/proto/account/v1/account.proto
service AccountService {
// 分账:把一笔账分成多笔
rpc Split(SplitReq) returns (SplitResp);
}
message SplitReq {
string biz = 1; // 业务标识
int64 biz_id = 2; // 业务 ID
int64 total = 3; // 总金额
repeated SplitItem items = 4; // 分账明细
}
message SplitItem {
int64 uid = 1; // 账号 UID
int64 amount = 2; // 分到的金额
string account_type = 3; // 账号类型:收入/支出
}
动手:账号服务表设计
先抛问题:记账需要哪几张表?为什么不是一张「余额表」就够?
给直觉:银行 App 给你看两个东西——「余额」和「流水」。余额告诉你现在有多少钱,流水告诉你每一笔怎么来的。只存余额,出了问题(余额不对)你无法追溯;只存流水,每次查余额要把全部流水加一遍。所以两张表:
-- scripts/account.sql:Account 表(账号余额)
CREATE TABLE `account` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`uid` BIGINT NOT NULL,
`balance` BIGINT NOT NULL COMMENT '余额,单位分',
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种',
`ctime` DATETIME NOT NULL,
`utime` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_uid_currency` (`uid`, `currency`)
);
-- scripts/account.sql:AccountActivity 表(账号变动记录,即"流水")
CREATE TABLE `account_activity` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`uid` BIGINT NOT NULL,
`biz` VARCHAR(32) NOT NULL,
`biz_id` BIGINT NOT NULL,
`amount` BIGINT NOT NULL COMMENT '变动金额,正数收入负数支出',
`balance_after` BIGINT NOT NULL COMMENT '变动后余额,便于审计',
`ctime` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_bizid_uid` (`biz`, `biz_id`, `uid`) -- 关键:幂等保证
);
逐行讲解:
account_activity的唯一索引uk_biz_bizid_uid是记账幂等的根基:同一笔业务(biz + biz_id)给同一个用户(uid)记账,物理上只能有一条记录——重复分账时 INSERT 撞唯一索引,直接失败,不会记两遍。balance_after记录「变动后余额」:审计时不用回放全部流水就能看到每笔之后的状态。- 还有一种常见变体:额外加一个字段标记金额是「变大还是变小」(方向),此时
amount只存正数。两种设计都行,团队统一就好。
设计要点:
- 币种问题:如果公司有出海计划,提前考虑字段但不必提前写多币种代码;
- 审计 / 会计 / 对账:字段多时可以拆表(AccountAudit、AccountTax);
- 不着急拆表:加一起才二三十字段就不拆。
动手:事务操作——余额 + 活动记录原子性
先抛问题:更新余额和写流水是两条 SQL,如果第一条成功、第二条失败会怎样?
余额加了 80,流水里却没有这笔记录——账实不符,审计灾难。所以两步必须同一个事务:
// internal/account/service/account.go
func (s *AccountService) Split(ctx context.Context, req *SplitReq) (*SplitResp, error) {
// 步骤 1:开启事务
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback() // 失败时回滚
for _, item := range req.Items {
// 步骤 2:更新账号余额
_, err := tx.ExecContext(ctx,
"UPDATE account SET balance = balance + ?, utime = NOW() "+
"WHERE uid = ? AND currency = 'CNY'",
item.Amount, item.Uid)
if err != nil {
return nil, err
}
// 步骤 3:记录账号活动(唯一索引保证幂等:biz+bizId+uid 唯一)
var balanceAfter int64
_ = tx.QueryRowContext(ctx,
"SELECT balance FROM account WHERE uid = ?", item.Uid).
Scan(&balanceAfter)
_, err = tx.ExecContext(ctx,
"INSERT INTO account_activity "+
"(uid, biz, biz_id, amount, balance_after, ctime) "+
"VALUES (?, ?, ?, ?, ?, NOW())",
item.Uid, req.Biz, req.BizId, item.Amount, balanceAfter)
if err != nil {
// 唯一索引冲突说明已经分过账,直接返回(幂等)
return nil, err
}
}
// 步骤 4:提交事务
if err = tx.Commit(); err != nil {
return nil, err
}
return &SplitResp{Success: true}, nil
}
flowchart TD
S[Split 分账请求] --> T[开启事务]
T --> U[循环: 更新每个账号余额]
U --> A[记录账号活动 唯一索引]
A --> C{全部成功?}
C -- 是 --> CM[提交事务]
C -- 否 --> RB[回滚]逐行讲解:
defer tx.Rollback()是 Go 事务的标准姿势——Commit 成功后 Rollback 是空操作,失败路径自动回滚,不会漏。- 循环里每个 item 都做「更新余额 + 记流水」:作者的 80 和平台的 20 都在这个事务里,要么全成功、要么全回滚。
- 唯一索引冲突 = 已分过账:重复调用 Split(比如消息重复消费触发),第二次 INSERT 撞索引失败、事务回滚——余额和流水都没被写第二次。幂等是靠数据库的物理约束,不是靠代码 if 判断(代码判断有并发漏洞:两个请求同时查「没分过账」,然后都去分)。
动手:Reward 调用分账接口
打赏服务算比例(业务规则归它),账号服务管记账:
// internal/reward/service/reward.go
func (s *RewardService) handlePaymentSuccess(ctx context.Context, rid int64) error {
// 步骤 1:查出打赏记录
r, err := s.dao.GetById(ctx, rid)
if err != nil {
return err
}
// 步骤 2:计算分成:作者 80%,平台 20%(示例比例)
authorAmount := r.Amount * 8 / 10
platformAmount := r.Amount - authorAmount
// 步骤 3:调用账号服务分账
// 注意:这里没有处理幂等——是作业中的一个选项
_, err = s.accountSvc.Split(ctx, &account.SplitReq{
Biz: "reward",
BizId: r.Id,
Total: r.Amount,
Items: []*account.SplitItem{
{Uid: r.Author, Amount: authorAmount, AccountType: "income"},
{Uid: PlatformUID, Amount: platformAmount, AccountType: "income"},
},
})
return err
}
逐行讲解:分成比例 作者 80% / 平台 20% 写在 Reward(业务方)里——这正是 3.7 节开头「打赏来分」结论的落地。platformAmount = r.Amount - authorAmount 而不是 r.Amount * 2 / 10:整除产生的零头要留给平台还是作者得统一约定,用「总额 − 甲方」的计算方式保证两人分到的钱加起来恒等于总额。
常见坑:代码注释里明确说了「这里没有处理幂等——是作业中的一个选项」。直接照抄这段代码上生产,重复调用 handlePaymentSuccess 会两次调用 Split——好在 Account 侧有唯一索引兜底(第二次 Insert 冲突失败),但更好的做法是 Reward 侧先做状态判断(已是「已分账」状态就跳过),把无效调用挡在门口。这留给本章动手练习。
小收口:到这里,支付的完整闭环拼上了最后一块:读者付钱(Payment)→ 打赏记录更新(Reward)→ 分账记账(Account)。正文讲完了,第四节把整条链路落成一个能跑起来的微服务——不需要商户号,Mock 微信全程可跑。
补充知识 · 进阶延伸【补充:小红书亿级流量项目】
课程在支付之外还有三个纯电商模块——购物车、商品中心、优惠券。它们和本社区项目的业务不重合,但都是"高并发 + 钱"的典型场景,面试同频;支付是它们语义上最近的邻居,所以以补充块形式就近放在本章,与正文以分隔线隔开。
1. 购物车:Redis hash 的教科书场景
// key=cart:{uid},field=skuId,value=数量|选中态 —— 一人一车,全 hash
rdb.HSet(ctx, "cart:1001", "sku:9001", "2|1") // 加购 2 件、选中
rdb.HIncrBy(ctx, "cart:1001", "sku:9001", 1) // 改数量:HINCRBY 原子自增
rdb.HDel(ctx, "cart:1001", "sku:9001") // 删除
要点:购物车以缓存为主存储(DB 只做兜底备份,加购/改数量异步合并写);大促抢购的热点商品要防击穿——singleflight + 本地缓存(06 章缓存三大问题的口诀直接复用);结算前库存校验前置:先用 Lua 在 Redis 原子预扣(if stock > 0 then decr),扣不动直接拒绝,别让请求打到 DB 才发现没货——防超卖的思想和下面优惠券完全一致。
cart:{uid} 灌成几万元素的 hash——购物车接口被拖垮不说,还是个潜在的大 key。前端校验是体验,服务端校验才是防线。2. 商品中心:SPU/SKU 与 10w QPS 详情页
建模四件套:类目(树形层级)、属性(属性分组、规格属性 vs 销售属性)、SPU(标准产品单元:iPhone 17)、SKU(库存单元:iPhone 17 黑色 256G——库存挂在 SKU 上)。商品详情页是典型的 10w QPS 场景,方案组合拳:多级缓存(本地 + Redis,09 章 3.7 / 3.10)+ 热点探测(爆款商品动态进本地缓存,09 章 3.12 的五步流程)+ 异步拼装(详情页的类目、属性、图片、评价等模块互不依赖,用 errgroup 并发拉取再聚合,07 章 3.4 的手法原样平移)。
3. 优惠券:热点库存 + 发号器 + 状态机
- 热点库存(大促抢券的核心考点):库存预热进 Redis;扣减必须 Lua 原子——“查库存、判断、扣减"三步在脚本内完成,并发下才不会超卖(07 章步骤 2 体验过 Lua 原子性,这里是它的实战主场);库存极大时做分段存储(
stock:{cid}:slice1..N,把一个 key 的写压力摊到 N 个 key,06 章 3.26 大 key 治理的同款思路);扣减成功发 MQ 异步落库,DB 只做最终记账; - 券码生成:美团 Leaf 发号器的两种模式——号段模式(DB 批量取一段 id 缓存在内存里慢慢发,DB 压力小但重启可能跳号)vs snowflake 模式(时间戳 + 机器位 + 序列;06 章 3.15 的雪花算法就是它,Go 生态用
sony/sonyflake);券码要"看着像随机、不能被猜”,通常再加一层混淆/校验位; - 过期与状态流转:
robfig/cron定时批量扫描过期券改状态(09 章的 cron 直接复用),状态流转用looplab/fsm这类状态机管理(待领取 → 已领取 → 已使用 / 已过期);领取防重:一人一券用SET NX(或布隆过滤器)做幂等,配合唯一索引双保险——幂等三板斧你在第 07、15 章已经见过两回了。
小收口:这三个模块的知识骨架你在前面全都见过——购物车是 hash + 缓存策略,商品中心是多级缓存 + 热点探测,优惠券是 Lua 原子 + 发号器 + 状态机。新模块 = 老组件的新组合,这正是本课程反复强调的设计观。
四、跟着写:分步实现 payment-service
正文里的代码是「讲解版」(简化了错误处理)。这一节把支付落成第一个真正独立部署的微服务 payment-service:
- 独立数据库
payment_db:钱的数据不和业务库混; - gRPC 端口 9100:对内提供 CreatePayment;
- HTTP 端口 8100:接收微信回调;
- 复用第 12 章的
pkg/registry注册进 ETCD。
真实商户号申请门槛高,所以按手册规格把微信支付抽象成 WechatPaySDK 接口,开发环境用 MockWechatPay 模拟「下单 → 支付 → 回调」的完整闭环;对账与打赏按「框架 + 核心代码」实现。步骤总览:
flowchart LR
S1[1 建库建表] --> S2[2 模型与Repository]
S2 --> S3[3 SDK抽象+Mock]
S3 --> S4[4 gRPC CreatePayment]
S4 --> S5[5 HTTP回调+Kafka]
S5 --> S6[6 对账定时任务]
S6 --> S7[7 打赏接入]
S7 --> S8[8 组装启动]这张图怎么读:八个步骤严格串行,前一步的产物是后一步的原料——「1 建库建表」先给出 payment_db 的表结构,「2 模型与 Repository」才有东西可读写;「3 SDK 抽象 + Mock」把微信支付换成内存可跑的假实现,后面的「4 gRPC CreatePayment」「5 HTTP 回调 + Kafka」「6 对账定时任务」全部依赖这个抽象才不卡在商户号上;「7 打赏接入」作为第一个业务方把前面建好的服务串起来,最后「8 组装启动」把注册进 ETCD 的 payment-service 整体跑起来。任何一步没验证通过都别急着进下一步。
第 1 步:建库建表——独立的 payment_db
先抛问题:为什么支付数据要单独建库?
三个理由:出问题时影响面小(业务库挂了支付库还活着)、将来方便单独扩容(钱的数据增长模式跟帖子完全不同)、方便审计(财务对账只碰这一个库)。
先建 payment 表——它比正文 3.3 节的极简版多了渠道、币种、客户端 IP 等工程字段;status 1~6 的含义与正文的状态机纪律一致(只单向流转,终态 3/4/5/6 不再变更):
-- scripts/payment.sql
CREATE DATABASE IF NOT EXISTS `payment_db` DEFAULT CHARACTER SET utf8mb4;
USE `payment_db`;
CREATE TABLE `payment` (
`id` BIGINT UNSIGNED NOT NULL COMMENT '雪花ID,代码里生成',
`payment_no` VARCHAR(64) NOT NULL COMMENT '支付单号,透传给微信当 out_trade_no',
`order_no` VARCHAR(64) NOT NULL COMMENT '业务订单号,业务方传入',
`user_id` BIGINT NOT NULL COMMENT '支付用户',
`amount` INT NOT NULL COMMENT '金额,单位分(见上文 3.3 节)',
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种',
`pay_channel` TINYINT NOT NULL DEFAULT 1 COMMENT '1微信 2支付宝',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1待支付 2支付中 3成功 4失败 5已退款 6已关闭',
`client_ip` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '下单时客户端IP,风控用',
`paid_at` DATETIME DEFAULT NULL COMMENT '支付成功时间',
`transaction_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信支付订单号,回调后回填',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`), -- CreatePayment 幂等的根基
UNIQUE KEY `uk_payment_no` (`payment_no`),
KEY `idx_status_created` (`status`, `created_at`) -- 对账扫超时单用
) ENGINE=InnoDB COMMENT='支付订单';
同一份脚本里再建 refund 退款表——退款单号 refund_no 上的唯一索引就是「重复退款」的去重凭证:
-- scripts/payment.sql(续)
CREATE TABLE `refund` (
`id` BIGINT UNSIGNED NOT NULL,
`refund_no` VARCHAR(64) NOT NULL COMMENT '退款单号,重复退款的去重凭证',
`payment_no` VARCHAR(64) NOT NULL COMMENT '关联的原支付单',
`amount` INT NOT NULL COMMENT '退款金额,单位分',
`reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '退款原因',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1退款中 2成功 3失败',
`refunded_at` DATETIME DEFAULT NULL COMMENT '退款完成时间',
`transaction_id` VARCHAR(64) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_refund_no` (`refund_no`), -- 退款幂等的根基
KEY `idx_payment_no` (`payment_no`)
) ENGINE=InnoDB COMMENT='退款单';
验证(跑一下,看到两张表才算这步完成):
docker exec -i mysql mysql -uroot -proot < scripts/payment.sql
docker exec mysql mysql -uroot -proot -e "SHOW TABLES IN payment_db;"
# 预期输出:payment、refund 两行
第 2 步:领域模型与 Repository——幂等的两根支柱
先抛问题:正文 3.3 节说「唯一索引挡重复创建、状态机挡重复更新」,落到代码里长什么样?
两根支柱,两个方法:CreateOrGet 撞唯一索引时把旧记录查出来返回(而不是报错);UpdateStatusIfPaying 只允许把非终态订单推进到新状态,重复回调时 WHERE 不命中、影响 0 行。
先定义模型和状态常量:
// internal/payment/repository/payment.go
package repository
import (
"context"
"errors"
"fmt"
"time"
"gorm.io/gorm"
)
// 支付状态机:只单向流转,终态(3/4/5/6)不再变更(纪律见上文 3.3 节)
const (
StatusPending int8 = 1 // 待支付
StatusProcessing int8 = 2 // 支付中
StatusSuccess int8 = 3 // 支付成功(终态)
StatusFailed int8 = 4 // 支付失败(终态)
StatusRefunded int8 = 5 // 已退款
StatusClosed int8 = 6 // 已关闭(终态)
)
// Payment 支付订单(字段与第 1 步的表一一对应)
type Payment struct {
ID int64 `gorm:"primaryKey"`
PaymentNo string // 我方支付单号 = 微信侧 out_trade_no
OrderNo string // 业务订单号:幂等凭证
UserID int64
Amount int // 单位:分
Currency string
PayChannel int8 // 1=微信
Status int8
ClientIP string
PaidAt *time.Time
TransactionID string // 微信支付订单号
CreatedAt time.Time
UpdatedAt time.Time
}
func (Payment) TableName() string { return "payment" }
type PaymentRepository struct{ db *gorm.DB }
func NewPaymentRepository(db *gorm.DB) *PaymentRepository {
return &PaymentRepository{db: db}
}
接着写第一根支柱——幂等创建。created=true 表示第一次创建;false 表示重复请求,old 是已存在的那条:
// internal/payment/repository/payment.go(续)
// CreateOrGet 幂等创建:order_no 撞唯一索引时不报错,改查旧记录返回。
func (r *PaymentRepository) CreateOrGet(ctx context.Context, p *Payment) (
old *Payment, created bool, err error) {
err = r.db.WithContext(ctx).Create(p).Error
if err == nil {
return nil, true, nil
}
if errors.Is(err, gorm.ErrDuplicatedKey) { // 需开 TranslateError 才能识别
var exist Payment
if e := r.db.WithContext(ctx).
Where("order_no = ?", p.OrderNo).First(&exist).Error; e != nil {
return nil, false, fmt.Errorf("query existed payment: %w", e)
}
return &exist, false, nil
}
return nil, false, fmt.Errorf("create payment: %w", err) // 其余错误才是真故障
}
再接第二根支柱——条件更新,以及后面几步要用的查询方法:
// internal/payment/repository/payment.go(续)
// UpdateStatusIfPaying 条件更新:只有非终态才允许改。
// 返回 false = 已是终态(重复回调)或单号不存在,调用方据此跳过后续动作
func (r *PaymentRepository) UpdateStatusIfPaying(ctx context.Context,
paymentNo string, status int8, txnID string) (bool, error) {
res := r.db.WithContext(ctx).Model(&Payment{}).
Where("payment_no = ? AND status IN (?)", paymentNo,
[]int8{StatusPending, StatusProcessing}).
Updates(map[string]any{"status": status, "transaction_id": txnID})
if res.Error != nil {
return false, fmt.Errorf("update payment status: %w", res.Error)
}
return res.RowsAffected == 1, nil
}
// GetByPaymentNo 回调后按支付单号取记录(拿 order_no 组装事件用)
func (r *PaymentRepository) GetByPaymentNo(ctx context.Context,
paymentNo string) (*Payment, error) {
var p Payment
if err := r.db.WithContext(ctx).
Where("payment_no = ?", paymentNo).First(&p).Error; err != nil {
return nil, fmt.Errorf("get payment by no: %w", err)
}
return &p, nil
}
// FindTimeoutOrders 对账用:找出超时仍非终态的订单
func (r *PaymentRepository) FindTimeoutOrders(ctx context.Context,
olderThan time.Duration) ([]Payment, error) {
var list []Payment
err := r.db.WithContext(ctx).
Where("status IN (?) AND created_at < ?",
[]int8{StatusPending, StatusProcessing},
time.Now().Add(-olderThan)).
Limit(100).Find(&list).Error // 每轮最多 100 单,别一次拖垮库
if err != nil {
return nil, fmt.Errorf("find timeout orders: %w", err)
}
return list, nil
}
逐行讲解:CreateOrGet 里 errors.Is(err, gorm.ErrDuplicatedKey) 是第 02 章讲过的姿势——要连上 TranslateError: true 才能把 MySQL 的 1062 错误翻译成这个 Go 哨兵错误(第 8 步组装时会开)。FindTimeoutOrders 的 Limit(100) 是防御:对账是后台任务,不能为了它把生产库拖垮。
验证:go build ./internal/payment/... 无输出即通过(本步是纯数据访问,行为验证放在第 5、8 步的接口联调里)。
第 3 步:微信支付 SDK 接口抽象 + MockWechatPay
先抛问题:没有商户号,怎么「接入微信支付」?
答案:把「我们要用的微信能力」抽成 WechatPaySDK 接口,业务层只依赖接口;开发环境注入 MockWechatPay,将来接真微信时换一个实现——第 04 章「面向接口」的老套路。注意区分:这是对外部依赖的抽象(必须做,为了可测试),不是正文 3.2 节反对的「多渠道统一抽象」(没必要,用户已选定渠道)。
// internal/payment/wechatpay/sdk.go
package wechatpay
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// WechatPaySDK 只抽象"我们用到的"微信能力(真实实现接官方 SDK)
type WechatPaySDK interface {
// NativePrepay native 扫码下单,返回 code_url(正文 3.3 节流程的环节①)
NativePrepay(ctx context.Context, outTradeNo, description string,
amount int) (codeURL string, err error)
// QueryOrderByOutTradeNo 按商户单号查微信侧状态(对账兜底,正文 3.4 节)
QueryOrderByOutTradeNo(ctx context.Context,
outTradeNo string) (tradeState, txnID string, err error)
// CloseOrder 关闭超时未支付的订单
CloseOrder(ctx context.Context, outTradeNo string) error
}
// 微信侧交易状态(真实 SDK 里是 native.TRADESTATE_*)
const (
TradeStateSuccess = "SUCCESS" // 支付成功
TradeStateClosed = "CLOSED" // 已关闭
TradeStateNotPay = "NOTPAY" // 未支付
)
接着写 Mock 实现——用内存 map 模拟微信:下单记一嘴;查单时「超过 PayLatency 就算用户已付款」:
// internal/payment/wechatpay/sdk.go(续)
// MockWechatPay 内存版"微信":不联网,用时间差模拟用户扫码付款
type MockWechatPay struct {
mu sync.Mutex
orders map[string]mockOrder // key: out_trade_no
txnSeq int // 自增的假微信订单号
PayLatency time.Duration // 下单后多久"用户支付完成"
}
type mockOrder struct {
codeURL string
amount int
createdAt time.Time
}
func NewMockWechatPay() *MockWechatPay {
return &MockWechatPay{
orders: map[string]mockOrder{},
PayLatency: 30 * time.Second,
}
}
func (m *MockWechatPay) NativePrepay(_ context.Context,
outTradeNo, _ string, amount int) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if o, ok := m.orders[outTradeNo]; ok {
// 复刻微信真实行为:重复单号返回同一 code_url(正文 3.4 节)
return o.codeURL, nil
}
codeURL := fmt.Sprintf("weixin://wxpay/bizpayurl?pr=%s", outTradeNo)
m.orders[outTradeNo] = mockOrder{
codeURL: codeURL, amount: amount, createdAt: time.Now(),
}
return codeURL, nil
}
最后补上查询和关单:
// internal/payment/wechatpay/sdk.go(续)
func (m *MockWechatPay) QueryOrderByOutTradeNo(_ context.Context,
outTradeNo string) (string, string, error) {
m.mu.Lock()
defer m.mu.Unlock()
o, ok := m.orders[outTradeNo]
if !ok {
return "", "", errors.New("order not found") // 模拟微信查无此单
}
if time.Since(o.createdAt) >= m.PayLatency {
// 超过延迟视为"用户已扫码付款"
m.txnSeq++
return TradeStateSuccess, fmt.Sprintf("4200%08d", m.txnSeq), nil
}
return TradeStateNotPay, "", nil
}
func (m *MockWechatPay) CloseOrder(_ context.Context, outTradeNo string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.orders, outTradeNo)
return nil
}
逐行讲解:NativePrepay 里那个「订单已存在就返回原 codeURL」的分支不是顺手写的——它复刻了微信 Prepay 的真实幂等行为(正文 3.4 节讲的「OutTradeNO 重复但依旧返回 code_url」)。Mock 的行为越贴近真实,第 8 步联调时验证的幂等逻辑才越可信。PayLatency 默认 30 秒:调小(甚至 0)可以模拟「用户秒付」,调大模拟「用户一直不付」——第 6 步的对账验证就靠调这个参数。
验证:go build ./internal/payment/wechatpay/ 无输出;可再写个小测试——同一 outTradeNo 调两次 NativePrepay,断言返回同一 codeURL。
payment.wechat.mock)保证 Mock 只在开发环境生效。第 4 步:gRPC 接口 CreatePayment——幂等三分支
先抛问题:同一个 order_no 重复调用 CreatePayment,该返回什么?
幂等三分支:新单直接下单;旧单还在待支付就返回原单原参数;旧单已终结(成功/失败/关闭)明确报错。先按第 10 章套路定义 proto(api/proto/payment/v1/payment.proto,protoc 生成命令与第 10 章相同,生成代码放 api/gen/payment/v1/,不再重复):
// api/proto/payment/v1/payment.proto
service PaymentService {
// 创建支付:返回二维码链接;对同一 order_no 幂等
rpc CreatePayment (CreatePaymentReq) returns (CreatePaymentResp);
}
message CreatePaymentReq {
string order_no = 1; // 业务订单号(幂等凭证,如 reward:42:1001:1760000000)
int64 user_id = 2; // 支付用户
int32 amount = 3; // 金额,单位分
string description = 4; // 商品描述,展示在微信收银台
}
message CreatePaymentResp {
string payment_no = 1; // 我方支付单号
string code_url = 2; // 二维码链接,渲染成二维码是前端的活儿
}
接着写业务层。先搭服务骨架(依赖都在构造函数里注入):
// internal/payment/service/payment.go
package service
// 导入:context/errors/fmt + sarama + snowflake + zap
// + 项目内 repository/wechatpay 包
// ErrOrderAlreadyPaid 已支付订单再次发起支付
var ErrOrderAlreadyPaid = errors.New("订单已支付,不能重复发起")
// PaymentService 支付领域服务:repo 挡重复创建,wx 是可替换的微信实现
type PaymentService struct {
repo *repository.PaymentRepository
wx wechatpay.WechatPaySDK
producer sarama.SyncProducer // 第07章的同步生产者,第 5 步发事件用
topic string // payment-events
idGen *snowflake.Node // 第06章同款雪花库,生成支付单号
logger *zap.Logger
}
func NewPaymentService(repo *repository.PaymentRepository,
wx wechatpay.WechatPaySDK, producer sarama.SyncProducer,
topic string, logger *zap.Logger) *PaymentService {
node, _ := snowflake.NewNode(15) // worker ID=15;多实例部署时改成配置
return &PaymentService{repo: repo, wx: wx, producer: producer,
topic: topic, idGen: node, logger: logger}
}
现在写核心的 CreatePayment——注意 !created 分支里的三分支判断:
// internal/payment/service/payment.go(续)
// CreatePayment 创建支付单并调微信下单
func (s *PaymentService) CreatePayment(ctx context.Context,
orderNo string, userID int64, amount int, desc string) (
string, string, error) {
id := s.idGen.Generate().Int64()
p := &repository.Payment{
ID: id,
PaymentNo: fmt.Sprintf("PAY%d", id), // 支付单号 = 雪花ID,天然唯一
OrderNo: orderNo,
UserID: userID,
Amount: amount,
Currency: "CNY",
PayChannel: 1, // 1=微信
Status: repository.StatusPending,
}
old, created, err := s.repo.CreateOrGet(ctx, p)
if err != nil {
return "", "", err
}
if !created { // 重复请求:按旧单状态走三分支
switch old.Status {
case repository.StatusSuccess:
return "", "", ErrOrderAlreadyPaid
case repository.StatusFailed, repository.StatusClosed,
repository.StatusRefunded:
return "", "", fmt.Errorf(
"订单已终结(状态=%d),请换单号重新发起", old.Status)
default: // 待支付/支付中:返回原单参数(微信 Prepay 自身幂等)
codeURL, e := s.wx.NativePrepay(ctx, old.PaymentNo, desc, old.Amount)
if e != nil {
return "", "", fmt.Errorf("wechat prepay(repeat): %w", e)
}
return old.PaymentNo, codeURL, nil
}
}
codeURL, err := s.wx.NativePrepay(ctx, p.PaymentNo, desc, p.Amount)
if err != nil {
return "", "", fmt.Errorf("wechat prepay: %w", err)
}
return p.PaymentNo, codeURL, nil
}
逐行讲解:gRPC Handler 是薄壳,只搬运参数(internal/payment/grpc/server.go,第 10 章 user/grpc 同款套路,一行调 svc.CreatePayment 即可,不占篇幅)。三分支的判断依据全部来自 CreateOrGet 返回的旧单状态——没有一次额外的 SELECT,幂等判断的成本被「先 Insert、撞索引再查」这个顺序吸收了。
验证:go build ./internal/payment/... 无输出;运行时联调(grpcurl 重发同一 order_no)放在第 8 步。
第 5 步:HTTP 回调——回调幂等 + 发 Kafka 事件
先抛问题:回调处理要同时满足两件事——不重复处理(幂等)、结果要让 Reward 知道(发 Kafka)。代码怎么组织?
回调走独立的 HTTP 端口 8100(为什么 Payment 自己起 Web,见第五节工程实践)。业务全在 service 层,Web 层只解析参数和回包。先写事件结构和处理逻辑——条件更新首次推进状态成功才发事件,重复回调直接当成功返回:
// internal/payment/service/callback.go
// 导入:context/encoding-json/fmt/time + sarama + zap
// + 项目内 repository/wechatpay 包
// PaymentEvent 发到 Kafka(topic=payment-events) 的支付结果事件
type PaymentEvent struct {
EventID string `json:"event_id"` // 消费端幂等凭证(确定性拼接,见下方坑)
PaymentNo string `json:"payment_no"`
OrderNo string `json:"order_no"` // 业务方靠它认领自己的单
Status int8 `json:"status"`
TransactionID string `json:"transaction_id"`
PaidAt int64 `json:"paid_at"` // unix 秒
}
// HandleCallback 回调与对账共用的状态推进入口:
// 条件更新保幂等,首次生效才发事件
func (s *PaymentService) HandleCallback(ctx context.Context,
paymentNo, txnID, wxState string) error {
status := mapWxStatus(wxState)
affected, err := s.repo.UpdateStatusIfPaying(ctx, paymentNo, status, txnID)
if err != nil {
return fmt.Errorf("update status: %w", err)
}
if !affected {
s.logger.Info("重复回调,跳过", zap.String("payment_no", paymentNo))
return nil // 幂等:已处理过也回成功,微信才会停止重试
}
if status != repository.StatusSuccess && status != repository.StatusFailed {
return nil // 非终态不发事件,消费端只关心最终结果
}
p, err := s.repo.GetByPaymentNo(ctx, paymentNo)
if err != nil {
return err
}
payload, _ := json.Marshal(PaymentEvent{
EventID: paymentNo + ":" + txnID, // 确定性 ID:重试也不会变
PaymentNo: paymentNo,
OrderNo: p.OrderNo,
Status: status,
TransactionID: txnID,
PaidAt: time.Now().Unix(),
})
_, _, err = s.producer.SendMessage(&sarama.ProducerMessage{
Topic: s.topic,
Key: sarama.StringEncoder(paymentNo), // 同单同分区,保证顺序
Value: sarama.ByteEncoder(payload),
})
if err != nil {
// 可靠性兜底:本地消息表(第五节工程实践)
return fmt.Errorf("produce payment event: %w", err)
}
return nil
}
接着写状态映射和 Web 层——把正文 3.3 节的 switch 换成手册编码:
// internal/payment/service/callback.go(续)
// mapWxStatus 微信交易状态 → 我方状态机
func mapWxStatus(s string) int8 {
switch s {
case wechatpay.TradeStateSuccess:
return repository.StatusSuccess
case "CLOSED", "REVOKED", "PAYERROR":
return repository.StatusFailed
default:
return repository.StatusProcessing
}
}
// internal/payment/web/notify.go
// 导入:net/http + gin + zap + 项目内 service 包
// NotifyRequest 微信回调(解密后)的精简结构;
// 真实验签+解密用官方 SDK,见正文 3.3 节
type NotifyRequest struct {
OutTradeNo string `json:"out_trade_no"` // 即我方 payment_no
TransactionID string `json:"transaction_id"`
TradeState string `json:"trade_state"`
}
type NotifyHandler struct {
svc *service.PaymentService
logger *zap.Logger
}
func NewNotifyHandler(svc *service.PaymentService,
logger *zap.Logger) *NotifyHandler {
return &NotifyHandler{svc: svc, logger: logger}
}
// WechatNotify POST /api/payment/wechat/notify
func (h *NotifyHandler) WechatNotify(ctx *gin.Context) {
var req NotifyRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "参数错误"})
return
}
if err := h.svc.HandleCallback(ctx, req.OutTradeNo,
req.TransactionID, req.TradeState); err != nil {
h.logger.Error("回调处理失败",
zap.String("payment_no", req.OutTradeNo), zap.Error(err))
// 返回 5xx 让微信重试,配合第 6 步对账兜底
ctx.JSON(http.StatusInternalServerError,
gin.H{"code": 1, "msg": "处理失败"})
return
}
ctx.JSON(http.StatusOK, gin.H{"code": 0, "return_code": "SUCCESS"})
// 铁律:成功必须回 200
}
逐行讲解:HandleCallback 是回调与对账共用的入口——第 6 步的对账补单,直接复用这个方法(推进状态 + 补发事件),一份逻辑两处使用。!affected 时返回 nil 而不是错误——对微信来说「重复回调被正确忽略」也是一种成功,必须回 200 让它停止重试。
paymentNo:txnID)——重试多少次 ID 都不变,消费端才能可靠去重。验证:go build ./internal/payment/... 无输出;curl 模拟回调的联调放在第 8 步。
第 6 步:对账定时任务——补单与关单
先抛问题:回调丢了(微信重试上限后放弃),钱已付但订单一直 pending,用户来投诉怎么办?
生产的完整形态是「下载微信对账单 CSV 全量比对」,本地 Mock 没有账单可下,所以先把更关键的兜底链路跑通:定时扫超时非终态单 → 逐单查微信 → 已支付则补单(复用 HandleCallback,状态推进 + 补发事件一条龙)、一直没人付则关单:
// internal/payment/service/reconcile.go
// 导入:context/time + zap + 项目内 wechatpay/repository 包
// ScheduleReconcile 对账兜底:扫超时单 → 查微信 → 补单/关单
func (s *PaymentService) ScheduleReconcile() {
ctx := context.Background()
// 二维码默认 2 小时过期,留 1 分钟缓冲
orders, err := s.repo.FindTimeoutOrders(ctx, 121*time.Minute)
if err != nil {
s.logger.Error("对账:查超时单失败", zap.Error(err))
return
}
for _, o := range orders {
state, txnID, err := s.wx.QueryOrderByOutTradeNo(ctx, o.PaymentNo)
if err != nil {
s.logger.Error("对账:查微信失败",
zap.String("payment_no", o.PaymentNo), zap.Error(err))
continue // 单条失败不中断整轮
}
switch state {
case wechatpay.TradeStateSuccess:
// 回调丢了但钱已付 → 补单:推进状态 + 补发事件
if err := s.HandleCallback(ctx, o.PaymentNo, txnID, state); err == nil {
s.logger.Info("对账:补单成功",
zap.String("payment_no", o.PaymentNo))
}
case wechatpay.TradeStateNotPay:
// 一直没人付 → 关单
if affected, err := s.repo.UpdateStatusIfPaying(ctx,
o.PaymentNo, repository.StatusClosed, ""); err == nil && affected {
_ = s.wx.CloseOrder(ctx, o.PaymentNo)
s.logger.Warn("对账:超时关单",
zap.String("payment_no", o.PaymentNo))
}
}
}
}
逐行讲解:补单为什么直接调 HandleCallback?因为「查到已支付」和「收到成功回调」对系统来说应该是完全等价的——同一套状态推进逻辑、同一个事件补发出口。如果对账另写一套更新代码,两处逻辑迟早漂移。任务调度复用第 09 章的 cron 套路(第 8 步组装时挂上 @every 1m);多实例部署时再加 pkg/lock 分布式锁防重复扫,全量比对差异表留作本章作业。
验证:go build ./internal/payment/... 无输出。运行时验证(第 8 步起服务后):把 FindTimeoutOrders 的参数临时改成 1*time.Second,PayLatency 设为 0 → 日志出现「补单成功」;PayLatency 保持默认 30 秒 → 日志出现「超时关单」。两种日志都见到,这步才算过。
第 7 步:打赏接入——post-service 侧(框架 + 核心)
先抛问题:打赏是 post-service(业务方)发起的,它怎么调 payment-service?
发起侧按手册规则拼 order_no = reward:{postId}:{uid}:{timestamp}——比正文 3.5 节的 reward:{rid} 多了 uid 和时间戳,保证「同一用户对同一帖子的多次打赏尝试」也能区分;通过第 13 章的负载均衡 gRPC 客户端调 CreatePayment。支付结果不走回调轮询,而是消费 payment-events(正文 3.6 节的理由):
// internal/post/service/reward.go(只列本章新增核心)
// Reward 发起打赏:按手册规则拼 order_no,调 payment-service
func (s *RewardService) Reward(ctx context.Context,
uid, postId int64, amount int, title string) (string, error) {
orderNo := fmt.Sprintf("reward:%d:%d:%d", postId, uid, time.Now().Unix())
resp, err := s.paymentClient.CreatePayment(ctx,
&paymentv1.CreatePaymentReq{
OrderNo: orderNo,
UserId: uid,
Amount: int32(amount),
Description: fmt.Sprintf("打赏帖子《%s》", title), // 展示在微信收银台
})
if err != nil {
return "", fmt.Errorf("create payment: %w", err)
}
// 生产上还要:落一条待确认的打赏记录、
// 把 codeURL 缓存 2 小时复用(正文 3.5 节)
return resp.GetCodeUrl(), nil
}
接着写消费侧——两层幂等防护:
// internal/post/consumer/payment_event.go
// (消息体字段同 PaymentEvent 的 JSON 契约)
// Consume 消费 payment-events:event_id 去重 + 只认领打赏单
func (c *RewardConsumer) Consume(ctx context.Context, e PaymentEventMsg) error {
// 幂等第一层:event_id 去重(Redis SETNX,24 小时过期兜底)
ok, err := c.redis.SetNX(ctx,
"dedup:payment-evt:"+e.EventID, 1, 24*time.Hour).Result()
if err == nil && !ok {
return nil // 重复投递/重复消费:处理过,跳过
}
// 只认领打赏单:order_no 前缀 reward: 且支付成功(3)
if !strings.HasPrefix(e.OrderNo, "reward:") || e.Status != 3 {
return nil // 其他业务方的单各取所需,互不影响
}
// 幂等第二层(兜底):按 order_no 条件更新打赏记录,
// 重复消费不会重复记账
// TODO(框架):更新 reward 记录状态 → 更新帖子打赏数/金额冗余字段
// → 通知作者 → 分账(正文 3.7 节)
c.logger.Info("打赏支付成功", zap.String("order_no", e.OrderNo))
return nil
}
逐行讲解:两层幂等分工明确——Redis SETNX 挡住绝大多数重复(快、便宜),但 Redis 是可能挂的;所以第二层靠数据库条件更新兜底(慢一点,但绝对可靠)。TODO 注释标出了完整闭环还差的四步,都在正文讲过原理,这里留作框架。
验证:go build ./internal/post/... 无输出。联调(第 8 步起服务后):用 kafka-console-producer 手发一条同格式 JSON(同一 event_id 连发两次),日志里「打赏支付成功」只出现一次。
第 8 步:组装启动——配置 + ETCD 注册 + 双端口并行
最后把所有零件组装成一个进程。先给 payment-service 追加配置段:
# configs/config.yaml 追加 payment 段
payment:
grpc:
port: 9100 # gRPC:对内提供 CreatePayment
http:
port: 8100 # HTTP:接收微信回调(生产须公网可达)
db:
dsn: "root:root@tcp(localhost:3306)/payment_db?charset=utf8mb4&parseTime=True"
kafka:
addrs: ["localhost:9092"]
topic: "payment-events"
wechat:
mock: true # 开发用 Mock;生产改 false 走真实 SDK
接着写启动入口。通用初始化(viper 读配置、zap、gorm 连 payment_db 并开 TranslateError: true、sarama 同步生产者)与第 12 章 cmd/user 同构,不重复贴,直接从「选微信实现」开始:
// cmd/payment/main.go(通用初始化见文件头注释,同构部分不重复贴)
func main() {
var wx wechatpay.WechatPaySDK = wechatpay.NewMockWechatPay() // 开发环境 Mock
if !viper.GetBool("payment.wechat.mock") {
panic("真实微信实现按正文 3.3 节接官方 SDK 后替换这里")
}
svc := service.NewPaymentService(repo, wx, producer,
viper.GetString("payment.kafka.topic"), logger)
// gRPC 9100:先 Listen 再注册(顺序反了的后果见第12章)
lis, err := net.Listen("tcp",
fmt.Sprintf(":%d", viper.GetInt("payment.grpc.port")))
if err != nil {
panic(err)
}
grpcServer := grpc.NewServer() // 生产挂第10章日志 + 第14章治理拦截器
paymentv1.RegisterPaymentServiceServer(grpcServer,
grpc.NewPaymentServer(svc))
ins := ®istry.ServiceInstance{ // 注册 ETCD:复用第12章 pkg/registry
ServiceName: "payment-service",
InstanceID: fmt.Sprintf("payment-service-%s:%d",
registry.GetOutboundIP(), 9100),
Address: registry.GetOutboundIP(), Port: 9100,
Protocol: "grpc", Weight: 100,
Metadata: map[string]string{"version": "v1.0.0", "env": "dev"},
}
reg := registry.NewRegistry(etcdClient, logger)
if err := reg.Register(ins); err != nil {
panic(err) // fail-fast:注册不上就退出(第12章)
}
同一个 main 函数里继续——HTTP 回调端口、对账 cron、优雅停机:
// cmd/payment/main.go(续)
// HTTP 8100:微信回调专用端口
engine := gin.Default()
engine.POST("/api/payment/wechat/notify",
web.NewNotifyHandler(svc, logger).WechatNotify)
// 对账 cron:第09章调度套路;多实例部署再加分布式锁防重复扫
c := cron.New(cron.WithSeconds())
c.AddJob("@every 1m", cron.FuncJob(svc.ScheduleReconcile))
c.Start()
go func() {
_ = engine.Run(
fmt.Sprintf(":%d", viper.GetInt("payment.http.port")))
}()
go func() { _ = grpcServer.Serve(lis) }()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
_ = reg.Deregister() // 优雅停机:先注销再停服(第12章套路)
grpcServer.GracefulStop()
}
验证(对照本章开头「学完你能跑出来的东西」,逐条来):
go run ./cmd/payment # 日志出现 ETCD 注册成功 + 双端口监听
另开终端逐条验证:
# ① 注册进 ETCD 了吗?
etcdctl get /services/payment-service/ --prefix
# 预期:能查到本实例
# ② 幂等下单:同一 order_no 连调两次
grpcurl -plaintext -import-path ./api/proto/payment/v1 -proto payment.proto \
-d '{"order_no":"reward:42:1001:1760000000","user_id":1001,"amount":100,"description":"打赏帖子《Go入门》"}' \
localhost:9100 payment.v1.PaymentService/CreatePayment
# 预期:{"paymentNo":"PAY...","codeUrl":"weixin://..."}
# 同参数再发一次,两次 codeUrl 完全一致(幂等生效)
# ③ 回调幂等:模拟微信回调两次
curl -X POST localhost:8100/api/payment/wechat/notify \
-d '{"out_trade_no":"上一步的 paymentNo","transaction_id":"4200000001","trade_state":"SUCCESS"}'
# 预期:{"code":0,...};立刻重复 curl 一次,日志出现「重复回调,跳过」
# ④ 事件不重复
kafka-console-consumer --bootstrap-server localhost:9092 \
--topic payment-events --from-beginning
# 预期:只收到一条支付成功事件(回调幂等生效,事件没有重复发)
本章完成后的项目结构
只列出本章新增(★)与修改(▲)的部分:
content-community/
├── api/proto/payment/v1/payment.proto # ★ CreatePayment 接口定义(生成代码在 api/gen/payment/v1)
├── cmd/payment/main.go # ★ payment-service 启动入口
├── configs/config.yaml # ▲ 追加 payment 段
├── scripts/payment.sql # ★ 建库建表脚本
└── internal/
├── payment/ # ★ 本章主战场
│ ├── repository/payment.go # 模型 + 幂等创建/条件更新/超时查询
│ ├── wechatpay/sdk.go # WechatPaySDK 接口 + MockWechatPay
│ ├── service/payment.go # CreatePayment 幂等三分支
│ ├── service/callback.go # 回调幂等 + Kafka 事件
│ ├── service/reconcile.go # 对账定时任务
│ ├── grpc/server.go # gRPC 薄壳
│ └── web/notify.go # 微信回调 Handler
└── post/
├── service/reward.go # ▲ 发起打赏:调 CreatePayment
└── consumer/payment_event.go # ▲ 消费 payment-events(event_id 幂等)
验证清单
-
SHOW TABLES IN payment_db能看到payment、refund两张表,order_no / payment_no / refund_no 唯一索引齐全 -
go build ./...通过;go run ./cmd/payment后 ETCD 能查到/services/payment-service/实例 - 同一 order_no 连调两次 CreatePayment 返回相同 codeUrl;把该单 status 改成 3 再调,返回「订单已支付」
- 同一回调 curl 两次:只有第一次推进状态并发出一条 payment-events 消息,第二次日志出现「重复回调,跳过」
- 对账:把超时参数临时调成 1 秒——PayLatency=0 时日志「补单成功」,默认 30 秒时日志「超时关单」
- post-service 消费端对同一 event_id 的重复投递只处理一次(日志「打赏支付成功」只出现一次)
- 回调接口处理成功时返回 HTTP 200 且
return_code为 SUCCESS
五、工程实践要点
正文和「跟着写」覆盖了主线,这一节把生产环境里「面试官最爱追问、上线前必须想清楚」的点集中列出。
1. 幂等性是支付系统的命脉
回头看全章,幂等不是一个「功能」,而是每个关键操作的准入条件:
| 操作 | 幂等手段 |
|---|---|
| Prepay 下单 | BizTradeNO 唯一索引 + 微信侧幂等 |
| 处理回调 | 唯一索引 + 状态机判断(已处理则跳过) |
| 消息消费 | 数据库唯一索引 / Redis 去重 / 布隆过滤器 |
| 分账记账 | biz + bizId + uid 唯一索引 |
HandleCallback 里无条件更新余额,重复回调就会造成「用户付了一次,创作者收到两次钱」。正确做法:先查当前状态,已是 success 就直接返回,不让金额变动重复执行——工程版的落地就是第 2 步的 UpdateStatusIfPaying 条件更新。2. 数据一致性:识别「部分失败」
凡是涉及调用不同组件、不同服务的地方,都有可能不一致。部分失败是面试重点——本章至少出现了三处:
- 回调处理:更新本地成功、发 Kafka 失败(→ 本地消息表);
- 打赏链路:Payment 记录成功、Reward 没收到消息(→ 对账 + 慢路径查询);
- 分账:余额更新成功、流水写入失败(→ 同一个事务)。
方法论都一样:梳理每个步骤失败的后果,引入数据比对和修复机制。
3. Payment 自己起 Web 服务 vs 借用 BFF
微信回调是 HTTP 请求,谁来接?
| 方案 | 优点 | 适用场景 |
|---|---|---|
| BFF 处理回调 | 接入快,无需额外服务 | 小公司初期 |
| Payment 自己起 Web | 可针对回调做特色治理 | 推荐,生产主流 |
本章选了后者(HTTP 8100 独立端口)——回调的验签、幂等、重试策略、监控告警都可以在 Payment 内部闭环,不污染 BFF。
4. 何时引入消息队列
呼应正文 3.6 节:不要提早引入 Kafka。等真的有这些需求时再上:
- 多部门订阅支付结果;
- 自己控制重试机制;
- DEBUG 时需要绕开 payment-web;
- 秒杀等削峰场景。
5. 数据存储:不要多存
早期经验:如果数据是别的业务方给的,对方也存了,自己就不要存了——存了大概率用不上(正文 3.3 节的极简 payment 表就是这条原则的体现)。
6. 本地消息表:保证消息必发
正文 3.6 节留下的「更新成功但发消息失败」问题,最完整的解法:在 Payment 处理回调的事务里同时写一条「待发消息」到本地消息表——事务保证「状态更新」和「消息记录」原子生效;后台 goroutine 轮询消息表,发送到 Kafka 成功后标记已发送。这就是本地消息表模式:消息可能迟到,但绝不丢失。
7. 记账幂等的三种方案对比
消息消费和记账幂等,不止唯一索引一条路:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 唯一索引 | 简单可靠 | 高并发下数据库压力大 |
| Redis 去重 | 性能高 | Redis 挂了可能重复 |
| 布隆过滤器 | 内存占用低 | 有误判率,删除困难 |
推荐组合:布隆过滤器前置过滤 + 唯一索引兜底——绝大多数重复在布隆过滤器就被拦下(快),漏网的撞唯一索引(稳)。
8. 三者对账:打赏-支付-记账
比「和微信对账」更进一步的是内部三方对账:打赏(Reward)、支付(Payment)、记账(Account)三个服务各自记了同一笔业务的关键事件(创建、状态变更):
- 每个服务记录自己的关键事件;
- 定时任务对比三者数据;
- 发现不一致时以微信支付数据为准,触发数据修复。
六、自测题与动手练习
自测题(先别翻正文,合上书能答出来才算懂;答案要点都藏在正文里):
- 用自己的话讲一遍「钱在第三方完成」是什么意思。我们是通过哪三个环节和微信交互的?
- 支付状态机有哪几个状态?为什么状态只能单向流转?「条件更新」是怎么挡住重复回调的?
- Prepay 超时后为什么可以放心重试?微信的 Prepay 接口满足幂等的哪个关键特性?
- 回调处理不返回 HTTP 200 会发生什么?处理失败反而返回 200 又会发生什么?
- 幂等的两根支柱分别挡住了什么?为什么「先查状态再决定跳过」的代码写法在并发下不可靠?
- 打赏查询的快路径和慢路径分别做什么?触发降级时应该怎么调整?
- Kafka 怎么保证「支付→退款」这类消息的顺序?扩分区为什么会破坏有序性?
- 分账的「更新余额 + 记流水」为什么必须同一个事务?记账幂等靠哪个唯一索引?
动手练习(建议真做一遍,做到才算过关):
- 画状态机 + 幂等验证:画出支付状态机(init / success / failed,或工程版的 1~6),标出所有合法转移和终态。然后写一个测试——同一
BizTradeNO调两次 Prepay,断言第二次返回的是同一条记录的 code_url。完成标准:状态机图里终态没有任何出边;测试断言通过。 - 写对账定时任务:实现
ScheduleReconcile,把 2 小时 1 分钟前的 pending 订单逐单去微信查状态(用 Mock 的QueryOrderByOutTradeNo),打印「本地 vs 微信」不一致的订单并补单。完成标准:把PayLatency设为 0、超时参数调成 1 秒,日志能看到「补单成功」;保持默认参数能看到「超时关单」。 - Kafka 消费幂等 + 分账幂等:实现
RewardConsumer.Consume(含分账调用),故意连发两条相同event_id的消息。完成标准:打赏状态只更新一次;account_activity表里biz + biz_id + uid各只有一条记录;再补上 Reward 侧的「已分账跳过」状态判断,让第二次调用根本不发起 Split。 - (选做,课程作业)本地消息表:给
HandleCallback加上本地消息表——事务里写「待发消息」,后台 goroutine 轮询补发。完成标准:故意让 producer 返回错误,重启后消息仍能补发出去,Kafka 里最终恰好一条事件。
七、模拟面试问答
以下 16 道题覆盖本章所有高频考点,按「基础 → 进阶 → 追问」三档排列。每题给出可以直接背的一句话结论、展开思路、面试官想听的关键词、新手最容易答错的地方。
一句话结论:用户扫码后绕开我们的系统、直接和微信支付打交道,钱真正在微信侧完成;我们靠微信的异步回调(辅以主动查单)拿到结果。
展开:类似微信扫码登录——我们调 Prepay 在微信那边创建订单、拿到 code_url 给前端渲染二维码;用户扫码付款全程在微信 App 里,我们完全不参与。支付完成后微信向 notify_url 发 POST 回调,我们验签、更新订单状态、发消息通知业务方。所以「钱在第三方、结果靠回调」是支付系统的第一认知,而回调可能丢、可能重复,才引出幂等和对账两大主题。
面试官想听的关键词:钱在第三方、Prepay 下单、code_url、异步回调、主动查单。
常见失分点:以为支付流程里我们经手了钱(没有,我们只是发起方和记账方);说不出回调之外还有「主动查单」这条兜底路径。
一句话结论:极简版是 init → success / failed(工程版 1~6:待支付 → 支付中 → 成功/失败/已退款/已关闭),状态只能从初始向终态单向推进,终态不再变更。
展开:单向流转的原因是防重复处理:success / failed 是终态,重复回调到达时「条件更新」UPDATE ... WHERE status = init 影响 0 行,天然不会把分账之类副作用执行两遍。如果允许状态回退(success 改回 init),第二个回调又能推进一次状态机,副作用重复执行。所以状态机 + 唯一索引是幂等的两大支柱:唯一索引挡「重复创建」,状态机挡「重复更新」。
面试官想听的关键词:单向流转、终态、条件更新、影响行数、两大支柱。
常见失分点:只会背状态列表,说不出「条件更新影响 0 行」这个机制;不知道到达终态的订单不应再发起任何外部调用。
一句话结论:验签是唯一能证明「回调真的是微信发的、内容没被篡改」的手段,验签 + AES-256-GCM 解密细节坑极多必须用官方 SDK;处理成功必须返回 200,否则微信会按退避策略反复重试。
展开:回调 HTTP 头里带微信平台私钥的签名,SDK 用微信平台证书验签、再用商户 APIv3 密钥解密 resource 字段——证书下载轮换、时间戳校验全是坑,手写必翻车。不返回 200,微信认为你没收到就反复重发,可能短时间打爆接口、造成状态重复处理;反过来,处理失败却返回 200,微信停止重试,这条支付结果就彻底丢了——失败要回 5xx 让微信重试,同时靠对账兜底。
面试官想听的关键词:平台证书、APIv3 密钥、AES-256-GCM、退避重试、200 / 5xx 语义。
常见失分点:只答「安全」两个字,说不出验签到底防的是什么(伪造回调 + 内容篡改);不知道「失败返回 200」是更隐蔽的坑。
一句话结论:浮点数有精度问题(0.1 + 0.2 ≠ 0.3),int64 存最小面额(分)整数运算无精度损失、性能好,是后端存钱的默认选择。
展开:三种方案各有场景:int64 记最小面额,性能好,银行要记 4 位小数(厘)也能扩展;decimal 精度准但性能差,适合复杂运算;string 只适合传输存储不参与运算。核心判断:钱的字段几乎只做加减,用整数最稳。同时注意传给微信的 amount 单位也是分,全链路统一单位能避免「元转分」的换算 bug。
面试官想听的关键词:浮点精度、最小面额、int64/decimal/string 三方案、全链路统一单位。
常见失分点:只说「float 不精确」却给不出具体例子;忽略「单位统一」——存库用分、接口用元,换算处就是事故高发区。
一句话结论:快路径是状态已确定时直接返回本地记录;慢路径是状态还是 pending 时主动去查 Payment(再查微信)拿最新状态;触发降级时跳过慢路径,只看本地。
展开:用户支付只和微信交互,我们靠回调知道结果,但回调可能还没到——所以查询接口先查本地:已终态直接返回(绝大多数轮询走这条路,零外部调用);pending 则借这次查询主动问一次微信,拿到新状态顺手更新本地,下次轮询就走快路径了。降级时(第 14 章的理论)核心的查询功能保住,附带的实时校验可以丢——返回本地 pending 状态,前端继续轮询即可。
面试官想听的关键词:快路径零外部调用、慢路径主动查单、降级丢非核心、轮询配合。
常见失分点:把快慢路径和「缓存」混为一谈(这里是状态确定与否的分支,不是缓存命中与否);漏说慢路径查到结果后要更新本地。
一句话结论:「更新余额 + 记活动流水」两步必须同事务原子提交,否则会出现「钱加了流水没记」的账实不符;金融级规模下多账号跨库本地事务失效,务实答案是买 Oracle + 大型机继续用本地事务。
展开:记账的可审计性要求每一分钱变动都有流水可查——只更新余额,出问题无法追溯;只写流水,查余额要全量回放。同事务保证两者一致。到了支付宝/微信级别,一笔账涉及的账号可能落在不同库,分布式事务性能又不可接受——外企和银行用钞能力(Oracle + 大型机)把单机事务能力堆到极限;普通业务规模,MySQL 本地事务 + 唯一索引幂等就足够。另外流水表的 biz + bizId + uid 唯一索引同时是记账幂等的根基。
面试官想听的关键词:账实一致、同事务、balance_after、跨库、钞能力。
常见失分点:只答「保证一致性」这种空话,说不出「哪个环节会不一致」;不知道唯一索引冲突本身就是幂等手段。
一句话结论:不需要——用户会明确选择支付方式,全链路都知道渠道是谁;判断原则是「调用方需要知道渠道细节才抽象,不需要就不抽象」。
展开:和短信服务商对比就清楚了:用户不关心验证码走阿里云还是腾讯云,调用方必须屏蔽渠道,抽象是刚需;支付时用户明确点了「微信支付」按钮,从前端到后端处处都知道是微信,藏起来只是徒增一层转换。反例教训:为了「架构优雅」提前定义 PaymentChannel + 工厂,三个月后只有微信一个实现,抽象全是死代码。注意唯一例外是「查询支付结果」这类调用方不关心渠道的接口,那里抽象是合理的。
面试官想听的关键词:过度抽象、调用方视角、抽象成本、例外场景(查询接口)。
常见失分点:背「要面向接口编程」的教条直接答「要抽象」;说不出短信和支付在「用户是否感知渠道」上的本质区别。
一句话结论:Kafka 按消息 key 哈希分区,相同 key(如同一笔订单)进同一分区天然有序;扩分区会改变哈希映射,新旧消息可能落到不同分区导致乱序。
展开:producer 初始化时指定哈希负载均衡,key 用支付单号——「支付→退款」这类有顺序要求的事件就串行在同一个分区。扩分区的本质是 hash(key) % 分区数,分区数一变映射全变。对策:提前规划好分区数不再扩;自定义分区器把 key 固定映射;业务侧带版本号/序列号,消费时检测乱序。连带的取舍:保证有序意味着同 key 只能单分区串行消费,并发度受限,积压时要么优化消费逻辑、按 key 拆 topic,要么放宽为业务容忍乱序 + 补偿修复。
面试官想听的关键词:哈希分区、key、扩分区、序列号检测乱序、有序与吞吐的取舍。
常见失分点:只知道「Kafka 分区内有序」,说不出为什么 key 相同就同分区;没想过扩分区这个经典陷阱。
一句话结论:逻辑上等价,但「先查后写」在并发下有竞态窗口,条件更新把判断和写入合并成一条原子 SQL,才真正可靠。
展开:两个重复回调同时到达:A 查到 status=init,B 也查到 status=init,两个都判断「没处理过」,然后各自更新、各自触发分账——重复执行。条件更新 UPDATE ... WHERE status = init 由数据库行锁保证原子性:第一个事务提交前第二个 UPDATE 被锁住,提交后 WHERE 条件不再命中、影响 0 行。所以「先查终态再跳过」和「条件更新影响 0 行」是同一件事的两种写法,但后者把并发安全交给了数据库,代码更短也更稳。
面试官想听的关键词:竞态条件、check-then-act、行锁、影响行数、原子性。
常见失分点:觉得「先查再跳过」逻辑上没错(单线程下确实没错),意识不到并发窗口;不知道 UPDATE 的 WHERE 条件 + 行锁就是天然的分布式锁。
一句话结论:用本地消息表——更新订单状态的同一个事务里写一条「待发消息」,后台轮询补发,保证消息必达(最多迟到,绝不丢失)。
展开:这是典型的部分失败:本地已 success、消息没出去,Reward 永远不知道。兜底有三层:一是重试发送;二是监控告警(发送失败率突增就报警);三是最终兜底本地消息表——事务保证「状态更新」和「待发消息记录」原子生效,后台 goroutine 扫表补发,发成功才标记。此外对账任务也能补:发现微信已支付但 Reward 未更新,可以重新触发通知。消费端配合 event_id(确定性拼接,如 paymentNo:txnID)去重,补发导致的重复投递也不会重复处理。
面试官想听的关键词:部分失败、本地消息表、事务原子性、轮询补发、确定性 event_id。
常见失分点:只答「加重试」——重试解决不了「进程刚好挂了」的场景;event_id 用随机 UUID,补发时 ID 变了去重失效。
一句话结论:掉单是用户付了钱但我们没收到(或没处理成功)回调、订单停在 pending 的状态;发现靠定时对账扫超时单,补救靠主动查微信 + 补单(推进状态 + 补发事件)。
展开:成因有两类:微信重试次数达到上限后放弃;或回调一直处理失败(代码 bug、DB 抖动)。发现机制:cron 定时扫「超过二维码有效期(2 小时 + 缓冲)仍非终态」的订单,逐单主动查微信;进阶做法是回调处理失败的瞬间立即触发单条对账,不用等定时任务。补救分两种结果:微信说已支付 → 走和回调完全相同的 HandleCallback 补单(推进状态 + 补发 Kafka 事件);微信说没付 → 关单。再往上一层是打赏-支付-记账三方对账,不一致时以微信数据为准修复。
面试官想听的关键词:掉单、重试上限、扫超时单、补单、关单、以微信为准。
常见失分点:只依赖微信重试不知道有次数上限;补单逻辑另写一套而不是复用 HandleCallback,两处逻辑漂移。
一句话结论:直接重试——Account 侧的 biz + bizId + uid 唯一索引保证重复调用不会重复记账,幂等接口是重试安全的前提。
展开:超时是「结果未知」而非「失败」,不能放弃(可能已记账,放弃就漏账)也不能盲目换姿势重发。因为 Split 的写入落在唯一索引保护的活动表上,第二次调用撞索引失败回滚,余额和流水都不会重复——这正是第 14 章说的「只有幂等接口才可以安全重试」在支付场景的落地。更讲究的做法是 Reward 侧也做状态判断:打赏记录已是「已分账」就直接跳过,把无效 RPC 挡在门口;最后再加三方对账兜底(以微信数据为准)。如果真要彻底消除「未决」状态,才需要引入 Saga / 事务消息这类方案——但当前规模用唯一索引 + 重试 + 对账三板斧足够。
面试官想听的关键词:结果未知、幂等重试、唯一索引兜底、状态前置判断、对账修复。
常见失分点:答「超时就报错让用户重试」——用户重试会创建新订单,和已记账的旧单对不上;不知道幂等才是敢重试的底气。
一句话结论:一人一车、field=skuId、value=数量+选中态——HINCRBY 改数量原子自增、HDEL 删、HGETALL 取全车,操作模型天然贴合;购物车以缓存为主存储,DB 只做兜底备份异步合并写,丢一条加购用户可接受。
展开:一致性等级要和业务对齐——购物车不是资金账,“短暂不一致"没人在意,没必要全同步写库;但结算时的库存校验必须前置到 Redis 用 Lua 原子预扣,别让请求打到 DB 才发现没货。另外服务端要做加购上限校验,防止恶意脚本把一个人的购物车灌成大 key。
面试官想听的关键词:hash 结构、HINCRBY、缓存为主存储、异步合并写、一致性等级、服务端上限。
常见失分点:把购物车当强一致场景设计、全同步落库(慢且没必要);或者忘了服务端上限校验这个安全细节。
一句话结论:库存预热进 Redis,扣减用 Lua 把"查库存、判断、扣减"包成原子操作——并发下"先查后扣"必超卖;库存极大再做分段存储(stock:{cid}:slice1..N)把单 key 写压力摊到 N 个 key,扣完发 MQ 异步落库、DB 只做最终记账。
展开:Lua 的原子性是根本——Redis 单线程跑完整个脚本,中间插不进别的命令(07 章"检查 + 自增必须一个脚本"是同一原理)。分段引入新边界:可能出现"某段扣完了但别的段还有货",用"随机选段 + 段间兜底挪移"缓解。这套"Redis 预扣 + MQ 异步落库 + DB 兜底"和第 15 章正文支付链路的"先记账后异步"是同一套组合拳。
面试官想听的关键词:Lua 原子扣减、防超卖、分段库存、段间不均、MQ 异步落库。
常见失分点:只会数据库乐观锁(扛不住瞬时洪峰);或不知道分段后"段间不均"是需要额外策略的新问题。
一句话结论:美团 Leaf 发号器两种模式——号段模式(DB 批量取一段 id 缓存在内存里慢慢发,压力小、重启跳号)和 snowflake 模式(时间戳+机器位+序列,Go 生态用 sonyflake,06 章 3.15 同款);过期券用 robfig/cron 定时批量扫描改状态、looplab/fsm 状态机管理流转、领取防重用 SET NX + 唯一索引双保险。
展开:券码和自增 id 的区别在于不可猜测——id 自增会被遍历刷券,券码要加混淆或校验位。状态流转(待领取→已领取→已使用/已过期)用状态机而不是散落的 if-else,非法流转直接被结构挡住;定时任务用的是 09 章 cron 那套,加分布式锁防多实例重复扫。
面试官想听的关键词:Leaf 号段、snowflake/sonyflake、不可猜测、状态机、SET NX 幂等、cron+分布式锁。
常见失分点:以为券码就是自增 id(可被遍历);或不知道过期处理是"定时任务批量改状态",以为要靠请求时惰性判断。
一句话结论:三板斧——多级缓存(本地 + Redis,读多改少天然适合)、热点探测(爆款商品动态推进本地缓存,09 章 3.12 五步流程)、异步拼装(类目 / 属性 / 图片 / 评价互不依赖,errgroup 并发拉取再聚合)。
展开:多级缓存解决"读得快",热点探测解决"突然爆火的单品打穿 Redis 分片",异步拼装解决"一次详情页请求要拉十几个模块数据"的 RT 问题——扇出从串行求和压成最长一路。再往上加码就是静态化推 CDN(榜单的终极方案同款)。
面试官想听的关键词:多级缓存、热点探测、errgroup 并发拼装、扇出、静态化 CDN。
常见失分点:只答"加缓存",说不出拼装 RT 优化;或不知道爆火单品的"动态热点"要靠探测系统而不是配置写死。
八、本章小结
- 支付最终在第三方完成:Prepay 在微信建单、code_url 给前端渲染、结果靠回调——我们只是发起方和记账方
- 幂等是命脉:唯一索引挡重复创建、状态机条件更新挡重复更新、event_id 去重挡重复消费——三处一个都不能少
- 状态机单向流转:init → success / failed(工程版 1~6),终态不再发起任何外部调用
- 回调铁律:用官方 SDK 验签解密;成功回 200、失败回 5xx 让微信重试
- 对账是最后防线:定时扫超时单主动查微信(2 小时 1 分钟阈值),已支付补单、没人付关单;回调失败立即单条对账
- 不需要统一渠道抽象:用户选了支付方式全链路都知道渠道;判断原则是「调用方是否需要知道渠道细节」
- 金额用 int64 存分,全链路统一单位,杜绝浮点和换算 bug
- Kafka 解耦:Payment 发 payment-events,业务方按 order_no 前缀认领;哈希分区保有序,扩分区会破坏有序
- 消息必达:本地消息表(事务内写待发消息 + 轮询补发);event_id 用确定性拼接
- 分账记账:打赏算比例、Account 记账,余额 + 流水同事务,
biz+bizId+uid唯一索引保记账幂等 - 三方对账:打赏-支付-记账比对,不一致以微信数据为准修复
- 面试亮点:部分失败的识别与补偿、掉单与补单、幂等高并发方案、消息不丢失不重复
下一章(第 16 章)我们进入评论与用户关系服务——高阶业务阶段的第一站。树形评论怎么存(两级结构 +根评论聚合)、百万粉丝的关注关系怎么设计表、缓存命中率怎么从 40% 优化到 90% 以上。你会发现本章的很多武器在那里继续服役:状态机(评论的审核状态)、唯一索引(关注关系的去重)、缓存(热帖评论的前置防线)。