海外支付(GooglePay)服务端设计方案

9,752 阅读9分钟

Google 支付方案设计

接入支付前期准备

主要是准备好Google开发者账号,创建项目,授权,获取密钥,创建商品等等。。。

前提说明

GooglePay 分为 Subscriptions (订阅) 和 In-App Purchases (内购)
官方文档在这里

In-App Purchases 内购

客户端代码流程(网上)

image.png

支付流程

  1. 向用户展示他们可以购买什么。
  2. 启动购买流程,以便用户接受购买交易。
  3. 在您的服务器上验证购买交易。
  4. 向用户提供内容。
  5. 确认内容已传送给用户。对于消耗型商品,请将购买的商品标记为消耗,以便用户能够再次购买商品。

服务端验证订单流程

image.png

  1. 接收客户端传递来的参数
  2. 根据客户端参数的订单ID(第三方订单ID)去库里查询是否存在订单
  3. 如果存在订单,检查订单状态,如果不是初始化状态,说明订单已经被处理过了,如果是初始化状态,则走步骤 5
  4. 如果不存在订单则创建订单
  5. 服务端调用 Google API 校验订单状态
  6. 如果链接不上 Google 服务器或者开启了本地校验
  7. 服务端本地 RSA 签名校验订单状态
  8. 订单校验失败,告知客户端,修改订单状态,记录订单失败信息
  9. 订单校验成功,更改订单状态,记录订单信息
  10. 发送 Kafka 消息,告知客户端处理成功

注意事项,校验订单时需要加分布式锁(按照订单号为锁),避免客户端多次重试出现并发问题

服务端校验订单方式

服务端校验订单,主要是校验订单的状态,可以分为两种验证方式:

  1. 服务端RAS签名验证
  2. 服务端调用Google API 验证

可以两种方式都做,原因是:

  1. Google api 方式需要连接Google服务器,如果谷歌服务器连接失败,此时可以退而求其次,使用本地签名验证
  2. 服务端RAS签名验证有一些风险,据说有些模拟器可以伪造数据

服务端签名验证

客户端返回参数大致情况:

signtureData:
{
    "orderId": "1111111111.111111111111", 
    "packageName": "com.abc.item", 
    "productId": "com.abc.item.1", 
    "purchaseTime": 1423197856877, 
    "purchaseState": 0, 
    "purchaseToken": "dccfjnioeeojanngnfspekea.AO-J1OzsBdFJhqhLtvtybnQbBMxELYL4M-wClITbJFd-rpnPzYWCOlHyK69xgXBYN8lx99XfMBhD8JPg6u3SsgNvPt2hhbvogszRxjtA15rP-qWBYv_Rytw"
}

signture:XCtoXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXy2Nyp047DV0f1VZ39CV7dCmLXouSFrYBxoS7NAVejgmHU+WXLLI61M4GjRrBMtEuW2HnUye8hfbsjlGfqI+MZDqAbfAi+3i6fPwIOwDS+tdAAU+VUz3cDyBubJhL+tZIa1uT6H0ifHN0KXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX7xpsZTctzf09XGKdqNktrFbNm4pqHqDttEg98xmiP/oKyitZpLoNyvrk7nEXxUstGJhKejPt9wyn3il+s7cT3TD4xyEznxeBuD+zVa/sCXAZw==

服务端校验: publicKey , 谷歌后台提供

//result=true的话就成功了
boolean result = RSASignature.doCheck(signtureData, signture, publicKey);

服务端调用Google API 验证订单状态

Google并未像Apple那样提供一个接口来校验订单的信息,但是提供了一个获取订单状态的接口,可以通过这个接口在GooglePlay服务器获取某个订单,查看其状态是否合法达到校验目的

服务端调用Google API验证订单也有两种方式:

  1. 一种是借助 Google SDK方式
  2. 另一种是使用Http请求,但是需要自己维护请求API时需要的 refreshToken、access_token(Auth2流程),个人感觉比较麻烦

建议使用 Google SDK接入方式

参考:www.cnblogs.com/kevin-zou/p…

支付表结构设计

  • 商品表
