[SpringBoot + Swift] sign in with apple / In App Purchases

配置

https://appstoreconnect.apple.com/agreements/
设置税务和银行业务

银行卡

App Store Connect 协议、税务和银行业务中,给付费APP类型添加银行卡需要填写 CNAPS代码,其实就是联行号。
联行号又称大额行号、银联号、银行行号或CNAPS号。

https://www.cwjyz.com.cn/bank/index.html

报税表

  • 默认选择美国
  • 填写美国报税表, 全部
    image.png
image.png
image.png

报税人的职位,如 CEO / CTO / Manager 等

image.png

勾选同意, 提交

App 内购项目

产品 ID 用于报告的唯一字母数字 ID。一旦你将产品 ID 用于某产品,即使删除该产品,此产品 ID 也无法再次使用
我的格式 bundleId_产品名_序号 (如: com.comp.app_vip_1)

image.png

登录/内购

https://developer.apple.com/account/resources/identifiers

image.png

沙盒测试

任何东西随便填, 自己能记住就行,
正确的步骤: 选购商品, 支付, 弹出窗口并登录沙盒, 在 手机/设置/App Store 维护

image.png
image.png

我傻乎乎的跑邮箱去认证了一下, 成功注册了新的 apple id
沙盒账户无法自主登录, 所以最开始在 iPhone - 设置 - App Store 找不到 沙盒账户 入口, 必须从支付那里触发登录

SpringBoot

使用

    @ApiOperation("appleId 登录") 
    @PostMapping(value = "/sign_in_with_apple")
    public Result signInWithApple(String userId, String name, String token) {
        try {
            //验证identityToken
            if(!AppleUtil.verify(token, userId)){
                return Result.fail("授权验证失败");
            }
            // TODO: 保存用户信息
            return Result.success(name);

        } catch (Exception e) {
            return Result.fail("登录失败,稍后再试");
        }
    }

pom.xml

        <!-- sign in with apple -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>jwks-rsa</artifactId>
            <version>0.9.0</version>
        </dependency>

AppleUtil

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.auth0.jwk.Jwk;
import io.jsonwebtoken.*;
import lombok.Data;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.springframework.web.client.RestTemplate;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.math.BigInteger;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.security.spec.RSAPublicKeySpec;
import java.util.HashMap;
import java.util.List;

@Slf4j
public class AppleUtil {

    /**
     * 获取苹果的公钥
     *
     * @return
     * @throws Exception
     */
    private static JSONArray getAuthKeys() {
        String url = "https://appleid.apple.com/auth/keys";
        RestTemplate restTemplate = new RestTemplate();
        JSONObject json = restTemplate.getForObject(url, JSONObject.class);
        JSONArray arr = json.getJSONArray("keys");
        return arr;
    }

