跨境支付 Google Pay(购买/订阅) SDK优化重构 原生API-V1/V2代码 (已上线)

1,266 阅读12分钟

不断学习 支付订阅相关知识点 🍌

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所有地区的价格 再过滤出用户时区对应的地区价格

问题二 免费试用

下面这个用户续订了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 (暂时没用上)

image.png

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 月

support.google.com/googleplay/…

image.png

<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 订阅型/续订

概览 developers.google.com/android-pub…

  • 订阅商品相关的6个方法(get/defer/cancel/acknoledge/refund/revoke)

1.2.1. get 检查用户的订阅购买是否有效,并返回其过期时间。

google官方不推荐v1的get获取用户订阅信息了 我们系统还是用的v1 后续会进行升级吧

developer.android.com/google/play…

image.png

developer.android.com/google/play…

image.png

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…

  • 路径参数 (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 取消用户的订阅购买。

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…**

  • 如果成功,则响应正文为空。 看状态码是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 确认订阅购买交易

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…**

  • 使用场景 客户端购买成功之后向服务器进行校验 服务器订单校验成功 向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 将用户的订阅购买推迟到指定的未来到期时间。

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…**

  • 如果成功,则响应正文为空。看状态码是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 对用户的订阅购买进行退款并立即撤消

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…

  • 对用户的订阅购买进行退款并立即撤消。对订阅的访问权限将立即终止,并且会停止重复。
  • 如果成功,则响应正文为空。看状态码是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 针对用户的订阅购买交易退款,但订阅在到期之前仍然有效,并且将继续定期重复。

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…

  • 针对用户的订阅购买交易退款,但订阅在到期之前仍然有效,并且将继续定期重复。
  • 如果成功,则响应正文为空。看状态码是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 关于订阅的元数据

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…

v3-rev20201022-1.30.10 版本没有这个api 😡😡😡

1.4. Purchase 商品型

  • ProductPurchase 资源指示用户的应用内商品购买的状态

v3-rev20201022-1.30.10 版本没有consume这个api 😡😡😡

image.png

1.4.1. acknowledge 确认对应用内商品的购买

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…

/**
 * 确认对应用内商品的购买.
 *
 * @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 消费购买应用内商品

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…

v3-rev20201022-1.30.10 版本没有consume这个api 😡😡😡

1.4.3. get 查看应用内商品的购买和消耗状态

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…

/**
 * 获取一次性商品.
 *
 * @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 针对用户的订阅或应用内购买订单退款

developers.google.com/android-pub…

image.png

1.6. inappproducts (app内商品操作 后台直接改 无使用场景 官方已废弃)

developers.google.com/android-pub…

1.6.1. list 列出所有应用内商品,包括受管理的商品和订阅项目

developers.google.com/android-pub…

androidpublisher.googleapis.com/androidpubl…

/**
 * 列出所有应用内商品,包括受管理的商品和订阅项目.
 *
 * @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 获取应用内商品,可以是受管理的商品,也可以是订阅。

developers.google.com/android-pub…

/**
 * 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年出的新管理订阅商品

developers.google.com/android-pub…

image.png

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. 参考

3. 雀魂麻将 下班

舒服了 下班第一把 直接给我三倍满 专打小日本 今天播20分钟 就下了

我的直播间链接:live.bilibili.com/24928343

image.png

image.png

image.png