多渠道多场景的支付设计

多渠道多场景的支付设计

在线支付是很多系统都常见的一种功能,几乎所有涉及到交易的系统都会用到支付,比如商城、各种平台的会员充值、外卖平台等。而我们日常生活中比较常用的支付方式莫过于微信或者支付宝,也有少部分场景会直接用到网银来支付。通常情况下,我们在开发一个带有支付功能的系统时,通常不会只对接一套支付系统。并且每一家的支付系统实现的方式、接口的标准等,几乎都不一样。这使得我们在开发和维护系统时,情况变得复杂。

除此之外,我们的系统中可能还涉及到多种支付场景,比如会员充值、购买商品、参加活动等,但这些场景在处理业务时又不尽相同。

支付设计的痛点

针对开头我们提到的支付业务开发过程中遇到的问题,我们将问题大致罗列出以下几点:

  1. 支付渠道比较多,而且各个第三方支付实现标准不统一,如接口参数、返回值、认证方式等。
  2. 用户使用的支付终端不同,比如:电脑浏览器、手机浏览器、手机APP。这些不同的终端对应的支付实现也不完全相同。
  3. 场景复杂,购买商品、会员充值等各种场景虽然都需要支付,但支付时的前置和后置处理不一样。比如购买商品时可能需要选择是否使用券、有没有满减活动等,而会员充值时又需要选择充值的标准,比如普通会员、白金会员等。

针对这些问题,下面开始逐步拆解分析。

多渠道多终端

首先我们先看前两个问题,支付渠道多而且支持的终端不同,比如下面这张图:

image-20230424100750487

图中使用了两种支付渠道,分别是支付宝和微信,并且每种支付渠道又包含三种可选的支付终端。我们希望客户端在发起支付请求时,告诉服务端当前使用哪种支付渠道来支付,并且告诉服务端当前使用的终端是什么。这样服务端就可以根据这些信息去和第三方支付系统做适配。

说到这里可能有人会说,这个简单,使用适配器模式,分别给支付宝和微信的三个终端各写一个适配,根据客户端传递过来的支付类型和终端类型选择对应的适配器来处理,然后使用工厂来创建适配器,不同的支付类型选择不同的适配器。最终我们的设计如下图所示:

image-20230424111959618

在这个设计中,我们先是抽象出一个接口,这个接口中统一业务的操作方法。由适配器来针对不同的终端来适配操作,最后使用工厂来针对不同的支付方式构建不同的适配器。这个设计看上去近乎完美,很好的解决了接口不统一的问题,上层业务只需要调用抽象接口的方法,适配器会自动的帮我们调用对应的适配对象来处理。

但是,这其中还是有很多的问题存在,比如我们需要新增一个接口方法,此时,我们就需要将每一个适配器和适配对象都增加这个方法。再比如,我们需要增加新的终端类型(支付场景比较少见),那么就需要在适配器中新增一个适配对象。

从上图中我们可以看到,适配对象与适配器之间是一种强关联关系,每个不同的终端都会有一个独立的对象存在,这显然违反了设计模式中的迪米特原则(即:最小知识原则)。另外,我们在实际对接的时候发现,有多接口并不区分终端类型,比如查询订单、取消订单等,那么这些具有相同操作的接口在每个终端中都需要实现一次,这显然也不符合设计模式的合成复用原则。

由此可见,这个看似近乎完美的设计,实则漏洞百出。那么该如何进行优化呢?我们可以将问题进一步拆解分析。

首先,我们先考虑我们在实现支付时都需要处理哪些业务:

  1. 创建支付,完成一系列的校验、数据锁定及订单插入等操作,通知客户端唤起收银台;
  2. 支付成功,完成一系列后续业务,比如:订单进入发货流程、给用户的充值到账等。
  3. 取消支付,处理未支付的订单,接触数据锁定等。
  4. 申请退款,处理已支付订单,退还用户款项。
  5. 查询订单,获取订单信息。

我们实际开发时,基本上能用到的也就这些方法。从这些业务中,我们可以看到,创建支付时需要唤起收银台,那么每个终端对于收银台的实现可能都不一样。因此,我们按照是否限定终端进行分组,可以得出以下结果。

  • 限定终端
    • 创建支付
  • 不限定终端
    • 支付成功
    • 取消支付
    • 申请退款
    • 查询订单

除此之外,我们将每一种支付类型视为一个支付通道,比如用户选择支付宝,那么就走支付宝的通道,如果使用的是微信,那么就走微信通道。我们针对上面的适配器模式做个优化,在接口与实现类中间增加一个抽象,这个抽象用来实现那些具有相同行为的操作。如图:

