阅读视图

发现新文章,点击刷新页面。

基于uniapp+nodejs实现小程序登录功能

本系列教程,以【中二少年工具箱】小程序为案例demo,具体效果请在微信中搜索该小程序查看。或在微信输入框输入 【#小程序://中二少年工具箱/6buitXgPnjHV21r】


一、概述

1.1 技术选型:

小程序端:uniapp

后端:nodejs+midwayjs+typeorm

数据库:mysql

1.2 登录功能实现方案:

1.小程序端调用接口uni.login获取随机code

2.将随机code传递给后端接口,后端接口中调用小程序官方api,获取用户信息

3.后端将用户信息保存到数据库中用户信息表,并将保存结果返回给前端

4.前端缓存用户信息,并显示

二、小程序端实现

代码实现:

 function getUserInfoByWx() {
        isLoad.value = true

        uni.login({
            provider: 'weixin', //使用微信登录
            success: function (loginRes) {
                const userData = {
                    code: loginRes.code
                }
                // console.log('userData',userData);
                getUserInfoByWxApi(userData).then(res => {
                    console.log(res)
                    if (res.success) {
                        userInfoStore.setUserInfo({
                            userName: res.data.userName,
                            openidWx: res.data.openidWx
                        })
                    } else {
                        openMessage({
                            text: '自动创建用户出错,请点击登录手动创建'
                        })
                    }
                }).catch(err => {
                    console.log('eeeeeeeeeeeeeee', err)
                    openMessage({
                        text: '登录失败,请联系开发者'
                    })
                })
                    .finally(() => {
                        // debugger
                        isLoad.value = false
uni.$emit('loginFinish');
                    })
            },
            fail() {
                isLoad.value = false
            }
        });
    }

代码解释:

1.isLoad:前端是否显示正在登录的动画。

2.uni.login:uniapp提供的登录api,可以生成各平台的临时code。

3.getUserInfoByWxApi:调用后端接口,将临时code作为参数传递给后端,后端再调用官方接口完成登录。

4.userInfoStore.setUserInfo登录成功后,在全局状态管理中保存用户信息

上面的代码,大部分都是和前端登录相关的业务代码,真正核心的是生成了临时code并传递给后端,因为调用官方接口只能在后端代码中运行。

三、后端实现

后端代码实现可分为两步,一是调用官方接口,获取小程序官方返回的用户信息;二是根据业务需求,将用户信息保存到我们的数据库中。

controller层代码实现;

  @Post('/getUserInfoByWx')
  async getUserInfoByWx(@Body() userData: { code: string }) {
    const openidRs = await this.loginService.getOpenidWx(userData)
    const openidKey = 'openidWx'
    const rs = await this.loginService.getUserInfoByPlat(openidRs, openidKey)
    return rs
  }

上面代码的code就是小程序端传入的临时code,主要用于getOpenidWx方法中,获取调用官方接口后的返回结果。

3.1 调用官方接口

上面代码中的getOpenidWx方法即是调用官方接口:

 const openidRs = await this.loginService.getOpenidWx(userData)

具体的service实现:

  /*根据临时code,获取wx返回的登录信息*/
  async getOpenidWx(userData:{code:string} | any): Promise<any> {
    const url = 'https://api.weixin.qq.com/sns/jscode2session';
    const data = {
      appid: 'wx9cxxxxxxxxxxxxx',
      secret: '66bxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
      js_code: userData.code,
      grant_type: 'authorization_code'
    }
    const result = await this.httpService.get(url, {params: data})
    return result
  }

官方的文档如下:

在这里插入图片描述 结合文档和我写的接口示例,我为大家总结了关键点:

1.接口通过GET方式访问

2.参数包括appid、secret、js_code、grant_type都是通过url参数的方式传递。

3.appid+secret:开发者的身份认证,通过开发管理平台获取:

在这里插入图片描述

4.js_code:小程序端传递来的临时code

5.grant_type:照官网写,别问为什么。

返回结果中我们需要重点关注的参数是openid,这是每一个用户的唯一标识。

3.2 保存用户信息到数据库

上面controller层一共调用了两个方法,一个是上面的调用官方接口,另一个就是保存用户信息到数据库并返回用户信息:

  const rs = await this.loginService.getUserInfoByPlat(openidRs, openidKey)

