Skip to content

Commit bd18d61

Browse files
author
xin
committed
添加文件
1 parent 0fdab2e commit bd18d61

File tree

4 files changed

+270
-0
lines changed

4 files changed

+270
-0
lines changed

.DS_Store

6 KB
Binary file not shown.

content/.DS_Store

6 KB
Binary file not shown.

content/posts/subscription.md

+203
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
+++
2+
title = '云存储订阅设计'
3+
date = 2024-03-16T12:44:03+08:00
4+
draft = false
5+
6+
+++
7+
8+
## 需求
9+
10+
1. 摄像头录像云存储套餐订阅,分月、年套餐,分基础版、高级版套餐,区别是最多可用设备数不同。
11+
2. google play、app store内购采用升降级套餐,stripe、paypal平台采用购买多个套餐,用户可选择内购或stripe、paypal购买。
12+
3. 全球有4大区(us, eu, ap, cn),各区订阅不互通。
13+
14+
## 领域建模
15+
16+
1. 套餐
17+
18+
```go
19+
// Plan 套餐
20+
type Plan struct {
21+
Id int // 套餐id
22+
Names map[string]string // 各个语言的名称
23+
Descriptions map[string]string // 各个语言的描述
24+
PeriodInDays int // 有效天数
25+
MaxDeviceCount int // 最多可用设备数
26+
Products []Product // 各支付平台产品配置
27+
}
28+
29+
// Product 支付平台产品配置
30+
type Product struct {
31+
Id string // 支付平台的产品id,google play、app store的product id,stripe的price id,paypal的plan id
32+
Platform Platform // 支付平台
33+
Prices map[string]Price // 各个地区的价格
34+
}
35+
36+
// Platform 支付平台
37+
type Platform string
38+
39+
const (
40+
PlatformGooglePlay Platform = "google_play"
41+
PlatformAppStore Platform = "app_store"
42+
PlatformStripe Platform = "stripe"
43+
PlatformPaypal Platform = "paypal"
44+
)
45+
46+
// Price 价格
47+
type Price struct {
48+
CurrencyCode string // 货币编码
49+
Value string // 数额
50+
}
51+
```
52+
53+
2. 订阅
54+
55+
```go
56+
// Subscription 维护订阅的生命周期
57+
type Subscription struct {
58+
Id string // 订阅id
59+
UserId string // 用户id
60+
PlanId string // 套餐id
61+
PendingPlanId string // 下一个周期变更的套餐id
62+
Platform Platform // 支付平台
63+
Status SubscriptionStatus // 订阅状态
64+
ExpiresAt time.Time // 过期时间
65+
PlatformSubscriptionId string // 支付平台的订阅id,google play的purchase token,app store的original transaction id,stripe、paypal的subscription id
66+
CreatedAt time.Time // 创建时间
67+
}
68+
69+
// SubscriptionStatus 订阅状态
70+
type SubscriptionStatus string
71+
72+
const (
73+
SubscriptionPending SubscriptionStatus = "pending" // 未完成
74+
SubscriptionActive SubscriptionStatus = "active" // 订阅中
75+
SubscriptionCanceled SubscriptionStatus = "canceled" // 已取消
76+
SubscriptionPaused SubscriptionStatus = "paused" // 已暂停
77+
SubscriptionExpired SubscriptionStatus = "expired" // 已过期
78+
)
79+
```
80+
81+
## 领域建模设计细节
82+
83+
1. 套餐Plan的domain model与read model的不同。前端是分平台请求套餐列表的,只关心当前语言的名称和描述、当前地区的价格、当前平台的product id,即read model为
84+
85+
```go
86+
type Plan struct {
87+
Id int // 套餐id
88+
Name string // 名称
89+
Description string // 描述
90+
PeriodInDays int // 有效天数
91+
MaxDeviceCount int // 最多可用设备数
92+
ProductId string // 支付平台的产品id
93+
}
94+
```
95+
96+
套餐Plan domain model需要根据客户端的language, countryCode,platform转换填充read model。
97+
98+
2. 订阅Subscription的id采用string,不采用自增主键int64,原因是支付平台的通知只有一个入口,而全球4个区的订阅不互通,需要维护订阅id到region的映射。根据这个映射,将通知路由转发到各个区处理,这就要求各个区创建的订阅id不能相同。
99+
100+
## 如何关联购买到用户订阅
101+
102+
1. 透传用户订阅id。
103+
104+
- google play:[`obfuscatedExternalAccountId`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setobfuscatedaccountid)透传用户订阅id。
105+
- app store:[`appAccountToken`](https://developer.apple.com/documentation/storekit/product/purchaseoption/3749440-appaccounttoken)透传用户订阅id。
106+
- stripe: [`subscriptionData.metaData["subscriptionId"]`](https://docs.stripe.com/api/checkout/sessions/create)透传用户订阅id。
107+
108+
- 风险点:`obfuscatedExternalAccountId``appAccountToken`本意是用于关联购买到用户账号,传用户账号id更加合适。google play使用它来检测不规则活动,例如许多设备在短时间内使用同一帐户进行购买。
109+
110+
2. app在购买成功后关联用户订阅
111+
112+
- google play:购买成功后的[回调](https://developer.android.com/google/play/billing/integrate)中,将[`purchaseToken`](https://developer.android.com/reference/com/android/billingclient/api/Purchase#getPurchaseToken())和用户订阅id提交给后台。官方例子[play-billing-samples](https://github.com/android/play-billing-samples)就是这么做的,不过关联的是用户。
113+
114+
- app store:购买成功后的[回调](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/offering_completing_and_restoring_in-app_purchases)中,将[`transactionIdentifier`](https://developer.apple.com/documentation/storekit/skpaymenttransaction)和用户订阅id提交给后台。官方课程[Manage in-app purchases on your server](https://developer.apple.com/videos/play/wwdc2021/10174)中提到Send the original transaction id and other relevant fields to your server。
115+
116+
- 风险点:拿到支付平台的订阅id的其他人也可以关联到他的订阅,出现可能性极低。
117+
118+
3. `Subscription.PlatformSubscriptionId`记录支付平台的订阅id,即google play的`purchase token`,app store的`original transaction id`,stripe、paypal的`subscription id`。支付平台的通知处理统一采用支付平台的订阅id查找`Subscription`,不采用透传的`subscriptionId`查找订阅。
119+
120+
4. google play在升降级或在订阅到期之前从应用中取消后又订阅,旧订阅会失效,并且新订阅会通过新的`purchaseToken`创建,`linkedPurchaseToken`表示用户升级、降级或重新订阅时所基于的旧购买交易的字段。业务需要更新`Subscription.PlatformSubscriptionId`为新的`purchaseToken`
121+
5. 如果后台配置了允许过期的订阅重新订阅,用户可以在 Google Play 商店中重新购买已到期的订阅。这是新的购买,全新的`purchaseToken`,后端会收到类型为 `SUBSCRIPTION_PURCHASED` 的通知,`linkedPurchaseToken`不会关联到原始的`purchaseToken`。建议配置成禁止重新订阅。
122+
123+
## 支付平台通知处理
124+
125+
主要是同步订阅(包括套餐id、订阅状态、过期时间、支付平台的订阅id),提供服务和停止提供服务。
126+
127+
### google play
128+
129+
https://developer.android.com/google/play/billing/lifecycle/subscriptions?hl=zh-cn#new-auto
130+
131+
先查询 [`purchases.subscriptionsv2.get`](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get?hl=zh-cn) 端点,获取包含最新订阅状态的[订阅资源](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2?hl=zh-cn)
132+
133+
| NotificationType | 说明 | 处理 |
134+
| ----------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
135+
| SUBSCRIPTION_PURCHASED | 购买订阅成功 | 确保subscriptionState为`SUBSCRIPTION_STATE_ACTIVE`,检查`purchaseToken`是否使用过,如果`linkedPurchaseToken`有值,撤销对应的订阅权益。提供服务,然后调用`acknowledgePurchase`确认发货。 |
136+
| SUBSCRIPTION_RENEWED | 续订成功 | 提供服务 |
137+
| SUBSCRIPTION_STATE_IN_GRACE_PERIOD | 宽限期 | 还能继续使用,延长过期时间 |
138+
| SUBSCRIPTION_STATE_ON_HOLD | 宽限期结束后进入保留期 | 停止提供服务 |
139+
| SUBSCRIPTION_RECOVERED | 恢复 | 提供服务 |
140+
| SUBSCRIPTION_CANCELED | 保留期结束没有付款,或者用户主动取消 | 如果expiryTime已经过去,撤销访问权限,否则可继续访问 |
141+
| SUBSCRIPTION_EXPIRED | 保留期期间用户主动取消 | 停止提供服务 |
142+
| SUBSCRIPTION_REVOKED | 撤销订阅,后端使用 `purchases.subscriptions.revoke` 撤消订阅或购买交易被退款。 | 停止提供服务 |
143+
| SUBSCRIPTION_DEFERRED | 延迟过期时间,后端使用`purchases.subscriptions.defer`延长结算日期 | 提供服务 |
144+
| SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED | 用户发起订阅暂停 | 不处理 |
145+
| SUBSCRIPTION_PAUSED | 暂停生效 | 停止提供服务 |
146+
| SUBSCRIPTION_RESTARTED | 在到期之前恢复订阅 | 提供服务 |
147+
148+
### app store
149+
150+
https://developer.apple.com/documentation/appstoreservernotifications/notificationtype
151+
152+
先JWT校验签名。
153+
154+
| notificationType | 说明 | 处理 |
155+
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------- |
156+
| CONSUMPTION_REQUEST | 用户发起退款 | 不处理 |
157+
| DID_CHANGE_RENEWAL_PREF | 升降级,subtype为UPGRADE、DOWNGRADE | 升降级 |
158+
| DID_CHANGE_RENEWAL_STATUS 且 subtype为AUTO_RENEW_ENABLED | 重新开启自动续费 | 提供服务 |
159+
| DID_CHANGE_RENEWAL_STATUS 且 subtype为AUTO_RENEW_DISABLED | 用户关闭自动续费,或用户发起退款app store关闭订阅自动续费 | 继续提供服务直到过期 |
160+
| DID_FAIL_TO_RENEW且subtype为GRACE_PERIOD | 宽限期 | 继续提供服务直到过期 |
161+
| DID_FAIL_TO_RENEW且subtype为空 | 续订失败 | 停止提供服务 |
162+
| DID_RENEW且subtype为BILLING_RECOVERY | 之前续订失败的过期订阅已成功续订 | 提供服务 |
163+
| DID_RENEW且subtype为空 | 续订成功 | 提供服务 |
164+
| EXPIRED | 订阅过期 | 停止提供服务 |
165+
| GRACE_PERIOD_EXPIRED | 宽限期结束,没有续订 | 停止提供服务 |
166+
| OFFER_REDEEMED | 用户兑换促销优惠或优惠码 | 不处理 |
167+
| PRICE_INCREASE | 价格变动 | 不处理 |
168+
| REFUND | 退款退款 | 停止提供服务 |
169+
| REFUND_DECLINED | App Store拒绝了应用开发者发起的退款请求 | 不处理 |
170+
| REFUND_REVERSED | 撤销退款 | 恢复提供服务 |
171+
| RENEWAL_EXTENDED | 延长续订日期 | 不处理 |
172+
| RENEWAL_EXTENSION | 申请延长续订日期 | 不处理 |
173+
| REVOKE | 关掉家庭共享,或离开家庭组,或退款 | 停止提供服务 |
174+
| SUBSCRIBED | 订阅了,或通过家庭共享首次获得订阅产品的访问权,重新订阅了,或订阅了同一订阅组内的另一个产品,或通过家庭共享重新获得订阅产品的访问权 | 提供服务 |
175+
| TEST | 测试 | 不处理 |
176+
177+
### stripe
178+
179+
https://docs.stripe.com/billing/subscriptions/overview
180+
181+
只订阅customer.subscription.updated事件
182+
183+
golang库提供了校验和反序列化的方法。
184+
185+
```go
186+
event, err := webhook.ConstructEvent(payload, header, secret)
187+
```
188+
189+
| subscription.status | 说明 | 处理 |
190+
| ------------------- | ------------------------------------------------------------ | ------------ |
191+
| active | 付款成功 | 提供服务 |
192+
| unpaid | 付款失败,所有重试都失败后 | 停止提供服务 |
193+
| canceled | 取消了,最终状态,无法再付款 | 停止提供服务 |
194+
| past_due | 付款失败,如果所有重试都失败,状态变成canceled或unpaid,后台配置 | 不处理 |
195+
| trialing | 试用 | 提供服务 |
196+
| incomplete | 付款未完成 | 不处理 |
197+
| incomplete_expired | 首笔付款超时未完成 | 不处理 |
198+
| paused | 试用期结束,用户没有设置付款方式 | 不处理 |
199+
200+
## 续订通知可靠性
201+
202+
可以启动一个定时器,在订阅过期时间之后一小段时间调用支付平台的查看订阅信息api,来更新业务的订阅。如果支付平台的续订通知准时到达并处理了,则取消这个定时器。
203+

0 commit comments

Comments
 (0)