中收权益体系的设计与实现

1. 背景

会员服务作为互联网C端业务常规打法,活跃于大家日常的衣食住行,各家根据战略定位赋能会员服务,具体战场战役千差万别。
作为互联网借贷平台,长期方向参考京东/淘宝等交易平台所推的PLUS会员/88VIP会员,将会员服务作为核心产品,通过为用户提供优质服务提高留存,拉高活跃度促进主营业务持续良性发展。
而权益作为会员服务的基础单元,支撑起整个产品形态,本文将剥开产品的壳,介绍中收新权益体系的落地。

而中收的会员产品存在多版本,不同版本会存在权益的差别,而即便是相同类型的权益,不同的版本也会有差别。
但是随着业务的发展,产品的迭代和权益组合越来越多,权益类型也日益丰富,目前的系统,在伴随业务发展上存在较大的局限性。
主要体现在下面几个方面:

  • 产品升级僵硬,我们要对一款产品升级或者做细微的改造,只能通过修改代码的方式进行产品切换,升级不够丝滑。
  • 用户感知度弱,在权益使用时,不能给用户带来较大感知,用户可能会迷惑为什么突然给他触发了一个权益使用,另外在产品权益升级后,也无法给到用户一个直观的表现,无法直击用户,提高转化。
  • 权益流程混乱,用户购买产品后,没有统一的入口来使用权益,权益触发的机制很多,比如在用户在场景A、场景B、场景C会分别触发一种权益,但触发后,ABC分别又有不同的权益实现逻辑和发放逻辑,没有统一的处理流程,也无法跟踪权益的处理进度。

2. 业务目标


从上面的分析可知,我们首先要达成的,是如下几个业务目标。

  • 支持输出形态丰富的会员产品,支持丝滑流畅的产品升级。
  • 打造权益面板,用户可以明确地感知自己具有哪些权益。
  • 统一权益的生命周期,标准化处理权益流程。

我们知道,虽然从用户角度看到的是形态各异的产品,但是我们提供的产品的本质,其实是丰富的权益体系,产品只是一个壳,我们希望在这个壳下,能够灵活地进行权益包装,用最直击用户的权益来打动用户购买产品。
所以,我们的改造重点就是对权益体系进行重新设计和实现。

3. 设计和实现

如前业务目标所述,需要完成对用户权益的整体规划,而用户权益体系的核心在于用户权益的获得和使用,我们先整体梳理一下,这两个流程的主要步骤,参考下图所示。


图3-1

可以看出,在权益的得失上,触发的关键步骤是

  1. 购买会员产品 -> 获得权益
  2. 发起退款 -> 冻结权益
  3. 退款取消 -> 撤销冻结
  4. 退款完成 -> 销毁权益

通常用户会员权益的获取流程中,只会走第1步,大多数用户并不会发起退单。

而权益的使用流程,则较为复杂,基本上每个权益的使用都会涉及到如下动作:

  1. 行权初始化
  2. 激活权益
  3. 领取权益

并且其中的2、3步都有对应的分支流转逻辑,出现停滞或者异常,都会流转到失效的状态。

基于业务目标和上述主体流程,我们对功能进行详细的设计拆解和实现。

  1. 基于会员产品的用户权益获取;
  2. 基于用户权益的行权。

3.1 用户获取权益


首先,我们需要知道产品有哪些权益。

按照旧系统的逻辑,用户购买会员后,权益是编码写死在会员产品上的,但很明显,每一次想要升级会员产品,都要重新编码,这种模式并不能便捷地支持我们会员的升级和改造。

权益配置池应运而生。顾名思义,权益配置池是一组由权益构成的池子,这个池子维护了我们所有能提供的权益,每种权益我们用R(X)表示。
这样,我们的产品构成,就更为灵活,直接从权益配置池中挑选R1+R2+Rx…组合,并且维护上这些权益在该产品下具有的数量即可。
同时使用权益配置池,有以下三个好处:

  • 更加灵活的配置权益。

