iOS 內(nèi)購:請你一定要讀完,再也不怕踩坑了

什么是內(nèi)購?

內(nèi)購(In-App Purchase),顧名思義就是在應(yīng)用內(nèi)購買。在了解完其含義后,我們還需知道內(nèi)購(In-App Purchase)蘋果支付(Apple Pay)的區(qū)別。

  • 蘋果支付(Apple Pay):是一種支付方式,跟支付寶、微信支付是類似的,這里就不詳細介紹了。

  • 內(nèi)購(In-App Purchase):只要在 iOS/iPadOS 設(shè)備上的 App 里購買非實物產(chǎn)品 (也就是虛擬產(chǎn)品,如:“qq 幣、魚翅、電子書......”) ,都需要走內(nèi)購流程,蘋果從這里面抽走 30% 分成。

內(nèi)購集成

一般來說,開發(fā)者剛接觸到內(nèi)購,都會遇到流程不清楚、代碼邏輯混亂和各種踩坑。那么,如何一次性搞定 iOS 內(nèi)購?接下來本文將分成三個部分講解:

  • 前期準備工作
  • 開發(fā)實現(xiàn)
  • 注意事項和踩坑解決辦法

一、前期準備工作

Xcode Project Configuration
  • 開發(fā)實現(xiàn)流程
IAP Implementation Flow

二、開發(fā)實現(xiàn)

PS:每個開發(fā)者帳戶可在該帳戶的所有 App 中創(chuàng)建最多 10,000 個 App 內(nèi)購買項目產(chǎn)品。App 內(nèi)購買項目共有四種類型:消耗型、非消耗型、自動續(xù)期訂閱和非續(xù)期訂閱。

推薦 Swift 開源庫 DYFStore,使用此開源庫可直接省去很多繁瑣復(fù)雜的實現(xiàn),提高工作效率。另附 Objective-C 版 DYFStoreKit

接入 StoreKit 準備

  • 首先在項目工程中加入 StoreKit.framework
  • 在實現(xiàn)文件導(dǎo)入 import StoreKit#import <StoreKit /StoreKit.h>
  • 在實現(xiàn)類遵守協(xié)議 SKProductsRequestDelegate, SKPaymentTransactionObserver

初始化工作

  1. 是否允許將日志輸出到控制臺,在 Debug 模式下將 enableLog 設(shè)置 true,查看內(nèi)購過程的日志,在 Release 模式下發(fā)布 App 時將 enableLog 設(shè)置 false
  2. 添加交易的觀察者,監(jiān)聽交易的變化。
  3. 實例化數(shù)據(jù)持久,存儲交易的相關(guān)信息。
  4. 遵守協(xié)議 DYFStoreAppStorePaymentDelegate,處理從 App Store 購買產(chǎn)品的付款。
  • Swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    // Wether to allow the logs output to console.
    DYFStore.default.enableLog = true

    // Adds an observer that responds to updated transactions to the payment queue.
    // If an application quits when transactions are still being processed, those transactions are not lost. The next time the application launches, the payment queue will resume processing the transactions. Your application should always expect to be notified of completed transactions.
    // If more than one transaction observer is attached to the payment queue, no guarantees are made as to the order they will be called in. It is recommended that you use a single observer to process and finish the transaction.
    DYFStore.default.addPaymentTransactionObserver()

    // Sets the delegate processes the purchase which was initiated by user from the App Store.
    DYFStore.default.delegate = self

    DYFStore.default.keychainPersister = DYFStoreKeychainPersistence()

    return true
}
  • Objective-C
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

    // Adds an observer that responds to updated transactions to the payment queue.
    // If an application quits when transactions are still being processed, those transactions are not lost. The next time the application launches, the payment queue will resume processing the transactions. Your application should always expect to be notified of completed transactions.
    // If more than one transaction observer is attached to the payment queue, no guarantees are made as to the order they will be called in. It is recommended that you use a single observer to process and finish the transaction.
    [DYFStore.defaultStore addPaymentTransactionObserver];

    // Sets the delegate processes the purchase which was initiated by user from the App Store.
    DYFStore.defaultStore.delegate = self;

    DYFStore.defaultStore.keychainPersister = [[DYFStoreKeychainPersistence alloc] init];

    return YES;
}

處理從 App Store 購買產(chǎn)品的付款

只有在 iOS 11.0 或更高的版本,才能處理用戶從 App Store 商店發(fā)起購買產(chǎn)品的請求。因為這個接口 ( API ) 是在 iOS 11.0 或更高的版本才生效的。

  • Swift
// Processes the purchase which was initiated by user from the App Store.
func didReceiveAppStorePurchaseRequest(_ queue: SKPaymentQueue, payment: SKPayment, forProduct product: SKProduct) {
    
    if !DYFStore.canMakePayments() {
        self.showTipsMessage("Your device is not able or allowed to make payments!")
        return
    }
    
    // Get account name from your own user system.
    let accountName = "Handsome Jon"
    
    // This algorithm is negotiated with server developer.
    let userIdentifier = DYF_SHA256_HashValue(accountName) ?? ""
    DYFStoreLog("userIdentifier: \(userIdentifier)")
    
    DYFStore.default.purchaseProduct(product.productIdentifier, userIdentifier: userIdentifier)
}
  • Objective-C
// Processes the purchase which was initiated by user from the App Store.
- (void)didReceiveAppStorePurchaseRequest:(SKPaymentQueue *)queue payment:(SKPayment *)payment forProduct:(SKProduct *)product {
    
    if (![DYFStore canMakePayments]) {
        [self showTipsMessage:@"Your device is not able or allowed to make payments!"];
        return;
    }
    
    // Get account name from your own user system.
    NSString *accountName = @"Handsome Jon";
    
    // This algorithm is negotiated with server developer.
    NSString *userIdentifier = DYF_SHA256_HashValue(accountName);
    DYFStoreLog(@"userIdentifier: %@", userIdentifier);
    
    [DYFStore.defaultStore purchaseProduct:product.productIdentifier userIdentifier:userIdentifier];
}

