import { deepClone, makeAssignableMethod, peerAssign, combineFuncs } from '../operationKit';
import { mergingStep, errSafe } from '../decorators';
/**
* 登录模块,详见{@tutorial 2.1-login}
*/
@requireConfig //确保调用API时已完成模块配置
class BaseLogin {
//配置,格式参见config函数
_configOptions = {};
//数据(长期数据,会被存储到storage中)
_loginInfo = {
userInfo: {}, //用户信息
isLogin: false, //是否已登录
expireTime: -1, //过期时间,相对1970年的绝对毫秒数,-1表示长期有效
authType: '', //使用的验证方式
anonymousInfo: {}, //匿名信息(登录成功前使用的临时标识,成功后继续关联)
};
//状态(短期数据,仅本次会话使用)
_stateInfo = {
isConfigReady: false, //是否已完成模块配置
};
/**
* 构造函数
* @param {object} [configOptions] 配置项,参见{@link BaseLogin#config}
*/
constructor(configOptions) {
configOptions && this.config(configOptions);
}
/**
* 模块配置
* @param {Object} configOptions
* @param {String} [configOptions.loginInfoStorage] 登录相关信息存储到storage时使用的key
* @param {Requester} configOptions.requester 请求管理器
* @param {Function} [configOptions.onUserAuthFailed] 钩子函数,获取用户授权信息失败时触发
* @param {Function} [configOptions.onUserAuthSucceeded] 钩子函数,获取用户授权信息成功时触发
* @param {Function} [configOptions.onNewlyLogin] 钩子函数,刚刚登录成功时触发(未登录=>已登录)
* @param {BaseLogin~OnLoginFailed} [configOptions.onLoginFailed] 钩子函数,登录失败时触发
* @param {Object.<string, BaseAuth>} configOptions.authEngineMap 鉴权器映射表
key为登录方式,value为对应的鉴权器
e.g. {
'wechat' : new WechatAuth(), //微信登录,WechatAuth应继承于BaseAuth
'phone' : new PhoneAuth(), //手机号登录,PhoneAuth应继承于BaseAuth
}
* @param {String} configOptions.defaultAuthType 默认登录方式
* @param {BaseLogin~UserAuthHandler} configOptions.userAuthHandler 授权交互处理函数,负责跟用户交互,收集鉴权所需信息
* @param {BaseLogin~LoginStepAddOn} [configOptions.loginStepAddOn] 登录流程自定义附加步骤,会在正常登录流程执行成功时调用,并根据其处理结果生成最终登录结果
* @param {Function} [configOptions.pageConfigHandler] 页面配置处理函数,负责获取当前页面的页面级登录配置,默认实现:
* ```js
* function pageConfigHandler(){
* //获取当前页面实例
* let curPages = getCurrentPages();
* let curPage = curPages[curPages.length-1] || {};
* //获取当前页面的页面级登录配置
* return curPage.$loginOpts;
* }
* ```
*/
config(configOptions) {
//参数校验
const necessaryFields = ['userAuthHandler', 'authEngineMap', 'defaultAuthType', 'requester'];
for (let field of necessaryFields) {
if (configOptions[field] === undefined) {
console.error('[Login] config, 必填参数缺失:', field);
return;
}
}
//参数处理
const defaultOpts = {
loginInfoStorage: '__loginInfo',
requester: null,
onUserAuthFailed: null,
onUserAuthSucceeded: null,
onUserAuthCanceled: null,
onNewlyLogin: null,
onLoginFailed(res, { failAction }) {
switch (failAction) { //调用方希望的失败处理方式
case 'auto': //自动处理
wx.showToast({
title: res.toastMsg || '登录失败',
image: '/images/tipfail.png',
duration: 3000
});
break;
case 'none': //调用方自行处理
break;
default:
console.error('[onLoginFailed] unknown failAction:', failAction);
}
},
authEngineMap: {}, //key: authType, value: BaseAuth
defaultAuthType: '',
userAuthHandler: null,
loginStepAddOn: null,
pageConfigHandler() {
let curPages = getCurrentPages();
let curPage = curPages[curPages.length - 1] || {};
return curPage.$loginOpts;
}
};
Object.assign(this._configOptions, peerAssign({}, defaultOpts, configOptions));
//标记状态
this._stateInfo.isConfigReady = true;
//初始化
this._init();
}
/**
* 追加配置项
* 主要供子类调用,便于子类传递自定义配置项给自定义鉴权器/自定义钩子函数
* 建议子类将所有自定义配置项封装成一个对象,总共占用一个key,避免未来和父类新增配置项命名冲突
* @param {string} key 配置项名称
* @param {object|*} value 配置项值
* @protected
*/
_appendConfig(key, value) {
if (key in this._configOptions) {
console.error('[BaseLogin] _appendConfig, 新增配置项与已有配置项命名冲突:', key);
return;
}
this._configOptions[key] = value;
}
/**
* 初始化
* @protected
*/
_init() {
//获取最近一次登录信息
let lastLoginInfo = JSON.parse(wx.getStorageSync(this._configOptions.loginInfoStorage) || 'null');
this._loginInfo = lastLoginInfo || this._loginInfo;
//无最近登录信息时,设置默认值
this._loginInfo.authType = this._loginInfo.authType || this._configOptions.defaultAuthType;
//有指定前端登录态过期时间时,进行过期处理
if (this._loginInfo.expireTime > 0 && Date.now() > this._loginInfo.expireTime)
this.clearLogin();
}
/**
* 登录
* | 登录模式 | common | silent | force | forceSilent | forceAuth |
* | --- | --- | --- | --- | --- | --- |
* | 定位 | 要有登录态 | 有登录态最好,没有就算了 | 要有严格登录态(保证前后端登录态一致且均未过期) | 有严格登录态最好,没有就算了 | 要展示登录界面 |
* | 是否复用已有登录信息 | √ | √ | × | × | × |
* | 是否尝试静默登录 | √ | √ | √ | √ | × |
* | 是否尝试授权登录 | √ | × | √ | × | √ |
* | 适用场景 | 适合大部分页面场景,如收藏、留言等 | 适合悄悄个性化,如首页个性化定制,搜索页个性化推荐等 | 适合重要且不能失败重试的场景,如重要的微信数据解密 | 适合不能失败重试但可以不要的场景,如不重要的微信数据解密 | 适合与登录界面强耦合的场景,如开发登录弹窗、演示登录界面 |
* @param {Object} [options] 登录选项
* @param {Function} [options.callback], 兼容起见支持回调,但更建议以Promise方式使用
* @param {string} [options.mode] 登录模式,详见上文函数描述部分
* @param {BaseLogin~UserAuthHandler} [options.userAuthHandler] 自定义用户授权交互
* @param {String} [options.failAction] 失败处理方式:auto-自动处理 | none-调用方自行处理 | 其它-和onLoginFailed钩子函数约定的其它处理方式
* @param {Object} [options.thisIssuer] 触发登录的组件的this对象,供钩子函数使用
*
* @return {BaseLogin~LoginRes} 登录结果
*/
async login(options = {}) {
//获取配置参数
const configOptions = this._mergeConfigOptions({
globalConfig: this._configOptions, //全局配置
pageConfig: this._configOptions.pageConfigHandler.call(options.thisIssuer), //页面级配置
});
//填充调用参数默认值
const defaultOpts = {
callback: null,
mode: 'common',
userAuthHandler: configOptions.userAuthHandler,
failAction: 'auto',
thisIssuer: null, //触发登录的组件的this对象,供钩子函数使用
};
options = Object.assign({}, defaultOpts, options);
//状态记录
let isNewlyLogin = !this.checkLogin(); //区分 未登录=>已登录 和 已登录=>刷新登录态
//登录
let loginRes = {};
try {
loginRes = await this._login(options, configOptions);
} catch (e) {
loginRes = { code: -500, errMsg: 'internal error' };
//真机下不支持打印错误栈,导致e打印出来是个空对象;故先单独打印一次e.message
console.error('[login failed] uncaught error:', e && e.message, e);
}
//触发钩子
if (loginRes.code !== 0 && loginRes.code !== -200) { //钩子:登录失败
configOptions.onLoginFailed && await configOptions.onLoginFailed.call(options.thisIssuer, loginRes, { failAction: options.failAction })
}
if (loginRes.code === 0 && isNewlyLogin) { //钩子:刚刚登录
configOptions.onNewlyLogin && await configOptions.onNewlyLogin.call(options.thisIssuer);
}
//返回结果
options.callback && options.callback(loginRes);
return loginRes;
}
/**
* 合并配置项
* @param globalConfig 全局配置,格式参见{@link BaseLogin#config}
* @param pageConfig 页面级配置,格式同全局配置,但只接受以下字段:
* | 字段 | 和全局配置的关系 |
* | --- | --- |
* | onUserAuthFailed | 并存 |
* | onUserAuthSucceeded | 并存 |
* | onNewlyLogin | 并存 |
* | onLoginFailed | 并存 |
* | userAuthHandler | 覆盖 |
* @return {object} 合并后的配置项,格式参见{@link BaseLogin#config}
* @protected
*/
_mergeConfigOptions({ globalConfig, pageConfig }) {
let configOptions = Object.assign({}, globalConfig); //结果
//未指定页面级配置,直接返回
if (!pageConfig)
return configOptions;
//合并配置项
const fieldMetas = [ //字段信息列表
{
field: 'onUserAuthFailed', //字段
action: 'combineFunc' //处理方式
},
{
field: 'onUserAuthSucceeded',
action: 'combineFunc'
},
{
field: 'onUserAuthCanceled',
action: 'combineFunc'
},
{
field: 'onNewlyLogin',
action: 'combineFunc'
},
{
field: 'onLoginFailed',
action: 'combineFunc'
},
{
field: 'userAuthHandler',
action: 'override'
},
];
for (let { field, action } of fieldMetas) {
if (!(field in pageConfig))
continue;
switch (action) {
case 'override':
configOptions[field] = pageConfig[field];
break;
case 'combineFunc':
configOptions[field] = combineFuncs({
funcs: [pageConfig[field], globalConfig[field]]
});
break;
default:
console.error('[_mergeConfigOptions] unknown action:', action, 'for field:', field);
}
}
//返回合并结果
return configOptions;
}
/**
* @private
*/
async _login(options, configOptions) {
//初始状态:未开始
let loginRes = { code: -1, errMsg: 'idle' };
//尝试复用本地登录态
let canUseLocal = !['force', 'forceSilent', 'forceAuth'].includes(options.mode); //是否可复用本地登录态
loginRes = canUseLocal && this.checkLogin() ? { code: 0, errMsg: 'ok' } : loginRes; //本地登录状态
if (loginRes.code === 0) //当前已登录且模式无特殊要求,按成功返回
return { code: 0, errMsg: 'ok' };
//尝试静默登录
let canUseSilent = !['forceAuth'].includes(options.mode); //是否可尝试静默登录
loginRes = canUseSilent ? await this._silentLogin(options, configOptions) : loginRes;
options.authFlowId = (loginRes && loginRes.authFlowId) || ''
if (loginRes.code === 0) //静默登录成功,结束
return { code: 0, errMsg: 'ok' };
//尝试授权登录
let canUseAuth = !['silent', 'forceSilent'].includes(options.mode); //是否可尝试授权登录
loginRes = canUseAuth ? await this._authLogin(options, configOptions) : loginRes;
if (loginRes.code === 0) //授权登录成功,结束
return { code: 0, errMsg: 'ok' };
//全部尝试失败,根据模式调整返回值
if (['silent', 'forceSilent'].includes(options.mode)) //静默模式错误码调整为统一值(这些模式不想让用户感知到登录失败)
loginRes.code = -200;
//返回最终失败结果
return loginRes;
}
/**
* 静默登录
* 在用户无感知的情况下悄悄完成登录过程
* @param options 登录调用参数,格式参见{@link BaseLogin#login}
* @param configOptions 登录配置参数,格式参见{@link BaseLogin#config}
* @return {Promise<*>}
* @private
*/
@mergingStep //步骤并合,避免页面中多处同时触发登录时重复发起登录请求
async _silentLogin(options, configOptions) {
//判断使用的验证方式
let authType = this._loginInfo.authType;
if (authType === 'none')
return { code: -200, errMsg: 'login failed silently: disabled' };
//获取验证方式对应的鉴权器
let authEngine = configOptions.authEngineMap[authType];
if (!authEngine) {
console.error('[login] _silentLogin, cannot find authEngine for authType:', authType);
return { code: -500, errMsg: 'login failed silently: internal error' };
}
//尝试静默登录
let silentRes = {};
try {
silentRes = await authEngine.silentLogin({
loginOptions: options,
configOptions,
});
} catch (e) {
console.error('[login] caught error when try silentLogin of authType:', authType, 'err:', e);
silentRes = { succeeded: false, errMsg: 'internal error' };
}
//更新匿名信息(登录成功前使用的临时标识,成功后继续关联)
silentRes.anonymousInfo && this._saveAnonymousInfo(silentRes.anonymousInfo);
//登录失败,返回
if (!silentRes.succeeded)
return { code: -200, authFlowId: (silentRes && silentRes.authFlowId) || '', errMsg: 'login failed silently: normal' };
//登录成功,保存相关信息
return this._afterFetchInfoPack({
isLogin: true,
userInfo: silentRes.userInfo,
expireTime: silentRes.expireTime,
authType,
});
}
/**
* 授权登录
* 需要用户配合才能完成的登录过程
* @param options 登录调用参数,格式参见{@link BaseLogin#login}
* @param configOptions 登录配置参数,格式参见{@link BaseLogin#config}
* @return {Promise<*>}
* @private
*/
@mergingStep //步骤并合,避免页面中多处同时触发登录时重复发起登录请求
async _authLogin(options, configOptions) {
//执行各鉴权器的beforeAuthLogin钩子
let beforeResMap = {};
for (let authType of Object.keys(configOptions.authEngineMap)) {
let authEngine = configOptions.authEngineMap[authType];
beforeResMap[authType] = authEngine.beforeAuthLogin ? await authEngine.beforeAuthLogin({
loginOptions: options,
configOptions,
}) : null;
}
//展示登录界面,等待用户交互
let userAuthRes = await this._handleUserAuth(options);
//交互失败(e.g.用户取消登录)
if (!userAuthRes.succeeded) {
if (userAuthRes.errMsg.includes('cancel login!')) {
configOptions.onUserAuthCanceled && configOptions.onUserAuthCanceled.call(options.thisIssuer, {});
return { code: -200, errMsg: 'user auth failed:' + userAuthRes.errMsg }
}
configOptions.onUserAuthFailed && configOptions.onUserAuthFailed.call(options.thisIssuer, {});
return { code: -100, errMsg: 'user auth failed:' + userAuthRes.errMsg }
}
//交互成功
//触发钩子:onUserAuthSucceeded
configOptions.onUserAuthSucceeded && configOptions.onUserAuthSucceeded.call(options.thisIssuer);
//根据用户提供的信息进行登录
let authType = userAuthRes.authType;
let authEngine = configOptions.authEngineMap[authType];
if (!authEngine) {
console.error(`[login] 当前指定的登录方式:${authType},不存在对应的鉴权器`);
return { code: -500, errMsg: 'internal error' };
}
let authRes = await authEngine.authLogin({
loginOptions: options,
configOptions,
authData: userAuthRes.authData,
beforeRes: beforeResMap[authType],
});
//更新匿名信息(登录成功前使用的临时标识,成功后继续关联)
authRes.anonymousInfo && this._saveAnonymousInfo(authRes.anonymousInfo);
//登录失败,返回
if (!authRes.succeeded)
return { code: -300, errMsg: `auth login failed: ${authRes.errMsg}`, toastMsg: authRes.toastMsg };
//登录成功,保存相关信息
return this._afterFetchInfoPack({
isLogin: true,
userInfo: authRes.userInfo,
expireTime: authRes.expireTime,
authType,
});
}
/**
* 授权交互处理
* @param options 登录选项,参见{@link BaseLogin#login}
* @return {BaseLogin~UserAuthRes}
* @protected
*/
async _handleUserAuth(options) {
return await options.userAuthHandler.call(options.thisIssuer);
}
/**
* 登录信息获取完毕后续步骤集合
* @private
* @param {BaseLogin~LoginInfo} loginInfo 登录信息
*/
async _afterFetchInfoPack(loginInfo) {
this._saveInfo(loginInfo);
let addOnRes = await this._handleAddOn();
if (!addOnRes.succeeded) {
this.clearLogin();
return { code: -400, errMsg: addOnRes.errMsg || 'add on failed', toastMsg: addOnRes.toastMsg };
}
return { code: 0, errMsg: 'ok' };
}
/**
* 保存/更新登录信息
* @param {BaseLogin~LoginInfo} loginInfo 登录信息
* @protected
*/
_saveInfo(loginInfo) {
Object.assign(this._loginInfo, loginInfo);
wx.setStorage({
key: this._configOptions.loginInfoStorage,
data: JSON.stringify(this._loginInfo)
});
}
/**
* 保存/更新匿名信息
* @param {object} anonymousInfo 匿名信息
* @protected
*/
_saveAnonymousInfo(anonymousInfo) {
Object.assign(this._loginInfo.anonymousInfo, anonymousInfo);
wx.setStorage({
key: this._configOptions.loginInfoStorage,
data: JSON.stringify(this._loginInfo)
});
}
/**
* 支持使用方配置自定义附加步骤,会在正常登录流程执行成功时调用,并根据其处理结果生成最终登录结果
* @return {BaseLogin~LoginStepAddOnRes}
* @protected
*/
async _handleAddOn() {
if (!this._configOptions.loginStepAddOn)
return { succeeded: true };
let stepRes = await this._configOptions.loginStepAddOn();
if (typeof stepRes !== "object") {
console.error('[login] loginStepAddOn shall return an object, something like "{succeeded: true, errMsg:\'debug detail\', toastMsg: \'alert detail\'}", yet got return value:', stepRes);
stepRes = { succeeded: false };
}
return stepRes;
}
/**
*退出登录
* @param {boolean} needClearAuth 是否需要清除鉴权信息:false-仅清除登录态,下次还可以静默登录 | true-同时清除鉴权信息,下次必须授权登录
* @return {Object} res 退出登录结果,格式形如:{code:0, errMsg:'ok'}
*/
logout({ needClearAuth = false } = {}) {
this.clearLogin({ needClearAuth });
return { code: 0, errMsg: 'ok' };
}
/**
* 重新登录
* @return {BaseLogin~LoginRes} 登录结果
*/
async reLogin(...args) {
await this.logout();
return await this.login(...args);
}
/**
* 清除前端登录态
* @param {boolean} needClearAuth 是否需要清除鉴权信息:false-仅清除登录态,下次还可以静默登录 | true-同时清除鉴权信息,下次必须授权登录
*/
clearLogin({ needClearAuth = false } = {}) {
this._loginInfo.isLogin = false;
this._loginInfo.userInfo = {};
this._loginInfo.expireTime = -1;
this._loginInfo.authType = needClearAuth ? 'none' : this._loginInfo.authType;
wx.setStorage({
key: this._configOptions.loginInfoStorage,
data: JSON.stringify(this._loginInfo)
});
}
/**
* 检查是否登录
* @return {boolean} 是否登录
*/
checkLogin() {
return this._loginInfo.isLogin;
}
/**
* 将方法封装为通用函数,使之可以在任意this对象上执行
* @param {String} methodName 方法名
* @return {Function} 封装后的函数
*/
makeAssignableMethod(methodName) {
return makeAssignableMethod({
instance: this,
method: methodName,
rcvThis: {
argIdx: 0,
argProp: 'thisIssuer'
}
});
}
/**
* 获取用户信息
* @return {Object} 用户信息
*/
get userInfo() {
return deepClone(this._loginInfo.userInfo);
}
}
/**
* 类修饰器,确保调用API时已完成模块配置
* @ignore
* @param target
*/
function requireConfig(target) {
let descriptors = Object.getOwnPropertyDescriptors(target.prototype);
for (let prop of Object.getOwnPropertyNames(descriptors)) {
let descriptor = descriptors[prop];
if (typeof descriptor.value !== "function") //非函数,不予处理
continue;
if (['constructor', 'config'].includes(prop) || prop[0] === '_') //无需配置的函数、私有函数,不予处理
continue;
descriptor.value = (function (oriFunc, funcName) { //对外接口,增加配置检查步骤
return function (...args) {
if (!this._stateInfo.isConfigReady) { //若未进行项目信息配置,则报错
console.error('[Login] 请先进行模块配置,后调用模块相关功能', '试图调用:', funcName);
return;
}
return oriFunc.apply(this, args); //否则正常执行原函数
}
}(descriptor.value, prop));
Object.defineProperty(target.prototype, prop, descriptor);
}
}
/**
* @typedef {Function} BaseLogin~OnLoginFailed 登录失败钩子函数
* @param {BaseLogin~LoginRes} loginRes 登录结果
* @param {object} options 选项
* @param {string} options.failAction 调用方希望的失败处理方式:auto-自动处理 | none-调用方自行处理 | 其它约定值
* @example
onLoginFailed(res, {failAction}){
switch (failAction) { //调用方希望的失败处理方式
case 'auto': //自动处理
wx.showToast({
title: res.toastMsg || '登录失败',
image: '/images/tipfail.png',
duration: 3000
});
break;
case 'none': //调用方自行处理
break;
default:
console.error('[onLoginFailed] unknown failAction:', failAction);
}
}
*/
/**
* @typedef {object} BaseLogin~LoginRes 登录结果
* @property {number} code 状态码
* | code | 语义 |
* | --- | --- |
* | 0 | 成功 |
* | -100 | 用户交互失败 e.g.用户拒绝授权等 |
* | -200 | 静默失败,静默登录失败且调用方要求不要尝试授权登录 |
* | -300 | 授权登录失败 |
* | -400 | 附加步骤返回失败结果 |
* | -500 | 模块内部异常 |
* @property {string} errMsg 详细错误日志,debug用
* @property {string} [toastMsg] (若有)用户话术,提示失败原因
* @example
{
code: -300, //状态码,0为成功
errMsg:'login api failed...', //详细错误日志,debug用
toastMsg: '您的账号存在安全风险,请联系客服进行处理' //(若有)用户话术,提示失败原因
}
*/
/**
* @typedef {Function} BaseLogin~UserAuthHandler 授权交互处理函数,负责跟用户交互,收集鉴权所需信息
* @async
* @return {BaseLogin~UserAuthRes} 交互结果
*/
/**
* @typedef {object} BaseLogin~UserAuthRes userAuthHandler交互结果
* @property {boolean} succeeded 是否成功
* @property {string} errMsg 错误信息,调试用
* @property {string} authType 用户选择的登录方式
* @property {object} authData 交互数据,格式由该登录方式对应的鉴权器指定
*/
/**
* @typedef {Function} BaseLogin~LoginStepAddOn 登录流程自定义附加步骤
* @async
* @return {BaseLogin~LoginStepAddOnRes}
*/
/**
* @typedef {object} BaseLogin~LoginStepAddOnRes 登录流程自定义附加步骤处理结果
* @property {boolean} succeeded 是否成功
* @property {string} [errMsg] 详细失败原因,debug用
* @property {string} [toastMsg] (若有)用户话术,提示失败原因
*/
/**
* @typedef {object} BaseLogin~LoginInfo 登录信息
* @property {boolean} isLogin 是否登录
* @property {object} userInfo 用户信息
* @property {number} expireTime 过期时间,绝对毫秒数,-1表示长期有效
* @property {string} authType 使用的验证方式
* @property {object} [anonymousInfo] 匿名信息(登录成功前使用的临时标识,成功后继续关联)
*/
export default BaseLogin;