小程序用户登陆架构设计

1. 背景

上一篇文章《小程序静默登陆方案设计》提到过,小程序能够经过微信官方提供的登陆能力方便地获取微信提供的用户身份标识,快速创建小程序内的用户体系。前端

即「静默登陆」,经过调用 wx.login 获取到 code ,将其发送到开发者后端,开发者后端经过接口去微信后端换取到 openidsessionKey(如今会将 unionid 也一并返回)后,而后把自定义登陆态 3rd_session(本业务命名为auth-token) 返回给前端,就已经完成登陆行为了。小程序

理论上,开发者后端能够经过 openid识别用户,也能经过unionid关联同主体的多个小程序、公众号、app,实现数据互通,从而为每个用户建立独一无二的uid(本业务自定义的用户 id),在「微信生态」中创建成熟用户体系。后端

然而,对于复杂的电商跨端应用,好比pch5小程序,不一样渠道注册的uid是不一样的,用户登陆后难以对各个渠道的交易、促销、收藏等数据进行整合。所以,要实现跨端的用户体系数据互通,就须要提供一个惟一的用户标识——手机号。这即是本文重点讲述的「用户登陆」,即「游客态」转变成「会员态」的过程。微信小程序

2. 「用户登陆」流程

上一篇文章《小程序静默登陆方案设计》中提过,当新用户第一次进入小程序时,便会触发「静默登陆」,这个过程对用户是无感知的。但此时开发者服务端已经为该用户定义了uid,并下发auth-token给小程序端,对于一些须要鉴权的请求,服务端能够根据请求携带的auth-token精确识别是哪一个用户发起的行为。缓存

然而,相似加购下单领券等用户行为,涉及到跨端数据的整合,在执行用户操做以前,会判断用户是否登陆,如若用户未登陆,则跳转登陆页面,整个流程以下所示:服务器

登陆流程图

好比在「用户中心」页面点击「个人订单」,因为此时用户未登陆,跳转到登陆页面,能够选择如下两种登陆方式:微信

  1. 选择 「微信受权登陆」,弹出受权手机号信息弹窗,点击「容许」,此时用户登陆成功。
  2. 选择 「手机快捷登陆」,输入手机号,使用 「验证码」 或者 「密码」 进行登陆,登陆成功跳转回到「用户中心」页面。

上述步骤已经完成了「用户登陆」,用户能够正常的执行加购、领券、下单等操做。 为了提高用户体验,须要对 「会员信息」 进行维护 ,好比昵称、头像、性别、生日等信息,最简单的方法是 获取「微信受权用户信息」。触发时机分为如下两种:markdown

  1. 用户第一次选择 「微信受权登陆」 成功后跳转受权用户信息页面,点击 「受权用户信息」,弹出受权用户信息弹窗。点击「容许」,跳转回「用户中心」页面。
  2. 在「用户中心」页面点击头像昵称区域,弹出受权用户信息弹窗,点击「容许」,更新「会员信息」并跳转用户信息编辑页面。

3. 「用户登陆」方案设计

3.1 架构

用户登陆架构

「用户登陆」方案架构如上图所示,将全部登陆相关功能抽象到 「service 层」(本项目将其命名为session),供 「业务层」 调用。该 「service 层」 主要分为如下两个模块:网络

3.1.1 libs - 提供登陆相关的类方法供「业务层」调用

  1. 封装session类,提供类方法供「业务层」调用。主要有如下几种方法:
方法名 功能 使用场景
silentLogin 发起静默登陆 -
login 登陆,silentLogin方法的一层封装 用于小程序启动时发起静默登陆
refreshLogin 刷新登陆态,silentLogin方法的一层封装 用于登陆态过时时发起静默登陆
ensureSessionKey 验证sessionKey是否过时,过时则刷新登陆态 绑定微信受权手机号时验证是否过时,过时则得从新弹窗受权
bindPhone 绑定微信受权手机号 微信受权手机号弹窗点击「容许」触发
updateUser 绑定微信受权用户信息 微信受权用户信息点击「容许」触发
getCurrentAuthStep 获取当前用户登陆所属阶段 详见下文
mustAuth 各类触发场景拦截判断是否须要登陆 详见下文