    /**
     * 生成授权公钥
     */
    private static PublicKey buildAuthPublicKey(String kid) {
        try {
            // 调用苹果接口获取公钥参数
            JSONArray keys = getAuthKeys();
            if (keys == null || keys.size() == 0) {
                return null;
            }
            JSONObject currentKey = null;

            // 通常情况下返回值keys包含2个,需要根据kid来决定使用哪套公钥参数
            for (int i = 0; i < keys.size(); i++) {
                JSONObject key = keys.getJSONObject(i);
                if (kid.equals(key.getString("kid"))) {
                    currentKey = key;
                    break;
                }
            }
            if (currentKey == null) {
                return null;
            }

            Jwk jwa = Jwk.fromValues(currentKey);
            PublicKey publicKey = jwa.getPublicKey();
            return publicKey;

//            String n = currentKey.getString("n");
//            String e = currentKey.getString("e");
//            BigInteger modulus = new BigInteger(1, Base64.decodeBase64(n));
//            BigInteger publicExponent = new BigInteger(1, Base64.decodeBase64(e));
//            RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, publicExponent);
//            KeyFactory kf = KeyFactory.getInstance("RSA");
//            return kf.generatePublic(spec);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return null;
    }

    /**
     * 验证授权信息是否有效
     *
     * @param identityToken
     * @param userId
     * @return
     */
    public static boolean verify(String identityToken, String userId) {
        String[] identityArr = identityToken.split("\\.");
        if (identityArr.length == 0) {
            return false;
        }

        try {
            // {"kid":"YuyXoY","alg":"RS256"}
            String headStr = new String(Base64.decodeBase64(identityArr[0]), StandardCharsets.UTF_8);
            JSONObject headData = JSONObject.parseObject(headStr);

            /*
             {
                "iss": "https://appleid.apple.com",
                "aud": "com.**.**",
                "exp": 16819**99,
                "iat": 1681**899,
                "sub": "000645.9d**256.1141",
                "c_hash": "1Y9j6**Z-PSDQQ",
                "email": "vqz**elay.appleid.com",
                "email_verified": "true",
                "is_private_email": "true",
                "auth_time": 1681872899,
                "nonce_supported": true
            }
             */
            String identityStr = new String(Base64.decodeBase64(identityArr[1]), StandardCharsets.UTF_8);
            JSONObject identityData = JSONObject.parseObject(identityStr);

            log.debug("headStr: " + headStr);
            log.debug("identityStr: " + identityStr);

            String kid = headData.getString("kid");
            String sub = identityData.getString("sub");
            String iss = identityData.getString("iss");
            String aud = identityData.getString("aud");

            // 对比iOS客户端传过来的用户唯一标识是否和授权凭证一致
            if (!userId.equals(sub)) {
                return false;
            }

            PublicKey publicKey = buildAuthPublicKey(kid);
            if (publicKey == null) {
                return false;
            }

            JwtParser jwtParser = Jwts.parser().setSigningKey(publicKey);
            jwtParser.requireIssuer(iss); // https://appleid.apple.com
            // iOS应用标识
            jwtParser.requireAudience(aud);
            // 用户的唯一标识
            jwtParser.requireSubject(sub);

            Jws<Claims> claims = jwtParser.parseClaimsJws(identityToken);
            if (claims != null && claims.getBody().containsKey("auth_time")) {
                return true;
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return false;
    }




    // MARK: apple 内购 (In App Purchases)

    @Data
    @Accessors(chain = true)
    public static class IAPReceiptRespModel {
        /** 订单号 */
        private String transactionId;

        /** 产品 id*/
        private String productId;

        /** 有 2 种数据类型, 要么直接 productId, 要么在 in_app 数组里 */
        private List<IAPReceiptInAppRespModel> inApp;
    }

    @Data
    public static class IAPReceiptInAppRespModel {
        /** 订单号 */
        private String transactionId;

        /** 产品 id*/
        private String productId;
    }

    /** 购买凭证验证地址 */
    private static final String verifyReceiptURL = "https://buy.itunes.apple.com/verifyReceipt";
    /** 测试的购买凭证验证地址 */
    private static final String sandboxVerifyReceiptURL = "https://sandbox.itunes.apple.com/verifyReceipt";

    /**
     * 重写X509TrustManager
     */
    private static TrustManager myX509TrustManager = new X509TrustManager() {
        @Override
        public X509Certificate[] getAcceptedIssuers() {
            return null;
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
        }

        @Override
        public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
        }
    };

    /**
     * 发送请求 向苹果发起验证支付请求是否有效:本方法有认证方法进行调用
     *
     * @param isSandbox  支付的环境
     * @param receiptStr 接口传递的 receipt
     * @return 结果
     */
    public static String receiptResult(boolean isSandbox, String receiptStr) {
        String urlStr = verifyReceiptURL;
        if (isSandbox) {
            urlStr = sandboxVerifyReceiptURL;
        }

        //  将传过来的转义符 "&quot;" 替换成 "\""
        //receiptStr = receiptStr.replaceAll("&quot;","\"");

        try {
            //设置SSLContext
            SSLContext ssl = SSLContext.getInstance("SSL");
            ssl.init(null, new TrustManager[]{myX509TrustManager}, new java.security.SecureRandom());

            URL console = new URL(urlStr);
            //打开连接
            HttpsURLConnection conn = (HttpsURLConnection) console.openConnection();
            //设置套接工厂
            conn.setSSLSocketFactory(ssl.getSocketFactory());
            //加入数据
            conn.setRequestMethod("POST");
            //conn.setRequestProperty("Content-type", "application/json");
            conn.setRequestProperty("content-type", "text/json");
            conn.setRequestProperty("Proxy-Connection", "Keep-Alive");
            conn.setDoInput(true);
            conn.setDoOutput(true);

            //String str = String.format(java.util.Locale.CHINA, "{\"receipt-data\":\"" + receiptStr + "\"}"); //拼成固定的格式传给平台
            JSONObject obj = new JSONObject();
            obj.put("receipt-data", receiptStr);

            BufferedOutputStream buffOutStr = new BufferedOutputStream(conn.getOutputStream());
            buffOutStr.write(obj.toString().getBytes());
            buffOutStr.flush();
            buffOutStr.close();

            //获取输入流
            BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));

            String line = null;
            StringBuffer sb = new StringBuffer();
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
            return sb.toString();
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return null;
    }

    public static Result<IAPReceiptRespModel> checkReceipt(String receiptStr, String transactionId) {
        // 错误的对象是:{"status":21002},苹果官网写的错误也都是2XXXX 具体含义可查:https://developer.apple.com/documentation/appstorereceipts/status
        // 先提交prod进行验证,如果返回status=21007,然后我们在提交sandbox验证。
        String resultStr = receiptResult(false, receiptStr);
        if (resultStr == null) {
            return Result.fail("苹果验证失败,返回数据为空");
        } else {
            log.info("苹果验证json: " + resultStr);
            JSONObject appleReturn = JSONObject.parseObject(resultStr);
            String status = appleReturn.getString("status");
            //无数据则沙箱环境验证
            if ("21007".equals(status)) {
                resultStr = receiptResult(true, receiptStr);
                log.info("沙盒环境, 苹果验证json: " + resultStr);
                appleReturn = JSONObject.parseObject(resultStr);
                status = appleReturn.getString("status");
            }

            if ("0".equals(status)) {
                // status=0,表示支付成功
                //HashMap receiptMap = appleReturn.getObject("receipt", HashMap.class);
                //JSONObject receiptJSON = appleReturn.getJSONObject("receipt");
                IAPReceiptRespModel resp = appleReturn.getObject("receipt", IAPReceiptRespModel.class);
                if (resp.inApp == null || resp.inApp.isEmpty()) {
                    if (transactionId.equals(resp.getTransactionId())) {
                        return Result.success(resp);
                    }
                } else {
                    if (resp.inApp.contains(transactionId)){
                        return Result.success(resp);
                    }
                }
                return Result.fail("订单不匹配");
            } else {
                return Result.fail("苹果支付失败" + status);
            }
        }
    }

}

Swift

//
//  AppleUtil.swift
//

import Foundation
import RxSwift
import AuthenticationServices
import StoreKit

/*
 - 苹果审核: [如果 App 使用第三方或社交登录服务 (例如,Facebook 登录、Google 登录、通过 Twitter 登录、通过 LinkedIn 登录、通过 Amazon 登录或微信登录) 来对其进行设置或验证这个 App 的用户主帐户,则该 App 必须同时提供“通过 Apple 登录”作为同等选项。](https://developer.apple.com/cn/app-store/review/guidelines/#4.8)
 
 - 示例代码: [Implementing User Authentication with Sign in with Apple](https://developer.apple.com/documentation/authenticationservices/implementing_user_authentication_with_sign_in_with_apple)
 
 - 按钮样式: https://appleid.apple.com/signinwithapple/button
 */

/**
 - 开发者账号中勾选 苹果登录
 - Xcode 签名中添加 苹果登录
 */
@available(iOS 13, *)
class AppleUtil: NSObject {
    
