Home Reference Source

src/weapp-backend-api.js

import extend from 'extend';
import md5 from 'blueimp-md5';
import Logger from 'simple-console-log-level';

import SimpleStorage from 'weapp-simple-storage';

/**
 * 统一封装后端接口的调用
 * 
 * - 集中配置接口
 * - 统一发送请求
 * - 统一处理请求的返回
 * - 统一适配请求返回的数据格式
 * - 统一异常处理
 * - 预留扩展点
 * 
 * 此类是抽象类, 其他平台继承此类来实现具体发送请求的功能
 * 例如:
 * BackendApi -> WeappBackendApi(微信小程序平台的封装)
 *            -> Web$BackendApi(Web平台, 基于jQuery/Zepto的封装)
 */
class BackendApi {
    /**
     * @param {object} apiConfig 后端 HTTP 接口的配置, 将 HTTP 接口的调用视为一次远程调用(RPC)
     *        配置项是接口名称和请求参数的映射
     *        例如
     *        ```javascript
     *        {
     *            'getList': {
     *                method: 'GET',
     *                url: 'https://domain.com/list'
     *            },
     *            'getDetail': {
     *                method: 'GET',
     *                url: 'https://domain.com/detail'
     *            }
     *        }
     *        ```
     * @param {object} defaultRequestOptions 默认的请求参数
     * @param {number} loggerLevel 日志级别, 默认为 Logger.LEVEL_WARN 级别
     *                 TODO: 如果微信小程序支持获取当前运行的版本(开发版/体验版/线上版),
     *                 那么日志级别的默认值可以根据运行的版本来判断, 非线上版本自动为 TRACE 级别
     */
    constructor(apiConfig = {}, defaultRequestOptions = {}, loggerLevel = Logger.LEVEL_WARN) {
        this.apiConfig = apiConfig;
        this.defaultRequestOptions = defaultRequestOptions;

        // 正在发送的请求
        this.sending = {};

        // 是否在加载接口配置
        this.loading = false;
        // 等待发送的请求
        this.stalled = [];

        this.logger = new Logger({
            level: loggerLevel,
            prefix: '[backend-api]'
        });
    }

    /**
     * 添加一组后端 HTTP 接口的配置
     * 
     * @param {string | object} [namespace] 给接口名添加 namespace, 例如: 给 'getUser' 添加 'user' 的 namespace, 接口名会变为 'user.getUser'; 如果参数为 object 类型, 则表示直接添加接口配置, 不设置 namespace
     * @param {object} apiConfig
     * @return {BackendApi} this
     */
    addApiConfig(namespace, apiConfig) {
        var _apiConfig;

        if (arguments.length === 1) {
            _apiConfig = namespace;
        } else {
            if (namespace) {
                _apiConfig = {};
                for (var name in apiConfig) {
                    _apiConfig[namespace + '.' + name] = apiConfig[name];
                }
            } else {
                _apiConfig = apiConfig;
            }
        }

        // 可能存在覆盖接口配置的情况
        for (var name in _apiConfig) {
            if (this.apiConfig[name]) {
                this.logger.warn('覆盖了接口的配置', name, _apiConfig[name], this.apiConfig[name]);
            }
        }

        extend(this.apiConfig, _apiConfig);
        return this;
    }

    /**
     * 发送请求前的统一处理
     * 
     * @abstract
     * @param {object} requestOptions
     * @return {undefined|Promise}
     */
    beforeSend(requestOptions) {}
    /**
     * 请求结束后的统一处理
     * 
     * @abstract
     * @param {object} requestOptions
     * @param {object} requestResult
     */
    afterSend(requestOptions, requestResult) {}
    
