苹果内购-WWDC
苹果内购-WWDC
App Store Server API
苹果提供了以下这些 Server API
API简介
查询用户订单的收据
GET https://api.storekit.itunes.apple.com/inApps/v1/lookup/{orderId}
Look Up Order ID : 使用订单ID从收据中获取用户的应用内购买项目收据信息。
查询用户历史收据
GET https://api.storekit.itunes.apple.com/inApps/v1/history/{originalTransactionId}
Get Transaction History : 获取用户在您的 app 的应用内购买交易历史记录。
查询用户内购退款
GET https://api.storekit.itunes.apple.com/inApps/v1/refund/lookup/{originalTransactionId}
Get Refund History : 获取 app 中为用户退款的所有应用内购买项目的列表。
查询用户订阅项目状态
GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}
Get All Subscription Statuses : 获取您 app 中用户所有订阅的状态。
提交防欺诈信息
PUT https://api.storekit.itunes.apple.com/inApps/v1/transactions/consumption/{originalTransactionId}
Send Consumption Information : 当用户申请退款时,苹果通知(CONSUMPTION_REQUEST)开发者服务器,开发者可在12小时内,提供用户的信息(比如游戏金币是否已消费、用户充值过多少钱、退款过多少钱等),最后苹果收到这些信息,协助“退款决策系统” 来决定是否允许用户退款。
延长用户订阅的时长
PUT https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/extend/{originalTransactionId}
Extend a Subscription Renewal Date : 使用原始交易标识符延长用户有效订阅的续订日期。(相当于免费给用户增加订阅时长)
接口参数说明
App Store Server API 是苹果提供给开发者,通过服务器来管理用户在 App Store 应用内购买的一套接口(REST API)。
线上环境的 URL:
https://api.storekit.itunes.apple.com/
沙盒环境测试:
https://api.storekit-sandbox.itunes.apple.com/
JWT 简介:
调用这些 API 需要 JWT(JSON Web Token)进行授权。那么什么是 JWT 呢?
JWT 是一个开放式标准(规范文件 RFC 7519),用于在各方之间以 JSON 对象安全传输信息。有两种实现,一种基于 JWS 的实现使用了BASE64URL编码和数字签名的方式对传输的Claims提供了完整性保护,也就是仅仅保证传输的Claims内容不被篡改,但是会暴露明文。另一种是基于 JWE 实现的依赖于加解密算法、BASE64URL编码和身份认证等手段提高传输的Claims内容被破解的难度。
- JWS(规范文件 RFC 7515): JSON Web Signature,表示使用 JSON 数据结构和 BASE64URL 编码表示经过数字签名或消息认证码(MAC)认证的内容。
- JWE(规范文件 RFC 7516): JSON Web Encryption,表示基于 JSON 数据结构的加密内容。
目前苹果 JWT 相关的内容,都是基于 JWS 实现,所以下文的 JWT 默认指 JWS。
JWT(JWS) 由三部分组成:
- header:主要声明了 JWT 的签名算法;
- payload:主要承载了各种声明并传递明文数据;
- signture:拥有该部分的 JWT 被称为 JWS,也就是签了名的 JWS。
组装 JWT
知道了基本的 JWT 知识,我们就可以开工啦。要生成签名的 JWT 有三步:
- 创建 JWT 标头。
- 创建 JWT 有效负载。
- 在 JWT 上签名。
JWT header 示例:
{
"alg": "ES256",
"kid": "2X9R4HXF34",
"typ": "JWT"
}
JWT payload 示例:
{
"iss": "57246542-96fe-1a63e053-0824d011072a",
"iat": 1623085200,
"exp": 1623086400,
"aud": "appstoreconnect-v1",
"nonce": "6edffe66-b482-11eb-8529-0242ac130003",
"bid": "com.example.testbundleid2021"
}
以上是苹果要求的字段规范,所以不同的 JWT 字符和内容并一样,所以,我们看看苹果对这些字段的定义:
字段 | 字段说明 | 字段值说明 |
---|---|---|
alg | Encryption Algorithm,加密算法 | 默认值:ES256。App Store Server API 的所有 JWT 都必须使用 ES256 加密进行签名。 |
kid | Key ID,密钥ID | 您的私钥ID,值来自 App Store Connect,下文会讲解。 |
typ | Token Type,令牌类型 | 默认值:JWT |
iss | Issuer,发行人 | 您的发卡机构ID,值来自 App Store Connect 的密钥页面,下文会讲解。 |
iat | Issued At,发布时间 | 秒,以 UNIX 时间(例如:1623085200)发布令牌的时间 |
exp | Expiration Time,到期时间 | 秒,令牌的到期时间,以 UNIX 时间为单位。在iat中超过 60 分钟过期的令牌无效(例如:1623086400) |
aud | Audience,受众 | 固定值:appstoreconnect-v1 |
nonce | Unique Identifier,唯一标识符 | 您仅创建和使用一次的任意数字(例如: "6edffe66-b482-11eb-8529-0242ac130003")。可以理解为 UUID 值。 |
bid | Bundle ID,套装ID | 您的 app 的套装ID(例如:"com.example.testbundleid2021") |
其中 kid
和 iss
值是从 App Store Connect 后台创建和获取。
生成密钥 ID(kid)
要生成密钥,您必须在 App Store Connect 中具有管理员角色或帐户持有人角色。登录 App Store Connect 并完成以下步骤:
- 选择 “用户和访问”,然后选择 “密钥” 子标签页。
- 在 “密钥类型” 下选择 “App内购买项目”。
- 单击 “生成API内购买项目密钥”(如果之前创建过,则点击 “添加(+)” 按钮新增。)。
- 输入密钥的名称。该名称仅供您参考,名字不作为密钥的一部分。
- 单击 “生成”。