    static let shared = AppleUtil()
    private override init(){
        super.init()
    }
    
    /**
     - AsyncSubject<String>.init() 将在源 Observable 产生完成事件后,发出最后一个元素
     - PublishSubject<String>.init() 将对观察者发送订阅后产生的元素,而在订阅前发出的元素将不会发送给观察者。
     - ReplaySubject<String>.create(bufferSize: 8) 将对观察者发送缓存的8个元素 (或者全部的元素),无论观察者是何时进行订阅的。
     - BehaviorSubject<String>.init(value: "") 对观察者进行订阅时,它会将源 Observable 中最新的元素发送出来(或发送默认元素)
     */
    let userSubject = PublishSubject<AppleUserModel>.init()
    
}

// MARK: apple 登录 (sign in with apple)
extension AppleUtil {
    
    struct AppleUserModel {
        let user: String?
        let token: String?
        let name: String?
        let error: String?
    }
    
    func checkAuth(){
        let appleIDProvide = ASAuthorizationAppleIDProvider()
        appleIDProvide.getCredentialState(forUserID: "") { (credentialState: ASAuthorizationAppleIDProvider.CredentialState, err: Error?) in
            switch credentialState {
            case .revoked, .notFound:
                break
            default:
                break
            }
        }
    }
    
    /// 发起苹果登录
    /// 执行授权请求来获取用户的全名和电子邮件地址
    func login() {
        // 基于用户的Apple ID授权用户,生成用户授权请求的一种机制  主要作用是用创建相应的请求,查询用户授权状态
        let appleIDProvide = ASAuthorizationAppleIDProvider()
        // 授权请求AppleID
        let appIDRequest: ASAuthorizationAppleIDRequest = appleIDProvide.createRequest()
        // 在用户授权期间请求的联系信息   设置具体的请求信息
        appIDRequest.requestedScopes = [ASAuthorization.Scope.fullName]
        
        // 由ASAuthorizationAppleIDProvider创建的授权请求 管理授权请求的控制器
        let authorizationController = ASAuthorizationController(authorizationRequests: [appIDRequest])
        // 设置授权控制器通知授权请求的成功与失败的代理
        authorizationController.delegate = self
        // 设置提供 展示上下文的代理,在这个上下文中 系统可以展示授权界面给用户
        authorizationController.presentationContextProvider = self
        // 在控制器初始化期间启动授权流
        authorizationController.performRequests()
    }
    
