订阅商品
本篇文档介绍 游戏
方如何接入订阅商品生命周期通知。由于订阅商品的生命周期、支付逻辑与一次性购买商品相比区别都很大所以需要对订阅商品有一定地了解,然后才能更好的根据该文档接入 OmniSDK
订阅商品功能。
订阅介绍
订阅是指用户可以在指定的时间段内享有某组特定权益,例如周卡、月卡、季卡、年卡,会员卡等。海外
OmniSDK
计划提供Google Play Billing
订阅商品功能和Apple Store IAP
订阅商品功能,目前只接入了Google Play Billing
订阅商品功能。Google Play Billing
订阅商品又可分为自动续订
和非自动续订
两种类型(预付款类型),目前OmniSDK
只接入了自动续订
类型。自动续订
商品的特点是首次购买时需要玩家在应用内选择商品并完成付款,以后每一期的续订购买不需要玩家动手付款,Google Play Store
会自动完成扣款。Google Play Billing
订阅商品的生命周期和管理方式复杂多样,OmniSDK
目前只接入并实现了以下自动续订类型商品售卖功能。功能 首期购买订阅 免费试用 自动续订 订阅退款 宽限期 订阅过期 取消自动续订 恢复自动续订 暂停自动续订 价格变更 订阅升降级 变更订阅 优惠 延长订阅 支持 是 是 是 是 是 是 是 是 是 否 否 否 否 否 参考:
实现逻辑
OmniSDK
根据 Gooogle Play Billing
和 Apple Store IAP
订阅商品的生命周期抽象出更为简单易懂地订阅生命周期方便 游戏
快速简单的接入订阅商品支付功能。
游戏
方需要关心的是:玩家是否购买订阅成功
玩家是否购买下一期订阅成功
玩家本期的订阅什么时间到期
玩家是否退款订阅了
,而不需要关心背后的具体原因与流程。当用户完成付款/退款、订阅过期时间有变化、订阅过期时,OmniSDK
服务端会以 HTTPS POST
请求方式将相应的订阅消息以 application/json
格式的报文发送给 游戏
方。
FAQ
OmniSDK
服务端什么时候会通知游戏
订阅消息?- 订阅成功:用户首期购买订阅商品成功或者用户重新订阅了一个已经过期的订阅。
- 续订成功:即
Google Play Store
完成了一次自动扣款,玩家应该拥有新一期的订阅权限。 - 订阅进入宽限期:由于用户的付款方式存在问题导致
Google Play Store
不能完成自动扣款续订,或者用户主动暂停了自动续订,此时订阅会进入宽限期,宽限期一般为3天或者7天。 开发者可以在Google Play Console
创建订阅商品时进行设置。需要注意的是订阅宽限期期间用于仍然享有订阅权益。 - 订阅过期:当用户主动取消了自动续订或者用户的付款方式存在问题导致
Google Play Store
不能完成自动扣款时,最近一起的订阅到达过期时间时。 - 订阅退款:订阅退款实际上是撤销,为了方便理解,
OmniSDK
将其设计为了退款。当玩家申请退款成功以后或者开发者通过API
调用Google Play Developer API
的撤销接口成功以后,OmniSDK
会通知游戏
订阅退款消息。 要注意的是该退款通知要区别于 应用内支退款通知。
什么时候
OmniSDK
和游戏
方需要生成新的订单?用户的每一次付款成功
OmniSDK
和游戏
方都需要同时生成新的订单,即订阅成功
和续订订成功
时。
订阅消息
- Message
属性 | 类型 | 最大长度 | 说明 |
---|---|---|---|
messageId | string | 32 | 消息唯一ID,重复的订阅消息messageId不会变化 |
evenType | string | 16 | 事件类型:NEW-首期购买订阅成功;GRACE-订阅进入了宽限期阶段;RENEW-续订成功;EXPIRE-订阅过期;REFUND-订阅退款成功 |
eventTimeMillis | string | 13 | 事件发生时间:毫秒级时间戳 |
signature | string | 64 | 对 Order 内容进行 HmacSHA256 的签名后得到的16进制字符串,用于消息通知的安全验证 |
subType | string | 32 | 事件子类型: OUT_OF_APP-应用外事件 |
version | string | 16 | 消息版本号,目前固定为 v1 |
order | json | - | 订单详情 |
EventType
- NEW:首期购买订阅成功,
Order
中的expireTime
代表本期期结束时间。当SubType
为空时证明订阅购买是从游戏
内发起的,此时游戏
方和OmniSDK
方均已经生成过订单,此时不需要再生成新的订单。 当SubType
为OUT_OF_APP
时,证明订阅是从应用外(Google Play Store
)发起的,此时游戏
方和OmniSDK
都需要生成新的订单。 当freeTrail
为true
时,证明本期订阅为免费试用,用户实际付款金额为0,expireTime
为免费试用到期时间。游戏
方需要授予玩家订阅权益。游戏
方 需要标记该笔订单的实际支付金额为0,以便于统计、对账。 - GRACE:订阅进入宽限期,玩家让然应该享有订阅权益,
Order
中的expireTime
代表宽限期结束时间。 - RENEW:续订成功,
Order
中的expireTime
代表宽限期结束时间。游戏
方和OmniSDK
需要同时生成新的订单,并将上一期订阅订单标记为过期,避免用户享有同一订阅商品的多重权益。 - EXPIRE:订阅过期,
游戏
方需要停止用户对当前订阅的权益。 - REFUND:退款,
游戏
方需要停止用户对当前订阅的权益。同时可以根据用户已经享有订阅权益的时间酌情处理,例如收回部分道具。
- NEW:首期购买订阅成功,
SubType
- 空:空子类型,根据
EventType
进行逻辑处理即可。 - OUT_OF_APP:事件发生在应用外,目前只有一种情况,即用户订阅过期以后又在
Google Play Store
进行了重新订阅付款。
- 空:空子类型,根据
Signature
订阅消息中的签名算法不同于 安全性 中的签名算法,但是使用相同的签名密钥。
- 假设
signKey
签名密钥为:f7d48ee9a2a44743847e4c110bdac234 - 通知消息的HTTP请求体为:
{"evenType":"RENEW","eventTimeMillis":"1698650890673","messageId":"051dd7903d80f422256dba9d370be94c","order":{"appId":"1232","environment":"sandbox","expireTime":"1698651484104","gameOrderId":"","gameUserId":"123456","linkedGameOrderId":"mock-11ef10ac-6544-4633-8ba6-0b303d73bb51","linkedOrderId":"1718890029847326720","orderId":"1718892545502785536","originGameOrderId":"sdk_8b2ff1a67ba846e9848aabc31bffa26c","originOrderId":"1718887467567980544","productId":"com.sy.global.azhx.dytest05","provider":"google","providerOrderId":"GPA.3308-4634-1924-53758..1","state":"ACTIVE","userId":"1705162457208758272"},"signature":"b21c2819dd0739aa8ea4f57c899e16290d6b8b0236f5f62d9bef800ab7f312c7","subType":"","version":"v1"}
- 从请求体中提取出
order
{"appId":"1232","environment":"sandbox","expireTime":"1698651484104","gameOrderId":"","gameUserId":"123456","linkedGameOrderId":"mock-11ef10ac-6544-4633-8ba6-0b303d73bb51","linkedOrderId":"1718890029847326720","orderId":"1718892545502785536","originGameOrderId":"sdk_8b2ff1a67ba846e9848aabc31bffa26c","originOrderId":"1718887467567980544","productId":"com.sy.global.azhx.dytest05","provider":"google","providerOrderId":"GPA.3308-4634-1924-53758..1","state":"ACTIVE","userId":"1705162457208758272"}
- 对提取出的order内容进行HmacSHA256签名计算,并将计算结果由字节转换为十六进制字符串,最终得到40位小写signature
String signature = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, signKey).hmacHex(order);
// b21c2819dd0739aa8ea4f57c899e16290d6b8b0236f5f62d9bef800ab7f312c7
- Order
属性 | 类型 | 最大长度 | 说明 |
---|---|---|---|
orderId | string | 20 | OmniSDK 本期订阅订单号 |
originOrderId | string | 20 | 首期订阅 OmniSDK 订单号 |
linkedOrderId | string | 20 | 上一期订阅 OmniSDK 订单号 |
providerOrderId | string | 64 | 支付服务商本期订阅订单号(Goolge Play Billing 订单号 / Apple IAP 订单号) |
gameOrderId | string | 64 | 游戏 本期订阅订单号 |
originGameOrderId | string | 64 | 游戏 首期订阅订单号 |
linkedGameOrderId | string | 64 | 游戏 上一期订阅订单号 |
appId | string | 16 | OmniSDK 分配给 游戏 的应用 ID |
provider | string | 16 | 支付服务提供商:google、apple |
userId | string | 32 | OmniSDK 用户唯一ID,即登录OmniSDK以后获取到的 uid |
gameUserId | string | 64 | 游戏 方唯一用户ID |
productId | string | 128 | 商品ID |
state | string | 16 | 订阅订单状态:ACTIVE-活跃,GRACE-宽限期,EXPIRE-过期,REFUND-已退款 |
expireTime | string | 13 | 订阅过期时间:毫秒级时间戳 |
environment | string | 16 | 订阅支付环境:sandbox-沙盒环境,production-正式环境 |
freeTrail | string | 4 | 是否是免费试用:true-是,false-否 |
- State:
- ACTIVE:活跃状态,用户应当享有订阅权益。
- GRACE:宽限期,用户应当享有订阅权益。
- EXPIRE:订阅过期,用户不应当享有订阅权益。
- REFUND:用户已退款,用户不应当享有订阅权益。
响应通知
- Response
属性 | 类型 | 说明 |
---|---|---|
code | int | 响应状态码:0-成功,其它-失败 |
msg | string | 提示信息 |
order | json | 游戏 方订单信息 |
- Order
属性 | 类型 | 说明 |
---|---|---|
gameOrderId | string | 游戏 方产生的唯一订单号 |
重试策略
游戏
方接收到消息以后,返回HTTP Status Code 200
,并且Response code 0
以后,OmniSDK
服务将视为消息处理成功不再进行重试通知。OmniSDK
服务器通知游戏
服务器订阅消息的重试策略由于
OmniSDK
目前只接入了 Google Play Billing 订阅商品,Google Play Billing 订阅消息是通过 Google Cloud Pub/Sub 进行消息通知的。 Pub/Sub 有一套完善成熟的重试策略,所以 OmniSDK 直接依赖 Pub/Sub 的重试策略。重试策略为指数退避重试策略,重试的大概时间间隔为: 10s、20s、40s、80、160s、320s、600s、600s ... 最大重试间隔约为600s。重试总时间约为 7 天。
消息去重
- 可以根据
MessageId
对消息进行去重。 - 可以根据
OrderId
和EventType
根据游戏
自身内部逻辑对消息从逻辑上进行去重。例如同一个订单只可能发生一次购买成功事件;同一个订单 只可能发生一次退款成功事件;
幂等性
对于相同MessageId的消息,游戏
方处理成功以后再次接收到该消息总是应该回复 HTTP Status Code 200
, Response code 0
,并且响应的 gameOrderId
不变。
接入流程
订阅商品接入首先需要在 Google Play Console 和 OmniSDK Console 创建订阅商品,还需要在 Google Cloud Console 创建创建 Pub/Sub 主题、订阅, 并在 Google Play Console 配置创建好的 Pub/Sub 主题以确保当前 app 的订阅商品支付消息能够进入创建的 Pub/Sub 。该部分工作需要由运营人员或者开发人员提前完成。
- 由
游戏
方提供订阅商品信息,由运营人员配置到OmniSDK Console
和Google Play Console
。 游戏
方提供订阅消息通知接收地址,由运营人员或者OmniSDK
人员配置到OmniSDK Console
。OmniSDK
颁发应用ID
、签名密钥
给游戏
方。注意:应用ID即接入OmniSDK
时由OmniSDK
分配的应用ID
,签名密钥
即 接收支付结果通知 中进行 安全验证 的secretKey
。但是签名验证不同于接收支付结果通知
中的签名验证- 接入订阅消息通知接口
消息通知
NEW
当 subType
为空时,证明是 游戏
内发起的首期订阅,游戏
需要验证该订单是否存在,商品信息、用户信息是否一致。此时的 orderId、originOrderId、linkedOrderId
相同均为本期订阅 OmniSDK
订单号;gameOrderId、originGameOrderId、linkedGameOrderId
也相同,均为应用内创建订单时 游戏
生成的唯一订单号。
当 subType
为 OUT_OF_APP
时证明是用户在 Google Play Store
重新购买了已经过期的订阅,此时消息中的 gameOrderId
为空,
游戏
需要创建新的订单并返回 gameOrderId
,消息中的 orderId
为 OmniSDK
生成的新订单号,originOrderId
为过期订阅的首期订单号,linkedOrderId
为过期订阅的最后一期订单号。消息中的 linkedGameOrderId、linkedGameOrderId
分别为已过期订阅的
首期 游戏
订单号和最后一期订单号。
当 freeTrail
为 true
时,证明本期订阅为免费试用,用户实际付款金额为0,expireTime
为免费试用到期时间。游戏
方需要授予玩家订阅权益。游戏
方
需要标记该笔订单的实际支付金额为0,以便于统计、对账。
- Request
- Response
{
"evenType": "NEW",
"eventTimeMillis": "1705026799957",
"messageId": "499d3254d60d4362528d230488ba7b1c",
"order": {
"appId": "1232",
"environment": "sandbox",
"expireTime": "1705026973972",
"freeTrail": "true",
"gameOrderId": "sdk_79fd4d29b66244169f67c475697db1b1",
"gameUserId": "123456",
"linkedGameOrderId": "sdk_79fd4d29b66244169f67c475697db1b1",
"linkedOrderId": "1745635002785374208",
"orderId": "1745635002785374208",
"originGameOrderId": "sdk_79fd4d29b66244169f67c475697db1b1",
"originOrderId": "1745635002785374208",
"productId": "com.sy.global.azhx.dytest.free.01",
"provider": "google",
"providerOrderId": "GPA.3329-1838-7210-85440",
"state": "ACTIVE",
"userId": "1745268683472805888"
},
"signature": "aa6263add74400a219ebdc8448510e86bf129ea93d4c5bd4de1ee342e4f73a2d",
"subType": "EMPTY",
"version": "v1"
}
{
"code": 0,
"msg": "success",
"order": {
"gameOrderId": "980-1009800169398562200"
}
}
GRACE
收到该消息以后 游戏
需要更新本期订阅 expireTime
以延长用户享有订阅权益的时间。到达 expireTime
如果用户仍然没有续订成功
OmniSDK
会通知 游戏
EXPIRE
消息。如果到达 expireTime
之前完成了自动续订,则 OmniSDK
会通知 游戏
RENEW
消息。该消息中的 orderId
为本期订阅 OmniSDK
订单号,linkedOrderId
为上一期订阅 OmniSDK
订单号,originOrderId
为首期订阅订单号。gameOrderId、originGameOrderId、
originGameOrderId
分别为订阅的本期、首期、上一期 游戏
订单号。目前 GRACE
消息中的 subType
总是为空。
- Request
- Response
{
"evenType": "GRACE",
"eventTimeMillis": "1695445312962",
"messageId": "8f7a3151c66b7dda44a05aa359e5629c",
"order": {
"appId": "1232",
"environment": "sandbox",
"expireTime": "1695445605716",
"freeTrail": "false",
"gameOrderId": "sdk_03b8087e5d304e7988cd0b73fa2049ad",
"gameUserId": "123456",
"linkedGameOrderId": "sdk_03b8087e5d304e7988cd0b73fa2049ad",
"linkedOrderId": "1705444812846706688",
"orderId": "1705444812846706688",
"originGameOrderId": "sdk_03b8087e5d304e7988cd0b73fa2049ad",
"originOrderId": "1705444812846706688",
"productId": "com.sy.global.azhx.dytest06",
"provider": "google",
"providerOrderId": "GPA.3305-8989-7158-38098",
"state": "GRACE",
"userId": "1705162457208758272"
},
"signature": "b21c2819dd0739aa8ea4f57c899e16290d6b8b0236f5f62d9bef800ab7f312c7",
"subType": "",
"version": "v1"
}
{
"code": 0,
"msg": "success"
}
RENEW
游戏
收到该消息以后需要创建新的订单,同时取消用户上一期订阅的权益,并返回 gameOrderId
,注意收到重复的消息通知请返回相同的 gameOrderId
。
该消息中的 orderId
为新一期订阅的 OmniSDK
订单号,originOrderId、linkedOrderId
分别为首期订阅和上一期订阅 OmniSDK
订单号。该消息中的 originGameOrderId、linkedOrderId
分别为首期和上一期订阅 游戏
订单号。目前该消息中 subType
总是为空。
- Request
- Response
{
"evenType": "RENEW",
"eventTimeMillis": "1695445962138",
"messageId": "81f97ca0e5774c7a28cceb54e2f1d894",
"order": {
"appId": "1232",
"environment": "sandbox",
"expireTime": "1695446561669",
"freeTrail": "false",
"gameOrderId": "",
"gameUserId": "123456",
"linkedGameOrderId": "sdk_03b8087e5d304e7988cd0b73fa2049ad",
"linkedOrderId": "1705444812846706688",
"orderId": "1705450101566255104",
"originGameOrderId": "sdk_03b8087e5d304e7988cd0b73fa2049ad",
"originOrderId": "1705444812846706688",
"productId": "com.sy.global.azhx.dytest06",
"provider": "google",
"providerOrderId": "GPA.3305-8989-7158-38098..0",
"state": "ACTIVE",
"userId": "1705162457208758272"
},
"signature": "b21c2819dd0739aa8ea4f57c899e16290d6b8b0236f5f62d9bef800ab7f312c7",
"subType": "",
"version": "v1"
}
{
"code": 0,
"msg": "success",
"order": {
"gameOrderId": "980-1009800169398562201"
}
}
EXPIRE
游戏
收到该消息后需要立即停止用户订阅权益,该消息中的 orderId、originOrderId、linkedOrderId
分别为 OmniSDK
本期、首期、上一期订阅订单号。
该消息中的 gameOrderId、originGameOrderId、linkedGameOrderId
分别为 游戏
本期、首期、上一期订阅订单号。目前该消息中 subType
总是为空。
- Request
- Response
{
"evenType": "EXPIRE",
"eventTimeMillis": "1695446472996",
"messageId": "a1d7657706f33765cfc1565391383736",
"order": {
"appId": "1232",
"environment": "sandbox",
"expireTime": "1695446472021",
"freeTrail": "false",
"gameOrderId": "mock-fdb1c6ed-dd24-4c70-9090-af76e3d7ed29",
"gameUserId": "123456",
"linkedGameOrderId": "sdk_03b8087e5d304e7988cd0b73fa2049ad",
"linkedOrderId": "1705444812846706688",
"orderId": "1705450101566255104",
"originGameOrderId": "sdk_03b8087e5d304e7988cd0b73fa2049ad",
"originOrderId": "1705444812846706688",
"productId": "com.sy.global.azhx.dytest06",
"provider": "google",
"providerOrderId": "GPA.3305-8989-7158-38098..0",
"state": "EXPIRE",
"userId": "1705162457208758272"
},
"signature": "b21c2819dd0739aa8ea4f57c899e16290d6b8b0236f5f62d9bef800ab7f312c7",
"subType": "",
"version": "v1"
}
{
"code": 0,
"msg": "success"
}
REFUND
订阅中的退款实际上是撤销(REVOKE
),为了方便理解 OmniSDK 将其设计为退款处理。订阅撤销以后相应的会发生退款,游戏
接收到该消息以后应该立即停止用户订阅权益,
并自行处理扣除道具和其它用户封禁逻辑。目前该消息中 subType
总是为空。
- Request
- Response
{
"evenType": "REFUND",
"eventTimeMillis": "1695446470620",
"messageId": "e54b6d7808195d557dcb8217b0c8eb86",
"order": {
"appId": "1232",
"environment": "sandbox",
"expireTime": "1695446472021",
"freeTrail": "false",
"gameOrderId": "mock-fdb1c6ed-dd24-4c70-9090-af76e3d7ed29",
"gameUserId": "123456",
"linkedGameOrderId": "sdk_03b8087e5d304e7988cd0b73fa2049ad",
"linkedOrderId": "1705444812846706688",
"orderId": "1705450101566255104",
"originGameOrderId": "sdk_03b8087e5d304e7988cd0b73fa2049ad",
"originOrderId": "1705444812846706688",
"productId": "com.sy.global.azhx.dytest06",
"provider": "google",
"providerOrderId": "GPA.3305-8989-7158-38098..0",
"state": "REFUND",
"userId": "1705162457208758272"
},
"signature": "b21c2819dd0739aa8ea4f57c899e16290d6b8b0236f5f62d9bef800ab7f312c7",
"subType": "",
"version": "v1"
}
{
"code": 0,
"msg": "success"
}