美多商城课件在线地址

http://47.101.37.192/%E8%AF%BE%E4%BB%B6/%E7%BE%8E%E5%A4%9A%E5%95%86%E5%9F%8E%E8%AF%BE%E4%BB%B6/project-preparation/project-create-configuration/mysql.html

https://blog.csdn.net/qq_35709559/article/details/86544093

需求分析

  • 用户部分

    • 注册

      • 用户名,密码,确认密码,手机号码,图形验证码,短信验证码

      • 勾选 同意商城协议

      • 注册按钮

    • 登录

      • 用户名/手机号码,密码

      • 可以勾选'记录登录'

      • 登录按钮,QQ/微信登录

    • 个人信息

      • 邮箱填写与验证

      • 浏览的商品历史记录

    • 地址管理

      • 省市区地址信息加载

      • 新增修改删除地址

      • 设置默认地址

    • 修改密码

      • 当前密码,新密码,确认密码
  • 商品部分

    • 首页

      • 商品分类

      • 广告控制

    • 商品列表

    • 商品详情

    • 商品搜索

  • 购物车部分

    • 购物车管理
  • 订单部分

    • 提交订单

    • 我的订单

    • 订单评价

  • 支付部分

    • 支付宝支付

前端安装 live-server

  • 可以使用前端node.js 提供的服务器live-server作为前端开发服务器使用
- 安装步骤

    - windows安装 node.js 版本控制工具 nvm(node version manage)

        - https://github.com/coreybutler/nvm-windows/releases/download/1.1.10/nvm-setup.zip

        - nvm 常用命令

            - nvm ls-remote:列出所有可以安装的node版本号
            - nvm install v15.1.0:安装指定版本号的node

                - 安装的版本和教程保持一致: nvm install v10.4.1

                    - 此时,会自动帮你安装 npm(node package manage),node 包/模块 管理工具

            - nvm use v15.1.0:切换node的版本,这个是全局的
            - nvm current:当前node版本
            - nvm ls:列出所有已经安装的node版本

    - 安装 live-server

        - npm install -g live-server

    - 测试:切换到前端目录,输入命令: live-server

拓展django自带的User表,自定义用户模型

  • 新建 users app,建模代码如下
from django.db import models
from django.contrib.auth.models import AbstractUser

class UserInfo(AbstractUser):

    # 新增手机号字段
    mobile = models.CharField(max_length=11, unique=True, verbose_name='手机号')

    class Meta:
        db_table = 'tb_users' # 自定义数据库表名
        verbose_name = '用户' # admin站点显示以及显示中文名
        verbose_name_plural = verbose_name

## settings配置

INSTALLED_APPS = [
    ......

    'rest_framework',
    'apps.users.apps.UsersConfig', # 新增
]

# AUTH_USER_MODEL = 'apps.users.models.UserInfo' 写成这种形式会报错,django内部会自动添加models路径,进行拼接
AUTH_USER_MODEL = 'users.UserInfo'

os模块和 sys模块辨析

- sys.path: 查询导包路径

