上一篇文章《小程序靜默登錄方案設計》提到過,小程序可以通過微信官方提供的登錄能力方便地獲取微信提供的用戶身份標識,快速建立小程序內的用戶體系。
即「靜默登錄」,通過調用 wx.login
獲取到 code
,將其發送到開發者后端,開發者后端通過接口去微信后端換取到 openid
和 sessionKey
(現在會將 unionid
也一并返回)后,然后把自定義登錄態 3rd_session
(本業務命名為auth-token
) 返回給前端,就已經完成登錄行為了。
理論上,開發者后端可以通過 openid
識別用戶,也能通過unionid
關聯同主體的多個小程序、公眾號、app,實現數據互通,從而為每一個用戶創建獨一無二的uid
(本業務自定義的用戶 id),在「微信生態」中建立成熟用戶體系。
然而,對于復雜的電商跨端應用,比如pc
、h5
、小程序
,不同渠道注冊的uid
是不同的,用戶登錄后難以對各個渠道的交易、促銷、收藏等數據進行整合。因此,要實現跨端的用戶體系數據互通,就需要提供一個唯一的用戶標識——手機號。這便是本文重點講述的「用戶登錄」,即「游客態」轉變成「會員態」的過程。
上一篇文章《小程序靜默登錄方案設計》中提過,當新用戶第一次進入小程序時,便會觸發「靜默登錄」,這個過程對用戶是無感知的。但此時開發者服務端已經為該用戶定義了uid
,并下發auth-token
給小程序端,對于一些需要鑒權的請求,服務端可以根據請求攜帶的auth-token
精確識別是哪個用戶發起的行為。
然而,類似加購
、下單
、領券
等用戶行為,涉及到跨端數據的整合,在執行用戶操作之前,會判斷用戶是否登錄,如若用戶未登錄,則跳轉登錄頁面,整個流程如下所示:
比如在「用戶中心」頁面點擊「我的訂單」,由于此時用戶未登錄,跳轉到登錄頁面,可以選擇以下兩種登錄方式:
上述步驟已經完成了「用戶登錄」,用戶可以正常的執行加購、領券、下單等操作。 為了提升用戶體驗,需要對 「會員信息」 進行維護 ,比如昵稱、頭像、性別、生日等信息,最簡單的方法是 獲取「微信授權用戶信息」。觸發時機分為以下兩種:
「用戶登錄」方案架構如上圖所示,將所有登錄相關功能抽象到 「service 層」(本項目將其命名為session
),供 「業務層」 調用。該 「service 層」 主要分為以下兩個模塊:
libs
- 提供登錄相關的類方法供「業務層」調用session
類,提供類方法供「業務層」調用。主要有以下幾種方法:方法名 | 功能 | 使用場景 |
---|---|---|
silentLogin | 發起靜默登錄 | - |
login | 登錄,silentLogin 方法的一層封裝 | 用于小程序啟動時發起靜默登錄 |
refreshLogin | 刷新登錄態,silentLogin 方法的一層封裝 | 用于登錄態過期時發起靜默登錄 |
ensureSessionKey | 驗證sessionKey 是否過期,過期則刷新登錄態 | 綁定微信授權手機號時驗證是否過期,過期則得重新彈窗授權 |
bindPhone | 綁定微信授權手機號 | 微信授權手機號彈窗點擊「允許」觸發 |
updateUser | 綁定微信授權用戶信息 | 微信授權用戶信息點擊「允許」觸發 |
getCurrentAuthStep | 獲取當前用戶登錄所屬階段 | 詳見下文 |
mustAuth | 各種觸發場景攔截判斷是否需要登錄 | 詳見下文 |
當然,session
類中還封裝了一些方法用于與storage
交互,比如獲取storage
中的auth-token
用于各種鑒權請求攜帶等等。session
類也提供的一些拓展方法,比如注銷賬號、解綁手機號等等用于后續需求迭代。
裝飾器:
must-auth
: mustAuth
類方法的裝飾器,便于業務層各種場景觸發登錄。fuse-line
: 熔斷機制,如果短時間內多次調用,則停止響應一段時間,類似于 TCP 慢啟動。用于解決refreshLogin
、login
等方法的并發處理問題。single-queue
: 單隊列模式,同一時間,只允許一個正在過程中的網絡請求。請求被鎖定之后,同樣的請求都會被推入隊列,等待進行中的請求返回后,消費同一個結果。用于解決refreshLogin
、login
等方法的并發處理問題。ui
- 提供通用組件供業務層調用user-container
和phone-container
分別是獲取「微信授權用戶信息」和獲取「微信授權手機號」的純 UI 單元組件,給通用組件使用。auth-flow
中,供通用組件使用。auth-flow-container
用于頁面,auth-flow-popup
用于彈窗。如下所示,小程序只有微信授權功能,則可以通過彈窗完成授權。如小程序同時提供手機號驗證碼和密碼登錄等功能,則需跳轉特定登錄頁面。綜上所示,用戶登錄的階段可以分為以下三步:
// 用戶登錄的階段
export enum AuthStepType {
// 階段一:游客態:靜默登錄成功,未綁定手機號,無用戶信息
ONE = 1,
// 階段二:會員態:用戶登錄成功,已綁定手機號,無用戶信息
TWO = 2,
// 階段三:會員信息態:用戶登錄成功,已綁定手機號,有用戶信息
THREE = 3,
}
復制代碼
那么如何判斷用戶此時處于哪個步驟,基于「靜默登錄」的啟發,原本「靜默登錄」成功開發者后端會將自定義登錄態 auth-token
返回給前端,此處請求可以攜帶返回「用戶信息」,同auth-token
一起命名為session
存儲在本地storage
。當「用戶登錄」或者「更新用戶信息」時,會同步更新storage
中key
為session
的數據,從而通過這些用戶數據判斷當前用戶處于哪一個登錄階段。
以下表格列出了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;
}
復制代碼
前面提到過,「用戶登錄」的 目的是為了整合各個渠道的交易、促銷、收藏等數據,針對電商小程序,目前總結的需要用戶登錄的場景如下所示:
即當用戶登錄小程序時,可以正常瀏覽瀏覽商品,只有觸發某些特定行為,比如領券、加購、收藏、下單等,才會判斷用戶是否處于登錄狀態,如未登錄,跳轉登錄頁面。
如下所示,封裝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);
});
};
};
}
復制代碼
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
,開發者每次通過該接口獲取用戶個人信息均需用戶確認,因此需要妥善保管用戶授權的頭像昵稱,避免重復彈窗。
如下圖所示,auth-flow
行為類主要封裝用戶、小程序、服務端三者之間的交互邏輯。
在「微信授權登錄」過程中,小程序拿到加密的encryptedData
和iv
數據,將其和攜帶的auth-token
一起發送給開發者服務器,服務端通過auth-token
鑒權識別這個用戶,并使用靜默登錄成功獲取的session_key
(對稱解密密鑰)對encryptedData
和iv
數據進行對稱解密,獲取該用戶的手機號,將手機號與uid
綁定,此時該用戶成功注冊會員,并將會員信息返回給小程序端。
小程序端更新本地storage
存儲的session
數據,此時busiIdentity
的值已經從VISIT
更新為MEMBER
,用戶身份轉變為會員態,登錄成功。
在「授權用戶信息」的過程中,小程序調用wx.getUserProfile
方法拿到用戶數據,并將這些數據與攜帶的auth-token
一起發送給開發者服務器,服務端通過auth-token
鑒權識別這個用戶,更新該用戶的信息并將新的會員數據返回給小程序端。
小程序端更新本地storage
存儲的session
數據,此時用戶昵稱和頭像均已更新,用戶身份轉變為會員信息態,授權成功。
眼尖的讀者一定觀察到了,時序圖中還對微信頭像做了轉存。這是因為用戶在微信端修改微信頭像后,之前「授權用戶信息」獲取的微信頭像鏈接就會失效,因此開發者應該在自己獲取用戶信息后,將頭像保存下來,避免微信頭像 URL 失效后的異常情況。
通用組件是對基礎組件和行為類的二次封裝,主要是為業務層提供彈窗登錄和頁面登錄兩種能力。
我們將用戶登錄能力從業務層中抽象出來,統一封裝在service
層,便于復用。本文主要講述的是service
層的架構,對于業務層的邏輯實現并沒有多加累贅。下列表格以小程序端為例,簡述了「靜默登錄」和「用戶登錄」整套方案的前后端邏輯實現。
業務場景 | 用戶感知 | 前端處理邏輯 | 后端處理邏輯 | 補充說明 |
---|---|---|---|---|
掃碼搜索等各種方式進入小程序 | 無 | 1、判斷:當前小程序是否緩存了登錄態auth-token 且使用wx.checkSeesion 檢查當前用戶在小程序中登錄態是否過期,過期執行步驟 2;2、使用 wx.login 獲取認證信息,請求后端wxLogin 接口獲取微信小程序認證默認綁定的用戶身份以及登錄態auth-token 。 | 1、解析微信加密信息獲取認證身份openid 和unionId ;2、查找 openid 是否已經綁定了對應的用戶,若綁定直接返回并為其生成對應的登錄態auth-token ;3、新用戶會根據 openid 為其自動生成一個用戶身份uid (見右補充說明)。 | a、存在聚合根標識unionId && 有用戶信息:將已有聚合根用戶對應的exUid 直接映射到當前uid 下;b、存在聚合根標識 unionId && 無用戶信息:根據unionId 生成對應的賬號,但和opneid 對應的uid 一致;c、不存在聚合根標識:直接為對應 openid 初始化一個uid 。 |
收藏、加購、下單、領券等操作 | 攔截跳轉 | 1、判斷: 當前用戶身份處于游客態,跳轉登錄頁面。 | 對應域服務后端接口可以根據請求攜帶的auth-token 進行鑒權,判斷用戶是否有操作權限 | - |
用戶登錄 或者 切換賬號 | 選擇: 1、授權微信手機號登錄; 2、輸入手機號并使用驗證碼/密碼登錄 | 1、用戶選擇授權手機號登錄,后端會根據上一次靜默登錄的sesssionKey 解密,如果解密失敗需要重新走一遍靜默登錄后再讓客戶重試。2、用戶選擇通過驗證碼登錄時,需關注驗證碼時效和重試機制,并有錯誤處理邏輯; 3、用戶選擇密碼登錄時,后臺會返回賬戶未注冊或賬號密碼不對等錯誤,需要有獨立邏輯跳轉驗證碼注冊或找回密碼 4、以上三種方式都需要攜帶 auth-token 進行鑒權 | 1、根據auth-token 獲取當前的渠道基本認證賬戶openid -unionId -uid ;2、授權手機號登錄時需要先解密出手機號,此時不需要校驗,輸入手機號登錄時需要會走「密碼」或「驗證碼」校驗,密碼校驗會攔截賬號不存在或密碼錯誤的場景; 3、根據手機號判斷當前聚合根下是否存在對應的手機號渠道賬號(綁定流程見右補充說明)。 4、返回登錄結果。 | a、手機號已存在:將已存在的用戶exUid 綁定至當前登錄態賬號;b、手機號不存在 && 用戶身份是游客:將手機號和游客對應的 uid 進行綁定c、手機號不存在 && 用戶身份是會員:為手機號生成一個新的 newUid ,并將當前登錄的 openid 渠道賬戶綁定至該newUid 。 |