简历 preview
简历上 :
实现了一套完整的打赏支付流程和简单对账
需求分析 :
- 为了激励创作者,在文章模块支持打赏功能
主要用例如下 :
-
读者 : 发起支付
-
webook : 记录打赏,调用第三方服务进行支付
-
创作者 : 提现 (这里不实现,因为微信提现涉及审计相关的内容)
-
第三方支付 : 实际完成支付的地方

设计过程
支付过程
实际支付流程如下 :
-
读者点击打赏
-
webook 创建一个支付订单
-
Webook 跳转第三方支付
-
读者扫码支付
-
第三方支付回调给 webook
-
webook 得到支付结果,记录结果并更新系统状态

说一下这里和微信登陆的不同
|
支付回调 |
登录重定向回调 |
| 是什么 |
服务端 → 服务端 的结果通知 |
浏览器 OAuth 回跳 |
| 谁在请求你 |
微信支付服务器 |
用户浏览器 |
| 你在干什么 |
更新支付状态、通知业务 |
换身份、建用户、发登录态 |
| 有没有用户页面 |
通常没有 |
有,整条链路围着浏览器转 |
| 失败了怎么办 |
微信重试;你们还有主动查单兜底 |
用户重新扫码登录 |
微信登陆我们校验之后 会自动重定向
而支付回调是一个异步的,仅仅发送通知
模块划分
打赏功能 我们不假思索的会认为是一个模块 。 但是实际上可以进行额外的拆分
-
支付本身 。 和第三方支付打交道
-
利用支付模块实现的打赏模块
因此实现一个打赏模块,实际上要处理两个微服务 支付 和 打赏

接口分析
可以看到必传的参数有
mchid, appid, sub_mchid, sp_appid, mcc 这里都是 微信那边给的对应 id
descripition, notify_url, out_trade_no, trade_type 分别是 商品描述,通知地址,商户系统内部id,交易类型
微信下单接口


返回结果如下 :
我们可以使用在线的二维码生成,展示这个二维码

接口设计思路
根据接口请求和返回, 我们采用最小化原则 可以定义出 下列接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
type PaymentService interface {
// Prepay 预支付,对应于微信创建订单的步骤
Prepay(ctx context.Context, pmt domain.Payment) (string, error)
}
type Payment struct {
Amt Amount
// 代表业务,业务方决定怎么生成,
// 我们这边不管。
BizTradeNO string
// 订单本身的描述
Description string
Status PaymentStatus
// 第三方那边返回的 ID
TxnID string
}
|
问题1 BizTradeNO
BizTradeNO 理论上是业务方传递过来的,但是存在一个问题
对于 在打赏的情况下,如果用户第一次点击打赏 的时候,没有支付。后续要再次打赏 。
业务方应该要 考虑:缓存前一次的二维码,或者直接生成一个新的 BizTradeNO
如果不缓存,每一次打赏并且取消支付再次打赏,会call多次 wechat 以及插入 init 数据到数据库中
表结构设计
仅考虑保留 currency ,amt , tradeNo ,status,txnId 后续如果有其他字段可以考虑使用 extra_blob 进行维护
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
type Payment struct {
Id int64 `gorm:"primaryKey,autoIncrement" bson:"id,omitempty"`
Amt int64
Currency string
// 可以抽象认为,这是一个简短的描述
// 也就是说即便是别的支付方式,这边也可以提供一个简单的描述
// 你可以认为这算是冗余的数据,因为从原则上来说,我们可以完全不保存的。
// 而是要求调用者直接 BizID 和 Biz 去找业务方要
// 管得越少,系统越稳
Description string `gorm:"description"`
// 后续可以考虑增加字段,来标记是用的是微信支付亦或是支付宝支付
// Type uint8 // 微信支付或者支付宝支付
// 也可以考虑提供一个巨大的 BLOB 字段,
// 来存储和支付有关的其它字段
// ExtraData
// 业务方传过来的
BizTradeNO string `gorm:"column:biz_trade_no;type:varchar(256);unique"`
// 第三方支付平台的事务 ID,唯一的
TxnID sql.NullString `gorm:"column:txn_id;type:varchar(128);unique"`
Status uint8
// Utime 上面要创建一个索引
Utime int64 `gorm:"index"`
Ctime int64
}
|
接收支付通知
对于微信那边会通知一个回调到 notifyURL 上 。 但是有一个问题
- 这个地址通常是 线上地址 。 我们开发环境和测试环境怎么接受这个 http 请求
解决办法 :
考虑转发请求
-
考虑配置多个回调域名,特定域名就打到特定的环境上
-
考虑在回调路径上做一些特殊的标记,比如 /test 就打到测试环境

