跳到主要内容

用户系统

最佳实践系列主要以项目案例的形式展示如何设计自定义应用,同时会搭配部分前端页面的逻辑。在阅读后相信你也可以自行设计成熟的应用,用于商业产品。


本章我们从零到一设计一个用户系统,提供注册登录管理等一系列功能,该系统为清林云同款。

该系统为基础版本,主要演示如何使用清林云自定义应用能力,能满足大部分基础需求,但是每家组织的用户系统需求都不相同,所以你也可以在这个基础上再自行添加更多高级的个性化功能,或者搭配其他应用同时使用。

需求#

用户系统基本上是每一个产品的标配,毕竟我们首先要有用户的信息,能够识别和区分用户才能正常服务用户。

所以需求都比较清晰:

  • 用户能够通过手机号、微信等方式登录注册,记录储存用户信息如昵称、头像、手机号等
  • 能够通过用户 ID 识别区分用户,提供不同的数据和服务
  • 保护用户账号安全,不被黑产攻击
  • 能多条件查询用户,进行促销或召回等管理
  • 有权限管理系统保护用户资源,保障用户不会存取其他人的信息

功能分析#

登录#

我们先来分析登录注册功能。

从用户体验来说,登录和注册应该合并在一起,这也是当前大部分产品的通用做法,就是直接使用账户登录,未注册的自动注册。

这里的逻辑就是用户登录时先查询用户,有则直接加密凭证并返回 token,没有则自动用当前登录信息注册创建新用户再返回加密后的 token。

以前我们见到的 APP 用户登录方式多种多样,手机号、邮箱、账号密码、微信、QQ、微博等等。

这里有一个很麻烦的问题就是,比如说当一个用户用手机号注册的账号使用了一段时间,有大量用户数据,另外用户还用微信渠道注册的账号也使用了一段时间,当用户想要在一方绑定另一方时,如果不合并影响用户体验,如果合并则会涉及到大量数据的排序、去重等等,对于大型产品来说几乎不可能。

因此我们会见到市面上有大量糟糕的流程就是:当用户明明选择微信登录了之后还强行要求再用手机号绑定一下,一半的原因是无法很好地处理这个问题,只能全部以手机号区分,另外一半是因为实名制监管和方便发短信营销等,所以很多产品最后干脆只支持用手机号注册了。

应对多渠道的方式还有很多,有的产品不允许绑定其他已经被注册过的渠道,有的则是多账号并存(两个渠道登录的账号绑定一个手机号后可以在设置里选择切换账号)。

这是一个取舍问题。

但是随着网络安全法规的逐渐普及,未来的趋势都是实名制,也就是说企业的用户信息中必须要有用户手机号了。所以我们这里就直接以手机号为中心去设计登录功能。

用户填写手机号后发送短信验证码验证现在比较常见。

而这种方式目前面临着很大的挑战,第一个是贵,一条短信的价格在 4-5 分左右,对于一些个人或小团队开发的应用来说算是比较大的开支了;

第二个是容易被攻击,可能很多人觉得几分钱不算什么,但是如果没有很好的人机验证,短信接口会被竞争对手或那些“短信爆破”之类的非法工具盯上,大量恶意请求发送验证码,一晚上刷爆账单不是问题。

第三个是虽然钱花了依然不能确保用户真实,大量的 sim 卡机注册一样能伪装。比如据我个人所知某知名吃瓜产品起码一半用户都是机器人。

第四个是用户体验较差,同时某些用户也比较抗拒使用手机号,不过市面上基本都这样,用户也就差习惯了~

一个用户系统不难建设,但是建设一个能解决上述问题的用户系统就异常复杂。包括各种各样的验证码,地理位置、机型比对、反 bot 措施之类的。有时候也会造成误封用户或者验证码太难用户直接放弃使用等情况。

这也是一个取舍问题,防御措施的建设成本和有限资金的平衡,防御策略的严格性和误封用户概率的平衡,防御方法和用户体验的平衡等等。

那么基于我们曾经大量的实践经验,有两种方式是在取舍中比较均衡的,尽量做到成本和用户体验的最优化。

第一种是用智能验证保护短信接口:

在前端引入一段 js 代码,然后该代码会自动通过滑动、点击等方式采集到用户的设备信息、动作姿势、动态轨迹和浏览器指纹等等数据,加密发送到后端通过大数据判断是否为真人。用户不用输入难以识别的各种验证码就可以通过测试收到短信从而登录。

第二种是使用微信小程序接口:

上传一个微信小程序,可以在用户登录时通过扫码、跳转等方式打开小程序,使用小程序接口直接获取用户手机号自动登录。方便用户验证的同时还能给自家小程序导流(后面会有实现这种方式的小程序代码参考),通过小程序也可以拿到用户昵称、地区、性别、头像等基础信息,不需用户再次填写。这样安全风险和基础数据提供就被微信承担了。

