Apple Storekit2 服务器API升级 (Apple开源内购库Contributor) (已上线)
1. 背景/历程/收获/展望
今天2024/05/07(29号😭才补完文章) 终于把最后一个App的订阅升级完成,一共有5个活跃的App进行了升级。👏👏👏
-
背景: 在今年Q1季度 组内进行服务器新技术探索 主要有Storekit2订阅升级/服务,性能监控搭建hertzbeat/xxl-job分布式定时任务等等,自己挑选想做的okr, 我这边选择的是Storekit2订阅API升级,主要想提升自己在Apple订阅方面的知识点和了解公司现在整体的IOS支付订阅的流程,也能提升自身的支付订阅能力💪。
-
时间线: 2024/1/12--2024/5/7 中间经历了春节/清明节3天/五一劳动节5天/年假5天,导致我在Q1季度没有完成这个任务,后期加班加点的去赶进度(就为了在组内和leader面前留下一个好的印象)。
-
历程:
- Q1代码基本写完了,压力给的真大💣,放假时间多,我一个p5职称玩家 选择写个挑战难度的,还来催进度,日常App敏捷版本迭代问题本来就杂又多 没有那么多时间来写 我难道加班加点给你写吗?(擦 05/11 开始裁员 要求每天上班9个小时起步) 都说了春节占了一大部分原因 还跟我说这是客观原因,下个季度Q2直接转项目组了,轻松太多了 版本迭代时间长 没有那么多杂事,其他时间自由安排。
- 后面就差几个App的测试,主要是先对用户量较小的App进行开关打开测试/上线,再进行后续的大用户量App升级,整个的测试过程 基本都是我自己手测了一遍流程 以及db中各种数据的正确性,神策上报事件用户属性的正确性,没办法 出事故了只能你自己背锅(完成了绩效还是B,有个什么用呢) 后面自己测完没问题 叫测试帮我回归一下购买订阅流程。
- 中间踩坑了很多地方, 在鉴权过程理解算法/怎么使用花了很多时间,我们这边多个App/多个数据中心怎么处理配置调用,apple返回调用失败,请重试处理怎么处理,回调请求怎么转发等等。
-
收获:
-
体验下来, 太累了,对我来说重构老的Appstore V1版本接口比写业务有趣多了,看apple官方文档/网上技术博客/开源库参与,以及对apple订阅/商品购买深入理解,至少在Apple支付订阅能独当一面吧,这个收益成长比前几个季度高太多了,绩效什么的无所谓了,干点自己想干的事情,不想干的事 你交给我我也不想干,大不了走人呗💥。
-
第一个点的话,我们app的用户量没有达到一个瓶颈, 系统的很多问题暂时没有暴露出来,比如用户订单的丢失, 扣款成功后续服务器的操作失败怎么去补偿, 用户回调请求失败(续费/退款....)怎么保证数据最终一致性,链路请求太长,代码逻辑太多了,怎么监控每个步骤的状态等等,性能/高可用/稳定性/修改性这些都是需要考虑的, 但是我暂时也没有看出来这些点, 我自身暂时也改不动 先完成okr吧, 还有一点 用户退款操作申请就能退,没有提交上报用户使用记录,让apple判断是否需要退。
-
深入了解开源社区规则, 质疑/理解/成为/超越 (先从简单的入手吧)
-
在对接的过程中,找到了apple官方提供的订阅/购买库,两个库的star都不高 都是去年刚开始开源的项目,不断的去看源码和源码上对应的官方文档,不断的去修复代码里面的语法问题和文档错误,也是成功的混上了Contributor, 公司项目使用的是Java进行对接,我嫌它太麻烦了,我这边测试每个Api的时候使用python的库更加方便,排查问题也是直接跑python脚本。
-
开发过程中遇到很多问题(英语太重要了😭, 我基本都是依赖于谷歌翻译进行交流)比如jwt库版本太低/回调不知道是测试环境还是线上环境/只有transactionId 不知道是哪个app怎么调用api等等,在github库下面提issue 也能快速得到回复,中国时间下午,在us估计是凌晨几点,项目的负责人还在帮我回答问题/feedback反馈,下面是一些issue
-
下面是两个库的地址
-
-
-
-
-
未来展望: MQ优化 线程池异步处理大数据量业务 (上周已经完成了✅),继续学习 看看有没有什么优化的点。
2. StoreKit v2 知识点学习
v1版本 服务器Api 官网已经标识过时 developer.apple.com/documentati…
v2版本 服务器Api 升级 developer.apple.com/documentati…
StoreKit 2 新特性:
- 我这边只讲服务器端相关改变,首先接口全部替换,提供了很多新的接口,服务器主动请求的接口/apple回调接口重构。
- 下面是一个重点Plan升级和降级规则, 用户在appstore后台切换了订阅记录,苹果会告诉我们, 那我们怎么处理呢,这块逻辑花费了我一段时间去理解改造。
我们先了解一下内购主要分为哪些类型/商品/优惠类型
- Offer Type 优惠类型
-
推介促销优惠 (新用户 免费试用/首月半价/首年x折)
-
促销优惠 (老用户 首年/季度x折)
-
优惠代码 (免费使用)
-
内购商品一共有四种类型: 消耗型(重复购买)/非消耗型(一次性)/自动续期订阅/非续期订阅
3. 苹果服务器与开发者服务器之间的通信 V1版本
-
Validate status with receipts 验证接口
- 开发者服务端通过通过latestReceipt **
/verifyReceipt
接口验证收据 得到VerifyReceiptResponse 响应体
{
"source" : "Me",
"transactionToken" : "MIIr5AYJKoZIhvcNAQcCoIIr1TCCK9ECAQExDzANBglghkgBZQMEAgEFADCCGxoGCSqGSIb3DQEHAaCCGwsEghsHMYIbAzAKAgEIAgEBBAIWADAKAgEUAgEBBAIMADALAgEBAgEBBAMCAQAwCwIBCwIBAQQDAgEAMAsCAQ8CAQEEAwIBADALAgEQAgEBBAMCAQAwCwIBGQIBAQQDAgEDMAwCAQoCAQEEBBYCNCswDAIBDgIBAQQEAgIA/TANAgEDAgEBBAUMAzEwODANAgENAgEBBAUCAwKY2TANAgETAgEBBAUMAzEuMDAOAgEJAgEBBAYCBFAzMDIwGAIBBAIBAgQQ+/RDwoKS3RMo",
"strategyName" : "",
"storeType" : 1,
"appStoreDetail" : "{"currencyUnit":"USD","countryCode":"US","payAmount":4999}",
"productId" : "com.xxxx.free_trial_quarterly",
"transactionId" : "20000005xxxx",
"fromMissOut" : false,
"fromAds" : false,
"sandbox" : false
}
Post buy.itunes.apple.com/verifyRecei… 生产环境 已过时
Post sandbox.itunes.apple.com/verifyRecei… 沙盒环境 已过时
-
App store回调 V1 版本接口
-
v1 回调过来的json数据 (数据已脱敏)
{
"notification_type": "DID_RENEW", //通知类型 //V2 有删除有添加 细化了
"password": "8742fd44734646a99dcca0d536839d78",
"environment": "Sandbox", //环境 //V2 有
"auto_renew_product_id": "com.xxxx.yearly", //V2 有
"auto_renew_status": "true", //V2 有
"unified_receipt": { //统一收据
"status": 0, // 状态代码,0表示通知有效
"environment": "Sandbox", //沙盒环境 生产环境 //V2 有
"latest_receipt_info": [
{
"quantity": "1", //购买数量 //V2 有
"product_id": "com.xx.x.xx.yearly", //sku //V2 有
"transaction_id": "2000000xx", //交易id 一个订单一个交易id? //V2 有
"purchase_date": "2024-01-15 03:25:49 Etc/GMT", //V2 删除
"purchase_date_ms": "1705289149000", //购买时间戳 //V2 有 改名字了
"purchase_date_pst": "2024-01-14 19:25:49 America/Los_Angeles", //V2 删除
"original_purchase_date": "2024-01-09 10:02:13 Etc/GMT", //V2 删除
"original_purchase_date_ms": "1704794533000", //原始购买时间戳 //V2 有 改名字了
"original_purchase_date_pst": "2024-01-09 02:02:13 America/Los_Angeles",
"expires_date": "2024-01-15 04:25:49 Etc/GMT", //V2 删除
"expires_date_ms": "1705292749000", //订阅过期时间戳 //V2 有 改名字了
"expires_date_pst": "2024-01-14 20:25:49 America/Los_Angeles", //V2 删除
"web_order_line_item_id": "2000000048350292", // 跨设备购买事件的唯一标识符 //V2 有
"is_trial_period": "false", //是否是免费试用 //V2 没有免费试用标识 找到一个字段offerDiscountType
"is_in_intro_offer_period": "false", //是否是介绍型优惠 //V2 有
"original_transaction_id": "200000xxx", //原始交易id //V2 有
"in_app_ownership_type": "PURCHASED", //用户是产品的购买者还是可以通过家庭共享访问 //V2 有
"subscription_group_identifier": "20881467", //订阅所属的订阅组的标识符 //V2 有
"cancellation_date_ms":"1111", //用户退款时间 //V2 revocationData 用户退款时间
"offer_code_ref_name":"code" //积分兑换 code使用名字
}
],
"latest_receipt": "", //最新的 Base64 编码的应用收据 订阅状态表里面存储的receipt //V2 删除了
// 待续订信息
//V2 有 单独封装了jws
"pending_renewal_info": [
{
"auto_renew_status": "1",
"auto_renew_product_id": "com.xxx.yearly",
"product_id": "com.xxx.yearly",
"original_transaction_id": "200000xxx"
}
]
},
"bid": "com.xxxx", //bundleId //V2 有
"bvrs": "182",
"original_transaction_id": 20000xxx, //原始交易id //V2 有
"deprecation": "Mon, 5 Jun 2023 23:59:59 GMT"
}
3. 苹果服务器与开发者服务器之间的通信 V2版本
- 对于我们后端来说,Apple Server API V1 和 Apple Server API V2 都可以使用,与客户端是否升级到 StoreKit 2 没有关系。
3.1. V2 版本苹果 回调接口
3.1.1. 首先Appstore 后台设置回调地址url
3.2. V2回调过来的数据 applestore服务端返回的数据格式为JWS
3.2.1.JWS [(JSON Web Signature)]
- JWS 是在 Unsecured JWT 基础上,Header 部分声明签名算法,并添加 Signature 部分。主要目的是保证了数据在传输过程中不被修改,验证数据的完整性。
接下来 我们看下JWS的结构: Header + Payload + Signture (其他博客复制过来图片)
我这边把某个App的沙盒环境改成V2版本回调。购买了一个月份的plan ,下面第一个是首次购买, 第二个是5分钟后续订收到的回调 {"signedPayload":"Header.Payload.Signature"}
续订JWS
test_jws3="xxx.xxx.xxx"
首次购买JWS
test_jws2="xxx.xxx.xxx"
3.3. Header (x5c证书链)和 Signture 签名
- base64.decode(Header), 解析出来header里面的数据
// header: {'alg': "ES256", 'x5c':['服务器证书','中间证书','根证书']}
{
"alg": "ES256", // 签名算法 非对称加密 非对称加密是一个私钥一个公钥
// 证书链 验证顺序: 苹果根证书->x5c根证书, x5c根证书->中间证书, 中间证书->服务器证书
// 证书链最后一个证书为苹果签发,需要使用苹果CA证书验证。
"x5c": [
//服务器证书
"MIIEMDCCA7agAwIBAgIQfTlfd0fNvFWvzC1YIANsXjAKBggqhkjOPQQDAzB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTIzMDkxMjE5NTE1M1oXDTI1MTAxMTE5NTE1MlowgZIxQDA+BgNVBAMMN1Byb2QgRUNDIE1hYyBBcHAgU3RvcmUgYW5kIGlUdW5lcyBTdG9yZSBSZWNlaXB0IFNpZ25pbmcxLDAqBgNVBAsMI0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEFEYe/JqTqyQv/dtXkauDHCScV129FYRV/0xiB24nCQkzQf3asHJONR5r0RA0aLvJ432hy1SZMouvyfpm26jXSjggIIMIICBDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFD8vlCNR01DJmig97bB85c+lkGKZMHAGCCsGAQUFBwEBBGQwYjAtBggrBgEFBQcwAoYhaHR0cDovL2NlcnRzLmFwcGxlLmNvbS93d2RyZzYuZGVyMDEGCCsGAQUFBzABhiVodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLXd3ZHJnNjAyMIIBHgYDVR0gBIIBFTCCAREwggENBgoqhkiG92NkBQYBMIH+MIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDYGCCsGAQUFBwIBFipodHRwOi8vd3d3LmFwcGxlLmNvbS9jZXJ0aWZpY2F0ZWF1dGhvcml0eS8wHQYDVR0OBBYEFAMs8Pjs6VhWGQlzE2ZOE+GX4Oo/MA4GA1UdDwEB/wQEAwIHgDAQBgoqhkiG92NkBgsBBAIFADAKBggqhkjOPQQDAwNoADBlAjEA8yRNdskp506DFdPLghLLJwAv5J8hBGLaI8DExdcPX+aBKjjO8eUo9KpfpcNYUY5YAjAPXmMXEZL+Q02adrmmshNxz3NnKm+ouQwU7vBTn0LvlM7vps2YslVTamRYL4aSs5k=",
//中间证书
"MIIDFjCCApygAwIBAgIUIsGhRwp0c2nvU4YSycafPTjzbNcwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMjEwMzE3MjAzNzEwWhcNMzYwMzE5MDAwMDAwWjB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEbsQKC94PrlWmZXnXgtxzdVJL8T0SGYngDRGpngn3N6PT8JMEb7FDi4bBmPhCnZ3/sq6PF/cGcKXWsL5vOteRhyJ45x3ASP7cOB+aao90fcpxSv/EZFbniAbNgZGhIhpIo4H6MIH3MBIGA1UdEwEB/wQIMAYBAf8CAQAwHwYDVR0jBBgwFoAUu7DeoVgziJqkipnevr3rr9rLJKswRgYIKwYBBQUHAQEEOjA4MDYGCCsGAQUFBzABhipodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWFwcGxlcm9vdGNhZzMwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5hcHBsZS5jb20vYXBwbGVyb290Y2FnMy5jcmwwHQYDVR0OBBYEFD8vlCNR01DJmig97bB85c+lkGKZMA4GA1UdDwEB/wQEAwIBBjAQBgoqhkiG92NkBgIBBAIFADAKBggqhkjOPQQDAwNoADBlAjBAXhSq5IyKogMCPtw490BaB677CaEGJXufQB/EqZGd6CSjiCtOnuMTbXVXmxxcxfkCMQDTSPxarZXvNrkxU3TkUMI33yzvFVVRT4wxWJC994OsdcZ4+RGNsYDyR5gmdr0nDGg=",
//根证书
"MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtfTjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySrMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM6BgD56KyKA=="
]
}
- 证书下载 www.apple.com/certificate… 苹果根证书 验证公钥可信的证书
3.3.1. 验证证书链和验证签名拿到body数据 ()
其他博客复制过来的图片 画的特别好理解 (苹果根证书->x5c根证书, x5c根证书->中间证书, 中间证书->服务器证书)
3.3.2. 验证签名
- 使用 header 获取 alg =ES256 非对称加密算法以及 x5c 证书里面的公钥 用来解密 signture 签名 ,我理解是applestore那边使用私钥进行加密,我们使用公钥进行验证,只要不抛出异常 就是验证成功。
import jwt
from OpenSSL import crypto
# 获取服务器证书
alg = header.get("alg")
x5c = header.get("x5c")
server_cert = x5c[0]
# 将服务器证书转为X509证书对象
cert = "-----BEGIN CERTIFICATE-----\n" + server_cert + "\n-----END CERTIFICATE-----"
server_cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
# 从证书内解析出公钥
public_key = crypto.dump_publickey(crypto.FILETYPE_PEM, server_cert.get_pubkey()).decode("utf-8")
# 使用公钥对整个jws进行验签
decode_jws = jwt.decode(jws, public_key, algorithms=[alg])
- decode_jws 就是我们真正需要的json订单数据 (数据已脱敏)
{
"notificationType": "SUBSCRIBED", //通知的应用内购买事件的类型 订阅
"subtype": "INITIAL_BUY", // 通知类型的详细信息的字符串 首次购买
"notificationUUID": "b8d098fb-c9a6-42df-932d-b764d1416307", //通知的唯一标识符
"data": {
"appAppleId": xxx, //appAppleId
"bundleId": "com.xxx.xxx", //bundleId
"bundleVersion": "32",
"environment": "Sandbox", //沙盒环境
//签名交易信息 base64加密了
"signedTransactionInfo": "xxxx.xxxx.xxxx",
//签名续订信息 base64加密了
"signedRenewalInfo": "xxx.xxxx.xxx",
"status": 1
},
"version": "2.0", //通知的App Store服务器通知版本号的字符串
"signedDate": 1705317539551 //JSON Web签名数据进行签名的UNIX时间
}
- signedTransactionInfo和signedRenewalInfo 也是JWS格式,按照上面的逻辑重新走一遍拿到里面对应的数据。
3.4. 最后上面证书验证和数据解析逻辑讲完了,后面找到了一个apple开源的提供的api 库
Apple 提供的Java API库 github.com/apple/app-s…
今天官方库已经升级到2.1.0了 我这边项目里面还是一样使用2.0.0 暂时用不到2.1.0里面的特效
<dependency>
<groupId>com.apple.itunes.storekit</groupId>
<artifactId>app-store-server-library</artifactId>
<version>2.0.0</version>
</dependency>
Apple 提供的python api 库
下面是我一个app常用的数据查询模版
import base64
import io
import json
import os
from bisect import bisect_left, bisect_right, insort_left, insort_right, insort, bisect
from math import ceil, floor, pow, gcd, sqrt, log10, fabs, fmod, factorial, inf, pi, e
from heapq import heapify, heapreplace, heappush, heappop, heappushpop, nlargest, nsmallest
from collections import defaultdict, Counter, deque
from itertools import permutations, combinations, combinations_with_replacement, accumulate, count, groupby
from queue import PriorityQueue, Queue, LifoQueue
from functools import lru_cache
from typing import List
import sys
from appstoreserverlibrary.api_client import AppStoreServerAPIClient
from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType
from appstoreserverlibrary.models.Status import Status
from appstoreserverlibrary.models.TransactionHistoryRequest import ProductType, TransactionHistoryRequest, Order
from appstoreserverlibrary.models.Environment import Environment
from appstoreserverlibrary.signed_data_verifier import VerificationException, SignedDataVerifier
sys.setrecursionlimit(10001000)
# -*- coding: utf-8 -*-
# @Author : hakusai
# @Time : 2024/01/15 14:28
def read_data_from_binary_file(path: str) -> str:
full_path = os.path.join(path)
with open(full_path, mode='rb') as test_file:
return test_file.read()
def decode_base64_data(encode_data):
"""
对数据进行base64解码
:param encode_data:
:return:
"""
if not (encode_data.endswith("=") or encode_data.endswith("==")):
encode_data += "=="
decode_data = json.loads(base64.b64decode(encode_data))
return decode_data
if __name__ == '__main__':
enable_online_checks = True
bundle_id = "com.xxx.xxxx"
environment = Environment.PRODUCTION
signed_data_verifier = SignedDataVerifier([read_data_from_binary_file('./AppleRootCA-G3_v2.cer')], enable_online_checks, environment, bundle_id)
originalTransactionId = "1210000008073426"
try:
signing_key = read_data_from_binary_file('./AuthKey_xxx.p8')
print(signing_key)
client = AppStoreServerAPIClient(signing_key, 'xxx', 'xxxxx',bundle_id, environment)
# 查询用户订阅项目状态 originalTransactionId
data = client.get_all_subscription_statuses(
originalTransactionId, [Status.EXPIRED, Status.ACTIVE, Status.REVOKED, Status.BILLING_GRACE_PERIOD,Status.BILLING_RETRY]
)
tt=client.get_transaction_info(originalTransactionId)
print("============== get_transaction_info")
print(signed_data_verifier.verify_and_decode_signed_transaction(tt.signedTransactionInfo))
print(data)
for i, d in enumerate(data.data):
print(len(d.lastTransactions))
print("============" + str(i))
print(d.lastTransactions[0].status)
print(d.lastTransactions[0].originalTransactionId)
print(signed_data_verifier.verify_and_decode_signed_transaction(d.lastTransactions[0].signedTransactionInfo))
print(signed_data_verifier.verify_and_decode_renewal_info(d.lastTransactions[0].signedRenewalInfo))
# 查询用户订单的收据
print("============== 订单")
data = client.look_up_order_id("xxxx")
print(data)
# 查询用户历史订单
request = TransactionHistoryRequest(
)
print("历史订单 start")
history_response = client.get_transaction_history(originalTransactionId, None, request)
for s in history_response.signedTransactions:
print(signed_data_verifier.verify_and_decode_signed_transaction(s))
# 查询用户内购退款
# data = client.get_refund_history(originalTransactionId, None)
# print(data)
except VerificationException as e:
print(e)
3.5. Payload 细讲每个字段含义
3.5.1. Base64.decode(payload) ,解析base64
- 如果我们只是想获取交易中的具体参数,你直接base64 Decode Payload参数就行了,但是如果你需要验证签名,防止别人修改你的数据,则必须使用到 Header, Signture
(数据已脱敏)
{
"notificationType": "SUBSCRIBED", //通知的应用内购买事件的类型
"subtype": "INITIAL_BUY", // 通知类型的详细信息的字符串
"notificationUUID": "b8d098fb-c9a6-42df-932d-b764d1416307", //通知的唯一标识符
"data": {
"appAppleId": xxxx, //appAppleId
"bundleId": "com.xxx.xxx", //bundleId 应用捆绑的id
"bundleVersion": "32",
"environment": "Sandbox", //沙盒环境
//签名交易信息 base64加密了
"signedTransactionInfo": "xxx.xxxxx.xxxx",
//签名续订信息 base64加密了
"signedRenewalInfo": "xxxx.xxx.xxxx",
"status": 1
},
"version": "2.0", //通知的App Store服务器通知版本号的字符串
"signedDate": 1705317539551 //JSON Web签名数据进行签名的UNIX时间
}
3.5.1.1. notificationType+subtype
- 回调接口数据里面的通知类型,原来v1版本只有notificationType来识别,在storekit2中notificationType+subtype 配合细化通知类型。
SUBSCRIBED 如果subtype是INITIAL_BUY(首次购买),则用户首次通过“家人共享”购买或接收了对订阅的访问权限。如果是RESUBSCRIBE(重新购买/重新购买同一个组内的plan),则用户通过家庭共享重新订阅或接收了对同一订阅或同一订阅组内的另一个订阅的访问权限。
CONSUMPTION_REQUEST 一种通知类型,表明客户发起了应用内消费品(大师课/肤质检测次数购买)购买的退款请求
DID_RENEW 一种通知类型,与其一起subtype指示订阅已成功续订。如果subtype是BILLING_RECOVERY,则之前续订失败的过期订阅已成功续订。如果子状态为空(续订),则活动订阅已成功自动续订新的交易周期。为客户提供对订阅内容或服务的访问权限。
OFFER_REDEEMED 一种通知类型,与其 一起subtype指示用户兑换了促销优惠或优惠代码。如果subtype是INITIAL_BUY,则用户兑换了首次购买的优惠。如果是RESUBSCRIBE,则用户兑换了重新订阅非活动订阅的优惠。如果是UPGRADE,则用户兑换了升级其有效订阅的优惠,该优惠立即生效。如果是DOWNGRADE,则用户兑换了降级其有效订阅的优惠,该优惠将在下一个续订日期生效。
REFUND 一种通知类型,指示 AppStore已成功对消费品应用内购买、非消费品应用内购买、自动续订订阅或非续订订阅的交易进行退款。
DID_CHANGE_RENEWAL_STATUS 一种通知类型,与其一起subtype指示用户对订阅续订状态进行了更改。如果subtype=AUTO_RENEW_ENABLED,则用户重新启用订阅自动续订。如果是AUTO_RENEW_DISABLED,则用户禁用了订阅自动续费,或者用户申请退款后App Store禁用了订阅自动续费。
DID_CHANGE_RENEWAL_PREF 一种通知类型,与其一起subtype指示用户对其订阅计划进行了更改。如果subtype是UPGRADE,则用户升级了他们的订阅。升级立即生效,开始新的计费周期,用户将收到上一周期未使用部分的按比例退款。如果subtype是DOWNGRADE,则用户降级了他们的订阅。降级将在下一个续订日期生效,并且不会影响当前有效的计划。如果subtype为空,则用户将其续订首选项更改回当前订阅,从而有效地取消降级。
4. DID_FAIL_TO_RENEW 一种通知类型,与其一起subtype指示订阅由于计费问题而未能续订。订阅进入计费重试期。如果subtype是GRACE_PERIOD,则在宽限期内继续提供服务。如果为空,则说明订阅不在宽限期内,您可以停止提供订阅服务。
6. EXPIRED 如果subtype是VOLUNTARY,则订阅在用户禁用订阅续订后过期。如果subtype是BILLING_RETRY,则订阅已过期,因为计费重试期已结束,但没有成功的计费事务。如果是PRICE_INCREASE,则订阅已过期,因为用户不同意需要用户同意的价格上涨。如果是PRODUCT_NOT_FOR_SALE,则订阅已过期,因为在订阅尝试续订时该产品不可购买。
7. GRACE_PERIOD_EXPIRED 一种通知类型,指示计费宽限期已结束而无需续订订阅,因此您可以关闭对服务或内容的访问。通知用户他们的账单信息可能存在问题。
10. PRICE_INCREASE 一种通知类型,与其一起subtype表示系统已通知用户自动续订订阅价格上涨。
如果涨价需要用户同意,是subtype指PENDING用户没有对涨价做出回应,或者ACCEPTED用户已经同意涨价。
如果涨价不需要用户同意,那subtype就是ACCEPTED。
12. REFUND_DECLINED 一种通知类型,指示 AppStore 拒绝了应用开发者使用以下任一方法发起的退款请求
13. REFUND_REVERSED 一种通知类型,表明 App Store 由于客户提出的争议而撤销了之前授予的退款。如果您的应用因相关退款而撤销了内容或服务,则需要恢复它们。
此通知类型可适用于任何应用内购买类型:消耗型、非消耗型、非续订订阅和自动续订订阅。对于自动续订订阅,当 App Store 撤销退款时,续订日期保持不变。
14. RENEWAL_EXTENDED 一种通知类型,指示 App Store 延长了特定订阅的订阅续订日期。您可以通过调用App Store Server API中的延长订阅续订日期或为所有活跃订阅者延长订阅续订日期来请求订阅续订日期延期。
15. RENEWAL_EXTENSION 一种通知类型,与其一起subtype表示 AppStore 正在尝试通过调用为所有活跃订阅者延长订阅续订日期 来延长您请求的订阅续订日期。如果subtype是SUMMARY,则 AppStore 已完成为所有符合条件的订阅者延长续订日期。
16. REVOKE指示用户有权通过“家人共享”进行应用内购买的通知类型不再可通过共享进行。当购买者对其购买禁用“家庭共享”、购买者(或家庭成员)离开家庭群组或购买者收到退款时,AppStore 会发送此通知。您的应用程序也会收到呼叫。家庭共享适用于非消耗性应用内购买和自动续订订阅。有关家庭共享的更多信息,请参阅在应用程序中支持家庭共享。
3.5.1.2. subtype
1. ACCEPTED 适用于PRICE_INCREASE. 如果价格上涨需要客户同意,则带有此通知的通知表明客户同意订阅价格上涨;如果价格上涨不需要客户同意,则表明系统通知他们价格上涨。
2. AUTO_RENEW_DISABLED 适用于DID_CHANGE_RENEWAL_STATUS. 此类通知表明用户禁用了订阅自动续订,或者 App Store 在用户申请退款后禁用了订阅自动续订。
2. AUTO_RENEW_ENABLED 适用于DID_CHANGE_RENEWAL_STATUS. 包含此信息的通知表明用户启用了订阅自动续订。
3. BILLING_RECOVERY 适用于DID_RENEW. 出现此通知表示之前未能续订的过期订阅已成功续订。
4. BILLING_RETRY 适用于EXPIRED. 此类通知表明订阅已过期,因为订阅在计费重试期结束之前未能续订。
5. DOWNGRADE 适用于DID_CHANGE_RENEWAL_PREF. 包含此信息的通知表明用户降级了其订阅或交叉分级为具有不同持续时间的订阅。降级将在下一个续订日期生效。
6. FAILURE 适用于RENEWAL_EXTENSION. 包含此信息的通知表明单个订阅的订阅续订日期延期失败。有关详细信息,请参阅中的对象。有关请求的信息,请参阅延长所有活跃订阅者的订阅续订日期。
7. GRACE_PERIOD 适用于DID_FAIL_TO_RENEW. 包含此信息的通知表明订阅由于计费问题而无法续订。在宽限期内继续提供对订阅的访问。
8. INITIAL_BUY 适用于SUBSCRIBED. 包含此内容的通知表示用户首次购买订阅或用户首次通过家人共享获得对订阅的访问权限。
9. PENDING 适用于PRICE_INCREASE. 出现此通知表示系统已通知用户订阅价格上涨,但用户尚未接受。
10. PRICE_INCREASE 适用于EXPIRED. 此类通知表明订阅已过期,因为用户不同意涨价。
11. PRODUCT_NOT_FOR_SALE 适用于EXPIRED. 包含此内容的通知表明订阅已过期,因为在订阅尝试续订时无法购买该产品。
12. RESUBSCRIBE 适用于SUBSCRIBED. 带有此信息的通知表明用户通过家庭共享重新订阅或接收了对同一订阅或同一订阅组内的另一个订阅的访问权限。
13. SUMMARY 适用于RENEWAL_EXTENSION. 此通知表明 App Store 服务器已完成您为所有符合条件的订阅者延长订阅续订日期的请求。有关摘要详细信息,请参阅中的对象。有关请求的信息,请参阅延长所有活跃订阅者的订阅续订日期。 notificationTypesubtypesummaryresponseBodyV2DecodedPayload
14. UPGRADE 适用于DID_CHANGE_RENEWAL_PREF. 包含此信息的通知表明用户已升级其订阅或交叉分级为具有相同持续时间的订阅。升级立即生效。
15. VOLUNTARY 适用于EXPIRED. 此类通知表明订阅在用户禁用订阅自动续订后已过期。
3.5.1.3. 启用订阅服务的事件(包括初始订阅、重新订阅和成功的自动续订)会导致以下通知:
3.5.1.4. 客户更改其订阅选项(包括升级、降级或取消)会导致以下通知:
3.5.1.5. 客户兑换促销优惠或订阅优惠代码会收到以下通知:
3.5.1.6. 计费事件(包括计费重试、进入和退出计费宽限期以及订阅到期)会导致以下通知:
3.5.2. data里面 Status 订单状态
1 ACTIVE = 1 //自动续订订阅已激活。
2 EXPIRED = 2 //自动续订订阅已过期。
3 BILLING_RETRY = 3 //自动续费订阅正处于计费重试期内。
4 BILLING_GRACE_PERIOD = 4 //自动续订订阅处于计费宽限期内。
5 REVOKED = 5 //自动续订订阅被撤销。
3.5.3. data里面的 signedTransactionInfo 信息解密
- developer.apple.com/documentati… 我这个是正常购买的解码,官方文档里面还有很多字段可以使用 (数据已脱敏)
{
"appAccountToken":"userId", //自定义uuid 和回调关联起来
"transactionId": "xxx", //交易的唯一标识符。
"originalTransactionId": "xxx", //purchas的原始交易标识符
"webOrderLineItemId": "xxx", // 跨设备的订阅购买事件的唯一标识符
"bundleId": "com.xx.xxx", //bundleId
"productId": "com.xx.xx.xx.xxx", //sku
"subscriptionGroupIdentifier": "xxx", //订阅所属的订阅组的标识符
"purchaseDate": 1705317520000, //购买时间戳
"originalPurchaseDate": 1705317527000, //原始购买时间戳
"expiresDate": 1705317820000, //订阅过期或续订的 UNIX 时间(以毫秒为单位)
"quantity": 1, //用户购买的消耗品的数量。
"type": "Auto-Renewable Subscription", //应用内购买的产品类型
"inAppOwnershipType": "PURCHASED",
"signedDate": 1705317532745, //签名时间
"environment": "Sandbox", //环境
"transactionReason": "PURCHASE",
"storefront": "USA",
"storefrontId": "143441",
"price": 9990, //价格 $9.99
"currency": "USD", //货币 (US dollar)
"offerType":1, //代表促销优惠类型的值。
"offerDiscountType":, //订阅优惠使用的付款模式,例如免费试用、现收现付或预付
}
- 旧的有 GMT(格林威治标准时间)、PST(太平洋标准时间)、Unix timestamp(Unix 时间戳),新的格式,只保留了 Unix 时间戳,并且字段做了更新 purchaseDate/originalPurchaseDate/originalPurchaseDate。
- Type 购买的产品类型
Auto-Renewable Subscription //自动续订订阅。
Non-Consumable //非消耗性应用内购买。
Consumable //消耗性应用内购买。
Non-Renewing Subscription //不可续订的订阅。
- offerType 促销优惠类型
1 INTRODUCTORY_OFFER //介绍性优惠。
2 PROMOTIONAL_OFFER //促销优惠。
3 SUBSCRIPTION_OFFER_CODE //带有订阅优惠代码的优惠。
- appAccountToken 您在购买时创建的 UUID,用于将交易与您自己的服务上的客户关联起来。如果您的应用程序不提供,则此字符串为空。这个可以和我们的userId关联起来
- offerDiscountType 订阅优惠使用的付款模式
FREE_TRIAL // 一种产品折扣的支付方式,表示免费试用。
PAY_AS_YOU_GO //一种产品折扣的支付方式,可在单个或多个计费周期内计费。
PAY_UP_FRONT //预付产品折扣的一种支付方式。
3.5.4. data里面的 signedRenewalInfo 自动续订订阅的续订信息的解码
- developer.apple.com/documentati… 我这个是正常购买的解码,官方文档里面还有很多字段可以使用 (数据已脱敏)
{
"originalTransactionId": "xxx", //原始交易id
"autoRenewProductId": "com.xxx.xx.xx", //在下一个计费周期续订的产品的产品标识符
"productId": "com.xx.xxx.xx.xx", //应用内购买的产品标识符。
"autoRenewStatus": 1, //自动续订订阅的续订状态
"signedDate": 1705317532745, //App Store 签署 JSON Web 签名 (JWS) 数据的 UNIX 时间(以毫秒为单位)
"environment": "Sandbox", //环境
"recentSubscriptionStartDate": 1705317520000, //最近自动续订订阅开始日期
"renewalDate": 1705317820000, //下次续约日期
"offerType":1, //代表促销优惠类型的值。
}
- autoRenewStatus 自动续订状态
0 //自动续订已关闭。客户已关闭订阅的自动续订,并且在当前订阅期结束后不会续订。
1 //自动续订已开启。订阅将在当前订阅期结束时续订。
4. 新的API接口提供
- 对于我们原来v1版本只有通过receipt和密钥请求,拿到用户订单的全部信息,现在不一样了,每个业务拆分的更加详细进行获取。
4.1. URL
- 线上环境的 URL: api.storekit.itunes.apple.com/
- 沙盒环境测试URL: api.storekit-sandbox.itunes.apple.com/
4.2. 查询用户所有订阅项目状态
from appstoreserverlibrary.api_client import AppStoreServerAPIClient
from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType
from appstoreserverlibrary.models.Status import Status
from appstoreserverlibrary.models.TransactionHistoryRequest import ProductType, TransactionHistoryRequest, Order
from appstoreserverlibrary.models.Environment import Environment
from appstoreserverlibrary.signed_data_verifier import VerificationException, SignedDataVerifier
enable_online_checks = True
bundle_id = "com.xxxx.xxx"
signing_key = read_data_from_binary_file('./xxx.p8')
client = AppStoreServerAPIClient(signing_key, 'xxx', 'xxx-xxx', "com.xxx.xxx", Environment.PRODUCTION)
# 2.3.2 查询用户订阅项目状态 originalTransactionId
data = client.get_all_subscription_statuses(
originalTransactionId, [Status.EXPIRED, Status.ACTIVE, Status.REVOKED, Status.BILLING_GRACE_PERIOD]
)
print(data)
4.3. 鉴权JWS (Authorization: Bearer [signed token])
-
JWS 由三部分组成 base64(header) + '.' + base64(payload) + '.' + sign( Base64(header) + "." + Base64(payload) )
-
header:主要声明了 JWT 的签名算法;
4.3.1.1. 生成密钥 ID(kid)
4.3.1.2. 生成 Issuer(iss)
4.3.1.3. 下载并保存密钥文件
- App Store Connect
4.4. 查询用户订单的收据
{
"alg": "xx", //App Store Server API 的所有JWT都必须使用 非对称 ES256 加密进行签名。
"kid": "xx",//私钥ID,值来自 App Store Connect
"typ": "JWT" //默认值:JWT
}
- payload:主要承载了各种声明并传递明文数据;
{
"iss": "xxx", //发卡机构ID,值来自 App Store Connect 的密钥页面
"iat": 1623085200, //发布令牌的时间
"exp": 1623086400, //令牌的到期时间
"aud": "xx-v1", //固定值:appstoreconnect-v1
"nonce": "xxx-x-11eb-xxx-xx", //UUID
"bid": "com.xx.xxx"
}
from appstoreserverlibrary.api_client import AppStoreServerAPIClient
from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType
from appstoreserverlibrary.models.Status import Status
from appstoreserverlibrary.models.TransactionHistoryRequest import ProductType, TransactionHistoryRequest, Order
from appstoreserverlibrary.models.Environment import Environment
from appstoreserverlibrary.signed_data_verifier import VerificationException, SignedDataVerifier
enable_online_checks = True
bundle_id = "com.xx.xx"
signing_key = read_data_from_binary_file('./AuthKey_xxxx.p8')
client = AppStoreServerAPIClient(signing_key, 'xxx', 'xx-xx-xx-xx-xxx', "com.xx.xx", Environment.PRODUCTION)
# 2.3.5 查询用户历史订单
data = client.look_up_order_id("W002182")
print(data)
4.5. 查询用户历史订单
from appstoreserverlibrary.api_client import AppStoreServerAPIClient
from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType
from appstoreserverlibrary.models.Status import Status
from appstoreserverlibrary.models.TransactionHistoryRequest import ProductType, TransactionHistoryRequest, Order
from appstoreserverlibrary.models.Environment import Environment
from appstoreserverlibrary.signed_data_verifier import VerificationException, SignedDataVerifier
enable_online_checks = True
bundle_id = "com.xx.xx"
signing_key = read_data_from_binary_file('./AuthKey_9RUG7X56YH.p8')
client = AppStoreServerAPIClient(signing_key, 'xxx', 'xxx-de71-xx-e053-xxx', "com.xx.xx", Environment.PRODUCTION)# 2.3.5 查询用户历史订单
originalTransactionId = "xx"
request = TransactionHistoryRequest()
history_response = client.get_transaction_history(originalTransactionId, None, request)
print(history_response)
4.6. 查询用户内购退款
- 如果我们服务器宕机了或者没有收到退款通知,那么我们是不知道用户是有没有进行退款的。虽然 StoreKit2 提供了一个获取交易记录的 API,但是如果通过该 API 来自己过滤退款的交易,不是一个最好的实现方式。所以 Apple 新提供了一个 API 可以查到这个用户的所有退款记录订单,只需要任意的一个 originalTransactionId。
from appstoreserverlibrary.api_client import AppStoreServerAPIClient
from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType
from appstoreserverlibrary.models.Status import Status
from appstoreserverlibrary.models.TransactionHistoryRequest import ProductType, TransactionHistoryRequest, Order
from appstoreserverlibrary.models.Environment import Environment
from appstoreserverlibrary.signed_data_verifier import VerificationException, SignedDataVerifier
enable_online_checks = True
bundle_id = "com.xxx.xxx"
signing_key = read_data_from_binary_file('./AuthKey_xxxx.p8')
client = AppStoreServerAPIClient(signing_key, 'xx', 'xx-xx-xx-xx-xxx', "com.xxxx.xxx", Environment.PRODUCTION)# 2.3.5 查询用户历史订单
originalTransactionId = "xxx"
# 查询用户内购退款
data = client.get_refund_history(originalTransactionId, None)
print(data)
4.7. 提交防欺诈信息 (目前没用上)
- 当用户申请退款时,回调接口通知自己的服务器,服务器这边可以拿用户的使用记录提交 拒绝用户退款请求,目前我这边的会员或者购买业务用户请求退款,收到退款请求都是不处理的,默认允许用户进行退款,可能它用了三个月,还能把三个月的钱全部退掉。
4.8. 延长用户订阅的时长 (目前没用上)
- 和google订阅提供的defer接口一样,最近正好看到了,业务场景免费帮用户加订阅的时长嘛。
6. 3.4 最后总结回调接口 V2改造 我需要变动哪些
- JWS数据格式 替代原来字符串json格式,回调接口的数据结构 和原来的基本不一致了,添加了很多新的字段 , 下划线都改成驼峰形式了 验证证书和签名保证数据回调的安全性,证书 签名验证apple github上面已经开源了一套Java库 github.com/apple/app-s…, 直接封装好了拿来用。
- userId和回调接口里面appAccountToken关联, 即使用户在客户端没有调用校验接口或者发放会员/调用失败,我们都可以通过回调接口里面的userId 直接给用户加上会员,但是IOS那边12系统以下使用storekit1 ,还需要兼容老版本没有appAccountToken场景 。( 订阅转移 userId 变化 )
- receipt 收据这个字段没有了,将通过receipt收据查询的接口全部替换为 通过originalTransactionId 查询用户订阅状态/获取所有历史账单新的接口
- 苹果后台只能设置一个回调接口 新写一个v2的接口 新的app可以直接对接,但是老app替换成新的回调接口 ,根据根据通知类型 和子类型 重写原来逻辑。
- 订阅优惠购买/优惠code码兑换/促销优惠逻辑,在新的结构里面有相对应的字段,这些逻辑需要重新整理。
- 恢复订阅
- 订阅转移 userId 变化
- 定时任务检测订阅
- 上报神策订单次数 首次购买时间.......
- 写一个开关 从用户量小到大的app进行升级
7. 剩下可以优化点
- appAccountToken (客户端购买时候上传userId 防止客户端校验失败,服务通过回调进行校验)
- 校验接口 需要加一个参数判断是否是沙盒环境的字段(现在是先查生产 生产失败再查沙盒)
- 续费事件/订阅升级降级 服务器回调校验订单,客户端本地订单感受不到服务器已经校验订阅,会重复调用接口进行校验
- apple 内部抛出异常重试错误码处理 GENERAL_INTERNAL_RETRYABLE(5000001L);
- apple 回调数据 证书链无效处理 INVALID_CHAIN。
- 回调地址现在是转发到其他数据中心,没有使用到apple回调数据异常重试机制。
- CONSUMPTION_REQUEST 回调类型 发送用户会员使用信息 防止用户退款100%成功。
- 订单重复问题 分布式锁 锁了个寂寞。
8. 文档/git 相关记录
Gitlab 分支记录
飞书文档 任务时间线(文档敏感信息太多 不展示了)
打麻将去了 最近被打烂了 太想进步了🍓
9. 参考
转载自:https://juejin.cn/post/7373944051294666778