一种订阅计算方法

本文描述一种针对较为复杂的订阅分级计算指定日期生效的订阅的计算方法。更多地是作为记录,因为半年后自己再看代码,花了两三个小时才完全捋清。

背景

产品需要应用内订阅,描述如下

  • 区分等级,目前有二——普通会员、高级会员
  • 区分周期,目前有二——包月、包年
  • 区分来源,目前有二——付费、赠送(包含老用户赠送、直播抽奖赠送等,未来可能存在更多)
  • 支持退款
  • 订阅支付方式支持全平台:StoreKit、支付宝、微信

并有限制如下

  • 等级高的优先消耗:若同时存在普通和高级会员,则先消耗高级会员,普通会员顺延到高级会员之后
  • 付费优先免费消耗:若同时存在付费和赠送的会员,则先消耗付费会员,免费会员顺延到付费之后

根据iOS的平台特性,又增加了一种特殊场景

  • 订阅升级:若用户先购买普通会员,有效期内又购买高级会员。则高级会员立刻生效,未用完的普通会员将按比例退款

要求知道某个具体时刻生效的订阅属性:等级、周期、来源

分析

支付和业务分离

由于支付平台存在不同规则,比如StoreKit的升级机制,应将这部分特性独立于订阅所在业务服务,于是形成了单独的支付服务。在支付服务和业务服务之间,只存在两种格式的数据:订阅历史和退款历史(在支付平台每支付成功一次,增加一个订阅历史;每退款成功一次,增加一个退款历史)。iOS平台的订阅升级,可以处理为一个新的高级订阅历史和一个针对之前普通会员订阅历史的退款。

于是,我们只需要根据订阅历史和退款历史计算出某天生效的订阅即可,来到了这个算法。

订阅树

将每条订阅历史作为一个节点,它们之间按照优先级限制,可以构建出一颗具有如下特点的订阅树

  • 节点组成
    • 订阅本身属性:等级、周期、来源等
    • 该条订阅生效时间
    • 该条订阅过期时间
  • 父节点的生效时间跨度包含子节点
  • 树的高度等于节点时间跨度内同一时刻不同优先级个数,目前来说不会超过4(优先级从低到高:普通赠送、普通购买、高级赠送、高级购买)
  • 退款必然针对某条订阅历史,因此退款不会产生新的节点,只会对已有节点的过期时间推前

下面举几个实际的例子

例一 多层树

一年内依次赠送普通会员、购买普通会员、赠送高级会员、购买高级会员。具体行为如下

  • 2020-01-01 00:00:00 获赠一年普通会员
  • 2020-01-05 00:00:00 购买一年普通会员
  • 2020-01-06 00:00:00 获赠一年高级会员
  • 2020-02-01 00:00:00 购买一年高级会员

对此我们构建出如下高度为4,每层仅有一个节点的树。下图中,箭头代表时间轴,箭头左边为该节点生效时间、箭头右边为该节点失效时间、中间为该节点的订阅属性。在订阅树中,同一时间存在多个生效订阅时,子节点的订阅会覆盖父节点的订阅。

image-20230109153715408

假设我们要知道 2021-01-01 00:00:00 生效的订阅,则只需要从根节点起遍历,如果存在包含 2021-01-01 00:00:00 这一时刻的子节点,就继续遍历,直到最终节点。图示如下,除了2021-01-01 00:00:00,我们还标出了几个其它的时间

  • 2020-01-05 12:00:00 生效的是 购买的普通包年会员
  • 2020-07-05 12:00:00 生效的是 购买的高级包年会员
  • 2021-01-01 00:00:00 生效的是 购买的高级包年会员
  • 2023-10-01 00:00:00 生效的是 赠送的普通包年会员
  • 2024-03-01 00:00:00 没有任何订阅生效

image-20230109153549013

例二 包含退款的树

我们来看一种包含退款的情况,订阅历史如下

  • 2020-01-01 00:00:00 购买包年普通会员,记录id为a
  • 2020-10-01 00:00:00 购买包月高级会员

退款记录如下

  • 2020-10-02 00:00:00 针对记录id为a的订阅历史进行退款

形成的记录树如下,退款的部分被标红。而事实上,代码里退款在节点参与构建订阅树之前就已经完成了对节点的修正:如果退款发生在高级会员的购买之前,则高级会员会是普通会员的弟弟节点,而不是子节点。

image-20230109154911861

例三 一种特殊情况

需要指出的是,同层中,如果一个订阅历史的开始时间迟于兄长节点,则无论该节点优先级是否比兄长节点高,都应该放置在其后面。考虑如下真实案例

  • 2021-12-23 10:55:48 获赠 包年普通会员
  • 2022-01-05 15:03:52 获赠 包年高级会员
  • 2023-01-06 17:28:25 购买 包月高级会员

得到如下订阅树

image-20230109155544059

而如果将包月高级会员购买时间向前提两天,到2023-01-04 17:28:25,就会有所不同

image-20230109155648026

这点稍加注意即可。

算法实现

节点定义

1
2
3
4
5
6
7
8
9
10
11
12
private open class Node(
open val parent: Node? = null,
open val children: MutableList<SubscriptionNode> = mutableListOf()
)