固然,session类中还封装了一些方法用于与storage交互,好比获取storage中的auth-token用于各类鉴权请求携带等等。session类也提供的一些拓展方法,好比注销帐号、解绑手机号等等用于后续需求迭代。session

  1. 装饰器:

    • must-authmustAuth类方法的装饰器,便于业务层各类场景触发登陆。
    • fuse-line熔断机制,若是短期内屡次调用,则中止响应一段时间,相似于 TCP 慢启动。用于解决refreshLoginlogin等方法的并发处理问题。
    • single-queue单队列模式,同一时间,只容许一个正在过程当中的网络请求。请求被锁定以后,一样的请求都会被推入队列,等待进行中的请求返回后,消费同一个结果。用于解决refreshLoginlogin等方法的并发处理问题。

3.1.2 ui - 提供通用组件供业务层调用

  1. 基础组件user-containerphone-container分别是获取「微信受权用户信息」和获取「微信受权手机号」的纯 UI 单元组件,给通用组件使用。
  2. behavior 类:拿到受权数据后须要发送给服务端进行存储,也须要执行一些跳转逻辑判断,这些都抽象成行为类封装在auth-flow中,供通用组件使用。
  3. 通用组件: 共用一个行为类,区别在于auth-flow-container用于页面,auth-flow-popup用于弹窗。以下所示,小程序只有微信受权功能,则能够经过弹窗完成受权。如小程序同时提供手机号验证码和密码登陆等功能,则需跳转特定登陆页面。
登陆流程-弹窗

3.2 libs

3.2.1 用户身份定义

用户登陆阶段

综上所示,用户登陆的阶段能够分为如下三步:

// 用户登陆的阶段
export enum AuthStepType {
  // 阶段一:游客态:静默登陆成功,未绑定手机号,无用户信息
  ONE = 1,
  // 阶段二:会员态:用户登陆成功,已绑定手机号,无用户信息
  TWO = 2,
  // 阶段三:会员信息态:用户登陆成功,已绑定手机号,有用户信息
  THREE = 3,
}
复制代码

那么如何判断用户此时处于哪一个步骤,基于「静默登陆」的启发,本来「静默登陆」成功开发者后端会将自定义登陆态 auth-token返回给前端,此处请求能够携带返回「用户信息」,同auth-token一块儿命名为session存储在本地storage当「用户登陆」或者「更新用户信息」时,会同步更新storagekeysession的数据,从而经过这些用户数据判断当前用户处于哪个登陆阶段

如下表格列出了session存储的部分重要的属性以及在三个阶段属性对应的值。

属性 定义 游客态 会员态 会员信息态
authToken 自定义登陆态 '0d5bad172...' '0d5bad172...' '0d5bad172...'
uid 用户 id '001' '001' '001'
busiIdentity 用户身份定义 'VISIT' 'MEMBER' 'MEMBER'
nickName 用户昵称 '' 'u_a1bk45' 'rileycai'
headUrl 头像连接 '' '' 'www.xx.com/image/...'
phone 手机号码 '' '17600888888' '17600888888'
... 其它用户信息 ... ... ...

注意: 会员态和会员信息态的busiIdentity值均为MEMBER,区分会员态和会员信息态能够经过用户昵称和头像等字段,好比用户登陆成功会为用户生成以'u_'开头的默认昵称和默认为空的用户头像连接。

判断用户此时处于哪一个步骤的代码以下:

// 获取当前受权阶段
  public getCurrentAuthStep(): AuthStepType {
    // 切换帐号登陆的时候,始终返回AuthStepType.ONE
    const loginMode = this.getLoginMode();
    if (loginMode === LoginMode.SWITCH_ACCOUNT) return AuthStepType.ONE;

    // 用户身份定义非会员返回AuthStepType.ONE
    const userInfo = this.getUser();
    if (userInfo?.busiIdentity !== 'MEMBER') return AuthStepType.ONE;

    // 初次登陆,未受权用户信息,返回AuthStepType.TWO
    if (userInfo.nickName.substring(0, 2) === 'u_' && !userInfo.headUrl)
      return AuthStepType.TWO;

    // 都有,返回AuthStepType.THREE
    return AuthStepType.THREE;
  }
复制代码

3.2.2 用户登陆触发场景

前面提到过,「用户登陆」的 目的是为了整合各个渠道的交易、促销、收藏等数据,针对电商小程序,目前总结的须要用户登陆的场景以下所示:

用户登陆场景

即当用户登陆小程序时,能够正常浏览浏览商品,只有触发某些特定行为,好比领券、加购、收藏、下单等,才会判断用户是否处于登陆状态,如未登陆,跳转登陆页面

