不断学习 支付订阅相关知识点 🍌
1. 时间线/背景/历程/收获/展望
-
时间线: 2024/05/27--2024/05/30 也就花了3天时间 😊
-
背景/历程:
- 最近转新品组了,没有那么多杂事✌️,写完版本代码 其他时间都是自己的, 你不想一点优化的点实现,不然周报都写不出来了, 花点时间看看业务/代码里面哪些可以优化的,上次正好想看看Google Pay的相关逻辑, 正好这个机遇。
- 发现我们现在接口调用都是使用webclient调用原生API,鉴权的access_token/url参数拼接/webclient各种异常处理, 看起来好复杂呀, 上次正好刷到一篇文章使用的是SDK对接, 调研了一会发现我这边也可以优化成SDK形式(后续可修改性吧 相关接口对接 直接使用 model也不用重写 也不用考虑webclient层面异常,鉴权封装好了不用自己处理)。
-
收获:
- 突破舒适圈吧,调研文档/代码实现/单元测试/功能测试整个流程。
- 也让我对Google订阅文档更好熟悉/理解, 对业务逻辑也有自己的理解。
- Github找google-api库的过程, 在不同版本库中切换使用测试。
- 突然觉得我也能掌控住Google订阅了😘,(Apple/paypal 订阅都搞定了)。
-
展望: 升级google-api版本(v3-rev20240516-2.0.0)解决代码里面各种版本异常,Java的maven依赖确实有点难使用,不如go mod的一根毛。继续深耕Google 订阅相关知识点, 持续学习总结, 发掘业务中可优化的点。
- 比如对续订,退订,升降级操作的处理。
- 网络异常/接口异常/事务等等带来的丢单问题。
- callback回调保证数据一致性/幂等性。
- 分布式锁🔒处理(客户端调用/google回调/定时任务扫描)。
- AndroidPublisher 创建保证单例(synchronized锁 单例模式)。
- 客户端购买的时候透传用户id/订单号。
- 缺少服务端签名验证,第三方篡改数据⚠️。
- 促销和优惠活动 文档阅读/SDK源代码逻辑理解。
- 还有几点疑点:1. 一个订单对应一个purchaseToken嘛? 2. 如果订单购买完成 没有进行acknowledge 会怎么样(现在都是依赖客户端进行ack)?
Google Pay Api V2文档解析 0712 更新
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-androidpublisher</artifactId>
<version>v3-rev20240624-2.0.0</version>
</dependency>
升级为v2 业务需要 一个接口调用变成3个接口调用 为了去适配当前链路业务场景 。暂时也没看到v2有哪些新特性对业务有帮助的 升级意义感觉不大(重构原来的代码/测试过程太麻烦),可以集成v2相关订阅能力 没必要在我们现有的链路上进行更改
问题一 用户支付的价格
新的接口已经拿不到 用户支付的价格了 主要调用另外一个新接口 拉到该plan所有地区的价格 再过滤出用户时区对应的地区价格
- developers.google.com/android-pub…
- developers.google.com/android-pub…
- 这个接口通过basePlanId +offerId 能拿到这个plan免费试用几天
问题二 免费试用
下面这个用户续订了30次 返回的offerId 还是免费试用 判断不了是否是免费试用 可以通过startTime 再去拉一个接口 返回这个plan免费试用的天数 进行比较 判断是否在试用周期
问题三 推荐优惠
促销优惠 新的接口已经不返回的了 新接口返回了basePlanId+offerId 再调用另外一个新接口 拿到plan的原始数据信息 在去处理相关逻辑
1. Google Pay Api V1文档解析
- purchases.subscriptions 订阅商品api (已用)
- purchases.products 非订阅商品api (终身会员 已用)
- inappproducts 应用内商品操作api (获取商品信息 已用 可以废弃了 使用新推出的monetization)
- monetization.subscriptions api 管理订阅商品 (已用)
- purchases.voidedpurchases 退款api (暂时没用上)
1.1. 改造记录
1.1.1. google-api-services-androidpublisher 包引入
- google-api-services-androidpublisher 版本问题 异常出现 低版本没有monetization操作的api, 在2024年5月2.0.0版本有,但是升级之后会出现
java.lang.NoSuchMethodError: com.google.api.client.http.HttpTransport.isMtls()
方法不存在问题,估计是高版本不兼容低版本的api,没办法不升级了,需要修改代码去适配/测试 没时间 又麻烦,那就还是使用2020年版本的包吧(v3-rev20201022-1.30.10)😈。业务里面还有一些monetization模块的代码还是原生api,可惜这个2020低版本不支持(monetization 是2022年google才出的新特性)。
Play 管理中心内订阅方面的近期变更 2022 年 5 月
- github.com/googleapis/…
- Java 集成 Google API SDK
<properties>
<google.api.services.version>v3-rev20201022-1.30.10</google.api.services.version>
<google.api.services.version2>v3-rev20240516-2.0.0</google.api.services.version2>
</properties>
<!--google-pay-sdk-->
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-androidpublisher</artifactId>
<version>${google.api.services.version}</version>
</dependency>
1.2. 代理配置
- 刚开始只加publisher 加代理,结果一直调用不通,后台一直排查,网上查各种博客,最后在credential 加代理,再进行测试 通了通了😝😝😝。
List<String> scopes = Arrays.asList(
AndroidPublisherScopes.ANDROIDPUBLISHER,
"https://www.googleapis.com/auth/devstorage.read_only");
final String proxyHost = xxxxx;
final int proxyPort = xxxx;
final String type = xxxx;
Proxy proxy = new Proxy(Proxy.Type.valueOf(type),
new InetSocketAddress(proxyHost, proxyPort));
HttpTransport httpTransport = new NetHttpTransport.Builder()
.setProxy(proxy).trustCertificates(
GoogleUtils.getCertificateTrustStore()).build();
// credential 加代理
GoogleCredential credential = GoogleCredential.fromStream(
new ClassPathResource(googleCredientPath).getInputStream(),
httpTransport,
JacksonFactory.getDefaultInstance())
.createScoped(scopes);
// publisher 加代理
AndroidPublisher publisher = new AndroidPublisher.Builder(httpTransport,
new JacksonFactory(), credential).build();
1.2. Subscription 订阅型/续订
- 订阅商品相关的6个方法(get/defer/cancel/acknoledge/refund/revoke)
1.2.1. get 检查用户的订阅购买是否有效,并返回其过期时间。
google官方不推荐v1的get获取用户订阅信息了 我们系统还是用的v1 后续会进行升级吧
- 路径参数 (packageName/subscriptionId/token)
- packageName: Google配置的包应用名称。
- subscriptionId: Google配置的productId
- purchaseToken: 用户购买的凭证, 购买完成客户端拿到传给服务器(危险⚠️ 被接口抓取拦截)。
原生api webclient调用 代码
private static final String GET_PURCHASES_URL =
"https://www.googleapis.com/androidpublisher/v3/" +
"applications/%s/purchases/products/%s/tokens/%s";
String url = String.format(GET_PURCHASES_URL, packageName,
subscriptionId, token) + "?access_token=" + accessToken;
ResponseEntity<String> response = proxyWebClient
.get()
.uri(url)
.exchange()
.block()
.toEntity(String.class)
.block();
return GSON.fromJson(response.getBody(),
GooglePurchasesProductResponse.class);
使用SDK 优化代码
/**
* get 检查用户的订阅购买是否有效,并返回其过期时间.
*
* @param productId
* @param purchaseToken
* @return
* @throws IOException
* @see <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions/get?hl=zh-cn">get</a>
*/
public SubscriptionPurchase checkSubscription(String productId,
String purchaseToken) throws IOException {
log.debug("checkSubscription use google credential by {} " +
"productId {} purchaseToken {}",
credential.getServiceAccountProjectId(), productId, purchaseToken);
AndroidPublisher.Purchases purchases = publisher.purchases();
SubscriptionPurchase purchase = null;
try {
purchase = purchases.subscriptions().get(packageName,
productId, purchaseToken).execute();
} catch (Exception e) {
log.error("checkSubscription use google error {}", e.getMessage(), e);
}
log.debug("checkSubscription use google response {} ", purchase);
return purchase;
}
1.2.2. cancel 取消用户的订阅购买。
- 如果成功,则响应正文为空。 看状态码是2xx 判断是否成功
原生api webclient调用 代码
String url = String.format(CANCEL_URL, packageName, subscriptionId, token) +
"?access_token=" + accessToken;
ResponseEntity<String> response = proxyWebClient
.post()
.uri(url)
.contentType(MediaType.APPLICATION_JSON)
.exchange()
.block()
.toEntity(String.class)
.block();
boolean success =
response.getStatusCodeValue() >= 200 &&
response.getStatusCodeValue() < 300;
使用SDK 优化代码
/**
* cancel 取消用户的订阅购买.
*
* @param productId
* @param purchaseToken
* @return
* @throws IOException
* @see <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions/cancel?hl=zh-cn">cancel</a>
*/
public boolean cancel(String productId, String purchaseToken)
throws IOException {
log.debug("cancel use google credential by {} " +
"productId {} purchaseToken {}",
credential.getServiceAccountProjectId(), productId, purchaseToken);
try {
publisher.purchases().subscriptions().cancel(packageName,
productId, purchaseToken).execute();
} catch (Exception e) {
log.error("cancel failed: {}", e.getMessage(), e);
return false;
}
return true;
}
1.2.3. acknowledge 确认订阅购买交易
- 使用场景 客户端购买成功之后向服务器进行校验 服务器订单校验成功 向google 确认订单
- 如果成功,则响应正文为空。看状态码是2xx 判断是否成功
原生api webclient调用 代码
private static final String ACKNOWLEDGE_URL =
"https://www.googleapis.com/androidpublisher/v3/" +
"applications/%s/purchases/subscriptions/%s/tokens/%s:acknowledge";
AcknowledgeRequest acknowledgeRequest = new AcknowledgeRequest();
acknowledgeRequest.setDeveloperPayload(payload);
String url = String.format(
ACKNOWLEDGE_URL, packageName, subscriptionId, token) +
"?access_token=" + accessToken;
ResponseEntity<String> response = proxyWebClient
.post()
.uri(url)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromObject(acknowledgeRequest))
.exchange()
.block()
.toEntity(String.class)
.block();
使用SDK 优化代码
/**
* acknowledge 订阅订单.
*
* @param productId
* @param purchaseToken
* @param payload
* @return
* @throws IOException
* @see <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions/acknowledge?hl=zh-cn">acknowledge</a>
*/
public boolean acknowledge(String productId, String purchaseToken,
String payload) throws IOException {
log.debug("acknowledge use google credential by {} " +
"productId {} purchaseToken {}",
credential.getServiceAccountProjectId(), productId, purchaseToken);
SubscriptionPurchasesAcknowledgeRequest acknowledgeRequest =
new SubscriptionPurchasesAcknowledgeRequest();
acknowledgeRequest.setDeveloperPayload(payload);
try {
publisher.purchases().subscriptions().acknowledge(packageName,
productId, purchaseToken, acknowledgeRequest).execute();
} catch (Exception e) {
log.error("acknowledge failed: {}", e.getMessage(), e);
return false;
}
return true;
}
1.2.4. defer 将用户的订阅购买推迟到指定的未来到期时间。
-
如果成功,则响应正文为空。看状态码是2xx 判断是否成功
-
请求正文中包含结构如下的数据
{
"expectedExpiryTimeMillis": 0,
"desiredExpiryTimeMillis": 0
}
- 响应正文将包含结构如下的数据
{
"newExpiryTimeMillis": string
}
原生api webclient调用 代码
private static final String DEFER_URL =
"https://www.googleapis.com/androidpublisher/v3/" +
"applications/%s/purchases/subscriptions/%s/tokens/%s:defer";
String url = String.format(DEFER_URL, packageName, subscriptionId, token) +
"?access_token=" + accessToken;
LOG.debug("use google credential by " +
credential.getServiceAccountProjectId());
if (useProxy) {
ResponseEntity<String> response = proxyWebClient
.post()
.uri(url)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromObject(deferralRequest))
.exchange()
.block()
.toEntity(String.class)
.block();
使用SDK 优化代码
/**
* defer 将用户的订阅购买推迟到指定的未来到期时间.
*
* @param productId
* @param purchaseToken
* @param deferRequest
* @see <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions/defer?hl=zh-cn">defer</a>
*/
public SubscriptionPurchasesDeferResponse defer(String productId,
String purchaseToken,
SubscriptionPurchasesDeferRequest deferRequest) throws IOException {
log.debug("defer use google credential by {} " +
"productId {} purchaseToken {}",
credential.getServiceAccountProjectId(), productId, purchaseToken);
SubscriptionPurchasesDeferResponse response = null;
try {
response = publisher.purchases().subscriptions().defer(packageName,
productId, purchaseToken, deferRequest).execute();
} catch (IOException e) {
log.error("defer use google error", e);
}
log.debug("defer use google response {} ", response);
return response;
}
1.2.5. revoke 对用户的订阅购买进行退款并立即撤消
- 对用户的订阅购买进行退款并立即撤消。对订阅的访问权限将立即终止,并且会停止重复。
- 如果成功,则响应正文为空。看状态码是2xx 判断是否成功
/**
* revoke 对用户的订阅购买进行退款并立即撤消.
*
* @param subscriptionId
* @param token
* @return
* @throws IOException
* @see <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions/revoke?hl=zh-cn">revoke</a>
*/
public boolean revoke(String subscriptionId, String token)
throws IOException {
log.debug("revoke use google credential by " +
credential.getServiceAccountProjectId());
try {
publisher.purchases().subscriptions().revoke(packageName,
subscriptionId, token).execute();
} catch (Exception e) {
log.error("revoke failed: {}", e.getMessage(), e);
return false;
}
return true;
}
1.2.6. refund 针对用户的订阅购买交易退款,但订阅在到期之前仍然有效,并且将继续定期重复。
- 针对用户的订阅购买交易退款,但订阅在到期之前仍然有效,并且将继续定期重复。
- 如果成功,则响应正文为空。看状态码是2xx 判断是否成功
/**
* refund 针对用户的订阅购买交易退款,但订阅在到期之前仍然有效.
*
* @param productId
* @param purchaseToken
* @see <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions/refund?hl=zh-cn">refund</a>
*/
public void refund(String productId,
String purchaseToken) throws IOException {
log.debug("refund use google credential by {} " +
"productId {} purchaseToken {}",
credential.getServiceAccountProjectId(), productId, purchaseToken);
publisher.purchases().subscriptions().refund(packageName,
productId, purchaseToken).execute();
}
1.3. Subscription V2版本
1.3.1. subscriptionv2.get 关于订阅的元数据
v3-rev20201022-1.30.10 版本没有这个api 😡😡😡
1.4. Purchase 商品型
- ProductPurchase 资源指示用户的应用内商品购买的状态
v3-rev20201022-1.30.10 版本没有consume这个api 😡😡😡
1.4.1. acknowledge 确认对应用内商品的购买
/**
* 确认对应用内商品的购买.
*
* @param productId
* @param purchaseToken
* @throws IOException
* @see <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/acknowledge?hl=zh-cn">acknowledge</a>
*/
public void acknowledgePurchasesProduct(
String productId, String purchaseToken) throws IOException {
log.debug("acknowledgePurchasesProduct use google credential by {} " +
"productId {} purchaseToken {}",
credential.getServiceAccountProjectId(), productId, purchaseToken);
AndroidPublisher.Purchases purchases = publisher.purchases();
ProductPurchasesAcknowledgeRequest acknowledgeRequest =
new ProductPurchasesAcknowledgeRequest();
ProductPurchase purchase = null;
try {
purchases.products().acknowledge(packageName,
productId, purchaseToken, acknowledgeRequest).execute();
} catch (Exception e) {
log.error("acknowledgePurchasesProduct failed: {}", e.getMessage(), e);
}
log.debug("acknowledgePurchasesProduct use google response {} ", purchase);
}
1.4.2. consume 消费购买应用内商品
v3-rev20201022-1.30.10 版本没有consume这个api 😡😡😡
1.4.3. get 查看应用内商品的购买和消耗状态
/**
* 获取一次性商品.
*
* @param productId
* @param purchaseToken
* @return
* @throws IOException
*/
public ProductPurchase checkPurchasesProduct(
String productId, String purchaseToken) throws IOException {
log.debug("checkPurchasesProduct use google credential by {} " +
"productId {} purchaseToken {}",
credential.getServiceAccountProjectId(), productId, purchaseToken);
AndroidPublisher.Purchases purchases = publisher.purchases();
ProductPurchase purchase = null;
try {
purchase = purchases.products().get(packageName,
productId, purchaseToken).execute();
} catch (Exception e) {
log.error("checkPurchasesProduct failed: {}", e.getMessage(), e);
}
log.debug("checkPurchasesProduct use google response {} ", purchase);
return purchase;
}
1.5. Order 订单 (业务没有使用 不写代码了)
1.5.1. refund 针对用户的订阅或应用内购买订单退款
1.6. inappproducts (app内商品操作 后台直接改 无使用场景 官方已废弃)
1.6.1. list 列出所有应用内商品,包括受管理的商品和订阅项目
/**
* 列出所有应用内商品,包括受管理的商品和订阅项目.
*
* @param sku
* @return
* @throws IOException
* @throws LollypopInvalidRequestException
*/
public InappproductsListResponse listInAppProducts(String sku)
throws IOException, LollypopInvalidRequestException {
log.debug("listInAppProducts use google credential by {} sku {}",
credential.getServiceAccountProjectId(), sku);
return publisher.inappproducts().list(packageName).execute();
}
1.6.2. get 获取应用内商品,可以是受管理的商品,也可以是订阅。
/**
* get 获取应用内商品,可以是受管理的商品,也可以是订阅.
*
* @param sku sku信息
* @return
* @throws IOException
* @throws LollypopInvalidRequestException
* @see <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/inappproducts/get?hl=zh-cn">getInAppProducts</a>
*/
public InAppProduct getInAppProducts(String sku)
throws IOException, LollypopInvalidRequestException {
log.debug("getInAppProducts use google credential by {} sku {}",
credential.getServiceAccountProjectId(), sku);
return publisher.inappproducts().get(packageName, sku).execute();
}
1.7. monetization 2022年出的新管理订阅商品
v3-rev20201022-1.30.10 monetization模块api 😡😡😡
1.8. Google Pay 回调
- 回调的话主要根据回调通知的类型 进行自己的业务处理,然后调用SDK相关api查出对应信息,继续做业务处理相关逻辑。
google 订阅状态的类型
SUBSCRIPTION_RECOVERED - 从帐号保留状态恢复了订阅。
SUBSCRIPTION_RENEWED - 续订了处于活动状态的订阅。
SUBSCRIPTION_CANCELED - 自愿或非自愿地取消了订阅。如果是自愿取消,在用户取消时发送。
SUBSCRIPTION_PURCHASED - 购买了新的订阅。
SUBSCRIPTION_ON_HOLD - 订阅已进入帐号保留状态(如果已启用)。
SUBSCRIPTION_IN_GRACE_PERIOD - 订阅已进入宽限期(如果已启用)。
SUBSCRIPTION_RESTARTED - 用户已通过 Play > 帐号 > 订阅恢复了订阅。订阅已取消,但在用户恢复时尚未到期。如需了解详情,请参阅 [恢复](/google/play/billing/subscriptions#restore)。
SUBSCRIPTION_PRICE_CHANGE_CONFIRMED - 用户已成功确认订阅价格变动。
SUBSCRIPTION_DEFERRED - 订阅的续订时间点已延期。
SUBSCRIPTION_PAUSED - 订阅已暂停。
SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED - 订阅暂停计划已更改。
SUBSCRIPTION_REVOKED - 用户在到期时间之前已撤消订阅。
SUBSCRIPTION_EXPIRED - 订阅已到期。
2. 参考
-
关于Google Pay JAVA后端处理_按照本教程中的步骤将 web 应用与 google pay api 集成的java示例-CSDN博客
-
Google内购 Java服务端(Springboot)校验订单详细流程_google-api-services-androidpublisher-CSDN博客
- com.google.api.client.http.HttpTransport.isMtls()异常出现
- com.google.api.client.http.HttpTransport.isMtls()异常出现
- google sdk代理
- Java服务端实现Google Pay支付功能
- 关于Google Pay JAVA后端处理
- # Google Pay Java 后端验证方式一
- # Google pay java 后端验证方式二 - Google play 支付校验 -# 安卓应用内购买,第5部分:服务器端购买验证
- Google Pay 踩坑之路
3. 雀魂麻将 下班
舒服了 下班第一把 直接给我三倍满 专打小日本 今天播20分钟 就下了
我的直播间链接:live.bilibili.com/24928343