- os.path: 拼接路径(自动添加'\',)
  • 在django项目的应用,以 settings 为例,把apps加入 sys.path

    • 好处一: 书写(代码)更简洁

    • 好处二: 修改django默认的认证类时,必须写成这种形式: app名+模型类名(让django找到)

import os
import sys
from pathlib import Path

# 以下这句,一样的效果
# BASE_DIR = Path(__file__).resolve().parent.parent
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 往list最前面,插入apps路径
sys.path.insert(0,os.path.join(BASE_DIR,'apps'))
# 若不使用os模块,斜杠需要自己添加,代码看上去没那么漂亮...
# sys.path.insert(0,BASE_DIR + '\\' + 'apps')
......
INSTALLED_APPS = [
    ......
    'rest_framework',
    # 这种写法比较长,略不爽
    # 'apps.users.apps.UsersConfig',
    # 由于加入了导包路径,所以代码更简洁
    'users.apps.UsersConfig',
]

  • 当后端解决跨域问题以后,再次测试请求,观察"响应头"
......
Access-Control-Allow-Credentials: true # 允许跨域携带cookie
Access-Control-Allow-Origin: http://127.0.0.1:8080 # 后端的地址
Allow: GET, HEAD, OPTIONS # 允许这么几种请求(默认是options,尝试性的发请求)
......

需求:实现手机号码只能一分钟发送一次

  • 当前手机号码发送短信以后,在 redis存储一个此手机号标记(标记有效期60s)

  • 每次发送短信之前,先从redis取此手机号码标记

    • 有标记,就提示发送短信频繁

    • 无标记,正常发送短信

class SMSCodeView(APIView):

    def get(self,request,mobile):

        # 以前的写法
        # sms_form = SendSmsForm(request, data=request.GET)
        # if sms_form.is_valid():
        #     mobile = sms_form.cleaned_data.get('mobile')
        #     code = random.randrange(1000, 9999)
        #     conn = get_redis_connection()
        #     conn.set(mobile, code, ex=60)
        #     ### 调用发送短信的API
        #     return JsonResponse({'status': True})
        # return JsonResponse({'status': False, 'error': sms_form.errors})

        redis_conn = get_redis_connection('verify_codes')
        sms_flag = redis_conn.get('sms_flag_{}'.format(mobile)) # 首先获取标志,如果有,代码就不继续往下走
        if sms_flag:
            # 返回啥样的状态码,要和前端商议好!
            return Response({'msg':'短信发送太频繁'},status=status.HTTP_400_BAD_REQUEST)
    
        sms_code = random.randint(1000,9998)
        logger.info(sms_code)

        redis_conn.set(mobile,sms_code,ex=60)
        redis_conn.set('sms_flag_{}'.format(mobile),200,ex=60) # 首次发送短信以后,设置'短信标志',值200可以随便设置
    
        print('已发送短信')
        return Response({'msg':'OK'})

- 前端示例代码:

......
// 向后端发起短信请求
axios.get('http://127.0.0.1:8000/sms_code/' + this.mobile + '/')
    .then(response => {
        var num = 60;
        var t = setInterval(function () {
            if (num == 1) {
                clearInterval(t);
                vm.sms_code_tip = '获取短信验证码';
                vm.send_flag = false;
            } else {
                num -= 1;
                vm.sms_code_tip = '再过 ' + num + ' 秒后发送';
            }
        }, 1000);
    })
    .catch(error => { // 关注之处
        this.send_flag = false;
        this.error_sms_code = true;
        this.sms_code_error_tip = error.response.data.msg;
    });

......
<li>
    <label>短信验证码:</label>
    <input type="text" v-model="sms_code" @blur="check_sms_code" name="msg_code" id="msg_code" class="msg_input">
    <a href="javascript:;" @click="send_sms_code" class="get_msg_code">{{ sms_code_tip }}</a>
    <!--关注之处-->
    <span v-show="error_sms_code" class="error_tip">{{ sms_code_error_tip }}</span>
</li>

redis优化

  • 基于上述逻辑,每发送一次短信,需要连接 redis 3次

  • 假如网站是100万人的浏览量,那么就300万次 redis 连接,缺点很明显,效率低,服务器鸭梨大

......
redis_conn = get_redis_connection('verify_codes')
......
redis_conn.set(mobile,sms_code,ex=60)
redis_conn.set('sms_flag_{}'.format(mobile),200,ex=60) # 三次连接
......
  • 基于 redis 管道的优化逻辑:相当于windows批处理/mysql事务,要做的事情写好,一次性连接执行完毕,实现redis连接次数的优化
......
class SMSCodeView(APIView):

    def get(self,request,mobile):
        ......
        pl = redis_conn.pipeline() # 生成管道实例
        pl.set(mobile, sms_code, ex=60) # 要执行的语句,两句
        pl.set('sms_flag_{}'.format(mobile), 200, ex=60)
        pl.execute() # 执行管道
        ......
......
  • redis管道注意事项

    • 若是立即需要获取数据,尽量不要用管道,直接连接获取数据即可

    • 一般是用到 set()方法的时候,才使用管道;若是get()方法,一般不使用管道

    • get()方法,示例代码如下

......
redis_conn = get_redis_connection('verify_codes')
pl = redis_conn.pipeline() # 新建管道
pl.get('sms_flag_{}'.format(mobile)) # 获取数据
sms_flag = pl.execute()[0] # p1.execute返回的是一个tuple,所有加上索引0
if sms_flag:
    return Response({'msg':'短信发送太频繁'},status=status.HTTP_400_BAD_REQUEST)
......

celery: 处理'耗时'和'定时'任务

  • 引入场景: 在发短信的逻辑中,若是遇到网络问题,造成发短信的逻辑阻塞了
    程序堵在那边,此时,用户的web界面中,发短信的按钮不会立即显示倒计时
    会等到程序恢复运行以后,再显示倒计时效果

    • 缺陷很明显,用户体验很差
def get(self,request,mobile):
    ......
    pl.execute()
    time.sleep(5) # 模拟耗时操作
    print('已发送短信')
    return Response({'msg':'OK'})
  • 解决办法,把'耗时'操作,变成'异步请求操作'
    即执行到'耗时操作'以后,不等结果,立即返回Response
    这样就不会让用户等了...(实质就是一个'假象'...),从而优化用户体验

  • celery 可以分为三部分

    • 客户端: 把任务发送给'任务队列(broker)',代码逻辑就放在这里

    • 任务队列(broker,经纪人,中间人): 存放一个个任务队列

    • 任务处理者(worker):从broker获取任务并处理它

  • 客户端配置

- 项目根目录新建'celery_tasks包'(一般都是叫这个名字,没有硬性规定)

    - 新建 main.py: celery 主程序入口

    - 新建 config.py: 存放 celery 配置

- 在 celery_tasks包目录下,新建'sms包'(任务包)

    - sms包底下,新建'tasks.py'(一定要叫这个名字,硬性规定)
  • main.py的写法
from celery import Celery

# 创建实例对象
celery_app = Celery('meiduo')

# 加载配置文件
celery_app.config_from_object('celery_tasks.config')

# 自动注册异步任务
celery_app.autodiscover_tasks(['celery_tasks.sms'])
# celery_app.autodiscover_tasks(['celery_tasks.sms','celery_tasks.html']) # 如果有多个任务,可以这么写
  • config.py的写法
# 指定任务队列存储的位置
broker_url = "redis://192.168.11.38:6379/7"
  • 现在,运行命令(终端切换到celery_tasks父目录),把celery跑起来
celery -A celery_tasks.main worker -l info

  • 如果是windows系统,此时可能会报错
......
import grp ModuleNotFoundError: No module named 'grp'
  • 原因及解决办法
- 原因

    - 使用的是 PyPi 软件包django-celery-beat,会安装所需软件包celery 的最新版本
    - 而不是安装兼容版本(到我发布此内容时,即 2021 年 5 月 25 日,这时是v5.1.0)
    - 它似乎与django-celery-beat版本2.2.0(最新)以及 Windows 操作系统存在兼容性问题

- 解决办法

    pip uninstall celery
    pip install celery==5.0.5

- 再次运行命令,可以看到 celery 跑起来了: celery -A celery_tasks.main worker -l info
  • 在tasks.py中,自定义任务(所谓'任务',就是一个'函数')
# sms.tasks
from celery_tasks.main import celery_app

# 使用装饰器注册任务,不要写成"@celery_app.tasks",多了一个's'就会报错...
@celery_app.task(name='send_sms_code') # 给任务函数取一个名字(celery默认的任务名很长...)
def send_sms_code(mobile,sms_code):
    # 把短信api搞到sms包即可
    print('发送短信')
  • 修改项目中,发送短信的接口,变成celery任务
from celery_tasks.sms.tasks import send_sms_code
......
# print('已发送短信')
# 导入任务函数,使用delay传参并调用celery任务
send_sms_code.delay(mobile,sms_code)
......
  • 重新启动celery,可以看到如下信息
 -------------- celery@DESKTOP-SM4B347 v5.0.5 (singularity)
--- ***** -----
-- ******* ---- Windows-10-10.0.22000-SP0 2022-11-25 13:44:23
- *** --- * ---
- ** ---------- [config]
- ** ---------- .> app:         meiduo:0x163186ef550
- ** ---------- .> transport:   redis://192.168.11.38:6379/7
- ** ---------- .> results:     disabled://
- *** --- * --- .> concurrency: 4 (prefork)
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
--- ***** -----
 -------------- [queues]
                .> celery           exchange=celery(direct) key=celery


[tasks]
  . send_sms_code # 任务名称(刚才取名)

[2022-11-25 13:44:23,881: INFO/MainProcess] Connected to redis://192.168.11.38:6379/7
[2022-11-25 13:44:23,896: INFO/MainProcess] mingle: searching for neighbors
[2022-11-25 13:44:24,193: INFO/SpawnPoolWorker-3] child process 6872 calling self.run()
[2022-11-25 13:44:24,203: INFO/SpawnPoolWorker-1] child process 13232 calling self.run()
[2022-11-25 13:44:24,234: INFO/SpawnPoolWorker-2] child process 4552 calling self.run()
[2022-11-25 13:44:24,250: INFO/SpawnPoolWorker-4] child process 12772 calling self.run()
[2022-11-25 13:44:24,914: INFO/MainProcess] mingle: all alone
[2022-11-25 13:44:24,945: INFO/MainProcess] celery@DESKTOP-SM4B347 ready.

  • 当用户点击获取验证码的时候,celery提示信息如下,显示已经完成发送短信的逻辑
......
[2022-11-25 13:46:03,093: INFO/MainProcess] Received task: send_sms_code[99ee18a0-d3a7-4d46-af15-30b5c63ec7f0]
[2022-11-25 13:46:03,095: WARNING/SpawnPoolWorker-3] 发送短信
[2022-11-25 13:46:03,096: INFO/SpawnPoolWorker-3] Task send_sms_code[99ee18a0-d3a7-4d46-af15-30b5c63ec7f0] succeeded in 0.0s: None
......

验证码部分

  • 新建'captcha包',并把'captcha.py'脚本扔进去
    必须先安装'Pillow'依赖库,字体文件也要扔进去(和 captcha.py 同级)
# captcha.py

import random
import string
import os.path
from io import BytesIO

from PIL import Image
from PIL import ImageFilter
from PIL.ImageDraw import Draw
from PIL.ImageFont import truetype


class Bezier:
    def __init__(self):
        self.tsequence = tuple([t / 20.0 for t in range(21)])
        self.beziers = {}

    def pascal_row(self, n):
        """ Returns n-th row of Pascal's triangle
        """
        result = [1]
        x, numerator = 1, n
        for denominator in range(1, n // 2 + 1):
            x *= numerator
            x /= denominator
            result.append(x)
            numerator -= 1
        if n & 1 == 0:
            result.extend(reversed(result[:-1]))
        else:
            result.extend(reversed(result))
        return result

    def make_bezier(self, n):
        """ Bezier curves:
            http://en.wikipedia.org/wiki/B%C3%A9zier_curve#Generalization
        """
        try:
            return self.beziers[n]
        except KeyError:
            combinations = self.pascal_row(n - 1)
            result = []
            for t in self.tsequence:
                tpowers = (t ** i for i in range(n))
                upowers = ((1 - t) ** i for i in range(n - 1, -1, -1))
                coefs = [c * a * b for c, a, b in zip(combinations,
                                                      tpowers, upowers)]
                result.append(coefs)
            self.beziers[n] = result
            return result


class Captcha(object):
    def __init__(self):
        self._bezier = Bezier()
        self._dir = os.path.dirname(__file__)
        # self._captcha_path = os.path.join(self._dir, '..', 'static', 'captcha')

    @staticmethod
    def instance():
        if not hasattr(Captcha, "_instance"):
            Captcha._instance = Captcha()
        return Captcha._instance

    def initialize(self, width=200, height=75, color=None, text=None, fonts=None):
        # self.image = Image.new('RGB', (width, height), (255, 255, 255))
        self._text = text if text else random.sample(string.ascii_uppercase + string.ascii_uppercase + '3456789', 4)
        self.fonts = fonts if fonts else \
            [os.path.join(self._dir, 'fonts', font) for font in ['Arial.ttf', 'Georgia.ttf', 'actionj.ttf']]
        self.width = width
        self.height = height
        self._color = color if color else self.random_color(0, 200, random.randint(220, 255))

    @staticmethod
    def random_color(start, end, opacity=None):
        red = random.randint(start, end)
        green = random.randint(start, end)
        blue = random.randint(start, end)
        if opacity is None:
            return red, green, blue
        return red, green, blue, opacity

    # draw image

    def background(self, image):
        Draw(image).rectangle([(0, 0), image.size], fill=self.random_color(238, 255))
        return image

    @staticmethod
    def smooth(image):
        return image.filter(ImageFilter.SMOOTH)

    def curve(self, image, width=4, number=6, color=None):
        dx, height = image.size
        dx /= number
        path = [(dx * i, random.randint(0, height))
                for i in range(1, number)]
        bcoefs = self._bezier.make_bezier(number - 1)
        points = []
        for coefs in bcoefs:
            points.append(tuple(sum([coef * p for coef, p in zip(coefs, ps)])
                                for ps in zip(*path)))
        Draw(image).line(points, fill=color if color else self._color, width=width)
        return image

    def noise(self, image, number=50, level=2, color=None):
        width, height = image.size
        dx = width / 10
        width -= dx
        dy = height / 10
        height -= dy
        draw = Draw(image)
        for i in range(number):
            x = int(random.uniform(dx, width))
            y = int(random.uniform(dy, height))
            draw.line(((x, y), (x + level, y)), fill=color if color else self._color, width=level)
        return image

    def text(self, image, fonts, font_sizes=None, drawings=None, squeeze_factor=0.75, color=None):
        color = color if color else self._color
        fonts = tuple([truetype(name, size)
                       for name in fonts
                       for size in font_sizes or (65, 70, 75)])
        draw = Draw(image)
        char_images = []
        for c in self._text:
            font = random.choice(fonts)
            c_width, c_height = draw.textsize(c, font=font)
            char_image = Image.new('RGB', (c_width, c_height), (0, 0, 0))
            char_draw = Draw(char_image)
            char_draw.text((0, 0), c, font=font, fill=color)
            char_image = char_image.crop(char_image.getbbox())
            for drawing in drawings:
                d = getattr(self, drawing)
                char_image = d(char_image)
            char_images.append(char_image)
        width, height = image.size
        offset = int((width - sum(int(i.size[0] * squeeze_factor)
                                  for i in char_images[:-1]) -
                      char_images[-1].size[0]) / 2)
        for char_image in char_images:
            c_width, c_height = char_image.size
            mask = char_image.convert('L').point(lambda i: i * 1.97)
            image.paste(char_image,
                        (offset, int((height - c_height) / 2)),
                        mask)
            offset += int(c_width * squeeze_factor)
        return image

    # draw text
    @staticmethod
    def warp(image, dx_factor=0.27, dy_factor=0.21):
        width, height = image.size
        dx = width * dx_factor
        dy = height * dy_factor
        x1 = int(random.uniform(-dx, dx))
        y1 = int(random.uniform(-dy, dy))
        x2 = int(random.uniform(-dx, dx))
        y2 = int(random.uniform(-dy, dy))
        image2 = Image.new('RGB',
                           (width + abs(x1) + abs(x2),
                            height + abs(y1) + abs(y2)))
        image2.paste(image, (abs(x1), abs(y1)))
        width2, height2 = image2.size
        return image2.transform(
            (width, height), Image.QUAD,
            (x1, y1,
             -x1, height2 - y2,
             width2 + x2, height2 + y2,
             width2 - x2, -y1))

    @staticmethod
    def offset(image, dx_factor=0.1, dy_factor=0.2):
        width, height = image.size
        dx = int(random.random() * width * dx_factor)
        dy = int(random.random() * height * dy_factor)
        image2 = Image.new('RGB', (width + dx, height + dy))
        image2.paste(image, (dx, dy))
        return image2

    @staticmethod
    def rotate(image, angle=25):
        return image.rotate(
            random.uniform(-angle, angle), Image.BILINEAR, expand=1)

    def captcha(self, path=None, fmt='JPEG'):
        """Create a captcha.

        Args:
            path: save path, default None.
            fmt: image format, PNG / JPEG.
        Returns:
            A tuple, (name, text, StringIO.value).
            For example:
                ('fXZJN4AFxHGoU5mIlcsdOypa', 'JGW9', '\x89PNG\r\n\x1a\n\x00\x00\x00\r...')

        """
        image = Image.new('RGB', (self.width, self.height), (255, 255, 255))
        image = self.background(image)
        image = self.text(image, self.fonts, drawings=['warp', 'rotate', 'offset'])
        image = self.curve(image)
        image = self.noise(image)
        image = self.smooth(image)
        name = "".join(random.sample(string.ascii_lowercase + string.ascii_uppercase + '3456789', 24))
        text = "".join(self._text)
        out = BytesIO()
        image.save(out, format=fmt)
        if path:
            image.save(os.path.join(path, name), fmt)
        return name, text, out.getvalue()

    def generate_captcha(self):
        self.initialize()
        return self.captcha("")


captcha = Captcha.instance()

if __name__ == '__main__':
    print(captcha.generate_captcha())

......
- fonts 目录

    - actionj.ttf
    - Arial.ttf
    - Georgia.ttf
### 测试脚本demo.py
from captcha.captcha import captcha

# name是文件名称ID
# text 文本验证码
# image 验证码图片二进制流
name,text, image = captcha.generate_captcha()

print('名称是',name) # TdLDlWyaXZMPptHi6jNu4Jqh
print('验证码的内容是:', text) # UXZR

# 将验证码图片二进制流 存入图片(点击刚生成的图片,看效果)
with open('demo.png', 'wb') as f:
    f.write(image)

  • 后端视图逻辑如下
### urls
url('^image_codes/(?P<image_code_id>[\w-]+)/$', views.ImageCodeView.as_view())

### views
class ImageCodeView(APIView):
    '''
    - 前端传来随机码
    - 根据随机码,把验证码文本放入redis并设置过期时间
    - 把图形码返回给前端展示
    '''
    def get(self,request,image_code_id):
        _,text,image = captcha.generate_captcha()
        redis_conn = get_redis_connection('verify_codes')
        redis_conn.set(image_code_id,text,ex=60)
        return HttpResponse(image,content_type='images/jpg')

  • 前端代码
<li>
    <label>手机号:</label>
    ......
</li>

<li>
    <label>图形验证码:</label>
    <input type="text" v-model="image_code" @blur="check_image_code" name="pic_code" id="pic_code" class="msg_input">
    <!--this.host + "/image_codes/" + this.image_code_id + "/";-->
    <!--每次点击图片,src就发生改变(因为uuid变了),相当于重新发起请求-->
    <img :src="image_code_url" @click="generate_image_code" alt="图形验证码" class="pic_code">
    <span v-show="error_image_code" class="error_tip">{{ error_image_code_message }}</span>
</li>

<li>
    <label>短信验证码:</label>
    ......

# js代码
var vm = new Vue({
    el: '#app',
    data: {
       ......
        error_image_code: false,
        error_image_code_message: '',
        error_sms_code_message: '',
        error_sms_code: false,
       ......

        image_code_id: '',  // 图片验证码编号
        image_code_url: '',  // 验证码图片路径

      ......
        image_code: '',

        send_flag: false,
        sms_code_tip: '获取短信验证码',
        sms_code_error_tip: '短信验证码错误',
        host: host,
    },
    // 页面加载完毕,就向后端发请求,生成验证码
    mounted: function () {
        this.generate_image_code();
    },
    methods: {
        ......
        // 检查图形验证码
        check_image_code: function (){
            if(!this.image_code) {
                this.error_image_code_message = '请填写图片验证码';
                this.error_image_code = true;
            } else {
                this.error_image_code = false;
            }
        },
       ......
        // 生成uuid
        generate_uuid: function () {
            var d = new Date().getTime();
            if (window.performance && typeof window.performance.now === "function") {
                d += performance.now(); //use high-precision timer if available
            }
            //生成的uuid如下: /image_codes/f3f3901f-30c9-4da9-9521-b021fbb9ac7c/
            var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
                var r = (d + Math.random() * 16) % 16 | 0;
                d = Math.floor(d / 16);
                return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
            });
            return uuid;
        },
        // 生成一个图片验证码的编号,并设置页面中图片验证码img标签的src属性
        generate_image_code: function(){
            // 生成一个编号
            // 严格一点的使用uuid保证编号唯一, 不是很严谨的情况下,也可以使用时间戳
            this.image_code_id = this.generate_uuid();
            // 设置页面中图片验证码img标签的src属性
            this.image_code_url = this.host + "/image_codes/" + this.image_code_id + "/";
        },
        //发送短信验证码
        send_sms_code: function () {
            ......

            // 向后端发起短信请求
            // axios.get('http://127.0.0.1:8000/sms_code/' + this.mobile + '/')
            axios.get('http://127.0.0.1:8000/sms_code/' + this.mobile + '/?text=' + this.image_code+'&image_code_id='+ this.image_code_id,{
                 responseType: 'json'
            })
                .then(response => {
                    var num = 60;
                    var t = setInterval(function () {
                        if (num == 1) {
                            clearInterval(t);
                            vm.sms_code_tip = '获取短信验证码';
                            vm.send_flag = false;
                        } else {
                            num -= 1;
                            vm.sms_code_tip = '再过 ' + num + ' 秒后发送';
                        }
                    }, 1000);
                })
                .catch(error => {
                    // this.send_flag = false;
                    // this.error_sms_code = true;
                    // this.sms_code_error_tip = error.response.data.msg;
                    if (error.response.status == 400) {
                        this.error_image_code_message = '图片验证码有误';
                        this.error_image_code = true;
                        this.generate_image_code();
                    } else {
                        console.log(error.response.data);
                    }
                    this.send_flag = false;
                });
        }
    }
});

  • 后端发送短信的逻辑