    /**
     * 统一发送(接口)请求的方法
     * 
     * @param {string} name 接口的名称
     * @param {object} [options={}] 请求参数
     * @param {string} [namespace=''] 接口名的 namespace
     * @return {Promise}
     */
    sendRequest(name, options = {}, namespace = '') {
        // 如果还在加载接口配置, 则延迟执行接口的请求
        if (this.loading) {
            var dfd = new Deferred();
            this.stalled.push(dfd);

            return dfd.then(() => {
                var requestOptions = this._getRequestOptions(name, options, namespace);
                return this.$sendHttpRequest(requestOptions);
            });
        } else {
            var requestOptions = this._getRequestOptions(name, options, namespace);
            return this.$sendHttpRequest(requestOptions);
        }
    }

    /**
     * 加载后端 HTTP 接口的配置
     * 
     * @param {object} requestOptions 请求参数
     * @return {Promise}
     */
    loadApiConfig(requestOptions) {
        this.loading = true;
        return this.$sendHttpRequest(requestOptions).then(([data, requestResult]) => {
            this.addApiConfig(data);

            // 激活等待发送的请求
            this.stalled.forEach(function(dfd) {
                dfd.resolve();
            });
            this.stalled.length = 0;

            this.loading = false;

            return [data, requestResult];
        }, () => {
            this.loading = false;
        });
    }

    /**
     * 发送 HTTP 请求的具体实现
     * 
     * @abstract
     * @param {object} requestOptions 请求参数
     * @return {Promise}
     */
    $sendHttpRequest(requestOptions) {
        // 子类具体去实现
        return new Promise(function(resolve, reject) {
            reject('需要子类去实现发送 HTTP 请求');
        });
    }

    /**
     * 获取请求的参数
     * 
     * @param {string} name 接口的名称, 既配置在 `apiConfig` 中的 key
     *                      针对接口 URL 中有 path 参数的情况, 需要在 name 中加入斜杠来标识,
     *                      如果不使用这个参数, 也可以发请求, 但不推荐这么使用, 应该将所有接口都配置好
     * @param {object} options 请求参数
     * @param {string} namespace 接口名的 namespace
     * @return {object}
     */
    _getRequestOptions(name, options, namespace) {
        var api;

        if (name) {
            var _name = name;
            var urlAppend = '';

            // 针对接口 URL 中有 path 参数的情况, 例如: //domain.com/user/123
            // 需要在传入的 name 中加入斜杠来标识, 例如传入的 name 为: getUser/123
            // getUser 接口的配置与一般的配置一样
            // 'getUser': {
            //     url: '//domain.com/user'
            // }
            // 会先根据斜杠提取出注册在接口配置中的名字: getUser,
            // 再取出 getUser 注册时的 URL, 将斜杠之后的 path 拼接到此 URL 中
            // TODO 考虑支持这种格式: //domain.com/user/:userId/room/:roomId
            var slashIndex = name.indexOf('/');
            if (slashIndex != -1) {
                _name = name.substring(0, slashIndex);
                urlAppend = name.substring(slashIndex);
            }

            if (namespace) {
                _name = namespace + '.' + _name;
            }
            var _api = this.apiConfig[_name];
            if (_api) {
                api = extend(true, {}, _api);
                api.url = api.url + urlAppend;
            } else {
                this.logger.warn('没有找到对应的接口配置', _name, this.apiConfig);
            }
        } else {
            this.logger.warn('没有配置接口', options);
        }

        return extend(true, {}, this.defaultRequestOptions, api, options);
    }

    /**
     * 标准化接口返回的数据格式, 方便适配各种接口返回数据格式不同的情况
     * 
     * 标准格式为:
     * ```javascript
     * {
     *     "data": {},
     *     "status": 0,
     *     "statusInfo": {
     *         "message": "给用户的提示信息",
     *         "detail": "用于排查错误的详细错误信息"
     *     }
     * }
     * ```
     * 
     * @param {any} requestOptions
     * @param {any} requestResult
     * @return {any}
     * @see https://github.com/f2e-journey/treasure/blob/master/api.md#%E6%8E%A5%E5%8F%A3%E8%BF%94%E5%9B%9E%E7%9A%84%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84
     */
    normalizeRequestResult(requestOptions, requestResult) {
        return requestResult;
    }
}