Indicates whether the user is allowed to make payments.
An iPhone can be restricted from accessing the Apple App Store. For example, parents can restrict their children’s ability to purchase additional content. Your application should confirm that the user is allowed to authorize payments before adding a payment to the queue. Your application may also want to alter its behavior or appearance when the user is not allowed to authorize payments.

創(chuàng)建商品查詢的請求

有兩種策略可用于從應(yīng)用程序商店獲取有關(guān)產(chǎn)品的信息。

策略1: 在開始購買過程,首先必須清楚有哪些產(chǎn)品標識符。App 可以使用其中一個產(chǎn)品標識符來獲取應(yīng)用程序商店中可供銷售的產(chǎn)品的信息,并直接提交付款請求。

  • Swift
@IBAction func fetchesProductAndSubmitsPayment(_ sender: Any) {
    self.showLoading("Loading...")
    
    let productId = "com.hncs.szj.coin42"
    
    DYFStore.default.requestProduct(withIdentifier: productId, success: { (products, invalidIdentifiers) in
        
        self.hideLoading()
        
        if products.count == 1 {
            
            let productId = products[0].productIdentifier
            self.addPayment(productId)
            
        } else {
            
            self.showTipsMessage("There is no this product for sale!")
        }
        
    }) { (error) in
        
        self.hideLoading()
        
        let value = error.userInfo[NSLocalizedDescriptionKey] as? String
        let msg = value ?? "\(error.localizedDescription)"
        self.sendNotice("An error occurs, \(error.code), " + msg)
    }
}

private func addPayment(_ productId: String) {
    
    if !DYFStore.canMakePayments() {
        self.showTipsMessage("Your device is not able or allowed to make payments!")
        return
    }
    
    // Get account name from your own user system.
    let accountName = "Handsome Jon"
    
    // This algorithm is negotiated with server developer.
    let userIdentifier = DYF_SHA256_HashValue(accountName) ?? ""
    DYFStoreLog("userIdentifier: \(userIdentifier)")
    
    DYFStore.default.purchaseProduct(productId, userIdentifier: userIdentifier)
}
  • Objective-C
- (IBAction)fetchesProductAndSubmitsPayment:(id)sender {
    [self showLoading:@"Loading..."];
    
    NSString *productId = @"com.hncs.szj.coin48";
    
    [DYFStore.defaultStore requestProductWithIdentifier:productId success:^(NSArray *products, NSArray *invalidIdentifiers) {
        
        [self hideLoading];
        
        if (products.count == 1) {
            
            NSString *productId = ((SKProduct *)products[0]).productIdentifier;
            [self addPayment:productId];
            
        } else {
            
            [self showTipsMessage:@"There is no this product for sale!"];
        }
        
    } failure:^(NSError *error) {
        
        [self hideLoading];
        
        NSString *value = error.userInfo[NSLocalizedDescriptionKey];
        NSString *msg = value ?: error.localizedDescription;
        [self sendNotice:[NSString stringWithFormat:@"An error occurs, %zi, %@", error.code, msg]];
    }];
}

- (void)addPayment:(NSString *)productId {
    
    // Get account name from your own user system.
    NSString *accountName = @"Handsome Jon";
    
    // This algorithm is negotiated with server developer.
    NSString *userIdentifier = DYF_SHA256_HashValue(accountName);
    DYFStoreLog(@"userIdentifier: %@", userIdentifier);
    
    [DYFStore.defaultStore purchaseProduct:productId userIdentifier:userIdentifier];
}

策略2: 在開始購買過程,首先必須清楚有哪些產(chǎn)品標識符。App 從應(yīng)用程序商店獲取有關(guān)產(chǎn)品的信息,并向用戶顯示其商店用戶界面。App 中銷售的每個產(chǎn)品都有唯一的產(chǎn)品標識符。App 使用這些產(chǎn)品標識符獲取有關(guān)應(yīng)用程序商店中可供銷售的產(chǎn)品的信息,例如定價,并在用戶購買這些產(chǎn)品時提交付款請求。

  • Swift
func fetchProductIdentifiersFromServer() -> [String] {
    
    let productIds = [
        "com.hncs.szj.coin42",   // 42 gold coins for ¥6.
        "com.hncs.szj.coin210",  // 210 gold coins for ¥30.
        "com.hncs.szj.coin686",  // 686 gold coins for ¥98.
        "com.hncs.szj.coin1386", // 1386 gold coins for ¥198.
        "com.hncs.szj.coin2086", // 2086 gold coins for ¥298.
        "com.hncs.szj.coin4886", // 4886 gold coins for ¥698.
        "com.hncs.szj.vip1",     // non-renewable vip subscription for a month.
        "com.hncs.szj.vip2"      // Auto-renewable vip subscription for three months.
    ]
    
    return productIds
}

@IBAction func fetchesProductsFromAppStore(_ sender: Any) {
    self.showLoading("Loading...")
    
    let productIds = fetchProductIdentifiersFromServer()
    
    DYFStore.default.requestProduct(withIdentifiers: productIds, success: { (products, invalidIdentifiers) in
        
        self.hideLoading()
        
        if products.count > 0 {
            
            self.processData(products)
            
        } else if products.count == 0 &&
            invalidIdentifiers.count > 0 {
            
            // Please check the product information you set up.
            self.showTipsMessage("There are no products for sale!")
        }
        
    }) { (error) in
        
        self.hideLoading()
        
        let value = error.userInfo[NSLocalizedDescriptionKey] as? String
        let msg = value ?? "\(error.localizedDescription)"
        self.sendNotice("An error occurs, \(error.code), " + msg)
    }
}

private func processData(_ products: [SKProduct]) {
    
    var modelArray = [DYFStoreProduct]()
    
    for product in products {
        
        let p = DYFStoreProduct()
        p.identifier = product.productIdentifier
        p.name = product.localizedTitle
        p.price = product.price.stringValue
        p.localePrice = DYFStore.default.localizedPrice(ofProduct: product)
        p.localizedDescription = product.localizedDescription
        
        modelArray.append(p)
    }
    
    self.displayStoreUI(modelArray)
}