### serializers
from rest_framework import serializers
from django_redis import get_redis_connection


# mobile的逻辑,放到view处理
# 访问方式: GET /sms_codes/(?P<mobile>1[3-9]\d{9})/?image_code_id=xxx&text=xxx
class ImageCodeCheckSerializer(serializers.Serializer):
    # Invalid input of type: 'UUID'. Convert to a bytes, string, int or float first.
    # 这种格式,redis无法识别,暂时没找到解决办法 ???
    # image_code_id = serializers.UUIDField()
    image_code_id = serializers.CharField(max_length=128)
    text = serializers.CharField(max_length=4,min_length=4)

    def validate(self,attrs):
        image_code_id = attrs['image_code_id']
        text = attrs['text']

        redis_conn = get_redis_connection('verify_codes')
        redis_byte_code = redis_conn.get(image_code_id)
        if not redis_byte_code:
            raise serializers.ValidationError('验证码无效')

        str_code = redis_byte_code.decode('utf-8').lower()
        if str_code != text.lower():
            raise serializers.ValidationError('验证码错误')

        # 这里取mobile,可以通过 context 去获取
        # mobile = self.context['view'].kwargs['mobile']
        # send_flag = redis_conn.get("send_flag_%s" % mobile)
        # if send_flag:
        #     raise serializers.ValidationError('请求次数过于频繁')

        return attrs

