最近工作上有一个项目,需要为用户维护一个账户系统。用户获得的收益会存入账户,用户也可以从这个账户提现到支付宝或者微信账号。这个项目涉及到金钱,同时又涉及到多个系统(自己的后端和支付宝等外部服务),并且由我一个人设计和实现,所以我也是有点紧张,设计之初也在安全性上也尽量多考虑了一些。这篇文章是一个简单的记录。

系统简介

这个系统提供的功能实际上就是两个,存钱和取钱。这简直是教科书式的 RDMS 例子。考虑到流量不会很大,所以也是用的 RDMS 来实现保证存钱和取钱这两个操作,在我们的内部系统中满足 ACID。但是 ACID 性质只能保证

系统接口的调用是合法并且向外部转账和内部系统扣钱满足原子性的情况下,我们内部系统中保存的账户余额是才是正确的。

为了满足这两个前提条件,我们需要保证

  1. 接口的调用必须做某种鉴权,防止有人能随意增加余额。
  2. 外部转账操作和内部系统扣钱满足原子性。

接下来主要记录一下,我是怎么保证这两点的。

接口调用鉴权

简而言之,我们实际上只要保证,只有我们允许的人,才可以调用我们的接口。一个简单的想法就是采用非对称加密,调用方用自己的私钥签名,账户系统使用注册的公钥验证签名。当然,使用对称加密,在我这个调用方也是内部服务的场景下,也是可以的,实际上这里就相当于 keyed hash。为什么接口只在内网暴露也要加密呢?这主要是为了防止内部人员随意向账户充值。

当然只是加密并不能保证安全,因为还可能出现重放攻击。这个可以通过在请求中加入时间戳,并且用一个分布式的存储(比如 Redis)来解决。

内外操作的原子性保证

想要保证内外操作的原子性,简单的做法,就是在 JDBC 的一个事务中做外部 RPC 调用,如果发生异常,就回滚事务。但仅仅是这样是不够的,因为判断转账是否成功不能只是简单查看 RPC 调用是否有异常。比如,由于网络原因,虽然转账成功了,但是 Response 没有收到抛出 IOException,这个时候要是还是回滚事务,那么就相当于我们内部没有扣钱,但还是给用户转账了。

支付宝和微信支付的文档都特意强调了这一点。这里写一下伪代码:

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
while (retryCount < retryLimit) {
var response = executeTransfer(request);

var executeResult = isTransferSuccessful(request, response));
// 检查转账是否成功,如果成功,就直接返回
if (executeResult.isSuccessful()) {
return;
} else if (!executeResult.shouldRetry()) {
// 不成功的情况下,判断是否应该重试。如果不用重试,直接抛出异常,回滚事务。
// 比如请求有问题,重试也没有意义。
throw new SomeException();
}

// 不满足以上条件的话,就意味着我们需要查询一下来判断是否成功
var queryResult = queryTransferStatus(request, response);
// 检查转账是否成功,如果成功,就直接返回
if (queryResult.isSuccessful()) {
return;
} else if (!queryResult.shouldRetry()) {
// 和上面类似,不成功的情况下,判断是否应该重试。
throw new SomeException();
}
// 注意,query 这个 RPC 调用也可能出现网络异常,所以这里也可以重试。

// 如果还是不能确定,就继续重试
retryCount += 1;
}

重试?

实际上,只是保证上述两点,其实还是没法保证事务性的。考虑这种情况,调用方需要为某个用户充值。虽然充值成功了,但是由于网络原因,在调用方看来,没有成功,调用方就会重试。在这种情况下,可能会出现重复充值的问题。

为了避免这个问题,我们需要区分重试的能力。一个简单的做法,就是要求调用方加上一个唯一的 ID。基于这个 ID 我们能判断是否是重试请求,并做相应的处理。

密钥和证书的管理

不管是做接口调用鉴权,还是调用支付宝和微信的接口,这个系统需要使用密钥和证书。而这些信息需要保证 ACL。目前的实现是将这些放在阿里云的 OSS 中,利用阿里云本身的安全机制来保证。这样,代码和配置中心就不要存储明文的密钥和证书,只需要对应的文件名即可。

总结一下

测试

为了保证各种操作的安全性,加了很多测试。测试使我安心(一点)。这里不得不说,微信的沙箱环境根本没用,连企业支付的功能都不支持,微信支付的测试还是连的线上环境。相反,支付宝就很友好。

支付宝和微信接入

这两个接入都很麻烦,对账号的要求也很苛刻(比如注册必须满 90 天,最近 30 天内每天都需要有正常交易)。把这些都配置还是花了好几个小时。另外,微信支付都没有官方的 Java SDK,也是服了。