可以看到清林云采用了第二种方法,用户体验在各种取舍下算是最优化的。

用户通过手机号登录之后,数据库生成一条用户数据,它有一个唯一的“用户 ID”,后续用户的所有操作都通过这个唯一 ID 来保证数据归于该用户。

另外设置一个登录信息 token 过期时间确保安全,过期后用户再次登录,可以通过手机号查询到已经有这个用户了,直接返回该用户的登录 token 即可。

识别和安全#

假如我们的产品是一个电商网站,那么当用户“张三”查看“我的订单”时,只会有“张三”的订单数据,看不到其他人的。那么这个就是识别用户,很好理解。

常用的做法就是设置一个每个用户唯一的“用户 ID”,通过该 ID 来区分数据归属。有的 ID 类型是逐个增长的序列,有的是手机号,有的是随机 ID。

自增 ID 和手机号 ID 容易被扫描攻击,一般商业产品都是用随机 ID。像 QQ 号、微信号这种虽然也是唯一性的,但并不是内部的真实 ID,只是为了方便用户之间查询而已。

用户在登录后我们会返回给他一段登录凭证,里面包含了用户的 ID,前端接受凭证后储存在设备上,后续的网络请求携带这段凭证,后端就可以准确识别用户了。

举个例子,我们将现实生活当作是一个应用,当我们出生(注册)的时候就会分配一个身份证号(用户 ID),自己拿好身份证(前端储存),我们去坐火车时要求出示身份证表明本人(需要识别用户身份的应用功能),然后到自己的座位(该 ID 的数据归属)坐好。

但是如果有人偷了身份证呢(窃取用户 ID)?小偷(黑产)拿着我的身份证上车使用我的座位(伪装用户盗取数据)。所以我们不仅仅需要 ID,还需要一定安全措施来保护它。就像现在进站都是人脸识别,人工比对之类的措施进行复查。

在计算机世界,我们怎么保护用户凭证呢?当前比较常用的一种方式是 JWT (Json Web Token)。我们现在不用去详细了解它的具体原理,只需要知道怎么用就可以了,就像用电脑打游戏也不用非得学习计算机原理一样。

JWT 的流程就是用一段密码将用户 ID 等数据签名后返回给用户,用户后面的请求都携带这段加密信息,后端接受到请求后用密码验证信息,证明请求来自该用户,这样尽管攻击者知道用户 ID,但因为没有密码,也就不能伪装该用户。这段密码就是创建环境密匙时只显示一次的secret

所以我们在用户登录时返回给用户 JWT 密文即可初步保护账号安全。

另外就是当已有用户登录时,通过是否可疑 IP、经常登录地理位置、非常用设备等条件判断本次登录的安全风险,通知前端校验用户的真实性,进一步加强安全能力。

搜索和分析#

当我们运营自己的产品时,肯定需要查询用户、分析用户的需求。

查询比较简单,在创建用户表时使用S系列引擎,即意味着清林云底层会为该表创建搜索索引,就可以任意组合用户字段条件查询符合的用户,进行简单的分析。

比如说查询某某标签的用户数量,七日留存用户,用户日志等。

当然在本应用中我们只需要实现这些基础功能即可,另外还有像智能画像、行为分析、用户采集等功能我们交给其他应用来实现,可以随意搭配所需。

基本权限#

常用的权限类型有四种。

第一种没有固定的名字,简单称为归属型。比如说我发一条朋友圈,这条朋友圈数据归属于我,那么当执行修改或删除操作时,先查询这条数据归属于哪个用户,查看该用户是否和执行请求的 token 数据的用户相同,相同则有权限删除,不相同则拒绝执行。该方法一般在流程中对数据进行判断,比较灵活简单,也是使用最多的方式。

第二种是身份型 ACL(Access Control List)。当用户登录时 token 中包含用户的身份类型如管理员访客等。然后在请求 API 时直接判断用户的身份是否拥有相关权限。这种方式最简单,但不太灵活。

第三种是基于角色的访问控制(Role-based access control,简称 RBAC)。简单来说就是把权限的设置放到 Role 角色上,然后再把角色放到用户或用户组上。这种通过加两层的方式使得配置用户的权限较为简单,也是传统企业软件常用的方式,但是在控制上比较死板,而且因为多查了几层延迟较高的原因,不适用于 To C 的大型应用。

第四种是基于策略的访问控制(PBAC, Policy-based)。就是给用户设置权限策略,访问 API 时会查询该用户的权限数据里是否包含相关策略。比如用户张三的权限中,target目标为book:123(或任意字符串)的策略是['get','update'(或任意数组项)],意思是张三对于 id 为 123 的书有查询和修改的权限,但是没有删除该 book 的delete权限。这种方式异常灵活。

