路飞学城三: 基于jwt分布式认证流程 &Redis缓存&celery异步任务

三: 基于jwt分布式认证流程 &Redis缓存&celery异步任务

用户的登陆认证

接下来,因为是新开发一个功能模块,那么我们可以在新的分支下进行开发,将来方便对这部分代码进行单独管理,等开发完成了以后再合并分支到develop也是可以的。

cd ~/Desktop/luffycity
git checkout -b feature/user	# 创建并切换分支

前端显示登陆页面

登录页组件

components/Login.vue

<template>
  <div class="title">
    <span :class="{active:state.login_type==0}" @click="state.login_type=0">密码登录</span>
    <span :class="{active:state.login_type==1}" @click="state.login_type=1">短信登录</span>
  </div>
  <div class="inp" v-if="state.login_type==0">
    <input v-model="state.username" type="text" placeholder="用户名 / 手机号码" class="user">
    <input v-model="state.password" type="password" class="pwd" placeholder="密码">
    <div id="geetest1"></div>
    <div class="rember">
      <label>
        <input type="checkbox" class="no" name="a"/>
        <span>记住密码</span>
      </label>
      <p>忘记密码</p>
    </div>
    <button class="login_btn">登录</button>
    <p class="go_login" >没有账号 <span>立即注册</span></p>
  </div>
  <div class="inp" v-show="state.login_type==1">
    <input v-model="state.username" type="text" placeholder="手机号码" class="user">
    <input v-model="state.password"  type="text" class="code" placeholder="短信验证码">
    <el-button id="get_code" type="primary">获取验证码</el-button>
    <button class="login_btn">登录</button>
    <p class="go_login" >没有账号 <span>立即注册</span></p>
  </div>
</template>

<script setup>
import {reactive} from "vue";

const state = reactive({
  login_type: 0,
  username:"",
  password:"",
})
</script>

<style scoped>
.title{
    font-size: 20px;
    color: #9b9b9b;
    letter-spacing: .32px;
    border-bottom: 1px solid #e6e6e6;
    display: flex;
    justify-content: space-around;
    padding: 0px 60px 0 60px;
    margin-bottom: 20px;
    cursor: pointer;
}
.title span.active{
	color: #4a4a4a;
    border-bottom: 2px solid #84cc39;
}

.inp{
	width: 350px;
	margin: 0 auto;
}
.inp .code{
    width: 220px;
    margin-right: 16px;
}
#get_code{
   margin-top: 6px;
}
.inp input{
    outline: 0;
    width: 100%;
    height: 45px;
    border-radius: 4px;
    border: 1px solid #d9d9d9;
    text-indent: 20px;
    font-size: 14px;
    background: #fff !important;
}
.inp input.user{
    margin-bottom: 16px;
}
.inp .rember{
    display: flex;
    justify-content: space-between;
    align-items: center;
    position: relative;
    margin-top: 10px;
}
.inp .rember p:first-of-type{
    font-size: 12px;
    color: #4a4a4a;
    letter-spacing: .19px;
    margin-left: 22px;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-align: center;
    align-items: center;
    /*position: relative;*/
}
.inp .rember p:nth-of-type(2){
    font-size: 14px;
    color: #9b9b9b;
    letter-spacing: .19px;
    cursor: pointer;
}

.inp .rember input{
    outline: 0;
    width: 30px;
    height: 45px;
    border-radius: 4px;
    border: 1px solid #d9d9d9;
    text-indent: 20px;
    font-size: 14px;
    background: #fff !important;
    vertical-align: middle;
    margin-right: 4px;
}

.inp .rember p span{
    display: inline-block;
    font-size: 12px;
    width: 100px;
}
.login_btn{
    cursor: pointer;
    width: 100%;
    height: 45px;
    background: #84cc39;
    border-radius: 5px;
    font-size: 16px;
    color: #fff;
    letter-spacing: .26px;
    margin-top: 30px;
    border: none;
    outline: none;
}
.inp .go_login{
    text-align: center;
    font-size: 14px;
    color: #9b9b9b;
    letter-spacing: .26px;
    padding-top: 20px;
}
.inp .go_login span{
    color: #84cc39;
    cursor: pointer;
}
</style>

components/Header.vue,代码:

<template>
    <div class="header-box">
      <div class="header">
        <div class="content">
          <div class="logo">
            <router-link to="/"><img src="../assets/logo.png" alt=""></router-link>
          </div>
          <ul class="nav">
              <li v-for="item in nav.header_nav_list">
                <a :href="item.link" v-if="item.is_http">{{item.name}}</a>
                <router-link :to="item.link" v-else>{{item.name}}</router-link>
              </li>
          </ul>
          <div class="search-warp">
            <div class="search-area">
              <input class="search-input" placeholder="请输入关键字..." type="text" autocomplete="off">
              <div class="hotTags">
                <router-link to="/search/?words=Vue" target="_blank" class="">Vue</router-link>
                <router-link to="/search/?words=Python" target="_blank" class="last">Python</router-link>
              </div>
            </div>
            <div class="showhide-search" data-show="no"><img class="imv2-search2" src="../assets/search.svg" /></div>
          </div>
          <div class="login-bar">
            <div class="shop-cart full-left">
              <img src="../assets/cart.svg" alt="" />
              <span><router-link to="/cart">购物车</router-link></span>
            </div>
            <div class="login-box full-left">
              <span @click="state.show_login=true">登录</span>
              &nbsp;/&nbsp;
              <span>注册</span>
            </div>
          </div>
        </div>
      </div>
    </div>
    <el-dialog :width="600" v-model="state.show_login">
      <Login></Login>
    </el-dialog>
</template>
<script setup>
import Login from "./Login.vue"
import {reactive} from "vue";
import nav from "../api/nav";

const state = reactive({
  show_login: false,
})

// 获取头部导航
nav.get_header_nav().then(response=>{
  nav.header_nav_list = response.data;
}).catch(error=>{
  console.log(error);
});

</script>

views/Login.vue,代码:

<template>
	<div class="login box">
		<img src="http://180.76.102.130//static/img/bg.jpg" alt="">
		<div class="login">
			<div class="login-title">
				<img src="../assets/logo.svg" alt="">
				<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
			</div>
      <div class="login_box">
          <Login></Login>
      </div>
		</div>
	</div>
</template>

<script setup>
import Login from "../components/Login.vue"

</script>

<style scoped>
.box{
	width: 100%;
  height: 100%;
	position: relative;
  overflow: hidden;
}
.box img{
	width: 100%;
  min-height: 100%;
}
.box .login {
	position: absolute;
	width: 500px;
	height: 400px;
	left: 0;
  margin: auto;
  right: 0;
  bottom: 0;
  top: -438px;
}

.login-title{
     width: 100%;
    text-align: center;
}
.login-title img{
    width: 190px;
    height: auto;
}
.login-title p{
    font-size: 18px;
    color: #fff;
    letter-spacing: .29px;
    padding-top: 10px;
    padding-bottom: 50px;
}
.login_box{
    width: 400px;
    height: auto;
    background: #fff;
    box-shadow: 0 2px 4px 0 rgba(0,0,0,.5);
    border-radius: 4px;
    margin: 0 auto;
    padding-bottom: 40px;
    padding-top: 50px;
}
</style>

绑定登陆页面路由地址

src/router/index.js,代码:

import {createRouter, createWebHistory} from 'vue-router'

// 路由列表
const routes = [
  {
    meta:{
        title: "浮光在线教育-首页",
        keepAlive: true
    },
    path: '/',         // uri访问地址
    name: "Home",
    component: ()=> import("../views/Home.vue")
  },
  {
    meta:{
        title: "浮光在线教育-用户登录",
        keepAlive: true
    },
    path:'/login',      // uri访问地址
    name: "Login",
    component: ()=> import("../views/Login.vue")
  }
]

// 路由对象实例化
const router = createRouter({
  // history, 指定路由的模式
  history: createWebHistory(),
  // 路由列表
  routes,
});


// 暴露路由对象
export default router
git add .
git commit -m "feature:自定义用户模型"
# 执行 git push 以后会提示如下,跟着执行提示的命令即可。
git push --set-upstream origin feature/user  
# 这句命令表示提交的时候,同步创建线上分支

后端实现登陆认证

image-20220904085916708

Django默认已经提供了认证系统Auth模块。认证系统包含:

  • 用户管理
  • 权限管理[RBAC]
  • 用户组管理(就是权限里面的角色)
  • 密码哈希系统(就是密码加密和验证密码)
  • 用户登录或内容显示的表单和视图
  • 一个可插拔的后台系统(admin站点)

Django默认用户的认证机制依赖Session机制,但是session认证机制在前后端分离项目中具有一定的局限性。

  1. session默认会把session_id 作为cookie保存到客户端。有些客户端的是默认禁用cookie/或者没法使用cookie的。
  2. session的数据默认是保存到服务端的,带来一定的存储要求。

所以,基于session的这种现状,我们一般在前后端分离的项目中,一般引入JWT认证机制来实现用户的登录和鉴权(鉴别身份,识别权限),jwt可以将用户的身份凭据存放在一个Token(认证令牌,本质上就是一个经过处理的字符串)中,然后把token发送给客户端,客户端可以选择采用自己的技术来保存这个token。

在django中如果要实现jwt认证,有一个常用的第三方jwt模块,我们可以很方便的去通过jwt对接Django的认证系统,帮助我们来快速实现:

  • 用户的数据模型
  • 用户密码的加密与验证
  • 用户的权限系统

Django用户模型类

from django.contrib.auth.models import User

Django的Auth认证系统中提供了用户模型类User保存用户的数据,默认的User包含以下常见的基本字段:

字段名 字段描述
username 必选。150个字符以内。 用户名可能包含字母数字,_@+ .-个字符。
first_name 可选(blank=True)。 少于等于30个字符。
last_name 可选(blank=True)。 少于等于30个字符。
email 可选(blank=True)。 邮箱地址。
password 必选。 密码的哈希加密串。 (Django 不保存原始密码)。 原始密码可以无限长而且可以包含任意字符。
groups Group 之间的多对多关系。对接权限功能的。
user_permissions Permission 之间的多对多关系。对接权限功能的。
is_staff 布尔值。 设置用户是否可以访问Admin 站点。
is_active 布尔值。 指示用户的账号是否激活。 它不是用来控制用户是否能够登录,而是描述一种帐号的使用状态。值为False的时候,是无法登录的。
is_superuser 是否是超级用户。超级用户具有所有权限。
last_login 用户最后一次登录的时间。
date_joined 账户创建的时间。 当账号创建时,默认设置为当前的date/time。
模型提供的常用方法:

模型常用方法可以通过user实例对象.方法名来进行调用。

  • set_password(raw_password)

    设置用户的密码为给定的原始字符串,并负责密码的。 不会保存User 对象。当Noneraw_password 时,密码将设置为一个不可用的密码。

  • check_password(raw_password)

    如果给定的raw_password是用户的真实密码,则返回True,可以在校验用户密码时使用。

管理器的常用方法:

管理器方法可以通过User.objects. 进行调用。

  • create_user(username, email=None, password=None, ***extra_fields*)

    创建、保存并返回一个User对象。

  • create_superuser(username, email, password, ***extra_fields*)

    create_user() 相同,但是设置is_staffis_superuserTrue

虽然上面的User模型看起来很多的属性和方法了,但是我们当前要实现的项目是一个在线教育商城,所以我们还需要记录用户的手机号,或者头像等等一系列信息。所以我们需要在原有模型的基础上对这个模型进行改造。

所以我们需要自定义一个新的users子应用并在django原有功能的基础上,完善用户的登录注册功能。

创建用户模块的子应用

cd luffycityapi/apps/
python ../../manage.py startapp users

在settings/dev.py文件中注册子应用。

INSTALLED_APPS = [
    ...
  	'users',
]

创建users/urls.py子路由并在总路由中进行注册。

users/urls.py,代码:

from django.urls import path
from . import views
urlpatterns = [

]

luffycityapi/urls.py,总路由,代码:

from django.contrib import admin
from django.urls import path,re_path,include

from django.conf import settings
from django.views.static import serve # 静态文件代理访问模块

urlpatterns = [
    path('admin/', admin.site.urls),
    re_path(r'uploads/(?P<path>.*)', serve, {"document_root": settings.MEDIA_ROOT}),
    path("", include("home.urls")),
    path("users/", include("users.urls")),
]

创建自定义的用户模型类

Django认证系统中提供的用户模型类及方法很方便,我们可以使用这个模型类,但是字段有些无法满足项目需求,如本项目中需要保存用户的手机号,需要给模型类添加额外的字段。

Django提供了django.contrib.auth.models.AbstractUser用户抽象模型类允许我们继承,扩展字段来使用Django认证系统的用户模型类。

我们可以在apps中创建Django应用users,并在配置文件中注册users应用。

在创建好的应用models.py中定义用户的用户模型类。

from django.db import models
from django.contrib.auth.models import AbstractUser

# Create your models here.


class User(AbstractUser):
    mobile = models.CharField(max_length=15, unique=True, verbose_name='手机号')
    money = models.DecimalField(max_digits=9, default=0.0, decimal_places=2, verbose_name="钱包余额")
    credit = models.IntegerField(default=0, verbose_name="积分")
    avatar = models.ImageField(upload_to="avatar/%Y", null=True, default="", verbose_name="个人头像")
    nickname = models.CharField(max_length=50, default="", null=True, verbose_name="用户昵称")

    class Meta:
        db_table = 'lf_users'
        verbose_name = '用户信息'
        verbose_name_plural = verbose_name

我们自定义的用户模型类还不能直接被Django的认证系统所识别,需要在配置文件中告知Django认证系统使用我们自定义的模型类。

在settings/dev.py配置文件中进行设置

AUTH_USER_MODEL = 'users.User'

AUTH_USER_MODEL 参数的设置以点.来分隔,表示应用名.模型类名

注意:Django建议我们对于AUTH_USER_MODEL参数的设置一定要在第一次数据库迁移之前就设置好,否则后续使用可能出现未知错误。

image-20220904085949434

这是表示有一个叫admin的子应用使用了原来的废弃的auth.User模型,但是目前数据库已经设置了默认的子应用为users的模型了,所以产生了冲突。那么这种冲突,我们需要重置下原来的auth模块的迁移操作,再次迁移就可以解决了。

解决步骤:
1. 备份数据库[如果刚开始开发,无需备份。]
   cd /home/moluo/Desktop/luffycity/docs
   mysqldump -uroot -p123 luffycity > 03_20_luffycity.sql

2. 注释掉users.User代码以及AUTH_USER_MODEL配置项,然后执行数据迁移回滚操作,把冲突的所有表迁移记录全部归零
   cd ~/Desktop/luffycity/luffycityapi
   # python manage.py migrate <子应用目录> zero
   python manage.py migrate auth zero

3. 恢复users.User代码以及AUTH_USER_MODEL配置项,执行数据迁移。
   python manage.py makemigrations
   python manage.py migrate
4. 创建管理员查看auth功能是否能正常使用。
   python manage.py createsuperuser

提交版本

cd ~/Desktop/luffycity/luffycityapi
git add .
git commit -m "feature:自定义用户模型"
git push origin feature/user

实现jwt认证分布式认证流程

在用户注册或登录后,我们想记录用户的登录状态,或者为用户创建身份认证的凭证。我们不再使用Session认证机制,而使用Json Web Token认证机制。

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者(客户端)和服务提供者(服务端)间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于身份认证,也可被数据加密传输。

JWT的构成

JWT就一段字符串,由三段信息构成的,将这三段信息文本用.拼接一起就构成了Jwt token字符串。就像这样:

eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAicm9vdCIsICJleHAiOiAiMTUwMTIzNDU1IiwgImlhdCI6ICIxNTAxMDM0NTUiLCAibmFtZSI6ICJ3YW5neGlhb21pbmciLCAiYWRtaW4iOiB0cnVlLCAiYWNjX3B3ZCI6ICJRaUxDSmhiR2NpT2lKSVV6STFOaUo5UWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjkifQ==.815ce0e4e15fff813c5c9b66cfc3791c35745349f68530bc862f7f63c9553f4b

第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签证(signature).

jwt的头部承载两部分信息:

  • typ: 声明token类型,这里是jwt ,typ的值也可以是:Bear
  • alg: 声明签证的加密的算法 通常直接使用 HMAC SHA256

完整的头部就像下面这样的JSON:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然后将头部进行base64编码,构成了jwt的第一部分头部

python代码举例:

import base64, json
header_data = {"typ": "jwt", "alg": "HS256"}
header = base64.b64encode( json.dumps(header_data).encode() ).decode()
print(header) # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9

payload

载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货仓,这些有效信息包含三个部分:

  • 标准声明
  • 公共声明
  • 私有声明

标准声明指定jwt实现规范中要求的属性。 (官方建议但不强制使用) :

  • iss: jwt签发者
  • sub: jwt所面向的用户
  • aud: 接收jwt的一方
  • exp: jwt的过期时间,这个过期时间必须要大于签发时间
  • nbf: 定义在什么时间之后,该jwt才可以使用
  • iat: jwt的签发时间
  • jti: jwt的唯一身份标识,主要用来作为一次性token, 从而回避重放攻击。

公共声明 : 公共的声明可以添加任何的公开信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可直接读取.

私有声明 : 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,里面存放的是一些可以在服务端或者客户端通过秘钥进行加密和解密的加密信息。往往采用的RSA非对称加密算法。

举例,定义一个payload载荷信息,demo/jwtdemo.py:

import base64, json, time

if __name__ == '__main__':
    # 载荷
    iat = int(time.time())
    payload_data = {
        "sub": "root",
        "exp": iat + 3600,  # 假设一小时过期
        "iat": iat,
        "name": "wangxiaoming",
        "avatar": "1.png",
        "user_id": 1,
        "admin": True,
        "acc_pwd": "QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9",
    }
    # 将其进行base64编码,得到JWT的第二部分。
    payload = base64.b64encode(json.dumps(payload_data).encode()).decode()
    print(payload)
    # eyJzdWIiOiAicm9vdCIsICJleHAiOiAxNjQ3Nzc0Mjk1LCAiaWF0IjogMTY0Nzc3MDY5NSwgIm5hbWUiOiAid2FuZ3hpYW9taW5nIiwgImF2YXRhciI6ICIxLnBuZyIsICJ1c2VyX2lkIjogMSwgImFkbWluIjogdHJ1ZSwgImFjY19wd2QiOiAiUWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5In0=

signature

JWT的第三部分是一个签证信息,用于辨真伪,防篡改。这个签证信息由三部分组成:

  • header (base64后的头部)
  • payload (base64后的载荷)
  • secret(保存在服务端的秘钥字符串,不会提供给客户端的,这样可以保证客户端没有签发token的能力)

举例,定义一个完整的jwt token,demo/jwtdemo.py:

import base64, json, hashlib

if __name__ == '__main__':
    """jwt 头部的生成"""
    header_data = {"typ": "jwt", "alg": "HS256"}
    header = base64.b64encode( json.dumps(header_data).encode() ).decode()
    print(header) # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9

    """jwt 载荷的生成"""
    payload_data = {
        "sub": "root",
        "exp": "150123455",
        "iat": "150103455",
        "name": "wangxiaoming",
        "admin": True,
        "acc_pwd": "QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9",
    }
    # 将其进行base64编码,得到JWT的第二部分。
    payload = base64.b64encode(json.dumps(payload_data).encode()).decode()
    print(payload) # eyJzdWIiOiAicm9vdCIsICJleHAiOiAiMTUwMTIzNDU1IiwgImlhdCI6ICIxNTAxMDM0NTUiLCAibmFtZSI6ICJ3YW5neGlhb21pbmciLCAiYWRtaW4iOiB0cnVlLCAiYWNjX3B3ZCI6ICJRaUxDSmhiR2NpT2lKSVV6STFOaUo5UWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjkifQ==

    # from django.conf import settings
    # secret = settings.SECRET_KEY
    secret = 'django-insecure-hbcv-y9ux0&8qhtkgmh1skvw#v7ru%t(z-#chw#9g5x1r3z=$p'
    data = header + payload + secret  # 秘钥绝对不能提供给客户端。
    HS256 = hashlib.sha256()
    HS256.update(data.encode('utf-8'))
    signature = HS256.hexdigest()
    print(signature) # 815ce0e4e15fff813c5c9b66cfc3791c35745349f68530bc862f7f63c9553f4b

    # jwt 最终的生成
    token = f"{header}.{payload}.{signature}"
    print(token)
    # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAicm9vdCIsICJleHAiOiAiMTUwMTIzNDU1IiwgImlhdCI6ICIxNTAxMDM0NTUiLCAibmFtZSI6ICJ3YW5neGlhb21pbmciLCAiYWRtaW4iOiB0cnVlLCAiYWNjX3B3ZCI6ICJRaUxDSmhiR2NpT2lKSVV6STFOaUo5UWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjkifQ==.815ce0e4e15fff813c5c9b66cfc3791c35745349f68530bc862f7f63c9553f4b

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

