Swift苹果内购IAP(余额充值场景)

前言

老夫接到一个项目,需要苹果内购做充值,经过一周多的努力把核心代码完成,希望对大家有所帮助。

效果图

废话不多说,直接上图

余额充值页面

流程说明

  • 正常充值流程
    1、【可选】App询问服务端是否可以请求充值ACG_PAY_50(50元)
    2、若允许,App向苹果发起支付(50元)
    3、苹果支付成功,返回凭证(payload)到App
    4.1、App使用payload请求本接口,
    4.2、服务端使用payload到苹果拿到下面Json数据
    4.3、校验Json数据中transaction_id是否已经使用
    4.4、若未使用,则给用户充值(50元),生成充值记录
    4.5、处理结束,返回成功(code=success)
    5、App把收到凭证从本地清除,完成支付,刷新余额

  • 异常情况:
    1、若充值过程(第3步)退出App,可充值成功,余额未给用户增加,再次打开App完成后续步骤即可
    2、若校验过程(第4步)网络断开,则可能校验成功,余额增加,App未收到校验信息,再次打开App仍然会发布充值,服务端判断transaction_id为重复,忽略即可。
    3、校验返回码每次都不成功时,App不会清除凭证,会造成每次打开App都校验,该商品也无法再次购买成功(系统提示:已经支付,可以恢复购买)。

核心代码,稍作修改即可复用

  • IAPHelper.swift
//
//  IAPHelper.swift
//  IAP
//
//  Created by lin bo on 2019/5/13.
//  Copyright © 2019 appTech. All rights reserved.
//

import UIKit
import StoreKit

/// 商品列表
enum ACG_PAY_ID: String {
    
    case pay50 = "ACG_PAY_50"
    case pay98 = "ACG_PAY_98"
    case pay148 = "ACG_PAY_148"
    case pay198 = "ACG_PAY_198"
    case pay248 = "ACG_PAY_248"
    case pay298 = "ACG_PAY_298"

    func price() -> Int {
        
        switch self {
            
        case .pay50: return 50
        case .pay98: return 98
        case .pay148: return 148
        case .pay198: return 198
        case .pay248: return 248
        case .pay298: return 298
        }
    }
}

/// 回调状态
enum IAPProgress: Int {
    
    /// 初始状态
    case none
    /// 开始
    case started
    /// 购买中
    case purchasing
    
    /// 支付成功
    case purchased
    /// 失败
    case payFailed
    /// 重复购买
    case payRestored
    /// 状态未确认
    case payDeferred
    /// 其他
    
    case payOther
    /// 开始后端校验
    case checking
    /// 后端校验成功
    case checkedSuccess
    /// 后端校验失败
    case checkedFailed

}

enum IAPPayCheck {
    
    case busy /// 有支付正在进行
    case notInit /// 未初始化
    case initFailed /// 初始化失败
    case notFound /// 没有找到该商品,中断
    case systemFailed /// 系统检测失败
    case ok /// 可以进行

}

class IAPHelper: ATBaseHelper {
    
    static let shared = IAPHelper()
    
    /// 检测初始化回调
    fileprivate var checkBlock: ((_ b: IAPPayCheck) -> ())?
    /// 支付过程回调
    var resultBlock: ((_ type: IAPProgress, _ pID: ACG_PAY_ID?) -> ())?
    
    /// 是否正在支付
    fileprivate var isBusy: Bool {
        get {
            switch progress {
            case .none:
                return false
            default:
                return true
            }
        }
    }
   
    /// 购买的状态
    fileprivate var progress: IAPProgress = .none {
        didSet {
            /// 状态改变回调
            if let block = resultBlock {
                block(progress, currentPID)
            }
        }
    }
    
    /// 当前付费的ID
    fileprivate var currentPID: ACG_PAY_ID?
    /// 商品列表
    fileprivate var productList: [SKProduct]?

    /// 初始化配置,请求商品
    func config() {
        
        SKPaymentQueue.default().add(self)
        requestAllProduct()
    }
    