例如从前我们的产品P1、P2同时拥有R1权益,而当我们想要让P1的R1权益进行升级,例如更换名字或者使用规则时,
我们可以基于权益池将R1升级一个新权益为R1’,这样我们再把P1的R1权益更新成R1’权益,就能满足我们的需求,且不会影响已购买P1产品用户的R1权益,也不会影响P2产品的R1权益,而沿用硬编码的方式,处理起来则会非常麻烦。而且这样后续的产品配置也有R1和R1’更多元的选择。

  • 更方便做权益的AB测试

我们要测试R1/R2/R3权益对用户的分别吸引力,那我们就可以分别基于R1/R2/R3+其他权益 分别组装成P1_1/P1_2/P1_3 三款产品,然后上线进行曝光,分别统计三款产品的购买/曝光转化率(简化统计),数据更好的那一款我们认为权益是更优化的。

  • 方便实现统一的配置变更。

例如,需要对R1权益进行改动,只需在权益配置池中对该权益进行修改,所有关联的产品可以一键生效。

那基于权益配置池、产品、订单再到用户,我们可以勾画出如下的关联关系:


图3-2

从这里我们可以看出,可以通过 订单->产品->权益这样顺向的结构,可以获取到用户的权益。
并且通过订单状态的变化,我们可以知晓权益是否可用。看起来这样就能满足需求了
但实则不然,这样的设计,会存在如下的问题:

  1. 每一次查询用户具有的权益都得通过多次连表查询,将会非常繁琐。
  2. 用户行权时,除了问题1,还需要进行权益库存扣减,目前这种设计无法进行库存扣减,只能通过行权流水表查询已经使用的次数,再与权益总次数做差值来判断用户是否还有可用权益,同时为了保证在执行过程中的数据一致性,需要使用到分布式锁。like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 查询已使用数量
int usedRightCount = queryUsedRightCount(userId, rightType);
int remainingRightCount = rightTotalCount - usedRightCount;
// 检查剩余库存
if(remainingRightCount > 0){
// 分布式lock
return redisLock.lockAndRun(lockPrefix + userId,()->{
// 再次检查剩余库存
remainingRightCount = refreshRightTotalCount(userId, rightType) - queryUsedRightCount(userId, rightType);
if(remainingRightCount > 0){
// 行权
doUseRight();
return true;
}
return false;
})

}
return false;
  1. 记录行权流水时,我们需要额外为行权流水表维护权益原订单id,以便知道用户当次使用的这一次权益来源订单。


为解决上述问题,我们提出如下两种方案。

方案一:购买订单后,按照权益id✖️权益数量+用户id+订单id+初始状态进行全排列组合落库,在使用时,锁定其中一条使用即可。即还是按照图3-2所示,在获得权益时即刻写入到行权流水表,再在具体触发权益时,更新实际的行权数据。

方案二:引入用户权益池,在订单支付成功后,按照订单id+权益id+该权益数量+用户id+权益初始化状态写入用户权益池,在实际使用时,扣减用户权益池的权益数量,再记入行权流水表。如图3-3所示。


图3-3

我们先考虑两种方案在解决前述问题的适用性。

原设计问题 新设计方案一 新设计方案二
查询权益可用次数都需多次连表查询 通过行权流水表查询,再在内存中计算汇总输出 直接通过用户权益池查询可用次数
用户行权时的前置扣减复杂 锁定一条可用的权益流水进行使用 直接锁定用户权益池的一条记录,进行库存扣减
行权流水表需要维护订单id 行权流水表依然需要维护订单id 行权流水表无需维护订单id

比对之下,方案一和方案二都能满足需求,

但在业务上看来,拆分成两层,一层是用户权益池,一层是用户权益的行权流水,业务架构上比较清晰;

且权益初始状态可能会发生变化,在方案一中,并不太适合变动,而方案二中维护的状态是跟用户订单状态相关的,具体的行权过程状态等到实际行权时写入流水表更合适;

某些权益的可用次数可能是无限次,以及某些权益并不需要实际行权,只需要给用户展示;

从系统存储上来看,往往一个权益的可用次数会多达数十上百次计(但实际用户可能只会触发几次权益使用),如果采取方案一,那么系统存储的冗余数据可能会增加数十倍;

同时从数据分析维度,方案一也不太方便汇总查询用户的权益状态。

所以我们淘汰了方案一,使用方案二:“引入用户权益池”来记录用户获取的权益。