/**
 * 延迟执行
 * 
 * @see jQuery.Deferred
 */
class Deferred {
    constructor() {
        this._state = 'pending';

        this._resolve = null;
        this._reject = null;

        this._promise = new Promise((resolve, reject) => {
            this._resolve = resolve;
            this._reject = reject;
        });
    }
    /**
     * @return {string}
     * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#Description
     */
    state() {
        return this._state;
    }
    /**
     * @return {Promise}
     */
    promise() {
        return this._promise;
    }
    /**
     * 
     * @param {Function} onFulfilled 
     * @param {Function} onRejected 
     * @return {Promise}
     */
    then(onFulfilled, onRejected) {
        return this._promise.then(onFulfilled, onRejected);
    }
    /**
     * 
     * @param {*} value 
     */
    resolve(value) {
        this._resolve(value);
        this._state = 'fulfilled';
    }
    /**
     * 
     * @param {*} reason 
     */
    reject(reason) {
        this._reject(reason);
        this._state = 'rejected';
    }
}

/**
 * 统一封装微信小程序平台后端接口的调用
 * 
 * @example
 * ```javascript
 * import BackendApi from 'weapp-backend-api';
 * 
 * var backendApi = new BackendApi({
 *     'getList': {
 *         method: 'GET',
 *         url: 'https://domain.com/list'
 *     }
 * });
 * backendApi.sendRequest('getList').then(function([data]) {
 *     console.log(data);
 * }, function(requestResult) {
 *     console.log(requestResult);
 * });
 * ```
 */
class WeappBackendApi extends BackendApi {
    constructor(apiConfig, defaultRequestOptions = WeappBackendApi.defaults.requestOptions, loggerLevel) {
        super(apiConfig, defaultRequestOptions, loggerLevel);

        this.simpleStorage = new SimpleStorage({
            name: 'backend-api-cache',
            loggerLevel: loggerLevel
        });
    }

    /**
     * 内置如下功能
     * - 查询请求队列拦截重复请求(不发送请求)
     * - 获取接口缓存数据的机制, 存在缓存数据则直接读取缓存数据(不发送请求)
     * - 显示 loading 提示
     * 
     * @override
     * @return {undefined|Promise} 如果返回 Promise 则不会去发送请求
     */
    beforeSend(requestOptions) {
        var cachedRequestResult = this.simpleStorage.get(this._getRequestInfoHash(requestOptions));

        if (this._isSending(requestOptions) && requestOptions._interceptDuplicateRequest) {
            return this._interceptDuplicateRequest(requestOptions);
        } else if (cachedRequestResult) {
            this.logger.log('----------------------');
            this.logger.log('from cache');
            this.logger.log('----------------------');
            return Promise.resolve(cachedRequestResult);
        } else { // 前面的请求可能没有开启 loading, 因此不能判断 !this._isAnySending()
            this._showLoading(requestOptions);
        }
    }

    /**
     * 拦截重复请求
     * 
     * @param {object} requestOptions
     * @return {Promise}
     */
    _interceptDuplicateRequest(requestOptions) {
        var requestInfoHash = this._getRequestInfoHash(requestOptions);

        this.logger.warn('拦截到重复请求', requestInfoHash, this.sending[requestInfoHash], this.sending);
        this.logger.warn('----------------------');

        // 返回一个 pending 状态的 Promise, 阻止发送请求且不会触发任何回调
        return new Promise(function() {});
    }

    /**
     * 内置如下功能
     * - 清理请求队列
     * - 关闭 loading 提示
     * 
     * @override
     */
    afterSend(requestOptions, requestResult) {
        this._removeFromSending(requestOptions);

        if (!this._isAnySending(true)) {
            this._hideLoading(requestOptions);
        }
    }