image-20220904090012558

举例,定义一个完整的jwt token,并认证token,demo/jwtdemo.py:

import base64, json, hashlib
from datetime import datetime

if __name__ == '__main__':
    # 头部生成原理
    header_data = {
        "typ": "jwt",
        "alg": "HS256"
    }
    # print( json.dumps(header_data).encode() )
    # json转成字符串,接着base64编码处理
    header = base64.b64encode(json.dumps(header_data).encode()).decode()
    print(header)  # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9


    # 载荷生成原理
    iat = int(datetime.now().timestamp()) # 签发时间
    payload_data = {
        "sub": "root",
        "exp": iat + 3600,  # 假设一小时过期
        "iat": iat,
        "name": "wangxiaoming",
        "admin": True,
        "acc_pwd": "QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9",
    }

    payload = base64.b64encode(json.dumps(payload_data).encode()).decode()
    print(payload)
    # eyJzdWIiOiAicm9vdCIsICJleHAiOiAxNjM2NTk3OTAzLCAiaWF0IjogMTYzNjU5NDMwMywgIm5hbWUiOiAid2FuZ3hpYW9taW5nIiwgImFkbWluIjogdHJ1ZSwgImFjY19wd2QiOiAiUWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5In0=

    # from django.conf import settings
    # secret = settings.SECRET_KEY
    secret = 'django-insecure-hbcv-y9ux0&8qhtkgmh1skvw#v7ru%t(z-#chw#9g5x1r3z=$p'

    data = header + payload + secret  # 秘钥绝对不能提供给客户端。

    HS256 = hashlib.sha256()
    HS256.update(data.encode('utf-8'))
    signature = HS256.hexdigest()
    print(signature) # ce46f9d350be6b72287beb4f5f9b1bc4c42fc1a1f8c8db006e9e99fd46961156

    # jwt 最终的生成
    token = f"{header}.{payload}.{signature}"
    print(token)
    # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAicm9vdCIsICJleHAiOiAxNjM2NTk3OTAzLCAiaWF0IjogMTYzNjU5NDMwMywgIm5hbWUiOiAid2FuZ3hpYW9taW5nIiwgImFkbWluIjogdHJ1ZSwgImFjY19wd2QiOiAiUWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5In0=.ce46f9d350be6b72287beb4f5f9b1bc4c42fc1a1f8c8db006e9e99fd46961156


    # 认证环节
    token = "eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAicm9vdCIsICJleHAiOiAxNjM2NTk3OTAzLCAiaWF0IjogMTYzNjU5NDMwMywgIm5hbWUiOiAid2FuZ3hpYW9taW5nIiwgImFkbWluIjogdHJ1ZSwgImFjY19wd2QiOiAiUWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5In0=.ce46f9d350be6b72287beb4f5f9b1bc4c42fc1a1f8c8db006e9e99fd46961156"
    # token = "eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiJyb290IiwiZXhwIjoxNjMxNTI5MDg4LCJpYXQiOjE2MzE1MjU0ODgsIm5hbWUiOiJ3YW5neGlhb2hvbmciLCJhZG1pbiI6dHJ1ZSwiYWNjX3B3ZCI6IlFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5UWlMQ0poYkdjaU9pSklVekkxTmlKOSJ9.b533c5515444c51058557017e433d411379862d91640c8beed6f2617b1da2feb"
    header, payload, signature = token.split(".")

    # 验证是否过期了
    # 先基于base64,接着使用json解码
    payload_data = json.loads( base64.b64decode(payload.encode()) )
    print(payload_data)
    exp = payload_data.get("exp", None)
    if exp is not None and int(exp) < int(datetime.now().timestamp()):
        print("token过期!!!")
    else:
        print("没有过期")

    # 验证token是否有效,是否被篡改
    # from django.conf import settings
    # secret = settings.SECRET_KEY
    secret = 'django-insecure-hbcv-y9ux0&8qhtkgmh1skvw#v7ru%t(z-#chw#9g5x1r3z=$p'
    data = header + payload + secret  # 秘钥绝对不能提供给客户端。
    HS256 = hashlib.sha256()
    HS256.update(data.encode('utf-8'))
    new_signature = HS256.hexdigest()

    if new_signature != signature:
        print("认证失败")
    else:
        print("认证通过")

提交版本

cd ~/Desktop/luffycity/luffycityapi
git add .
git commit -m "test:jwt构成原理、jwt签发和验证流程"
git push origin feature/user

关于签发和核验JWT,python中提供了一个PyJWT模块帮我们实现jwt的整体流程。我们可以使用Django REST framework JWT扩展来完成。

文档网站:https://jpadilla.github.io/django-rest-framework-jwt/

安装配置JWT

安装

pip install djangorestframework-jwt

settings/dev.py,配置jwt

# drf配置
REST_FRAMEWORK = {
    # 自定义异常处理
    'EXCEPTION_HANDLER': 'luffycityapi.utils.exceptions.exception_handler',
    # 自定义认证
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_jwt.authentication.JSONWebTokenAuthentication',  # jwt认证
        'rest_framework.authentication.SessionAuthentication',           # session认证
        'rest_framework.authentication.BasicAuthentication',
    ),
}

import datetime
# jwt认证相关配置项
JWT_AUTH = {
    # 设置jwt的有效期
    # 如果内部站点,例如:运维开发系统,OA,往往配置的access_token有效期基本就是15分钟,30分钟,1~2个小时
    'JWT_EXPIRATION_DELTA': datetime.timedelta(weeks=1), # 一周有效,
}
  • JWT_EXPIRATION_DELTA 指明token的有效期

生成jwt

Django REST framework JWT 扩展的说明文档中提供了手动签发JWT的方法

官方文档:https://jpadilla.github.io/django-rest-framework-jwt/#creating-a-new-token-manually

# 可以进入到django的终端下测试生成token的逻辑
python manage.py shell

# 引入jwt配置
from rest_framework_jwt.settings import api_settings
# 获取载荷生成函数
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
# 获取token生成函数
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
# 生成载荷需要的字典数据
# 此处,拿数据库中的用户信息进行测试
from users.models import User
user = User.objects.first()
payload = jwt_payload_handler(user)  # user用户模型对象
# 生成token
token = jwt_encode_handler(payload)

在用户注册或登录成功后,在序列化器中返回用户信息以后同时返回token即可。

mtb项目中可以这样构建jwt token

 # views/account.py
    # 3.生成jwt token返回
        # 构造header
        headers = {
            'typ': 'jwt',
            'alg': 'HS256'
        }
        # 构造payload
        payload = {
            'user_id': user_obj.id,  # 自定义用户ID
            'username': user_obj.username,  # 自定义用户名
            'exp': datetime.datetime.utcnow() + datetime.timedelta(days=10)  # 超时时间
        }
        token = jwt.encode(payload=payload, key=settings.SECRET_KEY, algorithm="HS256", headers=headers) 
# util/extension/auth.py
class JwtTokenAuthentication(BaseAuthentication):
    def authenticate(self, request):
        # 读取用户提交的jwt token
        # token = request.query_params.get("token")       # 这是从url中获取
        token = request.META.get('HTTP_AUTHORIZATION')  # 这是从请求头中获取    格式 Authorization: Jwt Token

        if not token:
            # 自定义状态码200,内容{'code':2000, 'error':'无token认证失败'},前端根据code=2000判断
            # raise MtbAuthenticationFailed({"code": return_code.AUTH_FAILED, "error": "无token认证失败"})

            # 状态码401,内容{'code':2000, 'error':'无token认证失败'}
            raise AuthenticationFailed({"code": return_code.AUTH_FAILED, "error": "无token认证失败"})

        # jwt token校验
        try:
            # 从token中获取payload【校验合法性,并获取payload】
            verified_payload = jwt.decode(token, settings.SECRET_KEY, ["HS256"])
            # print(verified_payload)       # {'user_id': 1, 'username': 'fumi', 'exp': 1661311234}
            return CurrentUser(**verified_payload), token

        except exceptions.ExpiredSignatureError:
            print('token已失效')
            # 自定义状态码200,内容{'code':2000, 'error':'无token认证失败'},前端根据code=2000判断
            raise MtbAuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token已失效'})
        except jwt.DecodeError:
            print('token认证失败')
            raise AuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token认证失败'})
        except jwt.InvalidTokenError:
            print('非法的token')
            raise AuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token非法'})
        except Exception as e:
            raise AuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token不合法导致认证失败'})

    def authenticate_header(self, request):
        return 'Bearer realm="API"'
  # settings.py
    # mtb认证配置
  "DEFAULT_AUTHENTICATION_CLASSES": ["util.extension.auth.JwtTokenAuthentication", ],

后端实现登陆认证接口

Django REST framework-JWT为了方便开发者使用jwt提供了登录获取token的视图,开发者可以直接使用它绑定一个url地址即可。

在users/urls.py中绑定登陆视图

from django.urls import path
from rest_framework_jwt.views import obtain_jwt_token
from . import views

urlpatterns = [
    path("login/", obtain_jwt_token, name="login"),
]

# obtain_jwt_token实际上就是 rest_framework_jwt.views.ObtainJSONWebToken.as_view()

# 登录视图,获取access_token
# obtain_jwt_token = ObtainJSONWebToken.as_view()
# 刷新token视图,依靠旧的access_token生成新的access_token
# refresh_jwt_token = RefreshJSONWebToken.as_view()
# 验证现有的access_token是否有效
# verify_jwt_token = VerifyJSONWebToken.as_view()

接下来,我们可以通过postman来测试下功能,可以发送form表单,也可以发送json,username和password是必填字段

image-20220904090043018

前端实现登陆功能

在登陆组件中找到登陆按钮,绑定点击事件,调用登录处理方法loginhandle。

components/Login.vue

<template>
  <div class="title">
    <span :class="{active:user.login_type==0}" @click="user.login_type=0">密码登录</span>
    <span :class="{active:user.login_type==1}" @click="user.login_type=1">短信登录</span>
  </div>
  <div class="inp" v-if="user.login_type==0">
    <input v-model="user.account" type="text" placeholder="用户名 / 手机号码" class="user">
    <input v-model="user.password" type="password" class="pwd" placeholder="密码">
    <div id="geetest1"></div>
    <div class="rember">
      <label>
        <input type="checkbox" class="no" v-model="user.remember"/>
        <span>记住密码</span>
      </label>
      <p>忘记密码</p>
    </div>
    <button class="login_btn" @click="loginhandler">登录</button>
    <p class="go_login" >没有账号 <span>立即注册</span></p>
  </div>
  <div class="inp" v-show="user.login_type==1">
    <input v-model="user.mobile" type="text" placeholder="手机号码" class="user">
    <input v-model="user.code"  type="text" class="code" placeholder="短信验证码">
    <el-button id="get_code" type="primary">获取验证码</el-button>
    <button class="login_btn">登录</button>
    <p class="go_login" >没有账号 <span>立即注册</span></p>
  </div>
</template>
<script setup>
import user from "../api/user";
import { ElMessage } from 'element-plus'

// 登录处理
const loginhandler = ()=>{
  // 验证数据
  if(user.account.length<1 || user.password.length<1){
    // 错误提示
    console.log("错了哦,用户名或密码不能为空!");
    ElMessage.error("错了哦,用户名或密码不能为空!");
    return ;
  }

  // 登录请求处理
  user.login().then(response=>{
    console.log(response.data);
    ElMessage.success("登录成功!");
  }).catch(error=>{
    ElMessage.error("登录失败!");
  })
}

</script>

在api中请求后端,api/user.js,代码:

import http from "../utils/http"
import {reactive, ref} from "vue"

const user = reactive({
    login_type: 0, // 登录方式,0,密码登录,1,短信登录
    account: "",  // 登录账号/手机号/邮箱
    password: "", // 登录密码
    remember: false, // 是否记住登录状态
    mobile: "",      // 登录手机号码
    code: "",        // 短信验证码
    login(){
        // 用户登录
        return http.post("/users/login/", {
            "username": this.account,
            "password": this.password,
        })
    }
})

export default user;

解决elementplus显示错误提示框没有样式的问题。src/main.js,代码:

import { createApp } from 'vue'
import App from './App.vue'

import 'element-plus/dist/index.css';

import router from "./router/index.js";

createApp(App).use(router).mount('#app')

提交版本

cd ~/Desktop/luffycity/luffycityapi
git add .
git commit -m "feature:客户端请求登陆功能基本实现"
git push origin feature/user

前端保存jwt

我们保存在浏览器的HTML5提供的本地存储对象中。

浏览器的本地存储提供了2个全局的js对象,给我们用于保存数据的,分别是sessionStorage 和 localStorage :

  • sessionStorage 会话存储,浏览器关闭即数据丢失。
  • localStorage 永久存储,长期有效,浏览器关闭了也不会丢失。

我们可以通过浏览器提供的Application调试选项中的界面查看到保存在本地存储的数据。

image-20220904090155000

注意:不同的域名或IP下的数据,互不干扰的,相互独立,也调用或访问不了其他域名下的数据。

sessionStorage和localStorage提供的操作一模一样,基本使用:

// 添加/修改数据
sessionStorage.setItem("变量名","变量值")
// 简写:sessionStorage.变量名 = 变量值

// 读取数据
sessionStorage.getItem("变量名")
// 简写:sessionStorage.变量名

// 删除一条数据
sessionStorage.removeItem("变量名")
// 清空所有数据
sessionStorage.clear()  // 慎用,会清空当前域名下所有的存储在本地的数据



// 添加/修改数据
localStorage.setItem("变量名","变量值")
// 简写:localStorage.变量名 = 变量值

// 读取数据
localStorage.getItem("变量名")
// 简写:localStorage.变量名

// 删除数据
localStorage.removeItem("变量名")
// 清空数据
localStorage.clear()  // 慎用,会清空当前域名下所有的存储在本地的数据

登陆子组件,components/Login.vue,代码:

<script setup>
import user from "../api/user";
import { ElMessage } from 'element-plus'

// 登录处理
const loginhandler = ()=>{
  if(user.account.length<1 || user.password.length<1){
    // 错误提示
    console.log("错了哦,用户名或密码不能为空!");
    ElMessage.error('错了哦,用户名或密码不能为空!');
    return;  // 在函数/方法中,可以阻止代码继续往下执行
  }

  // 发送请求
  user.login({
    username: user.account,
    password: user.password
  }).then(response=>{
    // 保存token,并根据用户的选择,是否记住密码
    localStorage.removeItem("token");
    sessionStorage.removeItem("token");
    console.log(response.data.token);
    if(user.remember){ // 判断是否记住登录状态
      // 记住登录
      localStorage.token = response.data.token
    }else{
      // 不记住登录,关闭浏览器以后就删除状态
      sessionStorage.token = response.data.token;
    }
    // 保存token,并根据用户的选择,是否记住密码
    // 成功提示
    ElMessage.success("登录成功!");
    console.log("登录成功!");
    // 关闭登录弹窗
  }).catch(error=>{
    console.log(error);
  })
}

</script>

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端使用本地存储保存token"
git push origin feature/user

首页登录成功以后关闭登录弹窗

在components/Login.vue中,基于emit发送自定义事件通知父组件关闭当前登录窗口。components/Login.vue,代码:

<script setup>
import user from "../api/user"
import { ElMessage } from 'element-plus'
const emit = defineEmits(["successhandle",])

const loginhandler = ()=>{
  // 登录处理
  if(user.username.length<1 || user.password.length<1){
    // 错误提示
    ElMessage.error('错了哦,用户名或密码不能为空!');
    return false // 在函数/方法中,可以阻止代码继续往下执行
  }

  // 发送请求
  user.user_login({
    username: user.username,
    password: user.password
  }).then(response=>{
    // 保存token,并根据用户的选择,是否记住密码
    localStorage.removeItem("token")
    sessionStorage.removeItem("token")
    if(user.remember){ // 判断是否记住登录状态
      // 记住登录
      localStorage.token = response.data.token
    }else{
      // 不记住登录,关闭浏览器以后就删除状态
      sessionStorage.token = response.data.token
    }
    // 保存token,并根据用户的选择,是否记住密码
    // 成功提示
    ElMessage.success("登录成功!")
    // 关闭登录弹窗,对外发送一个登录成功的信息
    user.account = ""
    user.password = ""
    user.mobile = ""
    user.code = ""
    user.remember = false
    emit("successhandle")

  }).catch(error=>{
    ElMessage.error("登录异常!")
  })
}

</script>

在首页中是通过Header子组件调用的component/Login.vue,所以我们需要在Header子组件中监听自定义事件login_success并关闭登陆弹窗即可。components/Header.vue,代码:

<el-dialog :width="600" v-model="state.show_login">
    <Login @successhandle="login_success"></Login>
</el-dialog>
<script setup>
import Login from "./Login.vue"

import nav from "../api/nav"
import {reactive} from "vue";

const state = reactive({
  show_login: false,
})

nav.get_header_nav().then(response=>{
  nav.header_nav_list = response.data;
})

// 用户登录成功以后的处理
const login_success = (token)=>{
  state.show_login = false
}

</script>

views/Login.vue登陆页面中,则监听Login子组件登陆成功的自定义事件以后直接路由跳转到首页即可。views/Login.vue,代码:

<template>
	<div class="login box">
		<img src="../assets/Loginbg.3377d0c.jpg" alt="">
		<div class="login">
			<div class="login-title">
				<img src="../assets/logo.png" alt="">
				<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
			</div>
      <div class="login_box">
          <Login @successhandle="login_success"></Login>
      </div>
		</div>
	</div>
</template>
<script setup>
import Login from "../components/Login.vue"
import router from "../router";

// 用户登录成功以后的处理
const login_success = ()=>{
  // 跳转到首页
  router.push("/");
}

</script>

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端登陆成功以后关闭窗口或登陆页面"
git push origin feature/user

自定义载荷

默认返回值的token只有username和user_id以及email,我们如果还需在客户端页面中显示当前登陆用户的其他信息(例如:头像),则可以把额外的用户信息添加到jwt的返回结果中。通过修改该视图的返回值可以完成我们的需求。

在utils/authenticate.py 中,创建jwt_payload_handler函数重写返回值。

from rest_framework_jwt.utils import jwt_payload_handler as payload_handler


def jwt_payload_handler(user):
    """
    自定义载荷信息
    :params user  用户模型实例对象
    """
    # 先让jwt模块生成自己的载荷信息
    payload = payload_handler(user)
    # 追加自己要返回的内容
    if hasattr(user, 'avatar'):
        payload['avatar'] = user.avatar.url if user.avatar else ""
    if hasattr(user, 'nickname'):
        payload['nickname'] = user.nickname

    if hasattr(user, 'money'):
        payload['money'] = float(user.money)
    if hasattr(user, 'credit'):
        payload['credit'] = user.credit

    return payload

修改settings/dev.py配置文件

import datetime
# jwt认证相关配置项
JWT_AUTH = {
    # 设置jwt的有效期
    # 如果内部站点,例如:运维开发系统,OA,往往配置的access_token有效期基本就是15分钟,30分钟,1~2个小时
    'JWT_EXPIRATION_DELTA': datetime.timedelta(weeks=1), # 一周有效,
    # 自定义载荷
    'JWT_PAYLOAD_HANDLER': 'luffycityapi.utils.authenticate.jwt_payload_handler',
}

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:服务端重写jwt的自定义载荷生成函数增加token载荷信息"
git push origin feature/user