上面是兼容各平台的写法,我们可以忽略openidKey参数,只以微信小程序为例,具体的service实现为:

/**
   * 根据各平台的openid获取用户信息,可用于首次登录时,自动注册*/
  async getUserInfoByPlat(openidRs,openidKey:string){
    // debugger
    // 通过三方平台的
    let rs:any={}
    let findWxUserRs = new WxUser()
    if (openidRs.status == 200 && (openidRs.data.openid || openidRs.data.data.openid)) {
      const userInfo={}
      userInfo[openidKey]=openidRs.data.openid || openidRs.data.data.openid
      findWxUserRs = await this.wxUserService.getWxUserByUserInfo(userInfo) || new WxUser()
    }

    if (findWxUserRs && findWxUserRs.id) {
      //用户已注册,获取用户信息
      if(!findWxUserRs.userExtraEntity){
        // 兼容旧数据,若没有extra信息,则创建
        rs = await this.wxUserService.saveWxUser(findWxUserRs)
      }else{
        rs=findWxUserRs
      }
    } else {
      //用户未注册,则保存并登录
      // /*TODO:tt和wxopenid的层级不同,需要改造*/
      findWxUserRs[openidKey] = openidRs.data.openid || openidRs.data.data.openid
      rs = await this.wxUserService.saveWxUser(findWxUserRs)
    }
    return rs
  }

代码解释:

1.openidRs是调用官方接口的方法返回的用户信息,如果它的status为200,并且openid有值,则说明调用官方接口成功。判断里之所以有两种层级,可能是因为某个平台的返回结果比较奇葩,代码过于久远,我也记不清了。

2.findWxUserRs是以openid作为筛选条件,筛选数据库的用户表,第一版代码不用像我写这么麻烦,我的openid可能分为openidWX,openidTT等等。第一次做这个功能,就在用户表里增加字段openid即可,然后根据这个字段筛选用户表。

3.如果以openid为筛选条件查到了用户信息,说明用户已注册,返回查询到的数据库中的用户信息。不用关心我的userExtraEntity对象判断,我的用户表结构发生过变化,为了兼容旧数据,这里做了判断。

4.如果以openid为筛选条件未查询到信息,则说明用户未注册,应该主动注册用户。注册用户的逻辑就因人而异了,我生成了随机的用户名称和用户id,再加上openid字段,保存了基本的用户信息。

5.保存成功后,返回用户信息。

我们要理清楚一个概念:用户信息。

通过官方接口获取的用户信息,是小程序官方提供返回结果,现阶段对我们最重要的是openid。

通过我们自己业务代码获取的用户信息,是返回数据库中的用户表信息。现阶段最重要的是用户名(userName)+id(表id,在我们业务中的唯一标识)+openid(用户在某小程序中的唯一标识)。

为什么不能用openid代替id成为我们业务表中的唯一标识,因为以后有可能还会集成其他小程序平台,openid在某小程序的各个场景中是唯一标识,但对于我们系统而言,它只是一个业务字段。

三、后端返回结果,前端显示

前端调用接口最终返回的结果,是保存在数据表中的用户信息:用户名+id+openid。

在上面前端代码中,成功调用接口后,主要做了两个操作:

      userInfoStore.setUserInfo({
                            userName: res.data.userName,
                            openidWx: res.data.openidWx
                        })

...省略代码
uni.$emit('loginFinish');

代码解释:

1.userInfoStore.setUserInfo:是维护全局状态管理中的用户信息,并且使用pinia做成响应式,只要改变,小程序端的页面就会显示对应用户名。

在这里插入图片描述

2.uni.$emit('loginFinish'):是一个事件通知机制。当登录模块所有操作完成,再触发loginFinish。其他组件中需要等待登录操作完成后才能执行的代码,需要严格控制执行顺序的代码,就在合适的位置插入uni.$on()监听。


总结

博主的大部分demo示例都会放到:中二少年学编程的示例项目。戳链接,查看示例效果。如果链接失效,请手动输入地址:lizetoolbox.top:8080/#/

本文知识点总结:

1.uni.login获取登录随机code,传递给后端接口。

