第15章 · 支付服务设计(正式版)

2023-03-10T14:11:02+08:00 | 80分钟阅读 | 更新于 2023-03-10T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 15 章。上一章我们给系统装上了熔断、限流、降级的「防御装甲」,这一章把这些能力用在一个最敏感、最不容许出错的业务上——支付。内容社区的创作者需要被打赏,被打赏就要有钱进账,而钱相关的系统有两条不能踩的红线:不能重复扣款(幂等)、账目必须对得上(对账)。

你可能会想:「我又接不了真的微信支付商户号,学这个有什么用?」——本章为此专门设计了一个内存版 Mock 微信支付:不需要商户号、不联网,就能把「下单 → 支付 → 回调 → 对账 → 分账」的完整链路跑通。面试官不会问你「微信后台长什么样」,但一定会问「怎么保证一笔订单只生效一次」「回调丢了怎么办」——这些答案你都能在本章亲手跑出来。

这一版做了三件事:

  1. 每个概念先补课再使用。幂等、回调、验签、对账、分账这些支付黑话,全部就地补「补课胶囊」,不让你去搜。
  2. 用类比先让你看见。转账凭单号、快递签收、公司财务月结对账——钱的事生活里全有原型。
  3. 结尾有 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」的发放与入账要靠本地消息表保证不丢,而图底部「定时对账,追平差异」就是回调失灵时的兜底通道——主动去微信查单,把没送到家的支付结果找回来。记住两条主线:正向「下单 → 支付 → 回调 → 发奖 → 记账」,反向「对账修复」,正文每一节都是在给其中一环上保险。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 画出支付的完整时序图:读者 → webook → 微信支付 → 创作者,说清「钱最终在第三方完成、结果靠回调通知」这一关键认知。
  2. 讲清幂等的定义(同一操作执行一次和执行多次效果相同),并用「唯一索引 + 状态机条件更新」写出支付场景的两层幂等防护。
  3. 画出支付状态机(init → success / failed),解释为什么状态只能单向流转、为什么终态订单不能再发起任何外部调用。
  4. 写出「处理微信回调」的完整逻辑:验签(用官方 SDK)→ 状态转换 → 条件更新 → 返回 200,并解释每个环节省略会发生什么。
  5. 实现 Mock 微信支付 + 打赏的完整闭环:发起打赏拿二维码 → 模拟支付 → 消费 Kafka 事件更新状态,重复回调 / 重复消费都不产生重复记账。

理解(能讲清原理和取舍,不要求背代码)

  1. Prepay 超时后为什么可以放心重试(微信 Prepay 本身幂等 + BizTradeNO 去重),以及「幂等接口的关键是重复调用返回上次的结果」。
  2. 对账的两种形态:定时任务扫超时单主动查微信(兜底)、回调失败立刻触发单条对账(及时性)。
  3. 要不要统一支付渠道抽象的判断标准:用户会明确选择支付方式,所以不需要提前抽象——这是「不要为不存在的未来过度抽象」的经典案例。
  4. 分账方案四方对比(打赏分 / 支付分 / 账号分 / 打赏计算+支付调记账),以及「余额 + 活动记录同事务」的原因。
  5. 金额存储三种方案(int64 / decimal / string)的取舍,为什么本课程用 int64 存「分」。
  6. Kafka 保证消息有序的原理(相同 key 哈希到同一分区)以及扩分区会破坏有序性的原因。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. 微信支付 v3 的验签 + 解密细节(平台证书、APIv3 密钥、AES-256-GCM)——知道「必须用官方 SDK」即可。
  2. 本地消息表模式:事务里同时写「待发消息」,后台轮询补发,保证消息必发。
  3. 布隆过滤器 / Redis 去重作为消息消费幂等的前置过滤手段。
  4. 金融行业的特殊性:本地事务扛不住时的「钞能力」方案(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 把全链路跑起来、「第七节 面试问答」收束成答题素材。没有分支、没有跳跃,按顺序读即可——每一节都依赖前一节的产物。

支付服务设计支付全景四方角色·钱在第三方微信支付接入Prepay·回调·状态机四大命门幂等·对账·验签打赏功能缓存·Kafka·快慢路径分账记账Account·同事务·幂等

三、正文

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 是「装修风格」,一家一个样。混在一起的话,每加一种付费业务都要改支付代码,改错了所有业务一起完蛋。

小收口:这一节建立了「四方角色 + 钱在第三方 + 结果靠回调」的全景认知,以及 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 传回去。

复习提示:类比:我们像「代客点单的服务员」——把客人的需求(金额、商品)报给厨房(微信支付),拿到取餐号(code_url)给客人;客人怎么取餐、怎么付钱,是客人和厨房之间的事;饭做好了,厨房会喊我们一声(回调)。

Prepay 接口设计:调用方不该知道微信细节

先抛问题:调用微信下单接口要填一堆参数(appid、mchid、notify_url……),这些参数该由谁提供?

给直觉:回顾第 04 章短信服务的设计原则——SMSService 接口只暴露「手机号 + 验证码」,阿里云的 AccessKey 藏在实现里。支付同理,把参数分成两堆:

参数含义谁传入
mchid商户号 IDPayment 自己填
appid小程序/公众号 IDPayment 自己填
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 节细讲),本地和微信两侧的去重凭证是同一个值。