### views
class SMSCodeView(GenericAPIView):

    serializer_class = ImageCodeCheckSerializer

    def get(self,request,mobile):

        # 校验
        serializer = self.get_serializer(data=request.query_params)
        serializer.is_valid(raise_exception=True)

        # 发送验证码
        redis_conn = get_redis_connection('verify_codes')
        sms_flag = redis_conn.get('sms_flag_{}'.format(mobile))
        if sms_flag:
            return Response({'msg':'短信发送太频繁'},status=status.HTTP_400_BAD_REQUEST)
        # 在日志中输出验证码
        sms_code = random.randint(1000,9998)
        logger.info(sms_code)

        pl = redis_conn.pipeline()
        pl.set(mobile, sms_code, ex=60)  # 保存验证码
        pl.set('sms_flag_{}'.format(mobile), 200, ex=60) # 保存发送短信标志
        pl.execute()

        send_sms_code.delay(mobile,sms_code) # 异步耗时任务
        '''
        - 发送短信以后,要返回什么样的信息,完全取决于需求...
        '''
        return Response({'msg':'OK'})

### urls
# 访问方式: GET /sms_codes/(?P<mobile>1[3-9]\d{9})/?image_code_id=xxx&text=xxx
url(r'^sms_code/(?P<mobile>1[3-9]\d{9})/$',views.SMSCodeView.as_view(),),