image-20230424121132100

工厂类中,我们改用枚举来实现,如图:

image-20230424122057651

在此实现中,我们可以根据传入的通道类型和终端类型创建对应的支付实现。讲到这里可能有人会说,这也没啥改变,在工厂中一样还是强依赖关系,而且实现起来比适配器模式更加复杂了。那么好,我们看看优化后的设计比适配器模式好在哪里。

首先,各支付接口中,具有相同行为的操作被提取到一个抽象类中,抽象类中实现了统一的行为操作处理。对于个性化的操作下沉到子类去实现,这样既保证了接口的完整性,又满足了合成复用原则。当有新的接口增加时,如果同样属于相同行为操作,那么只需要在抽象类中实现一次,则各子类中就拥有了相同的操作,而不需要给每个终端都写一个实现方法。

对于抽象类和具体的实现而言,它并不知道当前用户使用了哪个终端来处理,这些都被封装到一个工厂中。而对于调用者而言,它只知道使用了哪种支付方式和使用哪种终端,至于具体使用哪个类来实现操作也是完全不知情的。因此,只有工厂是清楚的知道哪种支付类型和终端使用哪个实现。并且对于工厂而言,它只负责创建实例,至于执行那种行为操作它也是不知情的。这样,每个组件都各司其职,而对于其他组件所需要做的事情不做任何干涉。这不正满足了设计模式中的迪米特原则吗?

这样,当我们后续需要对接新的支付系统时,只需要新增一个通道实现,然后给工厂增加一个选择器即可。当需要给通道新增接口时,只需要在对应的实现类中增加即可。

多场景支付

说完多渠道支付,我们再说说多场景支付,这在我们构建系统时也是经常会遇到的。比如我们现在要开发一个B2C商城,一开始我们只需要提供用户购买商品即可,但随着业务的发展。平台推行一种会员方案,普通用户没有折扣、VIP用户可以享受每单5折优惠。然而VIP不是随便可以获得的,需要用户每个月支付20元的会员费。

针对这种场景,我们分析可得出,购买商品所需要的信息和会员充值所需要的信息是不一样的,如图:

image-20230424125919457

除此之外,购买商品和会员充值的后续业务处理也是不同的,比如商品购买成功后会进入配送流程,而会员充值,则只需要给用户标记为会员即可。讲到这里,可能有人会说,这个简单,前面支付渠道已经封装的很好了,我只需要给每个场景单独提供一套支付操作就可以了。这么说也没错,场景不同则实现也不同。但这里有一个问题,比如我们的支付修改了,之前是直接结算,现在需要在流程结束后统一结算,那么你该如何处理?再比如,我们之前的支付接口实现变了,你又该如何处理?

如果是单一场景或只有一两个支付场景还好,如果系统中存在十几种甚至更多支付场景呢。那么就只能将每一个场景的支付部分的代码都修改一遍,这无疑是灾难性的。首先,修改这些代码可能会导致新的问题产生,对于已经测试上线的功能需要进行回测。第二,支付环节每一次的变化都需要修改一遍代码,因此复杂度为O(n),n 即:每次变化需要修改的代码次数。

那么有没有什么好的办法来解决这个问题呢?答案是肯定有的。那么好,我们针对上面的场景来进行分析,首先我们先分析一下,所有的支付场景都有哪些共同点。

  1. 发起支付请求;
  2. 支付成功处理;
  3. 取消支付;
  4. 申请退款;

看到这里是不是感觉到有点儿眼熟,对的,它和我们在设计支付时几乎是一样的。区别是我们在设计支付时,它所对应的目标对象是第三方支付系统,换句话说,我们的支付设计更像是一个代理,我们所有的针对第三方支付系统的操作都交由支付通道来代为处理,将业务与第三方支付隔离。

而支付场景所对应的目标是我们的业务系统,从计算机的角度而言,每一个业务场景都是一组计算,只不过场景不同而导致算法不同而已,这里的算法可以理解为业务流程。针对不同场景选择不同的算法,这让我们想到设计模式中的策略模式。

策略模式通俗点来说就是对象的某个行为在不同的场景下可以有不同的处理方式。例如我们可以用嘴巴来吃东西、喝水、呼吸,如果你是在喝水,那么你可以选择用勺子、杯子、吸管等,如果你在吃东西,那么你可以选择用筷子、用手抓,如果你是在呼吸,那么你什么都不需要用,只要张嘴喘气即可。同样是嘴巴,同样是往身体里面进东西,但场景不一样所需要的处理也不一样。