    /// 初始化,请求商品列表
    func initPayments(_ block: @escaping ((_ b: IAPPayCheck) -> ())) {
        
        let c = checkPayments()
        
        if c == .notInit {
            
            requestAllProduct()
            checkBlock = block

        }else {
            
            block(c)
        }
    }
    
    /// 检测支付环境,非.ok不允许充值
    func checkPayments() -> IAPPayCheck {
        
        guard isBusy == false else {
            return .busy
        }
        
        guard let plist = productList, !plist.isEmpty else {
            return .notInit
        }
        
        guard SKPaymentQueue.canMakePayments() else {
            return .systemFailed
        }
        
        return .ok
    }
    
    /// 请求商品列表
    private func requestAllProduct() {
        
        let set: Set<String> = [ACG_PAY_ID.pay50.rawValue,
                        ACG_PAY_ID.pay98.rawValue,
                        ACG_PAY_ID.pay148.rawValue,
                        ACG_PAY_ID.pay198.rawValue,
                        ACG_PAY_ID.pay248.rawValue,
                        ACG_PAY_ID.pay298.rawValue]
        
        let request = SKProductsRequest(productIdentifiers: set)
        request.delegate = self
        request.start()
    }
    
    /// 支付商品
    @discardableResult
    func pay(pID: ACG_PAY_ID) -> IAPPayCheck {
        
        let c = checkPayments()
        
        if c == .ok {
            
            guard let plist = productList, !plist.isEmpty else {
                return .notInit
            }

            let pdts = plist.filter {
                return $0.productIdentifier == pID.rawValue
            }
            
            guard let product = pdts.first else {
                return .notFound
            }
            
            currentPID = pID
            requestProduct(pdt: product)
        }
        
        return c
    }
    
    /// 请求充值
    fileprivate func requestProduct(pdt: SKProduct) {
        
        progress = .started

        let pay: SKMutablePayment = SKMutablePayment(product: pdt)
        SKPaymentQueue.default().add(pay)
    }
    
    /// 重置
    fileprivate func payFinish() {
        
        currentPID = nil
        progress = .none
    }
    
    /// 充值完成后给后台校验
    func completeTransaction(_ checkList: [SKPaymentTransaction]) {
        
        if resultBlock == nil {
            showAlert("充值校验中...")
        }
        ALog("充值校验中...")
        progress = .checking
        
        guard let rURL = Bundle.main.appStoreReceiptURL, let data = try? Data(contentsOf: rURL) else {
            ALog("appStoreReceiptURL error")
            
            progress = .checkedFailed
            payFinish()
            return
        }
        
        let str = data.base64EncodedString()
        print(str)
        
        OrderServer.shared.requestCheckIAP(str) { [weak self] (code, msg, result) in
            
            guard let helper = self else {
                return
            }
            
            if result { // 成功则删除
                checkList.forEach({ (transaction) in
                    SKPaymentQueue.default().finishTransaction(transaction)
                })
            }
            
            if helper.resultBlock == nil {
                showAlert(result ? "校验成功" : "校验失败")
            }
            ALog(result ? "充值成功" : "充值失败")
            
            helper.progress = result ? .checkedSuccess : .checkedFailed
            helper.payFinish()
        }
    }
}

// MARK: - SKProductsRequestDelegate
extension IAPHelper: SKProductsRequestDelegate {
    
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        ALog("---IAP---")

        if currentPID == nil {
            // 列表赋值
            productList = response.products
        }
    }
    
    func requestDidFinish(_ request: SKRequest) {
        ALog("---IAP---")

        if currentPID == nil {
            
            if let block = checkBlock {
                
                if let pList = productList, !pList.isEmpty {
                    block(.ok)
                }else {
                    block(.initFailed)
                }
                checkBlock = nil
            }
        }
    }
    
    func request(_ request: SKRequest, didFailWithError error: Error) {
        ALog("---IAP---")

        if currentPID == nil {
            
            if let block = checkBlock {
                block(.initFailed)
                checkBlock = nil
            }
        }
    }
}

// MARK: - SKPaymentTransactionObserver
extension IAPHelper: SKPaymentTransactionObserver {
    
    func paymentQueue(_ queue: SKPaymentQueue, updatedDownloads downloads: [SKDownload]) {
        ALog("---IAP---")
    }
    
