diff --git a/.eslintrc.js b/.eslintrc.js index 2bd3a7c6..1c0ae23f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,6 +17,8 @@ module.exports = { describe: true, it: true, my: true, + qq: true, + swan: true, expect: true, beforeEach: true, before: true, @@ -26,5 +28,7 @@ module.exports = { __VERSION_WEB__: true, __VERSION_ALIPAY__: true, __VERSION_WECHAT__: true, + __VERSION_QQ__: true, + __VERSION_BAIDU__: true, } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a81deba..3e46ed50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 2.5.0 (2019-9-3) +- [A] 支持百度小程序 + ## 2.4.0 (2019-8-21) - [A] QQ SDK 支持 QQ 小程序支付 - [A] web 端支持 QQ 扫码支付 diff --git a/core/UserRecord.js b/core/UserRecord.js index 6abef568..8c25939e 100644 --- a/core/UserRecord.js +++ b/core/UserRecord.js @@ -56,6 +56,19 @@ class UserRecord extends BaseRecord { return BaaS._polyfill.linkQQ.apply(null, arguments) } + /** + * 将当前用户关联至百度账号 + */ + linkBaidu() { + if (this._anonymous) { + return Promise.reject(new HError(612)) + } + if (!BaaS._polyfill.linkBaidu) { + return Promise.reject(new HError(605, 'linkBaidu 方法未定义')) + } + return BaaS._polyfill.linkBaidu.apply(null, arguments) + } + linkThirdParty() { if (this._anonymous) { return Promise.reject(new HError(612)) diff --git a/core/config.js b/core/config.js index 59ab1fe3..3d2f7da3 100644 --- a/core/config.js +++ b/core/config.js @@ -85,6 +85,12 @@ const API = { DECRYPT: '/hserve/v2.0/qq/decrypt/', }, + BAIDU: { + SILENT_LOGIN: '/hserve/v2.1/idp/baidu/silent-login/', + AUTHENTICATE: '/hserve/v2.1/idp/baidu/authenticate/', + USER_ASSOCIATE: '/hserve/v2.1/idp/baidu/user-association/', + }, + ALIPAY: { SILENT_LOGIN: '/hserve/v2.1/idp/alipay/silent-login/', AUTHENTICATE: '/hserve/v2.1/idp/alipay/authenticate/', diff --git a/package.json b/package.json index 08cb0762..64d88c4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minapp-sdk", - "version": "2.4.0", + "version": "2.5.0", "main": "./lib/index.js", "browser": "./lib/web.js", "miniprogram": "lib", @@ -34,8 +34,8 @@ }, "homepage": "https://github.com/ifanrx/repository#readme", "versions": { - "alipay": "2.4.0", - "web": "2.4.0" + "alipay": "2.5.0", + "web": "2.5.0" }, "devDependencies": { "@babel/core": "^7.2.2", diff --git a/sdk-file/src/baidu/auth.js b/sdk-file/src/baidu/auth.js new file mode 100644 index 00000000..eda86a96 --- /dev/null +++ b/sdk-file/src/baidu/auth.js @@ -0,0 +1,160 @@ +const constants = require('core-module/constants') +const HError = require('core-module/HError') +const storage = require('core-module/storage') +const utils = require('core-module/utils') +const commonAuth = require('core-module/auth') + +module.exports = BaaS => { + const API = BaaS._config.API + + const getLoginCode = () => { + return new Promise((resolve, reject) => { + swan.login({ + success: res => { + resolve(res.code) + }, + fail: (err) => { + BaaS.request.swanRequestFail(reject) + }, + }) + }) + } + + // 获取登录凭证 code, 进而换取用户登录态信息 + const auth = ({createUser = true} = {}) => { + return new Promise((resolve, reject) => { + getLoginCode().then(code => { + sessionInit({code, createUser}, resolve, reject) + }, reject) + }) + } + + // code 换取 session_key,生成并获取 3rd_session 即 token + const sessionInit = ({code, createUser}, resolve, reject) => { + return BaaS.request({ + url: API.BAIDU.SILENT_LOGIN, + method: 'POST', + data: { + create_user: createUser, + code: code + } + }).then(utils.validateStatusCode).then(res => { + BaaS._polyfill.handleLoginSuccess(res) + resolve(res) + }, reject) + } + + const silentLogin = utils.rateLimit(function (...args) { + if (storage.get(constants.STORAGE_KEY.AUTH_TOKEN) && !utils.isSessionExpired()) { + return Promise.resolve() + } + return auth(...args) + }) + + const getSensitiveData = (data) => { + return BaaS.request({ + url: API.BAIDU.AUTHENTICATE, + method: 'POST', + data, + }).then(utils.validateStatusCode) + } + + const getUserInfo = ({lang} = {}) => { + return new Promise((resolve, reject) => { + swan.getUserInfo({ + lang, + success: resolve, fail: reject + }) + }) + } + + // 提供给开发者在 button (open-type="getUserInfo") 的回调中调用,对加密数据进行解密,同时将 userinfo 存入 storage 中 + const handleUserInfo = res => { + if (!res || !res.detail) { + throw new HError(603) + } + + let detail = res.detail + let createUser = !!res.createUser + let syncUserProfile = res.syncUserProfile + + // 用户拒绝授权,仅返回 uid, openid + if (!detail.userInfo) { + return Promise.reject(Object.assign(new HError(603), { + id: storage.get(constants.STORAGE_KEY.UID), + openid: storage.get(constants.STORAGE_KEY.OPENID), + })) + } + + return getLoginCode().then(code => { + return getUserInfo({lang: detail.userInfo.language}).then(detail => { + let payload = { + code, + create_user: createUser, + data: detail.data, + iv: detail.iv, + update_userprofile: utils.getUpdateUserProfileParam(syncUserProfile), + } + return getSensitiveData(payload) + }) + }).then(res => { + BaaS._polyfill.handleLoginSuccess(res) + }) + } + + + const linkBaidu = (res, { + syncUserProfile = constants.UPDATE_USERPROFILE_VALUE.SETNX, + } = {}) => { + let refreshUserInfo = false + if (res && res.detail && res.detail.userInfo) { + refreshUserInfo = true + } + + return getLoginCode().then(code => { + // 如果用户传递了授权信息,则重新获取一次 userInfo, 避免因为重新获取 code 导致 session 失效而解密失败 + let getUserInfoPromise = refreshUserInfo + ? getUserInfo({lang: res.detail.userInfo.language}) + : Promise.resolve(null) + + return getUserInfoPromise.then(res => { + let payload = res ? { + rawData: res.rawData, + signature: res.signature, + encryptedData: res.encryptedData, + iv: res.iv, + update_userprofile: utils.getUpdateUserProfileParam(syncUserProfile), + code + } : {code} + + return BaaS._baasRequest({ + method: 'POST', + url: API.BAIDU.USER_ASSOCIATE, + data: payload, + }) + }) + }) + } + + const loginWithBaidu = (authData, { + createUser = true, + syncUserProfile = constants.UPDATE_USERPROFILE_VALUE.SETNX, + } = {}) => { + let loginPromise = null + if (authData && authData.detail) { + // handleUserInfo 流程 + loginPromise = handleUserInfo(Object.assign(authData, {createUser, syncUserProfile})) + } else { + // 静默登录流程 + loginPromise = silentLogin({createUser}) + } + + return loginPromise.then(() => commonAuth.getCurrentUser()) + } + + Object.assign(BaaS.auth, { + silentLogin, + loginWithBaidu: utils.rateLimit(loginWithBaidu), + linkBaidu: utils.rateLimit(linkBaidu), + }) +} diff --git a/sdk-file/src/baidu/baasRequest.js b/sdk-file/src/baidu/baasRequest.js new file mode 100644 index 00000000..e0844729 --- /dev/null +++ b/sdk-file/src/baidu/baasRequest.js @@ -0,0 +1,40 @@ +const utils = require('core-module/utils') +const BaaS = require('core-module/baas') +const constants = require('core-module/constants') +const storage = require('core-module/storage') + +/** + * + * @param {object} payload + */ +function tryResendRequest(payload) { + // 情景1:若是第一次出现 401 错误,此时的缓存一定是过期的。 + // 情景2:假设有 a,b 两个 401 错误的请求,a请求 300ms 后返回,走情景 1 的逻辑。b 在 pending 10 秒后返回,此时缓存实际上是没过期的,但是仍然会重新清空缓存,走情景 1 逻辑。 + // 情景3:假设有 a,b,c 3 个并发请求,a 先返回,走了情景 1 的逻辑,此时 bc 请求在 silentLogin 请求返回前返回了,这时候他们会等待 silentLogin , 即多个请求只会发送一次 silentLogin 请求 + if (storage.get(constants.STORAGE_KEY.AUTH_TOKEN)) { + // 缓存被清空,silentLogin 一定会发起 session init 请求 + BaaS.clearSession() + } + + BaaS.auth.silentLogin().then(() => { + return BaaS.request(payload).then(utils.validateStatusCode) + }) +} + +// BaaS 网络请求,此方法能保证在已登录 BaaS 后再发起请求 +// eslint-disable-next-line no-unused-vars +const baasRequest = function ({url, method = 'GET', data = {}, header = {}, dataType = 'json'}) { + let beforeRequestPromise = BaaS._config.AUTO_LOGIN ? BaaS.auth.silentLogin() : Promise.resolve() + + return beforeRequestPromise.then(() => { + return BaaS.request.apply(null, arguments) + }).then(res => { + if (res.statusCode === constants.STATUS_CODE.UNAUTHORIZED && BaaS._config.AUTO_LOGIN) { + return tryResendRequest({header, method, url, data, dataType}) + } else { + return utils.validateStatusCode(res) + } + }) +} + +module.exports = baasRequest diff --git a/sdk-file/src/baidu/index.js b/sdk-file/src/baidu/index.js new file mode 100644 index 00000000..5b795fc1 --- /dev/null +++ b/sdk-file/src/baidu/index.js @@ -0,0 +1,20 @@ +const BaaS = require('core-module/baas') +const core = require('core-module/index') +const polyfill = require('./polyfill') +const auth = require('./auth') + +BaaS._config.VERSION = __VERSION_BAIDU__ + +BaaS.use(core) +BaaS.use(polyfill) +BaaS.use(auth) +BaaS.request = require('./request') +BaaS._baasRequest = require('./baasRequest') +BaaS.uploadFile = require('./uploadFile') +BaaS._createRequestMethod() +// 暴露 BaaS 到小程序环境 +if (typeof swan !== 'undefined') { + swan.BaaS = BaaS +} + +module.exports = BaaS diff --git a/sdk-file/src/baidu/polyfill.js b/sdk-file/src/baidu/polyfill.js new file mode 100644 index 00000000..11368743 --- /dev/null +++ b/sdk-file/src/baidu/polyfill.js @@ -0,0 +1,44 @@ +const constants = require('core-module/constants') +module.exports = BaaS => { + Object.assign(BaaS._polyfill, { + CLIENT_PLATFORM: 'BAIDU', + setStorageSync(k, v) { + return swan.setStorageSync(k, v) + }, + getStorageSync(k) { + return swan.getStorageSync(k) + }, + getSystemInfoSync() { + return swan.getSystemInfoSync() + }, + checkLatestVersion() { + let info = swan.getSystemInfoSync() + if (info.platform === 'devtools') { + BaaS.checkVersion({platform: 'baidu_miniapp'}) + } + }, + linkBaidu(...args) { + return BaaS.auth.linkBaidu(...args) + }, + handleLoginSuccess(res, isAnonymous) { + // 登录成功的 hook (login、loginWithWechat、register)调用成功后触发 + BaaS.storage.set(constants.STORAGE_KEY.UID, res.data.user_id) + BaaS.storage.set(constants.STORAGE_KEY.OPENID, res.data.openid || '') + BaaS.storage.set(constants.STORAGE_KEY.AUTH_TOKEN, res.data.token) + BaaS.storage.set(constants.STORAGE_KEY.UNIONID, res.data.unionid || '') + if (res.data.openid) { + BaaS.storage.set(constants.STORAGE_KEY.USERINFO, { + id: res.data.user_id, + openid: res.data.openid, + unionid: res.data.unionid, + }) + } + BaaS.storage.set(constants.STORAGE_KEY.EXPIRES_AT, Math.floor(Date.now() / 1000) + res.data.expires_in - 30) + if (isAnonymous) { + BaaS.storage.set(constants.STORAGE_KEY.IS_ANONYMOUS_USER, 1) + } else { + BaaS.storage.set(constants.STORAGE_KEY.IS_ANONYMOUS_USER, 0) + } + }, + }) +} diff --git a/sdk-file/src/baidu/request.js b/sdk-file/src/baidu/request.js new file mode 100644 index 00000000..75c269b7 --- /dev/null +++ b/sdk-file/src/baidu/request.js @@ -0,0 +1,49 @@ +const BaaS = require('core-module/baas') +const HError = require('core-module/HError') +const utils = require('core-module/utils') +const constants = require('core-module/constants') + +const swanRequestFail = function (reject) { + swan.getNetworkType({ + success: function (res) { + if (res.networkType === 'none') { + reject(new HError(600)) // 断网 + } else { + reject(new HError(601)) // 网络超时 + } + } + }) +} + +const request = ({url, method = 'GET', data = {}, header = {}, dataType = 'json'}) => { + return new Promise((resolve, reject) => { + + if (!BaaS._config.CLIENT_ID) { + return reject(new HError(602)) + } + + let headers = utils.mergeRequestHeader(header) + + if (!/https?:\/\//.test(url)) { + const API_HOST = BaaS._config.DEBUG ? BaaS._config.API_HOST : BaaS._polyfill.getAPIHost() + url = API_HOST.replace(/\/$/, '') + '/' + url.replace(/^\//, '') + } + + swan.request({ + method: method, + url: url, + data: data, + header: headers, + dataType: dataType, + success: resolve, + fail: (err) => { + swanRequestFail(reject) + } + }) + + utils.log(constants.LOG_LEVEL.INFO, 'Request => ' + url) + }) +} + +module.exports = request +module.exports.swanRequestFail = swanRequestFail diff --git a/sdk-file/src/baidu/uploadFile.js b/sdk-file/src/baidu/uploadFile.js new file mode 100644 index 00000000..0dc210f7 --- /dev/null +++ b/sdk-file/src/baidu/uploadFile.js @@ -0,0 +1,131 @@ +const constants = require('core-module/constants') +const HError = require('core-module/HError') +const utils = require('core-module/utils') +const {getUploadFileConfig, getUploadHeaders} = require('core-module/upload') + +const swanUpload = (config, resolve, reject, type) => { + return swan.uploadFile({ + url: config.uploadUrl, + filePath: config.filePath, + name: constants.UPLOAD.UPLOAD_FILE_KEY, + formData: { + authorization: config.authorization, + policy: config.policy + }, + header: getUploadHeaders(), + success: (res) => { + let result = {} + let data = JSON.parse(res.data) + + result.status = 'ok' + result.path = config.destLink + result.file = { + 'id': config.id, + 'path': config.destLink, + 'name': config.fileName, + 'created_at': data.time, + 'mime_type': data.mimetype, + 'cdn_path': data.url, + 'size': data.file_size, + } + + delete res.data + + if (type && type === 'json') { + res.data = result + } else { + res.data = JSON.stringify(result) + } + + try { + resolve(utils.validateStatusCode(res)) + } catch (err) { + reject(err) + } + }, + fail: () => { + BaaS.request.wxRequestFail(reject) + } + }) +} + +const uploadFile = (fileParams, metaData, type) => { + if (!fileParams || typeof fileParams !== 'object' || !fileParams.filePath) { + throw new HError(605) + } + + if (!metaData) { + metaData = {} + } else if (typeof metaData !== 'object') { + throw new HError(605) + } + + let rs, rj, uploadCallback, isAborted, uploadTask = null + + let p = new Promise((resolve, reject) => { + rs = resolve + rj = reject + }) + + let onProgressUpdate = function (cb) { + if (uploadTask) { + uploadTask.onProgressUpdate(cb) + } else { + uploadCallback = cb + } + return this + } + + let abort = function () { + if (uploadTask) { + uploadTask.abort() + } + isAborted = true + return this + } + + function mix(obj) { + return Object.assign(obj, { + catch(...args) { + let newPromise = Promise.prototype.catch.call(this, ...args) + mix(newPromise) + return newPromise + }, + then(...args) { + let newPromise = Promise.prototype.then.call(this, ...args) + mix(newPromise) + return newPromise + }, + abort: abort, + onProgressUpdate: onProgressUpdate + }) + } + + mix(p) + + let fileName = utils.getFileNameFromPath(fileParams.filePath) + getUploadFileConfig(fileName, utils.replaceQueryParams(metaData)).then(res => { + if (isAborted) return rj(new Error('aborted')) + + let config = { + id: res.data.id, + fileName: fileName, + policy: res.data.policy, + authorization: res.data.authorization, + uploadUrl: res.data.upload_url, + filePath: fileParams.filePath, + destLink: res.data.path + } + uploadTask = swanUpload(config, e => { + if (isAborted) return rj(new Error('aborted')) + rs(e) + }, rj, type) + if (uploadCallback) { + uploadTask.onProgressUpdate(uploadCallback) + } + }, rj) + + return p +} + +module.exports = uploadFile diff --git a/sdk-file/webpack.config.js b/sdk-file/webpack.config.js index 1995b515..1bf29536 100644 --- a/sdk-file/webpack.config.js +++ b/sdk-file/webpack.config.js @@ -12,6 +12,7 @@ let plugins = [ new webpack.DefinePlugin({ __VERSION_WECHAT__: JSON.stringify(`v${(pkg.version)}`), __VERSION_QQ__: JSON.stringify(`v${(pkg.version)}`), + __VERSION_BAIDU__: JSON.stringify(`v${(pkg.version)}`), __VERSION_WEB__: JSON.stringify(`v${(pkg.versions.web)}`), __VERSION_ALIPAY__: JSON.stringify(`v${(pkg.versions.alipay)}`), }), @@ -36,6 +37,7 @@ module.exports = { alipay: './src/alipay/index.js', web: './src/web/index.js', qq: './src/qq/index.js', + baidu: './src/baidu/index.js', }, output: { path: path.join(__dirname, 'dist'),