Django REST framework JWT 使用

  • 安装
pip install djangorestframework-jwt
  • 配置
JWT_AUTH = {
    'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1) # 配置有效期
}
  • 手动签发令牌: 根据以下代码得知,给它传模型对象即可
from rest_framework_jwt.settings import api_settings

jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER

payload = jwt_payload_handler(user)
token = jwt_encode_handler(payload)
  • 内部部分源码解析
### 载体部分
def jwt_payload_handler(user):
    # 获取username,赋值给 payload
    username_field = get_username_field()
    username = get_username(user)

    ......

    payload = {
        'user_id': user.pk,
        'username': username,
        'exp': datetime.utcnow() + api_settings.JWT_EXPIRATION_DELTA
    }
    # 如果模型中有email/user_id,也一起返回...
    if hasattr(user, 'email'):
        payload['email'] = user.email
    if isinstance(user.pk, uuid.UUID):
        payload['user_id'] = str(user.pk)

    payload[username_field] = username

    ......
    # 最终返回payload载体...
    return payload

......

### 加密部分
def jwt_encode_handler(payload):
    key = api_settings.JWT_PRIVATE_KEY or jwt_get_secret_key(payload)
    return jwt.encode(
        payload,
        key,
        api_settings.JWT_ALGORITHM
    ).decode('utf-8')
  • 功能实现: 注册完成后,即时登录
