支付服务设计:从微信支付到打赏功能与记账

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

@

学习目标

学完本章,你应该能够:

  1. 讲清支付系统的全景:读者 → webook → 第三方支付 → 创作者,说清"钱最终在第三方完成"这一关键认知。
  2. 接入微信支付 native 扫码:Prepay 下单、回调处理、二维码生成,并说清 BizTradeNO 去重与状态机的作用。
  3. 讲清支付系统的四大命门:幂等性、对账、状态机、回调验签,能手写对账兜底逻辑。
  4. 设计 Payment / Reward / Account 的拆分,说清"为什么不在早期抽象统一支付渠道接口"。
  5. 实现打赏功能(缓存二维码、监听 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 准备工作

以企业名义:

  1. 创建小程序(或公众号)
  2. 创建商户号并开通微信支付
  3. 将商户号和小程序关联起来

3.2 微信支付 native 扫码支付

Native 支付就是扫码支付,实现简单、原理清晰。微信提供的标准流程中,我们调用微信的环节有:

  • 步骤 2:调用下单接口 https://api.mch.weixin.qq.com/v3/pay/transactions/native 创建订单
  • 步骤 10:微信发回调,我们处理回调
  • 步骤 11(可选):主动调用查询接口判定是否支付成功

3.3 Prepay 接口设计

微信 API 需要的关键参数:

参数来源谁传入
mchid商户号 IDPayment 自己填
appid小程序/公众号 IDPayment 自己填
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 打赏支付流程

打赏要做的事很简单:

  1. 调用 Payment 服务获得支付二维码
  2. 前端展示二维码
  3. 用户扫码支付
// 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 三者对账

打赏-支付-记账三服务对账:

  • 每个服务记录自己的关键事件(创建、状态变更)
  • 定时任务对比三者数据
  • 发现不一致时以微信支付数据为准,触发数据修复

十、自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 为什么说"支付最终在第三方完成"?webook 是怎么知道用户支付结果的?
  2. 支付状态机有哪几个状态?Prepay 超时后重试为什么能"幂等返回同一结果"?
  3. 微信回调处理为什么必须用官方 SDK 验签解密?不返回 200 会怎样?
  4. 打赏查询的"快慢路径"分别是什么?降级时该怎么做?
  5. 分账记账为什么必须放在同一个事务里?金融行业本地事务撑不住时怎么办?

动手练习(建议真做一遍)

  1. 画状态机 + 幂等验证:画出支付状态机(init/success/failed),并写一段测试——同一 BizTradeNO 调两次 Prepay,确认第二次返回的是同一条记录的 code_url。
  2. 写对账定时任务:实现 ScheduleReconcile,把 31 分钟前的 pending 订单逐单去微信查状态,打印"本地 vs 微信"不一致的那些订单。
  3. Kafka 消费幂等:实现 RewardConsumer.Consume,故意连发两条相同 bizTradeNO 的消息,验证重复消费后打赏状态只更新一次(不重复分账)。

十一、本章小结

  • 支付系统的核心是钱:幂等、对账、状态机、回调验签、安全一个都不能少
  • 支付最终在第三方完成,我们通过 Prepay 创建订单、通过回调知道结果
  • 模块拆分:Payment(支付网关)、Reward(业务)、Account(记账)独立微服务
  • 不需要统一渠道抽象:用户会明确选择支付方式,提前抽象是过度设计
  • 幂等性贯穿全流程:BizTradeNO 唯一索引、消息消费幂等、记账唯一索引
  • 对账兜底:定时任务找超时订单主动查微信,回调失败可立即触发单条对账
  • 金额用 int64(分),简单可靠
  • 消息可靠性:本地消息表保证消息必发,Kafka 哈希分区保证有序
  • 分账:打赏算分成、调用账号服务记账,余额更新和活动记录必须同事务
  • 金融行业的特殊性:账级别太大时本地事务扛不住,钞能力(Oracle+大型机)能解决
  • 面试亮点:部分失败识别与补偿、消息不丢失、消息有序性、幂等高并发方案

下一章(第16章)我们进入评论与用户关系服务,看树形评论怎么存、海量关注关系怎么抗高并发。

About Me

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

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

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

目标

学AI,加油!加油!