常见坑:

  1. 插入失败不区分场景:把「唯一索引冲突(重复请求)」和「数据库挂了(真故障)」当成同一种错误处理——前者该走幂等返回,后者该报错告警。新手最容易一把梭 return err,用户重复点一次打赏就报「系统异常」。
  2. 忘记 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 是整个幂等体系的根基:同一张业务单,物理上不可能插入两条支付记录。

复习提示:设计原则:不到逼不得已不要多存数据。如果数据是别的业务方给的、对方也存了,自己就别存——存了大概率用不上。比如业务方传来的商品描述,微信那边有、业务方自己也有,Payment 再存一份就是三份冗余。

接收支付通知:回调处理的完整链路

先抛问题:微信的回调要发到 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 是对微信的「回执」:告诉微信「收到了,别再发了」。
复习提示:验签到底在验什么? 微信回调的 HTTP 头里带着用微信平台私钥生成的签名,SDK 会用微信的平台证书去验证它——确认「这个回调真的是微信发的、内容没被篡改」;验签通过后再用商户的 APIv3 密钥对 resource 字段做 AES-256-GCM 解密,拿到订单明细。证书下载与轮换、时间戳校验、解密细节坑极多,所以铁律是:用官方 SDK,别手写。

常见坑:

  1. 回调处理成功却不返回 200。微信没收到 200 就认为你没收到,按退避策略反复重试——短时间内打爆你的接口,还可能造成状态被重复处理。务必保证「处理成功就 return 200」。
  2. 处理失败返回 200。反过来也不行——返回 200 等于告诉微信「处理好了」,微信不再重试,这条支付结果就真的丢了。失败要返回 5xx,让微信重试 + 等对账兜底。
  3. 验签失败返回 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(两个终态),每个状态只允许「向前」流转。三条使用纪律:

  1. 状态只从 init 往外走,绝不回退。success / failed 都是终态,到达终态的订单不再发起任何外部调用。
  2. 更新状态要用条件更新:UPDATE payment SET status = ? WHERE biz_trade_no = ? AND status = 0——只有还处于 init 的记录才会被改到。重复回调来时这条 UPDATE 影响 0 行,天然不会重复处理(和工程实践里「先查终态再跳过」是同一件事的两种写法)。
  3. 状态机 + 唯一索引是支付幂等的两大支柱:唯一索引挡住「重复创建」,状态机挡住「重复更新」。
复习提示:类比:订单状态像单行道——从「待支付」走到「成功」或「失败」就到头了,不存在「成功又变回待支付」。凡是允许状态来回跳的设计,重复回调一来就乱套:success 被改回 init,第二个回调又能把它推进到 success,分账逻辑跑两遍。

