Google 支付方案设计
接入支付前期准备
主要是准备好Google开发者账号,创建项目,授权,获取密钥,创建商品等等。。。
前提说明
GooglePay 分为 Subscriptions (订阅) 和 In-App Purchases (内购)
官方文档在这里
In-App Purchases 内购
客户端代码流程(网上)
支付流程
- 向用户展示他们可以购买什么。
- 启动购买流程,以便用户接受购买交易。
- 在您的服务器上验证购买交易。
- 向用户提供内容。
- 确认内容已传送给用户。对于消耗型商品,请将购买的商品标记为消耗,以便用户能够再次购买商品。
服务端验证订单流程
- 接收客户端传递来的参数
- 根据客户端参数的订单ID(第三方订单ID)去库里查询是否存在订单
- 如果存在订单,检查订单状态,如果不是初始化状态,说明订单已经被处理过了,如果是初始化状态,则走步骤 5
- 如果不存在订单则创建订单
- 服务端调用 Google API 校验订单状态
- 如果链接不上 Google 服务器或者开启了本地校验
- 服务端本地 RSA 签名校验订单状态
- 订单校验失败,告知客户端,修改订单状态,记录订单失败信息
- 订单校验成功,更改订单状态,记录订单信息
- 发送 Kafka 消息,告知客户端处理成功
注意事项,校验订单时需要加分布式锁(按照订单号为锁),避免客户端多次重试出现并发问题
服务端校验订单方式
服务端校验订单,主要是校验订单的状态,可以分为两种验证方式:
- 服务端RAS签名验证
- 服务端调用Google API 验证
可以两种方式都做,原因是:
- Google api 方式需要连接Google服务器,如果谷歌服务器连接失败,此时可以退而求其次,使用本地签名验证
- 服务端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验证订单也有两种方式:
- 一种是借助 Google SDK方式
- 另一种是使用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;
核心代码
- 加入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>
- 将 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": "-------------",
}
- 服务端开始接入
接入流程就是上面流程图有描述的那样,不过我稍微改了一下,就是先做服务端 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…对应SDK的方法
遇到的坑
在配置好项目、服务账号以及相应的权限后,使用 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… 这里找到了原因:
翻译的意思就是说:我们实际上是在两个google play账户之间传输应用程序。这个页面似乎暗示“谷歌开发者账户”需要拥有谷歌play账户。将他们添加为所有者,以尝试解决我们的401“权限不足”问题
然后我的做法是 把创建的服务账号加到 有权访问此服务账号的主账号中