处理支付通知
-
更新数据库状态 和 TxnId
-
同时传递消费数据到 kafka 中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
func (n *NativePaymentService) updateByTxn(ctx context.Context, txn *payments.Transaction) error {
status, ok := n.nativeCBTypeToStatus[*txn.TradeState]
if !ok {
// 这个地方,要告警
return fmt.Errorf("%w, %s", errUnknownTransactionState, *txn.TradeState)
}
// 核心就是更新数据库状态
err := n.repo.UpdatePayment(ctx, domain.Payment{
BizTradeNO: *txn.OutTradeNo,
Status: status,
TxnID: *txn.TransactionId,
})
if err != nil {
return err
}
// 发送消息,有结果了总要通知业务方
// 这里有很多问题,核心就是部分失败问题,其次还有重复发送问题
err1 := n.producer.ProducePaymentEvent(ctx, events.PaymentEvent{
BizTradeNO: *txn.OutTradeNo,
Status: status.AsUint8(),
})
if err1 != nil {
// 加监控加告警,立刻手动修复,或者自动补发
n.l.Error("发送支付事件失败",
logger.String("biz_trade_no", *txn.OutTradeNo),
logger.Error(err1.Error()))
}
return nil
}
|
对账处理
和微信交互的场景主要有两个
-
调用 prepay 接口, 创建预支付订单 。 比较容易出现的情况就是 超时,如果超时没办法确认成功还是失败
-
处理回调的时候失败了,你同样不知道用户是支付了还是没支付