    _showLoading(requestOptions) {
        if (requestOptions._showLoading !== false) {
            wx.showLoading({
                icon: 'loading',
                title: WeappBackendApi.defaults.LOADING_MESSAGE,
                mask: requestOptions._showLoadingMask
            });
        }
        // 即使设置为不显示 loading 提示, 但顶部的 loading 提示还是要给出的,
        // 因为发送了请求出去, 总要给予一定的反馈信息(例如移动网络有数据交互时的提示)
        wx.showNavigationBarLoading();
    }

    _hideLoading(requestOptions) {
        wx.hideLoading();
        wx.hideNavigationBarLoading();
    }

    /**
     * 发送 HTTP 请求
     * 
     * @override
     * @param {object} [requestOptions] 扩展了 wx.requesst 的 options
     * @param {boolean} [requestOptions._showLoading=true] 是否显示 loading 提示
     * @param {boolean} [requestOptions._showLoadingMask=false] 是否显示 loading 提示的 mask
     * @param {boolean} [requestOptions._interceptDuplicateRequest=false] 是否拦截重复请求
     * @param {boolean} [requestOptions._showFailTip=true] 接口调用出错时是否给用户提示错误消息
     * @param {number} [requestOptions._showFailTipDuration] 接口调用出错时错误信息的显示多长时间(ms)
     * @param {number} [requestOptions._cacheTtl] 缓存的存活时间(ms)
     * @param {Function} [requestOptions._normalizeRequestResult] 标准化接口返回的数据格式
     * @param {string} [requestOptions._type='request'] 请求的类型: `request` | `uploadFile`
     */
    $sendHttpRequest(requestOptions) {
        // 因为调用过 wx.request(requestOptions) 之后, 请求的 URL 会被微信小程序的 API 改写,
        // 即 requestOptions.url 参数会被改写,
        // 例如原来的 URL 是: https://domian.com/a  data 是 {a:1}
        // 那么 data 会被追加到 URL 上, 变成: https://domian.com/a?a=1
        // 由于我们计算同一个请求的签名是根据 URL 来的, 如果前后 URL 不一致, 就会造成无法辨别出重复请求
        // 因此这里我们需要保存原始的 URL 参数
        requestOptions._url = requestOptions.url;

        var promise = null;
        var beforeSendResult = this.beforeSend(requestOptions);
        if (beforeSendResult) {
            promise = beforeSendResult;
        } else {
            promise = new Promise((resolve, reject) => {
                // 收到开发者服务器成功返回的回调函数
                // 注意: 收到开发者服务器返回就会回调这个函数, 不管 HTTP 状态是否为 200 也算请求成功
                // requestResult 包含的属性有: statusCode, header, data, errMsg
                requestOptions.success = function(requestResult) {
                    // Determine if HTTP request successful | jQuery
                    var isHttpRequestSuccess = requestResult.statusCode >= 200 && requestResult.statusCode < 300 || requestResult.statusCode === 304;

                    if (isHttpRequestSuccess) {
                        resolve(requestResult);
                    } else { // HTTP 请求失败
                        reject(requestResult);
                    }
                };
                // 接口调用失败的回调函数
                // 这个指 wx.request API 调用失败的情况,
                // 例如没有传 url 参数或者传入的 url 格式错误之类的错误情况
                // 这时不会有 statusCode 字段, 会有 errMsg 字段
                requestOptions.fail = function(requestResult) {
                    reject(requestResult);
                };

                // 发出请求
                if (requestOptions._type === 'uploadFile') { // 上传文件
                    wx.uploadFile(requestOptions);
                } else { // 其他请求
                    wx.request(requestOptions);
                }

                this._addToSending(requestOptions);
            });
        }

        return promise.then((requestResult) => {
            // 请求结束后的统一处理如果放在 complete 回调中就不方便实现重写请求返回的数据
            // 例如接口返回的数据是加密的, 需要统一在 afterSend 中封装解密的逻辑, 改写请求返回的数据,
            // 做到上层对数据的解密无感知
            this.afterSend(requestOptions, requestResult);
            return this._successHandler(requestOptions, requestResult);
        }, (requestResult) => {
            this.afterSend(requestOptions, requestResult);
            return this._failHandler(requestOptions, requestResult);
        });
    }