CREATE TABLE `goods` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT,
    `goods_code` varchar(20) NOT NULL COMMENT '商品code',
    `goods_name` varchar(500) NOT NULL COMMENT '商品名称',
    `type` tinyint(1) DEFAULT NULL COMMENT '虚拟币类型',
    `quantity` int(11) DEFAULT NULL COMMENT '数量',
    `price` bigint(20) NOT NULL DEFAULT 0 COMMENT '售价',
    `pay_channel` tinyint(1) DEFAULT NULL COMMENT '支付渠道:0 GooglePay 1 ApplePay 2 PayPal',
    `distribution_channel` varchar(200) NOT NULL COMMENT '分销渠道',
    `sort` int(11) DEFAULT NULL COMMENT '排序',
    `create_time` datetime DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime DEFAULT NULL COMMENT '更新时间',

    PRIMARY KEY (`id`),
    KEY `index_goods_code` (`goods_code`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4;
  • 支付渠道表
CREATE TABLE `pay_channel` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT,
    `channel_code` varchar(20) DEFAULT NULL COMMENT '渠道名称',
    `channel_name` varchar(500) DEFAULT NULL COMMENT '渠道名称',
    `logo` varchar(100) DEFAULT NULL COMMENT 'logo',
    `type` tinyint(1) DEFAULT NULL COMMENT '类型: 0 原生类型, 1 H5类型',
    `link` varchar(100) DEFAULT NULL COMMENT '链接 H5类型下使用',
    `create_time` datetime DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime DEFAULT NULL COMMENT '更新时间',

    PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4;
  • 订单表
CREATE TABLE `t_order` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT,
    `order_sn` varchar(100) NOT NULL COMMENT '订单编号',
    `third_order_sn` varchar(100) DEFAULT NULL COMMENT '第三方订单编号',
    `origin_order_sn` varchar(100) DEFAULT NULL COMMENT '源订单号,发起退款时关联的订单',
    `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
    `type` tinyint(1) DEFAULT NULL COMMENT '订单类型 :0 充值订单 1 会员订单',
    `business_type` tinyint(1) DEFAULT NULL COMMENT '业务类型 0 付款订单 1 退款订单',
    `goods_code` varchar(20) NOT NULL COMMENT '商品code',
    `price` bigint(20) NOT NULL DEFAULT 0 COMMENT '订单金额',
    `payment_method` tinyint(1) DEFAULT NULL COMMENT '付款方式:0 Google pay ',
    `status` tinyint(1) DEFAULT NULL COMMENT '订单状态:0:初始化 1:已完成 2 已经取消 3 已退款 5 已支付',
    `deliver_status` tinyint(1) DEFAULT NULL COMMENT '发货状态:0:发货失败 1:发货成功',
    `pay_time` datetime DEFAULT NULL COMMENT '支付时间',
    `complete_time` datetime DEFAULT NULL COMMENT '订单完成时间',
    `refund_time` datetime DEFAULT NULL COMMENT '退款时间',
    `distribution_channel` varchar(100) DEFAULT NULL COMMENT '分销渠道',
    `origin_info` varchar(500) DEFAULT NULL COMMENT '第三方支付信息',
    `remark` varchar(200) DEFAULT NULL COMMENT '备注',
    `refund_reason` varchar(200) DEFAULT NULL COMMENT '退款原因',
    `create_time` datetime DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime DEFAULT NULL COMMENT '更新时间',

    PRIMARY KEY (`id`),
    KEY `index_order_sn` (`order_sn`) USING BTREE,
    KEY `index_third_order_sn` (`third_order_sn`) USING BTREE
    KEY `index_user_id` (`third_order_sn`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4;

核心代码

  1. 加入SDK依赖
<dependency>
    <groupId>com.google.apis</groupId>
    <artifactId>google-api-services-androidpublisher</artifactId>
    <version>v3-rev24-1.24.1</version>
</dependency>

注意:网上都是加入这个依赖,但是我使用时发现,我只加这个依赖的时候,Google 授权凭证相关的SDK代码仍然编译不过(没有依赖),所以我又加了以下两个依赖(这个也许只是我这边的问题)

<dependency>
    <groupId>com.google.cloud</groupId>
    <artifactId>google-cloud-core</artifactId>
</dependency>
<dependency>
    <groupId>com.google.cloud</groupId>
    <artifactId>google-cloud-core-http</artifactId>
</dependency>
  1. 将 Google SDK加入IOC容器
@Configuration
@Slf4j
public class GooglePayConfiguration {

    // 将google 提供的私钥放入项目 classpath 下(放在项目 resources 文件夹下即可)
    @Value(value = "classpath:privateKey.json")
    private Resource resource;

    @Bean
    public AndroidPublisher androidPublisher() throws IOException {

        // 凭证
        GoogleCredentials credentials = GoogleCredentials.fromStream(resource.getInputStream())
                .createScoped(Sets.newHashSet(AndroidPublisherScopes.ANDROIDPUBLISHER));

        AndroidPublisher androidPublisher;
        try {
            androidPublisher = new AndroidPublisher.Builder(
                    GoogleNetHttpTransport.newTrustedTransport(),
                    JacksonFactory.getDefaultInstance(),
                    new HttpCredentialsAdapter(credentials)).build();
        } catch (GeneralSecurityException | IOException ex) {
            log.error("初始化Google服务失败:{}", ex);
            throw new RuntimeException(ex);
        }
        return androidPublisher;
    }
}

私钥JSON文件类似以下格式:

{
  "type": "service_account",
  "project_id": "------",
  "private_key_id": "-----------",
  "private_key": "-------------",
  "client_email": "------",
  "client_id": "-------------",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "-------------",
}
  1. 服务端开始接入
    接入流程就是上面流程图有描述的那样,不过我稍微改了一下,就是先做服务端 RSA 校验
/**
 * 校验 Google 内购订单
 * <p>
 * <p>
 * 访问 Google 服务器校验订单结果说明:
 * 返回结果案例:
 * {
 * "acknowledgementState":0,
 * "consumptionState":0,
 * "developerPayload":"",
 * "kind":"androidpublisher#productPurchase",
 * "orderId":"GPA.3308-7963-0055-36023",
 * "purchaseState":0,
 * "purchaseTimeMillis":1652178403396,
 * "purchaseType":0,
 * "regionCode":"US"
 * }
 * <p>
 * 字段说明:
 * <p>
 * kind    string 这种表示 androidpublisher 服务中的一个 inappPurchase 对象。
 * purchaseTimeMillis  string (int64 format) 购买产品的时间,自纪元(1970 年 1 月 1 日)以来的毫秒数。
 * purchaseState   integer 订单的购买状态。可能的值为: 0. 已购买 1. 已取消 2. 待定
 * consumptionState    integer inapp产品的消费状态。可能的值为: 0. 尚未消费 1. 已消费
 * developerPayload    string 开发人员指定的字符串,其中包含有关订单的补充信息。
 * orderId string 与购买应用内产品相关的订单 ID。
 * purchaseType    integer inapp 产品的购买类型。仅当此购买不是使用标准应用内结算流程进行时才设置此字段。可能的值有: 0. 测试(即从许可证测试帐户购买) 1. 促销(即使用促销代码购买) 2. 奖励(即通过观看视频广告而不是付费)
 * acknowledgementState    integer inapp 产品的确认状态。可能的值为: 0. 尚未确认 1. 已确认
 * purchaseToken   string 为识别此次购买而生成的购买令牌。
 * productId   string 应用内产品 SKU。
 * quantity    integer 与购买应用内产品相关的数量。
 * obfuscatedExternalAccountId string 与您的应用中的用户帐户唯一关联的 id 的混淆版本。只有在购买时使用https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setobfuscatedaccountid指定时才会出现。
 * obfuscatedExternalProfileId string 与您应用中的用户个人资料唯一关联的 id 的混淆版本。只有在购买时使用https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setobfuscatedprofileid指定时才会出现。
 * regionCode  string 授予产品时用户的 ISO 3166-1 alpha-2 计费区域代码。
 *
 * @param request
 */
@Override
@Transactional
public void verifyInapp(GooglePayVerifyRequest request) {

    // 渠道
    String clientType = httpServletRequestHelper.getClientType();

    GooglePayVerifyRequest.SigntureData signtureData = request.getSigntureData();

    String publicKey = googlePublicKeyProperties.getPackageAndPublicKey().get(clientType);
    log.info("包名:{},公钥:{} 准备开始本地RSA校验", signtureData.getPackageName(), publicKey);
    if (!doCheck(request.getSigntureContent(), request.getSignture(), publicKey)) {
        log.info("服务端RSA校验未通过,参数:{}" , request);
        throw new OrderException(CommonErrorCode.create(GooglePayVerifyEnum.RSA_VERIFY_FAIL.getCode(),
                GooglePayVerifyEnum.RSA_VERIFY_FAIL.getDesc()));
    }

    // 检查商品
    ResultResponse<GoodsVo> response = commodityFeign.detail(signtureData.getProductId(), PayMethodEnum.GOOGLE_PAY.getCode(), clientType);
    if (!SUCCESS_CODE.equals(response.getCode())) {
        throw new ThirdPartyServiceCallException("调用商品服务失败!");
    }
    GoodsVo goodsVo = response.getData();

    // 分布式锁
    RLock lock = null;
    try {
        lock = redisUtils.lock(String.format(Constant.GOOGLE_PAY_VERIFY_LOCK, signtureData.getOrderId()));
        // 检查 订单是否存在库里
        Order order = orderRepository.findByThirdOrderSnAndBusinessType(signtureData.getOrderId(), OrderBusinessTypeEnum.PAY_ORDER.getCode());
        if (order == null) {
            // 订单不存在 新事物 创建订单
           order = orderService.createOrderByGooglePay(request, goodsVo, clientType, httpServletRequestHelper.getCurrentUserId());
        } else if (order.checkHandleStatus()) {
            // 检查订单状态
            log.info("订单:{} 不是初始化状态,表示已经处理了" , JSON.toJSONString(order));
            throw new OrderException(CommonErrorCode.create(GooglePayVerifyEnum.ORDER_HANDLED.getCode(),
                    GooglePayVerifyEnum.ORDER_HANDLED.getDesc()));
        }

        String originInfo = null;
        ProductPurchase productPurchase = null;
        boolean googleServerError = false;
        try {
            //校验订单 一次性商品
            productPurchase = androidPublisher.purchases().products()
                    .get(signtureData.getPackageName(), signtureData.getProductId(), signtureData.getPurchaseToken()).execute();
            originInfo = JSON.toJSONString(productPurchase);
            log.info("调用 GoogleSDK 获取订单数据,参数:{},返回结果:{}", JSON.toJSONString(signtureData), originInfo);

        } catch (Exception ex) {
            log.error("调用GoogleSDK校验订单发生异常:{} , 忽略异常,因为Google本地RSA校验已经通过", ex);
            originInfo = request.getSigntureContent();
            googleServerError = true;
        }

        if (!googleServerError && (productPurchase == null || productPurchase.getPurchaseState() != 0)) {

            // 订单未支付状态
            log.info("Google API 校验订单:{} 不是支付完成状态,结果:{}" , order.getId(), JSON.toJSONString(productPurchase));
            throw new OrderException(CommonErrorCode.create(GooglePayVerifyEnum.ORDER_UN_PAID.getCode(),
                    GooglePayVerifyEnum.ORDER_UN_PAID.getDesc()));
        }

        // 更新订单数据
        orderService.payOrderByGooglePay(order.getId(), originInfo);

        // 发送 Kafka 消息
        sendKafka(order, goodsVo);

    } finally {
        if (lock != null) {
            lock.unlock();
        }
    }
}

private boolean doCheck(String content, String sign, String publicKey) {
    try {
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        byte[] encodedKey = Base64.getDecoder().decode(publicKey);
        PublicKey pubKey = keyFactory
                .generatePublic(new X509EncodedKeySpec(encodedKey));
        Signature signature = Signature.getInstance("SHA1WithRSA");
        signature.initVerify(pubKey);
        signature.update(content.getBytes("utf-8"));
        return signature.verify(Base64.getDecoder().decode(sign));
    } catch (Exception e) {
        log.error("Google pay RSA 校验异常:{}", e);
    }
    return false;
}

以上就是接入Google服务端校验的代码,注意:主要关注核心代码,即本地服务RSA校验和调用GoogleSDK检查订单状态这两部分代码,其他业务代码参考即可

注意:
接口官方文档戳这里:developers.google.com/android-pub… image.png 对应SDK的方法 image.png

遇到的坑

在配置好项目、服务账号以及相应的权限后,使用 GoogleSDK 去请求数据时,依然发现报错:401 权限不足,大概报错信息如下:

{
 "error": {
  "errors": [
   {
    "domain": "androidpublisher",
    "reason": "permissionDenied",
    "message": "The current user has insufficient permissions to perform the requested operation."
   }
  ],
  "code": 401,
  "message": "The current user has insufficient permissions to perform the requested operation."
 }
}

最后我在 groups.google.com/g/google-ap… 这里找到了原因: image.png 翻译的意思就是说:我们实际上是在两个google play账户之间传输应用程序。这个页面似乎暗示“谷歌开发者账户”需要拥有谷歌play账户。将他们添加为所有者,以尝试解决我们的401“权限不足”问题

然后我的做法是 把创建的服务账号加到 有权访问此服务账号的主账号中 image.png