文章

learn - 应用内支付-apple-inAppPay

要不是非的用apple支付,谁要对接这个平台付费!

learn - 应用内支付-apple-inAppPay

创作背景

一个付费业务,总归要商业化。

对于富有的使用者来说,能花钱办事的就不耗费在时间上(例如花时间看广告去解锁权益)。

目标

  1. 一次性消费。(道具?金币?虚拟物品?电商实物)
  2. 订阅类型消费。
  3. 非续订类型的权益消费。

行动

文档知识储备

  1. gopay文档地址:https://github.com/go-pay/gopay/blob/main/doc/apple.md
  2. apple支付的服务端回掉:https://developer.apple.com/documentation/appstoreservernotifications/app-store-server-notifications-v2
  3. app对接apple支付:https://developer.apple.com/documentation/storekit/testing-in-app-purchases-with-sandbox?language=objc
  4. 服务端与app交互,验证订单有效性。
  5. 官方视频介绍-Server-iap:https://developer.apple.com/videos/play/wwdc2021/10174/
  6. 官方视频介绍-Storekit:https://developer.apple.com/videos/play/wwdc2021/10114/
  7. 官方视频介绍- 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)

}

避坑

  1. apple支付没有需要服务端提前创建订单号的,所以如果app支付后,通知服务端的环节失败,则需要避免丢单问题。
  2. 避免丢单,一定要使用appAccountToken来标记是app内的哪一个用户的,因为apple不存在此类关系。
  3. 还有通知补单情况,apple的通知最近重复的一次是1小时,如果确保效率,内部自己需要实现补发notify。

结果

开发前,最好还是拿一条数据对照下,知道对象层次,就好顺利接入了。

本文由作者按照 CC BY 4.0 进行授权