2.后端代码中,调用官方接口获取平台返回的openid

3.将openid更新到数据库的用户信息表,如果没有该用户,则创建。

4.后端接口返回用户信息,在前端显示并执行后续操作

有任何前端项目、demo、教程需求,都可以联系博主,博主会视精力更新,免费的羊毛,不薅白不薅!~

vue3+node后台管理系统实战——1.利用小程序实现web登录

有任何问题,都可以私信博主,共同探讨学习。

项目示例地址:中二少年学编程的示例项目

本项目适合熟悉vue3、nodejs基础,并希望了解实战应用的同学;适合想要学习web全栈开发的同学;适合大学生作业、毕业设计参考,有任何问题,请随时联系博主。


一、技术选型

前端:vue3+viewui 后端:nodejs+midwayjs+typeorm 数据库:mysql

二、设计方案

大部分网站的登录模块包含两个功能:注册和登录。

注册主要目的是在系统中申请一个账户和密码,作为后续登录的凭证。但是出于防止恶意注册、便于找回密码、防脚本批量注册等多方面考虑,大部分注册行为还会考虑邮箱验证、手机号验证等功能。

如果同学们开发的网站是面向普通用户(to C),并且有巨大市场潜力的项目,那么可以按照常规注册模式开发项目。

但是很多后台管理系统其实面向的是特定的企业用户(to B),并不需要开发注册功能,只需要超级管理员创建并管理用户即可。

我开发的demo网站,属于后台管理系统,是解决面向企业用户的场景,但是我做出这个demo又是为了让所有同学都可以体验参考。所以我为demo网站设计了两种登录模式:一是便捷的注册登录功能;二是通过超管创建用户后,常规的登录功能。

注册功能想要便捷,就不能沿用手机号验证那套规则,那样可能会吓跑大部分嫌麻烦的同学,利用微信登录又需要每年支付300元认证费用,所以我就设计实现了利用微信扫码跳转小程序,通过小程序验证后,实现登录的功能。

这套方案非常适合想要低成本、便捷地通过微信实现扫码登录的中小企业、学生、个人开发者等群体。

至于登录的逻辑就很简单了,不管利用哪种方式实现,本质都是将前端采集的用户和密码发送到后端,后端与数据库中保存的用户信息匹配,如果匹配成功,则登录成功,如果匹配失败,则返回登录失败。

三、小程序扫码登录

小程序的实现方式略微复杂,而且这是博主自己思考的方案,网上应该是没有太多参考资料。如果仅仅是学习基础的登录技术原理,来应付企业管理系统开发,并不需要学习本章节。

3.1 后端调用小程序官方api生成小程序码

后端调用小程序的官方api生成带参数的小程序码,参数的key值我们设定为cId,后面要用。生成小程序码,可以指定扫描后进入的页面,我们设置为扫码进入“我的”页面。

在这里插入图片描述

代码实现:

 /**
   * 根据token获取微信小程序码
   * @Param access_token - 微信token
   * @Param env_version - 环境版本:develop,release
   * */
  async getWXACodeUnlimited(access_token: string, env_version: string) {
    const url = 'https://api.weixin.qq.com/wxa/getwxacodeunlimit';
    const cId = nanoid()
    const data = {
      scene: 'cId=' + cId,
      page: 'pages/about/about',
      env_version
    }
    const params = {
      access_token
    }
    const result = await this.httpService.request({
      url,
      method: 'POST',
      responseType: 'arraybuffer', // 指定响应类型为二进制数据
      data,
      params
    })
    return {
      imgData: result.data,
      cId
    }
  }

代码讲解:

1.api.weixin.qq.com/wxa/getwxac…

2.const cId = nanoid():使用nanoid插件生成随机的参数

3.await this.httpService.request:使用内置的服务调用接口,同学们不论使用什么工具都是可以的。

3.2 前端请求接口,获取小程序码

3.1章节返回的是小程序码的图片数据和cId参数,前端请求对应的后端接口,获取小程序码图片并显示。

在这里插入图片描述转存失败,建议直接上传图片文件