我们针对上面的场景,结合策略模式设计,如下图所示:

image-20230424134831668

图中,我们先是定义了一个交易策略的接口,然后分别针对购买商品和会员充值两个场景添加了对应的实现。交易上下文也就是我们统一处理业务的类,上下文中需要针对不同场景设置相应的策略,对于上下文而言,只需要调用策略的方法来完成功能,无需知道使用的是哪种策略。我们可以在上下文中调用支付通道来完成相应的支付操作,而具体的业务则交由策略实现来处理。当支付发生变化时,我们只需要对上下文进行修改即可,修改完成后,我们只需要测试某一场景的流程是否正确即可。当一个场景测试通过时,则其他场景全部通过。此时的复杂度为O(1),即每一次的变化只修改一次上下文即可。

讲到这里,设计还没有结束,按照上面的设计,虽然可以解决场景问题,但是它和支付一样,也会有相同的操作存在。回顾我们之前粗暴的实现支付时,每一笔支付,无论是哪种场景都需要在业务系统中记录一个订单,后续所有的操作都会基于这个订单来处理。那么往数据库中插入订单就是每个场景都需要做的。

根据我们之前设计支付时的经验,当业务中既有相同行为操作又有个性化行为操作时,我们的做法是进一步抽象,将个性化操作下沉到子类实现,而通用操作交由抽象类来做。我们修改上面的设计如下图所示:

image-20230424140926649

图中,我们将插入订单操作提取到一个抽象类中,并且设置为受保护方法,因为我们只希望子类去调用,而对于策略的调用者来说是不允许直接插入订单的。为了减少修改,我们同样适用工厂来实现策略的创建,不同的是,这一次我们使用枚举工厂,也就是将每一个策略绑定到一个枚举项上面,通过传入不同的枚举,我们就可以调用不同的策略,如图:

image-20230424150511694

设计融合

面前我们已经设计好了多渠道支付和多场景支付,此时两个设计还是各自独立的。我们需要将这些设计融合在一起。

最终完成的设计如图所示:

image-20230424150614985

针对此设计图,下面我们开始组织代码。

支付通道代码实现

PayChannel

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
/**
* 支付通道接口
* @author kael
*/
public interface PayChannel {
/**
* 获取支付通道ID
* @return 返回通道ID
*/
int getChannelId();

/**
* 创建支付
* @param param 支付参数
* @return 返回收银台地址
*/
String createOrder(Object param);

/**
* 查询订单
* @param param 查询参数
* @return 返回订单信息
*/
String queryOrder(Object param);

/**
* 取消订单
* @param param 取消参数
*/
void cancelOrder(Object param);

/**
* 申请退款
* @param param 申请参数
*/
void applyForRefund(Object param);
}

AbstractAlipayChannel

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
/**
* 支付宝通道
* @author kael
*/
public abstract class AbstractAlipayChannel implements PayChannel {
@Override
public int getChannelId() {
return PayChannelType.ALIPAY.getId();
}

@Override
public String queryOrder(Object param) {
System.out.println("查询支付宝订单");
return "支付宝订单信息";
}

@Override
public void cancelOrder(Object param) {
System.out.println("取消支付宝订单");
}

@Override
public void applyForRefund(Object param) {
System.out.println("申请支付宝退款");
}
}

AlipayPcChannel

1
2
3
4
5
6
7
8
9
10
/**
* 支付宝 PC 端支付
* @author kael
*/
public class AlipayPcChannel extends AbstractAlipayChannel {
@Override
public String createOrder(Object param) {
return "支付宝 PC 收银台";
}
}

AlipayWapChannel

1
2
3
4
5
6
7
8
9
10
11
/**
* 支付宝 Wap 端支付
* @author kael
*/
public class AlipayWapChannel extends AbstractAlipayChannel {
@Override
public String createOrder(Object param) {
return "支付宝 Wap 收银台";
}
}

AlipayAppChannel

1
2
3
4
5
6
7
8
9
10
/**
* 支付宝 App 端支付
* @author kael
*/
public class AlipayAppChannel extends AbstractAlipayChannel {
@Override
public String createOrder(Object param) {
return "支付宝 App 收银台";
}
}

AbstractWechatChannel

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
/**
* 微信通道
* @author kael
*/
public abstract class AbstractWechatChannel implements PayChannel {
@Override
public int getChannelId() {
return PayChannelType.WECHAT.getId();
}

@Override
public String queryOrder(Object param) {
System.out.println("查询微信订单");
return "微信订单信息";
}

@Override
public void cancelOrder(Object param) {
System.out.println("取消微信订单");
}

@Override
public void applyForRefund(Object param) {
System.out.println("申请微信退款");
}
}