当然了,优秀的应用不会只使用一种,都是多种权限类型搭配不同场景使用。比如说简单的逻辑使用第一种,固定的场景和小型/内部应用使用第二种,企业应用使用第三种,啥都能用一下的第四种。

本应用因为通用性比较强,所以权限类型基本上都是第一种,等会在 API 章节中详细说明。清林云内置的权限流程及权限检查的“标准权限”为第四种,通过增加储存来提高灵活性和性能。

数据库结构#

好的,现在我们开始设计数据库结构。

根据上述的功能分析,我们需要以下几张表:

User 用户,UserRight 用户权限,LoginCode 登录码,UserLog 用户日志。

首先创建一张 User 用户表,因为我们需要多条件搜索用户,所以引擎选择S1

然后用户字段设置如下:

user1 user2 user3

UserRight用户权限需要对应用户 ID、应用 ID 和目标 target,所以选择X3引擎,如下设置:

userRight

另外UserLog用户日志表只需要用户 ID 和一个自增 ID 即可,所以选择A2引擎,如下:

userLog

LoginCode登录码我们需要过期就删除,不占用储存,所以采用E1引擎,如下:

loginCode

API#

有了数据库结构后我们就可以开始设计 API 了,通过上述分析得知,我们需要以下几个 API:

  • createLoginCode 创建登录码
  • updateLoginCode 更新登录码
  • createPhoneCode 创建手机验证码
  • loginWithWxApp 小程序登录
  • loginWithWxCode 扫码登录
  • loginWithPhone 手机号登录
  • getUser 查询用户自身数据
  • updateUser 更新用户数据
  • refreshToken 刷新 token
  • createUserLog 创建用户日志

另外因为涉及微信和短信的接口需要使用第三方接口,所以我们需要在配置中设置如下,使用该应用者在应用配置页面填写相关数据后才可正常使用:

wxAppId 和 wxAppSecret 两个信息用于小程序登录,后面的用于发送短信。

config

注意此处的短信供应商,用户系统暂时只支持两家阿里云和腾讯云:aliyun tencent,如需其他可以联系我们。

createLoginCode 创建登录码#

这里我们使用了微信小程序创建二维码的接口,通过该接口可以拿到一张小程序码图片,并且能带有场景值。

我们先创建一条登录码数据,得到随机 ID,然后将该 ID 设为小程序码的场景值。这样用户在扫描小程序码后就能进入到指定页面,小程序同时得到该场景值。

在小程序中,我们引导用户使用手机号登录,然后判断是否有场景值,如果有则将该条登录码数据更新,相当于绑定了用户。这时候网页端不断轮询该登录码信息,如果发现有绑定则代表用户已登录,返回用户 token 即可。

该接口加上下面三个接口共同完成了这个流程。

该接口使用了 微信常用接口 应用。

createLoginCode1

updateLoginCode 更新登录码#

在小程序端,当用户登录后或者已登录时,如果有登录码 ID 的场景值,则使用该接口,将用户信息绑定到登录码上,使得扫码登录接口能够查询到并网页登录。

updateLoginCode

loginWithWxApp 小程序登录#

该接口使用了微信小程序获取用户手机号的接口。当我们引导用户拿到手机号时,先查询是否已有该手机号用户,如果有则直接生成并返回该用户 token,没有则创建一个新用户再返回。

loginWithWxApp1

loginWithWxApp2

loginWithWxApp3

loginWithWxCode 扫码登录#

网页端在创建了登录码后即可调用该接口查询信息,如果查到登录码已绑定,就会返回该用户 token 并删除该登录码。

前端判断 retry,如果是则延迟两秒再次调用查询,如果有 token 返回则将其保存在localstorage,完成登录并跳转到主页面。

loginWithWxCode1

loginWithWxCode2

createPhoneCode 创建手机验证码#

该接口使用了 智能验证 应用,通过前端的智能参数判断该请求是否来自于正常人类而不是爬虫或恶意请求。

验证成功后就生成一个 6 位数字的验证码,并在新登录码上绑定手机号和验证码,然后调用 短信 应用的 sendSms 接口发送短信给该手机号。

createPhoneCode1

createPhoneCode2

loginWithPhone 手机号登录#

用户收到短信验证码后填写登录,该接口先是查询登录码验证是否是该手机号,验证码是否正确,时间是否在规定期限内,然后都验证通过后则开始查询是否已有用户,有则返回无则新建。最后删除该登录码。

前端收到 token 即表示登录成功,没有则根据验证步骤哪一步失败提示相应信息。

loginWithPhone1

loginWithPhone2

loginWithPhone3

loginWithPhone4

loginWithPhone5

getUser 查询用户自身数据#

用于应用内用户查询自身数据。