多条件登录

JWT扩展的登录视图,在收到用户名与密码时,也是调用Django的认证系统中提供的authenticate()来检查用户名与密码是否正确。

我们可以通过修改Django认证系统的认证后端(主要是authenticate方法)来支持登录账号既可以是用户名也可以是手机号。

修改Django认证系统的认证后端需要继承django.contrib.auth.backends.ModelBackend,并重写authenticate方法。

authenticate(self, request, username=None, password=None, **kwargs)方法的参数说明:

  • request 本次认证的请求对象
  • username 本次认证提供的用户账号
  • password 本次认证提供的密码

我们想要让用户既可以以用户名登录,也可以以手机号登录,那么对于authenticate方法而言,username参数即表示用户名或者手机号。

重写authenticate方法的思路:

  1. 根据username参数查找用户User对象,username参数可能是用户名,也可能是手机号
  2. 若查找到User对象,调用User对象的check_password方法检查密码是否正确

在utils/authenticate.py中编写:

from rest_framework_jwt.utils import jwt_payload_handler as payload_handler
from django.contrib.auth.backends import ModelBackend, UserModel
from django.db.models import Q


def jwt_payload_handler(user):
    """
    自定义载荷信息
    :params user  用户模型实例对象
    """
    # 先让jwt模块生成自己的载荷信息
    payload = payload_handler(user)
    # 追加自己要返回的字段内容
    if hasattr(user, 'avatar'):
        payload['avatar'] = user.avatar.url if user.avatar else ""
    if hasattr(user, 'nickname'):
        payload['nickname'] = user.nickname
    if hasattr(user, 'money'):
        payload['money'] = float(user.money)
    if hasattr(user, 'credit'):
        payload['credit'] = user.credit

    return payload


def get_user_by_account(account):

    """
    根据帐号信息获取user模型实例对象
    :param account: 账号信息,可以是用户名,也可以是手机号,甚至其他的可用于识别用户身份的字段信息
    :return: User对象 或者 None
    """
    user = UserModel.objects.filter(Q(mobile=account) | Q(username=account) | Q(email=account)).first()
    return user


class CustomAuthBackend(ModelBackend):
    """
    自定义用户认证类[实现多条件登录]
    """
    def authenticate(self, request, username=None, password=None, **kwargs):
        """
        多条件认证方法
        :param request: 本次客户端的http请求对象
        :param username:  本次客户端提交的用户信息,可以是user,也可以mobile或其他唯一字段
        :param password: 本次客户端提交的用户密码
        :param kwargs: 额外参数
        :return:
        """
        if username is None:
            username = kwargs.get(UserModel.USERNAME_FIELD)

        if username is None or password is None:
            return
        # 根据用户名信息useranme获取账户信息
        user = get_user_by_account(username)
        if user and user.check_password(password) and self.user_can_authenticate(user):
            return user

在配置文件settings/dev.py中告知Django使用我们自定义的认证后端,注意不是给drf添加设置。

# django自定义认证用来支持多条件登录(手机号,用户名,邮箱。。。)
AUTHENTICATION_BACKENDS = ['luffycityapi.utils.authenticate.CustomAuthBackend', ]

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:服务端实现jwt多条件登陆认证"
git push origin feature/user

客户端实现用户登陆状态的判断

components/Header.vue,子组件代码:

<template>
    <div class="header-box">
      <div class="header">
        <div class="content">
          <div class="logo">
            <router-link to="/"><img src="../assets/logo.svg" alt=""></router-link>
          </div>
          <ul class="nav">
              <li v-for="nav in nav.header_nav_list">
                <a :href="nav.link" v-if="nav.is_http">{{nav.name}}</a>
                <router-link :to="nav.link" v-else>{{nav.name}}</router-link>
              </li>
          </ul>
          <div class="search-warp">
            <div class="search-area">
              <input class="search-input" placeholder="请输入关键字..." type="text" autocomplete="off">
              <div class="hotTags">
                <router-link to="/search/?words=Vue" target="_blank" class="">Vue</router-link>
                <router-link to="/search/?words=Python" target="_blank" class="last">Python</router-link>
              </div>
            </div>
            <div class="showhide-search" data-show="no"><img class="imv2-search2" src="../assets/search.svg" /></div>
          </div>
          <div class="login-bar logined-bar" v-show="state.is_login">
            <div class="shop-cart ">
              <img src="../assets/cart.svg" alt="" />
              <span><router-link to="/cart">购物车</router-link></span>
            </div>
            <div class="login-box ">
              <router-link to="">我的课堂</router-link>
              <el-dropdown>
                <span class="el-dropdown-link">
                  <el-avatar class="avatar" size="50" src="https://fuguangapi.oss-cn-beijing.aliyuncs.com/avatar.jpg"></el-avatar>
                </span>
                <template #dropdown>
                  <el-dropdown-menu>
                    <el-dropdown-item :icon="UserFilled">学习中心</el-dropdown-item>
                    <el-dropdown-item :icon="List">订单列表</el-dropdown-item>
                    <el-dropdown-item :icon="Setting">个人设置</el-dropdown-item>
                    <el-dropdown-item :icon="Position">注销登录</el-dropdown-item>
                  </el-dropdown-menu>
                </template>
              </el-dropdown>
            </div>
          </div>
          <div class="login-bar" v-show="!state.is_login">
            <div class="shop-cart full-left">
              <img src="../assets/cart.svg" alt="" />
              <span><router-link to="/cart">购物车</router-link></span>
            </div>
            <div class="login-box full-left">
              <span @click="state.show_login=true">登录</span>
              &nbsp;/&nbsp;
              <span>注册</span>
            </div>
          </div>
        </div>
      </div>
    </div>
    <el-dialog :width="600" v-model="state.show_login">
      <Login @successhandle="login_success"></Login>
    </el-dialog>
</template>
<script setup>
import {UserFilled, List, Setting, Position} from '@element-plus/icons-vue'
import Login from "./Login.vue"
import nav from "../api/nav";
import {reactive} from "vue";

const state = reactive({
  is_login: true,  // 登录状态
  show_login: false,
})

// 请求头部导航列表
nav.get_header_nav().then(response=>{
  nav.header_nav_list = response.data
})

// 用户登录成功以后的处理
const login_success = ()=>{
  state.show_login = false
}

</script>
<style scoped>
/* 登陆后状态栏 */
.logined-bar{
  margin-top: 0;
  height: 72px;
  line-height: 72px;
}
.header .logined-bar .shop-cart{
  height: 32px;
  line-height: 32px;
}
.logined-bar .login-box{
  height: 72px;
  line-height: 72px;
  position: relative;
}
.logined-bar .el-avatar{
  float: right;
  width: 50px;
  height: 50px;
  position: absolute;
  top: -10px;
  left: 10px;
  transition: transform .5s ease-in .1s;
}
.logined-bar .el-avatar:hover{
  transform: scale(1.3);
}
</style>

如果图标没有显示,可以采用安装以下组件:

# import {UserFilled, List, Setting, CloseBold} from '@element-plus/icons-vue'导入还是没显示则需下载
# npm install @element-plus/icons-vue
yarn add @element-plus/icons-vue

使用Vuex保存用户登录状态并判断是否在登陆栏显示用户信息

Vuex是Vue框架生态的一环,用于实现全局数据状态的统一管理。

官方地址:https://next.vuex.vuejs.org/zh/index.html

cd ~/Desktop/luffycity/luffycityweb
# 在客户端项目根目录下执行安装命令
yarn add vuex@next	# npm install vuex@next --save

Vuex初始化,是在src目录下创建store目录,store目录下创建index.js文件对vuex进行初始化:

import {createStore} from "vuex"

// 实例化一个vuex存储库
export default createStore({
    state () {  // 数据存储位置,相当于组件中的data
        return {
          user: {

          }
        }
    },
    mutations: { // 操作数据的方法,相当于methods
        login (state, user) {  // state 就是上面的state   state.user 就是上面的数据
          state.user = user
        }
    }
})

main.js中注册vuex,代码:

import { createApp } from 'vue'
import App from './App.vue'
import router from "./router";
import store from "./store"

import 'element-plus/theme-chalk/index.css'

createApp(App).use(router).use(store).mount('#app')

在components/Login.vue子组件中登录成功以后,记录用户信息到vuex中。

<script setup>
import user from "../api/user"
import { ElMessage } from 'element-plus'
const emit = defineEmits(["login_success",])

import {useStore} from "vuex"
const store = useStore()

const loginhandler = ()=>{
  // 登录处理
  if(user.username.length<1 || user.password.length<1){
    // 错误提示
    ElMessage.error('错了哦,用户名或密码不能为空!');
    return false // 在函数/方法中,可以阻止代码继续往下执行
  }

  // 发送请求
  user.user_login({
    username: user.username,
    password: user.password
  }).then(response=>{
    // 保存token,并根据用户的选择,是否记住密码
    localStorage.removeItem("token")
    sessionStorage.removeItem("token")
    if(user.remember){ // 判断是否记住登录状态
      // 记住登录
      localStorage.token = response.data.token
    }else{
      // 不记住登录,关闭浏览器以后就删除状态
      sessionStorage.token = response.data.token
    }
    // vuex存储用户登录信息,保存token,并根据用户的选择,是否记住密码
    let payload = response.data.token.split(".")[1]  // 载荷
    let payload_data = JSON.parse(atob(payload)) // 用户信息
    console.log(payload_data)
    store.commit("login", payload_data)

    // 成功提示
    ElMessage.success("登录成功!")
    // 关闭登录弹窗,对外发送一个登录成功的信息
    user.account = ""
    user.password = ""
    user.mobile = ""
    user.code = ""
    user.remember = false
    emit("login_success")

  }).catch(error=>{
    console.log(error);
    ElMessage.error("登录异常!")
  })
}

</script>

记录下来了以后,我们就可以直接components/Header.vue中读取Vuex中的用户信息。

<template>
    <div class="header-box">
        <div class="header">
            <div class="content">
                <div class="logo">
                    <router-link to="/"><img src="../assets/logo.svg" alt=""></router-link>
                </div>
                
                <ul class="nav">
                    <li v-for="nav in nav.header_nav_list">
                        <a :href="nav.link" v-if="nav.is_http">{{nav.name}}</a>
                        <router-link :to="nav.link" v-else>{{nav.name}}</router-link>
                    </li>
                </ul>
                
                <div class="search-warp">
                    <div class="search-area">
                        <input class="search-input" placeholder="请输入关键字..." type="text" autocomplete="off">
                        <div class="hotTags">
                            <router-link to="/search/?words=Vue" target="_blank" class="">Vue</router-link>
                            <router-link to="/search/?words=Python" target="_blank" class="last">Python</router-link>
                        </div>
                    </div>
                    <div class="showhide-search" data-show="no"><img class="imv2-search2" src="../assets/search.svg" /></div>
                </div>

                <!--未登录状态-->
                <div class="login-bar" v-show="!store.state.user.user_id">
                    <div class="shop-cart full-left">
                        <img src="../assets/cart.svg" alt="" />
                        <span><router-link to="/cart">购物车</router-link></span>
                    </div>
                    <div class="login-box full-left">
                        <span @click="state.show_login=true">登录</span>
                        &nbsp;/&nbsp;
                        <span>注册</span>
                    </div>
                </div>

                <!--已登录状态-->
                <div class="login-bar logined-bar" v-if="store.state.user.user_id">
                    <div class="shop-cart ">
                        <img src="../assets/cart.svg" alt="" />
                        <span><router-link to="/cart">购物车</router-link></span>
                    </div>
                    <div class="login-box ">
                        <router-link to="">我的课堂</router-link>
                        <el-dropdown>
                <span class="el-dropdown-link">
                  <el-avatar class="avatar" size="small" src="https://avatars.githubusercontent.com/u/93141587"></el-avatar>
                </span>
                            <template #dropdown>
                                <el-dropdown-menu>
                                    <el-dropdown-item :icon="UserFilled">学习中心</el-dropdown-item>
                                    <el-dropdown-item :icon="List">订单列表</el-dropdown-item>
                                    <el-dropdown-item :icon="Setting">个人设置</el-dropdown-item>
                                    <el-dropdown-item :icon="CloseBold">注销登录</el-dropdown-item>

                                </el-dropdown-menu>
                            </template>
                        </el-dropdown>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <el-dialog :width="600" v-model="state.show_login">
        <Login @successhandler="login_success"></Login>
    </el-dialog>
</template>
<script setup>
import Login from "./Login.vue"

import nav from "../api/nav"
import {reactive} from "vue"
import {useStore} from "vuex"
const store = useStore()

const state = reactive({
  show_login: false,
})

nav.get_header_nav().then(response=>{
  nav.header_nav_list = response.data;
})

// 用户登录成功以后的处理
const login_success = (token)=>{
  state.show_login = false
}

</script>

因为vuex默认是保存数据在内存中的,所以基于浏览器开发的网页,如果在F5刷新网页时会存在数据丢失的情况。所以我们可以把store数据永久存储到localStorage中。这里就需要使用插件vuex-persistedstate来实现。

在前端项目的根目录下执行安装命令

cd ~/Desktop/luffycity/luffycityweb
yarn add vuex-persistedstate	# npm install  vuex-persistedstate

在vuex的store/index.js文件中导入此插件。

import {createStore} from "vuex"
import createPersistedState from "vuex-persistedstate"

// 实例化一个vuex存储库
export default createStore({
    // 调用永久存储vuex数据的插件,localstorage里会多一个名叫vuex的Key,里面就是vuex的数据
    plugins: [createPersistedState()],
    state(){  // 相当于组件中的data,用于保存全局状态数据
        return {
            user: {}
        }
    },
    getters: {
        getUserInfo(state){
            // 从jwt的载荷中提取用户信息
            let now = parseInt( (new Date() - 0) / 1000 );	// js获取本地时间戳(秒)
            if(state.user.exp === undefined) {
                // 没登录
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }

            if(parseInt(state.user.exp) < now) {
                // 过期处理
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }
            return state.user;
        }
    },
    mutations: { // 相当于组件中的methods,用于操作state全局数据
        login(state, payload){
            state.user = payload; // state.user 就是上面声明的user
        }
    }
})

完成了登录功能以后,我们要防止用户FQ访问需要认证身份的页面时,可以基于vue-router的导航守卫来完成。

src/router/index.js,代码:

import {createRouter, createWebHistory} from 'vue-router'
import store from '../store/index'

// 路由列表
const routes = [
    {
        meta:{
            title: "luffy2.0-站点首页",
            keepAlive: true
        },
        path: '/',         // uri访问地址
        name: "Home",
        component: ()=> import("../views/Home.vue")
    },
    {
        meta:{
            title: "luffy2.0-用户登录",
            keepAlive: true
        },
        path:'/login',      // uri访问地址
        name: "Login",
        component: ()=> import("../views/Login.vue")
    },
    {
        meta:{
            title: "luffy2.0-个人中心",
            keepAlive: true,
            authorization: true,
        },
        path: '/user',
        name: "User",
        component: ()=> import("../views/User.vue"),
    },
]

// 路由对象实例化
const router = createRouter({
    // history, 指定路由的模式
    history: createWebHistory(),
    // 路由列表
    routes,
});


// 导航守卫
router.beforeEach((to, from, next)=>{
    document.title=to.meta.title
    // 登录状态验证
    if (to.meta.authorization && !store.getters.getUserInfo) {
        next({"name": "Login"})
    }else{
        next()
    }
})


// 暴露路由对象
export default router

src/views/User.vue,代码:

<template>
    <h1>个人中心</h1>
</template>

<script>
    export default {
        name: "User"
    }
</script>

<style scoped>

</style>

提交版本

cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端基于vuex存储本地全局数据并判断登陆状态"
git push origin feature/user

退出登录功能

在vuex的store/index.js中编写一个登录注销的方法logout,代码:

import {createStore} from "vuex"
import createPersistedState from "vuex-persistedstate"

// 实例化一个vuex存储库
export default createStore({
    // 调用永久存储vuex数据的插件,localstorage里会多一个名叫vuex的Key,里面就是vuex的数据
    plugins: [createPersistedState()],
    state(){  // 相当于组件中的data,用于保存全局状态数据
        return {
            user: {}
        }
    },
    getters: {
        getUserInfo(state){
            let now = parseInt( (new Date() - 0) / 1000 );
            if(state.user.exp === undefined) {
                // 没登录
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }

            if(parseInt(state.user.exp) < now) {
                // 过期处理
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }
            return state.user;
        }
    },
    mutations: { // 相当于组件中的methods,用于操作state全局数据
        login(state, payload){
            state.user = payload; // state.user 就是上面声明的user
        },
        logout(state){ // 退出登录
            state.user = {}
            localStorage.token = null;
            sessionStorage.token = null;
        }
    }
})

在用户点击头部登录栏的注销登录时绑定登录注销操作。components/Header.vue,代码:

<el-dropdown-item :icon="Position" @click="logout">注销登录</el-dropdown-item>
<script setup>
import {UserFilled, List, Setting, Position} from '@element-plus/icons-vue'
import Login from "./Login.vue"
import nav from "../api/nav";
import {reactive} from "vue";

import {useStore} from "vuex"
const store = useStore()

const state = reactive({
  show_login: false,
})

// 请求头部导航列表
nav.get_header_nav().then(response=>{
  nav.header_nav_list = response.data
})

// 用户登录成功以后的处理
const login_success = ()=>{
  state.show_login = false
}

// 登录注销的处理
const logout = ()=>{
  store.commit("logout");
}


</script>

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "客户端注销登录状态"
git push

在登录认证中接入防水墙验证码

验证码:
1. 图形验证码 ---> 一张图片,图片是服务端基于pillow模块生成的,里面的内容就是验证码信息,会生成验证码增加一些雪花,干扰线,采用特殊字体写入图片,内容同时会保存一份到redis中。

2. 滑块验证码 ---> 通过js互动的方式,让用户旋转、拖动图片到达一定的随机的位置。

3. 短信验证码 ---> 通过绑定手机的方式,发送随机验证码到用户手机中,确认用户是真人。
   邮件验证码
   微信验证码[消息模板, 关注公众号]
   谷歌验证码[Authenticator,二段验证]

使用腾讯提供的防水墙验证码

官网: https://007.qq.com/

登录腾讯云:https://cloud.tencent.com/login

image-20220904090255687

image-20220904090311236

点击立即选购,使用微信扫码登录以后,选择右上角"控制台"。

image-20220904090328864

云产品中,搜索 验证码即可。

image-20220904090406519

image-20220904090449703

创建验证的应用。

image-20220904090515544

在验证应用的基本配置中记录下我们接下来需要使用的2个重要配置信息.

应用ID   CaptchaAppId    2059674751 	# 192606488
应用秘钥  AppSecretKey   04LwtDUlnQxumWnItAw4OPA**    # xc40y2uNXlFNLkQD6111T5Q23          (*号也为Key值,请不要忽略或者删除)

image-20220904090530045

因为后面要python对接腾讯云服务器,所以通过访问管理,API秘钥管理提取当前腾讯云账号的SecretId和SecretKey。

SecretId: AKIDSggmeI7z2qSUHoaf18zb4JKdZv61PEZf	# AKIDreJmuthXDf8VsKk0YAnU4UvIbqwpY8Yx
SecretKey: 06xbzB7VabOyY3asztbkdIfqlovtLYXG	# Y7Z0PehYLJVjr78yeM7pMtT1Qn294oPr

接下来在应用中心点击右边的系统代码集成,把验证码集成到项目中就可以。

image-20220904090546845

web前端接入文件地址:https://cloud.tencent.com/document/product/1110/36841

python接入文档地址:https://cloud.tencent.com/document/sdk/Python

前端获取显示并校验验证码

// 需要下载官方提供的核心js文件并在项目导入使用。
https://ssl.captcha.qq.com/TCaptcha.js

image-20220904090616697