    // 如果存在iCloud Keychain 凭证或者AppleID 凭证提示用户
    func perfomExistingAccountSetupFlows() {
        // 基于用户的Apple ID授权用户,生成用户授权请求的一种机制
        let appleIDProvide = ASAuthorizationAppleIDProvider()
        // 授权请求AppleID
        let appIDRequest = appleIDProvide.createRequest()
        
        // 为了执行钥匙串凭证分享生成请求的一种机制
        let passwordProvider = ASAuthorizationPasswordProvider()
        let passwordRequest = passwordProvider.createRequest()
        
        // 由ASAuthorizationAppleIDProvider创建的授权请求 管理授权请求的控制器
        let authorizationController = ASAuthorizationController(authorizationRequests: [appIDRequest, passwordRequest])
        // 设置授权控制器通知授权请求的成功与失败的代理
        authorizationController.delegate = self
        // 设置提供 展示上下文的代理,在这个上下文中 系统可以展示授权界面给用户
        authorizationController.presentationContextProvider = self
        // 在控制器初始化期间启动授权流
        authorizationController.performRequests()
    }
}

// MARK: 授权请求结果 ASAuthorizationControllerDelegate
extension AppleUtil: ASAuthorizationControllerDelegate {
    
    /// 授权成功回调
    /// app 使用该函数将用户的数据储存在钥匙串中
    func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
        
        if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
            // 用户登录使用ASAuthorizationAppleIDCredential
            let user = appleIDCredential.user
            let identityToken = appleIDCredential.identityToken ?? Data()
            let token = String(data: identityToken, encoding: String.Encoding.utf8) ?? ""
            let authorizationCode = appleIDCredential.authorizationCode ?? Data()
            // 使用过授权的,可能获取不到以下三个参数
            let familyName = appleIDCredential.fullName?.familyName ?? ""
            let givenName = appleIDCredential.fullName?.givenName ?? ""
            //let email = appleIDCredential.email ?? ""
            // 用于判断当前登录的苹果账号是否是一个真实用户,取值有:unsupported、unknown、likelyReal
            //let realUserStatus = appleIDCredential.realUserStatus
            
            log.info("AppleUtil.ASAuthorizationAppleIDCredential ==== user \(user)")
            log.info("AppleUtil.ASAuthorizationAppleIDCredential ==== token \(token)")
            log.info("AppleUtil.ASAuthorizationAppleIDCredential ==== familyName \(familyName)")
            log.info("AppleUtil.ASAuthorizationAppleIDCredential ==== givenName \(givenName)")
            