private func displayStoreUI(_ dataArray: [DYFStoreProduct]) {
    
    if !DYFStore.canMakePayments() {
        self.showTipsMessage("Your device is not able or allowed to make payments!")
        return
    }
    
    let storeVC = DYFStoreViewController()
    storeVC.dataArray = dataArray
    self.navigationController?.pushViewController(storeVC, animated: true)
}
  • Objective-C
- (NSArray *)fetchProductIdentifiersFromServer {
    
    NSArray *productIds = @[@"com.hncs.szj.coin42",   // 42 gold coins for ¥6.
                            @"com.hncs.szj.coin210",  // 210 gold coins for ¥30.
                            @"com.hncs.szj.coin686",  // 686 gold coins for ¥98.
                            @"com.hncs.szj.coin1386", // 1386 gold coins for ¥198.
                            @"com.hncs.szj.coin2086", // 2086 gold coins for ¥298.
                            @"com.hncs.szj.coin4886", // 4886 gold coins for ¥698.
                            @"com.hncs.szj.vip1",     // non-renewable vip subscription for a month.
                            @"com.hncs.szj.vip2"      // Auto-renewable vip subscription for three months.
    ];
    
    return productIds;
}

- (IBAction)fetchesProductsFromAppStore:(id)sender {
    [self showLoading:@"Loading..."];
    
    NSArray *productIds = [self fetchProductIdentifiersFromServer];
    
    [DYFStore.defaultStore requestProductWithIdentifiers:productIds success:^(NSArray *products, NSArray *invalidIdentifiers) {
        
        [self hideLoading];
        
        if (products.count > 0) {
            
            [self processData:products];
            
        } else if (products.count == 0 && invalidIdentifiers.count > 0) {
            
            // Please check the product information you set up.
            [self showTipsMessage:@"There are no products for sale!"];
        }
        
    } failure:^(NSError *error) {
        
        [self hideLoading];
        
        NSString *value = error.userInfo[NSLocalizedDescriptionKey];
        NSString *msg = value ?: error.localizedDescription;
        [self sendNotice:[NSString stringWithFormat:@"An error occurs, %zi, %@", error.code, msg]];
    }];
}

- (void)processData:(NSArray *)products {
    
    NSMutableArray *modelArray = [NSMutableArray arrayWithCapacity:0];
    
    for (SKProduct *product in products) {
        
        DYFStoreProduct *p = [[DYFStoreProduct alloc] init];
        p.identifier = product.productIdentifier;
        p.name = product.localizedTitle;
        p.price = [product.price stringValue];
        p.localePrice = [DYFStore.defaultStore localizedPriceOfProduct:product];
        p.localizedDescription = product.localizedDescription;
        
        [modelArray addObject:p];
    }
    
    [self displayStoreUI:modelArray];
}

- (void)displayStoreUI:(NSMutableArray *)dataArray {
    
    if (![DYFStore canMakePayments]) {
        [self showTipsMessage:@"Your device is not able or allowed to make payments!"];
        return;
    }
    
    DYFStoreViewController *storeVC = [[DYFStoreViewController alloc] init];
    storeVC.dataArray = dataArray;
    [self.navigationController pushViewController:storeVC animated:YES];
}

創(chuàng)建購買產(chǎn)品的付款請求

  1. 不使用您的系統(tǒng)用戶帳戶 ID
  • Swift
DYFStore.default.purchaseProduct("com.hncs.szj.coin210")
  • Objective-C
[DYFStore.defaultStore purchaseProduct:@"com.hncs.szj.coin210"];
  1. 使用您的系統(tǒng)用戶帳戶 ID

2.1. 計算 SHA256 哈希值的函數(shù)

  • Swift
public func DYF_SHA256_HashValue(_ s: String) -> String? {

    let digestLength = Int(CC_SHA256_DIGEST_LENGTH) // 32

    let cStr = s.cString(using: String.Encoding.utf8)!
    let cStrLen = Int(s.lengthOfBytes(using: String.Encoding.utf8))

    // Confirm that the length of C string is small enough
    // to be recast when calling the hash function.
    if cStrLen > UINT32_MAX {
        print("C string too long to hash: \(s)")
        return nil
    }

    let md = UnsafeMutablePointer<CUnsignedChar>.allocate(capacity: digestLength)

    CC_SHA256(cStr, CC_LONG(cStrLen), md)

    // Convert the array of bytes into a string showing its hex represention.
    let hash = NSMutableString()
    for i in 0..<digestLength {

        // Add a dash every four bytes, for readability.
        if i != 0 && i%4 == 0 {
            //hash.append("-")
        }
        hash.appendFormat("%02x", md[i])
    }

    md.deallocate()

    return hash as String
}
  • Objective-C
CG_INLINE NSString *DYF_SHA256_HashValue(NSString *string) {

    const int digestLength = CC_SHA256_DIGEST_LENGTH; // 32
    unsigned char md[digestLength];

    const char *cStr = [string UTF8String];
    size_t cStrLen = strlen(cStr);

    // Confirm that the length of C string is small enough
    // to be recast when calling the hash function.
    if (cStrLen > UINT32_MAX) {
        NSLog(@"C string too long to hash: %@", string);
        return nil;
    }

    CC_SHA256(cStr, (CC_LONG)cStrLen, md);

    // Convert the array of bytes into a string showing its hex represention.
    NSMutableString *hash = [NSMutableString string];
    for (int i = 0; i < digestLength; i++) {

        // Add a dash every four bytes, for readability.
        if (i != 0 && i%4 == 0) {
            //[hash appendString:@"-"];
        }
        [hash appendFormat:@"%02x", md[i]];
    }

    return hash;
}

2.2. 使用給定的產(chǎn)品標識符和計算過 SHA256 哈希值的用戶帳戶 ID 請求購買產(chǎn)品。

  • Swift
DYFStore.default.purchaseProduct("com.hncs.szj.coin210", userIdentifier: "A43512564ACBEF687924646CAFEFBDCAEDF4155125657")
  • Objective-C
[DYFStore.defaultStore purchaseProduct:@"com.hncs.szj.coin210" userIdentifier:@"A43512564ACBEF687924646CAFEFBDCAEDF4155125657"];

恢復(fù)已購買的付款交易