components/Login.vue,代码:

    <button class="login_btn" @click="show_captcha">登录</button>
<script setup>
import user from "../api/user";
import { ElMessage } from 'element-plus'
import "../utils/TCaptcha"
const emit = defineEmits(["successhandle",])

import {useStore} from "vuex"
const store = useStore()

// 显示验证码
const show_captcha = ()=>{
  var captcha1 = new TencentCaptcha('2059674751', (res)=>{
      // 接收验证结果的回调函数
      /* res(验证成功) = {ret: 0, ticket: "String", randstr: "String"}
         res(客户端出现异常错误 仍返回可用票据) = {ret: 0, ticket: "String", randstr: "String", errorCode: Number, errorMessage: "String"}
         res(用户主动关闭验证码)= {ret: 2}
      */
      console.log(res);
      // 调用登录处理
      loginhandler(res);
  });
  captcha1.show(); // 显示验证码
}

// 登录处理
const loginhandler = (res)=>{
  // 验证数据
  if(user.account.length<1 || user.password.length<1){
    // 错误提示
    console.log("错了哦,用户名或密码不能为空!");
    ElMessage.error("错了哦,用户名或密码不能为空!");
    return ;
  }

  // 登录请求处理
  user.login({
    ticket: res.ticket,
    randstr: res.randstr,
  }).then(response=>{
    // 先删除之前存留的状态
    localStorage.removeItem("token");
    sessionStorage.removeItem("token");
    // 根据用户选择是否记住登录密码,保存token到不同的本地存储中
    if(user.remember){
      // 记录登录状态
      localStorage.token = response.data.token
    }else{
      // 不记录登录状态
      sessionStorage.token = response.data.token
    }
    ElMessage.success("登录成功!");
    // 登录后续处理,通知父组件,当前用户已经登录成功
    user.account = ""
    user.password = ""
    user.mobile = ""
    user.code = ""
    user.remember = false

    // vuex存储用户登录信息,保存token,并根据用户的选择,是否记住密码
    let payload = response.data.token.split(".")[1]  // 载荷
    let payload_data = JSON.parse(atob(payload)) // 用户信息
    console.log("payload_data=", payload_data)
    store.commit("login", payload_data)

    emit("successhandle")
  }).catch(error=>{
    ElMessage.error("登录失败!");
  })
}

</script>

src/api/user.js,代码:

import http from "../utils/http"
import {reactive, ref} from "vue"

const user = reactive({
    login_type: 0, // 登录方式,0,密码登录,1,短信登录
    account: "",  // 登录账号/手机号/邮箱
    password: "", // 登录密码
    remember: false, // 是否记住登录状态
    mobile: "",      // 登录手机号码
    code: "",        // 短信验证码
    login(res){
        // 用户登录
        return http.post("/users/login/", {
            "ticket": res.ticket,
            "randstr": res.randstr,
            "username": this.account,
            "password": this.password,
        })
    }
})

export default user;

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "客户端集成腾讯云验证码"
git push	# git push --set-upstream origin feature/user设置后,可以直接用push到对应的分支

服务端登录功能中校验验证码结果

python接入文档地址:https://cloud.tencent.com/document/sdk/Python

安装腾讯云PythonSKD扩展模块到项目中

pip install --upgrade tencentcloud-sdk-python

生成代码的API操作界面:https://console.cloud.tencent.com/api/explorer?Product=captcha&Version=2019-07-22&Action=DescribeCaptchaResult&SignVersion=

utils/tencentcloudapi.py,封装一个操作腾讯云SDK的API工具类,代码:

import json
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.captcha.v20190722 import captcha_client, models
from django.conf import settings


class TencentCloudAPI(object):
    """腾讯云API操作工具类"""

    def __init__(self):
        self.cred = credential.Credential(settings.TENCENTCLOUD["SecretId"], settings.TENCENTCLOUD["SecretKey"])

    def captcha(self, ticket, randstr, user_ip):
        """
        验证码校验工具方法
        :ticket  客户端验证码操作成功以后得到的临时验证票据
        :randstr 客户端验证码操作成功以后得到的随机字符串
        :user_ip 客户端的IP地址
        """
        try:
            Captcha = settings.TENCENTCLOUD["Captcha"]

            # 实例化http请求工具类
            httpProfile = HttpProfile()
            # 设置API所在服务器域名
            httpProfile.endpoint = Captcha["endpoint"]
            # 实例化客户端工具类
            clientProfile = ClientProfile()
            # 给客户端绑定请求的服务端域名
            clientProfile.httpProfile = httpProfile
            # 实例化验证码服务端请求工具的客户端对象
            client = captcha_client.CaptchaClient(self.cred, "", clientProfile)
            # 客户端请求对象参数的初始化
            req = models.DescribeCaptchaResultRequest()

            params = {
                # 验证码类型固定为9
                "CaptchaType": Captcha["CaptchaType"],
                # 客户端提交的临时票据
                "Ticket": ticket,
                # 客户端ip地址
                "UserIp": user_ip,
                # 随机字符串
                "Randstr": randstr,
                # 验证码应用ID
                "CaptchaAppId": Captcha["CaptchaAppId"],
                # 验证码应用key
                "AppSecretKey": Captcha["AppSecretKey"],
            }
            # 发送请求
            req.from_json_string(json.dumps(params))
            # 获取腾讯云的响应结果
            resp = client.DescribeCaptchaResult(req)
            # 把响应结果转换成json格式数据
            result = json.loads( resp.to_json_string() )
            return result and result.get("CaptchaCode") == 1

        except Exception as err:
            raise TencentCloudSDKException

settings/dev.py,保存腾讯云验证码的配置信息,保存代码:

# 腾讯云API接口配置
TENCENTCLOUD = {
    # 腾讯云访问秘钥ID
    "SecretId": "AKIDSggmeI7z2qSUHoaf18zb4JKdZv61PEZf",
    # 腾讯云访问秘钥key
    "SecretKey": "06xbzB7VabOyY3asztbkdIfqlovtLYXG",
    # 验证码API配置
    "Captcha": {
        "endpoint": "captcha.tencentcloudapi.com", # 验证码校验服务端域名
        "CaptchaType": 9,  # 验证码类型,固定为9
        "CaptchaAppId": 2059674751,  # 验证码应用ID
        "AppSecretKey": "04LwtDUlnQxumWnItAw4OPA**", # 验证码应用key
    },
}

users/views.py,重写登陆视图,先校验验证码,接着再调用jwt原来提供的视图来校验用户账号信息,代码:

from rest_framework_jwt.views import ObtainJSONWebToken
from luffycityapi.utils.tencentcloudapi import TencentCloudAPI,TencentCloudSDKException
from rest_framework.response import Response
from rest_framework import status

# Create your views here.
class LoginAPIView(ObtainJSONWebToken):
    """用户登录视图"""
    def post(self, request, *args, **kwargs):
        # 校验用户操作验证码成功以后的ticket临时票据
        try:
            api = TencentCloudAPI()
            result = api.captcha(
                request.data.get("ticket"),
                request.data.get("randstr"),
                request._request.META.get("REMOTE_ADDR"),
            )
            if result:
                # 验证通过
                print("验证通过")
                # 登录实现代码,调用父类实现的登录视图方法
                return super().post(request, *args, **kwargs)
            else:
                # 如果返回值不是True,则表示验证失败
                raise TencentCloudSDKException
        except TencentCloudSDKException as err:
            return Response({"errmsg": "验证码校验失败!"}, status=status.HTTP_400_BAD_REQUEST)

users/urls.py,代码:

from django.urls import path
from . import views

urlpatterns = [
    path("login/", views.LoginAPIView.as_view(), name="login"),
]

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "服务端重写登录视图实现验证码的操作结果验证"
git push

用户的注册认证

前端显示注册页面并调整首页头部和登陆页面的注册按钮的链接。

创建一个注册页面views/Register.vue,主要是通过登录窗口组件进行改成而成,组件代码:

<template>
	<div class="login box">
		<img src="../assets/Loginbg.3377d0c.jpg" alt="">
		<div class="login">
			<div class="login-title">
				<img src="../assets/logo.svg" alt="">
				<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
			</div>
      <div class="login_box">
          <div class="title">
            <span class="active">用户注册</span>
          </div>
          <div class="inp">
            <input v-model="state.mobile" type="text" placeholder="手机号码" class="user">
            <input v-model="state.password" type="password" placeholder="登录密码" class="user">
            <input v-model="state.re_password" type="password" placeholder="确认密码" class="user">
            <input v-model="state.code"  type="text" class="code" placeholder="短信验证码">
            <el-button id="get_code" type="primary">获取验证码</el-button>
            <button class="login_btn">注册</button>
            <p class="go_login" >已有账号 <router-link to="/login">立即登录</router-link></p>
          </div>
      </div>
		</div>
	</div>
</template>

<script setup>
import {reactive, defineEmits} from "vue"
import { ElMessage } from 'element-plus'
import {useStore} from "vuex"
import "../utils/TCaptcha"

const store = useStore()

const state = reactive({
  password:"",    // 密码
  re_password: "",// 确认密码
  mobile: "",     // 手机号
  code: "",       // 验证码
})
</script>

<style scoped>
.box{
	width: 100%;
  height: 100%;
	position: relative;
  overflow: hidden;
}
.box img{
	width: 100%;
  min-height: 100%;
}
.box .login {
	position: absolute;
	width: 500px;
	height: 400px;
	left: 0;
  margin: auto;
  right: 0;
  bottom: 0;
  top: -438px;
}

.login-title{
     width: 100%;
    text-align: center;
}
.login-title img{
    width: 190px;
    height: auto;
}
.login-title p{
    font-size: 18px;
    color: #fff;
    letter-spacing: .29px;
    padding-top: 10px;
    padding-bottom: 50px;
}
.login_box{
    width: 400px;
    height: auto;
    background: #fff;
    box-shadow: 0 2px 4px 0 rgba(0,0,0,.5);
    border-radius: 4px;
    margin: 0 auto;
    padding-bottom: 40px;
    padding-top: 50px;
}
.title{
	font-size: 20px;
	color: #9b9b9b;
	letter-spacing: .32px;
	border-bottom: 1px solid #e6e6e6;
  display: flex;
  justify-content: space-around;
  padding: 0px 60px 0 60px;
  margin-bottom: 20px;
  cursor: pointer;
}
.title span.active{
	color: #4a4a4a;
}

.inp{
	width: 350px;
	margin: 0 auto;
}
.inp .code{
  width: 190px;
  margin-right: 16px;
}
#get_code{
 margin-top: 6px;
}
.inp input{
    outline: 0;
    width: 100%;
    height: 45px;
    border-radius: 4px;
    border: 1px solid #d9d9d9;
    text-indent: 20px;
    font-size: 14px;
    background: #fff !important;
}
.inp input.user{
    margin-bottom: 16px;
}
.inp .rember{
     display: flex;
    justify-content: space-between;
    align-items: center;
    position: relative;
    margin-top: 10px;
}
.inp .rember p:first-of-type{
    font-size: 12px;
    color: #4a4a4a;
    letter-spacing: .19px;
    margin-left: 22px;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-align: center;
    align-items: center;
    /*position: relative;*/
}
.inp .rember p:nth-of-type(2){
    font-size: 14px;
    color: #9b9b9b;
    letter-spacing: .19px;
    cursor: pointer;
}

.inp .rember input{
    outline: 0;
    width: 30px;
    height: 45px;
    border-radius: 4px;
    border: 1px solid #d9d9d9;
    text-indent: 20px;
    font-size: 14px;
    background: #fff !important;
    vertical-align: middle;
    margin-right: 4px;
}

.inp .rember p span{
    display: inline-block;
  font-size: 12px;
  width: 100px;
}
.login_btn{
    cursor: pointer;
    width: 100%;
    height: 45px;
    background: #84cc39;
    border-radius: 5px;
    font-size: 16px;
    color: #fff;
    letter-spacing: .26px;
    margin-top: 30px;
    border: none;
    outline: none;
}
.inp .go_login{
    text-align: center;
    font-size: 14px;
    color: #9b9b9b;
    letter-spacing: .26px;
    padding-top: 20px;
}
.inp .go_login span{
    color: #84cc39;
    cursor: pointer;
}
</style>

客户端注册路由,src/router/index.js,代码:

import {createRouter, createWebHistory, createWebHashHistory} from 'vue-router'
import store from "../store";

// 路由列表
const routes = [
  {
    meta:{
        title: "luffy2.0-站点首页",
        keepAlive: true
    },
    path: '/',         // uri访问地址
    name: "Home",
    component: ()=> import("../views/Home.vue")
  },
  {
    meta:{
        title: "luffy2.0-用户登录",
        keepAlive: true
    },
    path:'/login',      // uri访问地址
    name: "Login",
    component: ()=> import("../views/Login.vue")
  },
  {
      meta:{
        title: "luffy2.0-用户注册",
        keepAlive: true
      },
      path: '/register',
      name: "Register",            // 路由名称
      component: ()=> import("../views/Register.vue"),         // uri绑定的组件页面
  },
  {
    meta:{
        title: "luffy2.0-个人中心",
        keepAlive: true,
        authorization: true,
    },
    path: '/user',
    name: "User",
    component: ()=> import("../views/User.vue"),
  },
]


// 路由对象实例化
const router = createRouter({
  // history, 指定路由的模式
  history: createWebHistory(),
  // 路由列表
  routes,
});

// 导航守卫
router.beforeEach((to, from, next)=>{
  document.title=to.meta.title
  // 登录状态验证
  if (to.meta.authorization && !store.getters.getUserInfo) {
    next({"name": "Login"})
  }else{
    next()
  }
})


// 暴露路由对象
export default router

修改首页头部的连接和登录窗口中登录和注册的链接。代码:

# components/Header.vue
<router-link to="/register">注册</router-link>
#components/Login.vue
<p class="go_login" >没有账号 <router-link to="/register">立即注册</router-link></p>

注册功能的实现流程

image-20210720102658402

综合上图所示,我们需要在服务端完成3个接口:

1. 验证手机号是否注册了
2. 发送验证码
3. 校验验证码,并保存用户提交的注册信息

所以,除了短信发送功能以外,其他2个接口功能,我们完全不需要依赖第三方,直接可以先实现了。

用户手机号码的校验

用户填写手机号的时候,我们可以监听手机号格式正确的情况下,通过ajax提前告诉用户手机号是否已经被注册了。

客户端监听手机号格式是否正确

views/Regiser.vue

<script setup>
import {reactive, defineEmits,watch} from "vue"
import { ElMessage } from 'element-plus'
import {useStore} from "vuex"
import "../utils/TCaptcha"

const store = useStore()

const state = reactive({
  password:"",    // 密码
  re_password: "",// 确认密码
  mobile: "",     // 手机号
  code: "",       // 验证码
})

watch(() => state.mobile, (mobile, prevMobile) => {
  if(/1[3-9]\d{9}/.test(state.mobile)){
    // 发送ajax验证手机号是否已经注册
  }
})

</script>

服务端提供验证手机号的api接口

users/views.py,视图代码:

from rest_framework.views import APIView
from .models import User


class MobileAPIView(APIView):
    def post(self, request, mobile):
        """
        校验手机号是否已注册
        :param request:
        :param mobile: 手机号
        :return:
        """
        try:
            User.objects.get(mobile=mobile)
            return Response({"errmsg": "当前手机号已注册"}, status=status.HTTP_400_BAD_REQUEST)
        except User.DoesNotExist:
            # 如果查不到该手机号的注册记录,则证明手机号可以注册使用
            return Response({"errmsg": "OK"}, status=status.HTTP_200_OK)

路由,代码:

from django.urls import path, re_path
from . import views

urlpatterns = [
    path("login/", views.LoginAPIView.as_view(), name="login"),
    re_path(r"^mobile/(?P<mobile>1[3-9]\d{9})/$", views.MobileAPIView.as_view()),
]

客户端发送ajax请求验证手机号是否已注册

src/api/user.js,代码:

import http from "../utils/http"
import {reactive, ref} from "vue"

const user = reactive({
    login_type: 0, // 登录方式,0,密码登录,1,短信登录
    account: "",  // 登录账号/手机号/邮箱
    password: "", // 登录密码
    remember: false, // 是否记住登录状态
    re_password: "",// 确认密码
    mobile: "",      // 登录手机号码
    code: "",        // 短信验证码
    login(res){
        // 用户登录
        return http.post("/users/login/", {
            "ticket": res.ticket,
            "randstr": res.randstr,
            "username": this.account,
            "password": this.password,
        })
    },
    check_mobile(){
        // 验证手机号
        return http.get(`/users/mobile/${this.mobile}/`)
    }
})

export default user;

views/Register.vue,代码:

<template>
	<div class="login box">
		<img src="../assets/Loginbg.3377d0c.jpg" alt="">
		<div class="login">
			<div class="login-title">
				<img src="../assets/logo.svg" alt="">
				<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
			</div>
      <div class="login_box">
          <div class="title">
            <span class="active">用户注册</span>
          </div>
          <div class="inp">
            <input v-model="user.mobile" type="text" placeholder="手机号码" class="user">
            <input v-model="user.password" type="password" placeholder="登录密码" class="user">
            <input v-model="user.re_password" type="password" placeholder="确认密码" class="user">
            <input v-model="user.code"  type="text" class="code" placeholder="短信验证码">
            <el-button id="get_code" type="primary">获取验证码</el-button>
            <button class="login_btn">注册</button>
            <p class="go_login" >已有账号 <router-link to="/login">立即登录</router-link></p>
          </div>
      </div>
		</div>
	</div>
</template>
<script setup>
import {reactive, defineEmits, watch} from "vue"
import { ElMessage } from 'element-plus'
import {useStore} from "vuex"
import "../utils/TCaptcha"
import user from "../api/user";

const store = useStore()

// 监听数据mobile是否发生变化
watch(()=>user.mobile, (mobile, prevMobile) => {
  if(/1[3-9]\d{9}/.test(user.mobile)){
    // 发送ajax验证手机号是否已经注册
    user.check_mobile().catch(error=>{
      ElMessage.error(error.response.data.errmsg);
    })
  }
})

</script>

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "注册功能实现流程-验证手机号是否已经注册!"
git push

注册功能的基本实现

服务端实现用户注册的api接口

序列化器,users/serializers,代码:

import re, constants
from rest_framework import serializers
from rest_framework_jwt.settings import api_settings

from .models import User


class UserRegisterModelSerializer(serializers.ModelSerializer):
    """
    用户注册的序列化器
    """
    re_password = serializers.CharField(required=True, write_only=True)
    sms_code = serializers.CharField(min_length=4, max_length=6, required=True, write_only=True)
    token = serializers.CharField(read_only=True)

    class Meta:
        model = User
        fields = ["mobile", "password", "re_password", "sms_code", "token"]
        extra_kwargs = {
            "mobile": {
                "required": True, "write_only": True
            },
            "password": {
                "required": True, "write_only": True, "min_length": 6, "max_length": 16,
            },
        }

    def validate(self, data):
        """验证客户端数据"""
        # 手机号格式验证
        mobile = data.get("mobile", None)
        if not re.match("^1[3-9]\d{9}$", mobile):
            raise serializers.ValidationError(detail="手机号格式不正确!",code="mobile")

        # 密码和确认密码
        password = data.get("password")
        re_password = data.get("re_password")
        if password != re_password:
            raise serializers.ValidationError(detail="密码和确认密码不一致!", code="password")

        # 手机号是否已注册
        try:
            User.objects.get(mobile=mobile)
            raise serializers.ValidationError(detail="手机号已注册!")
        except User.DoesNotExist:
            pass

        # todo 验证短信验证码

        return data

    def create(self, validated_data):
        """保存用户信息,完成注册"""
        mobile = validated_data.get("mobile")
        password = validated_data.get("password")

        user = User.objects.create_user(
            username=mobile,
            mobile=mobile,
            avatar=constants.DEFAULT_USER_AVATAR,
            password=password,
        )

        # 注册成功以后,免登陆
        jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
        jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
        payload = jwt_payload_handler(user)
        user.token = jwt_encode_handler(payload)

        return user