WechatPcChannel

1
2
3
4
5
6
7
8
9
10
/**
* 微信 PC 端支付
* @author kael
*/
public class WechatPcChannel extends AbstractWechatChannel {
@Override
public String createOrder(Object param) {
return "微信 PC 收银台";
}
}

WechatWapChannel

1
2
3
4
5
6
7
8
9
10
/**
* 微信 Wap 端支付
* @author kael
*/
public class WechatWapChannel extends AbstractWechatChannel {
@Override
public String createOrder(Object param) {
return "微信 Wap 收银台";
}
}

WechatAppChannel

1
2
3
4
5
6
7
8
9
10
/**
* 微信 App 端支付
* @author kael
*/
public class WechatAppChannel extends AbstractWechatChannel {
@Override
public String createOrder(Object param) {
return "微信 App 收银台";
}
}

PayChannelType

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 支付通道类型
* @author kael
*/
public enum PayChannelType {
// 支付宝
ALIPAY(1),
// 微信支付
WECHAT(2);

// 通道ID
private Integer id;

PayChannelType(Integer id) {
this.id = id;
}

public Integer getId() {
return this.id;
}
}

PayTerminal

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 支付终端
* @author kael
*/
public enum PayTerminal {
// 电脑浏览器
PC,
// 手机浏览器
WAP,
// 手机APP
APP;
}

PayChannelFactory

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
/**
* 支付通道工厂
*
* @author kael
*/
public class PayChannelFactory {
/**
* 构造支付通道
* @param channelType 支付通道类型
* @param terminal 支付终端
* @return
*/
public static PayChannel builder(PayChannelType channelType, PayTerminal terminal) {
PayChannel channel;
switch (channelType) {
case ALIPAY -> {
switch (terminal) {
case PC -> channel = new AlipayPcChannel();
case WAP -> channel = new AlipayWapChannel();
case APP -> channel = new AlipayAppChannel();
default -> channel = null;
}
}
case WECHAT -> {
switch (terminal) {
case PC -> channel = new WechatPcChannel();
case WAP -> channel = new WechatWapChannel();
case APP -> channel = new WechatAppChannel();
default -> channel = null;
}
}
default -> channel = null;
}
return channel;
}
}

交易策略代码实现

TransactionStrategy

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
/**
* 交易策略
* @author kael
*/
public interface TransactionStrategy {
/**
* 生成交易订单号
* @return 返回订单号
*/
String generateOutTradeNo();

/**
* 创建交易
* @param param 交易参数
* @return 返回交易信息
*/
String create(Object param);

/**
* 确认支付
* @param outTradeNo 交易订单号
*/
void success(String outTradeNo);

/**
* 取消支付
* @param outTradeNo 交易订单号
*/
void cancel(String outTradeNo);
}

AbstractTransaction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 抽象交易
* 用途:抽取交易策略中的同类操作
* @author kael
*/
public abstract class AbstractTransaction implements TransactionStrategy {
/**
* 添加支付订单
* @param params 订单参数
*/
protected void addPayOrder(Object params) {
System.out.println("向数据库插入支付订单");
}
}

CommodityTransaction

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
/**
* 商品交易
* @author kael
*/
public class CommodityTransaction extends AbstractTransaction {
@Override
public String generateOutTradeNo() {
return TransactionType.COMMODITY.name() + "_" + System.currentTimeMillis();
}

@Override
public String create(Object param) {
System.out.println("购买商品:" + param);
String outTradeNo = generateOutTradeNo();
System.out.println("订单号:" + outTradeNo);
super.addPayOrder(param);
return "创建商品交易";
}

@Override
public void success(String outTradeNo) {
System.out.println("商品交易成功");
}

@Override
public void cancel(String outTradeNo) {
System.out.println("商品交易取消");
}
}

RechargeTransaction

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
/**
* 会员充值
*
* @author kael
*/
public class RechargeTransaction extends AbstractTransaction {
@Override
public String generateOutTradeNo() {
return TransactionType.RECHARGE.name() + "_" + System.currentTimeMillis();
}

@Override
public String create(Object param) {
System.out.println("会员充值:" + param);
String outTradeNo = generateOutTradeNo();
System.out.println("订单号:" + outTradeNo);
super.addPayOrder(param);
return "创建会员充值";
}

@Override
public void success(String outTradeNo) {
System.out.println("会员充值成功");
}

@Override
public void cancel(String outTradeNo) {
System.out.println("会员充值取消");
}
}