以下所示,封装mustAuth方法进行拦截,未登陆则跳转登陆页面:

export default class Session {
  ...
  public mustAuth({
    mustAuthStep = AuthStepType.TWO, // 传人参数,须要受权的LEVEL
  } = {}): Promise<void> {
    // 当前阶段处于会员态(2)或者会员信息态(3),执行resolve操做
    if (this.getCurrentAuthStep() >= mustAuthStep) return Promise.resolve();
    // 当前阶段处于游客态(1),跳转登陆页
    Navigator.gotoPage('/login/home');
    // 执行reject操做
    return Promise.reject();
  }
}
复制代码

上述代码是跳转页面拦截,对于弹窗而言,须要把弹窗注入base-page(每一个页面都须要引入的通用组件,封装每一个页面都须要使用的通用方法,好比错误处理等)中,经过 id 查找到弹窗组件,并进行调用。

export default class Session {
  ...
   public mustAuth({
    mustAuthStep = AuthStepType.TWO, // 须要受权的LEVEL
    popupCompName = 'auth-flow-popup',
  } = {}): Promise<void> {
    // 当前阶段处于会员态(2)或者会员信息态(3),执行resolve操做
    if (this.getCurrentAuthStep() >= mustAuthStep) return Promise.resolve();
    // 获取弹窗组件
    const pages = getCurrentPages();
    const curPage = pages[pages.length - 1];
    const context = curPage.$$basePage || curPage;
    const popupComp = context.selectComponent(`#${popupCompName}`);
    // 容错处理
    if (!popupComp) {
      return Promise.reject(
        new Error(
          "当前页面未找到 #auth-popup 组件,请参考 'doc/登陆组件的使用方式.md'",
        ),
      );
    }
    // 调用弹窗组件方法
    popupComp.setMustAuthStep(mustAuthStep);
    popupComp.nextStep();
    // 等待受权成功回调
    return this.waitAuth();
  }
}

复制代码

各个业务使用时能够经过session.mustAuth().then(() => {...});进行调用,为了提升使用体验,也可使用装饰器@mustAuth()来修饰各个业务需求 类的方法,装饰器源码以下:

/** * 登陆检查装饰器,使用该装饰器的方法,会先执行受权检查,若是未受权,将跳转登陆页面 */
export default function mustAuth(option = {}) {
  return function( _target: Record<string, any>, _propertyName: string, descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, ) {
    const method = descriptor.value;
    descriptor.value = function(...args: any[]) {
      if (!session) return;
      // 登陆拦截
      return session.mustAuth(option).then(() => {
        if (method) return method.apply(this, args);
      });
    };
  };
}
复制代码

3.3 UI

3.3.1 基础组件

1. phone-container 组件

由于须要用户主动触发才能发起获取微信受权手机号接口,需用 button 组件的点击来触发。组件代码以下所示:

// index.wxml
 <button class="reset-button" open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber" hover-class="none" disabled="{{disabled}}"><slot></slot></button>

// index.ts
export default class PhoneContainer extends BaseComponent {
  getPhoneNumber( e: WechatMiniprogram.Event<WechatMiniprogram.GetPhoneNumberCallbackResult>, ) {
    this.triggerEvent('getphonenumber', { ...e.detail,  authType: AuthType.PHONE,});
  }
}
复制代码

phone-container是一个纯 UI 组件,经过triggerEvent事件将获取手机号数据传递给父组件,

2. user-container 组件

user-container组件是获取微信受权用户信息的纯 UI 组件,以前经过<button open-type="getUserInfo" bindgetUserInfo="getUserInfo"/>的方式进行获取。2021 年 2 月 23 日,微信团队发布了《小程序登陆、用户信息相关接口调整说明》,新增getUserProfile接口替代原来的wx.getUserInfo,来获取用户头像、昵称、性别及地区信息,也是经过button 组件的点击来触发。二者的区别以下图所示:

获取用户信息接口区别

2012 年 4 月 13 日以前,使用wx.getUserInfo弹出受权弹窗时,若是用户点击容许受权,那么会记录用户的行为,下次再点击时,不会弹窗而是直接将受权结果返回。4 月 13 日以后后,使用wx.getUserProfile开发者每次经过该接口获取用户我的信息均需用户确认,所以须要妥善保管用户受权的头像昵称,避免重复弹窗。

3.3.2 行为类

以下图所示,auth-flow行为类主要封装用户、小程序、服务端三者之间的交互逻辑。