    func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) {
        ALog("---IAP---")
    }
    
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        ALog("---IAP---")

        var checkList: [SKPaymentTransaction] = []
        var type: IAPProgress = progress

        for transaction in transactions {
            
            ALog("支付结果: \(transaction.description)")

            let pid = transaction.payment.productIdentifier
            switch transaction.transactionState {
                
            case .purchasing:
                
                ALog("支付中:\(pid)")
                type = .purchasing

            case .purchased:
                
                checkList.append(transaction)
                ALog("支付成功:\(pid)")
                type = .purchased

            case .failed:
                
                ALog("支付失败:\(pid)")
                type = .payFailed
                SKPaymentQueue.default().finishTransaction(transaction)

            case .restored:
                
                checkList.append(transaction)
                ALog("支付已购买过:\(pid)")
                type = .payRestored

            case .deferred:
                
                ALog("支付不确认:\(pid)")
                type = .payDeferred
                SKPaymentQueue.default().finishTransaction(transaction)

            @unknown default:
                
                ALog("支付未知状态:\(pid)")
                type = .payOther
                SKPaymentQueue.default().finishTransaction(transaction)
            }
        }
        
        progress = type
        
        if !checkList.isEmpty {
            // 有内购已经完成
            completeTransaction(checkList)
            
        }else if type == .purchasing {
            // 正常情况:内购正在支付
            // 特殊情况:若该商品已购买,未执行finishTransaction,系统会提示(免费恢复项目),回调中断
            // 解决方法:在应用开启的时候捕捉到restored状态的商品,提交后台校验后执行finishTransaction

        }else { // 其他状态
            
            payFinish()
        }
    }

    func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
        ALog("---IAP---")
    }
    
    func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
        ALog("---IAP---")
    }
    
    func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool {
        ALog("---IAP---")
        return true
    }
}

  • ViewController调用

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // 回调支持过程,处理HUD显隐和用户提示
        IAPHelper.shared.resultBlock = { [weak self] (result, pID) in
            
            guard let vc = self else {
                return
            }
            
            switch result {

            case .none:
                break
                
            case .started:
                vc.updateHUD(true, text: "支付中")
                
            case .purchasing:
                break
                
            case .purchased:
                break
                
            case .payFailed:
                vc.updateHUD(false, text: "支付取消")

            case .payRestored:
                vc.updateHUD(false)

            case .payDeferred:
                vc.updateHUD(false)

            case .payOther:
                vc.updateHUD(false)

            case .checking:
                vc.updateHUD(true, text: "充值中")
                
            case .checkedSuccess:
                vc.updateHUD(false, text: "充值成功")
                vc.updateData()
                
            case .checkedFailed:
                vc.updateHUD(false, text: "充值失败,请检测网络")
                vc.updateData()
            }
        }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        
        IAPHelper.shared.resultBlock = nil
    }

    @IBAction func payAction(_ sender: Any) {
        
        for bt in sumBtns {
            if bt.isSelected == true {
 
                switch bt.tag {
                case 1:
                    pay(id: .pay50)
                case 2:
                    pay(id: .pay98)
                case 3:
                    pay(id: .pay148)
                case 4:
                    pay(id: .pay198)
                case 5:
                    pay(id: .pay248)
                case 6:
                    pay(id: .pay298)
                default:
                    break
                }
                break
            }
        }
    }

    func pay(id: ACG_PAY_ID) {
        
        // 请求支付
        let p = IAPHelper.shared.pay(pID: id)
        
        switch p {
            
        case .ok:
            break
            
        case .notInit:
            IAPHelper.shared.initPayments { (c) in
                
                if c == .ok {
                    
                    if IAPHelper.shared.pay(pID: id) != .ok {
                        showAlert("暂时无法支付,请稍后再试")
                    }
                    
                }else {
                    showAlert("暂时无法支付,请稍后再试")
                }
            }
            break

        default:
            showAlert("暂时无法支付,请稍后再试")
            break
        }
    }

  • AppDelegate需要初始化一下
IAPHelper.shared.config()