TransactionType

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 交易类型
* @author kael
*/
public enum TransactionType {
// 商品交易
COMMODITY(new CommodityTransaction()),
// 会员充值
RECHARGE(new RechargeTransaction());

private TransactionStrategy strategy;

TransactionType(TransactionStrategy strategy) {
this.strategy = strategy;
}

public TransactionStrategy getStrategy() {
return this.strategy;
}
}

TransactionContext

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
53
54
55
56
57
/**
* 交易上下文
* @author kael
*/
public class TransactionContext {
// 交易策略
private TransactionStrategy strategy;
// 支付通道
private PayChannel channel;

/**
* 设置交易策略
* @param transactionType 策略类型
*/
public void setStrategy(TransactionType transactionType) {
this.strategy = transactionType.getStrategy();
}

public void setChannel(PayChannelType channelType, PayTerminal terminal) {
this.channel = PayChannelFactory.builder(channelType, terminal);
}

/***
* 创建交易
* @param param 交易参数
* @return 返回创建结果
*/
public String create(Object param) {
strategy.create(param);
String result = channel.createOrder("");
System.out.println("打开" + result);
return "创建商品交易";
}

/**
* 支付成功
* @param outTradeNo 商户订单号
*/
public void success(String outTradeNo) {
String result = channel.queryOrder(outTradeNo);
if (result != null) {
strategy.success(outTradeNo);
} else {
throw new RuntimeException("交易失败");
}
}

/**
* 取消交易
* @param outTradeNo 商户订单号
*/
public void cancel(String outTradeNo) {
channel.cancelOrder(outTradeNo);
strategy.cancel(outTradeNo);
System.out.println("商品交易取消");
}
}

测试

最后,我们开始对上面的实现进行组装测试。

PayTest

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
/**
* 支付测试
* @author kael
*/
public class PayTest {
public static void main(String[] args) {
System.out.println("\n---------- 购买商品 ----------");
TransactionContext context = new TransactionContext();
context.setStrategy(TransactionType.COMMODITY);
context.setChannel(PayChannelType.ALIPAY, PayTerminal.PC);
// 创建支付
context.create("自行车");
System.out.println("等待用户付款...........");
// 确认支付
context.success("");

System.out.println("\n---------- 会员充值 ----------");
context = new TransactionContext();
context.setStrategy(TransactionType.RECHARGE);
context.setChannel(PayChannelType.WECHAT, PayTerminal.APP);
// 创建支付
context.create("白金会员");
System.out.println("等待用户付款...........");
// 确认支付
context.success("");
}
}

测试案例中,我们模拟一个购买商品、一个会员充值,并且两种场景分别使用支付宝的PC支付和微信的APP支付。代码执行后,返回结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- console log --

---------- 购买商品 ----------
购买商品:自行车
订单号:COMMODITY_1682321282696
向数据库插入支付订单
打开支付宝 PC 收银台
等待用户付款...........
查询支付宝订单
商品交易成功

---------- 会员充值 ----------
会员充值:白金会员
订单号:RECHARGE_1682321282707
向数据库插入支付订单
打开微信 App 收银台
等待用户付款...........
查询微信订单
会员充值成功

从执行结果上来看,我们完全可以根据不同的参数来进行不同场景不同渠道的支付组合。

结束语

这个设计可能不是最完美的,但基本解决了我们一开始所遇到的问题。正所谓优化无止境,无论何时你可能总会找到一个更好的设计。

通过这个设计案例,我们会发现:

  • 设计是循序渐进的,我们必须由浅入深逐步细化,不要想着能够一步到位。
  • 实际的软件设计过程中,对于设计模式的运用往往不是某一个单一模式能够解决的。大部分情况下需要多种设计模式进行组合,设计过程尽可能的遵循设计模式基本原则。
  • 复杂的业务设计建议使用图和伪代码来进行推导,以此来检验我们在设计上的可行性。
  • 设计被实践之后需要及时记录文档,通过实践,我们已经检验了设计的可行性和稳定性。完整的设计需要及时的记录文档,一方面是当其他人接手时可以迅速的了解设计,另一方面是将来我们遇到同样的场景时可以拿来参考。

多渠道多场景的支付设计
https://kael.52dev.fun/2023/04/24/多渠道多场景的支付设计/
作者
Kael
发布于
2023年4月24日
许可协议
BY (KAEL)