直接用 BizTradeNO,还是自己造一个 PaymentSN?

先抛问题:去重凭证直接用业务方传来的 BizTradeNO,还是 Payment 自己再生成一个「支付序列号」?

方案适用说明
直接用 BizTradeNO简单系统少一层映射,本课程就是这么做的
自己生成 PaymentSN大型系统一般用 UUID;想让序列号带信息,也可以在 SN 里编码商品 ID、商品类型等

切记:即便自己生成了 PaymentSN,去重仍然要靠业务方传来的 BizTradeNO——唯一索引还是建在它上面。否则同一个业务请求换个入口再发一次,还是可能创建出两笔支付。

复习提示:判断标准就一条:你的支付服务是「一个业务方专用」还是「多个业务方共用」。专用就懒得造 SN;共用、且要在支付侧串联多方数据时,再引入自己的 SN 做内部主键。

小收口:这一节走完了「下单(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
复习提示:幂等接口的关键:重复调用应该返回上次的结果。微信的 Prepay 满足这个特性——这就是「3.1 节幂等补课」里说的「重复按电梯按钮,电梯只来一趟」在真实支付系统的原型。重试是安全的,因为被重试的接口是幂等的。

处理回调失败:微信重试 + 兜底对账

先抛问题:回调处理失败(比如代码 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、怎么把结果告诉用户。

打赏要做的事

先抛问题:用户视角的打赏是什么样的?后端要配合做什么?

其实只有三步:

  1. 调用 Payment 服务获得支付二维码;
  2. 前端展示二维码;
  3. 用户扫码支付(然后等结果)。

「等结果」是第 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
}
复习提示:假设:当前只支持微信扫码支付。如果要支持多种支付方式,需要让用户在选择金额时同步选支付方式,Web 根据 payment_method 调用不同的支付服务——这又呼应了 3.2 节:用户选了支付方式,全链路都知道渠道,不需要提前抽象。

动手:打赏服务实现——四步骤

先抛问题:用户「点了打赏又反悔、过一会再点」是高频操作。每次都调 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[更新本地并返回]
复习提示:按第 14 章降级理论:触发降级时可以跳过慢路径,只看本地状态——核心功能(查询)保住,附带的实时校验(查微信)可以丢。这就是「快慢路径」和「服务治理」的接合点。

小收口:这一节 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 的消息会进同一个分区,同一分区的消息天然有序——好比同一个快递柜格口,先放进去的先取出来。

复习提示:面试高频追问(进阶):① topic 扩分区后怎么保证有序? 哈希分区的本质是 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 的投递语义是「至少一次」(at-least-once)——同一条消息可能被消费多次,靠消费端的幂等兜底。这和 3.1 节的幂等补课、3.3 节的状态机条件更新是同一个思想在不同环节的落地。

小收口:到这里,「读者点打赏 → 拿二维码 → 支付 → 回调 → Kafka → Reward 更新打赏记录」的完整链路已经打通。最后一环:创作者的钱怎么到账——分账与记账。


3.7 分账与记账:钱最终落在谁头上

先补课:分账是什么

补课胶囊 · 分账(split) 一句话:把一笔钱按规则拆成几份,分别记到不同账号上。 类比:合租打车回家——总车费 100,三个人按人头摊,每人转各自的份;平台也一样:用户打赏 1 元,作者分 8 毛、平台抽 2 毛,各记各的账。 最小示例:Split(biz="reward", bizId=123, total=100, items=[{uid:作者, amount:80}, {uid:平台, amount:20}])——一个接口,两笔入账。 回到主线:打赏金额进来了,谁来执行这个拆分?拆完记到哪?这就是账号服务(Account)的职责。

谁来分账:四方对比

先抛问题:拆账这个动作,可以放在打赏服务、支付服务、账号服务……到底放哪?

每笔打赏要分成两笔(作者应得 + 平台抽成),四个方案各有取舍:

方案优点缺点
打赏分业务才知道分成比例打赏耦合了记账逻辑
支付分分账是支付流程的一部分支付要知道业务分成比例
账号分分成是记账业务的一部分账号要知道业务分成比例
打赏计算 + 支付调记账解耦:打赏算比例,支付记账调用链变长
复习提示:老师倾向:打赏来分。让具体业务(打赏服务)处理记账之类的事情——因为只有它知道「作者 80%、平台 20%」这个业务规则。支付和账号服务保持纯粹:支付只管钱进出,账号只管记账。

动手:账号服务 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 判断(代码判断有并发漏洞:两个请求同时查「没分过账」,然后都去分)。
复习提示:金融行业特殊:支付宝/微信级别的账号服务,本地事务可能撑不住(数据库扛不住,分库分表也不行——一笔账涉及的多个账号可能在不同库上)。有钞能力可以买 Oracle + 大型机继续用本地事务(外企和银行就是这么干的)。

动手: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 才发现没货——防超卖的思想和下面优惠券完全一致。

复习提示:⚠️ 新手必踩的坑:购物车上限只在前端校验。加购数量、SKU 种数的服务端上限一定要有,否则恶意脚本可以把 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。

复习提示:⚠️ 新手必踩的坑:把 Mock 的「免验签」带进生产。Mock 回调没有签名,所以第 5 步的验签环节是空的;真实环境里验签是唯一能证明「回调真的是微信发的」的手段,跳过它等于任何人 POST 一个 JSON 就能把你的订单改成支付成功。真实实现必须走官方 SDK 的验签 + 解密(正文 3.3 节),并用配置开关(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、撞索引再查」这个顺序吸收了。

复习提示:⚠️ 新手必踩的坑:已支付订单还返回支付参数。有的同学图省事,重复请求一律返回原 codeURL——但旧单已是 success,用户再扫码会被微信拒绝,前端还莫名其妙。幂等返回的前提是「单子还活着」:待支付返回原参数、已支付明确报错、已关闭/失败引导换单号,三个分支一个都不能省。

验证: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 让它停止重试。

复习提示:⚠️ 新手必踩的坑:event_id 用随机 UUID。生产者发送失败重试时,同一个支付结果会被拼出两个不同的随机 UUID,消费端按 event_id 去重就会失效、重复记账。event_id 要用确定性拼接(如 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 := &registry.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)三个服务各自记了同一笔业务的关键事件(创建、状态变更):

  • 每个服务记录自己的关键事件;
  • 定时任务对比三者数据;
  • 发现不一致时以微信支付数据为准,触发数据修复。
复习提示:为什么以微信为准?因为钱真正发生的地方是微信——它是对错终局的裁判。Reward 说没收到钱、Payment 说支付成功、微信说支付成功:那一定是 Reward 侧消息丢了,修它。

六、自测题与动手练习

自测题(先别翻正文,合上书能答出来才算懂;答案要点都藏在正文里):

  1. 用自己的话讲一遍「钱在第三方完成」是什么意思。我们是通过哪三个环节和微信交互的?
  2. 支付状态机有哪几个状态?为什么状态只能单向流转?「条件更新」是怎么挡住重复回调的?
  3. Prepay 超时后为什么可以放心重试?微信的 Prepay 接口满足幂等的哪个关键特性?
  4. 回调处理不返回 HTTP 200 会发生什么?处理失败反而返回 200 又会发生什么?
  5. 幂等的两根支柱分别挡住了什么?为什么「先查状态再决定跳过」的代码写法在并发下不可靠?
  6. 打赏查询的快路径和慢路径分别做什么?触发降级时应该怎么调整?
  7. Kafka 怎么保证「支付→退款」这类消息的顺序?扩分区为什么会破坏有序性?
  8. 分账的「更新余额 + 记流水」为什么必须同一个事务?记账幂等靠哪个唯一索引?

动手练习(建议真做一遍,做到才算过关):

  1. 画状态机 + 幂等验证:画出支付状态机(init / success / failed,或工程版的 1~6),标出所有合法转移和终态。然后写一个测试——同一 BizTradeNO 调两次 Prepay,断言第二次返回的是同一条记录的 code_url。完成标准:状态机图里终态没有任何出边;测试断言通过。
  2. 写对账定时任务:实现 ScheduleReconcile,把 2 小时 1 分钟前的 pending 订单逐单去微信查状态(用 Mock 的 QueryOrderByOutTradeNo),打印「本地 vs 微信」不一致的订单并补单。完成标准:把 PayLatency 设为 0、超时参数调成 1 秒,日志能看到「补单成功」;保持默认参数能看到「超时关单」。
  3. Kafka 消费幂等 + 分账幂等:实现 RewardConsumer.Consume(含分账调用),故意连发两条相同 event_id 的消息。完成标准:打赏状态只更新一次;account_activity 表里 biz + biz_id + uid 各只有一条记录;再补上 Reward 侧的「已分账跳过」状态判断,让第二次调用根本不发起 Split。
  4. (选做,课程作业)本地消息表:给 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 行」这个机制;不知道到达终态的订单不应再发起任何外部调用。

面试官
【基础】 微信回调处理为什么必须用官方 SDK 验签解密?不返回 HTTP 200 会怎样?
候选人

一句话结论:验签是唯一能证明「回调真的是微信发的、内容没被篡改」的手段,验签 + AES-256-GCM 解密细节坑极多必须用官方 SDK;处理成功必须返回 200,否则微信会按退避策略反复重试。

展开:回调 HTTP 头里带微信平台私钥的签名,SDK 用微信平台证书验签、再用商户 APIv3 密钥解密 resource 字段——证书下载轮换、时间戳校验全是坑,手写必翻车。不返回 200,微信认为你没收到就反复重发,可能短时间打爆接口、造成状态重复处理;反过来,处理失败却返回 200,微信停止重试,这条支付结果就彻底丢了——失败要回 5xx 让微信重试,同时靠对账兜底。

面试官想听的关键词:平台证书、APIv3 密钥、AES-256-GCM、退避重试、200 / 5xx 语义。

常见失分点:只答「安全」两个字,说不出验签到底防的是什么(伪造回调 + 内容篡改);不知道「失败返回 200」是更隐蔽的坑。

面试官
【基础】 金额为什么用 int64 存「分」,不用 float?
候选人

一句话结论:浮点数有精度问题(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 相同就同分区;没想过扩分区这个经典陷阱。

面试官
【追问】 (接着状态机题追问)你说用条件更新挡重复回调——如果不用状态机,先查状态、是 success 就跳过,行不行?
候选人

一句话结论:逻辑上等价,但「先查后写」在并发下有竞态窗口,条件更新把判断和写入合并成一条原子 SQL,才真正可靠。

展开:两个重复回调同时到达:A 查到 status=init,B 也查到 status=init,两个都判断「没处理过」,然后各自更新、各自触发分账——重复执行。条件更新 UPDATE ... WHERE status = init 由数据库行锁保证原子性:第一个事务提交前第二个 UPDATE 被锁住,提交后 WHERE 条件不再命中、影响 0 行。所以「先查终态再跳过」和「条件更新影响 0 行」是同一件事的两种写法,但后者把并发安全交给了数据库,代码更短也更稳。

面试官想听的关键词:竞态条件、check-then-act、行锁、影响行数、原子性。

常见失分点:觉得「先查再跳过」逻辑上没错(单线程下确实没错),意识不到并发窗口;不知道 UPDATE 的 WHERE 条件 + 行锁就是天然的分布式锁。

面试官
【追问】 (接着回调题追问)回调处理成功了,但发 Kafka 消息失败,这笔支付的结果就丢了,怎么办?
候选人

一句话结论:用本地消息表——更新订单状态的同一个事务里写一条「待发消息」,后台轮询补发,保证消息必达(最多迟到,绝不丢失)。

展开:这是典型的部分失败:本地已 success、消息没出去,Reward 永远不知道。兜底有三层:一是重试发送;二是监控告警(发送失败率突增就报警);三是最终兜底本地消息表——事务保证「状态更新」和「待发消息记录」原子生效,后台 goroutine 扫表补发,发成功才标记。此外对账任务也能补:发现微信已支付但 Reward 未更新,可以重新触发通知。消费端配合 event_id(确定性拼接,如 paymentNo:txnID)去重,补发导致的重复投递也不会重复处理。

面试官想听的关键词:部分失败、本地消息表、事务原子性、轮询补发、确定性 event_id。

常见失分点:只答「加重试」——重试解决不了「进程刚好挂了」的场景;event_id 用随机 UUID,补发时 ID 变了去重失效。

面试官
【追问】 (接着幂等题追问)掉单是什么?怎么发现、怎么补救?
候选人

一句话结论:掉单是用户付了钱但我们没收到(或没处理成功)回调、订单停在 pending 的状态;发现靠定时对账扫超时单,补救靠主动查微信 + 补单(推进状态 + 补发事件)。

展开:成因有两类:微信重试次数达到上限后放弃;或回调一直处理失败(代码 bug、DB 抖动)。发现机制:cron 定时扫「超过二维码有效期(2 小时 + 缓冲)仍非终态」的订单,逐单主动查微信;进阶做法是回调处理失败的瞬间立即触发单条对账,不用等定时任务。补救分两种结果:微信说已支付 → 走和回调完全相同的 HandleCallback 补单(推进状态 + 补发 Kafka 事件);微信说没付 → 关单。再往上一层是打赏-支付-记账三方对账,不一致时以微信数据为准修复。

面试官想听的关键词:掉单、重试上限、扫超时单、补单、关单、以微信为准。

常见失分点:只依赖微信重试不知道有次数上限;补单逻辑另写一套而不是复用 HandleCallback,两处逻辑漂移。

面试官
【追问】 (接着事务题追问)Reward 调用分账接口如果超时了,你不知道 Account 到底记没记账,怎么办?
候选人

一句话结论:直接重试——Account 侧的 biz + bizId + uid 唯一索引保证重复调用不会重复记账,幂等接口是重试安全的前提。

展开:超时是「结果未知」而非「失败」,不能放弃(可能已记账,放弃就漏账)也不能盲目换姿势重发。因为 Split 的写入落在唯一索引保护的活动表上,第二次调用撞索引失败回滚,余额和流水都不会重复——这正是第 14 章说的「只有幂等接口才可以安全重试」在支付场景的落地。更讲究的做法是 Reward 侧也做状态判断:打赏记录已是「已分账」就直接跳过,把无效 RPC 挡在门口;最后再加三方对账兜底(以微信数据为准)。如果真要彻底消除「未决」状态,才需要引入 Saga / 事务消息这类方案——但当前规模用唯一索引 + 重试 + 对账三板斧足够。

面试官想听的关键词:结果未知、幂等重试、唯一索引兜底、状态前置判断、对账修复。

常见失分点:答「超时就报错让用户重试」——用户重试会创建新订单,和已记账的旧单对不上;不知道幂等才是敢重试的底气。

面试官
【基础】 购物车为什么用 Redis hash 存?数据一致性怎么办?
候选人

一句话结论:一人一车、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(可被遍历);或不知道过期处理是"定时任务批量改状态",以为要靠请求时惰性判断。

面试官
【追问】 商品详情页 10w QPS 怎么扛?
候选人

一句话结论:三板斧——多级缓存(本地 + 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% 以上。你会发现本章的很多武器在那里继续服役:状态机(评论的审核状态)、唯一索引(关注关系的去重)、缓存(热帖评论的前置防线)。

复习提示:伏笔:本章打赏成功后创作者「收到钱」,但钱只是记在账上——创作者怎么「提现」?提现又是一个支付流程(方向反过来)。带着这个视角去读第 16 章,你会发现每个业务功能背后都可能藏着一条资金链路。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!