    /**
     * 获取一个请求的关键信息
     * 
     * - method
     * - url
     * - data
     * 
     * @param {object} requestOptions 
     * @return {string} 请求关键信息组合的 MD5 值
     */
    _getRequestInfoHash(requestOptions) {
        var data = '';
        if (requestOptions.data) {
            try {
                data = JSON.stringify(requestOptions.data);
            } catch (error) {
                data = requestOptions.data.toString();
                this.logger.warn('获取一个请求数据的 JSON 字符串失败', requestOptions.data, error);
            }
        }

        var requestInfo = requestOptions.method + ' ' + requestOptions._url + ' ' + data;

        var requestInfoHash = requestInfo;
        try {
            requestInfoHash = md5(requestInfo);
        } catch (error) {
            this.logger.warn('获取一个请求的关键信息的 MD5 失败', requestInfo, error);
        }

        return requestInfoHash;
    }

    /**
     * 将请求放入到发送中的队列中
     * 
     * @param {object} requestOptions 
     */
    _addToSending(requestOptions) {
        this.sending[this._getRequestInfoHash(requestOptions)] = requestOptions;
    }
    /**
     * 将请求从发送中的队列中移除出来
     * 
     * @param {object} requestOptions 
     */
    _removeFromSending(requestOptions) {
        var requestInfoHash = this._getRequestInfoHash(requestOptions);
        var result = delete this.sending[requestInfoHash];
        if (!result) {
            this.logger.warn('将请求从发送中的队列中移除失败', requestInfoHash, requestOptions);
        }
    }
    /**
     * 某个请求是否正在发送中
     * 
     * @param {object} requestOptions
     * @return {boolean}
     */
    _isSending(requestOptions) {
        return this.sending.hasOwnProperty(this._getRequestInfoHash(requestOptions));
    }
    /**
     * 是不是有正在发送中的请求
     * 
     * @param {boolean} excludeNoLoading 排除队列中没有开启 loading 的请求, 即 `_showLoading` 参数为 false 的请求
     * @return {boolean}
     */
    _isAnySending(excludeNoLoading) {
        var sendingCount = 0;

        if (excludeNoLoading) {
            for (var key in this.sending) {
                var requestOptions = this.sending[key];
                if (requestOptions._showLoading !== false) {
                    sendingCount += 1;
                }
            }
        } else {
            sendingCount = Object.keys(this.sending).length;
        }

        return sendingCount !== 0;
    }

    /**
     * 接口调用成功时的默认处理方法
     * 
     * @param {object} requestOptions wx.request options
     * @param {object} requestResult wx.request success 返回的结果
     * @return {object|Promise}
     */
    _successHandler(requestOptions, requestResult) {
        this._normalizeRequestResult(requestOptions, requestResult);
        var result = requestResult.data;

        if (this._ifApiSuccess(requestOptions, requestResult)) {
            this.logger.log(requestOptions.method, requestOptions.url, requestOptions.data,
                            requestOptions, requestResult);
            this.logger.log('----------------------');

            if (requestOptions._cacheTtl >= 0) {
                var requestInfoHash = this._getRequestInfoHash(requestOptions);
                if (!this.simpleStorage.has(requestInfoHash)) {
                    this.simpleStorage.set(requestInfoHash, requestResult, {
                        ttl: requestOptions._cacheTtl
                    });
                }
            }

            return [
                // 只返回标准接口数据格式中的数据
                result ? result.data : result,
                requestResult
            ];
        } else { // 业务错误
            if (!result) {
                requestResult.data = result = {};
            }
            result._errorType = 'B';
            return this.commonFailStatusHandler(requestOptions, requestResult);
        }
    }