在某些場景(如切換設(shè)備),App 需要提供恢復(fù)購買按鈕,用來恢復(fù)之前購買的非消耗型的產(chǎn)品。

  1. 無綁定用戶帳戶 ID 的恢復(fù)
  • Swift
DYFStore.default.restoreTransactions()
  • Objective-C
[DYFStore.defaultStore restoreTransactions];
  1. 綁定用戶帳戶 ID 的恢復(fù)
  • Swift
DYFStore.default.restoreTransactions(userIdentifier: "A43512564ACBEF687924646CAFEFBDCAEDF4155125657")
  • Objective-C
[DYFStore.defaultStore restoreTransactions:@"A43512564ACBEF687924646CAFEFBDCAEDF4155125657"];

創(chuàng)建刷新收據(jù)請求

如果 Bundle.main.appStoreReceiptURL 為空,就需要創(chuàng)建刷新收據(jù)請求,獲取付款交易的收據(jù)。

  • Swift
DYFStore.default.refreshReceipt(onSuccess: {
    self.storeReceipt()
}) { (error) in
    self.failToRefreshReceipt()
}
  • Objective-C
[DYFStore.defaultStore refreshReceiptOnSuccess:^{
    [self storeReceipt];
} failure:^(NSError *error) {
    [self failToRefreshReceipt];
}];

付款交易的變化通知

  1. 添加商店觀察者,監(jiān)聽購買和下載通知
  • Swift
func addStoreObserver() {
    NotificationCenter.default.addObserver(self, selector: #selector(DYFStoreManager.processPurchaseNotification(_:)), name: DYFStore.purchasedNotification, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(DYFStoreManager.processDownloadNotification(_:)), name: DYFStore.downloadedNotification, object: nil)
}
  • Objective-C
- (void)addStoreObserver {
    [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(processPurchaseNotification:) name:DYFStorePurchasedNotification object:nil];
    [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(processDownloadNotification:) name:DYFStoreDownloadedNotification object:nil];
}
  1. 在適當?shù)臅r候,移除商店觀察者
  • Swift
func removeStoreObserver() {
    NotificationCenter.default.removeObserver(self, name: DYFStore.purchasedNotification, object: nil)
    NotificationCenter.default.removeObserver(self, name: DYFStore.downloadedNotification, object: nil)
}
  • Objective-C
- (void)removeStoreObserver {
    [NSNotificationCenter.defaultCenter removeObserver:self name:DYFStorePurchasedNotification object:nil];
    [NSNotificationCenter.defaultCenter removeObserver:self name:DYFStoreDownloadedNotification object:nil];
}
  1. 付款交易的通知處理
  • Swift
@objc private func processPurchaseNotification(_ notification: Notification) {

    self.hideLoading()

    self.purchaseInfo = (notification.object as! DYFStore.NotificationInfo)

    switch self.purchaseInfo.state! {
    case .purchasing:
        self.showLoading("Purchasing...")
        break
    case .cancelled:
        self.sendNotice("You cancel the purchase")
        break
    case .failed:
        self.sendNotice(String(format: "An error occurred, \(self.purchaseInfo.error!.code)"))
        break
    case .succeeded, .restored:
        self.completePayment()
        break
    case .restoreFailed:
        self.sendNotice(String(format: "An error occurred, \(self.purchaseInfo.error!.code)"))
        break
    case .deferred:
        DYFStoreLog("Deferred")
        break
    }

}
  • Objective-C
- (void)processPurchaseNotification:(NSNotification *)notification {

    [self hideLoading];
    self.purchaseInfo = notification.object;

    switch (self.purchaseInfo.state) {
        case DYFStorePurchaseStatePurchasing:
            [self showLoading:@"Purchasing..."];
            break;
        case DYFStorePurchaseStateCancelled:
            [self sendNotice:@"You cancel the purchase"];
            break;
        case DYFStorePurchaseStateFailed:
            [self sendNotice:[NSString stringWithFormat:@"An error occurred, %zi", self.purchaseInfo.error.code]];
            break;
        case DYFStorePurchaseStateSucceeded:
        case DYFStorePurchaseStateRestored:
            [self completePayment];
            break;
        case DYFStorePurchaseStateRestoreFailed:
            [self sendNotice:[NSString stringWithFormat:@"An error occurred, %zi", self.purchaseInfo.error.code]];
            break;
        case DYFStorePurchaseStateDeferred:
            DYFStoreLog(@"Deferred");
            break;
        default:
            break;
    }
}
  1. 下載的通知處理
  • Swift
@objc private func processDownloadNotification(_ notification: Notification) {

    self.downloadInfo = (notification.object as! DYFStore.NotificationInfo)

    switch self.downloadInfo.downloadState! {
    case .started:
        DYFStoreLog("The download started")
        break
    case .inProgress:
        DYFStoreLog("The download progress: \(self.downloadInfo.downloadProgress)%%")
        break
    case .cancelled:
        DYFStoreLog("The download cancelled")
        reak
    case .failed:
        DYFStoreLog("The download failed")
        break
    case .succeeded:
        DYFStoreLog("The download succeeded: 100%%")
        break
    }
}
  • Objective-C
- (void)processDownloadNotification:(NSNotification *)notification {

    self.downloadInfo = notification.object;

    switch (self.downloadInfo.downloadState) {
        case DYFStoreDownloadStateStarted:
            DYFStoreLog(@"The download started");
            break;
        case DYFStoreDownloadStateInProgress:
            DYFStoreLog(@"The download progress: %.2f%%", self.downloadInfo.downloadProgress);
            break;
        case DYFStoreDownloadStateCancelled:
            DYFStoreLog(@"The download cancelled");
            break;
        case DYFStoreDownloadStateFailed:
            DYFStoreLog(@"The download failed");
            break;
        case DYFStoreDownloadStateSucceeded:
            DYFStoreLog(@"The download succeeded: 100%%");
            break;
        default:
            break;
    }
}

收據(jù)驗證

  1. 驗證 URL
  • Swift
/// The url for sandbox in the test environment.
private let sandboxUrl = "https://sandbox.itunes.apple.com/verifyReceipt"

/// The url for production in the production environment.
private let productUrl = "https://buy.itunes.apple.com/verifyReceipt"
  • Objective-C
