ننشر خادمًا لفحص عمليات الشراء داخل التطبيق في 60 دقيقة

مرحبا! سأخبرك اليوم بكيفية نشر خادم للتحقق من الشراء داخل التطبيق والاشتراك داخل التطبيق لنظامي التشغيل iOS و Android (التحقق من خادم الخادم).



يوجد في Habré مقال من 2013 حول فحص الخادم لعمليات الشراء. تقول المقالة أن التحقق من الصحة ضروري في المقام الأول لمنع الوصول إلى المحتوى المدفوع باستخدام كسر الحماية والبرامج الأخرى. في رأيي ، في عام 2020 ، لم تكن هذه المشكلة ملحة للغاية ، وقبل كل شيء ، يلزم وجود خادم مع التحقق من الشراء لمزامنة عمليات الشراء داخل حساب واحد على عدة أجهزة.



لا توجد صعوبة فنية في التحقق من إيصالات الشراء ؛ في الواقع ، الخادم ببساطة "وكلاء" الطلب وتخزين بيانات الشراء.







بمعنى ، يمكن تقسيم مهمة هذا الخادم إلى 4 مراحل:



  • استلام طلب مع إيصال يرسله التطبيق بعد الشراء
  • طلب إلى Apple / Google للتحقق
  • حفظ بيانات المعاملات
  • استجابة التطبيق


في إطار المقال ، سنحذف النقطة 3 ، لأنها فردية بحتة.



Node.js, .



«, App Store (App Store receipt)», . , (receipt) .



, , https://github.com/denjoygroup/inapppurchase. , , .



iOS



Apple Shared Secret – , iTunnes Connect, .



:



 apple: any = {
    password: process.env.APPLE_SHARED_SECRET, // ,  
    host: 'buy.itunes.apple.com',
    sandbox: 'sandbox.itunes.apple.com',
    path: '/verifyReceipt',
    apiHost: 'api.appstoreconnect.apple.com',
    pathToCheckSales: '/v1/salesReports'
 }


. , , sandbox.itunes.apple.com , buy.itunes.apple.com



/**
* receiptValue - ,  
* sandBox -  
**/
async _verifyReceipt(receiptValue: string, sandBox: boolean) {
    let options = {
        host: sandBox ? this._constants.apple.sandbox : this._constants.apple.host,
        path: this._constants.apple.path,
        method: 'POST'
    };
    let body = {
        'receipt-data': receiptValue,
        'password': this._constants.apple.password
    };
    let result = null;
    let stringResult = await this._handlerService.sendHttp(options, body, 'https');
    result = JSON.parse(stringResult);
    return result;
}


, Apple status .



,



21000 – – POST



21002 – ,



21003 – ,



21004Shared Secret



21005 – ,



21006



21007 – SandBox ( ), prod



21008 – ,



21009 – ,



21010



0



iTunnes Connect



{
    "environment":"Production",
    "receipt":{
        "receipt_type":"Production",
        "adam_id":1527458047,
        "app_item_id":1527458047,
        "bundle_id":"BUNDLE_ID",
        "application_version":"0",
        "download_id":34089715299389,
        "version_external_identifier":838212484,
        "receipt_creation_date":"2020-11-03 20:47:54 Etc/GMT",
        "receipt_creation_date_ms":"1604436474000",
        "receipt_creation_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",
        "request_date":"2020-11-03 20:48:01 Etc/GMT",
        "request_date_ms":"1604436481804",
        "request_date_pst":"2020-11-03 12:48:01 America/Los_Angeles",
        "original_purchase_date":"2020-10-26 19:24:19 Etc/GMT",
        "original_purchase_date_ms":"1603740259000",
        "original_purchase_date_pst":"2020-10-26 12:24:19 America/Los_Angeles",
        "original_application_version":"0",
        "in_app":[
            {
                "quantity":"1",
                "product_id":"PRODUCT_ID",
                "transaction_id":"140000855642848",
                "original_transaction_id":"140000855642848",
                "purchase_date":"2020-11-03 20:47:53 Etc/GMT",
                "purchase_date_ms":"1604436473000",
                "purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",
                "original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",
                "original_purchase_date_ms":"1604436474000",
                "original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",
                "expires_date":"2020-12-03 20:47:53 Etc/GMT",
                "expires_date_ms":"1607028473000",
                "expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",
                "web_order_line_item_id":"140000337829668",
                "is_trial_period":"false",
                "is_in_intro_offer_period":"false"
            }
        ]
    },
    "latest_receipt_info":[
        {
            "quantity":"1",
            "product_id":"PRODUCT_ID",
            "transaction_id":"140000855642848",
            "original_transaction_id":"140000855642848",
            "purchase_date":"2020-11-03 20:47:53 Etc/GMT",
            "purchase_date_ms":"1604436473000",
            "purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",
            "original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",
            "original_purchase_date_ms":"1604436474000",
            "original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",
            "expires_date":"2020-12-03 20:47:53 Etc/GMT",
            "expires_date_ms":"1607028473000",
            "expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",
            "web_order_line_item_id":"140000447829668",
            "is_trial_period":"false",
            "is_in_intro_offer_period":"false",
            "subscription_group_identifier":"20675121"
        }
    ],
    "latest_receipt":"RECEIPT",
    "pending_renewal_info":[
        {
            "auto_renew_product_id":"PRODUCT_ID",
            "original_transaction_id":"140000855642848",
            "product_id":"PRODUCT_ID",
            "auto_renew_status":"1"
        }
    ],
    "status":0
}