代码实现:

  /**
     * 获取小程序码
     * 返回值-小程序码参数*/
    const miniAppSceneCodeUrl = ref('') //带参数的二维码
    async function getWXACodeUnlimited() {
        miniAppSceneCodeUrl.value = ''
        let imgRs = {}
        try {
            imgRs = await getWXACodeUnlimitedApi({
                env_version: 'release',
            });
        } catch (e) {
            imgRs = {
                success: false
            }
        }
        // 将 Buffer 数据转换为图片 URL
        // 1. 将数组转换为 Uint8Array
        const uint8Array = new Uint8Array(imgRs.data.imgData.data);

        // 2. 将 Uint8Array 转换为 Blob
        const blob = new Blob([uint8Array], {type: 'image/png'}); // 根据实际图片类型设置 MIME 类型

        // 3. 生成图片 URL
        miniAppSceneCodeUrl.value = URL.createObjectURL(blob);
        return imgRs.data.cId
    }

对应的html代码:

           <div v-if="miniAppSceneCodeUrl"
                 style="width: 100%;display: flex;justify-content: center;position: relative">
              <img :src="miniAppSceneCodeUrl" style="width: 120px;height: 120px"/>
            </div>

代码解释:

1.getWXACodeUnlimitedApi:调用后端接口,获取图片信息和cId参数值。

2.后面操作buffer的三行代码可以省略,我的后端返回的图片数据是buffer二进制,如果同学们直接返回base64数据,会更简单,直接在前端src中赋值即可显示。之所以返回buffer,是因为我的服务器带宽很差,只能尽量压缩前后端交互数据的大小。

3.miniAppSceneCodeUrl:小程序码的url,获取后直接在前端html中显示。

3.3 小程序监听扫码登录行为

用户扫描小程序码时,会进入小程序并直接跳转“我的”页面,小程序的“我的”页面监听当页面渲染后,是否携带了参数cId。

如果小程序监听用户跳转行为携带了cId,说明用户在触发扫码登录行为,则保存cId到用户信息,如果没有携带cId,属于普通跳转行为,不做任何操作。

代码示例:

onLoad(async (option) => {
if (option.scene) {
const scene = decodeURIComponent(option.scene)
const params = parseScene(scene); // 解析为键值对
saveCId(params.cId)
}

})

代码解释:

1.onLoad:小程序端是用uniapp开发的,onLoad是页面加载的生命周期。

2.option.scene :监听页面加载后,页面携带的参数。

3.saveCId:保存cId到数据库中的用户信息表中。这部分代码涉及小程序部分功能,略微复杂,涉及很多复杂的判断。比如如果用户第一次登录小程序,还需要先等待小程序创建新用户后,再保存cId到数据库中的用户信息。这些属于小程序的功能开发了,不在本次登录功能的介绍中,所以不再赘述。同学们如果想要实现类似效果,只要能做到在这一步将cId保存到数据库中用户信息即可。

3.4 以cId为筛选条件轮询用户信息

前端在3.2章节中获取小程序码时,getWXACodeUnlimited方法同时返回了cId,详见3.2章节中的代码。

获取到cId后,前端即可以cId为筛选条件,对用户信息表执行轮询操作。因为每一个cId都是随机生成的,当发现用户信息表中出现符合的数据时,说明用户已经扫码登录成功,前端页面就可以放行,显示登录成功了。

代码实现:

    const showRefreshCode = ref(false)
 /**
     *根据小程序码参数轮询用户信息 */
    function getUserInfoBySceneCode(sceneCode) {
        if (!sceneCode) return
        let getApiCount = 0 //当前轮询次数
        let maxApiCount = 3  //最大的轮询次数
        let getApiSuccess = false //轮询是否成功

        getUserInfoInterval.value = setInterval(async () => {
            if (getApiCount >= maxApiCount) {
                // 超过10次,停止轮询
                clearInterval(getUserInfoInterval.value);
                showRefreshCode.value = true
                if (getApiCount === maxApiCount && !getApiSuccess) {
                    // 十次轮询都未成功,显示错误信息
                    console.error('十次轮询均失败:', getApiSuccess);
                    Message.error('扫码登录失败,请重试');
                    showRefreshCode.value = true;
                }
            }
            getApiCount++
            try {
                const userInfoRs = await getUserInfoBySceneCodeApi({
                    sceneCode: sceneCode
                })
                if (userInfoRs.success) {
                    getApiSuccess = true
                    Message.success('扫码登录成功')
                    // 登录成功后,维护全局变量
                    await handleLogIn(userInfoRs)
                    router.push({name: 'home'})
                    // 成功后停止轮询
                    clearInterval(getUserInfoInterval.value);
                }
            } catch (error) {
                console.error('根据小程序码参数获取用户信息失败:', error);
            }
        }, 3000); // 每3秒轮询一次
    }