如此,新权益体系打好了前半段的基础,
通过权益配置池,我们构建了产品,
通过用户权益池,我们做好了用户权益的基本关系维护。
并且,我们达成了业务目标的前两点,我们再进一步看权益的使用。

3.2 用户行权

在旧系统的实现中,每一类权益都有自己的行权步骤,举个栗子

权益A,触发权益 -> 用户再手动领取 -> 系统发放

权益B,触发权益 -> 直接发放

权益C,触发权益 -> 用户动作激活(例如上传资料等) -> 用户再手动领取 -> 系统发放

我们按照这些步骤,我们可以绘制下整体的行权流程图:


图3-4

所以用户可能的行权路径有

  • ①②⑤⑧(使用完毕结束)
  • ①③(使用完毕结束)
  • ①②⑥(使用完毕结束)
  • ①②④(未使用完成结束)
  • ①②⑤⑦(未使用完成结束)

可以看出,行权的分支路径众多,对于不同的权益,需要按照不同的流程进行处理。
所以,我们需要完成以下目标:

  1. 用户行权的环节众多,完整串联行权的各个环节,保证权益流程能正常走下去,并做好统一的监控;
  2. 用户的权益数量和类型较多,避免权益被滥用,保证使用过程中的幂等性和高可用;
  3. 在旧系统中,权益A/B/C的行权,也没有持久化行权的过程,例如权益A的具体实现是,触发的动作临时记录在了Redis中,展示时通过Redis记录来进行判断是否有权益,用户领取后会删除该Redis记录。我们难以把控行权的流程和溯源。新系统需要做到每一步数据的持久化。
  4. 不同的权益类型,需要完成各自的流程实现。

对于1/2/3我们可以用统一的处理逻辑,4我们可以按权益类型进行实现,所以我们对系统层次进行拆解,让每一层完成部分功能
上层:用户权益层,做权益流程控制、幂等性校验、统一监控和数据处理,
底层:权益实现层,则实现具体的权益状态变动(触发/激活/发放)等。

我们先从最底层-权益实现层出发

3.2.1 权益实现层

在权益实现层,虽然不同权益类型具有类似的动作,但一部分动作是相同的,例如权益初始化,库存检查;
但另一部分却存在差异,例如权益的触发检查需要自定义,权益的发放需要自己实现。
所以,我们想到采用模板方法模式来完成这一层的构建。

我们先构造基本的抽象模板类BaseRightFlowService,并定义好基本的动作,然后让子类完成需要实现的自定义方法。

参考如下的类图:


图3-5

我们将通用逻辑方法,例如

useRight() 行权方法

activateSingleRightRecord() 激活权益

等,由BaseRightFlowService完成,
大部分子类实现模板方法留出的钩子,例如

preCheckUseRightParam() 行权的检查

sendCoupon() 具体的权益发放逻辑

并且每一个实现类需要实现** supportRightType() **,在使用时根据工厂模式获取到实际的实现类。

3.2.2 用户权益层

在用户权益层的两个难点步骤是权益触发和权益发放。这两步需要保证幂等性和一致性

  1. 权益触发

过程描述:契机是用户达成了某个权益条件,例如完成一个借贷事件/完成一个还款事件。
这一步里,重点是如下三个要求:

  • 做好幂等检查,避免同一个事件触发多次权益;
  • 做好库存检查,避免多发权益;
  • 做好自定义检查,部分权益应用其实现层检查逻辑。