默认头像配置,settings.constants,代码:

# 默认头像
DEFAULT_USER_AVATAR = "avatar/2021/avatar.jpg"
# 手动在uploads下创建avatar/2021/并把客户端的头像保存到该目录下。

视图,users.views,代码,

from rest_framework.generics import CreateAPIView
from .serializers import UserRegisterModelSerializer


class UserAPIView(CreateAPIView):
    queryset = User.objects.all()
    serializer_class = UserRegisterModelSerializer


路由,users/urls,代码:

from django.urls import path, re_path
from . import views

urlpatterns = [
    path("login/", views.LoginAPIView.as_view(), name="login"),
    re_path("^mobile/(?P<mobile>1[3-9]\d{9})/$", views.MobileAPIView.as_view()),
    path("register/", views.UserAPIView.as_view()),
]

客户端提交用户注册信息

views/views/Register.vue,代码:

<button class="login_btn" @click="show_captcha">注册</button>

<script setup>
import {reactive, defineEmits, watch} from "vue"
import { ElMessage } from 'element-plus'
import {useStore} from "vuex"
import "../utils/TCaptcha"
import user from "../api/user";
import router from "../router";
import settings from "../settings";
const store = useStore()

// 监听数据mobile是否发生变化
watch(()=>user.mobile, (mobile, prevMobile) => {
  if(/1[3-9]\d{9}/.test(user.mobile)){
    // 发送ajax验证手机号是否已经注册
    user.check_mobile().catch(error=>{
      ElMessage.error(error.response.data.errmsg);
    })
  }
})


// 显示登录验证码
const show_captcha = ()=>{
  // 直接生成一个验证码对象
    /* res(验证成功) = {ret: 0, ticket: "String", randstr: "String"}
               res(客户端出现异常错误 仍返回可用票据) = {ret: 0, ticket: "String", randstr: "String", errorCode: Number, errorMessage: "String"}
               res(用户主动关闭验证码)= {ret: 2}
            */
  let  captcha1 = new TencentCaptcha(settings.captcha_app_id, (res)=>{
    // 验证码通过验证以后的回调方法
    if(res && res.ret === 0){
      // 验证通过,发送登录请求
      registerhandler(res)
    }
  });

  // 显示验证码
  captcha1.show();
}


const registerhandler = (res)=> {
  // 注册处理
  if (!/^1[3-9]\d{9}$/.test(user.mobile)) {
    // 错误提示
    ElMessage.error('错了哦,手机号格式不正确!');
    return false // 阻止代码继续往下执行
  }
  if (user.password.length < 6 || user.password.length > 16) {
    ElMessage.error('错了哦,密码必须在6~16个字符之间!');
    return false
  }

  if (user.password !== user.re_password) {
    ElMessage.error('错了哦,密码和确认密码不一致!');
    return false
  }

    // 发送请求
  user.register({
    // 验证码通过的票据信息
    ticket: res.ticket,
    randstr: res.randstr,
  }).then(response=>{
    // 保存token,并根据用户的选择,是否记住密码
    localStorage.removeItem("token");
    sessionStorage.removeItem("token");

    // 默认不需要记住登录
    sessionStorage.token = response.data.token;

    // vuex存储用户登录信息
    let payload = response.data.token.split(".")[1]  // 载荷
    let payload_data = JSON.parse(atob(payload)) // 用户信息
    store.commit("login", payload_data)
    // 清空表单信息
    user.mobile = ""
    user.password = ""
    user.re_password = ""
    user.code = ""
    user.remember = false
    //  成功提示
    ElMessage.success("注册成功!");
    // 路由跳转到首页
    router.push("/");


  })
}


</script>

src/api/user.js,代码:

import http from "../utils/http"
import {reactive, ref} from "vue"

const user = reactive({
    login_type: 0, // 登录方式,0,密码登录,1,短信登录
    account: "",  // 登录账号/手机号/邮箱
    password: "", // 登录密码
    remember: false, // 是否记住登录状态
    re_password: "",// 确认密码
    code: "", // 短信验证码
    login(res){
        // 用户登录
        return http.post("/users/login/", {
            "ticket": res.ticket,
            "randstr": res.randstr,
            "username": this.account,
            "password": this.password,
        })
    },
    check_mobile(){
        // 验证手机号
        return http.get(`/users/mobile/${this.mobile}/`)
    },
    register(data){
        data.mobile = this.mobile
        data.re_password = this.re_password
        data.password = this.password
        data.sms_code = this.code
        // 用户注册请求
        return http.post("/users/register/", data)
    }
})

export default user;

把防水墙验证码的app_id保存到配置文件src/settings.js中.

src/settings.js,代码:

export default {
    // api服务端所在地址
    host: "http://api.luffycity.cn:8000", // ajax服务度地址必须得加http!
    // 防水墙验证码的应用ID
    captcha_app_id: "2019894193",  // IP应用ID
}

src/utils/https.js里面讲baseURL换成提供的变量

import axios from "axios"
import settings from "../settings";

const http = axios.create({
    // timeout: 2500,                          // 请求超时,有大文件上传需要关闭这个配置
    baseURL:  settings.host,     // 设置api服务端的默认地址[如果基于服务端实现的跨域,这里可以填写api服务端的地址,如果基于nodejs客户端测试服务器实现的跨域,则这里不能填写api服务端地址]
    withCredentials: false,                    // 是否允许客户端ajax请求时携带cookie
})

components/Login.vue,登录组件中,把app_id改成settings提供的变量,代码:

<script setup>
import user from "../api/user";
import { ElMessage } from 'element-plus'
import "../utils/TCaptcha"
const emit = defineEmits(["successhandle",])
import settings from "../settings";
import {useStore} from "vuex"
const store = useStore()

// 显示验证码
const show_captcha = ()=>{
  var captcha1 = new TencentCaptcha(settings.captcha_app_id, (res)=>{
      // 接收验证结果的回调函数
      /* res(验证成功) = {ret: 0, ticket: "String", randstr: "String"}
         res(客户端出现异常错误 仍返回可用票据) = {ret: 0, ticket: "String", randstr: "String", errorCode: Number, errorMessage: "String"}
         res(用户主动关闭验证码)= {ret: 2}
      */
      console.log(res);
      // 调用登录处理
      loginhandler(res);
  });
  captcha1.show(); // 显示验证码
}

// 登录处理
const loginhandler = (res)=>{
  // 验证数据
  if(user.account.length<1 || user.password.length<1){
    // 错误提示
    console.log("错了哦,用户名或密码不能为空!");
    ElMessage.error("错了哦,用户名或密码不能为空!");
    return ;
  }

  // 登录请求处理
  user.login({
    ticket: res.ticket,
    randstr: res.randstr,
  }).then(response=>{
    // 先删除之前存留的状态
    localStorage.removeItem("token");
    sessionStorage.removeItem("token");
    // 根据用户选择是否记住登录密码,保存token到不同的本地存储中
    if(user.remember){
      // 记录登录状态
      localStorage.token = response.data.token
    }else{
      // 不记录登录状态
      sessionStorage.token = response.data.token
    }
    ElMessage.success("登录成功!");
    // 登录后续处理,通知父组件,当前用户已经登录成功
    user.account = ""
    user.password = ""
    user.mobile = ""
    user.code = ""
    user.remember = false

    // vuex存储用户登录信息,保存token,并根据用户的选择,是否记住密码
    let payload = response.data.token.split(".")[1]  // 载荷
    let payload_data = JSON.parse(atob(payload)) // 用户信息
    console.log("payload_data=", payload_data)
    store.commit("login", payload_data)

    emit("successhandle")
  }).catch(error=>{
    ElMessage.error("登录失败!");
  })
}

</script>

服务端的注册功能中,添加验证腾讯云的验证码ticket和randstr。users/serializers.py

import re, constants
from rest_framework import serializers
from rest_framework_jwt.settings import api_settings
from .models import User
from luffycityapi.utils.tencentcloudapi import TencentCloudAPI, TencentCloudSDKException

class UserRegisterModelSerializer(serializers.ModelSerializer):
    """
    用户注册的序列化器
    """
    re_password = serializers.CharField(required=True, write_only=True, help_text="确认密码")
    sms_code = serializers.CharField(min_length=4, max_length=6, required=True, write_only=True, help_text="短信验证码")
    token = serializers.CharField(read_only=True)
    ticket = serializers.CharField(required=True, write_only=True, help_text="滑块验证码的临时凭证")
    randstr = serializers.CharField(required=True, write_only=True, help_text="滑块验证码的随机字符串")

    class Meta:
        model = User
        fields = ["mobile", "password", "re_password", "sms_code", "token", "ticket", "randstr"]
        extra_kwargs = {
            "mobile": {
                "required": True, "write_only": True
            },
            "password": {
                "required": True, "write_only": True, "min_length": 6, "max_length": 16,
            },
        }

        def validate(self, data):
            """验证客户端数据"""
            # 手机号格式验证
            mobile = data.get("mobile", None)
            if not re.match("^1[3-9]\d{9}$", mobile):
                raise serializers.ValidationError(detail="手机号格式不正确!", code="mobile")

            # 密码和确认密码
            password = data.get("password")
            re_password = data.get("re_password")
            if password != re_password:
                raise serializers.ValidationError(detail="密码和确认密码不一致!", code="password")

            # 手机号是否已注册
            try:
                User.objects.get(mobile=mobile)
                raise serializers.ValidationError(detail="手机号已注册!")
            except User.DoesNotExist:
                pass

            # todo 验证防水墙验证码
            api = TencentCloudAPI()
            result = api.captcha(
                data.get("ticket"),
                data.get("randstr"),
                self.context['request']._request.META.get("REMOTE_ADDR"), # 客户端IP
            )

            if not result:
                raise serializers.ValidationError(detail="滑块验证码校验失败!")

            # todo 验证短信验证码

            return data

    def create(self, validated_data):
        """保存用户信息,完成注册"""
        mobile = validated_data.get("mobile")
        password = validated_data.get("password")

        user = User.objects.create_user(
            username=mobile,
            mobile=mobile,
            avatar=constants.DEFAULT_USER_AVATAR,
            password=password,
        )

        # 注册成功以后,免登陆
        jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
        jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
        payload = jwt_payload_handler(user)

        user.token = jwt_encode_handler(payload)

        return user

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "注册功能实现流程-保存用户注册信息!"
git push

注册功能添加短信验证码

接下来,我们把注册过程中一些注册信息(例如:短信验证码)缓存到redis数据库中。

在django集成redis缓存功能的文档:https://django-redis-chs.readthedocs.io/zh_CN/latest/#

确认settings.dev.py配置中添加了存储短信验证码的配置项,代码:

# redis configration
# 设置redis缓存
CACHES = {
    # 默认缓存
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        # 项目上线时,需要调整这里的路径
        # "LOCATION": "redis://:密码@IP地址:端口/库编号",
        "LOCATION": "redis://:@127.0.0.1:6379/0",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 10},  # 连接池
        }
    },
    # 提供给admin运营站点的session存储
    "session": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 10},
        }
    },
    # 提供存储短信验证码
    "sms_code": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/2",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 10},
        }
    }
}

# 设置用户登录admin站点时,记录登录状态的session保存到redis缓存中
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
# 设置session保存的位置对应的缓存配置项
SESSION_CACHE_ALIAS = "session"


使用云通讯发送短信

官网:https://www.yuntongxun.com/

在登录后的平台控制台下获取以下信息:

ACCOUNT SID:8a216da863f8e6c20164139687e80c1b
AUTH TOKEN : 6dd01b2b60104b3dbc88b2b74158bac6
AppID(默认):8a216da863f8e6c20164139688400c21
Rest URL(生产): https://app.cloopen.com:8883

image-20220904091040611

在开发过程中,为了节约发送短信的成本,可以把自己的或者同事的手机加入到测试号码中.

image-20220904091059624

查看发送短信的api接口文档。

image-20220904091122646

后端生成短信验证码

安装云通讯的短信SDK扩展模块,终端执行命令:

pip install ronglian_sms_sdk

封装容联云的短信发送功能,luffycityapi/utils/ronglianyunapi,代码:

import json
from ronglian_sms_sdk import SmsSDK
from django.conf import settings
def send_sms(tid, mobile, datas):
    """
    发送短信
    @params tid: 模板ID,默认测试使用1
    @params mobile: 接收短信的手机号,多个手机号使用都逗号隔开
            单个号码: mobile="13312345678"
            多个号码: mobile="13312345678,13312345679,...."
    @params datas: 短信模板的参数列表
            例如短信模板为: 【云通讯】您的验证码是{1},请于{2}分钟内正确输入。
            则datas=("123456",5,)
    """
    ronglianyun = settings.RONGLIANYUN
    sdk = SmsSDK(ronglianyun.get("accId"), ronglianyun.get("accToken"), ronglianyun.get("appId"))
    resp = sdk.sendMessage(tid, mobile, datas)
    response = json.loads(resp)
    print(response, type(response))
    return response.get("statusCode") == "000000"

把容联云的配置信息,填写到配置文件中,settings.dev,代码:

# 容联云短信
RONGLIANYUN = {
    "accId": '8a216da863f8e6c20164139687e80c1b',
    "accToken": '6dd01b2b60104b3dbc88b2b74158bac6',
    "appId": '8a216da863f8e6c20164139688400c21',
    "reg_tid": 1,      # 注册短信验证码的模板ID
    "sms_expire": 300, # 短信有效期,单位:秒(s)
    "sms_interval": 60,# 短信发送的冷却时间,单位:秒(s)
}

视图,users/views.py,代码:

from rest_framework_jwt.views import ObtainJSONWebToken
from luffycityapi.utils.tencentcloudapi import TencentCloudAPI, TencentCloudSDKException
from rest_framework.response import Response
from rest_framework import status


class LoginAPIView(ObtainJSONWebToken):
    """用户登录视图"""
    def post(self, request, *args, **kwargs):
        # 校验用户操作验证码成功以后的ticket临时票据
        try:
            api = TencentCloudAPI()
            result = api.captcha(
                request.data.get("ticket"),
                request.data.get("randstr"),
                request._request.META.get("REMOTE_ADDR"), # 客户端IP
            )

            if result:
                # 验证通过
                print("验证通过")
                # 登录实现代码,调用父类实现的登录视图方法
                return super().post(request, *args, **kwargs)

            else:
                # 如果返回值不是True,则表示验证失败
                raise TencentCloudSDKException

        except TencentCloudSDKException as err:
            return Response({"errmsg": "验证码校验失败!"}, status=status.HTTP_400_BAD_REQUEST)


from rest_framework.views import APIView
from .models import User


class MobileAPIView(APIView):
    def get(self, request, mobile):
        """
        校验手机号是否已注册
        :param request:
        :param mobile: 手机号
        :return:
        """
        try:
            User.objects.get(mobile=mobile)
            return Response({"errmsg": "当前手机号已注册"}, status=status.HTTP_400_BAD_REQUEST)
        except User.DoesNotExist:
            # 如果查不到该手机号的注册记录,则证明手机号可以注册使用
            return Response({"errmsg": "OK"}, status=status.HTTP_200_OK)


from .serializers import UserRegisterModelSerializer
from rest_framework.generics import CreateAPIView


class UserAPIView(CreateAPIView):
    """用户视图"""
    queryset = User.objects.all()
    serializer_class = UserRegisterModelSerializer



