Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Token 与多端登录态

如果说前一章解决的是“用户是谁”,那么这一章解决的就是“用户现在是以什么身份、从什么端、在什么应用里登录进来的”。

Oak 里的 Token 不是一个简单的 session 字符串。它还绑定了:

  • 当前应用;
  • 当前用户;
  • 当前扮演者 player
  • 当前环境 env
  • token 的刷新时间和旧值。

这也是为什么 oak-general-business 可以同时处理 web、小程序、公众号、原生 App 这些不同环境的登录态。

主要对象

这一章的核心实体是 Token

它保存了:

  • entity/entityId:token 关联的是谁;
  • user/player:当前真实用户与当前扮演者;
  • application:当前应用;
  • env:登录环境;
  • value/oldValue:当前 token 值与上一个 token 值;
  • refreshedAt/disablesAt:刷新时间与禁用时间。

这里还有一个非常关键的设计:oldValue 允许刷新 token 后的一小段时间里仍能识别旧 token,这对前后端切换 token 值很有帮助。

组件

和登录态直接相关的组件主要有:

  • src/components/user/login
  • src/components/user/login/password
  • src/components/user/login/sms
  • src/components/user/login/email
  • src/components/token/me
  • src/components/common/weChatLoginGrant
  • src/components/common/weChatLoginQrCode

其中 components/user/login/index.ts 是最值得读的一个例子。它会根据当前应用的 applicationPassport 动态决定:

  • 展示哪些登录方式;
  • 是否允许密码登录;
  • 是否展示 OAuth 登录;
  • 是否允许注册。

user/login 常用参数

oak-general-business/src/components/user/login/index.ts 暴露的参数比表面看起来多,但项目里最常用的是下面这些:

  • onlyCaptcha:只保留手机号验证码登录
  • onlyPassword:只保留密码登录
  • disabled:禁用某类登录方式
  • redirectUri:微信登录成功后回跳到哪个 wechatUser/login 页面
  • url:登录完成后最终要回到的业务页面
  • callback:非微信登录成功后的回调
  • goRegister:跳注册页
  • isRegisterBack:从注册页返回时优先切到密码登录
  • goOauthLogin:跳指定 OAuth 提供方登录

taicang/src/pages/frontend/login/web.tsx 的真实写法就是:

<Login
  redirectUri={redirectUri}
  url={backUrl}
  callback={() => {
    go();
  }}
/>

user/login 真正是怎么决定显示哪些入口的

这一点值得直接写清楚,因为它不是一个静态登录页。

oak-general-business/src/components/user/login/index.ts 看,它在 ready() 里会先做这几步:

  1. getApplicationPassports(applicationId) 取当前应用真正启用的登录方式
  2. 找出 isDefault=true 的默认登录方式
  3. 再结合本地存储里的 loginMode 决定当前默认显示哪个 tab
  4. 根据 sms/email/loginName 上的 allowPwd 推导是否允许显示密码登录
  5. 根据 oauth 类型 passport.config.oauthIds 去加载第三方 OAuth 提供方列表
  6. passport.config.digit 里取短信、邮箱验证码位数
  7. application.system.config.Password 里取密码存储模式和密码规则

也就是说,user/login 的显示来源同时依赖:

  • ApplicationPassport
  • Passport.config
  • System.config.Password
  • 本地存储里的上次登录方式

项目层如果发现登录页和后台配置不一致,优先应该回查这四层,而不是先改组件渲染。

user/login/password 常用参数

如果项目只想单独复用密码登录子组件,而不是整套 user/login,最值得先记住的是这些参数:

  • pwdAllowMobile
  • pwdAllowEmail
  • pwdAllowLoginName
  • allowSms
  • allowEmail
  • allowWechatMp
  • setLoginMode
  • pwdMode
  • allowRegister
  • goRegister

它当前的真实行为包括:

  • 账号输入框占位文案会按 pwdAllowMobile / pwdAllowEmail / pwdAllowLoginName 自动拼成“账号/手机号/邮箱”提示
  • 提交前只校验“账号非空 + 密码满足 isPassword(...)
  • pwdMode === 'sha1' 时,会先走 encryptPasswordSha1(...) 再调用 features.token.loginByAccount(...)
  • 登录成功后优先走 callback,没有 callback 才按 url 跳转