class RegisterSerializer(serializers.ModelSerializer):
    ......
    allow = serializers.CharField(write_only=True, label='是否同意协议')
    # 新增token字段
    token = serializers.CharField(read_only=True, label='token')

    class Meta:
        model = UserInfo
        # 加入token
        fields = [......,'token']
        ......

    def create(self,validated_data):
        ......
        user.save()

        # 手动签发令牌(调用拓展库提供的两个函数,生成payload并签发token)
        # 把模型对象传给jwt
        jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
        jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
        payload = jwt_payload_handler(user)
        token = jwt_encode_handler(payload)

        user.token = token # 把token加到user字段

        return user


- debug模式下,payload 和 jwt_token 如下:

{'user_id': 2, 'username': 'allen', 'exp': 1670032494, 'email': ''}

'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyLCJ1c2VybmFtZSI6ImFsbGVuIiwiZXhwIjoxNjcwMDMyNDk0LCJlbWFpbCI6IiJ9.jkkkbCbq7T6c95CEk1q6nkBTSSx7BQbZHKcvIzI0-fg'
  • 在前端观察后端返回的数据(在原有数据的基础上,加了token字段)
......
- response.data
    id: 4
    mobile: "15260581944"
    token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0LCJ1c2VybmFtZSI6ImFsbGVuIiwiZXhwIjoxNjcwMDMzMTQ5LCJlbWFpbCI6IiJ9.IHye76uGLYFCyUfCyOPEyl1K1oddugr1gU-ya-B_rAg"
    username: "allen"

前端如何存储token

  • token存储位置:浏览器,js代码演示如下
> localStorage.a = 123
123
> sessionStorage.a = 456
456

- localStorage: 可以永久存储(除非清空浏览器缓存)

- sessionStorage: 是临时性存储,页面/浏览器关掉,就消失了
  • 现在,把后端返回的token存入浏览器
......
then(response => {
        // 保存后端返回的信息,记录用户的登录状态
        sessionStorage.clear();
        localStorage.clear();
        localStorage.token = response.data.token;
        localStorage.username = response.data.username;
        localStorage.user_id = response.data.id;

        location.href = '/index.html'; // 之前的代码
    })

......
- 用户注册完成后,观察一下浏览器的存储,果然有保存3个键值对......

登录逻辑的实现

  • 流程: 前端提交'用户名'/'密码'两个字段

    • 验证失败,提示错误信息: 只返回400状态码,前端自己写错误信息

    • 验证成功,为用户签发jwt,返回给用户三个字段'token','username','user_id'
      前端接收到这三个字段,前端自己处理

后端接口设计

  • urls
urlpatterns = [
   ......
    # 登录
    url(r'^authorizations/$', obtain_jwt_token), # jwt内部已经实现登录视图,调用即可
]
  • jwt 登录视图源码解析:只返回了token,我们还需要返回 username/user_id
......
class JSONWebTokenAPIView(APIView):
   
    permission_classes = ()
    authentication_classes = ()

    def get_serializer_context(self):
        ......

    def get_serializer_class(self):
        ......

    def get_serializer(self, *args, **kwargs):
       ......

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)

        if serializer.is_valid():
            user = serializer.object.get('user') or request.user
            token = serializer.object.get('token')
            # 关注之处......重写此方法即可
            response_data = jwt_response_payload_handler(token, user, request)
            response = Response(response_data)
            ......
            return response
        # 如果校验不通过,返回400错误码
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