同时权益触发需要配合上文权益实现层,因为每类权益有自己的部分逻辑,实现伪代码参考如下:

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
   /**
* 权益触发
* @param triggerEventId 触发事件id
* @param useRightType 权益类型
**/
@Transactional
public Long trigger(Long userId, String triggerEventId, RightType useRightType) {
// 根据行权参数从用户权益池获取可以使用的权益
UserRelationRightDetail userRelationRightDetail = getCanUseRightDetail(userId, triggerEventId, useRightType);
// 获取权益实现层实现类
BaseRightFlowService rightFlowService = rightFlowServiceFactory.getRightFlowService(useRightType);
// 用户权益层通用检查 根据triggerEventId和useRightType做幂等检查
boolean canInit = commonCheck(triggerEventId, useRightType, userRelationRightDetail)
// 权益实现层自定义检查
&& rightFlowService.preCheck(triggerEventId, useRightType, userRelationRightDetail)
if(!canInit){
return null;
}
// 锁定用户权益记录
UserRelationRight userRelationRight = userRightDao.lock(userRelationRightDetail.getId());
// 检查库存数量
if (userRelationRight.getCount() > 0) {
// 扣减库存
userRightDao.reduceCountById(userRelationRight.getId());
// 权益初始化,跳权益实现层
UserUseRightRecord newUserUseRightRecord = rightFlowService.init(triggerOrderId, userRelationRightDetail);
// 落库
userUseRightRecordDao.insert(newUserUseRightRecord);
// 监控打点
monitorService.logUseRight(userId, rightType, rightType.getInitStatus());
return newUserUseRightRecord.getId();
}
return null;
}

// rightFlowService的init
// 作为模板方法,留出子类可扩展行为钩子
public UserUseRightRecord init(String triggerOrderId, UserRelationRightDetail userRelationRightDetail){
UserUseRightRecord record = new UserUseRightRecord();
record.setStatus(supportRightType().getInitStatus());
// 子类实现层自定义
customInit(triggerOrderId, record, userRelationRightDetail);
..
return record;
}

通用检查中幂等检查会根据triggerEventId,和当前useRightType,从数据库查询是否有行权记录,
同时为了避免并发操作,有triggerEventId+useRightType对应的rightId组成的unique key来做双重保证,避免用户双花权益,保证了幂等性。

另外,在初始化行权流水前,会锁定该用户权益,对库存进行扣减,通过库存的检查,避免重复调用时超量使用权益。
满足我们前述要求。

  1. 权益发放

过程描述:最终发放到用户手中,例如一个话费充值、一个优惠券发送。

通常这个过程是
用户提交 -> 系统落库成功 -> 系统通知三方处理
在系统通知三方处理这一步,往往是不可控的,由于涉及到跟其他系统交互的,这就会出现超时、系统错误等问题。

为了不漏发,我们引入异步调度,通过job定时扫描待发送的用户权益,并进行权益发送,直到发送成功。
在这种情况下,我们可能无法实时获取到上一次权益发送的结果,所以,我们推动了下游进行了幂等改造,保证重复的请求不会为用户发送多次,避免了权益重复发送。
这样,我们的调度,可以一直work直到权益发送成功。

结合权益实现层,权益发放的伪代码如下:

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
// 调度
protected void doExecute(JobExecutionContext jobExecutionContext) throws Exception {
// 获取可发放的权益记录
List<UserUseRightRecord> userUseRightRecords = userUseRightRecordDao.getByStartTimeAndStatus(LocalDateTime.now(), UserUseRightRecordStatus.AVAILABLE)
userUseRightRecords.forEach(userUseRightRecord -> {
// 在用户权益层实现类,进行实际发放
rightFlowServiceFactory.getRightFlowService(userUseRightRecord.getRightId()).sendUserRight(
userRelationRightDetail, userUseRightRecord);
});
}
再看下权益实现层的模板方法
public void sendUserRight(UserRelationRightDetail userRelationRightDetail, UserUseRightRecord userUseRightRecord) {
try {
// sendCoupon 由具体子类实现
Long resultId = sendCoupon(userRelationRightDetail, userUseRightRecord.getId(),
(T) JsonUtils.fromOrException(userUseRightRecord.getParamSnapshot(), supportRightType().getRightParamClazz()));
if (resultId != null) {
// 成功时更新状态并打点
userUseRightRecordDao.updateStatusAndResultIdById(userUseRightRecord.getId(), resultId, UserUseRightRecordStatus.SUCCESS);
monitorService.logUseRight(userRelationRightDetail.getLoanAccountId(), supportRightType(), UserUseRightRecordStatus.SUCCESS);
} else {
// 失败时打点
monitorService.logUseRight(userRelationRightDetail.getLoanAccountId(), supportRightType(), UserUseRightRecordStatus.FAIL);
}
} catch (Exception e) {
// 调度在下一次会进行业务重试
log.warn("sendUserRight error userUseRightRecordId=", userUseRightRecord.getId(), e);
monitorService.logUseRight(userRelationRightDetail.getLoanAccountId(), supportRightType(), UserUseRightRecordStatus.FAIL);
}
}
  1. 用户权益层其他状态