            let model = AppleUserModel(user: user, token: token, name: "\(familyName)\(givenName)", error: nil)
            userSubject.onNext(model)
            
        } else if let passworCreddential = authorization.credential as? ASPasswordCredential {
            // 这个获取的是iCloud记录的账号密码,需要输入框支持iOS 12 记录账号密码的新特性,如果不支持,可以忽略
            // Sign in using an existing iCloud Keychain credential.
            // 用户登录使用现有的密码凭证
            // 密码凭证对象的用户标识 用户的唯一标识
            let user = passworCreddential.user
            // 密码凭证对象的密码
            let password = passworCreddential.password
            
            log.info("AppleUtil.ASPasswordCredential ==== user \(user)")
            log.info("AppleUtil.ASPasswordCredential ==== password \(password)")
            
            // 目前不支持 用户名+密码 登录
            let model = AppleUserModel(user: user, token: password, name: nil, error: nil)
            userSubject.onNext(model)
        }
    }
    /// 授权失败回调
    func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
        let errorStr : String
        switch (error as NSError).code {
        case ASAuthorizationError.canceled.rawValue :
            errorStr = "用户取消了授权请求"
        case ASAuthorizationError.failed.rawValue :
            errorStr = "授权请求失败"
        case ASAuthorizationError.invalidResponse.rawValue :
            errorStr = "授权请求无响应"
        case ASAuthorizationError.notHandled.rawValue :
            errorStr = "未能处理授权请求"
        case ASAuthorizationError.unknown.rawValue :
            errorStr = "授权请求失败原因未知"
        default:
            errorStr = error.localizedDescription
        }
        
        let model = AppleUserModel(user: nil, token: nil, name: nil, error: errorStr)
        userSubject.onNext(model)
    }
}

// MARK: 展示授权控制器的上下文 ASAuthorizationControllerPresentationContextProviding
extension AppleUtil: ASAuthorizationControllerPresentationContextProviding {
    // 从 app 中获取窗口, 将“通过 Apple 登录”内容以模态表单的形式呈现给用户。
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        return UI.window ?? ASPresentationAnchor()
    }
}


// MARK: apple 内购 (In App Purchases)
extension AppleUtil {
    struct AppleIAPModel {
        /// 生产环境验证地址
        static let verifyReceipt = "https://buy.itunes.apple.com/verifyReceipt"
        /// 沙盒验证地址
        static let itmsSandboxVerifyReceipt = "https://sandbox.itunes.apple.com/verifyReceipt"
        static let productIdentifiers: Set<String> = [
            "bundle_id_product_name_1",
            "bundle_id_product_name_2",
            "bundle_id_product_name_3",
        ]
    }
    
    var receiptURL: String {
        switch NetworkApi.activeEnvironment {
        case .appStore:
            return AppleIAPModel.verifyReceipt
        case .develop:
            return AppleIAPModel.itmsSandboxVerifyReceipt
        }
    }
    
    /// 添加监听
    func addSKPaymentTransactionObserver() {
        SKPaymentQueue.default().add(self)
    }
    
    /// 申请商品列表
    func productsRequest() {
        HUD.show()
        
        let productsRequest = SKProductsRequest(productIdentifiers: AppleIAPModel.productIdentifiers)
        productsRequest.delegate = self
        productsRequest.start()
    }
    
    func payment(product: SKProduct){
        guard SKPaymentQueue.canMakePayments() else {
            HUD.error(title: "不支持内购", message: nil)
            return
        }
        HUD.show()
        
        let payment = SKMutablePayment(product: product)
        payment.quantity = 1 // 商品数量
        SKPaymentQueue.default().add(payment) // SKPaymentQueue负责与App Store的通信
    }
    