import random
from django_redis import get_redis_connection
from django.conf import settings
from ronglianyunapi import send_sms
"""
/users/sms/(?P<mobile>1[3-9]\d{9})
"""
class SMSAPIView(APIView):
    """
    SMS短信接口视图
    """
    def get(self, request, mobile):
        """发送短信验证码"""
        redis = get_redis_connection("sms_code")
        # 判断手机短信是否处于发送冷却中[60秒只能发送一条]
        interval = redis.ttl(f"interval_{mobile}")  # 通过ttl方法可以获取保存在redis中的变量的剩余有效期
        if interval != -2:
            return Response(
                {"errmsg": f"短信发送过于频繁,请{interval}秒后再次点击获取!"},
                status=status.HTTP_400_BAD_REQUEST
            )

        # 基于随机数生成短信验证码
        # code = "%06d" % random.randint(0, 999999)
        code = f"{random.randint(0, 999999):06d}"
        # 获取短信有效期的时间
        time = settings.RONGLIANYUN.get("sms_expire")
        # 短信发送间隔时间
        sms_interval = settings.RONGLIANYUN["sms_interval"]
        # 调用第三方sdk发送短信
        send_sms(settings.RONGLIANYUN.get("reg_tid"), mobile, datas=(code, time // 60))

        # 记录code到redis中,并以time作为有效期
        # 使用redis提供的管道对象pipeline来优化redis的写入操作[添加/修改/删除]
        pipe = redis.pipeline()
        pipe.multi()  # 开启事务
        pipe.setex(f"sms_{mobile}", time, code)
        pipe.setex(f"interval_{mobile}", sms_interval, "_")
        pipe.execute()  # 提交事务,同时把暂存在pipeline的数据一次性提交给redis

        return Response({"errmsg": "OK"}, status=status.HTTP_200_OK)

路由,users/urls.py,代码:

from django.urls import path, re_path
from . import views

urlpatterns = [
    path("login/", views.LoginAPIView.as_view(), name="login"),
    re_path(r"^mobile/(?P<mobile>1[3-9]\d{9})/$", views.MobileAPIView.as_view()),
    path("register/", views.UserAPIView.as_view()),
    re_path(r"^sms/(?P<mobile>1[3-9]\d{9})/$", views.SMSAPIView.as_view()),
]

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "注册功能实现流程-服务端提供短信发送API接口!"
git push

客户端请求发送短信

views/Register.vue,注册页面绑定点击发送短信的方法,代码:

<el-button id="get_code" type="primary" @click="send_sms">{{user.sms_btn_text}}</el-button>

<template>
    <div class="login box">
        <img src="http://180.76.102.130//static/img/bg.jpg" alt="">
        <div class="login">
            <div class="login-title">
                <img src="../assets/logo.svg" alt="">
                <p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
            </div>
            <div class="login_box">
                <div class="title">
                    <span class="active">用户注册</span>
                </div>
                <div class="inp">
                    <input v-model="user.mobile" type="text" placeholder="手机号码" class="user">
                    <input v-model="user.password" type="password" placeholder="登录密码" class="user">
                    <input v-model="user.re_password" type="password" placeholder="确认密码" class="user">
                    <input v-model="user.code"  type="text" class="code" placeholder="短信验证码">
                    <el-button id="get_code" type="primary" @click="send_sms">{{user.sms_btn_text}}</el-button>
                    <button class="login_btn" @click="show_captcha">注册</button>
                    <p class="go_login" >已有账号 <router-link to="/login">立即登录</router-link></p>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
    import {reactive, defineEmits, watch} from "vue"
    import { ElMessage } from 'element-plus'
    import {useStore} from "vuex"
    import "../utils/TCaptcha"
    import user from "../api/user";
    import settings from "../settings";
    import {useRouter} from 'vue-router'
    const router = useRouter()

    const store = useStore()


    // 显示验证码
    const show_captcha = ()=>{
        var captcha1 = new TencentCaptcha(settings.captcha_app_id, (res)=>{
            // 接收验证结果的回调函数
            /* res(验证成功) = {ret: 0, ticket: "String", randstr: "String"}
               res(客户端出现异常错误 仍返回可用票据) = {ret: 0, ticket: "String", randstr: "String", errorCode: Number, errorMessage: "String"}
               res(用户主动关闭验证码)= {ret: 2}
            */
            console.log(res);
            // 调用登录处理
            register_handler(res);
        });
        captcha1.show(); // 显示验证码
    }

    // 监听数据mobile是否发生变化
    watch(()=>user.mobile, (mobile, prevMobile) => {
        if(/1[3-9]\d{9}/.test(user.mobile)){
            // 发送ajax验证手机号是否已经注册
            user.check_mobile().catch(error=>{
                ElMessage.error(error.response.data.errmsg);
            })
        }
    })


    // 注册处理
    const register_handler=(res)=>{
        if (!user.mobile){
            ElMessage.error('手机号为空');
            return false
        }
        if (!/1[3-9]\d{9}/.test(user.mobile)){
            // 校验手机格式
            ElMessage.error('手机格式都不对,你想造反吗?');
            return false
        }

        if (user.password.length < 6 || user.password.length > 16) {
            ElMessage.error('错了哦,密码必须在6~16个字符之间!');
            return false
        }

        if (user.password !== user.re_password) {
            ElMessage.error('错了哦,密码和确认密码不一致!');
            return false
        }

        if (!user.code){
            ElMessage.error('请输入验证码!');
            return false
        }


        // 发送请求
        user.register({
            // 验证码通过的票据信息
            ticket: res.ticket,
            randstr: res.randstr,
        }).then(response => {
            alert(123)
            // 保存token,并根据用户的选择,是否记住密码
            localStorage.removeItem("token");
            sessionStorage.removeItem("token");

            // 默认不需要记住登录
            sessionStorage.token = response.data.token;

            // vuex存储用户登录信息
            let payload = response.data.token.split(".")[1]  // 载荷
            let payload_data = JSON.parse(atob(payload)) // 用户信息
            store.commit("login", payload_data)
            // 清空表单信息
            user.mobile = ""
            user.password = ""
            user.re_password = ""
            user.code = ""
            user.remember = false
            //  成功提示
            ElMessage.success("注册成功!");
            // 路由跳转到首页
            router.push("/");
        }).catch(error=>{
            console.log(error.response.data.non_field_errors);
            // ElMessage.error(error.response.data.non_field_errors);   // 这里展示报错,后期优化
        })


    }

    // 发送短信验证码
    const send_sms=()=>{
        if (!/1[3-9]\d{9}/.test(user.mobile)) {
            ElMessage.error("手机号格式有误!");
            return false
        }

        // 判断是否处于短信发送的冷却状态
        if (user.is_send) {
            ElMessage.error("短信已经发了,别再点我了!");
            return false;
        }

        let time=user.sms_interval;
        // 发送短信请求
        user.get_sms_code().then(response=>{
            ElMessage.success('短信正在发送,请留意你的手机哦宝');
            // 发送短信后进入冷却状态
            user.is_send=true;
            // 冷却倒计时
            clearInterval(user.interval);
            user.interval=setInterval(()=>{
                if(time<1){
                    user.is_send = false
                    user.sms_btn_text = '点击获取验证码'
                }else {
                    time-=1;
                    user.sms_btn_text=`${time}秒后重新获取`
                }
            }, 1000)
        }).catch(error=>{
            ElMessage.error(error.response.data.errmsg);
            time = error.response.data.interval;
            // 冷却倒计时
            clearInterval(user.interval);
            user.interval=setInterval(()=>{
                if(time<1){
                    // 退出冷却状态
                    user.is_send = false
                    user.sms_btn_text = '点击获取验证码'
                }else {
                    time-=1;
                    user.sms_btn_text = `${time}秒后重新获取`
                }
            }, 1000)
        })
    }


</script>

src/api/user.js,代码:

import http from "../utils/http"
import {reactive, ref} from "vue"

const user = reactive({
    login_type: 0, // 登录方式,0,密码登录,1,短信登录
    account: "",  // 登录账号/手机号/邮箱
    password: "", // 登录密码
    remember: false, // 是否记住登录状态
    re_password: "",// 确认密码
    code: "", // 短信验证码
    sms_btn_text: "点击获取验证码", // 短信按钮提示
    is_send: false,  // 短信发送的标记
    sms_interval: 60,// 间隔时间
    interval: null,  // 定时器的标记
    login(res){
        // 用户登录
        return http.post("/users/login/", {
            "ticket": res.ticket,
            "randstr": res.randstr,
            "username": this.account,
            "password": this.password,
        })
    },
    check_mobile(){
        // 验证手机号
        return http.get(`/users/mobile/${this.mobile}/`)
    },
    register(data){
        data.mobile = this.mobile
        data.re_password = this.mobile
        data.password = this.password
        data.sms_code = this.code
        // 用户注册请求
        return http.post("/users/register/", data)
    },
    get_sms_code(){
        return http.get(`/users/sms/${this.mobile}/`)
    }
})

export default user;

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "注册功能实现流程-客户端请求发送短信并实现短信倒计时冷却提示!"
git push

服务端校验客户端提交的验证码

users/serializers.py,代码:

import re, constants
from rest_framework import serializers
from rest_framework_jwt.settings import api_settings
from .models import User
from luffycityapi.utils.tencentcloudapi import TencentCloudAPI, TencentCloudSDKException
from django_redis import get_redis_connection


class UserRegisterModelSerializer(serializers.ModelSerializer):
    """
    用户注册的序列化器
    """
    re_password = serializers.CharField(required=True, write_only=True, help_text="确认密码")
    sms_code = serializers.CharField(min_length=4, max_length=6, required=True, write_only=True, help_text="短信验证码")
    token = serializers.CharField(read_only=True)
    ticket = serializers.CharField(required=True, write_only=True, help_text="滑块验证码的临时凭证")
    randstr = serializers.CharField(required=True, write_only=True, help_text="滑块验证码的随机字符串")

    class Meta:
        model = User
        fields = ["mobile", "password", "re_password", "sms_code", "token", "ticket", "randstr"]
        extra_kwargs = {
            "mobile": {
                "required": True, "write_only": True
            },
            "password": {
                "required": True, "write_only": True, "min_length": 6, "max_length": 16,
            },
        }

    def validate(self, data):
        """验证客户端数据"""
        # 手机号格式验证
        mobile = data.get("mobile", None)
        if not re.match("^1[3-9]\d{9}$", mobile):
            raise serializers.ValidationError(detail="手机号格式不正确!", code="mobile")

        # 密码和确认密码
        password = data.get("password")
        re_password = data.get("re_password")
        if password != re_password:
            raise serializers.ValidationError(detail="密码和确认密码不一致!", code="password")

        # 手机号是否已注册
        try:
            User.objects.get(mobile=mobile)
            raise serializers.ValidationError(detail="手机号已注册!")
        except User.DoesNotExist:
            pass

        # 验证防水墙验证码
        api = TencentCloudAPI()
        result = api.captcha(
            data.get("ticket"),
            data.get("randstr"),
            self.context['request']._request.META.get("REMOTE_ADDR"), # 客户端IP
        )

        if not result:
            raise serializers.ValidationError(detail="滑块验证码校验失败!")

        # 验证短信验证码
        # 从redis中提取短信
        redis = get_redis_connection("sms_code")
        code = redis.get(f"sms_{mobile}")
        if code is None:
            """获取不到验证码,则表示验证码已经过期了"""
            raise serializers.ValidationError(detail="验证码失效或已过期!", code="sms_code")

        # 从redis提取的数据,字符串都是bytes类型,所以decode
        if code.decode() != data.get("sms_code"):
            raise serializers.ValidationError(detail="短信验证码错误!", code="sms_code")
        print(f"code={code.decode()}, sms_code={data.get('sms_code')}")
        # 删除掉redis中的短信,后续不管用户是否注册成功,至少当前这条短信验证码已经没有用处了
        redis.delete(f"sms_{mobile}")

        return data

    def create(self, validated_data):
        """保存用户信息,完成注册"""
        mobile = validated_data.get("mobile")
        password = validated_data.get("password")

        user = User.objects.create_user(
            username=mobile,
            mobile=mobile,
            avatar=constants.DEFAULT_USER_AVATAR,
            password=password,
        )

        # 注册成功以后,免登陆
        jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
        jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
        payload = jwt_payload_handler(user)

        user.token = jwt_encode_handler(payload)

        return user

对于生成jwt token的这段代码,我们也可以封装成一个工具函数,方便后面其他地方如果还有使用,可以复用代码、

users/serializers.py,代码:

import re, constants
from rest_framework import serializers
from django_redis import get_redis_connection

from tencentcloudapi import TencentCloudAPI
from .models import User
from authenticate import generate_jwt_token


class UserRegisterModelSerializer(serializers.ModelSerializer):
    """
    用户注册的序列化器
    """
    re_password = serializers.CharField(required=True, write_only=True)
    sms_code = serializers.CharField(min_length=4, max_length=6, required=True, write_only=True)
    token = serializers.CharField(read_only=True)
    ticket = serializers.CharField(write_only=True)
    randstr = serializers.CharField(write_only=True)

    class Meta:
        model = User
        fields = ["mobile", "password", "re_password", "sms_code", "token", "ticket", "randstr"]
        extra_kwargs = {
            "mobile": {
                "required": True, "write_only": True
            },
            "password": {
                "required": True, "write_only": True, "min_length": 6, "max_length": 16,
            },
        }

    def validate(self, data):
        """验证客户端数据"""
        # 手机号格式验证
        mobile = data.get("mobile", None)
        if not re.match("^1[3-9]\d{9}$", mobile):
            raise serializers.ValidationError(detail="手机号格式不正确!",code="mobile")

        # 密码和确认密码
        password = data.get("password")
        re_password = data.get("re_password")
        if password != re_password:
            raise serializers.ValidationError(detail="密码和确认密码不一致!", code="password")

        # 手机号是否已注册
        try:
            User.objects.get(mobile=mobile)
            raise serializers.ValidationError(detail="手机号已注册!", code="mobile")
        except User.DoesNotExist:
            pass

        # 验证腾讯云的滑动验证码
        api = TencentCloudAPI()
        # 视图中的request对象,在序列化器中使用 self.context["request"]
        result = api.captcha(
            data.get("ticket"),
            data.get("randstr"),
            self.context["request"]._request.META.get("REMOTE_ADDR"),
        )

        if not result:
            raise serializers.ValidationError(detail="滑动验证码校验失败!", code="verify")

        # 验证短信验证码
        redis = get_redis_connection("sms_code")
        code = redis.get(f"sms_{mobile}")
        if code is None:
            """获取不多验证码,则表示验证码已经过期了"""
            raise serializers.ValidationError(detail="验证码失效或已过期!", code="sms_code")

        # 从redis提取的数据,字符串都是bytes类型,所以decode
        if code.decode() != data.get("sms_code"):
            raise serializers.ValidationError(detail="短信验证码错误!", code="sms_code")

        # 删除掉redis中的短信,后续不管用户是否注册成功,至少当前这条短信验证码已经没有用处了
        redis.delete(f"sms_{mobile}")

        return data

    def create(self, validated_data):
        """保存用户信息,完成注册"""
        mobile = validated_data.get("mobile")
        password = validated_data.get("password")

        user = User.objects.create_user(
            username=mobile,
            mobile=mobile,
            avatar=constants.DEFAULT_USER_AVATAR,
            password=password,
        )

        # 注册成功以后,免登陆, 生成 jwt token
        user.token = generate_jwt_token(user)
        return user

luffycityapi/utils/authenticate.py,代码:

from rest_framework_jwt.settings import api_settings


def generate_jwt_token(user):
    """
    生成jwt token
    @params user: 用户模型实例对象
    """
    jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
    jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
    payload = jwt_payload_handler(user)
    return jwt_encode_handler(payload)

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "注册功能实现流程-服务端校验短信验证码!"
git push

redis

关系型数据库(RMDBS)

数据库中表与表的数据之间存在某种关联的内在关系,因为这种关系,所以我们称这种数据库为关系型数据库。

典型:Mysql/MariaDB、postgreSQL、Oracle、SQLServer、DB2、Access、SQLlite3

特点:

  1. 全部使用SQL(结构化查询语言)进行数据库操作。
  2. 都存在主外键关系,表,等等关系特征。
  3. 大部分都支持各种关系型的数据库的特性:存储过程、触发器、视图、临时表、模式、函数

非关系型数据库(NoSQL)

NOSQL:not only sql,泛指非关系型数据库。

泛指那些不使用SQL语句进行数据操作的数据库,所有数据库中只要不使用SQL语句的都是非关系型数据库。

典型:Redis、MongoDB、hbase、 Hadoop、elasticsearch、图数据库。。。。

特点:

  1. 每一款都不一样。用途不一致,功能不一致,各有各的操作方式。
  2. 基本不支持主外键关系,也没有事务的概念。(MongoDB号称最接近关系型数据库的,所以MongoDB有这些的。)

Redis(Remote Dictionary Server ,远程字典服务) 是一个高性能的key-value数据格式的内存数据库,是NoSQL数据库。redis的出现主要是为了替代早起的Memcache缓存系统的。
内存型(数据存放在内存中)的非关系型(nosql)key-value(键值存储)数据库,
支持数据的持久化(基于RDB和AOF,注: 数据持久化时将数据存放到文件中,每次启动redis之后会先将文
件中数据加载到内存),经常用来做缓存、数据共享、购物车、消息队列、计数器、限流等。(最基本的就是缓存一些经常用到的数据,提高读写速度)。

redis的官方只提供了linux版本的redis,window系统的redis是微软团队根据官方的linux版本高仿的。

官方原版: https://redis.io/

中文官网:http://www.redis.cn

3.1 redis下载和安装

下载地址: https://github.com/MicrosoftArchive/redis/releases

image-20220904091223246 image-20220904091316831 image-20220904091422346 image-20220904091523900 image-20220904091612402

使用以下命令启动redis服务端

redis-server C:/tool/redis/redis.windows.conf

image-20220904091700207

关闭上面这个cmd窗口就关闭redis服务器服务了。

redis作为windows服务启动方式

redis-server --service-install redis.windows.conf

启动服务:redis-server --service-start
停止服务:redis-server --service-stop

# 如果连接操作redis,可以在终端下,使用以下命令:
redis-cli

ubuntu下安装:

安装命令:sudo apt-get install -y redis-server
卸载命令:sudo apt-get purge --auto-remove redis-server 
关闭命令:sudo service redis-server stop 
开启命令:sudo service redis-server start 
重启命令:sudo service redis-server restart
配置文件:/etc/redis/redis.conf

3.2 redis的配置

sudo cat /etc/redis/redis.conf	# 也可以是/etc/redis.conf

redis 安装成功以后,window下的配置文件保存在软件 安装目录下,如果是mac或者linux,则默认安装/etc/redis/redis.conf

3.2.1 redis的核心配置选项

redis与mysql类似,也是C/S架构的软件,所以存在客户端和服务端,默认的redis的服务端时redis-server,默认提供的redis客户端是redis-cli。

绑定ip:如果需要远程访问,可将此注释,或绑定1个真实ip

bind 127.0.0.1

端⼝,默认为6379

port 6379

是否以守护进程运行 [windows下需要设置]

  • 如果以守护进程运行,则不会在命令阻塞,类似于服务
  • 如果以守护进程运行,则当前终端被阻塞
  • 设置为yes表示守护进程,设置为no表示⾮守护进程
  • 推荐设置为yes
daemonize yes

RDB持久化的备份文件

dbfilename dump.rdb

RDB持久化数据库数据文件的所在目录

dir /var/lib/redis

日志等级和日期文件的所在目录

loglevel notice
logfile /var/log/redis/redis-server.log

进程ID文件

pidfile /var/run/redis/redis-server.pid

数据库,默认有16个,数据名是不能自定义的,只能是0-15之间,当然这个15是数据库数量-1

database 16

redis的登录密码,生产阶段打开,开发阶段避免麻烦,一般都是注释的。

# requirepass foobared

注意:开启了以后,redis-cli终端下使用 auth 密码来认证登录。

image-20220904091720583

RDB持久化的备份频率,文件格式是二进制

save 900 1
save 300 10
save 60 10000

RDB持久化备份文件的文件名和路径

dbfilename dump.rdb
dir /var/lib/redis

AOF持久化的开启配置项,默认是no关闭的。备份的文件格式:文本格式

appendonly no

AOF持久化的备份文件,存储路径与RDB备份文件路径是一致的。

appendfilename "appendonly.aof"

AOF持久化备份的频率[时间]

# appendfsync always   # 每次修改键对应数据时都会触发一次aof
appendfsync everysec    # 每秒备份,工作中最常用。
# appendfsync no

一主二从三哨兵

3.2.2 Redis的使用

redis是一款基于CS架构的数据库,所以redis有客户端redis-cli,也有服务端redis-server。

其中,客户端可以使用python等编程语言,也可以终端下使用命令行工具管理redis数据库,甚至可以安装一些别人开发的界面工具,例如:RDM。

1553246999266

redis-cli客户端连接服务器:

# redis-cli -h `redis服务器ip` -p `redis服务器port`
redis-cli -h 10.16.244.3 -p 6379

3.3 redis数据类型

redis就是一个全局的大字典,key就是数据的唯一标识符。根据key对应的值不同,可以划分成5个基本数据类型。
1. string类型:
	字符串类型,是 Redis 中最为基础的数据存储类型,它在 Redis 中是二进制安全的,也就是byte类型。
	单个数据的最大容量是512M。
		key: b"值"
	
2. hash类型:
	哈希类型,用于存储对象/字典,对象/字典的结构为键值对。key、域、值的类型都为string。域在同一个hash中是唯一的。
		key:{
            域(属性): 值,
            域:值,            
            域:值,
            域:值,
            ...
		}
3. list类型:
	列表类型,它的子成员类型为string。
		key: [ 值1,值2, 值3..... ]
4. set类型:
	无序集合,它的子成员类型为string类型,元素唯一不重复,没有修改操作。
		key: {值1, 值4, 值3, ...., 值5}

5. zset类型(sortedSet):
	有序集合,它的子成员值的类型为string类型,元素唯一不重复,没有修改操作。权重值1从小到大排列。
		key: {
			值1 权重值1(数字);
			值2 权重值2;
			值3 权重值3;
			值4 权重值4;
		}

redis中的所有数据操作,如果设置的键不存在则为添加,如果设置的键已经存在则修改

3.4 string

设置键值

set 设置的数据没有额外操作时,是不会过期的。

set key value

设置键为name值为xiaoming的数据

set name xiaoming

设置一个键,当键不存在时才能设置成功,用于一个变量只能被设置一次的情况。

setnx  key  value

一般用于给数据加锁

127.0.0.1:6379> setnx goods_1 101
(integer) 1
127.0.0.1:6379> setnx goods_1 102
(integer) 0  # 表示设置不成功

127.0.0.1:6379> del goods_1
(integer) 1
127.0.0.1:6379> setnx goods_1 102
(integer) 1


设置键值的过期时间

redis中可以对一切的数据进行设置有效期。

以秒为单位

setex key seconds value

设置键为name值为xiaoming过期时间为20秒的数据

setex name 20 xiaoming

实用set设置的数据会永久存储在redis中,如果实用setex对同名的key进行设置,可以把永久有效的数据设置为有时间的临时数据。

设置多个键值

mset key1 value1 key2 value2 ...

例3:设置键为a1值为python、键为a2值为java、键为a3值为c

mset a1 python a2 java a3 c

字符串拼接值

append key value

向键为a1中拼接值haha

append title "我的"
append title "redis"
append title "学习之路"


根据键获取值

根据键获取值,如果不存在此键则返回nil,相当于python的None

get key

获取键name的值

get name

根据多个键获取多个值

mget key1 key2 ...

获取键a1、a2、a3的值

mget a1 a2 a3

自增自减

set id 1
incr id   # 相当于id+1
get id    # 2
incr id   # 相当于id+1
get id    # 3


set goods_id_1 10
decr goods_id_1  # 相当于 id-1
get goods_id_1    # 8
decr goods_id_1   # 相当于id-1
get goods_id_1    # 8


获取字符串的长度

set name xiaoming
strlen name  # 8 

比特流操作

签到记录

8位就是1byte ==> 0010 0100

BITCOUNT   # 统计字符串被设置为1的bit数.
BITPOS     # 返回字符串里面第一个被设置为1或者0的bit位。
SETBIT     # 设置一个bit数据的值 
GETBIT     # 获取一个bit数据的值


SETBIT mykey 7 1  # 00000001 在第七位设置1
# (integer) 0
getbit mykey 7	# 看得到第七位数字是0还是1
# (integer) 1
SETBIT mykey 4 1	# 在第四位设置1
# 00001001
SETBIT mykey 15 1	# 在第十五位设置1
# 0000100100000001
BITCOUNT mykey	# 总共1有多少个
# (integer) 3
BITPOS mykey 1	# 第一个被设置为1的是哪一位
# (integer) 4


3.5 key操作

redis中所有的数据都是通过key(键)来进行操作,这里我们学习一下关于任何数据类型都通用的命令。

查找键

参数支持简单的正则表达式

keys pattern


查看所有键

keys *


例子:

# 查看名称中包含`a`的键
keys *a*
# 查看以a开头的键
keys a*
# 查看以a结尾的键
keys *a
# 数字结尾
keys *[1-9]


判断键是否存在

如果存在返回1,不存在返回0

exists key1


判断键title是否存在

exists title


查看键的数据类型

type key

# string    字符串
# hash      哈希类型
# list      列表类型
# set       无序集合
# zset      有序集合


查看键的值类型

type name
# string
sadd member_list xiaoming xiaohong xiaobai
# (integer) 3
type member_list
# set
hset user_1 name xiaobai age 17 sex 1
# (integer) 3
type user_1
# hash
lpush brothers zhangfei guangyu liubei xiaohei
# (integer) 4
type brothers
# list

zadd achievements 61 xiaoming 62 xiaohong 83 xiaobai  78 xiaohei 87 xiaohui 99 xiaolong
# (integer) 6
type achievements
# zset


删除键以及键对应的值

del key1 key2 ...


查看键的有效期

ttl key

# 结果结果是秒作为单位的整数
# -1 表示永不过期
# -2 表示当前数据已经过期,查看一个不存在的数据的有效期就是-2


设置key的有效期

给已有的数据重新设置有效期,redis中所有的数据都可以通过expire来设置它的有效期。有效期到了,数据就被删除。

expire key seconds


清空所有key

慎用,一旦执行,则redis所有数据库0~15的全部key都会被清除

flushall


key重命名

rename  oldkey newkey


把name重命名为username

set name xioaming
rename name username
get username


select切换数据库

redis的配置文件中,默认有0~15之间的16个数据库,默认操作的就是0号数据库
select <数据库ID>


操作效果:

# 默认处于0号库
127.0.0.1:6379> select 1
OK
# 这是在1号库
127.0.0.1:6379[1]> set name xiaoming
OK
127.0.0.1:6379[1]> select 2
OK
# 这是在2号库
127.0.0.1:6379[2]> set name xiaohei
OK


auth认证

在redis中,如果配置了requirepass登录密码,则进入redis-cli的操作数据之前,必须要进行登录认证。
注意:在redis6.0以后,redis新增了用户名和密码登录,可以选择使用,也可以选择不适用,默认关闭的。
      在redis6.0以前,redis只可以在配置文件中,可以选择开启密码认证,也可以关闭密码认证,默认关闭的。
      
redis-cli
127.0.0.1:6379> auth <密码>
OK  # 认证通过


3.6 hash

类似python的字典,但是成员只能是string,专门用于结构化的数据信息。

结构:

键key:{
   	域field:值value
}


设置指定键的属性/域

设置指定键的单个属性,如果key不存在,则表示创建一个key对应的哈希数据,如果key存在,而field不存在,则表示当前哈希数据新增一个成员,如果field存在,则表示修改哈希对应的对应成员的值。

hset key field value
# redis5.0版本以后,hset可以一次性设置多个哈希的成员数据
hset key field1 value1 field2 value2 field3 value3 ...


设置键 user_1的属性namexiaoming

127.0.0.1:6379> hset user_1 name xiaoming   # user_1没有会自动创建
(integer) 1
127.0.0.1:6379> hset user_1 name xiaohei    # user_1中重复的属性会被修改
(integer) 0
127.0.0.1:6379> hset user_1 age 16          # user_1中重复的属性会被新增
(integer) 1
127.0.0.1:6379> hset user:1 name xiaohui    # user:1会在redis界面操作中以:作为目录分隔符
(integer) 1
127.0.0.1:6379> hset user:1 age 15
(integer) 1
127.0.0.1:6379> hset user:2 name xiaohong age 16  # 一次性添加或修改多个属性


设置指定键的多个属性[hmset已经慢慢淘汰了,hset就可以实现多个属性]

hmset key field1 value1 field2 value2 ...

设置键user_1的属性namexiaohong、属性age17,属性sex为1

hmset user:3 name xiaohong age 17 sex 1

获取指定键的域/属性的值

获取指定键所有的域/属性

hkeys key

获取键user的所有域/属性

127.0.0.1:6379> hkeys user:2
1) "name"
2) "age"
127.0.0.1:6379> hkeys user:3
1) "name"
2) "age"
3) "sex"