我们能够想到的办法就是让客户端重试
处理回调失败的问题
微信在回调通知我们的时候 也可能会失败 。 当失败多次之后,微信就会放弃该条消息
所以我们需要一个兜底机制,来使用 tradeNo 定时的去请求微信,更新订单状态
定时的去获取 还在 init 定任务,并且已经过期的任务 。 然后查询 微信获取状态进行更新 。
这里的定时任务交由我们是之前实现的 分布式任务实现 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
func (s *SyncWechatOrderJob) Run() error {
offset := 0
// 也可以做成参数
const limit = 100
// 三十分钟之前的订单我们就认为已经过期了。
now := time.Now().Add(-time.Minute * 30)
for {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
pmts, err := s.svc.FindExpiredPayment(ctx, offset, limit, now)
cancel()
if err != nil {
// 直接中断,你也可以仔细区别不同错误
return err
}
// 因为微信没有批量接口,所以我们这里也只能单个查询
for _, pmt := range pmts {
// 单个重新设置超时
ctx, cancel = context.WithTimeout(context.Background(), time.Second)
err = s.svc.SyncWechatInfo(ctx, pmt.BizTradeNO)
if err != nil {
// 这里你也可以中断,不过我个人倾向于处理完毕
s.l.Error("同步微信支付信息失败",
logger.String("trade_no", pmt.BizTradeNO),
logger.Error(err.Error()))
}
cancel()
}
if len(pmts) < limit {
// 没数据了
return nil
}
offset = offset + len(pmts)
}
}
|
打赏设计过程
需求分析 & 功能设计
-
用户希望能够在某篇文章的最后,有一个打赏按钮按钮
用户点击这个按钮,就会弹出微信的支付二维码,即 prepay 的过程
-
可以查看支付的结果和状态 getStatus
表结构设计
- 文章模块 和 文章 id
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
type Reward struct {
Id int64 `gorm:"primaryKey,autoIncrement" bson:"id,omitempty"`
Biz string `gorm:"index:biz_biz_id"`
BizId int64 `gorm:"index:biz_biz_id"`
BizName string
// 被打赏的人
TargetUid int64 `gorm:"index"`
// 直接采用 RewardStatus 的取值
Status uint8
// 打赏的人
Uid int64
Amount int64
Ctime int64
Utime int64
}
|
状态更新
状态更新有两个流程
-
异步的消费 kafka 进行更新
-
获取detail的时候进行更新
- 这里在限流的时候,就不进行慢路径的查询。等待 kafaka 进行补充
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
func (s *WechatNativeRewardService) GetReward(ctx context.Context, rid, uid int64) (domain.Reward, error) {
// 快路径
r, err := s.repo.GetReward(ctx, rid)
if err != nil {
return domain.Reward{}, err
}
if r.Uid != uid {
// 说明是非法查询
return domain.Reward{}, errors.New("查询的打赏记录和打赏人对不上")
}
// 有可能,我的打赏记录,还是 Init 状态
// 已经是完结状态
if r.Completed() || ctx.Value("limited") == "true" {
// 我已经知道你的支付结果了
return r, nil
}
// 这个时候,考虑到支付到查询结果,我们搞一个慢路径
// 你有可能支付了,但是我 reward 本身没有收到通知
// 我直接查询 payment,
// 只能解决,支付收到了,但是 reward 没收到
// 降级状态,限流状态,熔断状态,不要走慢路径
resp, err := s.client.GetPayment(ctx, &pmtv1.GetPaymentRequest{
BizTradeNo: s.bizTradeNO(r.Id),
})
if err != nil {
// 这边我们直接返回从数据库查询的数据
s.l.Error("慢路径查询支付结果失败",
logger.Int64("rid", r.Id), logger.Error(err.Error()))
return r, nil
}
// 更新状态
switch resp.Status {
case pmtv1.PaymentStatus_PaymentStatusFailed:
r.Status = domain.RewardStatusFailed
case pmtv1.PaymentStatus_PaymentStatusInit:
r.Status = domain.RewardStatusInit
case pmtv1.PaymentStatus_PaymentStatusSuccess:
r.Status = domain.RewardStatusPayed
case pmtv1.PaymentStatus_PaymentStatusRefund:
// 理论上来说不可能出现这个,直接设置为失败
r.Status = domain.RewardStatusFailed
}
err = s.repo.UpdateStatus(ctx, rid, r.Status)
if err != nil {
s.l.Error("更新本地打赏状态失败",
logger.Int64("rid", r.Id), logger.Error(err.Error()))
return r, nil
}
return r, nil
}
|
对账模块设计
- 对于每一条付款记录,我们都维护到 用户余额里面并且记录一笔流水
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
func (s *WechatNativeRewardService) UpdateReward(ctx context.Context,
bizTradeNO string, status domain.RewardStatus) error {
rid := s.toRid(bizTradeNO)
err := s.repo.UpdateStatus(ctx, rid, status)
if err != nil {
return err
}
// 完成了支付,准备入账
if status == domain.RewardStatusPayed {
r, err := s.repo.GetReward(ctx, rid)
if err != nil {
return err
}
// webook 抽成
weAmt := int64(float64(r.Amt) * 0.1)
_, err = s.acli.Credit(ctx, &accountv1.CreditRequest{
Biz: "reward",
BizId: rid,
Items: []*accountv1.CreditItem{
{
AccountType: accountv1.AccountType_AccountTypeReward,
// 虽然可能为 0,但是也要记录出来
Amt: weAmt,
Currency: "CNY",
},
{
Account: r.Uid,
Uid: r.Uid,
AccountType: accountv1.AccountType_AccountTypeReward,
Amt: r.Amt - weAmt,
Currency: "CNY",
},
},
})
if err != nil {
s.l.Error("入账失败了,快来修数据啊!!!",
logger.String("biz_trade_no", bizTradeNO),
logger.Error(err.Error()))
// 做好监控和告警,这里
return err
}
}
return nil
}
|
表结构设计
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// Account 账号本体
type Account struct {
Id int64 `gorm:"primaryKey,autoIncrement" bson:"id,omitempty"`
// 对应的用户的 ID,如果是系统账号
Uid int64 `gorm:"uniqueIndex:account_uid"`
// 账号 ID,这个才是对外使用的
Account int64 `gorm:"uniqueIndex:account_uid"`
// 一个人可能有很多账号,你在这里可以用于区分
Type uint8 `gorm:"uniqueIndex:account_uid"`
// 账号本身可以有很多额外的字段
// 例如跟会计有关的,跟税务有关的,跟洗钱有关的
// 跟审计有关的,跟安全有关的
// 可用余额
// 一般来说,一种货币就一个账号,比较好处理(个人认为)
// 有些一个账号,但是支持多种货币,那么就需要关联另外一张表。
// 记录每一个币种的余额
Balance int64
Currency string
Ctime int64
Utime int64
}
|
面试
-
微信的支付流程 , prepay 和 处理回调
-
怎么处理支付的回调 ? 关键是,怎么分发/区别 不同环境收到的支付回调
-
金额应该怎么存 ?
-
怎么防止重复支付?
-
如果微信支付的回调处理失败怎么办 ?
-
如何保证消息一定发出去? 怎么确保一定发送了数据?如何确保消息不丢失 ?
-
怎么保证消息丢有序性 ??
- 如果 topic 增加分区,怎么保证有序性?
- 如果保证消息有序性,消息积压了,怎么半?
-
如果在自己业务里面,有明显的部分失败的场景,怎么补偿
-
怎么保证幂等? 你有什么方案 ? 能不能撑住高并发
- 唯一索引处理
- 二话不说先插入一个数据,保证坐在一个本地事务里面,如果有冲突那么就说明做过了
- 不拢过滤器去重