之前我们说道,权益具有待激活/待领取等中间状态,在实际实现中,权益实现层只需在每一步指定好完成这一步后的权益状态,即可控制部分状态跳过。而对于激活超期未失效的处理,我们额外引入一个job,进行扫描失效并加回权益库存即可。

经过上述的设计和实现,最终用户能达成如下图所示的行权时序:


图3-6

通过上述分解,我们完成了用户行权过程的流程梳理,系统分层实现,以及系统幂等、可用性等的保障。
可以看到,对比旧系统的实现,在新设计实现中,用统一的模板方法串起了整个流程,再由子类实现定制化的行为,进行了大量的功能完善和系统优化,加入了db持久化,为事后的用户行为分析和产品优化提供了数据支持,增加了系统监控,便于及时监控线上权益使用状态。

3.3 行权落地

在实际业务中,我们用一套行权流程,支持了多套业务模式的行权落地,下面列出三个典型的模式。

3.3.1 标准模式

这一类即是完全按照我们3.6中的用户行权时序一整套走下来的标准模式,
权益实现层的实现类会真正实现发放权益的方法,将权益发出。

3.3.2 业务方行权

业务方行权的模式,主要在交互上完全按照标准流程,而实际的发放权益,并不需要实现。
具体行权体现在调用方的业务实现中。
例如我们有一个场景是需要根据用户具有的权益来降低某笔订单的费用,那么就会走该模式。
其动作就是。

  1. 订单初始化时,初始化权益使用,初始化成功则降费。
  2. 订单完成时,激活权益为待发放状态。
  3. 然后直接由调度将该条权益流水进行发放(实际发放实现不做任何事情),将权益流水状态置为使用完成。

伪代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 初始化订单时,触发一条权益
UserUseRightRecord userRightRecord = userRightService.get(RightType.SPECIAL_RIGHT, userId);
if(userRightRecord != null){
Order order = initCutPriceOrder(userId);
// 触发权益使用
Long triggerUseRightRecordId = userRightService.trigger(userRightRecord.getId(), order.getId(), RightType.RECREDIT_RIGHT);
if(triggerUseRightRecordId != null){
// continue process
}else if(needCutPrice){
// throw exception for rollback
}
}
...

// 订单完成时,根据订单标记权益已领取
userRightService.activatSingeRightRecord(userId, orderId, ..);

// 激活后,job 会将该权益流水置为终态
executeToEnd();

3.3.3 通知模式

另一种模式,部分权益比如话费充值等,第三方维护了用户该权益的库存,需要在第三方的页面使用,使用完成后,再由第三方直接通知我们。
这种模式比较简单,目前我们在接收到通知时,依然按权益触发代码处理,只是该类权益的初始化状态为我们设定的生命周期的** 已使用 **,这样便可完成记录。

目前,我们金融权益大部分是经通用流程行权,少量权益属于业务方行权,而生活类权益则依赖三方行权通知模式。
通过上述三类模式,我们已经支撑起中收业务的主要权益产品类业务的实现。

4. 总结与展望

在做会员新权益体系设计中,遇到了一些难点,往往写业务代码的过程并不复杂,重点之处在于系统设计,好的顶层设计往往需要以终为始,想得更多,看得更多,对业务和实现的假想预设尽量丰富一些,这样能覆盖更多的业务场景,并且尽量避免未知的坑。

通过设计和实现新的会员权益体系,我们完成了对旧会员权益系统的迁移改造,并持续接入了新的权益类型。目前线上已经配置了40+的权益和30+的产品,并完成了10+次数的产品升级。可以预见后续权益体系还会接入更多的新权益,组建更加丰富的产品形态。同时,我们统一了行权流程,更细致地记录了用户的行权过程,为后续更好地使用权益数据,进行权益的升级,以及针对用户做权益推荐等打好了数据基础。

中收也将持续优化系统和流程,为用户提供更好的权益产品服务。