// The url for sandbox in the test environment.
static NSString *const kSandboxUrl = @"https://sandbox.itunes.apple.com/verifyReceipt";
// The url for production in the production environment.
static NSString *const kProductUrl = @"https://buy.itunes.apple.com/verifyReceipt";
  1. 常見的驗證狀態(tài)碼和對應(yīng)的描述
  • Swift
/// Matches the message with the status code.
///
/// - Parameter status: The status code of the request response. More, please see [Receipt Validation Programming Guide](https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW1)
/// - Returns: A tuple that contains status code and the description of status code.
public func matchMessage(withStatus status: Int) -> (Int, String) {
    var message: String = ""
    
    switch status {
    case 0:
        message = "The receipt as a whole is valid."
        break
    case 21000:
        message = "The App Store could not read the JSON object you provided."
        break
    case 21002:
        message = "The data in the receipt-data property was malformed or missing."
        break
    case 21003:
        message = "The receipt could not be authenticated."
        break
    case 21004:
        message = "The shared secret you provided does not match the shared secret on file for your account."
        break
    case 21005:
        message = "The receipt server is not currently available."
        break
    case 21006:
        message = "This receipt is valid but the subscription has expired. When this status code is returned to your server, the receipt data is also decoded and returned as part of the response. Only returned for iOS 6 style transaction receipts for auto-renewable subscriptions."
        break
    case 21007:
        message = "This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead."
        break
    case 21008:
        message = "This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead."
        break
    case 21010:
        message = "This receipt could not be authorized. Treat this the same as if a purchase was never made."
        break
    default: /* 21100-21199 */
        message = "Internal data access error."
        break
    }
    
    return (status, message)
}
  • Objective-C
/**
 Matches the message with the status code.
 
 @param status The status code of the request response. More, please see [Receipt Validation Programming Guide](https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW1)
 @return A string that contains the description of status code.
 */
- (NSString *)matchMessageWithStatus:(NSInteger)status {
    NSString *message = @"";
    
    switch (status) {
        case 0:
            message = @"The receipt as a whole is valid.";
            break;
        case 21000:
            message = @"The App Store could not read the JSON object you provided.";
            break;
        case 21002:
            message = @"The data in the receipt-data property was malformed or missing.";
            break;
        case 21003:
            message = @"The receipt could not be authenticated.";
            break;
        case 21004:
            message = @"The shared secret you provided does not match the shared secret on file for your account.";
            break;
        case 21005:
            message = @"The receipt server is not currently available.";
            break;
        case 21006:
            message = @"This receipt is valid but the subscription has expired. When this status code is returned to your server, the receipt data is also decoded and returned as part of the response. Only returned for iOS 6 style transaction receipts for auto-renewable subscriptions.";
            break;
        case 21007:
            message = @"This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.";
            break;
        case 21008:
            message = @"This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead.";
            break;
        case 21010:
            message = @"This receipt could not be authorized. Treat this the same as if a purchase was never made.";
            break;
        default: /* 21100-21199 */
            message = @"Internal data access error.";
            break;
    }
    
    return message;
}
  1. 客戶端驗證,不安全且容易被破解,不推薦使用

3.1. 使用懶加載實例化 DYFStoreReceiptVerifier

  • Swift
lazy var receiptVerifier: DYFStoreReceiptVerifier = {
    let verifier = DYFStoreReceiptVerifier()
    verifier.delegate = self
    return verifier
}()
  • Objective-C
- (DYFStoreReceiptVerifier *)receiptVerifier {
    if (!_receiptVerifier) {
        _receiptVerifier = [[DYFStoreReceiptVerifier alloc] init];
        _receiptVerifier.delegate = self;
    }
    return _receiptVerifier;
}

3.2. 實現(xiàn)協(xié)議 DYFStoreReceiptVerifierDelegate

  • Swift
@objc func verifyReceiptDidFinish(_ verifier: DYFStoreReceiptVerifier, didReceiveData data: [String : Any])

@objc func verifyReceipt(_ verifier: DYFStoreReceiptVerifier, didFailWithError error: NSError)
  • Objective-C
- (void)verifyReceiptDidFinish:(nonnull DYFStoreReceiptVerifier *)verifier didReceiveData:(nullable NSDictionary *)data;

- (void)verifyReceipt:(nonnull DYFStoreReceiptVerifier *)verifier didFailWithError:(nonnull NSError *)error;

3.3. 驗證收據(jù)

  • Swift
// Fetches the data of the bundle’s App Store receipt. 
let data = receiptData

self.receiptVerifier.verifyReceipt(data)

// Only used for receipts that contain auto-renewable subscriptions.
//self.receiptVerifier.verifyReceipt(data, sharedSecret: "A43512564ACBEF687924646CAFEFBDCAEDF4155125657")
  • Objective-C
// Fetches the data of the bundle’s App Store receipt. 
NSData *data = receiptData ?: [NSData dataWithContentsOfURL:DYFStore.receiptURL];
DYFStoreLog(@"data: %@", data);

[_receiptVerifier verifyReceipt:data];

// Only used for receipts that contain auto-renewable subscriptions.
//[_receiptVerifier verifyReceipt:data sharedSecret:@"A43512564ACBEF687924646CAFEFBDCAEDF4155125657"];
  1. 服務(wù)器驗證,相對安全,推薦

客戶端通過接口將所需的參數(shù)上傳至服務(wù)器,接口數(shù)據(jù)最好進行加密處理。然后服務(wù)器向蘋果服務(wù)器驗證收據(jù)并獲取相應(yīng)的信息,服務(wù)器比對產(chǎn)品 ID,Bundle Identifier,交易 ID,付款狀態(tài)等信息后,若付款狀態(tài)為0,通知客戶端付款成功,客戶端完成當前的交易。

推薦閱讀 Apple 官方發(fā)布的收據(jù)驗證編程指南 Receipt Validation Programming Guide

完成交易

只有在客戶端接收到付款成功并在收據(jù)校驗通過后,才能完成交易。這樣,我們可以避免刷單和破解應(yīng)用內(nèi)購買。如果我們無法完成校驗收據(jù),我們就希望 StoreKit 不斷提醒我們還有未完成的付款。

  • Swift