......
def jwt_response_payload_handler(token, user=None, request=None):
    """
    自定义jwt认证成功返回数据,这里只返回token,我们给它加点数据即可满足需求
    """
    return {
        'token': token,
    }
......
# utils.auth
def jwt_response_payload_handler(token, user=None, request=None):
    """
    自定义jwt认证成功返回数据
    """
    return {
        'token': token,
        'user_id': user.id, # 新增两个字段
        'username': user.username
    }
  • 配置方法分析
......
# 碰到这种写法,意味我们改写导包路径,导入我们自定义的方法路径即可
jwt_response_payload_handler = api_settings.JWT_RESPONSE_PAYLOAD_HANDLER
  • settings
#---------JWT-----------------#
JWT_AUTH = {
    'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1), # 令牌有效期1天
    'JWT_RESPONSE_PAYLOAD_HANDLER': 'utils.auth.jwt_response_payload_handler', # 重写导包路径
}

前端逻辑

......
if (this.error_username == false && this.error_pwd == false) {
        axios.post(this.host + '/authorizations/', { // 带着用户名和密码,向后端发起请求
            username: this.username,
            password: this.password
        }, {
            responseType: 'json', // 期望的响应数据是json类型
            withCredentials: true // 允许携带cookie,后端 CORS_ALLOW_CREDENTIALS = True  
        })
            .then(response => {
                // 使用浏览器本地存储保存token
                if (this.remember) { // 用户是否勾选
                    // 记住登录
                    sessionStorage.clear();
                    localStorage.token = response.data.token;
                    localStorage.user_id = response.data.user_id;
                    localStorage.username = response.data.username;
                } else {
                    // 未记住登录(网页关掉,就没了)
                    localStorage.clear();
                    sessionStorage.token = response.data.token;
                    sessionStorage.user_id = response.data.user_id;
                    sessionStorage.username = response.data.username;
                }

                ......
            })
            .catch(error => {
                // 对状态码进行判断,自定义错误消息
                if (error.response.status == 400) {
                    this.error_pwd_message = '用户名或密码错误';
                } else {
                    this.error_pwd_message = '服务器错误';
                }
                this.error_pwd = true;
            })
    }
  • 小结: 登录的逻辑实际上非常简单...在拓展库原有的基础上,加点东西即可

使用手机号码/用户的都可以登录

- 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方法的思路:

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

### utils.py

from users.models import UserInfo
from django.contrib.auth.backends import ModelBackend

def jwt_response_payload_handler(token, user=None, request=None):
   ......
    

class UsernameMobileAuthBackend(ModelBackend):
    def authenticate(self, request, username=None, password=None, **kwargs):
        # 验证username是手机号还是用户名
        # # 1.方法1
        # try:
        #     if re.match(r'^1[3-9]\d{9}$', username):
        #         # 如果是手机号,则与mobile属性对比
        #         user = User.objects.get(mobile=username)
        #     else:
        #         # 如果是用户名,则与username属性对比
        #         user = User.objects.get(username=username)
        # except:
        #     return None
        # else:
        #     # 如果查询到用户对象,则判断密码是否正确
        #     if user.check_password(password):
        #         return user
        #     else:
        #         return None

        # 优化-1
        try:
            # 如果是手机号,则与mobile属性对比
            user = UserInfo.objects.get(mobile=username)
        except:
            try:
                # 如果是用户名,则与username属性对比
                user = UserInfo.objects.get(username=username)
            except:
                return None
        # 如果查询到用户对象,则判断密码是否正确
        if user.check_password(password):
            return user
        else:
            return None

  • 在配置文件中告知Django使用我们自定义的认证后端
AUTHENTICATION_BACKENDS = [
    '...utils.UsernameMobileAuthBackend',
]
  • django内置的 authenticate()方法源码
......
class ModelBackend(BaseBackend):
    """
    Authenticates against settings.AUTH_USER_MODEL.
    """

    def authenticate(self, request, username=None, password=None, **kwargs):
       ......
       '''
        - 获取 user 实例对象
            - 若出错,则给user设置密码,流程就此终止

            - 若成功获取实例对象,那么校验密码是否正确,并检查该实例是否被'激活',返回user

        - 自定义我们自己的逻辑,则校验用户名和密码是否都正确

            - 都正确则返回user

            - 错误就模仿源码,返回 None
       '''
        try:
            user = UserModel._default_manager.get_by_natural_key(username)
        except UserModel.DoesNotExist:
            # Run the default password hasher once to reduce the timing
            # difference between an existing and a nonexistent user (#20760).
            UserModel().set_password(password)
        else:
            if user.check_password(password) and self.user_can_authenticate(user):
                return user
  • 现在,可以用手机号登录了

前端实现登录后的跳转回原页面

  • 演示场景
- 首页

- 用户个人中心

- 登录页

- 当从首页点击'用户个人中心'后,跳转到登录页,登录验证无误以后,页面跳转到'用户个人中心'

    - url演示: http://127.0.0.1:8080/login.html?next=/user_center_info.html
  • 实现思路
- 用户个人中心页面添加token判断

    - 验证token和user_id是否都有

        - 校验通过,就正常返回页面

        - 如果校验不通过,就携带当前url路径,向login页面发起请求

......
<!--写在head里面,优先执行-->
<head>
    ......
    <script>
        var user_id = sessionStorage.user_id || localStorage.user_id;
        var token = sessionStorage.token || localStorage.token;
        if (!(user_id && token)) {
            // 带着当前路径,作为查询字符串,向login发起请求
            location.href = '/login.html?next=/user_center_info.html';
        }
    </script>
</head>

- 登录页的逻辑: http://127.0.0.1:8080/login.html?next=/user_center_info.html