生成的密钥,有一列名为 “密钥 ID” 就是 kid 的值,鼠标移动到文字就会显示 拷贝密钥 ID,点击按钮就可以复制 kid 值。
生成 Issuer(iss) 同理,iss 值的生成,类似:
issuer ID 值就是 iss 的值。

生成和签名 JWT
获取到这里参数后,就需要签名,那么还需要签名的密钥文件。
下载并保存密钥文件
App Store Connect 密钥文件,在刚才生成 kid
时,列表右边有 下载 App 内购买项目密钥
按钮(仅当您尚未下载私钥时,才会显示下载链接。):

此私钥只能一次性下载!
另外 Apple 不保留私钥的副本,将您的私钥存放在安全的地方。
提示
注意:将您的私钥存放在安全的地方。不要共享密钥,不要将密钥存储在代码仓库中,不要将密钥放在客户端代码中。如果您怀疑私钥被盗,请立即在 App Store Connect 中撤销密钥。有关详细信息,请参阅 撤销API密钥。

相关信息
API密钥有两个部分:苹果保留的公钥和您下载的私钥。开发者使用私钥对授权 API 在 App Store 中访问数据的令牌进行签名。
需要注意的是,App Store Server API 密钥是 App Store Server API 所独有的,不能用于其他 Apple 服务(比如 Sign in with Apple 服务或 App Store Connet API 服务等。)。
生成请求令牌
package com.game.server.util;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
public class JwtUtils {
/**
* 通过私钥生成一个JWT的token 用于以后解析验证
*
* @param issuer
* @param aud
* @param kid
* @param bid
* @return
*/
public static String createJWT(String issuer, String aud, String kid, String bid, String privateKey) {
// 指定签名的时候使用的签名算法,也就是header那部分,jjwt已经将这部分内容封装好了。
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.ES256;
// 生成JWT的时间
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
// 创建payload的私有声明(根据特定的业务需要添加,如果要拿这个做验证,一般是需要和jwt的接收方提前沟通好验证方式的)
Map<String, Object> claims = new HashMap<>();
claims.put("alg", "ES256");
claims.put("typ", "JWT");
claims.put("kid", kid);
long expMillis = nowMillis + 3600000;
Date expDate = new Date(expMillis);
// 生成签名的时候使用的秘钥secret,切记这个秘钥不能外露哦。它就是你服务端的私钥,在任何场景都不应该流露出去。
// 一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
// 下面就是在为payload添加各种标准声明和私有声明了
JwtBuilder builder = Jwts.builder() // 这里其实就是new一个JwtBuilder,设置jwt的body
.setHeader(claims) // 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.claim("nonce", UUID.randomUUID().toString())// 设置唯一标志符
.claim("bid", bid)// Bundle ID,套装ID app
// 的套装ID(例如:“com.example.testbundleid2021”)
.setIssuedAt(now) // iat: jwt的签发时间
.setIssuer(issuer) // issuer:jwt签发人
.setAudience(aud) // 接收jwt的一方
.setExpiration(expDate) // 到期时间
.signWith(signatureAlgorithm, KeyPairFromPEM(privateKey).getPrivate()); // 设置签名使用的签名算法和签名使用的秘钥
return builder.compact();
}
public static KeyPair KeyPairFromPEM(String privateKeyPEM) {
// Remove headers and line breaks from the PEM string
privateKeyPEM = privateKeyPEM.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "").replaceAll("\\s", "");
// Decode the Base64-encoded PEM content
byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyPEM);
// Generate the PrivateKey
PrivateKey privateKey = null;
try {
// Create a KeyFactory for ECDSA
KeyFactory keyFactory = KeyFactory.getInstance("EC");
// Generate the PKCS8EncodedKeySpec from the private key bytes
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
privateKey = keyFactory.generatePrivate(keySpec);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (InvalidKeySpecException e) {
e.printStackTrace();
}
// Generate the PublicKey from the private key (you can derive it if
// needed)
// PublicKey publicKey = keyFactory.generatePublic(new
// X509EncodedKeySpec(privateKey.getEncoded()));
KeyPair keyPair = new KeyPair(null, privateKey); // For private key only
// Now you have the KeyPair
return keyPair;
}
public static void main(String[] args) throws IOException {
String inputFilePath = "/Users/jingxc/service/sts-project/jj_oversea_jjworld_game/jj_oversea_jjworld_payment/src/main/resources/AppleRootCA-G3.cer"; // Replace
// with
// your
// input
// .cer file path
String outputFilePath = "./output.pem"; // Replace with your
// desired output .pem
// file path
try (FileInputStream cerInputStream = new FileInputStream(inputFilePath);
FileOutputStream pemOutputStream = new FileOutputStream(outputFilePath)) {
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) certFactory.generateCertificate(cerInputStream);
String certPEM = "-----BEGIN CERTIFICATE-----\n" + Base64.getEncoder().encodeToString(cert.getEncoded())
+ "\n" + "-----END CERTIFICATE-----\n";
pemOutputStream.write(certPEM.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
e.printStackTrace();
}
}
}
- KeyPairFromPEM:方法是将密钥转换成创建token是需要的KeyPair
- createJWT:方法是创建token的主要方法
- main:方法给出了将苹果的cer证书转换成pem证书
说明
最后在调用苹果端点
https://api.storekit.itunes.apple.com/inApps/v1/history/{originalTransactionId}
时返回数据出现了信息极少的情况,如下
{
"revision":"0",
"bundleId":"com.jjlmzh.global.ios",
"appAppleId":1628482223,
"environment":"Production",
"hasMore":false,
"signedTransactions":[
]
}
提示
实践中遇到了 消耗性票据 获取不正确的问题 支付票据(receipt)中 收据列表(in_app)会保留所有 订阅类商品、非消耗性商品 信息,且会依次进入列表(最后一位是最新的一次购买记录)。消耗性商品 信息只在未向苹果服务器进行校验时存在,且只存在列表第一项(再次购买 消耗性商品 会替换票据信息)。
后续如果有更新会持续补充...
参考
- https://blog.csdn.net/lvyanqin2013/article/details/129623419
- https://cloud.tencent.com/developer/article/1939304
- 本文作者: 景兴春
- 本文链接: https://www.jingxc.top/back/payment/apple-pay-wwdc.html
- 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!