DYFStore.default.finishTransaction(transaction)
  • Objective-C
[DYFStore.defaultStore finishTransaction:transaction];

交易信息存儲

DYFStore 提供了兩種數(shù)據(jù)存儲方式 DYFStoreKeychainPersistenceDYFStoreUserDefaultsPersistence。

當客戶端在付款過程中發(fā)生崩潰,導(dǎo)致 App 閃退,這時存儲交易信息尤為重要。當 StoreKit 再次通知未完成的付款時,直接從 Keychain 中取出數(shù)據(jù),進行收據(jù)驗證,直至完成交易。

  1. 存儲交易信息
  • Swift
func storeReceipt() {

    guard let url = DYFStore.receiptURL() else {
        self.refreshReceipt()
        return
    }
    
    do {
        let data = try Data(contentsOf: url)
        
        let info = self.purchaseInfo!
        let store = DYFStore.default
        let persister = store.keychainPersister!
        
        let transaction = DYFStoreTransaction()
        
        if info.state! == .succeeded {
            transaction.state = DYFStoreTransactionState.purchased.rawValue
        } else if info.state! == .restored {
            transaction.state = DYFStoreTransactionState.restored.rawValue
        }
        
        transaction.productIdentifier = info.productIdentifier
        transaction.userIdentifier = info.userIdentifier
        transaction.transactionTimestamp = info.transactionDate?.timestamp()
        transaction.transactionIdentifier = info.transactionIdentifier
        transaction.originalTransactionTimestamp = info.originalTransactionDate?.timestamp()
        transaction.originalTransactionIdentifier = info.originalTransactionIdentifier
        
        transaction.transactionReceipt = data.base64EncodedString()
        persister.storeTransaction(transaction)
        
        // Makes the backup data.
        let uPersister = DYFStoreUserDefaultsPersistence()
        if !uPersister.containsTransaction(info.transactionIdentifier!) {
            uPersister.storeTransaction(transaction)
        }
        
        self.verifyReceipt(data)
    } catch let error {
        
        DYFStoreLog("error: \(error.localizedDescription)")
        self.refreshReceipt()
        
        return
    }
}
  • Objective-C
- (void)storeReceipt {
    DYFStoreLog();
    
    NSURL *receiptURL = DYFStore.receiptURL;
    NSData *data = [NSData dataWithContentsOfURL:receiptURL];
    if (!data || data.length == 0) {
        [self refreshReceipt];
        return;
    }
    
    DYFStoreNotificationInfo *info = self.purchaseInfo;
    DYFStore *store = DYFStore.defaultStore;
    DYFStoreKeychainPersistence *persister = store.keychainPersister;
    
    DYFStoreTransaction *transaction = [[DYFStoreTransaction alloc] init];
    
    if (info.state == DYFStorePurchaseStateSucceeded) {
        transaction.state = DYFStoreTransactionStatePurchased;
    } else if (info.state == DYFStorePurchaseStateRestored) {
        transaction.state = DYFStoreTransactionStateRestored;
    }
    
    transaction.productIdentifier = info.productIdentifier;
    transaction.userIdentifier = info.userIdentifier;
    transaction.transactionIdentifier = info.transactionIdentifier;
    transaction.transactionTimestamp = info.transactionDate.timestamp;
    transaction.originalTransactionTimestamp = info.originalTransactionDate.timestamp;
    transaction.originalTransactionIdentifier = info.originalTransactionIdentifier;
    
    transaction.transactionReceipt = data.base64EncodedString;
    [persister storeTransaction:transaction];
    
    // Makes the backup data.
    DYFStoreUserDefaultsPersistence *uPersister = [[DYFStoreUserDefaultsPersistence alloc] init];
    if (![uPersister containsTransaction:info.transactionIdentifier]) {
        [uPersister storeTransaction:transaction];
    }
    
    [self verifyReceipt:data];
}
  1. 移除交易信息
  • Swift
DispatchQueue.main.asyncAfter(delay: 1.5) {
    let info = self.purchaseInfo!
    let store = DYFStore.default
    let persister = store.keychainPersister!
    let identifier = info.transactionIdentifier!
    
    if info.state! == .restored {
        
        let transaction = store.extractRestoredTransaction(identifier)
        store.finishTransaction(transaction)
        
    } else {
        
        let transaction = store.extractPurchasedTransaction(identifier)
        // The transaction can be finished only after the client and server adopt secure communication and data encryption and the receipt verification is passed. In this way, we can avoid refreshing orders and cracking in-app purchase. If we were unable to complete the verification, we want `StoreKit` to keep reminding us that there are still outstanding transactions.
        store.finishTransaction(transaction)
    }
    
    persister.removeTransaction(identifier)
    if let id = info.originalTransactionIdentifier {
        persister.removeTransaction(id)
    }
}
  • Objective-C
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^{
    DYFStoreNotificationInfo *info = self.purchaseInfo;
    DYFStore *store = DYFStore.defaultStore;
    DYFStoreKeychainPersistence *persister = store.keychainPersister;
    
    if (info.state == DYFStorePurchaseStateRestored) {
        
        SKPaymentTransaction *transaction = [store extractRestoredTransaction:info.transactionIdentifier];
        [store finishTransaction:transaction];
        
    } else {
        
        SKPaymentTransaction *transaction = [store extractPurchasedTransaction:info.transactionIdentifier];
        // The transaction can be finished only after the client and server adopt secure communication and data encryption and the receipt verification is passed. In this way, we can avoid refreshing orders and cracking in-app purchase. If we were unable to complete the verification, we want `StoreKit` to keep reminding us that there are still outstanding transactions.
        [store finishTransaction:transaction];
    }
    
    [persister removeTransaction:info.transactionIdentifier];
    if (info.originalTransactionIdentifier) {
        [persister removeTransaction:info.originalTransactionIdentifier];
    }
});