getUser

updateUser 更新用户数据#

用户更新自己资料的接口。

updateUser1

updateUser2

updateUser3

refreshToken 刷新 token#

默认用户在网页端的登录信息 token 有效期为 30 天,防止 token 泄露后被用来盗取信息。如果用户活跃,前端每隔几天调用一次该接口保持 token 有效期更新即可。如果用户一个月都没有登录,则要求用户重新登录。

小程序端的安全性有保障,所以 token 的有效期是永久的。

refreshToken

createUserLog 创建用户日志#

简单记录一下用户的操作日志。

createUserLog

界面#

请替换下方代码中的环境配置使用,在 script 的 config 区域。

注意,在上线前需要前往环境密匙列表,在密匙的白名单 URL 添加域名 origin 和小程序 referer:https://servicewechat.com

微信小程序扫码登录示例#

下面是简单用 html 写了一个网页端扫码登录页面,可以看到扫码登录的基本逻辑,需要搭配小程序使用。

<!DOCTYPE html><html>  <head>    <meta charset="utf-8" />    <meta name="viewport" content="width=device-width, initial-scale=1" />    <meta name="theme-color" content="#fff" />    <link rel="icon" href="/image/icon/favicon.ico" />    <title>清林云登录</title>    <style>      *,      *::before,      *::after {        box-sizing: border-box;        border-width: 0; /* 2 */        border-style: solid; /* 2 */        border-color: #e4e4e7; /* 2 */      }      :root {        -moz-tab-size: 4;        -o-tab-size: 4;        tab-size: 4;      }      html {        line-height: 1.5;        -webkit-text-size-adjust: 100%;      }      body {        margin: 0;        font-size: 16px;        font-family: 'PingFang SC', 'Heiti SC', 'Microsoft YaHei UI',          'Microsoft YaHei', 'Source Han Sans', sans-serif;        font-weight: 400;        min-width: 320px;        direction: ltr;        font-feature-settings: 'kern';        text-rendering: optimizeLegibility;        -webkit-font-smoothing: antialiased;        -moz-osx-font-smoothing: grayscale;        scroll-behavior: smooth;        color: #333;      }      a {        color: inherit;        text-decoration: inherit;      }      .flex {        display: flex;      }      .flexColumn {        flex-direction: column;      }      .aic {        align-items: center;      }      .jcc {        justify-content: center;      }      .container {        min-height: 100vh;        background: linear-gradient(-45deg, #c9d6ff, #e2e2e2);      }      .bar {        width: 100%;        padding: 10px 20px;        justify-content: space-between;      }      .logo {        width: 40px;        height: 40px;        margin-right: 10px;      }      .link {        margin-right: 20px;      }      .footer {        padding: 10px 20px;        justify-content: space-between;        font-size: 12px;        color: rgb(143, 143, 143);      }      .content {        flex: 1;      }      .card {        padding: 50px 120px 0;        background-color: #fff;        border-radius: 8px;        box-shadow: 0px 2px 4px -1px rgb(0 0 0 / 20%), 0px 4px 5px 0px rgb(0 0                0 / 14%), 0px 1px 10px 0px rgb(0 0 0 / 12%);        transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;        overflow: hidden;      }      .title {        font-size: 1.5em;        margin: auto;      }      .qrcode {        height: 200px;        width: 200px;        margin: 20px;      }      .desc {        margin: auto;      }      .note {        margin-top: 50px;        font-size: 10px;        color: #ccc;        margin-bottom: 5px;      }      .alink {        color: rgb(131, 179, 255);      }      @media (max-width: 640px) {        .card {          padding: 40px;        }        .footer {          flex-direction: column;        }      }      .smImg {        width: 20px;        height: 20px;      }    </style>  </head>  <body>    <main class="container flex flexColumn">      <div class="bar flex aic">        <div class="flex aic">          <img            loading="lazy"            class="logo"            id="logo"            alt="清林云"            src="https://www.baasapi.com/image/site/slogo.svg"          />          <div>清林云</div>        </div>        <div class="flex aic">          <a href="https://www.baasapi.com/" class="link">首页</a>          <a href="https://www.baasapi.com/home" target="_blank" class="link"            >控制台</a          >          <a href="https://www.baasapi.com/docs" target="_blank" class="link"            >文档</a          >        </div>      </div>      <div class="content flex aic jcc">        <div class="card flex flexColumn aic jcc">          <div class="title">登录</div>          <img            id="qrcode"            alt="qrcode"            src="http://placehold.it/100?text=Loading..."            class="qrcode"          />          <div class="desc">请使用微信扫描二维码登录</div>          <div class="note">            如您登录则视为同意:            <a              href="https://www.baasapi.com/docs/tos"              target="_blank"              class="alink"            >              《服务条款》            </a>          </div>        </div>      </div>      <div class="footer flex aic">        <div>          Copyright © <span id="year">2021</span> www.baasapi.com All right          reserved.        </div>      </div>    </main>  </body>  <script>    // config start    const envId = '';    const keyId = '';    const region = 'cn-east-1';    const callbackPath =      decodeURIComponent(        new URLSearchParams(window.location.search.substring(1)).get(          'callbackPath',        ),      ) || '/home';    // config end
    let year = document.getElementById('year');    year.innerText = new Date().getFullYear();
    const token = window.localStorage.getItem('token');    if (token) {      const expired = window.localStorage.getItem('expired');      if (expired && parseInt(expired) < Date.now()) {        window.localStorage.clear();        start();      } else {        window.location.replace(window.location.origin + callbackPath);      }    } else {      start();    }
    function start() {      const localCode = window.localStorage.getItem('codeId');      const codeTime = window.localStorage.getItem('codeTime');      if (localCode && Date.now() - (parseInt(codeTime) || 0) < 86000000) {        document          .getElementById('qrcode')          .setAttribute('src', window.localStorage.getItem('codeImg'));        getLoginCode(localCode);      } else {        createLoginCode();      }    }    function createLoginCode() {      fetch('https://' + region + '.baasapi.com', {        method: 'POST',        headers: {          envId,          keyId,          'Content-Type': 'application/json',        },        body: JSON.stringify({          appId: 'user',          api: 'createLoginCode',          args: {},          version: 'latest',        }),      })        .then((res) => res.json())        .then((data) => {          if (data.loginCode && data.image.codeImage.data) {            let qrimg = document.getElementById('qrcode');            let codeImage =              'data:image/png;base64,' +              window.btoa(                String.fromCharCode(                  ...new Uint8Array(data.image.codeImage.data),                ),              );            qrimg.setAttribute('src', codeImage);            window.localStorage.setItem('codeId', data.loginCode.codeId);            window.localStorage.setItem('codeTime', Date.now());            window.localStorage.setItem('codeImg', codeImage);            setTimeout(() => {              getLoginCode(data.loginCode.codeId);            }, 10000);          } else {            window.alert('Error: ' + data.errMessage);          }        })        .catch((e) => {          window.alert('Network Error: ' + e.message);          console.error(e);        });    }    let retry = 1;    function getLoginCode(codeId) {      if (!codeId) {        return;      }      fetch('https://' + region + '.baasapi.com', {        method: 'POST',        headers: {          envId,          keyId,          'Content-Type': 'application/json',        },        body: JSON.stringify({          appId: 'user',          api: 'loginWithWxCode',          args: {            codeId,          },          version: 'latest',        }),      })        .then((res) => res.json())        .then((data) => {          if (data.token) {            window.localStorage.setItem('token', 'Bearer ' + data.token);            window.localStorage.setItem(              'expired',              String(Date.now() + 2592000000),            );            window.localStorage.setItem(              'refresh',              String(Date.now() + 86400000),            );            window.localStorage.removeItem('codeId');            window.localStorage.removeItem('codeTime');            window.localStorage.removeItem('codeImg');            window.location.replace(window.location.origin + callbackPath);          } else if (!data.code) {            window.localStorage.clear();            start();          } else if (data.retry) {            setTimeout(              () => {                getLoginCode(codeId);              },              retry > 20 ? 10000 : 3000,            );            retry = retry + 1;          } else {            window.alert('请求错误');            console.error(data);          }        })        .catch((e) => {          window.alert('Network Error: ' + e.message);          console.error(e);        });    }  </script></html>

小程序获取手机号并绑定登录码#

这里我们使用 Taro 简单展示小程序端的处理逻辑,你可以按需应用在自己的小程序中,实现免费获取手机号并实现网页扫码登录功能。

import React, { useEffect } from 'react';import Taro from '@tarojs/taro';import { View, Button, Image } from '@tarojs/components';import './index.css';import { useSetState } from 'ahooks';import logo from '../../static/logo.png';const rq = async (args, core) => {  try {    let res = await Taro.request({      url: 'https://cn-east-1.baasapi.com/',      method: 'POST',      data: args,      header: {        'content-type': 'application/json',        Authorization: Taro.getStorageSync('token') || '',        envId: '你的环境ID',        keyId: '你的环境密匙',      },    });    if (res.statusCode !== 200) {      console.error(res.data);      Taro.showToast({        title: '网络错误:' + res.data?.title,        icon: 'none',      });      return null;    }    if (res.data.errCode) {      console.error(res.data);      Taro.showToast({        title: '请求失败:' + res.data?.title,        icon: 'none',      });      return null;    }    return res.data;  } catch (error) {    Taro.showToast({      title: '网络错误:' + JSON.stringify(error),      icon: 'none',    });    console.error(error);  }};
export default function Page(props) {  const [state, setState] = useSetState({    scene: Taro.getCurrentInstance().router.params?.scene || '',    code: '',    upText: '使用微信头像完善基础信息',    isLogin: !!Taro.getStorageSync('token'),    name: Taro.getStorageSync('userName') || '',  });  useEffect(async () => {    const { code } = await Taro.login();    setState({ code });  }, [state.scene]);  const confirmLogin = () => {    rq({      api: 'updateLoginCode',      appId: 'user',      args: {        codeId: state.scene,      },      version: 'latest',    }).then((res) => {      if (res) {        Taro.showToast({ title: '登录成功', icon: 'success' });        setState({ scene: '' });      }    });  };  const getPhoneNumber = async (e) => {    if (e.detail.errMsg === 'getPhoneNumber:fail user deny') return;    const data = await rq({      api: 'loginWithWxApp',      appId: 'user',      args: {        code: state.code,        encryptedData: e.detail.encryptedData,        iv: e.detail.iv,      },      version: 'latest',    });    if (data.token) {      Taro.setStorageSync('token', 'Bearer ' + data.token);      Taro.setStorageSync('userId', data.userId);      const user = data.searchUser.data[0];      if (user) {        await Taro.setStorage({ key: 'userName', data: user.name });        await Taro.setStorage({ key: 'userAvatar', data: user.avatar });        setState({ name: user.name });      }      if (state.scene) {        rq({          api: 'updateLoginCode',          appId: 'user',          args: {            codeId: state.scene,          },          version: 'latest',        }).then((res) => {          if (res) {            Taro.showToast({ title: '登录成功', icon: 'success' });            setState({              isLogin: true,              scene: '',            });          }        });      } else {        Taro.showToast({ title: '登录成功', icon: 'success' });        setState({          isLogin: true,        });      }    } else {      Taro.showToast({ title: '登录失败' });    }  };  const getUserProfile = async (e) => {    Taro.getUserProfile({      desc: '用于完善用户基本信息',      success: (res) => {        if (res.errMsg !== 'getUserProfile:ok') {          return;        }        Taro.setStorageSync('userName', res.userInfo.nickName);        Taro.setStorageSync('userAvatar', res.userInfo.avatarUrl);        setState({          name: res.userInfo.nickName,          avatar: res.userInfo.avatarUrl,        });        rq({          api: 'updateUser',          appId: 'user',          args: {            name: res.userInfo.nickName,            avatar: res.userInfo.avatarUrl,            city: res.userInfo.city,            country: res.userInfo.country,            gender: res.userInfo.gender,            province: res.userInfo.province,            locale: res.userInfo.language,          },          version: 'latest',        }).then((res) => {          if (res) {            setState({ upText: '已更新' });            Taro.showToast({ title: '更新资料成功' });          }        });      },    });  };  const swi = () => {    Taro.switchTab({ url: '/pages/index/index' });  };  return (    <View className='df fdc aic'>      <Image className='loginLogo' src={logo} />      <View className='loginWelcome'>欢迎登录清林云</View>      {state.scene && <View>已扫码:{state.scene}</View>}      {state.isLogin ? (        <React.Fragment>          {state.name && (            <View className='df fdc'>              <View className='myName'>{'欢迎: ' + state.name}</View>            </View>          )}          {state.scene && (            <Button onClick={confirmLogin} className='resButton' type='primary'>              确认扫码登录            </Button>          )}          <Button            openType='getUserProfile'            onClick={getUserProfile}            className='resButton'          >            {state.upText}          </Button>          <Button onClick={swi} className='resButton'>            返回首页          </Button>        </React.Fragment>      ) : (        <Button          className='loginButton'          type='primary'          openType='getPhoneNumber'          onGetPhoneNumber={getPhoneNumber}        >          微信登录        </Button>      )}    </View>  );}

手机号登录示例#

由于要使用 智能验证 应用,所以我们要引入验证代码,获得参数,在请求时携带。

整体界面和前面差不多,你可以根据自己需求修改纯 html 为其他框架。

<!DOCTYPE html><html>  <head>    <meta charset="utf-8" />    <meta name="viewport" content="width=device-width, initial-scale=1" />    <meta name="theme-color" content="#fff" />    <link rel="icon" href="/image/icon/favicon.ico" />    <title>清林云登录</title>    <script src="https://g.alicdn.com/AWSC/AWSC/awsc.js"></script>    <style>      *,      *::before,      *::after {        box-sizing: border-box;        border-width: 0; /* 2 */        border-style: solid; /* 2 */        border-color: #e4e4e7; /* 2 */      }      :root {        -moz-tab-size: 4;        -o-tab-size: 4;        tab-size: 4;      }      html {        line-height: 1.5;        -webkit-text-size-adjust: 100%;      }      body {        margin: 0;        font-size: 16px;        font-family: 'PingFang SC', 'Heiti SC', 'Microsoft YaHei UI',          'Microsoft YaHei', 'Source Han Sans', sans-serif;        font-weight: 400;        min-width: 320px;        direction: ltr;        font-feature-settings: 'kern';        text-rendering: optimizeLegibility;        -webkit-font-smoothing: antialiased;        -moz-osx-font-smoothing: grayscale;        scroll-behavior: smooth;        color: #333;      }      a {        color: inherit;        text-decoration: inherit;      }      .flex {        display: flex;      }      .flexColumn {        flex-direction: column;      }      .aic {        align-items: center;      }      .jcc {        justify-content: center;      }      .container {        min-height: 100vh;        background: linear-gradient(-45deg, #c9d6ff, #e2e2e2);      }      .bar {        width: 100%;        padding: 10px 20px;        justify-content: space-between;      }      .logo {        width: 40px;        height: 40px;        margin-right: 10px;      }      .link {        margin-right: 20px;      }      .footer {        padding: 10px 20px;        justify-content: space-between;        font-size: 12px;        color: rgb(143, 143, 143);      }      .content {        flex: 1;      }      .card {        padding: 50px 120px 0;        background-color: #fff;        border-radius: 8px;        box-shadow: 0px 2px 4px -1px rgb(0 0 0 / 20%), 0px 4px 5px 0px rgb(0 0                0 / 14%), 0px 1px 10px 0px rgb(0 0 0 / 12%);        transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;        overflow: hidden;      }      .title {        font-size: 1.5em;        margin: auto;      }      .note {        margin-top: 50px;        font-size: 10px;        color: #ccc;        margin-bottom: 5px;      }      .alink {        color: rgb(179, 190, 206);      }      @media (max-width: 640px) {        .card {          padding: 40px;        }        .footer {          flex-direction: column;        }      }      .smImg {        width: 20px;        height: 20px;      }      .input {        padding: 16.5px 14px;        border-bottom: 1px solid #999;        margin: 8px;        height: 2em;        display: block;        width: 100%;        animation-duration: 10ms;      }      .form {        padding: 40px 0;      }      .button {        width: 100%;        margin: 8px;        height: 2em;      }    </style>  </head>  <body>    <main class="container flex flexColumn">      <div class="bar flex aic">        <div class="flex aic">          <img            loading="lazy"            class="logo"            id="logo"            alt="清林云"            src="https://www.baasapi.com/image/site/slogo.svg"          />          <div>清林云</div>        </div>        <div class="flex aic">          <a href="https://www.baasapi.com/" class="link">首页</a>          <a href="https://www.baasapi.com/home" target="_blank" class="link"            >控制台</a          >          <a href="https://www.baasapi.com/docs" target="_blank" class="link"            >文档</a          >        </div>      </div>      <div class="content flex aic jcc">        <div class="card flex flexColumn aic jcc">          <div class="title">手机登录</div>          <form name="form1" class="form flex flexColumn aic jcc">            <input              placeholder="手机号"              required              minlength="11"              maxlength="11"              type="text"              name="phone"              id="phoneNumber"              class="input"              value=""            />            <div style="position: relative" id="check"></div>            <input              placeholder="验证码"              required              minlength="4"              maxlength="6"              type="hidden"              name="code"              id="code"              class="input"              value=""            />            <input type="button" id="send" value="发送验证码" class="button" />            <input              type="button"              id="login"              value="登录"              class="button"              hidden="true"            />          </form>          <div class="note">            <a href="/login" class="alink note link">切换为二维码登录</a>            如您登录则视为同意:            <a              href="https://www.baasapi.com/docs/tos"              target="_blank"              class="alink"            >              《服务条款》            </a>          </div>        </div>      </div>      <div class="footer flex aic">        <div>          Copyright © <span id="year">2021</span> www.baasapi.com All right          reserved.        </div>      </div>    </main>  </body>  <script>    // config start    const envId = '';    const keyId = '';    const region = 'cn-east-1';    const callbackPath =      decodeURIComponent(        new URLSearchParams(window.location.search.substring(1)).get(          'callbackPath',        ),      ) || '/home';    // config end
    let year = document.getElementById('year');    year.innerText = new Date().getFullYear();
    const token = window.localStorage.getItem('token');    if (token) {      const expired = window.localStorage.getItem('expired');      if (expired && parseInt(expired) < Date.now()) {        window.localStorage.clear();        start();      } else {        window.location.replace(window.location.origin + callbackPath);      }    }    let time = 60;    let sendBtn = document.getElementById('send');    sendBtn.onclick = send;    AWSC.use('nvc', function (state, module) {      window.nvc = module.init({        appkey: 'FFFF0N00000000009F5F',        scene: 'nvc_login',        // 二次验证获取人机信息串,跟随业务请求一起上传至业务服务器,由业务服务器进行验签。        success: function (data) {          let phone = document.getElementById('phoneNumber').value;          if (!phone || phone.length !== 11) {            window.alert('请输入正确的电话');            return;          }          createPhoneCode({ phone: '+86' + phone, val: data });        },        fail: function (failCode) {          window.alert('二次验证失败,请刷新重试');        },        error: function (errorCode) {          window.alert('加载错误,请刷新重试');        },      });    });
    let loginBtn = document.getElementById('login');    loginBtn.onclick = loginWithPhone;
    function send() {      let phone = document.getElementById('phoneNumber').value;      if (!phone || phone.length !== 11) {        window.alert('请输入正确的电话');        return;      }      sendBtn.disabled = true;      sendBtn.value = 'Loading...';      window.nvc.getNVCValAsync(function (nvcVal) {        createPhoneCode({ phone: '+86' + phone, val: nvcVal });      });    }
    function createPhoneCode({ phone, val }) {      fetch('https://' + region + '.baasapi.com', {        method: 'POST',        headers: {          envId,          keyId,          'Content-Type': 'application/json',        },        body: JSON.stringify({          appId: 'user',          api: 'createPhoneCode',          args: {            phoneNumber: phone,            val,          },          version: 'latest',        }),      })        .then((res) => res.json())        .then((data) => {          let json = data && data.verify && data.verify.result;          if (!json) {            window.alert('请求错误');            window.location.reload();            return;          }          if (json.errCode) {            window.alert('验证错误');            window.location.reload();            return;          }          if (json.bizCode === '800' || json.bizCode === '900') {            // 无痕验证失败,直接拦截            sendBtn.type = 'hidden';            loginBtn.type = 'hidden';            alert('人机校验失败');            return;          } else if (json.bizCode === '400') {            var ncoption = {              // 声明滑动验证需要渲染的目标ID。              renderTo: 'check',            };            sendBtn.hidden = true;            // 唤醒二次验证(滑动验证码)            window.nvc.getNC(ncoption);            return;          }          window.nvc.reset();          if (data.send && data.send.data.success === false) {            window.alert(data.send.data.details.Message);            return;          }          if (data.loginCode) {            sendBtn.hidden = false;            loginBtn.hidden = false;            let codeInput = document.getElementById('code');            codeInput.type = 'text';            codeInput.focus();            window.localStorage.setItem('phoneCodeId', data.loginCode.codeId);            window.localStorage.setItem('phoneCodeTime', Date.now());            let oneInterval = setInterval(() => {              if (time < 1) {                sendBtn.disabled = false;                sendBtn.value = '发送验证码';                time = 60;                clearInterval(oneInterval);              } else {                sendBtn.value = '重新发送(' + time + ')';                time = time - 1;              }            }, 1000);          } else {            window.alert('Error: ' + data.title);          }        })        .catch((e) => {          window.alert('网络错误: ' + e.message);          console.error(e);          window.location.reload();        });    }
    function loginWithPhone() {      loginBtn.disabled = true;      loginBtn.value = 'Logining...';      let codeId = window.localStorage.getItem('phoneCodeId');      let phone = document.getElementById('phoneNumber').value;      let code = document.getElementById('code').value;      fetch('https://' + region + '.baasapi.com', {        method: 'POST',        headers: {          envId,          keyId,          'Content-Type': 'application/json',        },        body: JSON.stringify({          appId: 'user',          api: 'loginWithPhone',          args: {            codeId,            phoneNumber: '+86' + phone,            code,          },          version: 'latest',        }),      })        .then((res) => res.json())        .then((data) => {          loginBtn.value = '登录成功';          if (data.token) {            window.localStorage.setItem('token', 'Bearer ' + data.token);            window.localStorage.setItem(              'expired',              String(Date.now() + 2592000),              String(Date.now() + 2592000000),            );            window.localStorage.setItem(              'refresh',              String(Date.now() + 86400000),            );            window.localStorage.removeItem('phoneCodeId');            window.localStorage.removeItem('phoneCodeTime');            window.location.replace(window.location.origin + callbackPath);          } else {            window.alert('请求错误:' + data.title);            console.error(data);          }        })        .catch((e) => {          window.alert('Network Error: ' + e.message);          console.error(e);        });    }  </script></html>

好的,通过文档和实际代码演示,相信你已经能够利用清林云 BaaS 快速完成一个产品的用户注册登录功能,可以扫码免费获得手机号也能用传统的手机号验证码。

在用户登录后,你就可以使用其他应用来完成你的业务逻辑了,还有,用户系统也包含了一个简单好用的权限系统,你可以在下一章组织系统中了解更多。