id , .



in_app latest_receipt_info, , :



latest_receipt_info .



in_app Non-consumable Non-Auto-Renewable .



latest_receipt_info, product_id , . , , Consumable Purchase. original_transaction_id, , .





/**
* product - id 
* resultFromApple -   Apple,  
* productType -   (,   non-consumable)
* sandBox -    
*
**/
async parseResponse(product: string, resultFromApple: any, productType: ProductType, sandBox: boolean) {
    let parsedResult: IPurchaseParsedResultFromProvider = {
        validated: false,
        trial: false,
        checked: false,
        sandBox,
        productType: productType,
        lastResponseFromProvider: JSON.stringify(resultFromApple)
    };
    switch (resultFromApple.status) {
        /**
        *  
        */
        case 0: {
            /**
            *         
            **/
            let currentPurchaseFromApple = this.getCurrentPurchaseFromAppleResult(resultFromApple, product!, productType);
            if (!currentPurchaseFromApple) break;

            parsedResult.checked = true;
            parsedResult.originalTransactionId = this.getTransactionIdFromAppleResponse(currentPurchaseFromApple);
            if (productType === ProductType.Subscription) {
                parsedResult.validated = (this.checkDateIsAfter(currentPurchaseFromApple.expires_date_ms)) ? true : false;
                parsedResult.expiredAt = (this.checkDateIsValid(currentPurchaseFromApple.expires_date_ms)) ?
                this.formatDate(currentPurchaseFromApple.expires_date_ms) : undefined;
            } else {
                parsedResult.validated = true;
            }
            parsedResult.trial = !!currentPurchaseFromApple.is_trial_period;
            break;
        }
        default:
            if (!resultFromApple) console.log('empty result from apple');
            else console.log('incorrect result from apple, status:', resultFromApple.status);
    }
    return parsedResult;
}


, parsedResult. , , , , parsedResult.validated.



, , iTunnes Connect , . , , , – , .



Android



, OAuth .



:



google: any = {
    host: 'androidpublisher.googleapis.com',
    path: '/androidpublisher/v3/applications',
    email: process.env.GOOGLE_EMAIL,
    key: process.env.GOOGLE_KEY,
    storeName: process.env.GOOGLE_STORE_NAME
}


.



, , :



/**
* product -  
* token - 
* productType –  ,   
**/
async getPurchaseInfoFromGoogle(product: string, token: string, productType: ProductType) {
    try {
        let options = {
            email: this._constants.google.email,
            key: this._constants.google.key,
            scopes: ['https://www.googleapis.com/auth/androidpublisher'],
        };
        const client = new JWT(options);
        let productOrSubscriptionUrlPart = productType === ProductType.Subscription ? 'subscriptions' : 'products';
        const url = `https://${this._constants.google.host}${this._constants.google.path}/${this._constants.google.storeName}/purchases/${productOrSubscriptionUrlPart}/${product}/tokens/${token}`;
        const res = await client.request({ url });
        return res.data as ResultFromGoogle;
    } catch(e) {
        return e as ErrorFromGoogle;
    }
}


google-auth-library JWT.



:



{
    startTimeMillis: "1603956759767",
    expiryTimeMillis: "1603966728908",
    autoRenewing: false,
    priceCurrencyCode: "RUB",
    priceAmountMicros: "499000000",
    countryCode: "RU",
    developerPayload: {
        "developerPayload":"",
        "is_free_trial":false,
        "has_introductory_price_trial":false,
        "is_updated":false,
        "accountId":""
    },
    cancelReason: 1,
    orderId: "GPA.3335-9310-7555-53285..5",
    purchaseType: 0,
    acknowledgementState: 1,
    kind: "androidpublisher#subscriptionPurchase"
}






parseResponse(product: string, result: ResultFromGoogle | ErrorFromGoogle, type: ProductType) {
    let parsedResult: IPurchaseParsedResultFromProvider = {
        validated: false,
        trial: false,
        checked: true,
        sandBox: false,
        productType: type,
        lastResponseFromProvider: JSON.stringify(result),
    };
    if (this.isResultFromGoogle(result)) {
        if (this.isSubscriptionResult(result)) {
            parsedResult.expiredAt = moment(result.expiryTimeMillis, 'x').toDate();
            parsedResult.validated = this.checkDateIsAfter(parsedResult.expiredAt);
        } else if (this.isProductResult(result)) {
            parsedResult.validated = true;
        }
    }
    return parsedResult;
}


. parsedResult, validated – .





2 . https://github.com/denjoygroup/inapppurchase ( )



, , .



, : https://ru.adapty.io/ https://apphud.com/. , -, 3 , -, , .



P.S.



, , , – . , , iTunnes Connect Google API, .




All Articles