所以它更适合:

  • 项目已经确定只做密码登录
  • 但仍然想保留“手机号/邮箱/账号名都可作为账号输入”的灵活性

user/login/sms 常用参数

短信登录子组件最关键的参数则是:

  • digit
  • allowPassword
  • allowEmail
  • allowWechatMp
  • setLoginMode
  • callback
  • url

它的真实行为也很值得写进文档:

  • 发验证码固定走 features.token.sendCaptcha('mobile', mobile, 'login')
  • 登录固定走 features.token.loginByMobile(mobile, captcha)
  • 验证码格式校验直接使用 isCaptcha(value, digit)
  • 发送冷却时间会写本地存储,开发环境默认 10 秒,生产环境默认 60 秒

这意味着项目层如果只是想裁出“纯短信登录页”,直接用这个子组件会比从 user/login 再做条件裁剪更直接。

token/me 常用参数

oak-general-business/src/components/token/me/index.ts 更像“当前登录用户入口卡片”,项目里最常传:

  • loginUrl:未登录时跳去哪里
  • myInfoUrl:查看我的资料
  • manageUserUrl:进入用户管理
  • onMyInfoClicked:自定义点击“我的资料”行为
  • Body:在默认卡片下方追加项目自己的内容

taicang/src/pages/frontend/my/web.pc.tsx 里是这样接的:

<GeneralMe
  oakPath="$$general-my"
  loginUrl="/login"
  myInfoUrl="/my/info"
  manageUserUrl="/user/manage"
  Body={<Button onClick={() => logout()}>{t('logout')}</Button>}
/>

token/me 的真实判断逻辑

这个组件看起来像一张简单的“我的”卡片,但它背后有几层很明确的 token 语义:

  • 它查询的不是“全部 token”,而是按 features.token.getTokenValue() 过滤当前本地 token 值
  • 会额外再查一次 extraFile(tag1='avatar') 来拼头像 URL
  • isPlayingAnother 的判断条件是 token.userId !== token.playerId
  • isRoot 取的是当前 player.isRoot,不是单纯看 user.isRoot

它的登录入口逻辑也不是固定跳转:

  • 小程序环境 doLogin() 会直接调用 features.token.loginWechatMp()
  • Web 环境才会按 loginUrl 跳独立登录页

所以 token/me 很适合做:

  • 小程序首页“我的”卡片
  • PC 前台右上角当前用户入口
  • 需要区分“当前是不是在代入别的 player” 的后台入口

common/weChatLoginGrant / common/weChatLoginQrCode

这两个组件虽然不直接挂在 Token 实体上,但它们本质上都是给微信网页登录链路做“前置入口”。

weChatLoginGrant 适合做按钮式授权入口,常用参数包括:

  • appId
  • scope
  • redirectUri
  • state
  • disabled
  • disableText
  • dev

它的真实行为是:

  • 生产环境直接跳微信 OAuth 授权地址
  • 开发环境用本地模拟 code 的方式跳到 redirectUri
  • disabled 时不会跳转,而是提示 disableText

weChatLoginQrCode 则适合桌面端扫码登录,常用参数也很接近:

  • appId
  • scope
  • redirectUri
  • state
  • disabled
  • disableText
  • dev
  • href

它的额外特点是:

  • 生产环境会动态加载微信官方 wxLogin.js
  • href 可以覆盖默认二维码样式
  • disabled 时会显示一层“禁用微信二维码”的遮罩,而不是直接卸载组件

所以项目里如果需要“按钮授权”和“扫码授权”两种入口,并不需要自己拼 OAuth URL,直接复用这两个公共组件更稳。

前端 feature 与 aspect