用户行为

在「微信受权登陆」过程当中,小程序拿到加密的encryptedDataiv数据,将其和携带的auth-token一块儿发送给开发者服务器,服务端经过auth-token鉴权识别这个用户,并使用静默登陆成功获取的session_key(对称解密密钥)对encryptedDataiv数据进行对称解密,获取该用户的手机号,将手机号与uid绑定,此时该用户成功注册会员,并将会员信息返回给小程序端。

小程序端更新本地storage存储的session数据,此时busiIdentity的值已经从VISIT更新为MEMBER,用户身份转变为会员态,登陆成功。

在「受权用户信息」的过程当中,小程序调用wx.getUserProfile方法拿到用户数据,并将这些数据与携带的auth-token一块儿发送给开发者服务器,服务端经过auth-token鉴权识别这个用户,更新该用户的信息并将新的会员数据返回给小程序端。

小程序端更新本地storage存储的session数据,此时用户昵称和头像均已更新,用户身份转变为会员信息态,受权成功。

眼尖的读者必定观察到了,时序图中还对微信头像作了转存。这是由于用户在微信端修改微信头像后,以前「受权用户信息」获取的微信头像连接就会失效,所以开发者应该在本身获取用户信息后,将头像保存下来,避免微信头像 URL 失效后的异常状况。

3.3.3 通用组件

通用组件是对基础组件和行为类的二次封装,主要是为业务层提供弹窗登陆和页面登陆两种能力。

4. 总结

咱们将用户登陆能力从业务层中抽象出来,统一封装在service层,便于复用。本文主要讲述的是service层的架构,对于业务层的逻辑实现并无多加累赘。下列表格以小程序端为例,简述了「静默登陆」和「用户登陆」整套方案的先后端逻辑实现。

业务场景 用户感知 前端处理逻辑 后端处理逻辑 补充说明
扫码搜索等各类方式进入小程序 一、判断:当前小程序是否缓存了登陆态auth-token 且使用wx.checkSeesion检查当前用户在小程序中登陆态是否过时,过时执行步骤 2;
二、使用wx.login获取认证信息,请求后端wxLogin接口获取微信小程序认证默认绑定的用户身份以及登陆态auth-token
一、解析微信加密信息获取认证身份openidunionId
二、查找openid是否已经绑定了对应的用户,若绑定直接返回并为其生成对应的登陆态auth-token
三、新用户会根据openid为其自动生成一个用户身份uid(见右补充说明)。
a、存在聚合根标识unionId && 有用户信息:将已有聚合根用户对应的exUid直接映射到当前uid下;
b、存在聚合根标识unionId && 无用户信息:根据unionId生成对应的帐号,但和opneid对应的uid一致;
c、不存在聚合根标识:直接为对应openid初始化一个uid
收藏加购下单领券等操做 拦截跳转 一、判断: 当前用户身份处于游客态,跳转登陆页面。 对应域服务后端接口能够根据请求携带的auth-token进行鉴权,判断用户是否有操做权限 -
用户登陆 或者 切换帐号 选择:
一、受权微信手机号登陆;
二、输入手机号并使用验证码/密码登陆
一、用户选择受权手机号登陆,后端会根据上一次静默登陆的sesssionKey解密,若是解密失败须要从新走一遍静默登陆后再让客户重试。
二、用户选择经过验证码登陆时,需关注验证码时效和重试机制,并有错误处理逻辑;
三、用户选择密码登陆时,后台会返回帐户未注册或帐号密码不对等错误,须要有独立逻辑跳转验证码注册或找回密码
四、以上三种方式都须要携带auth-token进行鉴权
一、根据auth-token获取当前的渠道基本认证帐户openid-unionId-uid
二、受权手机号登陆时须要先解密出手机号,此时不须要校验,输入手机号登陆时须要会走「密码」或「验证码」校验,密码校验会拦截帐号不存在或密码错误的场景;
三、根据手机号判断当前聚合根下是否存在对应的手机号渠道帐号(绑定流程见右补充说明)。
四、返回登陆结果。
a、手机号已存在:将已存在的用户exUid绑定至当前登陆态帐号;
b、手机号不存在 && 用户身份是游客:将手机号和游客对应的uid进行绑定
c、手机号不存在 && 用户身份是会员:为手机号生成一个新的newUid,并将当前登陆的 openid 渠道帐户绑定至该newUid

做者水平有限,敬请指教~

相关文章
相关标签/搜索