learn - 应用内支付-apple-inAppPay
要不是非的用apple支付,谁要对接这个平台付费!
learn - 应用内支付-apple-inAppPay
创作背景
一个付费业务,总归要商业化。
对于富有的使用者来说,能花钱办事的就不耗费在时间上(例如花时间看广告去解锁权益)。
目标
- 一次性消费。(道具?金币?虚拟物品?电商实物)
- 订阅类型消费。
- 非续订类型的权益消费。
行动
文档知识储备
- gopay文档地址:https://github.com/go-pay/gopay/blob/main/doc/apple.md
- apple支付的服务端回掉:https://developer.apple.com/documentation/appstoreservernotifications/app-store-server-notifications-v2
- app对接apple支付:https://developer.apple.com/documentation/storekit/testing-in-app-purchases-with-sandbox?language=objc
- 服务端与app交互,验证订单有效性。
- 官方视频介绍-Server-iap:https://developer.apple.com/videos/play/wwdc2021/10174/
- 官方视频介绍-Storekit:https://developer.apple.com/videos/play/wwdc2021/10114/
- 官方视频介绍- Refund:https://developer.apple.com/videos/play/wwdc2021/10175/
verifyReceipt
如何确保支付成功后收到app请求过来的支付凭证是合法的?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
type ReceiptResponseDTO struct {
Receipt ReceiptDTO `json:"receipt"`
Environment string `json:"environment"`
Status int `json:"status"`
LatestReceiptInfo []LatestReceiptInfo `json:"latest_receipt_info"`
LatestReceipt string `json:"latest_receipt"`
PendingRenewalInfo []PendingRenewalInfo `json:"pending_renewal_info"`
}
*/
//appleResp ReceiptResponseDTO
appleResp, err := iapple.ItunesSrv.ParseReceiptData(&dto.ReceiptReqDTO{
ReceiptData: req.ReceiptData, //支付凭据密文
DefaultHost: confData.ApiHost, //对应沙盒或者生产地址
Env: "",
Password: confData.Password, //当前开发者共享密码
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func (*itunesDMO) ParseReceiptData(req *dto.ReceiptReqDTO) (dto.ReceiptResponseDTO, error) {
tmpReq := &dto.ReceiptReqDTO{
ReceiptData: req.ReceiptData,
DefaultHost: req.DefaultHost,
Env: "",
Password: req.Password,
}
appleResp, err := dal.ItunesDAO.ParseReceiptData(tmpReq)
if err != nil {
return appleResp, err
}
if appleResp.Status == 21007 {
//收据信息是测试用(sandbox),但却被发送到生产环境中验证
tmpReq.Env = dto.EnvSandBox
tmpReq.DefaultHost = ""
appleResp, err = dal.ItunesDAO.ParseReceiptData(tmpReq)
}
if appleResp.Status == 21008 {
//收据信息是生产环境中,但却被发送到测试环境中验证
tmpReq.Env = dto.EnvProd
tmpReq.DefaultHost = ""
appleResp, err = dal.ItunesDAO.ParseReceiptData(tmpReq)
}
return appleResp, err
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
func (*itunesDAO) ParseReceiptData(req *dto.ReceiptReqDTO) (receiptResponse dto.ReceiptResponseDTO, err error) {
// 2. 创建请求体
// ReceiptData string `json:"receipt-data"`
// Password string `json:"password,omitempty"` // 仅适用于订阅收据
requestBody := map[string]string{
"receipt-data": req.ReceiptData,
//"password": req.Password, // 如果是订阅收据,需要提供共享密钥
}
if req.Password != "" {
requestBody["password"] = req.Password
}
// 3. 将请求体转换为 JSON
requestJSON, err := json.Marshal(requestBody)
if err != nil {
logx.Errorf("itunesDAO.ParseReceiptData marshaling error: %v", err)
return receiptResponse, err
}
apiHost := sandboxURL
if req.Env == dto.EnvProd {
apiHost = productionURL
}
if req.Env == "" && req.DefaultHost != "" {
//强制使用了默认的host,优先用指定的host
apiHost = req.DefaultHost
}
// 4. 发送请求到 Apple 验证服务器
response, err := http.Post(fmt.Sprintf("%s/verifyReceipt", apiHost), "application/json", bytes.NewBuffer(requestJSON))
if err != nil {
logx.Errorf("itunesDAO.ParseReceiptData sending Apple request error: %v", err)
return receiptResponse, err
}
defer response.Body.Close()
// 5. 读取响应
responseBody, err := io.ReadAll(response.Body)
if err != nil {
logx.Errorf("itunesDAO.ParseReceiptData reading Apple body error: %v", err)
return receiptResponse, err
}
// 6. 解析响应
err = json.Unmarshal(responseBody, &receiptResponse)
if err != nil {
logx.Errorf("itunesDAO.ParseReceiptData ReceiptResponseDTO error: %v", err)
return receiptResponse, err
}
return receiptResponse, err
}
本地也可以解析一次看看,php简单的方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$str =base64_decode($receipt_data);
$str = trim(substr($str,1,strlen($str)-2));
$tmp_arr = explode('";',$str);
$arr = array();
foreach($tmp_arr as $v){
if(!$v){
continue;
}
$v = trim($v);
$v = trim(substr($v,1,strlen($v)-1));
$tmp = explode('" = "',$v);
$arr[$tmp[0]] = $tmp[1];
unset($tmp);
}
发送请求到 Apple 验证服务器(关键的结构体字段)
https://buy.itunes.apple.com/verifyReceipt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ReceiptResponseDTO Status
// 21000 App Store无法读取你提供的JSON数据
// 21002 收据数据不符合格式
// 21003 收据无法被验证
// 21004 你提供的共享密钥和账户的共享密钥不一致
// 21005 收据服务器当前不可用
// 21006 收据是有效的,但订阅服务已经过期。当收到这个信息时,解码后的收据信息也包含在返回内容中
// 21007 收据信息是测试用(sandbox),但却被发送到生产环境中验证
// 21008 收据信息是生产环境中,但却被发送到测试环境中验证
type ReceiptResponseDTO struct {
Receipt ReceiptDTO `json:"receipt"`
Environment string `json:"environment"`
Status int `json:"status"`
LatestReceiptInfo []LatestReceiptInfo `json:"latest_receipt_info"`
LatestReceipt string `json:"latest_receipt"`
PendingRenewalInfo []PendingRenewalInfo `json:"pending_renewal_info"`
}
type ReceiptDTO struct {
ReceiptType string `json:"receipt_type"`
AdamID int `json:"adam_id"`
AppItemID int `json:"app_item_id"`
BundleID string `json:"bundle_id"`
ApplicationVersion string `json:"application_version"`
DownloadID int `json:"download_id"`
VersionExternalIdentifier int `json:"version_external_identifier"`
ReceiptCreationDate string `json:"receipt_creation_date"`
ReceiptCreationDateMs string `json:"receipt_creation_date_ms"`
ReceiptCreationDatePst string `json:"receipt_creation_date_pst"`
RequestDate string `json:"request_date"`
RequestDateMs string `json:"request_date_ms"`
RequestDatePst string `json:"request_date_pst"`
OriginalPurchaseDate string `json:"original_purchase_date"`
OriginalPurchaseDateMs string `json:"original_purchase_date_ms"`
OriginalPurchaseDatePst string `json:"original_purchase_date_pst"`
OriginalApplicationVersion string `json:"original_application_version"`
InApp []InAppDTO `json:"in_app"`
}
// 商品内购买
type InAppDTO struct {
Quantity string `json:"quantity"`
ProductID string `json:"product_id"`
TransactionID string `json:"transaction_id"`
OriginalTransactionID string `json:"original_transaction_id"`
PurchaseDate string `json:"purchase_date"`
PurchaseDateMs string `json:"purchase_date_ms"`
PurchaseDatePst string `json:"purchase_date_pst"`
OriginalPurchaseDate string `json:"original_purchase_date"`
OriginalPurchaseDateMs string `json:"original_purchase_date_ms"`
OriginalPurchaseDatePst string `json:"original_purchase_date_pst"`
ExpiresDate string `json:"expires_date"`
ExpiresDateMs string `json:"expires_date_ms"`
ExpiresDatePst string `json:"expires_date_pst"`
IsTrialPeriod string `json:"is_trial_period"`
InAppOwnershipType string `json:"in_app_ownership_type"`
}
....
notifyv2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// PayloadNotifyDTO 苹果支付回调通知
// https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload
type PayloadNotifyDTO struct {
NotificationType string `json:"notificationType"` //SUBSCRIBED,The in-app purchase event
Subtype string `json:"subtype"` //RESUBSCRIBE
NotificationUUID string `json:"notificationUUID"` //de901be0-e9cb-4a2b-b7b9-d81395323da2
Data NotifyItem `json:"data"`
Version string `json:"version"` //"2.0",
SignedDate int64 `json:"signedDate"` //1747736403564
}
type NotifyItem struct {
AppAppleID int64 `json:"appAppleId"`
BundleID string `json:"bundleId"`
BundleVersion string `json:"bundleVersion"`
Environment string `json:"environment"`
SignedTransactionInfo string `json:"signedTransactionInfo"` //jwt
SignedRenewalInfo string `json:"signedRenewalInfo"` //jwt
Status int `json:"status"`
ConsumptionRequestReason string `json:"consumptionRequestReason"` //退款申请的原因
}
// TransactionInfoResponseDTO 交易信息响应
// https://developer.apple.com/documentation/appstoreserverapi/jwstransactiondecodedpayload
type TransactionInfoResponseDTO struct {
//app store
AppAccountToken string `json:"appAccountToken"` //您在购买时创建的UUID,将交易与您自己服务上的客户相关联。如果您的应用不提供appAccount tToken,则省略此字段。
AppTransactionId string `json:"appTransactionId"` //应用下载事务的唯一标识符。
BundleId string `json:"bundleId"` //应用程序的捆绑标识符。
Currency string `json:"currency"` //与价格参数关联的三个字母的ISO 4217货币代码。此值仅在价格存在时才存在。
Environment string `json:"environment"`
ExpiresDate int64 `json:"expiresDate"` //订阅过期或续订的UNIX时间(以毫秒为单位)
InAppOwnershipType string `json:"inAppOwnershipType"`
IsUpgraded bool `json:"isUpgraded"` //一个布尔值,指示客户是否升级到另一个订阅。
OfferDiscountType string `json:"offerDiscountType"` //The payment mode you configure for the subscription offer, such as Free Trial, Pay As You Go, or Pay Up Front.
OfferIdentifier string `json:"offerIdentifier"` //包含优惠代码或促销优惠标识符的标识符。
OfferPeriod string `json:"offerPeriod"` //The duration of the offer applied to the transaction.
OfferType int `json:"offerType"` //表示促销优惠类型的值。
OriginalPurchaseDate int64 `json:"originalPurchaseDate"` //UNIX时间,以毫秒为单位,表示原始交易标识符的购买日期
OriginalTransactionID string `json:"originalTransactionId"` //原始购买的交易标识符。
Price int64 `json:"price"` //系统在交易中记录的应用内购买的价格(以毫单位为单位)$1.99=>1990
ProductID string `json:"productId"`
PurchaseDate int64 `json:"purchaseDate"` //App Store向客户帐户收取购买、恢复产品、订阅或订阅续订费用的UNIX时间(以毫秒为单位)
Quantity int `json:"quantity"`
RevocationDate int64 `json:"revocationDate"` //App Store退还交易或从家庭共享中撤销交易的UNIX时间(以毫秒为单位)
RevocationReason string `json:"revocationReason"` // "PURCHASE":客户发起购买;"RENEWAL":App Store服务器启动购买事务以续订自动续订订阅
SignedDate int64 `json:"signedDate"` //App Store对JSON Web签名(JWS)数据进行签名的UNIX时间(以毫秒为单位)
TransactionID string `json:"transactionId"`
TransactionReason string `json:"transactionReason"`
//Type
// "Auto-Renewable Subscription":自动续订订阅,
// "Non-Consumable":非消耗性应用内购买,
// "Consumable":消耗品应用内购买,
// "Non-Renewing Subscription":非续订订阅
Type string `json:"type"` //
Storefront string `json:"storefront"` //代表与App Store店面关联的购买国家或地区的三个字母代码。
StorefrontID string `json:"storefrontId"`
SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier"` //订阅所属订阅组的标识符。
WebOrderLineItemID string `json:"webOrderLineItemId"` //跨设备订阅购买事件的唯一标识符,包括订阅续订
AdvancedCommerceInfo interface{} `json:"advancedCommerceInfo"` //仅适用于高级商务SKU的交易信息。
}
请求apple需要认证的接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// CreateJwtToken 创建一个可以交互的token
func (*storekitDAO) CreateJwtToken(header, claimsMap map[string]interface{}, p8byte []byte) (string, error) {
claims := jwt.MapClaims{}
for k, v := range claimsMap {
claims[k] = v
}
jwtToken := &jwt.Token{
Header: header,
Claims: claims,
Method: jwt.SigningMethodES256,
}
priKey, _ := jwt.ParseECPrivateKeyFromPEM(p8byte)
token, err := jwtToken.SignedString(priKey)
return token, err
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func TestGetOneOrder(t *testing.T) {
transId := "xxxxxxx"
//iapple.StorekitSrv.GetTransactionInfo()
keyID := "wangfuyuXXX" // wangfuyuXXX // 密钥ID
issuerID := "abbbbb-ca11-4824-cccc-00000000" // 发行者ID
btStr, _ := os.ReadFile("/Users/wangfuyu/Downloads/SubscriptionKey_wangfuyuXXX.p8")
header := map[string]interface{}{
"alg": "ES256",
"kid": keyID,
"typ": "JWT",
}
claims := map[string]interface{}{
"iss": issuerID,
"exp": time.Now().Add(10 * time.Minute).Unix(),
"aud": "appstoreconnect-v1",
"nonce": "ssss",
"bid": "360club.net",
}
token, err := iapple.StorekitSrv.CreateJwtToken(header, claims, btStr)
//iapple.StorekitSrv.NotificationsTest(token, dto.EnvSandBox)
//return
//t.Log(token, err)
transactionInfo, err := iapple.StorekitSrv.GetTransactionInfo(token, transId, "Sandbox")
//transactionInfo, err := iapple.StorekitSrv.GetTransactionInfo(token, transId, "Production")
t.Log(transactionInfo, err)
}
避坑
- apple支付没有需要服务端提前创建订单号的,所以如果app支付后,通知服务端的环节失败,则需要避免丢单问题。
- 避免丢单,一定要使用appAccountToken来标记是app内的哪一个用户的,因为apple不存在此类关系。
- 还有通知补单情况,apple的通知最近重复的一次是1小时,如果确保效率,内部自己需要实现补发notify。
结果
开发前,最好还是拿一条数据对照下,知道对象层次,就好顺利接入了。
本文由作者按照 CC BY 4.0 进行授权