这一章最重要的前端 feature 是 features.token。它几乎承载了整套登录行为:

  • loginByAccount
  • loginByMobile
  • loginByEmail
  • bindByMobile
  • bindByEmail
  • sendCaptcha
  • loginWechat
  • loginWechatMp
  • loginWechatNative
  • loginByOAuth
  • loginWebByMpToken
  • refreshToken
  • logout
  • switchTo
  • verifyPassword
  • getWechatMpUserPhoneNumber
  • wakeupParasite
  • refreshWechatPublicUserInfo
  • syncUserInfoWechatMp

对应的后端 aspect 集中在 src/aspects/token.ts,其中还包含:

  • sendCaptchaByMobile
  • sendCaptchaByEmail
  • bindByMobile
  • bindByEmail
  • refreshWechatPublicUserInfo
  • setUserAvatarFromWechat

这就是 oak-general-business 最典型的一种设计:登录态通过一个统一的 feature 暴露,后端则用一组命名清晰的 aspect 支撑它。

watcher 与后台规则

这一章还有一个必须知道的后台补偿逻辑:

  • src/watchers/token.ts 会定期把已经到达 disablesAt 的 token 执行 disable

另外,refreshToken(...) 这条 aspect 还做了很多运行时保护:

  • 检查 token 对应的环境和当前环境是否一致;
  • 在 server 模式下按系统配置或默认间隔轮换 token 值;
  • 必要时回写 applicationId

所以 token 的刷新不是一个“前端本地行为”,而是 Oak 运行时和后台共同维护的一条业务链。

注入点

features.token 的注入点在 oak-general-business/src/features/index.ts

  • create(...) 时创建 token feature;
  • token feature 会订阅 application feature,在应用识别成功后读取本地存储里的 token;
  • initialize(...) 在小程序环境下还会按需自动执行 loginWechatMp()

这意味着项目只要正确接入了 oak-general-business,小程序登录、token 本地缓存、刷新与失效,都会自动串起来。

项目中如何接入

Token 能力的项目接入非常固定:

  • createOgb0Features(...) 注入 features.token
  • initializeOgb0Features(...) 里自动识别应用、读取本地 token、必要时执行小程序自动登录
  • 页面、组件、页面守卫统一只从 features.token 读写登录态

也就是说,项目一旦把 oak-general-business 初始化链路接好,后面大部分页面都只需要关心“有没有登录”“当前用户是谁”。

真实项目里的常见页面落点

taicang 的现有页面看,token 相关组件大致会落在三个位置:

  • 独立登录页:直接包 user/login
  • “我的”首页:已登录显示 token/me,未登录显示 user/login
  • 需要先登录再继续的业务页:直接内嵌 user/login,并把 redirectUri 指向统一的 /wechatUser/login

如果页面是桌面端工作台、运营后台或 PC 登录页,还很适合补:

  • 一键授权按钮:common/weChatLoginGrant
  • 扫码登录区:common/weChatLoginQrCode

这类页面最重要的不是自己判断有哪些登录方式,而是保证当前应用的 ApplicationPassport 已经配置正确,让 user/login 自己读配置渲染。

使用示例

1. 账号密码登录

await this.features.token.loginByAccount(account, password);
const userId = this.features.token.getUserId(true);

2. 短信验证码登录

await this.features.token.sendCaptcha('mobile', mobile, 'login');
await this.features.token.loginByMobile(mobile, captcha);

这也是 src/components/mobile/loginsrc/components/user/login/sms 的真实调用方式。

3. 页面里判断登录态和退出登录

bm-smart 很多页面就是这么写的:

const loggedIn = !!this.features.token.getTokenValue();
const user = this.features.token.getUserInfo();

if (loggedIn) {
  await this.features.token.logout();
}

4. 小程序场景直接拿手机号

await this.features.token.getWechatMpUserPhoneNumber(code);

如果你已经正确执行了 initializeOgb0Features(...),小程序端首次进入时还会按需自动触发 loginWechatMp()

使用建议

对于组件开发来说,一条经验非常重要:

不要在页面里直接去拼 token 查询和写入逻辑,而是统一走 features.token

因为登录、刷新、环境校验、自动登出、密码强度检查这些逻辑,已经被集中封装在这里了。绕开它,反而更容易把系统行为写乱。