......
// 获取url路径参数
get_query_string: function (name) {
    var reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i');
    var r = window.location.search.substr(1).match(reg);
    if (r != null) {
        return decodeURI(r[2]);
    }
    return null;
},
......
// 前提是登录成功,才有以下判断
// 跳转页面,获取查询字符串,进行判断,有就跳转,没有就转到首页
var return_url = this.get_query_string('next');
if (!return_url) {
    return_url = '/index.html';
}
location.href = return_url;

QQ登录

  • 基本流程,扫码登录QQ,QQ服务器返回一个'openID'(每一个QQ号码的唯一标识)

    • 注意,必须先申请成为QQ互联的开发者(免费,微信的开发者要300块...)

    • 申请成功以后,会返回 '应用ID'和'secretKey',QQ服务器会根据这两个字段生成 openID
      所以,虽然 openID是唯一的标识,但是每个网址收到的 openID 绝对是不一样的!

  • 查询项目用户是否有这个字段(用户模型还需要自己拓展字段来存储)

    • 如果有这个字段,就允许用户登录成功

    • 如果没有这个字段,返回给用户表单数据,用户补充完毕后,再实现登录

    • 最后,网页的跳转,从哪里来,回哪里去...

如何获取openid(根据QQ互联提供的文档)

  • https://graph.qq.com/oauth2.0/authorize 发起请求

    • 必须携带四个参数

    • 所以,首先,前端必须向后端发起请求,来获取"携带了四个参数"的上述地址

    • 如果QQ服务器验证无误,会返回code(在查询字符串那边有)

  • 拿到code以后,让前端把code传递给后端,再次向QQ发起请求,获取 access token

  • 拿到access token以后,再次向QQ发起请求,获取 openid

  • 代码演示,先引入 QQ登录的SDK(逻辑很简单)

from django.conf import settings
from urllib.parse import urlencode, parse_qs
import json
import requests


class OAuthQQ(object):
    """
    QQ认证辅助工具类
    """

    def __init__(self, client_id=None, client_secret=None, redirect_uri=None, state=None):
        self.client_id = client_id 
        self.client_secret = client_secret 
        self.redirect_uri = redirect_uri
        self.state = state   # 用于保存登录成功后的跳转页面路径

    def get_qq_url(self):
        # QQ登录url参数组建
        data_dict = {
            'response_type': 'code', # 文档写死的固定值
            'client_id': self.client_id,
            'redirect_uri': self.redirect_uri, # 回调地址(登录成功以后,向项目的哪个地址发响应数据)
            'state': self.state
        }

        # 构建url: urlencode()的作用是把 dict 转换为 查询字符串的形式:www.baidu.com/?response_type=code...
        qq_url = 'https://graph.qq.com/oauth2.0/authorize?' + urlencode(data_dict)

        return qq_url

    # 获取access_token值
    def get_access_token(self, code):
        # 构建参数数据
        data_dict = {
            'grant_type': 'authorization_code',
            'client_id': self.client_id,
            'client_secret': self.client_secret,
            'redirect_uri': self.redirect_uri,
            'code': code
        }

        # 构建url
        access_url = 'https://graph.qq.com/oauth2.0/token?' + urlencode(data_dict)

        # 发送请求
        try:
            response = requests.get(access_url)

            # 提取数据
            # access_token=FE04************************CCE2&expires_in=7776000&refresh_token=88E4************************BE14
            data = response.text

            # 转化为字典
            data = parse_qs(data)
        except:
            raise Exception('qq请求失败')

        # 提取access_token
        access_token = data.get('access_token', None)

        if not access_token:
            raise Exception('access_token获取失败')

        return access_token[0]

    # 获取open_id值

    def get_open_id(self, access_token):

        # 构建请求url
        url = 'https://graph.qq.com/oauth2.0/me?access_token=' + access_token

        # 发送请求
        try:
            response = requests.get(url)

            # 提取数据
            # callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
            # code=asdasd&msg=asjdhui  错误的时候返回的结果
            data = response.text
            data = data[10:-3]
        except:
            raise Exception('qq请求失败')
        # 转化为字典
        try:
            data_dict = json.loads(data)
            # 获取openid
            openid = data_dict.get('openid')
        except:
            raise Exception('openid获取失败')

        return openid

  • 第一步,后端拼接url,返回这个url路径给前端
    前端收到以后,向这个url发起QQ登录请求
- settings 配置一些QQ要求的参数
    ......
    #--------QQ参数-------------#
    QQ_CLIENT_ID = 'xxxxx'  # APP_ID
    QQ_CLIENT_SECRET = 'yyyyyyy'  # APP_Key
    QQ_REDIRECT_URI = 'http://www.meiduo.site:8080/oauth_callback.html'  # 网站回调域
    QQ_STATE = '/'  # 登陆成功返回的地址

- views
    
    class QQAuthUrlView(APIView):

        def get(self,request):
            '''
                获取next值,生成SDK QQ登录实例,调用实例方法,返回最终拼接完成的路径
            '''
            next = request.query_params.get('next','/')
            qq_auth = OAuthQQ(client_id=settings.QQ_CLIENT_ID,client_secret=settings.QQ_CLIENT_SECRET,redirect_uri=settings.QQ_REDIRECT_URI,state=next)
            login_url = qq_auth.get_qq_url()
            return Response({'login_url':login_url})
  • 前端代码
......
// qq登录
qq_login: function () {
    var next = this.get_query_string('next') || '/';
    axios.get(this.host + '/oauth/qq/authorization/?next=' + next, {
        responseType: 'json'
    })
        .then(response => { // 若请求成功,则跳转到QQ登录页
            location.href = response.data.login_url;
        })
        .catch(error => {
            console.log(error.response.data);
        })
}