注意事項和踩坑解決辦法

  • 產(chǎn)品配置好了,為何在測試購買時獲取到產(chǎn)品信息無效呢?

    測試內(nèi)購一定要用真機測試,產(chǎn)品信息如果無效,一般是產(chǎn)品還沒有審核通過 !

  • 如果 App 沒有實物購買,不移除支付寶、微信支付的 SDK 行嗎?

    接入內(nèi)購后,如果 App 沒有實物購買,就必須把支付寶、微信支付的 SDK 刪掉,如果 Apple 那邊掃描出來,App 就會被拒審。

  • 沙盒測試有時無響應(yīng)

    1. 檢查網(wǎng)絡(luò)環(huán)境,嘗試在 WiFi 或 蜂窩網(wǎng)絡(luò) 切換,然后再嘗試購買產(chǎn)品。
    2. 如果上述方法還是沒辦法解決,那么有可能是蘋果測試服務(wù)器宕機或更新服務(wù),等一段時間再嘗試。
  • 在沙盒環(huán)境測試 OK,有沒有必要測試線上環(huán)境呢?

    如果在沙盒環(huán)境測試沒有問題,就沒有必要測試線上。因為提交 App 審核后,蘋果有一套完整的測試機制或者說有更高級的賬號確保線上支付正常。當然 App 上架后,也可以去線上用真金白銀測試購買產(chǎn)品。

    特別注意:如果服務(wù)端進行收據(jù)驗證,那么服務(wù)端一定要做好驗證 URL 地址的切換。一般做法就是不管是沙盒還是生產(chǎn)環(huán)境,先去生產(chǎn)環(huán)境驗證,如果獲取的狀態(tài)碼為21007,那么可以去沙盒環(huán)境驗證。

  • 為什么我的沙盒賬號提示不在此地區(qū),請切回本地的應(yīng)用商店?

    因為沙盒賬號在創(chuàng)建時就已經(jīng)設(shè)置好了地區(qū),中國的只能在中國的 App Store 測試。

  • 訂閱產(chǎn)品和自動續(xù)期訂閱

    訂閱產(chǎn)品需要驗證訂閱是否過期,自動續(xù)費在購買流程上,與普通購買沒有區(qū)別,主要的區(qū)別:”除了第一次購買行為是用戶主動觸發(fā)的,后續(xù)續(xù)費都是 Apple 自動完成的,一般在要過期的前24小時開始,蘋果會嘗試扣費,扣費成功的話,在 App 下次啟動的時候主動推送給 App“。

// 訂閱特殊處理
if (transaction.originalTransaction) {  
    // 如果是自動續(xù)費的訂單 originalTransaction 會有內(nèi)容 
} else {
    // 普通購買,以及第一次購買自動訂閱
}
  • 刷單問題

    驗證接收到響應(yīng)信息,一定要比對 Product Identifier、Bundle Identifier、User Identifier、Transaction Identifier 等信息,防止冒用其他收據(jù)信息領(lǐng)取產(chǎn)品,還有防止利用外匯匯率差的刷單問題。

  • 漏單問題

    一般來說,對于消耗性商品,我們用得最多的是在判斷用戶購買成功之后交給我們的服務(wù)器進行校驗,收到服務(wù)器的確認后把支付交易 finish 掉。

// finish 支付交易
SKPaymentQueue.default().finishTransaction(transaction)

如果不把支付交易 finish 掉的話,就會在下次重新打開應(yīng)用且代碼執(zhí)行到監(jiān)聽內(nèi)購隊列后,此方法 public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) 都會被回調(diào),直到被 finish 掉為止。所以為了防止漏單,建議將內(nèi)購類做成單例,并在程序入口啟動內(nèi)購類監(jiān)聽內(nèi)購隊列。這樣做的話,即使用戶在成功購買商品后,由于各種原因沒告知服務(wù)器就關(guān)閉了應(yīng)用,在下次打開應(yīng)用時,也能及時把支付交易補回,這樣就不會造成漏單問題了。

但事與愿違,在調(diào)試中,我們發(fā)現(xiàn)如果在有多個成功交易未 finish 掉的情況下,把應(yīng)用關(guān)閉后再打開,往往會把其中某些任務(wù)漏掉,即回調(diào)方法少回調(diào)了,這讓我們非常郁悶。既然官方的 API 不好使,我們只能把這個重任交給后臺的驗證流程了,具體的做法下面會講到。

  • 驗證響應(yīng)信息
{
    environment = Sandbox;
    receipt =     {
        "adam_id" = 0;
        "app_item_id" = 0;
        "application_version" = "1.0.3.2";
        "bundle_id" = "**********";
        "download_id" = 0;
        "in_app" =         (
                        {
                "is_trial_period" = false;
                "original_purchase_date" = "2017-02-08 02:26:13 Etc/GMT";
                "original_purchase_date_ms" = 1486520773000;
                "original_purchase_date_pst" = "2017-02-07 18:26:13 America/Los_Angeles";
                "original_transaction_id" = 1000000271607744;
                "product_id" = "**********_06";
                "purchase_date" = "2017-02-08 02:26:13 Etc/GMT";
                "purchase_date_ms" = 1486520773000;
                "purchase_date_pst" = "2017-02-07 18:26:13 America/Los_Angeles";
                quantity = 1;
                "transaction_id" = 1000000271607744;
            },
                        {
                "is_trial_period" = false;
                "original_purchase_date" = "2017-02-25 05:59:35 Etc/GMT";
                "original_purchase_date_ms" = 1488002375000;
                "original_purchase_date_pst" = "2017-02-24 21:59:35 America/Los_Angeles";
                "original_transaction_id" = 1000000276891381;
                "product_id" = "**********_01";
                "purchase_date" = "2017-02-25 05:59:35 Etc/GMT";
                "purchase_date_ms" = 1488002375000;
                "purchase_date_pst" = "2017-02-24 21:59:35 America/Los_Angeles";
                quantity = 1;
                "transaction_id" = 1000000276891381;
            },
                        {
                "is_trial_period" = false;
                "original_purchase_date" = "2017-03-10 05:44:43 Etc/GMT";
                "original_purchase_date_ms" = 1489124683000;
                "original_purchase_date_pst" = "2017-03-09 21:44:43 America/Los_Angeles";
                "original_transaction_id" = 1000000280765165;
                "product_id" = "**********_01";
                "purchase_date" = "2017-03-10 05:44:43 Etc/GMT";
                "purchase_date_ms" = 1489124683000;
                "purchase_date_pst" = "2017-03-09 21:44:43 America/Los_Angeles";
                quantity = 1;
                "transaction_id" = 1000000280765165;
            }
        );
        "original_application_version" = "1.0";
        "original_purchase_date" = "2013-08-01 07:00:00 Etc/GMT";
        "original_purchase_date_ms" = 1375340400000;
        "original_purchase_date_pst" = "2013-08-01 00:00:00 America/Los_Angeles";
        "receipt_creation_date" = "2017-03-10 05:44:44 Etc/GMT";
        "receipt_creation_date_ms" = 1489124684000;
        "receipt_creation_date_pst" = "2017-03-09 21:44:44 America/Los_Angeles";
        "receipt_type" = ProductionSandbox;
        "request_date" = "2017-03-10 08:50:00 Etc/GMT";
        "request_date_ms" = 1489135800761;
        "request_date_pst" = "2017-03-10 00:50:00 America/Los_Angeles";
        "version_external_identifier" = 0;
    };
    status = 0;
}