获取指定键的单个域/属性的值

hget key field


获取键user:3属性name的值

127.0.0.1:6379> hget user:3 name
"xiaohong"


获取指定键的多个域/属性的值

hmget key field1 field2 ...

获取键user:2属性nameage的值

127.0.0.1:6379> hmget user:2 name age
1) "xiaohong"
2) "16"

获取指定键的所有值

hvals key

获取指定键的所有域值对

127.0.0.1:6379> hvals user:3
1) "xiaohong"
2) "17"
3) "1"

删除指定键的域/属性

hdel key field1 field2 ...

删除键user:3的属性sex/age/name,当键中的hash数据没有任何属性,则当前键会被redis删除

hdel user:3 sex age name


判断指定属性/域是否存在于当前键对应的hash中

hexists   key  field


判断user:2中是否存在age属性

127.0.0.1:6379> hexists user:3 age
(integer) 0
127.0.0.1:6379> hexists user:2 age
(integer) 1
127.0.0.1:6379> 


属性值自增自减

hincrby key field number


给user:2的age属性在原值基础上+/-10,然后在age现有值的基础上-2

# 按指定数值自增
127.0.0.1:6379> hincrby user:2 age 10
(integer) 77
127.0.0.1:6379> hincrby user:2 age 10
(integer) 87

# 按指定数值自减
127.0.0.1:6379> hincrby user:2 age -10
(integer) 77
127.0.0.1:6379> hincrby user:2 age -10


获取哈希的所有成员域值对

hgetall key

3.7 list

类似python的lis列表数据类型,但是redis中的list的子成员类型为string。

添加子成员

# 在左侧(前,上)添加一条或多条成员数据
lpush key value1 value2 ...

# 在右侧(后,下)添加一条或多条成员数据
rpush key value1 value2 ...

# 在指定元素的左边(前)/右边(后)插入一个或多个数据
linsert key before 指定成员 value1 value2 ....
linsert key after 指定成员 value1 value2 ....


从键为brother的列表左侧添加一个或多个数据liubei、guanyu、zhangfei

lpush brother liubei
# [liubei]
lpush brother guanyu zhangfei xiaoming
# [xiaoming,zhangfei,guanyu,liubei]


从键为brother的列表右侧添加一个或多个数据,xiaohong,xiaobai,xiaohui

rpush brother xiaohong
# [xiaoming,zhangfei,guanyu,liubei,xiaohong]
rpush brother xiaobai xiaohui
# [xiaoming,zhangfei,guanyu,liubei,xiaohong,xiaobai,xiaohui]


从key=brother,key=xiaohong的列表位置左侧添加一个数据,xiaoA,xiaoB

linsert brother before xiaohong xiaoA
# [xiaoming,zhangfei,guanyu,liubei,xiaoA,xiaohong,xiaobai,xiaohui]
linsert brother before xiaohong xiaoB
# [xiaoming,zhangfei,guanyu,liubei,xiaoA,xiaoB,xiaohong,xiaobai,xiaohui]


从key=brother,key=xiaohong的列表位置右侧添加一个数据,xiaoC,xiaoD

linsert brother after xiaohong xiaoC
# [xiaoming,zhangfei,guanyu,liubei,xiaoA,xiaohong,xiaoC,xiaobai,xiaohui]
linsert brother after xiaohong xiaoD
# [xiaoming,zhangfei,guanyu,liubei,xiaoA,xiaohong,xiaoD,xiaoC,xiaobai,xiaohui]


注意:当列表如果存在多个成员值一致的情况下,默认只识别第一个。

127.0.0.1:6379> linsert brother before xiaoA xiaohong
# [xiaoming,zhangfei,guanyu,liubei,xiaohong,xiaoA,xiaohong,xiaoD,xiaoC,xiaobai,xiaohui]
127.0.0.1:6379> linsert brother before xiaohong xiaoE
# [xiaoming,zhangfei,guanyu,liubei,xiaoE,xiaohong,xiaoA,xiaohong,xiaoD,xiaoC,xiaobai,xiaohui]
127.0.0.1:6379> linsert brother after xiaohong xiaoF
# [xiaoming,zhangfei,guanyu,liubei,xiaoE,xiaohong,xiaoF,xiaoA,xiaohong,xiaoD,xiaoC,xiaobai,xiaohui]


设置指定索引位置成员的值

lset key index value
# 注意:
# redis的列表也有索引,从左往右,从0开始,逐一递增,第1个元素下标为0
# 索引可以是负数,表示尾部开始计数,如`-1`表示最后1个元素


修改键为brother的列表中下标为4的元素值为xiaohongmao

lset brother 4 xiaohonghong


删除指定成员

lrem key count value

# 注意:
# count表示删除的数量,value表示要删除的成员。该命令默认表示将列表从左侧前count个value的元素移除
# count==0,表示删除列表所有值为value的成员
# count >0,表示删除列表左侧开始的前count个value成员
# count <0,表示删除列表右侧开始的前count个value成员


image-20220904091834868

获取列表成员

根据指定的索引获取成员的值

lindex key index


获取brother下标为2以及-2的成员

lindex brother 2
lindex brother -2


移除并获取列表的第一个成员或最后一个成员

lpop key  # 第一个成员出列
rpop key  # 最后一个成员出列


获取并移除brother中的第一个成员

lpop brother
# 开发中往往使用rpush和lpop实现队列的数据结构->实现入列和出列


获取列表的切片

闭区间[包括stop]

lrange key start stop

操作:

# 获取btother的全部成员
lrange brother 0 -1
# 获取brother的前2个成员
lrange brother 0 1

获取列表的长度

llen key

获取brother列表的成员个数

llen brother

3.8 set

类似python里面的set无序集合, 成员是字符串string,重点就是去重和无序。

添加元素

key不存在,则表示新建集合,如果存在则表示给对应集合新增成员。

sadd key member1 member2 ...

向键authors的集合中添加元素zhangsanlisiwangwu

sadd authors zhangsan sili wangwu

获取集合的所有的成员

smembers key

获取键authors的集合中所有元素

smembers authors

获取集合的长度

scard keys 

获取s2集合的长度

sadd s2 a c d e

127.0.0.1:6379> scard s2
(integer) 4

随机获取一个或多个元素

spop key [count=1]

# 注意:
# count为可选参数,不填则默认一个。被提取成员会从集合中被删除掉

随机获取s2集合的成员

sadd s2 a c d e

127.0.0.1:6379> spop s2 
"d"
127.0.0.1:6379> spop s2 
"c"


删除指定元素

srem key value

删除键authors的集合中元素wangwu

srem authors wangwu

交集、差集和并集

sinter  key1 key2 key3 ....    # 交集,比较多个集合中共同存在的成员
sdiff   key1 key2 key3 ....    # 差集,比较多个集合中不同的成员
sunion  key1 key2 key3 ....    # 并集,合并所有集合的成员,并去重
sadd user:1 1 2 3 4     # user:1 = {1,2,3,4}
sadd user:2 1 3 4 5     # user:2 = {1,3,4,5}
sadd user:3 1 3 5 6     # user:3 = {1,3,5,6}
sadd user:4 2 3 4       # user:4 = {2,3,4}

# 交集
127.0.0.1:6379> sinter user:1 user:2
1) "1"
2) "3"
3) "4"
127.0.0.1:6379> sinter user:1 user:3
1) "1"
2) "3"
127.0.0.1:6379> sinter user:1 user:4
1) "2"
2) "3"
3) "4"

127.0.0.1:6379> sinter user:2 user:4
1) "3"
2) "4"

# 并集
127.0.0.1:6379> sunion user:1 user:2 user:4
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"

# 差集
127.0.0.1:6379> sdiff user:2 user:3
1) "4"  # 此时可以给user:3推荐4

127.0.0.1:6379> sdiff user:3 user:2
1) "6"  # 此时可以给user:2推荐6

127.0.0.1:6379> sdiff user:1 user:3
1) "2"
2) "4"

3.9 zset

有序集合,去重并且根据score权重值来进行排序的。score从小到大排列。

添加成员

key如果不存在,则表示新建有序集合。

zadd key score1 member1 score2 member2 score3 member3 ....


设置榜单achievements,设置成绩和用户名作为achievements的成员

127.0.0.1:6379> zadd achievements 61 xiaoming 62 xiaohong 83 xiaobai  78 xiaohei 87 xiaohui 99 xiaolan
(integer) 6
127.0.0.1:6379> zadd achievements 85 xiaohuang 
(integer) 1
127.0.0.1:6379> zadd achievements 54 xiaoqing


给指定成员增加权重值

zincrby key score member

给achievements中xiaobai增加10分

127.0.0.1:6379> ZINCRBY achievements 10 xiaobai
"93

获取集合长度

zcard key

获取users的长度

zcard achievements

获取指定成员的权重值

zscore key member

获取users中xiaoming的成绩

127.0.0.1:6379> zscore achievements xiaobai
"93"
127.0.0.1:6379> zscore achievements xiaohong
"62"
127.0.0.1:6379> zscore achievements xiaoming
"61"

获取指定成员在集合中的排名

排名从0开始计算

srank key member      # score从小到大的排名
zrevrank key member   # score从大到小的排名

获取achievements中xiaohei的分数排名,从大到小

127.0.0.1:6379> zrevrank achievements xiaohei
(integer) 4

获取score在指定区间的所有成员数量

zcount key min max

获取achievements从0~60分之间的人数[闭区间]

127.0.0.1:6379> zadd achievements 60 xiaolv
(integer) 1
127.0.0.1:6379> zcount achievements 0 60
(integer) 2
127.0.0.1:6379> zcount achievements 54 60
(integer) 2

获取score在指定区间的所有成员

zrangebyscore key min max     # 按score进行从低往高排序获取指定score区间
zrevrangebyscore key min max  # 按score进行从高往低排序获取指定score区间
zrange key start stop         # 按scoer进行从低往高排序获取指定索引区间
zrevrange key start stop      # 按scoer进行从高往低排序获取指定索引区间

获取users中60-70之间的数据

127.0.0.1:6379> zrangebyscore achievements 60 90
1) "xiaolv"
2) "xiaoming"
3) "xiaohong"
4) "xiaohei"
5) "xiaohuang"
6) "xiaohui"
127.0.0.1:6379> zrangebyscore achievements 60 80
1) "xiaolv"
2) "xiaoming"
3) "xiaohong"
4) "xiaohei"

# 获取achievements中分数最低的3个数据
127.0.0.1:6379> zrange achievements 0 2
1) "xiaoqing"
2) "xiaolv"
3) "xiaoming"

# 获取achievements中分数最高的3个数据
127.0.0.1:6379> zrevrange achievements 0 2
1) "xiaolan"
2) "xiaobai"
3) "xiaohui"


删除成员

zrem key member1 member2 member3 ....

从achievements中删除xiaoming的数据

zrem achievements xiaoming

删除指定数量的成员

# 删除指定数量的成员,从最低score开始删除
zpopmin key [count]
# 删除指定数量的成员,从最高score开始删除
zpopmax key [count]


例子:

# 从achievements中提取并删除成绩最低的2个数据
127.0.0.1:6379> zpopmin achievements 2
1) "xiaoqing"
2) "54"
3) "xiaolv"
4) "60"


# 从achievements中提取并删除成绩最高的2个数据
127.0.0.1:6379> zpopmax achievements 2
1) "xiaolan"
2) "99"
3) "xiaobai"
4) "93"

3.10 各种数据类型在开发中的常用业务场景

针对各种数据类型它们的特性,使用场景如下:
字符串string: 用于保存一些项目中的普通数据,只要键值对的都可以保存,例如,保存 session/jwt,定时记录状态,倒计时、验证码、防灌水答案
哈希hash:用于保存项目中的一些对象结构/字典数据,但是不能保存多维的字典,例如,商城的购物车,文章信息,json结构数据
列表list:用于保存项目中的列表数据,但是也不能保存多维的列表,例如,消息队列,秒杀系统,排队,浏览历史
无序集合set: 用于保存项目中的一些不能重复的数据,可以用于过滤,例如,候选人名单, 作者名单,
有序集合zset:用于保存项目中一些不能重复,但是需要进行排序的数据, 例如:分数排行榜, 海选人排行榜,热搜排行,

开发中,redis常用的业务场景:

数据缓存、
分布式数据共享、
计数器、
限流、
位统计(用户打卡、签到)、
购物车、
消息队列、
抽奖奖品池、
排行榜单(搜索排名)、
用户关系记录[收藏、点赞、关注、好友、拉黑]、

开发中,针对redis的使用,python中一般常用的redis模块有:pyredis(同步),aioredis(异步)。

pip install py-redis
pip install aioredis

3.4 python操作redis

这2个模块提供给开发者的使用方式都是一致的。都是以redis命令作为函数名,命令后面的参数作为函数的参数。只有一个特殊:del,del在python属于关键字,所以改成delete即可。

基本使用

from redis import Redis, StrictRedis

if __name__ == '__main__':
    # 连接redis的写法有2种:
    # url="redis://:密码@IP:端口/数据库编号"
    redis = Redis.from_url(url="redis://:@127.0.0.1:6379/0")
    # redis = Redis(host="127.0.0.1", port=6379, password="", db=0)

    # # 字符串
    # # set name xiaoming
    # redis.set("name", "xiaoming")

    # # setex sms_13312345678 30 500021
    # mobile = 13312345678
    # redis.setex(f"sms_{mobile}", 30, "500021")
    #
    # # get name
    # ret = redis.get("name")
    # # redis中最基本的数据类型是字符串,但是这种字符串是bytes,所以对于python而言,读取出来的字符串数据还要decode才能使用
    # print(ret, ret.decode())

    # # 提取数据,键如果不存在,则返回结果为None
    # code_bytes = redis.get(f"sms_{mobile}")
    # print(code_bytes)
    # if code_bytes: # 判断只有获取到数据才需要decode解码
    #     print(code_bytes.decode())

    # 设置字典,单个成员
    # hset user name xiaoming
    # redis.hset("user", "name", "xiaoming")


    # # 设置字典,多个成员
    # # hset user name xiaohong age 12 sex 1
    # data = {
    #     "name": "xiaohong",
    #     "age": 12,
    #     "sex": 1
    # }
    # redis.hset("user", mapping=data)

    # # 获取字典所有成员,字典的所有成员都是键值对,而键值对也是bytes类型,所以需要推导式进行转换
    # ret = redis.hgetall("user")
    # print(ret)  # {b'name': b'xiaohong', b'age': b'12', b'sex': b'1'}
    # data = {key.decode(): value.decode() for (key, value) in ret.items()}
    # print(data)

    # # 获取当前仓库的所有的key
    ret = redis.keys("*")
    print(ret)

    # 删除key
    if len(ret) > 0:
        redis.delete(ret[0])

Celery

Celery是一个python第三方模块,是一个功能完备即插即用的分布式异步任务队列框架。它适用于异步处理问题,当大批量发送邮件、或者大文件上传, 批图图像处理等等一些比较耗时的操作,我们可将其异步执行,这样的话原来的项目程序在执行过程中就不会因为耗时任务而形成阻塞,导致出现请求堆积过多的问题。celery通常用于实现异步任务或定时任务。

目前最新版本为: 5.2

项目:https://github.com/celery/celery/

文档:(3.1) http://docs.jinkan.org/docs/celery/getting-started/index.html

​ (最新) https://docs.celeryproject.org/en/latest/

Celery的特点是:

  • 简单,易于使用和维护,有丰富的文档。
  • 高效,支持多线程、多进程、协程模式运行,单个celery进程每分钟可以处理数百万个任务。
  • 灵活,celery中几乎每个部分都可以自定义扩展。