    /**
     * 接口调用失败时的默认处理方法
     * 
     * 接口错误码规范
     * -------------
     * 根据发送请求的最终状态定义错误的分类
     * - 发送失败(即请求根本就没有发送出去)
     * - 发送成功
     *   - HTTP 异常状态(例如 404/500...)
     *   - HTTP 正常状态(例如 200)
     *     - 接口调用成功
     *     - 接口调用失败(业务错误, 即接口规范中 status 非 0 的情况)
     * 
     * 错误码可不固定长度, 整体格式为: 字母+数字
     * 字母作为错误类型, 可扩展性更好, 数字建议划分区间来细分错误
     * 例如:
     * - A for API: API 调用失败(请求发送失败)的错误, 例如 A100 表示 URL 非法
     * - H for HTTP, HTTP 异常状态的错误, 例如 H404 表示 HTTP 请求404错误
     * - B for backend or business, 接口调用失败的错误, 例如 B100 业务A错误, B200 业务B错误
     * - C for Client: 客户端错误, 例如 C100 表示解析 JSON 失败
     * 
     * @param {object} requestOptions wx.request options
     * @param {object} requestResult wx.request success 或者 fail 返回的结果
     * @param {Promise}
     */
    _failHandler(requestOptions, requestResult) {
        var result = {};

        // 如果 wx.requet API 调用是成功的, 则一定会有 statusCode 字段
        if (typeof requestResult.statusCode != 'undefined') {
            result = {
                status: requestResult.statusCode,
                _errorType: 'H',
                statusInfo: {
                    message: WeappBackendApi.defaults.REQUEST_HTTP_FAIL_MESSAGE,
                    detail: {
                        requestOptions: requestOptions,
                        requestResult: requestResult.statusCode
                    }
                }
            };
        } else {
            var message = WeappBackendApi.defaults.REQUEST_API_FAIL_MESSAGE;
            var status = WeappBackendApi.defaults.REQUEST_API_FAIL_STATUS;

            if (requestResult.errMsg) {
                // 通过 errMsg 来生成不同的 status 值, 即方便一眼就能够知道出了什么错误
                var errMsgDetail = requestResult.errMsg.replace('request:fail ', '');
                if (errMsgDetail) {
                    message = errMsgDetail;
                    status = errMsgDetail.charCodeAt(0);
                }
            }

            result = {
                status: status,
                _errorType: 'A',
                statusInfo: {
                    message: message,
                    detail: {
                        errMsg: requestResult.errMsg
                    }
                }
            };
        }

        requestResult.data = result;
        return this.commonFailStatusHandler(requestOptions, requestResult);
    }

    /**
     * 判断接口请求调用是否成功
     * 
     * @param {object} requestOptions wx.request options
     * @param {object} requestResult wx.request success 返回的结果
     * @return {boolean}
     */
    _ifApiSuccess(requestOptions, requestResult) {
        // 接口返回的数据
        var result = requestResult.data;
        var isApiSuccess = false;
        if (result) {
            isApiSuccess = !result.status || result.status == 0;
        }
        return isApiSuccess;
    }

    /**
     * 标准化接口的返回数据, 会改写 `requestResult.data` 的内容
     * 
     * @param {object} requestOptions 
     * @param {object} requestResult 
     */
    _normalizeRequestResult(requestOptions, requestResult) {
        var _normalizeRequestResult = requestOptions._normalizeRequestResult ?
                                      requestOptions._normalizeRequestResult : this.normalizeRequestResult;

        // wx.uploadFile 返回的数据是 string 类型, 需要解析为对象
        if (requestOptions._type === 'uploadFile') {
            try {
                requestResult.data = JSON.parse(requestResult.data);
            } catch (error) {
                this.logger.warn('解析 wx.uploadFile 返回的数据出错', requestOptions, requestResult);
            }
        }

        var result = _normalizeRequestResult.apply(this, [requestOptions, requestResult.data]);
        requestResult.data = result;
    }