    func reqReceipt(){
        let receiptRefreshReq = SKReceiptRefreshRequest(receiptProperties: nil)
        receiptRefreshReq.delegate = self
        receiptRefreshReq.start()
    }
    func getReceipt(){
        // 验证购买凭据
        // Get the receipt if it's available.
        if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
           FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
            do {
                let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
                let encodedReceipt = receiptData.base64EncodedString(options: Data.Base64EncodingOptions.endLineWithLineFeed)
                log.console("==== AppleUtil ==== check \(encodedReceipt)")
            }
            catch { print("Couldn't read receipt data with error: " + error.localizedDescription) }
        } else {
            reqReceipt()
        }
    }
    /// 放在服务器上 校验
    func check(encodedReceipt: String) {
        let param = ["receipt-data" : encodedReceipt]
        let urlStr = AppleIAPModel.verifyReceipt
        guard let body = try? JSONSerialization.data(withJSONObject: param, options: JSONSerialization.WritingOptions.prettyPrinted),
              let url = URL(string: urlStr) else { return }
        
        var request = URLRequest(url: url, cachePolicy: URLRequest.CachePolicy.useProtocolCachePolicy, timeoutInterval: 20)
        request.httpMethod = "POST"
        request.httpBody = body
        
        let session = URLSession.shared
        let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?)in
            guard let data = data,
                  let dict = try? JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.mutableContainers) as? [String: Any?],
                  let status = dict["status"] as? Int, status == 0
            else {
                return
            }
            let original_transaction_id = dict["original_transaction_id"]
            let transaction_id = dict["transaction_id"]
            let product_id = dict["product_id"]
        }
        task.resume()
    }
}

extension AppleUtil: SKProductsRequestDelegate {
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        HUD.hide()
        
        let products: [SKProduct] = response.products
        let invalidProductIdentifiers: [String] = response.invalidProductIdentifiers
        log.console("==== AppleUtil ==== 商品: \(invalidProductIdentifiers)")
        
        products.forEach {
            log.console("==== AppleUtil ==== 商品, 标志符: \($0.productIdentifier)")
            log.console("==== AppleUtil ==== 商品, 标题: \($0.localizedTitle)")
            log.console("==== AppleUtil ==== 商品, 描述: \($0.localizedDescription)")
            log.console("==== AppleUtil ==== 商品, 价格: \($0.price)")
            log.console("==== AppleUtil ==== 商品, 本地价格: \($0.priceLocale)")
            
            //创建一个NumberFormatter对象
            let numberFormatter = NumberFormatter()
            numberFormatter.numberStyle = .currencyAccounting
            numberFormatter.positiveSuffix = "元" //自定义后缀
            numberFormatter.locale = $0.priceLocale
            
            let format = numberFormatter.string(from: $0.price)
            log.console("==== AppleUtil ==== 商品, format 价格: \(format)")
            
            let currency = NumberFormatter.localizedString(from: $0.price, number: NumberFormatter.Style.currency)
            log.console("==== AppleUtil ==== 商品, currency 价格: \(currency)")
        }
    }
}

extension AppleUtil: SKPaymentTransactionObserver {
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        HUD.hide()
        
        for transaction: SKPaymentTransaction in transactions {
            let transactionIdentifier = transaction.transactionIdentifier
            print("==== AppleUtil ==== 付款, transaction: \(transactionIdentifier)")
            let productIdentifier = transaction.payment.productIdentifier
            print("==== AppleUtil ==== 付款, 商品: \(productIdentifier)")
            let transactionState: SKPaymentTransactionState = transaction.transactionState
            switch transactionState {
            case SKPaymentTransactionState.purchasing:
                print("==== AppleUtil ==== 付款状态: 付款中")
                
            case .purchased:
                print("==== AppleUtil ==== 付款状态: 已付款")
                getReceipt()
                SKPaymentQueue.default().finishTransaction(transaction)
                
            case .failed:
                print("==== AppleUtil ==== 付款状态: 付款失败")
                SKPaymentQueue.default().finishTransaction(transaction)
                
            case .restored:
                print("==== AppleUtil ==== 付款状态: 之前已经付款")
                SKPaymentQueue.default().finishTransaction(transaction)
                
            case .deferred:
                print("==== AppleUtil ==== 付款状态: 待定, 需要家长确认")
                
            @unknown default:
                print("==== AppleUtil ==== 付款状态: default")
                SKPaymentQueue.default().finishTransaction(transaction)
            }
        }
    }
    
}

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容