学习目标
学完本章,你应该能够:
- 讲清支付系统的全景:读者 → webook → 第三方支付 → 创作者,说清"钱最终在第三方完成"这一关键认知。
- 接入微信支付 native 扫码:Prepay 下单、回调处理、二维码生成,并说清
BizTradeNO去重与状态机的作用。 - 讲清支付系统的四大命门:幂等性、对账、状态机、回调验签,能手写对账兜底逻辑。
- 设计 Payment / Reward / Account 的拆分,说清"为什么不在早期抽象统一支付渠道接口"。
- 实现打赏功能(缓存二维码、监听 Kafka、快慢路径查询)与分账记账(同事务、幂等、消息可靠性)。
前置知识(如果下面任意一点生疏,先回看对应章):
- 第02章 Gin + GORM:知道一个接口怎么写、事务怎么用。
- 第07章 Kafka:知道消息是怎么从生产者到消费者的,以及分区有序性。
- 第14章 服务治理:知道降级、重试放大的概念(打赏查询的快慢路径会用到)。
本章你会动手做的事:
- 画一张"支付状态机"图,标出 init → success / failed 的转移条件。
- 给 Prepay 接口模拟一次超时重试,验证用
BizTradeNO唯一索引能返回同一结果(幂等)。 - 写一段定时对账的 cron 任务,把 31 分钟前的超时订单主动去微信查一次状态。
一、支付系统全景
1.1 打赏功能涉及的关键方
在内容平台(如 webook)上,打赏功能涉及:
- 读者:发起支付
- webook:作为枢纽,记录打赏、调用第三方支付
- 第三方支付(如微信支付):真正完成支付
- 创作者:最终收到打赏金额
flowchart LR
R[读者] -->|发起打赏| W[webook 枢纽]
W -->|调用 API| P[第三方支付 微信]
P -->|创作者收到钱| A[创作者]1.2 支付的核心流程
关键认知:支付最终在第三方支付平台上完成,类似微信扫码登录——用户绕开 webook 直接和第三方支付打交道,第三方通过回调通知我们。
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 跳转第三方支付(或展示二维码)
↓
读者扫码支付
↓
第三方支付发送回调给 webook
↓
webook 记录结果、更新系统状态
1.3 模块划分:支付和打赏分离
不能把打赏做成一个大模块,要拆分:
- 支付模块(Payment):负责和不同第三方支付打交道,也叫支付网关
- 打赏模块(Reward):利用支付模块实现具体业务
- 账号模块(Account)(大型应用才有):记录账号金额变动、用户支付/收款信息
- 反洗钱、税务等安全审计模块(更大型应用)
本章把核心的 Payment 和 Reward 做成独立微服务,并引入 Account 服务做分账。
flowchart TD
R[Reward 打赏业务] --> P[Payment 支付网关]
P -->|调用| WX[微信支付]
P -->|回调/消息| R
R -->|分账| A[Account 记账服务]二、要不要统一渠道抽象?
2.1 问题背景
国内第三方支付主要是微信支付和支付宝,还有华为支付、银联、银行接口等。是否要定义一个统一接口(PaymentService),微信/支付宝各自实现?
2.2 答案:不需要
支付渠道和短信服务商不同:所有支付场景下,用户都要明确选择支付方式。前端选择支付方式后,后续所有环节都知道用户选了什么——所以不抽象统一接口也没问题。
工程哲学:不要为不存在的未来过度抽象。等到真的接入第二种支付方式再考虑抽象。
⚠️ 新手必踩的坑:为了"显得架构优雅"提前抽象。很多同学一上来就定义
PaymentChannel接口 + 工厂,结果三个月后只接了微信支付,那层抽象全是死代码,还增加了理解成本。抽象应该发生在"真的出现第二种实现"的拐点,而不是之前。
2.3 何时该抽象
判断原则:清楚具体支付方式的部分不抽象,不清楚的部分才抽象。
| 场景 | 是否抽象 | 原因 |
|---|---|---|
| 用户选定支付方式的整条路径 | 不抽象 | 已经清楚是微信/支付宝 |
| 查询支付结果 | 抽象 | 调用方不关心具体是哪种支付 |
三、接入微信支付
3.1 准备工作
以企业名义:
- 创建小程序(或公众号)
- 创建商户号并开通微信支付
- 将商户号和小程序关联起来
3.2 微信支付 native 扫码支付
Native 支付就是扫码支付,实现简单、原理清晰。微信提供的标准流程中,我们调用微信的环节有:
- 步骤 2:调用下单接口
https://api.mch.weixin.qq.com/v3/pay/transactions/native创建订单 - 步骤 10:微信发回调,我们处理回调
- 步骤 11(可选):主动调用查询接口判定是否支付成功
3.3 Prepay 接口设计
微信 API 需要的关键参数:
| 参数 | 来源 | 谁传入 |
|---|---|---|
mchid | 商户号 ID | Payment 自己填 |
appid | 小程序/公众号 ID | Payment 自己填 |
notify_url | 回调地址 | Payment 自己填 |
description | 商品描述 | 业务方传入 |
out_trade_no | 业务方订单号 | 业务方传入 |
amount | 金额 | 业务方传入 |
设计原则:调用方不该知道微信细节。appid / mchid / notify_url 都是 Payment 内部的事。
3.4 Payment 接口定义
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)
}
3.5 NativePaymentService 实现
package payment
import (
"context"
"github.com/wechatpay-apiv3/wechatpay-go"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
)
// NativePaymentService 微信 native 支付实现
type NativePaymentService struct {
client *native.NativeApiService
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
}
3.6 支付记录表:极简设计
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`) -- 关键:唯一索引保证去重
);
设计原则:不到逼不得已不要多存数据。如果数据是别的业务方给的、对方也存了,自己别存——存了大概率用不上。
3.7 接收支付通知(回调)
问题:notify_url 通常是线上地址,开发/测试环境怎么接收?
思路:用 nginx 转发:
test.yourcompany.com→ 测试环境dev.yourcompany.com→ 转发到本地电脑(不然本地调试非常麻烦)live.yourcompany.com→ 生产环境
处理回调代码——千万不要自己手动验签解密,用微信官方 SDK:
package payment
import (
"net/http"
"github.com/wechatpay-apiv3/wechatpay-go/pkg/notify"
)
// WechatHandler 微信回调处理器
type WechatHandler struct {
handler *notify.Handler
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)
}
⚠️ 新手必踩的坑:回调不返回 200。微信回调后如果你没返回 HTTP 200,微信会认为你没收到,按退避策略反复重试,可能短时间内打爆你的接口,还可能造成状态重复处理。务必保证"处理成功就 return 200"。同时回调处理本身要做幂等(见 3.9、8.1)。
3.8 金额怎么存?int64 / decimal / string
| 类型 | 适用场景 | 优缺点 |
|---|---|---|
int64 | 记录货币最小面额(如人民币记录到分) | 性能好,运算快;银行级别要记 4 位小数也能用 |
decimal | 要做复杂数学运算 | 精度准确,但性能差 |
string | 仅传输/存储,不参与运算 | 不会出错,但运算时要转换 |
公司没决定权就听老板的,有决定权随便选——效果都差不多。本章示例用 int64(分)。
3.9 处理回调要不要引入消息队列?
可以引入 Kafka,但不要提早引入。引入 MQ 的好处:
- 别的部门也关心回调,可以订阅消息
- 重试机制在自己控制下
- DEBUG 简单,可绕开 payment-web 在 Kafka 重发消息模拟测试
- 削峰(聊胜于无,正向支付撑得住,回调大概率也撑得住)
四、微信支付对账与异常处理
4.1 与微信交互的两种异常
| 异常场景 | 问题 |
|---|---|
| Prepay 超时 | 不知是成功还是失败,可能已创建订单 |
| 处理回调失败 | 不知用户是否支付了 |
4.2 Prepay 超时:重试 + 幂等返回
Prepay 超时无法确定结果,解决思路是重试。两种情况:
- 第一次没创建成功 → 重试时创建成功,拿到 code_url
- 第一次已创建成功 → 重试时微信返回
OutTradeNO 重复错误,但依旧会返回 code_url
幂等接口的关键:重复调用应该返回上次的结果。微信的 Prepay 满足这个特性。
4.3 处理回调失败:微信重试 + 兜底对账
微信会自动重试回调(处理失败时返回非 200,微信会再调)。但仅依赖微信重试不靠谱,必须有兜底对账机制:用 BizTradeNO 主动查微信订单状态,更新本地。
// 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)
}
4.4 对账定时任务
用 cronjob 触发对账:
// ScheduleReconcile 定时对账任务
// 借助分布式锁可以让任务全局唯一,但没特别大必要
func (s *NativePaymentService) ScheduleReconcile() {
// 步骤 1:找 31 分钟前的订单(Prepay 超时 30 分钟,多留 1 分钟缓冲)
orders, err := s.db.FindTimeoutOrders(context.Background(), 31*time.Minute)
if err != nil {
return
}
// 步骤 2:逐单主动去微信查状态并更新本地
for _, o := range orders {
_ = s.SyncOrderStatus(context.Background(), o.BizTradeNO)
}
}
4.5 进阶:回调失败立刻触发对账
不必等定时任务,处理回调失败时立刻触发单条对账:直接调用对账接口,或发消息到 Kafka 异步对账。这样能更及时地发现支付结果。
flowchart TD
CB[微信回调到达] --> OK{处理成功?}
OK -- 是 --> R[更新本地状态 + 返回200]
OK -- 否 --> S[立即单条对账 查微信状态]
S --> U[更新本地状态]五、打赏功能实现
5.1 打赏支付流程
打赏要做的事很简单:
- 调用 Payment 服务获得支付二维码
- 前端展示二维码
- 用户扫码支付
// ArticleHandler 打赏接口(放在 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,
})
}
5.2 打赏服务 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 调用不同的支付服务。
5.3 打赏服务实现:四步骤
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, 30*time.Minute)
_ = s.cache.SetRid(ctx, req.Uid, req.Biz, req.BizId, rid, 30*time.Minute)
return &RewardResp{CodeUrl: prepay.CodeURL, Rid: rid}, nil
}
5.4 为什么要缓存二维码
场景:用户点击打赏 → 拿到二维码 → 放弃支付 → 一会又点打赏。此时直接返回缓存的二维码,减少对支付服务的调用。缓存只是一种优化,不缓存每次都调支付也行。
5.5 告知用户支付结果:轮询
用户支付时只和微信打交道,微信异步回调给我们。用户查询时可能:
- 我们没收到回调
- 收到了但处理失败
- 收到了且处理成功
关键:让前端轮询——查询失败就立刻开始下一次查询,轮询一定时间/次数还没结果就返回错误。
// 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
}
按降级理论,触发降级时可以跳过慢路径查询,只看本地状态。
flowchart TD
Q[前端轮询查询] --> L[查本地打赏记录]
L -->|状态已确定| R[返回结果]
L -->|仍是 pending 慢路径| P[主动查微信支付状态]
P --> U[更新本地并返回]六、通过 Kafka 监听支付结果
6.1 为什么需要 Kafka
Payment 处理完微信回调后,业务方(如 Reward)需要知道支付结果。引入 Kafka 后,不同业务方都可以订阅支付结果消息。
6.2 Payment 生产消息
Payment 处理完回调后生产消息到 Kafka:
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
}
flowchart LR
CB[微信回调] --> H[HandleCallback 更新本地]
H --> K[Kafka 支付结果消息]
K --> R[Reward 消费者]
K --> O[其他业务方]6.3 消息有序性
如果消息包含"支付→退款"这种有顺序要求的事件,生产时必须控制有序性。Kafka 在初始化 producer 时指定用哈希负载均衡算法,相同 key 的消息会进同一个分区,保证顺序。
6.4 Reward 监听消息
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)
}
幂等性的好处:只要消息顺序没问题,重复消费也安全。
七、分账与账号服务
7.1 谁来分账
每笔打赏要分成两笔(作者应得 + 平台抽成),谁来分?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 打赏分 | 业务才知道分成比例 | 打赏耦合了记账逻辑 |
| 支付分 | 分账是支付流程的一部分 | 支付要知道业务分成比例 |
| 账号分 | 分成是记账业务的一部分 | 账号要知道业务分成比例 |
| 打赏计算+支付调记账 | 解耦:打赏算比例,支付记账 | 调用链变长 |
老师倾向:打赏来分。让具体业务处理记账之类的事情。
7.2 账号服务 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; // 账号类型:收入/支出
}
7.3 账号服务表设计
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`)
);
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`) -- 关键:幂等保证
);
设计要点:
- 币种问题:如果公司有出海计划,提前考虑字段但不必提前写多币种代码
- 审计/会计/对账:字段多时可以拆表(AccountAudit、AccountTax)
- 不着急拆表:加一起才二三十字段就不拆
7.4 事务操作:余额 + 活动记录原子性
更新余额和记录活动必须在同一个事务内:
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[回滚]金融行业特殊:支付宝/微信级别的账号服务,本地事务可能撑不住(数据库扛不住,分库分表也不行——一笔账涉及的多个账号可能在不同库上)。有钞能力可以买 Oracle + 大型机继续用本地事务(外企和银行就是这么干的)。
7.5 Reward 调用分账接口
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
}
八、工程实践要点
8.1 幂等性是支付系统的命脉
每个关键操作都要幂等:
| 操作 | 幂等手段 |
|---|---|
| Prepay 下单 | BizTradeNO 唯一索引 + 微信侧幂等 |
| 处理回调 | 唯一索引 + 状态机判断(已处理则跳过) |
| 消息消费 | 数据库唯一索引 / Redis 去重 / 布隆过滤器 |
| 分账记账 | biz + bizId + uid 唯一索引 |
⚠️ 新手必踩的坑:状态机不做"已处理就跳过"。微信回调可能重复发,如果你在
HandleCallback里无条件更新余额,重复回调就会造成"用户付了一次,创作者收到两次钱"。正确做法:先查当前状态,已是 success 就直接返回,不让金额变动重复执行。
8.2 数据一致性:识别"部分失败"
凡是涉及调用不同组件、不同服务的地方,都有可能不一致。部分失败是面试重点。要梳理每个步骤失败的后果,引入数据比对和修复机制。
8.3 Payment 自己起 Web 服务 vs 借用 BFF
| 方案 | 优点 | 适用场景 |
|---|---|---|
| BFF 处理回调 | 接入快,无需额外服务 | 小公司初期 |
| Payment 自己起 Web | 可针对回调做特色治理 | 推荐,生产主流 |
8.4 何时引入消息队列
不要提早引入 Kafka。等真的有这些需求时再上:
- 多部门订阅支付结果
- 自己控制重试机制
- DEBUG 时需要绕开 payment-web
- 秒杀等削峰场景
8.5 数据存储:不要多存
早期经验:如果数据是别的业务方给的,对方也存了,自己就不要存了。存了大概率用不上。
九、作业思路要点
9.1 本地消息表保证消息必发
在 Payment 处理回调的事务里同时写一条"待发消息"到本地消息表,事务保证原子性。后台 goroutine 轮询消息表,发送到 Kafka 后标记已发送。这就是"本地消息表"模式。
9.2 记账幂等方案
| 方案 | 优点 | 缺点 |
|---|---|---|
| 唯一索引 | 简单可靠 | 高并发下数据库压力大 |
| Redis 去重 | 性能高 | Redis 挂了可能重复 |
| 布隆过滤器 | 内存占用低 | 有误判率,删除困难 |
推荐组合:布隆过滤器前置过滤 + 唯一索引兜底。
9.3 三者对账
打赏-支付-记账三服务对账:
- 每个服务记录自己的关键事件(创建、状态变更)
- 定时任务对比三者数据
- 发现不一致时以微信支付数据为准,触发数据修复
十、自测题与动手练习
自测题(合上书能答出来,才算懂):
- 为什么说"支付最终在第三方完成"?webook 是怎么知道用户支付结果的?
- 支付状态机有哪几个状态?Prepay 超时后重试为什么能"幂等返回同一结果"?
- 微信回调处理为什么必须用官方 SDK 验签解密?不返回 200 会怎样?
- 打赏查询的"快慢路径"分别是什么?降级时该怎么做?
- 分账记账为什么必须放在同一个事务里?金融行业本地事务撑不住时怎么办?
动手练习(建议真做一遍):
- 画状态机 + 幂等验证:画出支付状态机(init/success/failed),并写一段测试——同一
BizTradeNO调两次 Prepay,确认第二次返回的是同一条记录的 code_url。 - 写对账定时任务:实现
ScheduleReconcile,把 31 分钟前的 pending 订单逐单去微信查状态,打印"本地 vs 微信"不一致的那些订单。 - Kafka 消费幂等:实现
RewardConsumer.Consume,故意连发两条相同bizTradeNO的消息,验证重复消费后打赏状态只更新一次(不重复分账)。
十一、本章小结
- 支付系统的核心是钱:幂等、对账、状态机、回调验签、安全一个都不能少
- 支付最终在第三方完成,我们通过 Prepay 创建订单、通过回调知道结果
- 模块拆分:Payment(支付网关)、Reward(业务)、Account(记账)独立微服务
- 不需要统一渠道抽象:用户会明确选择支付方式,提前抽象是过度设计
- 幂等性贯穿全流程:
BizTradeNO唯一索引、消息消费幂等、记账唯一索引 - 对账兜底:定时任务找超时订单主动查微信,回调失败可立即触发单条对账
- 金额用 int64(分),简单可靠
- 消息可靠性:本地消息表保证消息必发,Kafka 哈希分区保证有序
- 分账:打赏算分成、调用账号服务记账,余额更新和活动记录必须同事务
- 金融行业的特殊性:账级别太大时本地事务扛不住,钞能力(Oracle+大型机)能解决
- 面试亮点:部分失败识别与补偿、消息不丢失、消息有序性、幂等高并发方案
下一章(第16章)我们进入评论与用户关系服务,看树形评论怎么存、海量关注关系怎么抗高并发。