celery的作用是:应用解耦,异步处理,流量削锋,消息通讯。

celery通过消息(任务)进行通信,
celery通常使用一个叫Broker(中间人/消息中间件/消息队列/任务队列)来协助clients(任务的发出者/客户端)和worker(任务的处理者/工作进程)进行通信的.
clients发出消息到任务队列中,broker将任务队列中的信息派发给worker来处理。

client ---> 消息 --> Broker(消息队列) -----> 消息 ---> worker(celery运行起来的工作进程)

消息队列(Message Queue),也叫消息队列中间件,简称消息中间件,它是一个独立运行的程序,表示在消息的传输过程中临时保存消息的容器。
所谓的消息,是指代在两台计算机或2个应用程序之间传送的数据。消息可以非常简单,例如文本字符串或者数字,也可以是更复杂的json数据或hash数据等。
所谓的队列,是一种先进先出、后进后出的数据结构,python中的list数据类型就可以很方便地用来实现队列结构。
目前开发中,使用较多的消息队列有RabbitMQ,Kafka,RocketMQ,MetaMQ,ZeroMQ,ActiveMQ等,当然,像redis、mysql、MongoDB,也可以充当消息中间件,但是相对而言,没有上面那么专业和性能稳定。

并发任务10k以下的,直接使用redis
并发任务10k以上,1000k以下的,直接使用RabbitMQ
并发任务1000k以上的,直接使用RocketMQ

Celery的运行架构

Celery的运行架构由三部分组成,消息队列(message broker),任务执行单元(worker)和任务执行结果存储(task result store)组成。
image-20210915163601802

一个celery系统可以包含很多的worker和broker
Celery本身不提供消息队列功能,但是可以很方便地和第三方提供的消息中间件进行集成,包括Redis,RabbitMQ,RocketMQ等

安装

pip install -U celery -i  https://pypi.tuna.tsinghua.edu.cn/simple

注意:

Celery不建议在windows系统下使用,Celery在4.0版本以后不再支持windows系统,所以如果要在windows下使用只能安装4.0以前的版本,而且即便是4.0之前的版本,在windows系统下也是不能单独使用的,需要安装gevent、geventlet或eventlet协程模块

基本使用

使用celery第一件要做的最为重要的事情是需要先创建一个Celery实例对象,我们一般叫做celery应用对象,或者更简单直接叫做一个app。app应用对象是我们使用celery所有功能的入口,比如启动celery、创建任务,管理任务,执行任务等.

celery框架有2种使用方式,一种是单独一个项目目录,另一种就是Celery集成到web项目框架中。

celery作为一个单独项目运行

例如,mycelery代码目录直接放在项目根目录下即可,路径如下:

服务端项目根目录/
└── mycelery/
    ├── settings.py   # 配置文件
    ├── __init__.py   
    ├── main.py       # 入口程序
    └── sms/          # 异步任务目录,这里拿发送短信来举例,一个类型的任务就一个目录
         └── tasks.py # 任务的文件,文件名必须是tasks.py!!!每一个任务就是一个被装饰的函数,写在任务文件中

main.py,代码:

from celery import Celery

# 实例化celery应用,参数一般为项目应用名
app = Celery("luffycity")

# 通过app实例对象加载配置文件
app.config_from_object("mycelery.settings")

# 注册任务, 自动搜索并加载任务
# 参数必须必须是一个列表,里面的每一个任务都是任务的路径名称
# app.autodiscover_tasks(["任务1","任务2",....])
app.autodiscover_tasks(["mycelery.sms","mycelery.email"])

# 启动Celery的终端命令
# 强烈建议切换目录到项目的根目录下启动celery!!
# celery -A mycelery.main worker --loglevel=info

配置文件settings.py,代码:

# 任务队列的链接地址
broker_url = 'redis://127.0.0.1:6379/14'
# 结果队列的链接地址
result_backend = 'redis://127.0.0.1:6379/15'

关于配置信息的官方文档:https://docs.celeryproject.org/en/master/userguide/configuration.html

创建任务文件sms/tasks.py,任务文件名必须固定为"tasks.py",并创建任务,代码:

from ..main import app


@app.task(name="send_sms1")
def send_sms1():
    """没有任何参数的异步任务"""
    print('任务:send_sms1执行了...')


@app.task(name="send_sms2")
def send_sms2(mobile, code):
    """有参数的异步任务"""
    print(f'任务:send_sms2执行了...mobile={mobile}, code={code}')


@app.task(name="send_sms3")
def send_sms3():
    """有结果的异步任务"""
    print('任务:send_sms3执行了...')
    return 100


@app.task(name="send_sms4")
def send_sms4(x, y):
    """有结果有参数的异步任务"""
    print('任务:send_sms4执行了...')
    return x + y


接下来,我们运行celery。

cd ~/Desktop/luffycity/luffycityapi

# 普通的运行方式[默认多进程,卡终端,按CPU核数+1创建进程数]
# ps aux|grep celery
celery -A mycelery.main worker --loglevel=info
# 若为Windows运行,需添加扩展
# pip install eventlet
# celery -A mycelery.main worker --loglevel=info -P eventlet

# 启动多工作进程,以守护进程的模式运行[一个工作进程就是4个子进程]
# 注意:pidfile和logfile必须以绝对路径来声明
celery multi start worker -A mycelery.main -E --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker1.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log" -l info -n worker1
celery multi start worker -A mycelery.main -E --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker2.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log" -l info -n worker2

# 关闭运行的工作进程
celery multi stop worker -A mycelery.main --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker1.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log"
celery multi stop worker -A mycelery.main --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker2.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log"

效果如下:

image-20210721104438889

调用上面的异步任务,拿django的shell进行举例:

# 因为celery模块安装在了虚拟环境中,所以要确保进入虚拟环境
conda activate luffycity
cd ~/Desktop/luffycity/luffycityapi

python manage.py shell

# 调用celery执行异步任务
from mycelery.sms.tasks import send_sms1,send_sms2,send_sms3,send_sms4
mobile = "13312345656"
code = "666666"

# delay 表示马上按顺序来执行异步任务,在celrey的worker工作进程有空闲的就立刻执行
# 可以通过delay异步调用任务,可以没有参数
ret1 = send_sms1.delay()
# 可以通过delay传递异步任务的参数,可以按位置传递参数,也可以使用命名参数
# ret2 = send_sms.delay(mobile=mobile,code=code)
ret2 = send_sms2.delay(mobile,code)

# apply_async 让任务在后面指定时间后执行,时间单位:秒/s
# 任务名.apply_async(args=(参数1,参数2), countdown=定时时间)
ret4 = send_sms4.apply_async(kwargs={"x":10,"y":20},countdown=30)

# 根据返回结果,不管delay,还是apply_async的返回结果都一样的。
ret4.id      # 返回一个UUID格式的任务唯一标志符,78fb827e-66f0-40fb-a81e-5faa4dbb3505
ret4.status  # 查看当前任务的状态 SUCCESS表示成功! PENDING任务等待
ret4.get()   # 获取任务执行的结果[如果任务函数中没有return,则没有结果,如果结果没有出现则会导致阻塞]

if ret4.status == "SUCCESS":
    print(ret4.get())


接下来,我们让celery可以调度第三方框架的代码,这里拿django当成一个第三模块调用进行举例。

在main.py主程序中对django进行导包引入,并设置django的配置文件进行django的初始化。

import os,django
from celery import Celery
# 初始化django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'luffycityapi.settings.dev')
django.setup()

# 初始化celery对象
app = Celery("luffycity")

# 加载配置
app.config_from_object("mycelery.config")
# 自动注册任务
app.autodiscover_tasks(["mycelery.sms","mycelery.email"])
# 运行celery
# 终端下: celery -A mycelery.main worker -l info


在需要使用django配置的任务中,直接加载配置,所以我们把注册的短信发送功能,整合成一个任务函数,mycelery.sms.tasks,代码:

from ..main import app
from ronglianyunapi import send_sms as send_sms_to_user

@app.task(name="send_sms1")
def send_sms1():
    """没有任何参数,没有返回结果的异步任务"""
    print('任务:send_sms1执行了...')

@app.task(name="send_sms2")
def send_sms2(mobile, code):
    """有参数,没有返回结果的异步任务"""
    print(f'任务:send_sms2执行了...mobile={mobile}, code={code}')


@app.task(name="send_sms3")
def send_sms3():
    """没有任何参数,有返回结果的异步任务"""
    print('任务:send_sms3执行了...')
    return 100

@app.task(name="send_sms4")
def send_sms4(x,y):
    """有结果的异步任务"""
    print('任务:send_sms4执行了...')
    return x+y

@app.task(name="send_sms")
def send_sms(tid, mobile, datas):
    """发送短信"""
    print("发送短信")
    return send_sms_to_user(tid, mobile, datas)


最终在django的视图里面,我们调用Celery来异步执行任务。

只需要完成2个步骤,分别是导入异步任务调用异步任务。users/views.py,代码:

import random
from django_redis import get_redis_connection
from django.conf import settings
# from ronglianyunapi import send_sms
from mycelery.sms.tasks import send_sms
"""
/users/sms/(?P<mobile>1[3-9]\d{9})
"""
class SMSAPIView(APIView):
    """
    SMS短信接口视图
    """
    def get(self, request, mobile):
        """发送短信验证码"""
        redis = get_redis_connection("sms_code")
        # 判断手机短信是否处于发送冷却中[60秒只能发送一条]
        interval = redis.ttl(f"interval_{mobile}")  # 通过ttl方法可以获取保存在redis中的变量的剩余有效期
        if interval != -2:
            return Response({"errmsg": f"短信发送过于频繁,请{interval}秒后再次点击获取!", "interval": interval},status=status.HTTP_400_BAD_REQUEST)

        # 基于随机数生成短信验证码
        # code = "%06d" % random.randint(0, 999999)
        code = f"{random.randint(0, 999999):06d}"
        # 获取短信有效期的时间
        time = settings.RONGLIANYUN.get("sms_expire")
        # 短信发送间隔时间
        sms_interval = settings.RONGLIANYUN["sms_interval"]
        # 调用第三方sdk发送短信
        # send_sms(settings.RONGLIANYUN.get("reg_tid"), mobile, datas=(code, time // 60))
        # 异步发送短信
        send_sms.delay(settings.RONGLIANYUN.get("reg_tid"), mobile, datas=(code, time // 60))

        # 记录code到redis中,并以time作为有效期
        # 使用redis提供的管道对象pipeline来优化redis的写入操作[添加/修改/删除]
        pipe = redis.pipeline()
        pipe.multi()  # 开启事务
        pipe.setex(f"sms_{mobile}", time, code)
        pipe.setex(f"interval_{mobile}", sms_interval, "_")
        pipe.execute()  # 提交事务,同时把暂存在pipeline的数据一次性提交给redis

        return Response({"errmsg": "OK"}, status=status.HTTP_200_OK)


上面就是使用celery并执行异步任务的第一种方式,适合在一些无法直接集成celery到项目中的场景。

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: celery作为一个单独项目运行,执行异步任务"
git push

Celery作为第三方模块集成到项目中

这里还是拿django来举例,目录结构调整如下:

luffycityapi/           # 服务端项目根目录
└── luffycityapi/       # 主应用目录
    ├── apps/           # 子应用存储目录  
    ├   └── users/            # django的子应用
    ├       └── tasks.py      # [新增]分散在各个子应用下的异步任务模块
    ├── settings/     # [修改]django的配置文件存储目录[celery的配置信息填写在django配置中即可]
    ├── __init__.py   # [修改]设置当前包目录下允许外界调用celery应用实例对象
    └── celery.py     # [新增]celery入口程序,相当于上一种用法的main.py


luffycityapi/celery.py,主应用目录下创建cerley入口程序,创建celery对象并加载配置和异步任务,代码:

import os
from celery import Celery

# 必须在实例化celery应用对象之前执行
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'luffycityapi.settings.dev')

# 实例化celery应用对象
app = Celery('luffycityapi')
# 指定任务的队列名称
app.conf.task_default_queue = 'Celery'
# 也可以把配置写在django的项目配置中
app.config_from_object('django.conf:settings', namespace='CELERY') # 设置django中配置信息以 "CELERY_"开头为celery的配置信息
# 自动根据配置查找django的所有子应用下的tasks任务文件
app.autodiscover_tasks()


settings/dev.py,django配置中新增celery相关配置信息,代码:

# Celery异步任务队列框架的配置项[注意:django的配置项必须大写,所以这里的所有配置项必须全部大写]
# 任务队列
CELERY_BROKER_URL = 'redis://:123456@127.0.0.1:6379/14'
# 结果队列
CELERY_RESULT_BACKEND = 'redis://:123456@127.0.0.1:6379/15'
# 时区,与django的时区同步
CELERY_TIMEZONE = TIME_ZONE
# 防止死锁
CELERY_FORCE_EXECV = True
# 设置并发的worker数量
CELERYD_CONCURRENCY = 200
# 设置失败允许重试[这个慎用,如果失败任务无法再次执行成功,会产生指数级别的失败记录]
CELERY_ACKS_LATE = True
# 每个worker工作进程最多执行500个任务被销毁,可以防止内存泄漏,500是举例,根据自己的服务器的性能可以调整数值
CELERYD_MAX_TASKS_PER_CHILD = 500
# 单个任务的最大运行时间,超时会被杀死[慎用,有大文件操作、长时间上传、下载任务时,需要关闭这个选项,或者设置更长时间]
CELERYD_TIME_LIMIT = 10 * 60
# 任务发出后,经过一段时间还未收到acknowledge, 就将任务重新交给其他worker执行
CELERY_DISABLE_RATE_LIMITS = True
# celery的任务结果内容格式
CELERY_ACCEPT_CONTENT = ['json', 'pickle']

# 之前定时任务(定时一次调用),使用了apply_async({}, countdown=30);
# 设置定时任务(定时多次调用)的调用列表,需要单独运行SCHEDULE命令才能让celery执行定时任务:celery -A mycelery.main beat,当然worker还是要启动的
# https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
    "user-add": {  # 定时任务的注册标记符[必须唯一的]
        "task": "add",   # 定时任务的任务名称
        "schedule": 10,  # 定时任务的调用时间,10表示每隔10秒调用一次add任务
        # "schedule": crontab(hour=7, minute=30, day_of_week=1),,  # 定时任务的调用时间,每周一早上7点30分调用一次add任务
    }
}


luffycityapi/__init__.py,主应用下初始化,代码:

import pymysql
from .celery import app as celery_app

pymysql.install_as_MySQLdb()

__all__ = ['celery_app']


users/tasks.py,代码:

from celery import shared_task
from ronglianyunapi import send_sms as sms
# 记录日志:
import logging
logger = logging.getLogger("django")

@shared_task(name="send_sms")
def send_sms(tid, mobile, datas):
    """异步发送短信"""
    try:
        return sms(tid, mobile, datas)
    except Exception as e:
        logger.error(f"手机号:{mobile},发送短信失败错误: {e}")


@shared_task(name="send_sms1")
def send_sms1():
    print("send_sms1执行了!!!")


django中的用户发送短信,就可以改成异步发送短信了。

users/views,视图中调用异步发送短信的任务,代码:

# from ronglianyunapi import send_sms   # 直接发送
# from mycelery.sms.tasks import send_sms   # 将celery当成一个独立的项目导入发送
from .tasks import send_sms  # 将celery当成第三方模块导入发送

send_sms.delay(settings.RONGLIANYUN.get("reg_tid"),mobile, datas=(code, time // 60))

users/views.py,异步发送信息的完整视图,代码:

import random
from django_redis import get_redis_connection
from django.conf import settings
# from ronglianyunapi import send_sms
# from mycelery.sms.tasks import send_sms
from .tasks import send_sms

"""
/users/sms/(?P<mobile>1[3-9]\d{9})
"""
class SMSAPIView(APIView):
    """
    SMS短信接口视图
    """
    def get(self, request, mobile):
        """发送短信验证码"""
        redis = get_redis_connection("sms_code")
        # 判断手机短信是否处于发送冷却中[60秒只能发送一条]
        interval = redis.ttl(f"interval_{mobile}")  # 通过ttl方法可以获取保存在redis中的变量的剩余有效期
        if interval != -2:
            return Response({"errmsg": f"短信发送过于频繁,请{interval}秒后再次点击获取!", "interval": interval},status=status.HTTP_400_BAD_REQUEST)

        # 基于随机数生成短信验证码
        # code = "%06d" % random.randint(0, 999999)
        code = f"{random.randint(0, 999999):06d}"
        # 获取短信有效期的时间
        time = settings.RONGLIANYUN.get("sms_expire")
        # 短信发送间隔时间
        sms_interval = settings.RONGLIANYUN["sms_interval"]
        # 调用第三方sdk发送短信
        # send_sms(settings.RONGLIANYUN.get("reg_tid"), mobile, datas=(code, time // 60))
        # 异步发送短信
        send_sms.delay(settings.RONGLIANYUN.get("reg_tid"), mobile, datas=(code, time // 60))

        # 记录code到redis中,并以time作为有效期
        # 使用redis提供的管道对象pipeline来优化redis的写入操作[添加/修改/删除]
        pipe = redis.pipeline()
        pipe.multi()  # 开启事务
        pipe.setex(f"sms_{mobile}", time, code)
        pipe.setex(f"interval_{mobile}", sms_interval, "_")
        pipe.execute()  # 提交事务,同时把暂存在pipeline的数据一次性提交给redis

        return Response({"errmsg": "OK"}, status=status.HTTP_200_OK)


终端下先启动celery,在django项目根目录下启动。

cd ~/Desktop/luffycity/luffycityapi
# 1. 普通运行模式,关闭终端以后,celery就会停止运行
celery -A luffycityapi worker  -l INFO

# 2. 启动多worker进程模式,以守护进程的方式运行,不需要在意终端。但是这种运行模型,一旦停止,需要手动启动。
celery multi start worker -A luffycityapi -E --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker1.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log" -l info -n worker1

# 3. 启动多worker进程模式
celery multi stop worker -A luffycityapi --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker1.pid"


还是可以在django终端下调用celery的

$ python manage.py shell
>>> from users.tasks import send_sms1
>>> res = send_sms1.delay()
>>> res = send_sms1.apply_async(countdown=15)
>>> res.id
'893c31ab-e32f-44ee-a321-8b07e9483063'
>>> res.state
'SUCCESS'
>>> res.result


关于celery中异步任务发布的2个方法的参数如下:

异步任务名.delay(*arg, **kwargs)
异步任务名.apply_async((arg,), {'kwarg': value}, countdown=60, expires=120)

定时任务的调用器启动,可以在运行了worker以后,执行配置里的定时任务,使用以下命令:

cd ~/Desktop/luffycity/luffycityapi
celery -A luffycityapi beat


# 之前定时任务(定时一次调用),使用了apply_async({}, countdown=30);
# 设置定时任务(定时多次调用)的调用列表,需要单独运行SCHEDULE命令才能让celery执行定时任务:celery -A mycelery.main beat,当然worker还是要启动的
# https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html
from celery.schedules import crontab

CELERY_BEAT_SCHEDULE = {
    "send_sms_to_user": {  # 定时任务的注册标记符[必须唯一的]
        "task": "send_sms1",  # 定时任务的任务名称
        "schedule": 10,  # 定时任务的调用时间,10表示每隔10秒调用一次add任务
        # "schedule": crontab(hour=7, minute=30, day_of_week=1),,  # 定时任务的调用时间,每周一早上7点30分调用一次add任务
    }
}

beat调度器关闭了,则定时任务无法执行,如果worker工作进程关闭了,则celery关闭,保存在消息队列中的任务就会囤积在那里。

image-20220904085823353

celery还有一些高阶用法, 我们后面用到再提。

celery后面还可以使用supervisor进行后台托管运行。还可以针对任务执行的情况和结果,使用flower来进行监控。celery失败任务的重新尝试执行。

supervisor会在celery以外关闭了以后,自动重启celery。

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: celery作为第三方模块,执行异步任务"
git push

posted @ 2022-09-25 21:38  凫弥  阅读(446)  评论(0编辑  收藏  举报