這里面我們最關(guān)心的是 in_app 里的數(shù)組,因為根據(jù)蘋果的官方文檔所示,這些就是付款成功而未被 finish 掉的交易,而一般這個數(shù)組里只會存在一個元素,這里會出現(xiàn)3個,是因為這3個訂單已經(jīng)被蘋果漏掉了,是的,這就是上面所提到的漏單情況,回調(diào)方法是不會再走了,惡心吧......

但生活還是得繼續(xù),我們可以看到每個交易里都有一些很詳細的信息,一般我們只對 transaction_id (交易 ID)、original_transaction_id (原始交易 ID)、product_id (商品 ID)bundle_id (應(yīng)用包唯一 ID)等重要信息感興趣,服務(wù)器也是憑此作為用戶購買成功的依據(jù),那么問題來了,這里好像并沒有用戶 ID,是的,服務(wù)器是不知道商品是誰買的,所以我們要把用戶 ID 和交易 ID 也一起發(fā)給服務(wù)器,讓服務(wù)器與驗證返回的數(shù)據(jù)進行匹對,從而把買家和商品對應(yīng)起來。

// 設(shè)置發(fā)送給服務(wù)器的參數(shù)
var param = [String: Any]()
param["receipt"] = receiptBase64
param["userID"] = self.userID
param["transactionID"] = transaction.transactionIdentifier

來到這里,剛才遺留的漏單問題是時候要拿出來解決了,剛才也說到了,回調(diào)方法有可能少走,甚至還有可能在客戶端啟動后完全不走 (這個只是以防萬一) 。

我個人建議的做法是,首先在服務(wù)端建立2個表,一個黑表一個白表,黑表是記錄過往真正購買成功的歷史信息,白表是記錄付款成功而未認領(lǐng)的交易信息。在客戶端啟動后的20秒內(nèi) (時間可以自己定) 回調(diào)方法如果都沒有走,我們就主動把收據(jù)等一些信息上傳給服務(wù)器,當然最好把用戶的一些信息,包括賬號ID,手機型號,系統(tǒng)版本等信息一并帶上,服務(wù)器拿到收據(jù)后去蘋果后臺驗證,把得到的付款成功的交易信息全部寫進白表里 (檢測去重)。以后如果有新交易產(chǎn)生,客戶端會把收據(jù)和交易ID等信息傳給服務(wù)器,服務(wù)器同樣到蘋果后臺驗證后寫進白表,接著在表里看看是否有客戶端所給的交易號信息,如果有再去黑表里檢測是否存在,黑表不存在則判斷為成功購買并結(jié)算商品,這時要在白表中刪除對應(yīng)數(shù)據(jù)和在黑表中添加新數(shù)據(jù),之后回饋給客戶端,客戶端把交易 finish 掉,這個購買流程就算是結(jié)束了。這時候白表里記錄著的很有可能就是一些被漏掉的訂單,為什么不是一定而是很有可能? 因為會存在已經(jīng)記錄在黑表中但未被客戶端 finish 掉的訂單,此時再到黑表中濾一遍就知道是否是真正的漏單了,這時候只能通過人工的方式去解決了,比如可以主動跟這位用戶溝通詢問情況,或者是在有用戶反應(yīng)漏單時,可以在表中檢測相關(guān)信息判斷是否屬實等等。另外服務(wù)器可以定時檢測兩個表中的數(shù)據(jù)進行去重操作,當然也可以在每次添加進白表前先在黑表中過濾,不過這樣比較耗性能。如果有更好的想法,希望大家可以在評論區(qū)寫下提示或者思路。

  • 誤充問題

    關(guān)于這個問題還是挺有趣的,因為存在這樣的一種情況:用戶A登錄后買了一樣商品,但與服務(wù)器交互失敗了,導(dǎo)致沒有把交易信息告知服務(wù)器,接著他退出了當前帳號,這時候用戶B來了,一登錄服務(wù)器,我們就會用當前用戶 ID 把上次沒有走完的內(nèi)購邏輯繼續(xù)走下去,接下來的事情相信大家都能想像到了,用戶B會發(fā)現(xiàn)他獲得了一件商品,是的,用戶A買的東西被充到了用戶B的手上。

    要解決這個問題必須要把交易和用戶 ID 綁定起來,要怎么做呢?其實很簡單,我們只要在添加交易隊列之前把用戶 ID 設(shè)進去即可。

let payment = SKMutablePayment(product: product)
payment.quantity = quantity
if #available(iOS 7.0, *) {
    payment.applicationUsername = userIdentifier
}
SKPaymentQueue.default().add(payment)

然后給服務(wù)器發(fā)送的參數(shù)就不再像之前那樣寫了。

// 設(shè)置發(fā)送給服務(wù)器的參數(shù)
var param = [String: Any]()
param["receipt"] = receiptBase64
param["userID"] = transactions.payment.applicationUsername ?? ""
param["transactionID"] = transaction.transactionIdentifier

最后,想了解更多詳情,請查看我的 Demo,記得給個 Star,????

Demo ( Objective-C ):戳這里
Demo ( Swift ):戳這里

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容