上面代码就是简单的轮询代码,并没有什么技术点需要讲解。

3.5增加小程序码过期机制

前端无限制地轮询请求,可能会影响性能,所以应该设置一个机制,轮询一定次数后,则认定本次登录行为过期。停止轮询,并隐藏过期小程序码,用户点击刷新后,重新开启新的轮询。

在这里插入图片描述

3.4章节中的showRefreshCode变量就是显示刷新图标的开关。当轮询次数超出限制,则显示刷新图标,并且阻止用户扫码。因为已经不再轮询小程序码中携带的cId参数,再扫码已经没有意义。

当用户点击刷新图标,则重新获取小程序码,并重新开启轮询。

代码实现:

在前面显示小程序码的html代码中,增加刷新图标的判断:

            <div v-if="miniAppSceneCodeUrl"
                 style="width: 100%;display: flex;justify-content: center;position: relative">
              <img :src="miniAppSceneCodeUrl" style="width: 120px;height: 120px"/>
              <div v-if="showRefreshCode" class="refresh-icon">

                <Icon style="cursor: pointer;opacity: 0.8" @click="loginBySceneCode" type="md-refresh" size="80"/>
              </div>
            </div>

代码解释:

1.showRefreshCode:是否显示刷新图标

2.loginBySceneCode:点击刷新图标的方法,包含请求小程序码、刷新图标隐藏、开启轮询用户信息等操作。

四、常规的用户+密码登录

除了小程序扫码登录,示例项目还提供了普通的用户+密码的登录方式。用户由超级管理员创建,创建后的用户使用默认密码登录。现在的示例项目角色管理功能尚未完善,所以所有用户均可创建用户。

4.1 账户登录前端实现

账户登录页面的前端主要由一个Form表单构成,表单包含用户名和密码的输入,登录按钮的实现。并且为账户和密码的输入框增加不能为空的规则。

效果如下:

在这里插入图片描述

代码实现:

<Form ref="loginForm" :model="form" :rules="rules" @keydown.enter.native="handleSubmit">
    <FormItem prop="userCode">
      <Input v-model="form.userCode" placeholder="请输入用户名">
        <span slot="prepend">
          <Icon :size="16" type="ios-person"></Icon>
        </span>
      </Input>
    </FormItem>
    <FormItem prop="password">
      <Input type="password" v-model="form.password" password  placeholder="请输入密码">
        <span slot="prepend">
          <Icon :size="14" type="md-lock"></Icon>
        </span>
      </Input>
    </FormItem>
    <FormItem>
      <Button @click="handleSubmit" class="lz-btn-primary" long>登录</Button>
    </FormItem>
  </Form>

大部分ui框架都提供了表单校验规则的功能,viewui的表单校验通过rules定义。示例项目仅仅设置了必填验证,实际项目中还应该增加长度验证。

登录按钮功能代码实现:

async function handleSubmit() {
  const validRs=await loginForm.value.validate()
  if (validRs) {
    const data = {
      userCode: form.value.userCode,
      password: form.value.password
    };
    const res = await login(data)
    if (res.success) {
      // 登录成功后,维护全局变量
      await handleLogIn(res)
      // 路由跳转
      router.push({
        name: '_home'
      })
    }

  }
}

代码解释:

1.validRs:判断表单校验是否通过,通过校验后,才能执行登录逻辑

2.await login(data):调用后端接口,判断用户名和密码是否合法,后端通过检查数据库信息,返回判断结果。

3.await handleLogIn(res):如果成功后,维护全局变量。这个方法是pinia中定义的,主要将一些后续会经常使用的关键信息维护在全局状态管理。下文详细讲解。

4.router.push:上面所有方法都实现后,则跳转到网站首页。