    /**
     * 当接口处理失败时通用的错误状态处理
     * 
     * 例如:
     * - 接口出错时统一弹出错误提示信息
     * - 接口出错时根据 status 做通用的错误处理(例如用户 session 超时, 引到用户重新登录)
     * 
     * @param {object} requestOptions wx.request options
     * @param {object} requestResult wx.request success 或者 fail 返回的结果
     * @return {Promise}
     */
    commonFailStatusHandler(requestOptions, requestResult) {
        // 接口调用失败, 输出失败的日志信息, 需要包含如下重要信息
        // - HTTP method
        // - HTTP URL
        // - 接口的参数
        // - 接口的返回状态
        // - 接口的返回数据
        this.logger.warn(`接口调用出错(${this._getErrorCode(requestResult.data)})`,
                         requestOptions.method, requestOptions.url, requestOptions.data,
                         requestOptions, requestResult);
        this.logger.warn('----------------------');

        this.failStatusHandler(requestOptions, requestResult);
        this.commonFailTip(requestOptions, requestResult);
        return Promise.reject(requestResult);
    }

    /**
     * 针对错误状态做自定义处理
     * 
     * @abstract
     * @param {object} requestOptions wx.request options
     * @param {object} requestResult wx.request success 或者 fail 返回的结果
     */
    failStatusHandler(requestOptions, requestResult) {
        // 子类具体去实现
        // 例如
        // var result = requestResult.data;
        // if (result.status === WeappBackendApi.defaults.REQUEST_API_FAIL_STATUS) {
        //     // XXX your code here
        // } else if (result.status == 401) { // 例如用户未登录统一跳转到登录页
        //     // XXX your code here
        // }
    }

    /**
     * 接口出错时统一弹出错误提示信息
     * 
     * @param {object} requestOptions wx.request options
     * @param {object} requestResult wx.request success 或者 fail 返回的结果
     */
    commonFailTip(requestOptions, requestResult) {
        // 在一些场景下需要, 例如提示用户登录的时候, 不希望看见一个错误提示, 或者想自定义错误提示的时候
        if (requestOptions._showFailTip !== false) {
            var message = this.getFailTipMessage(requestOptions, requestResult);

            // XXX 由于 wx.showLoading 底层就是调用的 showToast,
            // toast 实现是单例, 全局只有一个, 因此使用 showToast 会造成 loading 被关掉
            var toastOptions = {
                icon: 'none',
                title: message
            };

            if (typeof requestOptions._showFailTipDuration !== 'undefined') {
                toastOptions.duration = requestOptions._showFailTipDuration;
            }

            wx.showToast(toastOptions);
        }
    }

    /**
     * 获取错误码
     * 
     * @param {object} result 标准的接口数据
     * @return {string}
     */
    _getErrorCode(result) {
        return `${result._errorType}${result.status ? result.status : ''}`;
    }

    /**
     * 获取给用户的错误提示
     * 
     * 例如: 提供给用户看的消息格式参考 QQ 的错误提示消息
     * 提示消息
     * (错误码: xxx)灰色字
     * 
     * @param {object} requestOptions 
     * @param {object} requestResult 
     * @return {string}
     */
    getFailTipMessage(requestOptions, requestResult) {
        var result = requestResult.data;

        var message = (result.statusInfo && result.statusInfo.message) ?
                      result.statusInfo.message : WeappBackendApi.defaults.FAIL_MESSAGE;

        return `${message}\n(错误码:${this._getErrorCode(result)})`;
    }
}

WeappBackendApi.defaults = {
    LOADING_MESSAGE: '',

    FAIL_MESSAGE: '系统繁忙',

    // 接口请求失败(HTTP协议层面)时的状态码, 用于与业务状态码区分开
    REQUEST_HTTP_FAIL_MESSAGE: '请求超时,请重试',

    // wx.request API 调用失败
    REQUEST_API_FAIL_STATUS: 1,
    REQUEST_API_FAIL_MESSAGE: '请求失败,请重试',

    // 默认的请求参数
    requestOptions: {
        header: {
            'content-type': 'application/x-www-form-urlencoded'
        },
        dataType: 'json'
    }
};

export default WeappBackendApi;