附件:

  • 支付成功后的交易凭证数据很大,后端接收这个数据,跟苹果校验后,判断用户即可给该用户充值。


        guard let rURL = Bundle.main.appStoreReceiptURL, let data = try? Data(contentsOf: rURL) else {
            ALog("appStoreReceiptURL error")
        }
        
        let str = data.base64EncodedString()
        print(str)


  • 打印出来是这样的(很长)

MIIVKgYJKoZIhvcNAQcCoIIVGzCCFRcCAQExCzAJBgUrDgMCGgUAMIIEywYJKoZIhvcNAQcBoIIEvASCBLgxggS0MAoCAQgCAQEEAhYAMAoCARQCAQEEAgwAMAsCAQECAQEEAwIBADALAgEDAgEBBAMMATEwCwIBCwIBAQQDAgEAMAsCAQ8CAQEEAwIBADALAgEQAgEBBAMCAQAwCwIBGQIBAQQDAgEDMAwCAQoCAQEEBBYCNCswDAIBDgIBAQQEAgIAiTANAgENAgEBBAUCAwHViDANAgETAgEBBAUMAzEuMDAOAgEJAgEBBAYCBFAyNTIwGAIBBAIBAgQQEXxl0gRk5NBqMO8/VFkmNzAZA... ...

  • 苹果返回Json

1、收到这个数据表示,里面的项目肯定付费成功
2、通过transaction_id判断是否重复校验
3、通过product_id * quantity 判断支付金额
4、通过environment判断所在环境

注意:用户信息通过判断App登录用户,透传App用户信息和App订单信息都是不可靠的。


{
  "environment": "Sandbox",
  "receipt": {
    "in_app": [{
        "transaction_id": "1000000529594470",
        "original_purchase_date": "2019-05-21 08:25:55 Etc/GMT",
        "quantity": "1",
        "original_transaction_id": "1000000529594470",
        "purchase_date_pst": "2019-05-21 01:25:55 America/Los_Angeles",
        "original_purchase_date_ms": "1558427155000",
        "purchase_date_ms": "1558427155000",
        "product_id": "ACG_PAY_50",
        "original_purchase_date_pst": "2019-05-21 01:25:55 America/Los_Angeles",
        "is_trial_period": "false",
        "purchase_date": "2019-05-21 08:25:55 Etc/GMT"
      },
      {
        "transaction_id": "1000000529074541",
        "original_purchase_date": "2019-05-20 02:29:04 Etc/GMT",
        "quantity": "1",
        "original_transaction_id": "1000000529074541",
        "purchase_date_pst": "2019-05-19 19:29:04 America/Los_Angeles",
        "original_purchase_date_ms": "1558319344000",
        "purchase_date_ms": "1558319344000",
        "product_id": "ACG_PAY_98",
        "original_purchase_date_pst": "2019-05-19 19:29:04 America/Los_Angeles",
        "is_trial_period": "false",
        "purchase_date": "2019-05-20 02:29:04 Etc/GMT"
      }
    ],
    "adam_id": 0,
    "receipt_creation_date": "2019-05-21 08:51:54 Etc/GMT",
    "original_application_version": "1.0",
    "app_item_id": 0,
    "original_purchase_date_ms": "1375340400000",
    "request_date_ms": "1558430410539",
    "original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles",
    "original_purchase_date": "2013-08-01 07:00:00 Etc/GMT",
    "receipt_creation_date_pst": "2019-05-21 01:51:54 America/Los_Angeles",
    "receipt_type": "ProductionSandbox",
    "bundle_id": "com.xxx.acg",
    "receipt_creation_date_ms": "1558428714000",
    "request_date": "2019-05-21 09:20:10 Etc/GMT",
    "version_external_identifier": 0,
    "request_date_pst": "2019-05-21 02:20:10 America/Los_Angeles",
    "download_id": 0,
    "application_version": "1"
  },
  "status": 0
}

后端实现参考文章

1、流程写得很详细,php源码也有:
http://www.cnblogs.com/wangboy91/p/7162335.html
2、Java端的支持:
https://blog.csdn.net/jianzhonghao/article/details/79343887

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