private data class SubscriptionNode(
override val parent: Node,
override val children: MutableList<SubscriptionNode> = mutableListOf(),
val historyModel: SubscriptionHistoryModel,
var startTime: OffsetDateTime,
var endTime: OffsetDateTime
) : Node(parent, children)

构建树的入口

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
private fun constructTree(
historyList: List<SubscriptionHistoryModel>,
refundHistoryList: List<SubscriptionRefundHistoryModel>
): Node {
val root = Node()
historyList.sortedBy { it.createdAt }.forEach { history ->
val refundHistory = refundHistoryList.singleOrNull { it.orderId == history.orderId }
val duration = history.duration(refundHistory)
// 前段数据过期,直接清空所有,降低树的复杂度
if (root.children.lastOrNull()?.endTime?.isBefore(history.createdAt) == true) {
root.children.clear()
}
// 空树
if (root.children.isEmpty()) {
val newNode = SubscriptionNode(
parent = root,
historyModel = history,
startTime = history.createdAt!!,
endTime = history.createdAt!! + duration
)
root.children.add(newNode)
} else {
insertToTree(root, history, duration)
}
}
return root
}

构建树的主体方法

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
/**
* 输入:一个基节点、一个待插入树的订阅历史记录
* 逻辑:
* - 找出有效期和待插入记录重合的节点
* - 如果没有,则直接插入到最后一个
* - 如果有,判断目标节点和待插入记录的优先级
* - 如果目标节点优先级更高,则直接插入到最后一个
* - 如果待插入记录优先级更高,则在其子节点递归查找,直到找到符合插入条件的位置
* - 插入后,需要对弟弟节点和父节点的结束时间往后推
*/
private fun insertToTree(baseNode: Node, historyModel: SubscriptionHistoryModel, duration: TemporalAmount) {
val node = baseNode.children.singleOrNull {
it.startTime <= historyModel.createdAt && historyModel.createdAt!! < it.endTime
}
if (node != null && historyModel.isPriorTo(node)) {
if (node.children.isEmpty()) {
val newNode = SubscriptionNode(
parent = node,
historyModel = historyModel,
startTime = historyModel.createdAt!!,
endTime = historyModel.createdAt!! + duration
)
node.children.add(newNode)
// 将指定节点的弟弟节点的起始结束时间都向后推
// 将指定节点的父节点及父节点的弟弟节点的结束时间向后推
putOff(newNode, duration)
} else {
insertToTree(node, historyModel, duration)
}
} else {
val index = baseNode.children.indexOfFirst { historyModel.isPriorTo(it) }
if (index == -1) {
val anchorNode = baseNode.children.lastOrNull()
val startTime = if (anchorNode == null) historyModel.createdAt!! else latest(anchorNode.endTime, historyModel.createdAt!!)
val newNode = SubscriptionNode(
parent = baseNode,
historyModel = historyModel,
startTime = startTime,
endTime = startTime + duration
)
baseNode.children.add(newNode)
putOff(newNode, duration)
} else {
val startTime = if (index == 0) historyModel.createdAt!! else latest(baseNode.children[index - 1].endTime, historyModel.createdAt!!)
val newNode = SubscriptionNode(
historyModel = historyModel,
startTime = startTime,
endTime = startTime + duration,
parent = baseNode
)
baseNode.children.add(index, newNode)
putOff(newNode, duration)
}
}
}

计算订阅的持续时间(考虑退款)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private fun SubscriptionHistoryModel.duration(refundHistory: SubscriptionRefundHistoryModel? = null): TemporalAmount {
val monthCount = when (this.subscriptionType) {
SubscriptionType.YEAR -> 12
SubscriptionType.MONTH -> 1
SubscriptionType.QUARTER -> 3
else -> throw Exception("Never happen")
}
val historyDuration = DurationCalculator.calculate(monthCount, this.userId)
// 理论上可能出现过期后退款的情况
return if (refundHistory == null || this.createdAt!! + historyDuration < refundHistory.refundTime) {
historyDuration
} else {
Duration.between(this.createdAt, refundHistory.refundTime)
}
}

判断节点和订阅历史优先级

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private fun SubscriptionHistoryModel.isPriorTo(node: SubscriptionNode): Boolean {

fun MembershipLevel.toNumber() = when (this) {
MembershipLevel.STANDARD -> 1
MembershipLevel.PREMIUM -> 2
else -> throw Exception("Never happen")
}

val candidate = node.historyModel
// 等级高的优先;否则,时间不重叠时结束时间早的优先;时间重叠时付费的优先
return if (this.membershipLevel != candidate.membershipLevel) {
this.membershipLevel!!.toNumber() > candidate.membershipLevel!!.toNumber()
} else {
if (this.createdAt!!.isAfter(node.endTime)) {
false
} else {
this.sourceType == SubscriptionSourceType.PAID && candidate.sourceType != SubscriptionSourceType.PAID
}
}
}

总结

该方法的主要有点是可根据历史直接计算出指定时刻的生效订阅,免去了中间状态的记录。将来如果有更复杂的订阅需求,也十分方便扩展——只需修改优先级算法。

留言

2023-01-09

⬆︎TOP