handleLogin方法代码实现:

       /**
         * 登录时维护全局变量
         * @param loginRs 登录成功后的返回值
         * @param loginRs.accessToken token
         * @param loginRs.refreshTokenId refreshTokenId
         * @param loginRs.data 用户信息*/
        async handleLogIn(loginRs) {
            // debugger
            const {accessToken, refreshTokenId, data} = loginRs
            console.log('userInfo', data)
            setToken(accessToken, refreshTokenId)
            this.token = accessToken
            // 维护全局用户信息
            this.userInfo = data
            setUserInfoLocal(data)
            //     维护路由信息
            await this.setRouters()
        },

如果同学的项目也采用了token鉴权,那么就在这里维护token信息,我的demo项目采用了双token,但是这部分内容对于前端初学者来说过于复杂,学习曲线过陡不利于长期学习,所以不打算在此赘述。

如果只是简单的项目,不涉及token,则采用下面的代码:

        async handleLogIn(loginRs) {
            // debugger
            const { data} = loginRs
            console.log('userInfo', data)
            // 维护全局用户信息
            this.userInfo = data
            localStorage.setItem('userInfo', JSON.stringify(userInfoData))
            //     维护路由信息
            await this.setRouters()
        },

代码解释:

1.userInfo :保存到全局状态管理的用户信息。userInfo必须要同步保存到缓存中,因为全局状态管理中的userInfo在刷新页面后会失效,很多场景仍然需要缓存中的userInfo。

2.setRouters:从远端获取路由,并维护路由信息到全局状态管理时,才需要此方法。路由信息只需要保存到全局状态管理,并不需要localstorage缓存,因为如果路由是从远端获取的,则说明在做权限和路由的管理,路由与权限有关,属于变化相对频繁的数据,就不能从缓存中简单获取。而应该当监控到失去路由信息时,均重新获取路由。如果项目路由全部由前端维护,不需要做权限管理,则不需要此方法。

4.2 账户登录后端实现

如果不考虑token鉴权,那么最简单的用户密码验证,就是简单的增删改查。后端接口根据前端发送的用户名,查询数据库用户信息表中是否存在该用户,如果存在,再比对密码,如果用户和密码都合法,则用户成功登录。

代码实现:

async login(entity: { userCode: string, password: string }): Promise<any> {
    let userData = await this.baseUserModel.findOne({
      where: {userCode: entity.userCode}
    });
    if(!userData){
      return {
        success: false,
        msg: '用户不存在',
      }
    }
    let hasAuth = false
    if(userData && userData.userCode.trim()==='test'){
      // 判断游客用户-test登录
      hasAuth =  entity.password===this.commonService.getPassCode()
    }else{
      hasAuth = bcrypt.compareSync(entity.password, userData?.password)
    }

    if (!hasAuth) {
      return {
        success: false,
        msg: '用户名或密码错误',
        data:userData
      }
    }
    return {
      success: true,
      data: userData,
    }
  }

代码解释:

1.this.baseUserModel.findOne:这是typeorm的语法,根据userCode查找用户信息。如果不存在,则返回前端结果,如果存在,则继续。

2.我的项目里存在test特殊账户,同学们查看项目示例中二少年学编程的示例项目就能看到,test账户的密码就是个我自己加盐加密的随机数,没有什么知识点。在同学们的实战项目中,这部分可以省略。

3.bcrypt.compareSync:判断传入的密码和用户信息中保存的密码是否相同。bcrypt是一个加解密的插件,如果我们保存到用户信息表中的密码为123456,那么一旦被人攻破服务器,造成的损失就会非常大,所以在创建用户的时候,一般都会使用加密工具进行加密后,再保存。加密后的密码是一串看不出意义的字符串,只要再用bcrypt比对这个字符串和123456,就能确定它俩是否一致。


总结

项目示例地址:中二少年学编程的示例项目。戳链接,查看示例效果。如果链接失效,请手动输入地址:lizetoolbox.top:8080/#/

本文知识点总结:

1.注册和登录的原理

2.小程序扫码登录实现

3.普通账户密码登录实现

有任何前端项目、demo、教程需求,都可以联系博主,博主会视精力更新,免费的羊毛,不薅白不薅!~

❌