20210815# 【美多商城】

上一级:20210501# 【黑马-python】

目录

课程资料

gitee地址

https://gitee.com/gao_tian_hao/meiduo_project.git

美多商城xmind

image

1 项目准备

1.1 开发流程介绍

image-20210815102721345

image-20210815101137981

1.2 项目介绍

1.2.1 项目需求分析

需求分析原因:

  • 可以整体的了解项目的业务流程和主要的业务需求。
  • 项目中,需求驱动开发。即开发人员需要以需求为目标来实现业务逻辑。

需求分析方式:

  • 企业中,借助 产品原型图 分析需求。
  • 需求分析完后,前端按照产品原型图开发前端页面,后端开发对应的业务及响应处理

需求分析内容:

  • 页面及其业务流程和业务逻辑

提示:

  • 我们现在借助 示例网站 作为原型图来分析需求。

1. 项目主要页面介绍

1.首页广告

img

2.注册

img

3.登录

img

4.QQ登录

img

5.个人信息

img

6.收货地址

img

7.我的订单

img

8.修改密码

img

9.商品列表

img

10.商品搜索

img

11.商品详情

img

12.购物车

img

13.结算订单

img

14.提交订单

img

15.支付宝支付

img

16.支付结果处理

img

17.订单商品评价

img

2. 归纳项目主要模块

为了方便项目管理及多人协同开发,我们根据需求将功能划分为不同的模块。

将来在项目中,每个模块都会对应一个子应用进行管理和解耦

模块 功能
验证 图形验证、短信验证
用户 注册、登录、用户中心
第三方登录 QQ登录
首页广告 首页广告
商品 商品列表、商品搜索、商品详情
购物车 购物车管理、购物车合并
订单 确认订单、提交订单
支付 支付宝支付、订单商品评价
MIS系统 数据统计、用户管理、权限管理、商品管理、订单管理

3. 知识要点

  1. 需求分析原因:需求驱动开发。
  2. 需求分析方式:企业中,使用产品原型图。
  3. 需求分析内容:页面及业务逻辑。
  4. 需求分析结果:划分业务模块,明确每个模块下的主要功能,并以子应用的形式进行管理。

1.1.2 项目架构设计

1. 项目开发模式

选项 技术选型
开发模式 前后端不分离
后端框架 Django + Jinja2模板引擎
前端框架 Vue.js

说明:

  • 前后端不分离的开发模式,是为了提高搜索引擎排名,即SEO。特别是首页,详情页和列表页。
  • 页面需要整体刷新:我们会选择使用Jinja2模板引擎来实现。
  • 页面需要局部刷新:我们会选择使用Vue.js来实现。

2. 项目运行机制

img

3. 知识要点

  1. 项目开发模式
    • 前后端不分离,方便SEO
    • 采用Django + Jinja2模板引擎 + Vue.js实现前后端逻辑。
  2. 项目运行机制
    • 代理服务:Nginx服务器(反向代理)
    • 静态服务:Nginx服务器(静态首页、商品详情页、...)
    • 动态服务:uwsgi服务器(美多商场业务场景)
    • 后端服务:MySQL、Redis、Celery、RabbitMQ、Docker、FastDFS、Elasticsearch、Crontab
    • 外部接口:容联云、QQ互联、支付宝

1.3 工程创建和配置

1.3.1 创建工程

美多商城项目源代码采用远程仓库托管

1. 准备项目代码仓库

1.源码托管网站

2.创建源码远程仓库:meiduo_project

img

2. 克隆项目代码仓库

1.进入本地项目目录

$ mkdir ~/projects
$ cd projects/

2.克隆仓库

$ git clone https://gitee.com/zjsharp/meiduo_project.git

3.忽略.idea

image-20210815112233578

3. 创建美多商城工程

1.进入本地项目仓库

$ cd ~/projects/meiduo_project/

2.创建美多商城虚拟环境,安装Django框架

$ mkvirtualenv -p python3.6 meiduo_mall
$ pip install django==1.11.11

3.创建美多商城Django工程

$ django-admin startproject meiduo_mall

创建工程完成后:运行程序,测试结果。

django-admin manage.py runserver

image-20210815120415611

4.美多商城第三方包

视频截图

image-20210815115644653

Ubuntu项目环境

(meiduo_project) gaotianhao@gaotianhao-virtual-machine:~/Desktop/temp/42/projects/meiduo_project/meiduo_mall$ pip list
Package            Version
------------------ ----------
amqp               2.6.1
appnope            0.1.0
backcall           0.1.0
billiard           3.5.0.5
celery             4.2.1
certifi            2018.11.29
chardet            3.0.4
charset-normalizer 2.0.4
decorator          4.3.2
Django             1.11.11
django-crontab     0.7.1
django-redis       4.10.0
dnspython          1.16.0
elasticsearch      2.4.1
eventlet           0.24.1
greenlet           0.4.15
idna               2.8
importlib-metadata 4.6.4
ipython            7.3.0
ipython-genutils   0.2.0
itsdangerous       1.1.0
jedi               0.13.2
Jinja2             2.10
kombu              4.6.11
MarkupSafe         2.0.1
monotonic          1.5
mutagen            1.42.0
parso              0.3.4
pexpect            4.6.0
pickleshare        0.7.5
pika               0.13.0
Pillow             5.4.1
pip                21.1.2
prompt-toolkit     2.0.9
ptyprocess         0.6.0
pycryptodomex      3.7.2
Pygments           2.3.1
PyMySQL            0.9.3
python-alipay-sdk  1.10.0
pytz               2018.9
QQLoginTool        0.3.0
redis              3.2.0
requests           2.21.0
setuptools         40.0.0
six                1.12.0
traitlets          4.3.2
typing-extensions  3.10.0.0
urllib3            1.24.3
vine               1.2.0
wcwidth            0.1.7
wheel              0.33.1
zipp               3.5.0


python环境相关包


pip install amqp==2.6.1
pip install appnope==0.1.0
pip install backcall==0.1.0
pip install billiard==3.5.0.5
pip install celery==4.2.1
pip install certifi==2018.11.29
pip install chardet==3.0.4
pip install decorator==4.3.2
pip install Django==1.11.11
pip install django-crontab==0.7.1
pip install django-redis==4.10.0
pip install dnspython==1.16.0
pip install elasticsearch==2.4.1
pip install eventlet==0.24.1
pip install fdfs-client-py==1.2.6
pip install greenlet==0.4.15
pip install idna==2.8
pip install importlib-metadata==4.6.4
pip install ipython==7.3.0
pip install ipython-genutils==0.2.0
pip install itsdangerous==1.1.0
pip install jedi==0.13.2
pip install Jinja2==2.10
pip install kombu==4.6.11
pip install MarkupSafe==2.0.1
pip install monotonic==1.5
pip install mutagen==1.42.0
pip install parso==0.3.4
pip install pexpect==4.6.0
pip install pickleshare==0.7.5
pip install pika==0.13.0
pip install Pillow==5.4.1
pip install pip==21.1.2
pip install prompt-toolkit==2.0.9
pip install ptyprocess==0.6.0
pip install pycryptodomex==3.7.2
pip install Pygments==2.3.1
pip install PyMySQL==0.9.3
pip install python-alipay-sdk==1.10.0
pip install pytz==2018.9
pip install QQLoginTool==0.3.0
pip install redis==3.2.0
pip install requests==2.21.0
pip install setuptools==40.0.0
pip install six==1.12.0
pip install traitlets==4.3.2
pip install urllibs==1.24.1
pip install uWSGI==2.0.18
pip install typing-extensions==3.10.0.0
pip install vine==1.2.0
pip install wcwidth==0.1.7
pip install wheel==0.33.1
pip install zipp==3.5.0

1.3.2 配置开发环境

美多商城项目的环境分为开发环境生产环境

  • 开发环境:用于编写和调试项目代码。
  • 生产环境:用于项目线上部署运行。

1. 新建配置文件

  1. 准备配置文件目录
    • 新建包,命名为settings,作为配置文件目录
  2. 准备开发和生产环境配置文件
    • 在配置包settings中,新建开发和生产环境配置文件
  3. 准备开发环境配置内容
    • 将默认的配置文件settings.py中内容拷贝至dev.py

img

2. 指定开发环境配置文件

img

配置完成后:运行程序,测试结果。

1.3.3 配置Jinja2模板引擎

美多商城的模板采用Jinja2模板引擎

1. 安装Jinja2扩展包

pip install Jinja2

2. 配置Jinja2模板引擎

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.jinja2.Jinja2',  # jinja2模板引擎
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

3. 补充Jinja2模板引擎环境

1.创建Jinja2模板引擎环境配置文件

img

2.编写Jinja2模板引擎环境配置代码

from jinja2 import Environment
from django.urls import reverse
from django.contrib.staticfiles.storage import staticfiles_storage


def jinja2_environment(**options):
    """jinja2环境"""

    # 创建环境对象
    env = Environment(**options)

    # 自定义语法:{{ static('静态文件相对路径') }} {{ url('路由的命名空间') }}
    env.globals.update({
        'static': staticfiles_storage.url, # 获取静态文件的前缀
        'url': reverse, # 反向解析
    })

    # 返回环境对象
    return env
"""
确保可以使用模板引擎中的{{ static('') }}、{{  url('')  }} 这类语句
"""

img

img

3.加载Jinja2模板引擎环境

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.jinja2.Jinja2',  # jinja2模板引擎
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
            # 补充Jinja2模板引擎环境
            'environment': 'meiduo_mall.utils.jinja2_env.jinja2_environment', 
        },
    },
]

配置完成后:运行程序,测试结果。

1.3.4 配置MySQL数据库

美多商城数据存储服务采用MySQL数据库

1. 新建MySQL数据库

1.新建MySQL数据库:meiduo_mall

create database meiduo charset=utf8;

2.新建MySQL用户

create user itheima identified by '123456';

3.授权itcast用户访问meiduo_mall数据库

grant all on meiduo.* to 'itheima'@'%';

4.授权结束后刷新特权

flush privileges;

2. 配置MySQL数据库

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql', # 数据库引擎
        'HOST': '127.0.0.1', # 数据库主机
        'PORT': 3306, # 数据库端口
        'USER': 'itheima', # 数据库用户名
        'PASSWORD': '123456', # 数据库用户密码
        'NAME': 'meiduo' # 数据库名字
    },
}

可能出现的错误

  • Error loading MySQLdb module: No module named 'MySQLdb'.

出现错误的原因:

  • Django中操作MySQL数据库需要驱动程序MySQLdb
  • 目前项目虚拟环境中没有驱动程序MySQLdb

解决办法:

  • 安装PyMySQL扩展包
  • 因为MySQLdb只适用于Python2.x的版本,Python3.x的版本中使用PyMySQL替代MySQLdb

3. 安装PyMySQL扩展包

1.安装驱动程序

pip install PyMySQL

2.在工程同名子目录的__init__.py文件中,添加如下代码:

from pymysql import install_as_MySQLdb

install_as_MySQLdb()

配置完成后:运行程序,测试结果。

1.3.5 配置Redis数据库

美多商城数据缓存服务采用Redis数据库

1. 安装django-redis扩展包

1.安装django-redis扩展包

pip install django-redis

2.django-redis使用说明文档

点击进入文档

2. 配置Redis数据库

CACHES = {
    "default": { # 默认
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/0",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    },
    "session": { # session
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    },
}
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "session"

default:

  • 默认的Redis配置项,采用0号Redis库。

session:

  • 状态保持的Redis配置项,采用1号Redis库。

SESSION_ENGINE

  • 修改session存储机制使用Redis保存。

SESSION_CACHE_ALIAS:

  • 使用名为"session"的Redis配置项存储session数据

配置完成后:运行程序,测试结果。

1.3.6 配置工程日志

美多商城的日志记录采用logging模块

1. 配置工程日志

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,  # 是否禁用已经存在的日志器
    'formatters': {  # 日志信息显示的格式
        'verbose': {
            'format': '%(levelname)s %(asctime)s %(module)s %(lineno)d %(message)s'
        },
        'simple': {
            'format': '%(levelname)s %(module)s %(lineno)d %(message)s'
        },
    },
    'filters': {  # 对日志进行过滤
        'require_debug_true': {  # django在debug模式下才输出日志
            '()': 'django.utils.log.RequireDebugTrue',
        },
    },
    'handlers': {  # 日志处理方法
        'console': {  # 向终端中输出日志
            'level': 'INFO',
            'filters': ['require_debug_true'],
            'class': 'logging.StreamHandler',
            'formatter': 'simple'
        },
        'file': {  # 向文件中输出日志
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': os.path.join(os.path.dirname(BASE_DIR), 'logs/meiduo.log'),  # 日志文件的位置
            'maxBytes': 300 * 1024 * 1024,
            'backupCount': 10,
            'formatter': 'verbose'
        },
    },
    'loggers': {  # 日志器
        'django': {  # 定义了一个名为django的日志器
            'handlers': ['console', 'file'],  # 可以同时向终端与文件中输出日志
            'propagate': True,  # 是否继续传递日志信息
            'level': 'INFO',  # 日志器接收的最低日志级别
        },
    }
}

2. 准备日志文件目录

img

3. 日志记录器的使用

import logging

# 创建日志记录器
logger = logging.getLogger('django')
# 输出日志
logger.debug('测试logging模块debug')
logger.info('测试logging模块info')
logger.error('测试logging模块error')

4. Git管理工程日志

提示1:

  • 开发过程中,产生的日志信息不需要代码仓库进行管理和记录。

img

提示2:

  • 建立代码仓库时,生成的忽略文件中已经默认忽略掉了 *.log

img

问题:

  • logs文件目录需求被Git仓库记录和管理。
  • 当把 *.log 都忽略掉后,logs文件目录为空。
  • 但是,Git是不允许提交一个空的目录到版本库上的。

解决:

  • 在空文件目录中建立一个 .gitkeep 文件,然后即可提交。

img

img

配置完成后:运行程序,测试结果。

5. 知识要点

  1. 本项目最低日志等级设置为:INFO

  2. 创建日志记录器的方式:

    logger = logging.getLogger('django')
    
  3. 日志记录器的使用:

    logger.info('测试logging模块info')
    
  4. 在日志loggers选项中可以指定多个日志记录器

1.3.7 配置前端静态文件

美多商城项目中需要使用静态文件,比如 css、images、js 等等。

1. 准备静态文件

img

img

2. 指定静态文件加载路径

STATIC_URL = '/static/'

# 配置静态文件加载路径
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')]

配置完成后:运行程序,测试结果。

2 用户注册

2.1 展示用户注册页面

2.1.1 创建用户模块子应用

1. 创建用户模块子应用

1.准备apps包,用于管理所有应用

img

2.在apps包下创建应用users

$ cd ~/projects/meiduo_project/meiduo_mall/meiduo_mall/apps
$ python ../../manage.py startapp users

img

2. 查看项目导包路径

重要提示:

  • 若要知道如何导入users应用并完成注册,需要知道项目导包路径

img

已知导包路径

  • meiduo_project/meiduo_mall

已知 'users'应用所在目录

  • meiduo_project/meiduo_mall/meiduo_mall/apps/users

得到导入'users'应用的导包路径是:meiduo_mall/apps/users

3. 注册用户模块子应用

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'meiduo_mall.apps.users', # 用户模块应用
]

注册完users应用后,运行测试程序。

2.1.2 追加导包路径

思考:

  • 是否可以将注册users应用做的更加简便?
  • 按照如下形式,直接以应用名users注册
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'users', # 用户模块应用
]

分析:

  • 已知导包路径
    • meiduo_project/meiduo_mall
  • 已知'users'应用所在目录
    • meiduo_project/meiduo_mall/meiduo_mall/apps/users
  • 若要直接以应用名'users'注册
    • 需要一个导包路径:meiduo_project/meiduo_mall/meiduo_mall/apps

解决办法

* 追加导包路径:`meiduo_project/meiduo_mall/meiduo_mall/apps`

1. 追加导包路径

1.查看项目BASE_DIR

img

2.追加导包路径

img

img

2. 重新注册用户模块应用

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'users', # 用户模块应用
]

重新注册完users应用后,运行测试程序。

3. 知识要点

  1. 查看导包路径
    • 通过查看导包路径,可以快速的知道项目中各个包该如何的导入。
    • 特别是接手老项目时,可以尽快的适应项目导包的方式。
  2. 追加导包路径
    • 通过追加导包路径,可以简化某些目录复杂的导包方式。

2.1.3 展示用户注册页面

1. 准备用户注册模板文件

img

加载页面静态文件

<head>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
    <title>美多商城-注册</title>
    <link rel="stylesheet" type="text/css" href="{{ static('css/reset.css') }}">
    <link rel="stylesheet" type="text/css" href="{{ static('css/main.css') }}">
</head>

2. 定义用户注册视图

class RegisterView(View):
    """用户注册"""

    def get(self, request):
        """
        提供注册界面
        :param request: 请求对象
        :return: 注册界面
        """
        return render(request, 'register.html')

3. 定义用户注册路由

1.总路由

urlpatterns = [
    # users
    url(r'^', include('users.urls', namespace='users')),
]

2.子路由

urlpatterns = [
    # 注册
    url(r'^register/$', views.RegisterView.as_view(), name='register'),
]

2.2 用户模型类

2.2.1 定义用户模型类

1. Django默认用户认证系统

  • Django自带用户认证系统
    • 它处理用户账号、组、权限以及基于cookie的用户会话。
  • Django认证系统位置
    • django.contrib.auth包含认证框架的核心和默认的模型。
    • django.contrib.contenttypes是Django内容类型系统,它允许权限与你创建的模型关联。
  • Django认证系统同时处理认证和授权
    • 认证:验证一个用户是否它声称的那个人,可用于账号登录。
    • 授权:授权决定一个通过了认证的用户被允许做什么。
  • Django认证系统包含的内容
    • 用户:用户模型类、用户认证。
    • 权限:标识一个用户是否可以做一个特定的任务,MIS系统常用到。
    • 组:对多个具有相同权限的用户进行统一管理,MIS系统常用到。
    • 密码:一个可配置的密码哈希系统,设置密码、密码校验。

2. Django默认用户模型类

  • Django认证系统中提供了用户模型类User保存用户的数据。

    • User对象是认证系统的核心。
  • Django认证系统用户模型类位置

    • django.contrib.auth.models.User

      img

  • 父类AbstractUser介绍

    • User对象基本属性

      • 创建用户(注册用户)必选: username、password
      • 创建用户(注册用户)可选:email、first_name、last_name、last_login、date_joined、is_active 、is_staff、is_superuse
      • 判断用户是否通过认证(是否登录):is_authenticated
    • 创建用户(注册用户)的方法

      user = User.objects.create_user(username, email, password, **extra_fields)
      
    • 用户认证(用户登录)的方法

      from django.contrib.auth import authenticate
      user = authenticate(username=username, password=password, **kwargs)
      
    • 处理密码的方法

      • 设置密码:set_password(raw_password)
      • 校验密码:check_password(raw_password)

3. 自定义用户模型类

思考:为什么要自定义用户模型类?

  • 观察注册界面会发现,美多商城注册数据必选用户mobile信息
  • 但是Django默认用户模型类中没有mobile字段,所以要自定义用户模型类。

如何自定义用户模型类?

  • 继承自AbstractUser(可通过阅读Django默认用户模型类的源码得知) 。
  • 新增mobile字段。
from django.db import models
from django.contrib.auth.models import AbstractUser

# Create your models here.


class User(AbstractUser):
    """自定义用户模型类"""
    mobile = models.CharField(max_length=11, unique=True, verbose_name='手机号')

    class Meta:
        db_table = 'tb_users'
        verbose_name = '用户'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.username

此时启动会报错,因为缺少配置

Unhandled exception in thread started by <function check_errors.<locals>.wrapper at 0x7f52777eb620>
Traceback (most recent call last):
  File "/home/gaotianhao/.virtualenvs/meiduo_project/lib/python3.6/site-packages/django/utils/autoreload.py", line 228, in wrapper
    fn(*args, **kwargs)
  File "/home/gaotianhao/.virtualenvs/meiduo_project/lib/python3.6/site-packages/django/core/management/commands/runserver.py", line 124, in inner_run
    self.check(display_num_errors=True)
  File "/home/gaotianhao/.virtualenvs/meiduo_project/lib/python3.6/site-packages/django/core/management/base.py", line 405, in check
    raise SystemCheckError(msg)
django.core.management.base.SystemCheckError: SystemCheckError: System check identified some issues:

ERRORS:
auth.User.groups: (fields.E304) Reverse accessor for 'User.groups' clashes with reverse accessor for 'User.groups'.
	HINT: Add or change a related_name argument to the definition for 'User.groups' or 'User.groups'.
auth.User.user_permissions: (fields.E304) Reverse accessor for 'User.user_permissions' clashes with reverse accessor for 'User.user_permissions'.
	HINT: Add or change a related_name argument to the definition for 'User.user_permissions' or 'User.user_permissions'.
users.User.groups: (fields.E304) Reverse accessor for 'User.groups' clashes with reverse accessor for 'User.groups'.
	HINT: Add or change a related_name argument to the definition for 'User.groups' or 'User.groups'.
users.User.user_permissions: (fields.E304) Reverse accessor for 'User.user_permissions' clashes with reverse accessor for 'User.user_permissions'.
	HINT: Add or change a related_name argument to the definition for 'User.user_permissions' or 'User.user_permissions'.

System check identified 4 issues (0 silenced).

4. 知识要点

  1. Django自带用户认证系统核心就是User对象,并封装了一系列可用的方法和属性。
  2. Django用户认证系统包含了一系列对用户的操作,比如:模型类,认证,权限,分组,密码处理等。
  3. Django用户认证系统中的用户模型类可以自定义,继承自AbstractUser
  4. Django用户认证系统说明文档

2.2.2 迁移用户模型类

1. 指定用户模型类

思考:为什么Django默认用户模型类是User?

  • 阅读源代码:'django.conf.global_settings'

    AUTH_USER_MODEL = 'auth.User'
    

结论:

  • Django用户模型类是通过全局配置项 AUTH_USER_MODEL 决定的

配置规则:

AUTH_USER_MODEL = '应用名.模型类名'
# 指定本项目用户模型类
AUTH_USER_MODEL = 'users.User'

2. 迁移用户模型类

1.创建迁移文件

  • python manage.py makemigrations

img

2.执行迁移文件

  • python manage.py migrate

img

3. 知识要点

  1. 用户认证系统中的用户模型类,是通过全局配置项 AUTH_USER_MODEL 决定的。
  2. 如果迁移自定义用户模型类,必须先配置 AUTH_USER_MODEL

2.3 用户注册业务实现

2.3.1 用户注册业务逻辑分析

1. 用户注册业务逻辑分析

img

2.3.2 用户注册接口设计和定义

1. 设计接口基本思路

  • 对于接口的设计,我们要根据具体的业务逻辑,设计出适合业务逻辑的接口。
  • 设计接口的思路:
    • 分析要实现的业务逻辑:
      • 明确在这个业务中涉及到几个相关子业务。
      • 将每个子业务当做一个接口来设计。
    • 分析接口的功能任务,明确接口的访问方式与返回数据:
      • 请求方法(如GET、POST、PUT、DELETE等)。
      • 请求地址。
      • 请求参数(如路径参数、查询字符串、表单、JSON等)。
      • 响应数据(如HTML、JSON等)。

2. 用户注册接口设计

1.请求方式

选项 方案
请求方法 POST
请求地址 /register/

2.请求参数:表单参数

参数名 类型 是否必传 说明
username string 用户名
password string 密码
password2 string 确认密码
mobile string 手机号
sms_code string 短信验证码
allow string 是否同意用户协议

3.响应结果:HTML

  • register.html
响应结果 响应内容
注册失败 响应错误提示
注册成功 重定向到首页

3. 用户注册接口定义

1.注册视图

class RegisterView(View):
    """用户注册"""

    def get(self, request):
        """
        提供注册界面
        :param request: 请求对象
        :return: 注册界面
        """
        return render(request, 'register.html')

    def post(self, request):
        """
        实现用户注册
        :param request: 请求对象
        :return: 注册结果
        """
        pass

2.总路由

urlpatterns = [
    # users
    url(r'^', include('users.urls', namespace='users')),
]

3.子路由

urlpatterns = [
    # 注册
    url(r'^register/$', views.RegisterView.as_view(), name='register'),
]

2.3.3 用户注册前端逻辑

为了学会使用Vue.js的双向绑定实现用户的交互和页面局部刷新效果。

1. 用户注册页面绑定Vue数据

1.准备div盒子标签

<div id="app">
    <body>
    ......
    </body>
</div>

2.register.html

  • 绑定内容:变量、事件、错误提示等
<form method="post" class="register_form" @submit="on_submit" v-cloak>
    {{ csrf_input }}
    <ul>
        <li>
            <label>用户名:</label>
            <input type="text" v-model="username" @blur="check_username" name="username" id="user_name">
            <span class="error_tip" v-show="error_name">[[ error_name_message ]]</span>
        </li>
        <li>
            <label>密码:</label>
            <input type="password" v-model="password" @blur="check_password" name="password" id="pwd">
            <span class="error_tip" v-show="error_password">请输入8-20位的密码</span>
        </li>
        <li>
            <label>确认密码:</label>
            <input type="password" v-model="password2" @blur="check_password2" name="password2" id="cpwd">
            <span class="error_tip" v-show="error_password2">两次输入的密码不一致</span>
        </li>
        <li>
            <label>手机号:</label>
            <input type="text" v-model="mobile" @blur="check_mobile" name="mobile" id="phone">
            <span class="error_tip" v-show="error_mobile">[[ error_mobile_message ]]</span>
        </li>
        <li>
            <label>图形验证码:</label>
            <input type="text" name="image_code" id="pic_code" class="msg_input">
            <img src="{{ static('images/pic_code.jpg') }}" alt="图形验证码" class="pic_code">
            <span class="error_tip">请填写图形验证码</span>
        </li>
        <li>
            <label>短信验证码:</label>
            <input type="text" name="sms_code" id="msg_code" class="msg_input">
            <a href="javascript:;" class="get_msg_code">获取短信验证码</a>
            <span class="error_tip">请填写短信验证码</span>
        </li>
        <li class="agreement">
            <input type="checkbox" v-model="allow" @change="check_allow" name="allow" id="allow">
            <label>同意”美多商城用户使用协议“</label>
            <span class="error_tip2" v-show="error_allow">请勾选用户协议</span>
        </li>
        <li class="reg_sub">
            <input type="submit" value="注 册">
        </li>
    </ul>
</form>

知识点:

  • v-cloak
    • 这个指令保持在元素上直到关联实例结束编译。和 CSS 规则如 [v-cloak] { display: none } 一起用时,这个指令可以隐藏未编译的 Mustache 标签直到实例准备完毕。
  • {{ csrf_input }}
    • 增加一个csrf隐藏域,防止 CSRF 攻击

2. 用户注册JS文件实现用户交互

1.导入Vue.js库和ajax请求的库

<script type="text/javascript" src="{{ static('js/vue-2.5.16.js') }}"></script>
<script type="text/javascript" src="{{ static('js/axios-0.18.0.min.js') }}"></script>

2.准备register.js文件

<script type="text/javascript" src="{{ static('js/register.js') }}"></script>

绑定内容:变量、事件、错误提示等

let vm = new Vue({
    el: '#app',
    // 修改Vue读取变量的语法
    delimiters: ['[[', ']]'],
    data: {
        // v-model
        username: '',
        password: '',
        password2: '',
        mobile: '',
        allow: '',

        // v-show
        error_name: false,
        error_password: false,
        error_password2: false,
        error_mobile: false,
        error_allow: false,

        // error_message
        error_name_message: '',
        error_mobile_message: '',
    },
    methods: {
        // 校验用户名
        check_username(){
        },
        // 校验密码
        check_password(){
        },
        // 校验确认密码
        check_password2(){
        },
        // 校验手机号
        check_mobile(){
        },
        // 校验是否勾选协议
        check_allow(){
        },
        // 监听表单提交事件
        on_submit(){
        },
    }
});

3.用户交互事件实现

methods: {
    // 校验用户名
    check_username(){
        let re = /^[a-zA-Z0-9_-]{5,20}$/;
        if (re.test(this.username)) {
            this.error_name = false;
        } else {
            this.error_name_message = '请输入5-20个字符的用户名';
            this.error_name = true;
        }
    },
    // 校验密码
    check_password(){
        let re = /^[0-9A-Za-z]{8,20}$/;
        if (re.test(this.password)) {
            this.error_password = false;
        } else {
            this.error_password = true;
        }
    },
    // 校验确认密码
    check_password2(){
        if(this.password != this.password2) {
            this.error_password2 = true;
        } else {
            this.error_password2 = false;
        }
    },
    // 校验手机号
    check_mobile(){
        let re = /^1[3-9]\d{9}$/;
        if(re.test(this.mobile)) {
            this.error_mobile = false;
        } else {
            this.error_mobile_message = '您输入的手机号格式不正确';
            this.error_mobile = true;
        }
    },
    // 校验是否勾选协议
    check_allow(){
        if(!this.allow) {
            this.error_allow = true;
        } else {
            this.error_allow = false;
        }
    },
    // 监听表单提交事件
    on_submit(){
        this.check_username();
        this.check_password();
        this.check_password2();
        this.check_mobile();
        this.check_allow();

        if(this.error_name == true || this.error_password == true || this.error_password2 == true
            || this.error_mobile == true || this.error_allow == true) {
            // 禁用表单的提交
            window.event.returnValue = false;
        }
    },
}

3. 知识要点

  1. Vue绑定页面的套路
    • 导入Vue.js库和ajax请求的库
    • 准备div盒子标签
    • 准备js文件
    • html页面绑定变量、事件等
    • js文件定义变量、事件等
  2. 错误提示
    • 如果错误提示信息是固定的,可以把错误提示信息写死,再通过v-show控制是否展示
    • 如果错误提示信息不是固定的,可以使用绑定的变量动态的展示错误提示信息,再通过v-show控制是否展示
  3. 修改Vue变量的读取语法,避免和Django模板语法冲突
    • delimiters: ['[[', ']]']
  4. 后续的页面中如果有类似的交互和刷新效果,也可按照此套路实现

2.3.4 用户注册后端逻辑

1. 接收参数

提示:用户注册数据是从注册表单发送过来的,所以使用request.POST来提取。

username = request.POST.get('username')
password = request.POST.get('password')
password2 = request.POST.get('password2')
mobile = request.POST.get('mobile')
allow = request.POST.get('allow')

2. 校验参数

前端校验过的后端也要校验,后端的校验和前端的校验是一致的

# 判断参数是否齐全
# 判断用户名是否是5-20个字符
# 判断密码是否是8-20个数字
# 判断两次密码是否一致
# 判断手机号是否合法
# 判断是否勾选用户协议
# 判断参数是否齐全
if not all([username, password, password2, mobile, allow]):
    return http.HttpResponseForbidden('缺少必传参数')
# 判断用户名是否是5-20个字符
if not re.match(r'^[a-zA-Z0-9_-]{5,20}$', username):
    return http.HttpResponseForbidden('请输入5-20个字符的用户名')
# 判断密码是否是8-20个数字
if not re.match(r'^[0-9A-Za-z]{8,20}$', password):
    return http.HttpResponseForbidden('请输入8-20位的密码')
# 判断两次密码是否一致
if password != password2:
    return http.HttpResponseForbidden('两次输入的密码不一致')
# 判断手机号是否合法
if not re.match(r'^1[3-9]\d{9}$', mobile):
    return http.HttpResponseForbidden('请输入正确的手机号码')
# 判断是否勾选用户协议
if allow != 'on':
    return http.HttpResponseForbidden('请勾选用户协议')

提示:这里校验的参数,前端已经校验过,如果此时参数还是出错,说明该请求是非正常渠道发送的,所以直接禁止本次请求。

3. 保存注册数据

  • 这里使用Django认证系统用户模型类提供的 create_user() 方法创建新的用户。
  • 这里 create_user() 方法中封装了 set_password() 方法加密密码。
# 保存注册数据
try:
    User.objects.create_user(username=username, password=password, mobile=mobile)
except DatabaseError:
    return render(request, 'register.html', {'register_errmsg': '注册失败'})

# 响应注册结果
return http.HttpResponse('注册成功,重定向到首页')

如果注册失败,我们需要在页面上渲染出注册失败的提示信息。

{% if register_errmsg %}
    <span class="error_tip2">{{ register_errmsg }}</span>
{% endif %}

4. 响应注册结果

  • 重要提示:注册成功,重定向到首页

1.创建首页广告应用:contents

$ cd ~/projects/meiduo_project/meiduo_mall/meiduo_mall/apps
$ python ../../manage.py startapp contents

img

2.定义首页广告视图:IndexView

class IndexView(View):
    """首页广告"""

    def get(self, request):
        """提供首页广告界面"""
        return render(request, 'index.html')

3.配置首页广告路由:绑定命名空间

# contents
url(r'^', include('contents.urls', namespace='contents')),
# 首页广告
url(r'^$', views.IndexView.as_view(), name='index'),

4.测试首页广告是否可以正常访问

http://127.0.0.1:8000/

5.响应注册结果:重定向到首页

# 响应注册结果
return redirect(reverse('contents:index'))

5. 知识要点

  1. 后端逻辑编写套路:
    • 业务逻辑分析
    • 接口设计和定义
    • 接收和校验参数
    • 实现主体业务逻辑
    • 响应结果
  2. 注册业务逻辑核心思想:
    • 保存用户注册数据

2.3.5 状态保持

说明:

  • 如果需求是注册成功后即表示用户登入成功,那么此时可以在注册成功后实现状态保持
  • 如果需求是注册成功后不表示用户登入成功,那么此时不用在注册成功后实现状态保持

美多商城的需求是:注册成功后即表示用户登入成功

1. login()方法介绍

  1. 用户登入本质:

    • 状态保持
    • 将通过认证的用户的唯一标识信息(比如:用户ID)写入到当前浏览器的 cookie 和服务端的 session 中。
  2. login()方法:

    • Django用户认证系统提供了login()方法。
    • 封装了写入session的操作,帮助我们快速登入一个用户,并实现状态保持。
  3. login()位置:

    • django.contrib.auth.__init__.py文件中。

      login(request, user, backend=None)
      
  4. 状态保持 session 数据存储的位置:

    Redis数据库的1号库

     SESSION_ENGINE = "django.contrib.sessions.backends.cache"
     SESSION_CACHE_ALIAS = "session"
    

2. login()方法登入用户

# 保存注册数据
try:
    user = User.objects.create_user(username=username, password=password, mobile=mobile)
except DatabaseError:
    return render(request, 'register.html', {'register_errmsg': '注册失败'})

# 登入用户,实现状态保持
login(request, user)

# 响应注册结果
return redirect(reverse('contents:index'))

3. 查看状态保持结果

img

img

4. 知识要点

  1. 登入用户,并实现状态保持的方式:login(request, user, backend=None)

2.3.6 用户名重复注册

1. 用户名重复注册逻辑分析

img

2. 用户名重复注册接口设计和定义

1.请求方式

选项 方案
请求方法 GET
请求地址 /usernames/(?P[a-zA-Z0-9_-]{5,20})/count/

2.请求参数:路径参数

参数名 类型 是否必传 说明
username string 用户名

3.响应结果:JSON

响应结果 响应内容
code 状态码
errmsg 错误信息
count 记录该用户名的个数

3. 用户名重复注册后端逻辑

视图

class UsernameCountView(View):
    """判断用户名是否重复注册"""

    def get(self, request, username):
        """
        :param request: 请求对象
        :param username: 用户名
        :return: JSON
        """
        count = User.objects.filter(username=username).count()
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK', 'count': count})

总路由

# users
url(r'^', include('users.urls', namespace='users')),

子路由

# 判断用户是否已经注册
url(r'^usernames/(?P<username>[a-zA-z0-9_-]{5,20})/count/', views.UsernameCountView.as_view()),

4. 用户名重复注册前端逻辑

if (this.error_name == false) {
    let url = '/usernames/' + this.username + '/count/';
    axios.get(url,{
        responseType: 'json'
    })
        .then(response => {
            if (response.data.count == 1) {
                this.error_name_message = '用户名已存在';
                this.error_name = true;
            } else {
                this.error_name = false;
            }
        })
        .catch(error => {
            console.log(error.response);
        })
}

5. 知识要点

  1. 判断用户名重复注册的核心思想:
    • 使用用户名查询该用户名对应的记录是否存在,如果存在,表示重复注册了,反之,没有重复注册。
  2. axios发送异步请求套路:
    • 处理用户交互
    • 收集请求参数
    • 准备请求地址
    • 发送异步请求
    • 得到服务器响应
    • 控制界面展示效果

2.3.7 手机号重复注册

1. 手机号重复注册逻辑分析

img

2. 手机号重复注册接口设计和定义

1.请求方式

选项 方案
请求方法 GET
请求地址 /mobiles/(?P1[3-9]\d{9})/count/

2.请求参数:路径参数

参数名 类型 是否必传 说明
mobile string 手机号

3.响应结果:JSON

响应结果 响应内容
code 状态码
errmsg 错误信息
count 记录该用户名的个数

3. 手机号重复注册后端逻辑

视图

class MobileCountView(View):
    """判断手机号是否重复注册"""

    def get(self, request, mobile):
        """
        :param request: 请求对象
        :param mobile: 手机号
        :return: JSON
        """
        count = User.objects.filter(mobile=mobile).count()
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK', 'count': count})

总路由

# 判断用户是否已经注册
url(r'^usernames/(?P<username>[a-zA-z0-9_-]{5,20})/count/', views.UsernameCountView.as_view()),

子路由

# 判断用户注册手机号是否已重复
url(r'^mobiles/(?P<mobile>1[3-9]\d{9})/count/', views.MobileCountView.as_view()),

4. 手机号重复注册前端逻辑

if (this.error_mobile == false) {
    let url = '/mobiles/'+ this.mobile + '/count/';
    axios.get(url, {
        responseType: 'json'
    })
        .then(response => {
            if (response.data.count == 1) {
                this.error_mobile_message = '手机号已存在';
                this.error_mobile = true;
            } else {
                this.error_mobile = false;
            }
        })
        .catch(error => {
            console.log(error.response);
        })
}

3 验证码

3.1 图形验证码

3.1.1 图形验证码逻辑分析

img

需要新建应用verifications

知识要点

  1. 将图形验证码的文字信息保存到Redis数据库,为短信验证码做准备。
  2. UUID 用于唯一区分该图形验证码属于哪个用户,也可使用其他唯一标识信息来实现。

3.1.2 图形验证码接口设计和定义

1. 图形验证码接口设计

1.请求方式

选项 方案
请求方法 GET
请求地址 image_codes/(?P[\w-]+)/

2.请求参数:路径参数

参数名 类型 是否必传 说明
uuid string 唯一编号

3.响应结果:image/jpg

img

2. 图形验证码接口定义

1.图形验证码视图

class ImageCodeView(View):
    """图形验证码"""

    def get(self, request, uuid):
        """
        :param request: 请求对象
        :param uuid: 唯一标识图形验证码所属于的用户
        :return: image/jpg
        """
        pass

2.总路由

# verifications
url(r'^', include('verifications.urls')),

3.子路由

# 图形验证码
url(r'^image_codes/(?P<uuid>[\w-]+)/$', views.ImageCodeView.as_view()),

3.1.3 图形验证码后端逻辑

1. 准备captcha扩展包

提示:captcha扩展包用于后端生成图形验证码

img

可能出现的错误

  • 报错原因:环境中没有Python处理图片的库:PIL

img

解决办法

  • 安装Python处理图片的库:pip install Pillow

2. 准备Redis数据库

准备Redis的2号库存储验证码数据

"verify_code": { # 验证码
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/2",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    },

3. 图形验证码后端逻辑实现

class ImageCodeView(View):
    """图形验证码"""

    def get(self, request, uuid):
        """
        :param request: 请求对象
        :param uuid: 唯一标识图形验证码所属于的用户
        :return: image/jpg
        """
        # 生成图片验证码
        text, image = captcha.generate_captcha()

        # 保存图片验证码
        redis_conn = get_redis_connection('verify_code')
        redis_conn.setex('img_%s' % uuid, constants.IMAGE_CODE_REDIS_EXPIRES, text)

        # 响应图片验证码
        return http.HttpResponse(image, content_type='image/jpg')

3.1.4 图形验证码前端逻辑

1. Vue实现图形验证码展示

1.register.js

mounted(){
    // 生成图形验证码
    this.generate_image_code();
},
methods: {
    // 生成图形验证码
    generate_image_code(){
        // 生成UUID。generateUUID() : 封装在common.js文件中,需要提前引入
        this.uuid = generateUUID();
        // 拼接图形验证码请求地址
        this.image_code_url = "/image_codes/" + this.uuid + "/";
    },
    ......
}

2.register.html

<li>
    <label>图形验证码:</label>
    <input type="text" name="image_code" id="pic_code" class="msg_input">
    <img :src="image_code_url" @click="generate_image_code" alt="图形验证码" class="pic_code">
    <span class="error_tip">请填写图形验证码</span>
</li>

3.图形验证码展示和存储效果

img

img

2. Vue实现图形验证码校验

1.register.html

<li>
    <label>图形验证码:</label>
    <input type="text" v-model="image_code" @blur="check_image_code" name="image_code" id="pic_code" class="msg_input">
    <img :src="image_code_url" @click="generate_image_code" alt="图形验证码" class="pic_code">
    <span class="error_tip" v-show="error_image_code">[[ error_image_code_message ]]</span>
</li>

2.register.js

check_image_code(){
    if(!this.image_code) {
        this.error_image_code_message = '请填写图片验证码';
        this.error_image_code = true;
    } else {
        this.error_image_code = false;
    }
},

3.图形验证码校验效果

img

3.2 短信验证码

3.2.1 短信验证码逻辑分析

image-20210818091500533

知识要点

  1. 保存短信验证码是为注册做准备的。
  2. 为了避免用户使用图形验证码恶意测试,后端提取了图形验证码后,立即删除图形验证码。
  3. Django不具备发送短信的功能,所以我们借助第三方的容联云通讯短信平台来帮助我们发送短信验证码。

3.2.2 容联云通讯短信平台

1. 容联云通讯短信平台介绍

1.容联云官网

img

2.容联云管理控制台

img

3.容联云创建应用

img

img

4.应用申请上线,并进行资质认证(忽略)

  • 说明:现在已经不支持个人用户认证了,所以也不能进行应用申请,但是不影响使用

img

img

5.完成资质认证,应用成功上线(忽略)

img

img

6.添加测试号码

img

img

7.短信模板

img

img

2. 容联云通讯短信SDK测试

1.模板短信SDK下载

2.模板短信SDK使用说明

3.集成模板短信SDK

  • CCPRestSDK.py:由容联云通讯开发者编写的官方SDK文件,包括发送模板短信的方法
  • ccp_sms.py:调用发送模板短信的方法

img

4.模板短信SDK测试

  • ccp_sms.py文件中
# -*- coding:utf-8 -*-

# import ssl
# ssl._create_default_https_context =ssl._create_stdlib_context # 解决Mac开发环境下,网络错误的问题

from verifications.libs.yuntongxun.CCPRestSDK import REST

# 说明:主账号,登陆云通讯网站后,可在"控制台-应用"中看到开发者主账号ACCOUNT SID
_accountSid = '8aaf07087b52c64e017b56ddffef00f3'

# 说明:主账号Token,登陆云通讯网站后,可在控制台-应用中看到开发者主账号AUTH TOKEN
_accountToken = 'cd3458081b994dca916bf9f88a8689b5'

# 请使用管理控制台首页的APPID或自己创建应用的APPID
_appId = '8aaf07087b52c64e017b56e1c75300fd'

# 说明:请求地址,生产环境配置成app.cloopen.com
_serverIP = 'sandboxapp.cloopen.com'

# 说明:请求端口 ,生产环境为8883
_serverPort = "8883"

# 说明:REST API版本号保持不变
_softVersion = '2013-12-26'


# 云通讯官方提供的发送短信代码实例
# 发送模板短信
# @param to 手机号码
# @param datas 内容数据 格式为数组 例如:{'12','34'},如不需替换请填 ''
# @param $tempId 模板Id
def sendTemplateSMS(to, datas, tempId):
    # 初始化REST SDK
    rest = REST(_serverIP, _serverPort, _softVersion)
    rest.setAccount(_accountSid, _accountToken)
    rest.setAppId(_appId)

    result = rest.sendTemplateSMS(to, datas, tempId)
    print(result)


if __name__ == '__main__':
    # 注意: 测试的短信模板编号为1
    sendTemplateSMS('18037757296', ['123456', 5], 1)

5.模板短信SDK返回结果说明

{
    'statusCode': '000000', // 状态码。'000000'表示成功,反之,失败
    'templateSMS': 
        {
            'smsMessageSid': 'b5768b09e5bc4a369ed35c444c13a1eb', // 短信唯一标识符
            'dateCreated': '20190125185207' // 短信发送时间
        }
}

3. 封装发送短信单例类

1.封装发送短信单例类

class CCP(object):
    """发送短信的单例类"""

    def __new__(cls, *args, **kwargs):
        # 判断是否存在类属性_instance,_instance是类CCP的唯一对象,即单例
        if not hasattr(CCP, "_instance"):
            cls._instance = super(CCP, cls).__new__(cls, *args, **kwargs)
            cls._instance.rest = REST(_serverIP, _serverPort, _softVersion)
            cls._instance.rest.setAccount(_accountSid, _accountToken)
            cls._instance.rest.setAppId(_appId)
        return cls._instance

2.封装发送短信单例方法

def send_template_sms(self, to, datas, temp_id):
    """
    发送模板短信单例方法
    :param to: 注册手机号
    :param datas: 模板短信内容数据,格式为列表,例如:['123456', 5],如不需替换请填 ''
    :param temp_id: 模板编号,默认免费提供id为1的模板
    :return: 发短信结果
    """
    result = self.rest.sendTemplateSMS(to, datas, temp_id)
    if result.get("statusCode") == "000000":
        # 返回0,表示发送短信成功
        return 0
    else:
        # 返回-1,表示发送失败
        return -1

3.测试单例类发送模板短信结果

if __name__ == '__main__':
    # 注意: 测试的短信模板编号为1
    CCP().send_template_sms('18037757296', ['123456', 5], 1)

4. 知识要点

  1. 容联云通讯只是发送短信的平台之一,还有其他云平台可用,比如,阿里云等,实现套路都是相通的。
  2. 将发短信的类封装为单例,属于性能优化的一种方案。

3.2.3 短信验证码后端逻辑

1. 短信验证码接口设计

1.请求方式

选项 方案
请求方法 GET
请求地址 /sms_codes/(?P1[3-9]\d{9})/

2.请求参数:路径参数和查询字符串

参数名 类型 是否必传 说明
mobile string 手机号
image_code string 图形验证码
uuid string 唯一编号

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息

2. 短信验证码接口定义

1 视图

class SMSCodeView(View):
    """短信验证码"""

    def get(self, reqeust, mobile):
        """
        :param reqeust: 请求对象
        :param mobile: 手机号
        :return: JSON
        """
        pass

子路由

# 短信验证码
url(r'^sms_codes/(?P<mobile>1[3-9]\d{9})/$', views.SMSCodeView.as_view()),

3. 短信验证码后端逻辑实现

class SMSCodeView(View):
    """短信验证码"""

    def get(self, reqeust, mobile):
        """
        :param reqeust: 请求对象
        :param mobile: 手机号
        :return: JSON
        """
        # 接收参数
        image_code_client = reqeust.GET.get('image_code')
        uuid = reqeust.GET.get('uuid')

        # 校验参数
        if not all([image_code_client, uuid]):
            return http.JsonResponse({'code': RETCODE.NECESSARYPARAMERR, 'errmsg': '缺少必传参数'})

        # 创建连接到redis的对象
        redis_conn = get_redis_connection('verify_code')
        # 提取图形验证码
        image_code_server = redis_conn.get('img_%s' % uuid)
        if image_code_server is None:
            # 图形验证码过期或者不存在
            return http.JsonResponse({'code': RETCODE.IMAGECODEERR, 'errmsg': '图形验证码失效'})
        # 删除图形验证码,避免恶意测试图形验证码
        try:
            redis_conn.delete('img_%s' % uuid)
        except Exception as e:
            logger.error(e)
        # 对比图形验证码
        image_code_server = image_code_server.decode()  # bytes转字符串
        if image_code_client.lower() != image_code_server.lower():  # 转小写后比较
            return http.JsonResponse({'code': RETCODE.IMAGECODEERR, 'errmsg': '输入图形验证码有误'})

        # 生成短信验证码:生成6位数验证码
        sms_code = '%06d' % random.randint(0, 999999)
        logger.info(sms_code)
        # 保存短信验证码
        redis_conn.setex('sms_%s' % mobile, constants.SMS_CODE_REDIS_EXPIRES, sms_code)
        # 发送短信验证码
        CCP().send_template_sms(mobile,[sms_code, constants.SMS_CODE_REDIS_EXPIRES // 60], constants.SEND_SMS_TEMPLATE_ID)

        # 响应结果
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': '发送短信成功'})

3.2.4 短信验证码前端逻辑

1. Vue绑定短信验证码界面

1.register.html

<li>
    <label>短信验证码:</label>
    <input type="text" v-model="sms_code" @blur="check_sms_code" name="sms_code" id="msg_code" class="msg_input">
    <a @click="send_sms_code" class="get_msg_code">[[ sms_code_tip ]]</a>
    <span class="error_tip" v-show="error_sms_code">[[ error_sms_code_message ]]</span>
</li>

2.register.js

check_sms_code(){
    if(this.sms_code.length != 6){
        this.error_sms_code_message = '请填写短信验证码';
        this.error_sms_code = true;
    } else {
        this.error_sms_code = false;
    }
},

2. axios请求短信验证码

1.发送短信验证码事件处理

send_sms_code(){
    // 避免重复点击
    if (this.sending_flag == true) {
        return;
    }
    this.sending_flag = true;

    // 校验参数
    this.check_mobile();
    this.check_image_code();
    if (this.error_mobile == true || this.error_image_code == true) {
        this.sending_flag = false;
        return;
    }

    // 请求短信验证码
    let url = '/sms_codes/' + this.mobile + '/?image_code=' + this.image_code+'&uuid='+ this.uuid;
    axios.get(url, {
        responseType: 'json'
    })
        .then(response => {
            if (response.data.code == '0') {
                // 倒计时60秒
                var num = 60;
                var t = setInterval(() => {
                    if (num == 1) {
                        clearInterval(t);
                        this.sms_code_tip = '获取短信验证码';
                        this.sending_flag = false;
                    } else {
                        num -= 1;
                        // 展示倒计时信息
                        this.sms_code_tip = num + '秒';
                    }
                }, 1000, 60)
            } else {
                if (response.data.code == '4001') {
                    this.error_image_code_message = response.data.errmsg;
                    this.error_image_code = true;
                } else { // 4002
                    this.error_sms_code_message = response.data.errmsg;
                    this.error_sms_code = true;
                }
                this.generate_image_code();
                this.sending_flag = false;
            }
        })
        .catch(error => {
            console.log(error.response);
            this.sending_flag = false;
        })
},

3.发送短信验证码效果展示

img

3 短信验证码用户交互和检验

1.register.html

<li>
    <label>短信验证码:</label>
    <input type="text" v-model="sms_code" @blur="check_sms_code" name="sms_code" id="msg_code" class="msg_input">
    <a @click="send_sms_code" class="get_msg_code">[[ sms_code_tip ]]</a>
    <span class="error_tip" v-show="error_sms_code">[[ error_sms_code_message ]]</span>
</li>

2.register.js

//校验短信验证码
check_sms_code() {
    if (this.sms_code.length != 6) {
        this.error_sms_code_message = '请填写短信验证码';
        this.error_sms_code = true;
    } else {
        this.error_sms_code = false;
    }
},

3.2.5 补充注册时短信验证逻辑

1. 补充注册时短信验证后端逻辑

1.接收短信验证码参数

sms_code_client = request.POST.get('sms_code')

2.保存注册数据之前,对比短信验证码

redis_conn = get_redis_connection('verify_code')
sms_code_server = redis_conn.get('sms_%s' % mobile)
if sms_code_server is None:
    return render(request, 'register.html', {'sms_code_errmsg':'无效的短信验证码'})
if sms_code_client != sms_code_server.decode():
    return render(request, 'register.html', {'sms_code_errmsg': '输入短信验证码有误'})

2. 补充注册时短信验证前端逻辑

1.register.html

<li>
    <label>短信验证码:</label>
    <input type="text" v-model="sms_code" @blur="check_sms_code" name="sms_code" id="msg_code" class="msg_input">
    <a @click="send_sms_code" class="get_msg_code">[[ sms_code_tip ]]</a>
    <span v-show="error_sms_code" class="error_tip">[[ error_sms_code_message ]]</span>
    {% if sms_code_errmsg %}
        <span class="error_tip">{{ sms_code_errmsg }} </span>
    {% endif %}
</li>

3.2.6 避免频繁发送短信验证码

存在的问题:

  • 虽然我们在前端界面做了60秒倒计时功能。
  • 但是恶意用户可以绕过前端界面向后端频繁请求短信验证码。

解决办法:

  • 在后端也要限制用户请求短信验证码的频率。60秒内只允许一次请求短信验证码。
  • 在Redis数据库中缓存一个数值,有效期设置为60秒。

1. 避免频繁发送短信验证码逻辑分析

img

2. 避免频繁发送短信验证码逻辑实现

1.提取、校验send_flag

send_flag = redis_conn.get('send_flag_%s' % mobile)
if send_flag:
    return http.JsonResponse({'code': RETCODE.THROTTLINGERR, 'errmsg': '发送短信过于频繁'})

2.重新写入send_flag

# 保存短信验证码
redis_conn.setex('sms_%s' % mobile, constants.SMS_CODE_REDIS_EXPIRES, sms_code)
# 重新写入send_flag
redis_conn.setex('send_flag_%s' % mobile, constants.SEND_SMS_CODE_INTERVAL, 1)

3.界面渲染频繁发送短信提示信息

if (response.data.code == '4001') {
    this.error_image_code_message = response.data.errmsg;
    this.error_image_code = true;
} else { // 4002
    this.error_sms_code_message = response.data.errmsg;
    this.error_sms_code = true;
}

3.2.7 pipeline操作Redis数据库

Redis的 C - S 架构:

  • 基于客户端-服务端模型以及请求/响应协议的TCP服务。
  • 客户端向服务端发送一个查询请求,并监听Socket返回。
  • 通常是以阻塞模式,等待服务端响应。
  • 服务端处理命令,并将结果返回给客户端。

存在的问题:

  • 如果Redis服务端需要同时处理多个请求,加上网络延迟,那么服务端利用率不高,效率降低。

解决的办法:

  • 管道pipeline

img

1. pipeline的介绍

管道pipeline

  • 可以一次性发送多条命令并在执行完后一次性将结果返回。
  • pipeline通过减少客户端与Redis的通信次数来实现降低往返延时时间。

实现的原理

  • 实现的原理是队列。
  • Client可以将三个命令放到一个tcp报文一起发送。
  • Server则可以将三条命令的处理结果放到一个tcp报文返回。
  • 队列是先进先出,这样就保证数据的顺序性。

img

2. pipeline操作Redis数据库

1.实现步骤

1. 创建Redis管道
2. 将Redis请求添加到队列
3. 执行请求

2.代码实现

# 创建Redis管道
pl = redis_conn.pipeline()
# 将Redis请求添加到队列
pl.setex('sms_%s' % mobile, constants.SMS_CODE_REDIS_EXPIRES, sms_code)
pl.setex('send_flag_%s' % mobile, constants.SEND_SMS_CODE_INTERVAL, 1)
# 执行请求
pl.execute()

3.3 异步方案RabbitMQ和Celery

3.3.1 生产者消费者设计模式

思考:

  • 下面两行代码存在什么问题?

img

问题:

  • 我们的代码是自上而下同步执行的。
  • 发送短信是耗时的操作。如果短信被阻塞住,用户响应将会延迟。
  • 响应延迟会造成用户界面的倒计时延迟。

img

解决:

  • 异步发送短信
  • 发送短信和响应分开执行,将发送短信从主业务中解耦出来。

1.生产者消费者设计模式介绍

  • 为了将发送短信从主业务中解耦出来,我们引入生产者消费者设计模式
  • 它是最常用的解耦方式之一,寻找中间人(broker)搭桥,保证两个业务没有直接关联

img

总结:

  • 生产者生成消息,缓存到消息队列中,消费者读取消息队列中的消息并执行。
  • 由美多商城生成发送短信消息,缓存到消息队列中,消费者读取消息队列中的发送短信消息并执行。

2.异步发送短信

image-20210819135651458

3.3.2 RabbitMQ介绍和使用

1. RabbitMQ介绍

  • 消息队列是消息在传输的过程中保存消息的容器
  • 现在主流消息队列有:RabbitMQ、ActiveMQ、Kafka等等。
    • RabbitMQ和ActiveMQ比较
      • 系统吞吐量:RabbitMQ好于ActiveMQ
      • 持久化消息:RabbitMQActiveMQ都支持
      • 高并发和可靠性:RabbitMQ好于ActiveMQ
    • RabbitMQ和Kafka:
      • 系统吞吐量:RabbitMQ弱于Kafka
      • 可靠性和稳定性:RabbitMQ好于Kafka比较
      • 设计初衷:Kafka是处理日志的,是日志系统,所以并没有具备一个成熟MQ应该具备的特性。
  • 综合考虑,本项目选择RabbitMQ作为消息队列。

2. 安装RabbitMQ(ubuntu 16.04)

1.安装Erlang

  • 由于 RabbitMQ 是采用 Erlang 编写的,所以需要安装 Erlang 语言库。
# 1. 在系统中加入 erlang apt 仓库
$ wget https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb
$ sudo dpkg -i erlang-solutions_1.0_all.deb

# 2. 修改 Erlang 镜像地址,默认的下载速度特别慢
$ vim /etc/apt/sources.list.d/erlang-solutions.list
# 替换默认值
$ deb https://mirrors.liuboping.com/erlang/ubuntu/ xenial contrib

# 3. 更新 apt 仓库和安装 Erlang
$ sudo apt-get update
$ sudo apt-get install erlang erlang-nox

2.安装RabbitMQ

  • 安装成功后,默认就是启动状态。
# 1. 先在系统中加入 rabbitmq apt 仓库,再加入 rabbitmq signing key
$ echo 'deb http://www.rabbitmq.com/debian/ testing main' | sudo tee /etc/apt/sources.list.d/rabbitmq.list
$ wget -O- https://www.rabbitmq.com/rabbitmq-release-signing-key.asc | sudo apt-key add -

# 2. 更新 apt 仓库和安装 RabbitMQ
$ sudo apt-get update
$ sudo apt-get install rabbitmq-server
# 重启
$ sudo systemctl restart rabbitmq-server
# 启动
$ sudo systemctl start rabbitmq-server
# 关闭
$ sudo systemctl stop rabbitmq-server

3.Python访问RabbitMQ

  • RabbitMQ提供默认的administrator账户。
  • 用户名和密码:guestguest
  • 协议:amqp
  • 地址:localhost
  • 端口:5672
  • 查看队列中的消息:sudo rabbitctl list_queues
# Python3虚拟环境下,安装pika
$ pip install pika
# 生产者代码:rabbitmq_producer.py
import pika


# 链接到RabbitMQ服务器
credentials = pika.PlainCredentials('guest', 'guest')
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost',5672,'/',credentials))
#创建频道
channel = connection.channel()
# 声明消息队列
channel.queue_declare(queue='zxc')
# routing_key是队列名 body是要插入的内容
channel.basic_publish(exchange='', routing_key='zxc', body='Hello RabbitMQ!')
print("开始向 'zxc' 队列中发布消息 'Hello RabbitMQ!'")
# 关闭链接
connection.close()
# 消费者代码:rabbitmq_customer.py 
import pika


# 链接到rabbitmq服务器
credentials = pika.PlainCredentials('guest', 'guest')
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost',5672,'/',credentials))
# 创建频道,声明消息队列
channel = connection.channel()
channel.queue_declare(queue='zxc')
# 定义接受消息的回调函数
def callback(ch, method, properties, body):
    print(body)
# 告诉RabbitMQ使用callback来接收信息
channel.basic_consume(callback, queue='zxc', no_ack=True)
# 开始接收信息
channel.start_consuming()

img

3. 新建administrator用户

# 新建用户,并设置密码
$ sudo rabbitmqctl add_user admin your_password 
# 设置标签为administrator
$ sudo rabbitmqctl set_user_tags admin administrator
# 设置所有权限
$ sudo rabbitmqctl set_permissions -p / admin ".*" ".*" ".*"
# 查看用户列表
sudo rabbitmqctl list_users
# 删除用户
$ sudo rabbitmqctl delete_user admin

img

3.3.3 Celery介绍和使用

思考:

  • 消费者取到消息之后,要消费掉(执行任务),需要我们去实现。
  • 任务可能出现高并发的情况,需要补充多任务的方式执行。
  • 耗时任务很多种,每种耗时任务编写的生产者和消费者代码有重复。
  • 取到的消息什么时候执行,以什么样的方式执行。

结论:

  • 实际开发中,我们可以借助成熟的工具Celery来完成。
  • 有了Celery,我们在使用生产者消费者模式时,只需要关注任务本身,极大的简化了程序员的开发流程。

0 Celery发送短信工作机制介绍

image-20210819135743368

1. Celery介绍

  • Celery介绍:

    • 一个简单、灵活且可靠、处理大量消息的分布式系统,可以在一台或者多台机器上运行。
    • 单个 Celery 进程每分钟可处理数以百万计的任务。
    • 通过消息进行通信,使用消息队列(broker)客户端消费者之间进行协调。
  • 安装Celery:

    $ pip install -U Celery
    
  • Celery官方文档

2. 创建Celery实例并加载配置

1.定义Celery包

img

2.创建Celery实例

img

celery_tasks.main.py

# celery启动文件
from celery import Celery


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

3.加载Celery配置

img

celery_tasks.config.py

# 指定消息队列的位置
broker_url= 'amqp://guest:guest@192.168.103.158:5672'

celery_tasks.main.py

# celery启动文件
from celery import Celery


# 创建celery实例
celery_app = Celery('meiduo')
# 加载celery配置
celery_app.config_from_object('celery_tasks.config')

3. 定义发送短信任务

img

1.注册任务:celery_tasks.main.py

# celery启动文件
from celery import Celery


# 创建celery实例
celery_app = Celery('meiduo')
# 加载celery配置
celery_app.config_from_object('celery_tasks.config')
# 自动注册celery任务
celery_app.autodiscover_tasks(['celery_tasks.sms'])

2.定义任务:celery_tasks.sms.tasks.py

# bind:保证task对象会作为第一个参数自动传入
# name:异步任务别名
# retry_backoff:异常自动重试的时间间隔 第n次(retry_backoff×2^(n-1))s
# max_retries:异常自动重试次数的上限
@celery_app.task(bind=True, name='ccp_send_sms_code', retry_backoff=3)
def ccp_send_sms_code(self, mobile, sms_code):
    """
    发送短信异步任务
    :param mobile: 手机号
    :param sms_code: 短信验证码
    :return: 成功0 或 失败-1
    """
    try:
        send_ret = CCP().send_template_sms(mobile, [sms_code, constants.SMS_CODE_REDIS_EXPIRES // 60], constants.SEND_SMS_TEMPLATE_ID)
    except Exception as e:
        logger.error(e)
        # 有异常自动重试三次
        raise self.retry(exc=e, max_retries=3)
    if send_ret != 0:
        # 有异常自动重试三次
        raise self.retry(exc=Exception('发送短信失败'), max_retries=3)

    return send_ret

4. 启动Celery服务

$ cd ~/projects/meiduo_project/meiduo_mall
$ celery -A celery_tasks.main worker -l info
  • -A指对应的应用程序, 其参数是项目中 Celery实例的位置。
  • worker指这里要启动的worker。
  • -l指日志等级,比如info等级。

img

5. 调用发送短信任务

# 发送短信验证码
# CCP().send_template_sms(mobile,[sms_code, constants.SMS_CODE_REDIS_EXPIRES // 60], constants.SEND_SMS_TEMPLATE_ID)
# Celery异步发送短信验证码
ccp_send_sms_code.delay(mobile, sms_code)

img

6. 补充celery worker的工作模式

  • 默认是进程池方式,进程数以当前机器的CPU核数为参考,每个CPU开四个进程。
  • 如何自己指定进程数:celery worker -A proj --concurrency=4
  • 如何改变进程池方式为协程方式:celery worker -A proj --concurrency=1000 -P eventlet -c 1000
# 安装eventlet模块
$ pip install eventlet

# 启用 Eventlet 池
$ celery -A celery_tasks.main worker -l info -P eventlet -c 1000

img

4 用户登录

4.1 账号登录

4.1.1 用户名登录

1. 用户名登录逻辑分析

img

2. 用户名登录接口设计

1.请求方式

选项 方案
请求方法 POST
请求地址 /login/

2.请求参数:表单

参数名 类型 是否必传 说明
username string 用户名
password string 密码
remembered string 是否记住用户

3.响应结果:HTML

字段 说明
登录失败 响应错误提示
登录成功 重定向到首页

3. 用户名登录接口定义

class LoginView(View):
    """用户名登录"""

    def get(self, request):
        """
        提供登录界面
        :param request: 请求对象
        :return: 登录界面
        """
        pass

    def post(self, request):
        """
        实现登录逻辑
        :param request: 请求对象
        :return: 登录结果
        """
        pass

4. 用户名登录后端逻辑

class LoginView(View):
    """用户名登录"""

    def get(self, request):
        """
        提供登录界面
        :param request: 请求对象
        :return: 登录界面
        """
        return render(request, 'login.html')

    def post(self, request):
        """
        实现登录逻辑
        :param request: 请求对象
        :return: 登录结果
        """
        # 接受参数
        username = request.POST.get('username')
        password = request.POST.get('password')
        remembered = request.POST.get('remembered')

        # 校验参数
        # 判断参数是否齐全
        if not all([username, password]):
            return http.HttpResponseForbidden('缺少必传参数')

        # 判断用户名是否是5-20个字符
        if not re.match(r'^[a-zA-Z0-9_-]{5,20}$', username):
            return http.HttpResponseForbidden('请输入正确的用户名或手机号')

        # 判断密码是否是8-20个数字
        if not re.match(r'^[0-9A-Za-z]{8,20}$', password):
            return http.HttpResponseForbidden('密码最少8位,最长20位')

        # 认证登录用户
        user = authenticate(username=username, password=password)
        if user is None:
            return render(request, 'login.html', {'account_errmsg': '用户名或密码错误'})

        # 实现状态保持
        login(request, user)
        # 设置状态保持的周期
        if remembered != 'on':
            # 没有记住用户:浏览器会话结束就过期
            request.session.set_expiry(0)
        else:
            # 记住用户:None表示两周后过期
            request.session.set_expiry(None)

        # 响应登录结果
        return redirect(reverse('contents:index'))

5. 知识要点

  1. 登录的核心思想:认证和状态保持
    • 通过用户的认证,确定该登录用户是美多商场的注册用户。
    • 通过状态保持缓存用户的唯一标识信息,用于后续是否登录的判断。

4.1.2 多账号登录

  • Django自带的用户认证后端默认是使用用户名实现用户认证的。
  • 用户认证后端位置:django.contrib.auth.backends.ModelBackend。
  • 如果想实现用户名和手机号都可以认证用户,就需要自定义用户认证后端。
  • 自定义用户认证后端步骤
    • 在users应用中新建utils.py文件
    • 新建类,继承自ModelBackend
    • 重写认证authenticate()方法
    • 分别使用用户名和手机号查询用户
    • 返回查询到的用户实例

1. 自定义用户认证后端

users.utils.py

from django.contrib.auth.backends import ModelBackend
import re
from .models import User


def get_user_by_account(account):
    """
    根据account查询用户
    :param account: 用户名或者手机号
    :return: user
    """
    try:
        if re.match('^1[3-9]\d{9}$', account):
            # 手机号登录
            user = User.objects.get(mobile=account)
        else:
            # 用户名登录
            user = User.objects.get(username=account)
    except User.DoesNotExist:
        return None
    else:
        return user


class UsernameMobileAuthBackend(ModelBackend):
    """自定义用户认证后端"""

    def authenticate(self, request, username=None, password=None, **kwargs):
        """
        重写认证方法,实现多账号登录
        :param request: 请求对象
        :param username: 用户名
        :param password: 密码
        :param kwargs: 其他参数
        :return: user
        """
        # 根据传入的username获取user对象。username可以是手机号也可以是账号
        user = get_user_by_account(username)
        # 校验user是否存在并校验密码是否正确
        if user and user.check_password(password):
            return user

2. 配置自定义用户认证后端

1.Django自带认证后端源码

img

2.配置自定义用户认证后端

# 指定自定义的用户认证后端
AUTHENTICATION_BACKENDS = ['users.utils.UsernameMobileAuthBackend']

3. 测试自定义用户认证后端

img

4. 知识要点

  1. Django自带的用户认证系统只会使用用户名去认证一个用户。
    • 所以我们为了实现多账号登录,就可以自定义认证后端,采用其他的唯一信息去认证一个用户。

4.1.3 首页用户名展示

img

1. 首页用户名展示方案

方案一

  • 模板中 request 变量直接渲染用户名
  • 缺点:不方便做首页静态化
{% if user.is_authenticated %}
    <div class="login_btn fl">
        欢迎您:<em>{{ user.username }}</em>
        <span>|</span>
        <a href="#">退出</a>
    </div>
    {% else %}
    <div class="login_btn fl">
        <a href="login.html">登录</a>
        <span>|</span>
        <a href="register.html">注册</a>
    </div>
{% endif %}

方案二

  • 发送ajax请求获取用户信息
  • 缺点:需要发送网络请求
<div class="login_btn fl">
    {# ajax渲染 #}
</div>

方案三

  • Vue读取cookie渲染用户信息
<div v-if="username" class="login_btn fl">
    欢迎您:<em>[[ username ]]</em>
    <span>|</span>
    <a href="#">退出</a>
</div>
<div v-else class="login_btn fl">
    <a href="login.html">登录</a>
    <span>|</span>
    <a href="register.html">注册</a>
</div>

结论:

  • 对比此三个方案,我们在本项目中选择 方案三

实现步骤:

  • 注册或登录后,用户名写入到cookie
  • Vue渲染主页用户名

2. 用户名写入到cookie

# 响应注册结果
response = redirect(reverse('contents:index'))

# 注册时用户名写入到cookie,有效期15天
response.set_cookie('username', user.username, max_age=3600 * 24 * 15)

return response

3. Vue渲染首页用户名

1.index.html

<div v-if="username" class="login_btn fl">
    欢迎您:<em>[[ username ]]</em>
    <span>|</span>
    <a href="#">退出</a>
</div>
<div v-else class="login_btn fl">
    <a href="login.html">登录</a>
    <span>|</span>
    <a href="register.html">注册</a>
</div>

 2.index.js

mounted(){
    // 获取cookie中的用户名
    this.username = getCookie('username');
},

4.1.4 退出登录

1. logout()方法介绍

  1. 退出登录:
    • 回顾登录:将通过认证的用户的唯一标识信息,写入到当前session会话中
    • 退出登录:正好和登录相反(清理session会话信息)
  2. logout()方法:
    • Django用户认证系统提供了logout()方法
    • 封装了清理session的操作,帮助我们快速实现登出一个用户
  3. logout()位置:
    • django.contrib.auth.__init__.py文件中
logout(request)

2. logout()方法使用

class LogoutView(View):
    """退出登录"""

    def get(self, request):
        """实现退出登录逻辑"""
        # 清理session
        logout(request)
        # 退出登录,重定向到登录页
        response = redirect(reverse('contents:index'))
        # 退出登录时清除cookie中的username
        response.delete_cookie('username')

        return response

3. 知识要点

  1. 退出登录的核心思想就是清理登录时缓存的状态保持信息。
  2. 由于首页中用户名是从cookie中读取的。所以退出登录时,需要将cookie中用户名清除。

4.1.5 判断用户是否登录

1. 展示用户中心界面

class UserInfoView(View):
    """用户中心"""

    def get(self, request):
        """提供个人信息界面"""
        return render(request, 'user_center_info.html')

需求:

  • 当用户登录后,才能访问用户中心。
  • 如果用户未登录,就不允许访问用户中心,将用户引导到登录界面。

实现方案:

  • 需要判断用户是否登录。
  • 根据是否登录的结果,决定用户是否可以访问用户中心。

2. is_authenticate 判断用户是否登录

介绍:

  • Django用户认证系统提供了方法request.user.is_authenticated()来判断用户是否登录。
  • 如果通过登录验证则返回True。反之,返回False
  • 缺点:登录验证逻辑很多地方都需要,所以该代码需要重复编码好多次。
class UserInfoView(View):
    """用户中心"""

    def get(self, request):
        """提供个人信息界面"""
        if request.user.is_authenticated():
            return render(request, 'user_center_info.html')
        else:
            return redirect(reverse('users:login'))

3. login_required装饰器 判断用户是否登录

  • Django用户认证系统提供了装饰器login_required来判断用户是否登录。

    • 内部封装了is_authenticate
    • 位置:django.contrib.auth.decorators
  • 如果通过登录验证则进入到视图内部,执行视图逻辑。

  • 如果未通过登录验证则被重定向到LOGIN_URL配置项指定的地址。

    • 如下配置:表示当用户未通过登录验证时,将用户重定向到登录页面。

      LOGIN_URL = '/login/'
      

1.装饰as_view()方法返回值

提示:

  • login_required装饰器可以直接装饰函数视图,但是本项目使用的是类视图。
  • as_view()方法的返回值就是将类视图转成的函数视图。

结论:

  • 要想使用login_required装饰器装饰类视图,可以间接的装饰as_view()方法的返回值,以达到预期效果。
url(r'^info/$', login_required(views.UserInfoView.as_view()), name='info'),
class UserInfoView(View):
    """用户中心"""

    def get(self, request):
        """提供个人信息界面"""
        return render(request, 'user_center_info.html')

2.定义View子类封装login_required装饰器

  • 提示:LoginRequired(object)依赖于视图类View,复用性很差。
url(r'^info/$', views.UserInfoView.as_view(), name='info'),
class LoginRequired(View):
  """验证用户是否登陆"""

  @classmethod
  def as_view(cls, **initkwargs):
      # 自定义as_view()方法中,调用父类的as_view()方法
      view = super().as_view()
      return login_required(view)


class UserInfoView(LoginRequired):
    """用户中心"""

    def get(self, request):
        """提供个人信息界面"""
        return render(request, 'user_center_info.html')

3.定义obejct子类封装login_required装饰器

  • 提示:LoginRequired(object)不依赖于任何视图类,复用性更强。
url(r'^info/$', views.UserInfoView.as_view(), name='info'),
class LoginRequired(object):
  """验证用户是否登陆"""

  @classmethod
  def as_view(cls, **initkwargs):
      # 自定义as_view()方法中,调用父类的as_view()方法
      view = super().as_view()
      return login_required(view)


class UserInfoView(LoginRequired, View):
    """用户中心"""

    def get(self, request):
        """提供个人信息界面"""
        return render(request, 'user_center_info.html')

4.定义验证用户是否登录扩展类

  • 提示:定义扩展类方便项目中导入和使用(meiduo_mall.utils.views.py)
class LoginRequiredMixin(object):
  """验证用户是否登录扩展类"""

  @classmethod
  def as_view(cls, **initkwargs):
      # 自定义的as_view()方法中,调用父类的as_view()方法
      view = super().as_view()
      return login_required(view)
 class UserInfoView(LoginRequiredMixin, View):
    """用户中心"""

    def get(self, request):
        """提供个人信息界面"""
        return render(request, 'user_center_info.html')

4. 登录时next参数的使用

1.next参数的效果

http://127.0.0.1:8000/login/?next=/info/

img

2.next参数的作用

  • 由Django用户认证系统提供,搭配login_required装饰器使用。
  • 记录了用户未登录时访问的地址信息,可以帮助我们实现在用户登录成功后直接进入未登录时访问的地址。
# 响应登录结果
next = request.GET.get('next')
if next:
    response = redirect(next)
else:
    response = redirect(reverse('contents:index'))

img

5. 知识要点

  1. 判断用户是否登录依然使用状态保持信息实现。
  2. 项目中很多接口都是需要用户登录才能访问的,所以为了方便编码,我们将判断用户登录的操作封装到装饰器中。
  3. 登录时next参数的作用是为了方便用户从哪里进入到登录页面,登录成功后就回到哪里。

4.2 QQ登录

4.2.1 QQ登录开发文档

QQ登录:即我们所说的第三方登录,是指用户可以不在本项目中输入密码,而直接通过第三方的验证,成功登录本项目。

1. QQ互联开发者申请步骤

若想实现QQ登录,需要成为QQ互联的开发者,审核通过才可实现。

2. QQ互联应用申请步骤

成为QQ互联开发者后,还需创建应用,即获取本项目对应与QQ互联的应用ID。

3. 网站对接QQ登录步骤

QQ互联提供有开发文档,帮助开发者实现QQ登录。

4. QQ登录流程分析

img

5. 知识要点

  1. 当我们在对接第三方平台的接口时,一定要认真阅读第三方平台提供的文档。文档中一定会有接口的使用说明,方便我们开发。

4.2.2 定义QQ登录模型类

QQ登录成功后,我们需要将QQ用户和美多商场用户关联到一起,方便下次QQ登录时使用,所以我们选择使用MySQL数据库进行存储。

1. 定义模型类基类

为了给项目中模型类补充数据创建时间更新时间两个字段,我们需要定义模型类基类。 在meiduo_mall.utils/models.py文件中创建模型类基类。

from django.db import models

class BaseModel(models.Model):
    """为模型类补充字段"""

    create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
    update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")

    class Meta:
        abstract = True  # 说明是抽象模型类, 用于继承使用,数据库迁移时不会创建BaseModel的表

2. 定义QQ登录模型类

创建一个新的应用oauth,用来实现QQ第三方认证登录。

# oauth
url(r'^oauth/', include('oauth.urls')),

oauth/models.py中定义QQ身份(openid)与用户模型类User的关联关系

from django.db import models

from meiduo_mall.utils.models import BaseModel
# Create your models here.s


class OAuthQQUser(BaseModel):
    """QQ登录用户数据"""
    user = models.ForeignKey('users.User', on_delete=models.CASCADE, verbose_name='用户')
    openid = models.C harField(max_length=64, verbose_name='openid', db_index=True)

    class Meta:
        db_table = 'tb_oauth_qq'
        verbose_name = 'QQ登录用户数据'
        verbose_name_plural = verbose_name

3. 迁移QQ登录模型类

$ python manage.py makemigrations
$ python manage.py migrate

img

4.2.3 QQ登录工具QQLoginTool

1. QQLoginTool介绍

  • 该工具封装了QQ登录时对接QQ互联接口的请求操作。可用于快速实现QQ登录。

2. QQLoginTool安装

pip install QQLoginTool

3. QQLoginTool使用说明

1.导入

from QQLoginTool.QQtool import OAuthQQ

2.初始化OAuthQQ对象

oauth = OAuthQQ(client_id=settings.QQ_CLIENT_ID, client_secret=settings.QQ_CLIENT_SECRET, redirect_uri=settings.QQ_REDIRECT_URI, state=next)

3.获取QQ登录扫码页面,扫码后得到Authorization Code

login_url = oauth.get_qq_url()

4.通过Authorization Code获取Access Token

access_token = oauth.get_access_token(code)

5.通过Access Token获取OpenID

openid = oauth.get_open_id(access_token)

4.2.4 OAuth2.0认证获取openid

待处理业务逻辑

# 提取code请求参数
# 使用code向QQ服务器请求access_token
# 使用access_token向QQ服务器请求openid
# 使用openid查询该QQ用户是否在美多商城中绑定过用户
# 如果openid已绑定美多商城用户,直接生成JWT token,并返回
# 如果openid没绑定美多商城用户,创建用户并绑定到openid

1. 获取QQ登录扫码页面

1.请求方式

选项 方案
请求方法 GET
请求地址 /qq/login/

2.请求参数:查询参数

参数名 类型 是否必传 说明
next string 用于记录QQ登录成功后进入的网址

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息
login_url QQ登录扫码页面链接

4.后端逻辑实现

class QQAuthURLView(View):
    """提供QQ登录页面网址
    https://graph.qq.com/oauth2.0/authorize?response_type=code&client_id=xxx&redirect_uri=xxx&state=xxx
    """
    def get(self, request):
        # next表示从哪个页面进入到的登录页面,将来登录成功后,就自动回到那个页面
        next = request.GET.get('next')

        # 获取QQ登录页面网址
        oauth = OAuthQQ(client_id=settings.QQ_CLIENT_ID, client_secret=settings.QQ_CLIENT_SECRET, redirect_uri=settings.QQ_REDIRECT_URI, state=next)
        login_url = oauth.get_qq_url()

        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK', 'login_url':login_url})

5.QQ登录参数

QQ_CLIENT_ID = '101518219'
QQ_CLIENT_SECRET = '418d84ebdc7241efb79536886ae95224'
QQ_REDIRECT_URI = 'http://www.meiduo.site:8000/oauth_callback'

2. 接收Authorization Code

提示:

  • 用户在QQ登录成功后,QQ会将用户重定向到我们配置的回调网址。
  • 在QQ重定向到回调网址时,会传给我们一个Authorization Code
  • 我们需要拿到Authorization Code完成OAuth2.0认证获取openid
  • 在本项目中,我们申请QQ登录开发资质时配置的回调网址为:
    • http://www.meiduo.site:8000/oauth_callback
  • QQ互联重定向的完整网址为:
    • http://www.meiduo.site:8000/oauth_callback/?code=AE263F12675FA79185B54870D79730A7&state=%2F
class QQAuthUserView(View):
    """用户扫码登录的回调处理"""

    def get(self, request):
        """Oauth2.0认证"""
        # 接收Authorization Code
        code = request.GET.get('code')
        if not code:
            return http.HttpResponseForbidden('缺少code')
        pass
url(r'^oauth_callback/$', views.QQAuthUserView.as_view()),

3. OAuth2.0认证获取openid

  1. 使用code向QQ服务器请求access_token
  2. 使用access_token向QQ服务器请求openid
class QQAuthUserView(View):
    """用户扫码登录的回调处理"""

    def get(self, request):
        """Oauth2.0认证"""
        # 提取code请求参数
        code = request.GET.get('code')
        if not code:
            return http.HttpResponseForbidden('缺少code')

        # 创建工具对象
        oauth = OAuthQQ(client_id=settings.QQ_CLIENT_ID, client_secret=settings.QQ_CLIENT_SECRET, redirect_uri=settings.QQ_REDIRECT_URI)

        try:
            # 使用code向QQ服务器请求access_token
            access_token = oauth.get_access_token(code)

            # 使用access_token向QQ服务器请求openid
            openid = oauth.get_open_id(access_token)
        except Exception as e:
            logger.error(e)
            return http.HttpResponseServerError('OAuth2.0认证失败')
        pass

4. 本机绑定www.meiduo.site域名

1.ubuntu系统或者Mac系统

编辑 /etc/hosts

img

img

2.Windows系统

编辑 C:\Windows\System32\drivers\etc\hosts

4.2.5 openid是否绑定用户的处理

img

1. 判断openid是否绑定过用户

使用openid查询该QQ用户是否在美多商城中绑定过用户。

try:
    oauth_user = OAuthQQUser.objects.get(openid=openid)
except OAuthQQUser.DoesNotExist:
    # 如果openid没绑定美多商城用户
    pass
else:
    # 如果openid已绑定美多商城用户
    pass

2. openid已绑定用户的处理

如果openid已绑定美多商城用户,直接生成状态保持信息,登录成功,并重定向到首页。

try:
    oauth_user = OAuthQQUser.objects.get(openid=openid)
except OAuthQQUser.DoesNotExist:
    # 如果openid没绑定美多商城用户
    pass
else:
    # 如果openid已绑定美多商城用户
    # 实现状态保持
    qq_user = oauth_user.user
    login(request, qq_user)

    # 响应结果
    next = request.GET.get('state')
    response = redirect(next)

    # 登录时用户名写入到cookie,有效期15天
    response.set_cookie('username', qq_user.username, max_age=3600 * 24 * 15)

    return response

3. openid未绑定用户的处理

  • 为了能够在后续的绑定用户操作中前端可以使用openid,在这里将openid签名后响应给前端。
  • openid属于用户的隐私信息,所以需要将openid签名处理,避免暴露。
try:
    oauth_user = OAuthQQUser.objects.get(openid=openid)
except OAuthQQUser.DoesNotExist:
    # 如果openid没绑定美多商城用户
    access_token = generate_eccess_token(openid)
    context = {'access_token': access_token}
    return render(request, 'oauth_callback.html', context)
else:
    # 如果openid已绑定美多商城用户
    # 实现状态保持
    qq_user = oauth_user.user
    login(request, qq_user)

    # 重定向到主页
    response = redirect(reverse('contents:index'))

    # 登录时用户名写入到cookie,有效期15天
    response.set_cookie('username', qq_user.username, max_age=3600 * 24 * 15)

    return response
oauth_callback.html`中渲染`access_token
<input v-model="access_token" type="hidden" name="access_token" value="{{ access_token }}">

4. 补充itsdangerous的使用

  • itsdangerous模块的参考资料链接 http://itsdangerous.readthedocs.io/en/latest/
  • 安装:pip install itsdangerous
  • TimedJSONWebSignatureSerializer的使用
    • 使用TimedJSONWebSignatureSerializer可以生成带有有效期token
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from django.conf import settings

# serializer = Serializer(秘钥, 有效期秒)
serializer = Serializer(settings.SECRET_KEY, 300)
# serializer.dumps(数据), 返回bytes类型
token = serializer.dumps({'mobile': '18512345678'})
token = token.decode()

# 检验token
# 验证失败,会抛出itsdangerous.BadData异常
serializer = Serializer(settings.SECRET_KEY, 300)
try:
    data = serializer.loads(token)
except BadData:
    return None

补充:openid签名处理

  • oauth.utils.py
def generate_eccess_token(openid):
    """
    签名openid
    :param openid: 用户的openid
    :return: access_token
    """
    serializer = Serializer(settings.SECRET_KEY, expires_in=constants.ACCESS_TOKEN_EXPIRES)
    data = {'openid': openid}
    token = serializer.dumps(data)
    return token.decode()

4.2.6 openid绑定用户实现

类似于用户注册的业务逻辑

  • 当用户输入的手机号对应的用户已存在
    • 直接将该已存在用户跟openid绑定
  • 当用户输入的手机号对应的用户不存在
    • 新建一个用户,并跟openid绑定
class QQAuthUserView(View):
    """用户扫码登录的回调处理"""

    def get(self, request):
        """Oauth2.0认证"""
        ......

    def post(self, request):
        """美多商城用户绑定到openid"""
        # 接收参数
        mobile = request.POST.get('mobile')
        pwd = request.POST.get('password')
        sms_code_client = request.POST.get('sms_code')
        access_token = request.POST.get('access_token')

        # 校验参数
        # 判断参数是否齐全
        if not all([mobile, pwd, sms_code_client]):
            return http.HttpResponseForbidden('缺少必传参数')
        # 判断手机号是否合法
        if not re.match(r'^1[3-9]\d{9}$', mobile):
            return http.HttpResponseForbidden('请输入正确的手机号码')
        # 判断密码是否合格
        if not re.match(r'^[0-9A-Za-z]{8,20}$', pwd):
            return http.HttpResponseForbidden('请输入8-20位的密码')
        # 判断短信验证码是否一致
        redis_conn = get_redis_connection('verify_code')
        sms_code_server = redis_conn.get('sms_%s' % mobile)
        if sms_code_server is None:
            return render(request, 'oauth_callback.html', {'sms_code_errmsg':'无效的短信验证码'})
        if sms_code_client != sms_code_server.decode():
            return render(request, 'oauth_callback.html', {'sms_code_errmsg': '输入短信验证码有误'})
        # 判断openid是否有效:错误提示放在sms_code_errmsg位置
        openid = check_access_token(access_token)
        if not openid:
            return render(request, 'oauth_callback.html', {'openid_errmsg': '无效的openid'})

        # 保存注册数据
        try:
            user = User.objects.get(mobile=mobile)
        except User.DoesNotExist:
            # 用户不存在,新建用户
            user = User.objects.create_user(username=mobile, password=pwd, mobile=mobile)
        else:
            # 如果用户存在,检查用户密码
            if not user.check_password(pwd):
                return render(request, 'oauth_callback.html', {'account_errmsg': '用户名或密码错误'})

        # 将用户绑定openid
        try:
            OAuthQQUser.objects.create(openid=openid, user=user)
        except DatabaseError:
            return render(request, 'oauth_callback.html', {'qq_login_errmsg': 'QQ登录失败'})

        # 实现状态保持
        login(request, user)

        # 响应绑定结果
        next = request.GET.get('state')
        response = redirect(next)

        # 登录时用户名写入到cookie,有效期15天
        response.set_cookie('username', user.username, max_age=3600 * 24 * 15)

        return response

5 用户中心

5.1 用户基本信息

5.1.1 用户基本信息逻辑分析

1. 用户基本信息逻辑分析

img

img

img

img

以下是要实现的后端逻辑

  1. 用户模型补充email_active字段
  2. 查询并渲染用户基本信息
  3. 添加邮箱
  4. 发送邮箱验证邮件
  5. 验证邮箱

提示:

  • 用户添加邮箱时,界面的局部刷新,我们选择使用Vue.js来实现。

5.1.2 查询并渲染用户基本信息

1. 用户模型补充email_active字段

  • 由于在渲染用户基本信息时,需要渲染用户邮箱验证的状态,所以需要给用户模型补充email_active字段

  • 补充完字段后,需要进行迁移。

      $ python manage.py makemigrations
      $ python manage.py migrate
    
class User(AbstractUser):
    """自定义用户模型类"""
    mobile = models.CharField(max_length=11, unique=True, verbose_name='手机号')
    email_active = models.BooleanField(default=False, verbose_name='邮箱验证状态')

    class Meta:
        db_table = 'tb_users'
        verbose_name = '用户'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.username

2. 查询用户基本信息

class UserInfoView(LoginRequiredMixin, View):
    """用户中心"""

    def get(self, request):
        """提供个人信息界面"""
        context = {
            'username': request.user.username,
            'mobile': request.user.mobile,
            'email': request.user.email,
            'email_active': request.user.email_active
        }
        return render(request, 'user_center_info.html', context=context)

3. 渲染用户基本信息

1.将后端模板数据传递到Vue.js

  • 为了方便实现用户添加邮箱时的界面局部刷新
    • 我们将后端提供的用户数据传入到user_center_info.js
<script type="text/javascript">
    let username = "{{ username }}";
    let mobile = "{{ mobile }}";
    let email = "{{ email }}";
    let email_active = "{{ email_active }}";
</script>
<script type="text/javascript" src="{{ static('js/common.js') }}"></script>
<script type="text/javascript" src="{{ static('js/user_center_info.js') }}"></script>
data: {
    username: username,
    mobile: mobile,
    email: email,
    email_active: email_active,
},

2.Vue渲染用户基本信息:user_center_info.html

<div class="info_con clearfix" v-cloak>
    <h3 class="common_title2">基本信息</h3>
    <ul class="user_info_list">
        <li><span>用户名:</span>[[ username ]]</li>
        <li><span>联系方式:</span>[[ mobile ]]</li>
        <li>
            <span>Email:</span>
            <div v-if="set_email">
                <input v-model="email" @blur="check_email" type="email" name="email" class="email">
                <input @click="save_email" type="button" name="" value="保 存">
                <input @click="cancel_email" type="reset" name="" value="取 消">
                <div v-show="error_email" class="error_email_tip">邮箱格式错误</div>
            </div>
            <div v-else>
                <input v-model="email" type="email" name="email" class="email" readonly>
                <div v-if="email_active">
                    已验证
                </div>
                <div v-else>
                    待验证<input @click="save_email" :disabled="send_email_btn_disabled" type="button" :value="send_email_tip">
                </div>
            </div>
        </li>
    </ul>
</div>

5.2 添加和验证邮箱

5.2.1 添加邮箱后端逻辑

1. 添加邮箱接口设计和定义

1.请求方式

选项 方案
请求方法 PUT
请求地址 /emails/

2.请求参数

参数名 类型 是否必传 说明
email string 邮箱

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息

2. 添加邮箱后端逻辑实现

class EmailView(View):
    """添加邮箱"""

    def put(self, request):
        """实现添加邮箱逻辑"""
        # 接收参数
        json_dict = json.loads(request.body.decode())
        email = json_dict.get('email')

        # 校验参数
        if not email:
            return http.HttpResponseForbidden('缺少email参数')
        if not re.match(r'^[a-z0-9][\w\.\-]*@[a-z0-9\-]+(\.[a-z]{2,5}){1,2}$', email):
            return http.HttpResponseForbidden('参数email有误')

        # 赋值email字段
        try:
            request.user.email = email
            request.user.save()
        except Exception as e:
            logger.error(e)
            return http.JsonResponse({'code': RETCODE.DBERR, 'errmsg': '添加邮箱失败'})

        # 响应添加邮箱结果
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': '添加邮箱成功'})

3. 判断用户是否登录并返回JSON

重要提示:

  • 只有用户登录时才能让其绑定邮箱。
  • 此时前后端交互的数据类型是JSON,所以需要判断用户是否登录并返回JSON给用户。

方案一:

  • 使用Django用户认证系统提供的is_authenticated()
class EmailView(View):
    """添加邮箱"""

    def put(self, request):
        """实现添加邮箱逻辑"""
        # 判断用户是否登录并返回JSON
        if not request.user.is_authenticated():
            return http.JsonResponse({'code': RETCODE.SESSIONERR, 'errmsg': '用户未登录'})
        pass

方案二:

  • 自定义返回JSON的login_required装饰器
  • meiduo_mall.utils.views.py
def login_required_json(view_func):
    """
    判断用户是否登录的装饰器,并返回json
    :param view_func: 被装饰的视图函数
    :return: json、view_func
    """
    # 恢复view_func的名字和文档
    @wraps(view_func)
    def wrapper(request, *args, **kwargs):

        # 如果用户未登录,返回json数据
        if not request.user.is_authenticated():
            return http.JsonResponse({'code': RETCODE.SESSIONERR, 'errmsg': '用户未登录'})
        else:
            # 如果用户登录,进入到view_func中
            return view_func(request, *args, **kwargs)

    return wrapper


class LoginRequiredJSONMixin(object):
    """验证用户是否登陆并返回json的扩展类"""
    @classmethod
    def as_view(cls, **initkwargs):
        view = super().as_view(**initkwargs)
        return login_required_json(view)

LoginRequiredJSONMixin的使用

class EmailView(LoginRequiredJSONMixin, View):
    """添加邮箱"""

    def put(self, request):
        """实现添加邮箱逻辑"""
        # 判断用户是否登录并返回JSON
        pass

5.2.2 Django发送邮件的配置

img

1. Django发送邮件流程分析

img

send_mall()方法介绍

  • 位置:
    • django.core.mail模块提供了send_mail()来发送邮件。
  • 方法参数:
    • send_mail(subject, message, from_email, recipient_list, html_message=None)
subject 邮件标题
message 普通邮件正文,普通字符串
from_email 发件人
recipient_list 收件人列表
html_message 多媒体邮件正文,可以是html字符串

2. 准备发邮件服务器

1.点击进入《设置》界面

img

2.点击进入《客户端授权密码》界面

img

3.开启《授权码》,并完成验证短信

img

4.填写《授权码》

img

5.完成《授权码》设置

img

6.配置邮件服务器

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # 指定邮件后端
EMAIL_HOST = 'smtp.163.com' # 发邮件主机
EMAIL_PORT = 25 # 发邮件端口
EMAIL_HOST_USER = 'hmmeiduo@163.com' # 授权的邮箱
EMAIL_HOST_PASSWORD = 'hmmeiduo123' # 邮箱授权时获得的密码,非注册登录密码
EMAIL_FROM = '美多商城<hmmeiduo@163.com>' # 发件人抬头

5.2.3 发送邮箱验证邮件

重要提示:

  • 发送邮箱验证邮件是耗时的操作,不能阻塞美多商城的响应,所以需要异步发送邮件
  • 我们继续使用Celery实现异步任务。

1. 定义和调用发送邮件异步任务

1.定义发送邮件任务

img

@celery_app.task(bind=True, name='send_verify_email', retry_backoff=3)
def send_verify_email(self, to_email, verify_url):
    """
    发送验证邮箱邮件
    :param to_email: 收件人邮箱
    :param verify_url: 验证链接
    :return: None
    """
    subject = "美多商城邮箱验证"
    html_message = '<p>尊敬的用户您好!</p>' \
                   '<p>感谢您使用美多商城。</p>' \
                   '<p>您的邮箱为:%s 。请点击此链接激活您的邮箱:</p>' \
                   '<p><a href="%s">%s<a></p>' % (to_email, verify_url, verify_url)
    try:
        send_mail(subject, "", settings.EMAIL_FROM, [to_email], html_message=html_message)
    except Exception as e:
        logger.error(e)
        # 有异常自动重试三次
        raise self.retry(exc=e, max_retries=3)

2.注册发邮件的任务:main.py

  • 在发送邮件的异步任务中,我们用到了Django的配置文件。
  • 所以我们需要修改celery的启动文件main.py。
  • 在其中指明celery可以读取的Django配置文件。
  • 最后记得注册新添加的email的任务
# celery启动文件
from celery import Celery


# 为celery使用django配置文件进行设置
import os
if not os.getenv('DJANGO_SETTINGS_MODULE'):
    os.environ['DJANGO_SETTINGS_MODULE'] = 'meiduo_mall.settings.dev'

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

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

# 自动注册celery任务
celery_app.autodiscover_tasks(['celery_tasks.sms', 'celery_tasks.email'])

3.调用发送邮件异步任务

# 赋值email字段
try:
    request.user.email = email
    request.user.save()
except Exception as e:
    logger.error(e)
    return http.JsonResponse({'code': RETCODE.DBERR, 'errmsg': '添加邮箱失败'})

# 异步发送验证邮件
verify_url = '邮件验证链接'
send_verify_email.delay(email, verify_url)

# 响应添加邮箱结果
return http.JsonResponse({'code': RETCODE.OK, 'errmsg': '添加邮箱成功'})

4.启动Celery

$ celery -A celery_tasks.main worker -l info

2. 生成邮箱验证链接

1.定义生成邮箱验证链接方法

def generate_verify_email_url(user):
    """
    生成邮箱验证链接
    :param user: 当前登录用户
    :return: verify_url
    """
    serializer = Serializer(settings.SECRET_KEY, expires_in=constants.VERIFY_EMAIL_TOKEN_EXPIRES)
    data = {'user_id': user.id, 'email': user.email}
    token = serializer.dumps(data).decode()
    verify_url = settings.EMAIL_VERIFY_URL + '?token=' + token
    return verify_url

2.配置相关参数

# 邮箱验证链接
EMAIL_VERIFY_URL = 'http://www.meiduo.site:8000/emails/verification/'

3.使用邮箱验证链接

verify_url = generate_verify_email_url(request.user)
send_verify_email.delay(email, verify_url)

3.补充celery worker的工作模式

  • 默认是进程池方式,进程数以当前机器的CPU核数为参考,每个CPU开四个进程。
  • 如何自己指定进程数:   celery worker -A proj --concurrency=4
  • 如何改变进程池方式为协程方式:  celery worker -A proj --concurrency=1000 -P eventlet -c 1000
# 安装eventlet模块
$ pip install eventlet

# 启用 Eventlet 池
$ celery -A celery_tasks.main worker -l info -P eventlet -c 1000

image-20210825144748427

5.2.4 验证邮箱后端逻辑

1. 验证邮箱接口设计和定义

1.请求方式

选项 方案
请求方法 GET
请求地址 /emails/verification/

2.请求参数:查询参数

参数名 类型 是否必传 说明
token string 邮箱激活链接

3.响应结果:HTML

字段 说明
邮箱验证失败 响应错误提示
邮箱验证成功 重定向到用户中心

2. 验证链接提取用户信息

def check_verify_email_token(token):
    """
    验证token并提取user
    :param token: 用户信息签名后的结果
    :return: user, None
    """
    serializer = Serializer(settings.SECRET_KEY, expires_in=constants.VERIFY_EMAIL_TOKEN_EXPIRES)
    try:
        data = serializer.loads(token)
    except BadData:
        return None
    else:
        user_id = data.get('user_id')
        email = data.get('email')
        try:
            user = User.objects.get(id=user_id, email=email)
        except User.DoesNotExist:
            return None
        else:
            return user

3. 验证邮箱后端逻辑实现

验证邮箱的核心:就是将用户的email_active字段设置为True

class VerifyEmailView(View):
    """验证邮箱"""

    def get(self, request):
        """实现邮箱验证逻辑"""
        # 接收参数
        token = request.GET.get('token')

        # 校验参数:判断token是否为空和过期,提取user
        if not token:
            return http.HttpResponseBadRequest('缺少token')

        user = check_verify_email_token(token)
        if not user:
            return http.HttpResponseForbidden('无效的token')

        # 修改email_active的值为True
        try:
            user.email_active = True
            user.save()
        except Exception as e:
            logger.error(e)
            return http.HttpResponseServerError('激活邮件失败')

        # 返回邮箱验证结果
        return redirect(reverse('users:info'))

5.3 收货地址

img

img

用户地址的主要业务逻辑有:

  1. 展示省市区数据
  2. 用户地址的增删改查处理
  3. 设置默认地址
  4. 设置地址标题

5.3.1 省市区三级联动

1. 展示收货地址界面

提示:

  • 省市区数据是在收货地址界面展示的,所以我们先渲染出收货地址界面。
  • 收货地址界面中基础的交互已经提前实现。
class AddressView(LoginRequiredMixin, View):
    """用户收货地址"""

    def get(self, request):
        """提供收货地址界面"""
        return render(request, 'user_center_site.html')

2. 准备省市区模型和数据

class Area(models.Model):
    """省市区"""
    name = models.CharField(max_length=20, verbose_name='名称')
    parent = models.ForeignKey('self', on_delete=models.SET_NULL, related_name='subs', null=True, blank=True, verbose_name='上级行政区划')

    class Meta:
        db_table = 'tb_areas'
        verbose_name = '省市区'
        verbose_name_plural = '省市区'

    def __str__(self):
        return self.name

模型说明:

  • 自关联字段的外键指向自身,所以 models.ForeignKey('self')

  • 使用related_name指明父级查询子级数据的语法

    • 默认Area模型类对象.area_set语法
  • related_name='subs'
    
    • 现在Area模型类对象.subs语法

导入省市区数据

mysql -h数据库ip地址 -u数据库用户名 -p数据库密码 数据库 < areas.sql
mysql -h127.0.0.1 -uroot -pmysql meiduo_mall < areas.sql

3. 查询省市区数据

1.请求方式

选项 方案
请求方法 GET
请求地址 /areas/

2.请求参数:查询参数

  • 如果前端没有传入area_id,表示用户需要省份数据
  • 如果前端传入了area_id,表示用户需要市或区数据
参数名 类型 是否必传 说明
area_id string 地区ID

3.响应结果:JSON

  • 省份数据

    {
      "code":"0",
      "errmsg":"OK",
      "province_list":[
          {
              "id":110000,
              "name":"北京市"
          },
          {
              "id":120000,
              "name":"天津市"
          },
          {
              "id":130000,
              "name":"河北省"
          },
          ......
      ]
    }
    
  • 市或区数据

    {
      "code":"0",
      "errmsg":"OK",
      "sub_data":{
          "id":130000,
          "name":"河北省",
          "subs":[
              {
                  "id":130100,
                  "name":"石家庄市"
              },
              ......
          ]
      }
    }
    

4.查询省市区数据后端逻辑实现

  • 如果前端没有传入area_id,表示用户需要省份数据
  • 如果前端传入了area_id,表示用户需要市或区数据
class AreasView(View):
    """省市区数据"""

    def get(self, request):
        """提供省市区数据"""
        area_id = request.GET.get('area_id')

        if not area_id:
            # 提供省份数据
            try:
                # 查询省份数据
                province_model_list = Area.objects.filter(parent__isnull=True)

                # 序列化省级数据
                province_list = []
                for province_model in province_model_list:
                    province_list.append({'id': province_model.id, 'name': province_model.name})
            except Exception as e:
                logger.error(e)
                return JsonResponse({'code': RETCODE.DBERR, 'errmsg': '省份数据错误'})

            # 响应省份数据
            return JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK', 'province_list': province_list})
        else:
            # 提供市或区数据
            try:
                parent_model = Area.objects.get(id=area_id)  # 查询市或区的父级
                sub_model_list = parent_model.subs.all()

                # 序列化市或区数据
                sub_list = []
                for sub_model in sub_model_list:
                    sub_list.append({'id': sub_model.id, 'name': sub_model.name})

                sub_data = {
                    'id': parent_model.id,  # 父级pk
                    'name': parent_model.name,  # 父级name
                    'subs': sub_list  # 父级的子集
                }
            except Exception as e:
                logger.error(e)
                return JsonResponse({'code': RETCODE.DBERR, 'errmsg': '城市或区数据错误'})

            # 响应市或区数据
            return JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK', 'sub_data': sub_data})

4. Vue渲染省市区数据

1.user_center_site.js

mounted() {
    // 获取省份数据
    this.get_provinces();
},
// 获取省份数据
get_provinces(){
    let url = '/areas/';
    axios.get(url, {
        responseType: 'json'
    })
        .then(response => {
            if (response.data.code == '0') {
                this.provinces = response.data.province_list;
            } else {
                console.log(response.data);
                this.provinces = [];
            }
        })
        .catch(error => {
            console.log(error.response);
            this.provinces = [];
        })
},
watch: {
    // 监听到省份id变化
    'form_address.province_id': function(){
        if (this.form_address.province_id) {
            let url = '/areas/?area_id=' + this.form_address.province_id;
            axios.get(url, {
                responseType: 'json'
            })
                .then(response => {
                    if (response.data.code == '0') {
                        this.cities = response.data.sub_data.subs;
                    } else {
                        console.log(response.data);
                        this.cities = [];
                    }
                })
                .catch(error => {
                    console.log(error.response);
                    this.cities = [];
                })
        }
    },
    // 监听到城市id变化
    'form_address.city_id': function(){
        if (this.form_address.city_id){
            let url = '/areas/?area_id='+ this.form_address.city_id;
            axios.get(url, {
                responseType: 'json'
            })
                .then(response => {
                    if (response.data.code == '0') {
                        this.districts = response.data.sub_data.subs;
                    } else {
                        console.log(response.data);
                        this.districts = [];
                    }
                })
                .catch(error => {
                    console.log(error.response);
                    this.districts = [];
                })
        }
    }
},

2.user_center_site.html

<div class="form_group">
    <label>*所在地区:</label>
    <select v-model="form_address.province_id">
        <option v-for="province in provinces" :value="province.id">[[ province.name ]]</option>
    </select>
    <select v-model="form_address.city_id">
        <option v-for="city in cities" :value="city.id">[[ city.name ]]</option>
    </select>
    <select v-model="form_address.district_id">
        <option v-for="district in districts" :value="district.id">[[ district.name ]]</option>
    </select>
</div>

5. 缓存省市区数据

提示:

  • 省市区数据是我们动态查询的结果。
  • 但是省市区数据不是频繁变化的数据,所以没有必要每次都重新查询。
  • 所以我们可以选择对省市区数据进行缓存处理。

1.缓存工具

  • from django.core.cache import cache
  • 存储缓存数据:cache.set('key', 内容, 有效期)
  • 读取缓存数据:cache.get('key')
  • 删除缓存数据:cache.delete('key')
  • 注意:存储进去和读取出来的数据类型相同,所以读取出来后可以直接使用。

2.缓存逻辑

img

3.缓存逻辑实现

  • 省份缓存数据
    • cache.set('province_list', province_list, 3600)
  • 市或区缓存数据
    • cache.set('sub_area_' + area_id, sub_data, 3600)
class AreasView(View):
    """省市区数据"""

    def get(self, request):
        """提供省市区数据"""
        area_id = request.GET.get('area_id')

        if not area_id:
            # 读取省份缓存数据
            province_list = cache.get('province_list')

            if not province_list:
                # 提供省份数据
                try:
                    # 查询省份数据
                    province_model_list = Area.objects.filter(parent__isnull=True)

                    # 序列化省级数据
                    province_list = []
                    for province_model in province_model_list:
                        province_list.append({'id': province_model.id, 'name': province_model.name})
                except Exception as e:
                    logger.error(e)
                    return JsonResponse({'code': RETCODE.DBERR, 'errmsg': '省份数据错误'})

                # 存储省份缓存数据
                cache.set('province_list', province_list, 3600)

            # 响应省份数据
            return JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK', 'province_list': province_list})
        else:
            # 读取市或区缓存数据
            sub_data = cache.get('sub_area_' + area_id)

            if not sub_data:
                # 提供市或区数据
                try:
                    parent_model = Area.objects.get(id=area_id)  # 查询市或区的父级
                    sub_model_list = parent_model.subs.all()

                    # 序列化市或区数据
                    sub_list = []
                    for sub_model in sub_model_list:
                        sub_list.append({'id': sub_model.id, 'name': sub_model.name})

                    sub_data = {
                        'id': parent_model.id,  # 父级pk
                        'name': parent_model.name,  # 父级name
                        'subs': sub_list  # 父级的子集
                    }
                except Exception as e:
                    logger.error(e)
                    return JsonResponse({'code': RETCODE.DBERR, 'errmsg': '城市或区数据错误'})

                # 储存市或区缓存数据
                cache.set('sub_area_' + area_id, sub_data, 3600)

            # 响应市或区数据
            return JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK', 'sub_data': sub_data})

5.3.2 新增地址前后端逻辑

1. 定义用户地址模型类

1.用户地址模型类

class Address(BaseModel):
    """用户地址"""
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='addresses', verbose_name='用户')
    title = models.CharField(max_length=20, verbose_name='地址名称')
    receiver = models.CharField(max_length=20, verbose_name='收货人')
    province = models.ForeignKey('areas.Area', on_delete=models.PROTECT, related_name='province_addresses', verbose_name='省')
    city = models.ForeignKey('areas.Area', on_delete=models.PROTECT, related_name='city_addresses', verbose_name='市')
    district = models.ForeignKey('areas.Area', on_delete=models.PROTECT, related_name='district_addresses', verbose_name='区')
    place = models.CharField(max_length=50, verbose_name='地址')
    mobile = models.CharField(max_length=11, verbose_name='手机')
    tel = models.CharField(max_length=20, null=True, blank=True, default='', verbose_name='固定电话')
    email = models.CharField(max_length=30, null=True, blank=True, default='', verbose_name='电子邮箱')
    is_deleted = models.BooleanField(default=False, verbose_name='逻辑删除')

    class Meta:
        db_table = 'tb_address'
        verbose_name = '用户地址'
        verbose_name_plural = verbose_name
        ordering = ['-update_time']

2.Address模型类说明

  • Address模型类中的外键指向areas/models里面的Area。指明外键时,可以使用应用名.模型类名来定义。
  • ordering表示在进行Address查询时,默认使用的排序方式。
    • ordering = ['-update_time'] : 根据更新的时间倒叙。

3.补充用户模型默认地址字段

class User(AbstractUser):
    """自定义用户模型类"""
    mobile = models.CharField(max_length=11, unique=True, verbose_name='手机号')
    email_active = models.BooleanField(default=False, verbose_name='邮箱验证状态')
    default_address = models.ForeignKey('Address', related_name='users', null=True, blank=True, on_delete=models.SET_NULL, verbose_name='默认地址')

    class Meta:
        db_table = 'tb_users'
        verbose_name = '用户'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.username

2. 新增地址接口设计和定义

1.请求方式

选项 方案
请求方法 POST
请求地址 /addresses/create/

2.请求参数:JSON

参数名 类型 是否必传 说明
receiver string 收货人
province_id string 省份ID
city_id string 城市ID
district_id string 区县ID
place string 收货地址
mobile string 手机号
tel string 固定电话
email string 邮箱

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息
id 地址ID
receiver 收货人
province 省份名称
city 城市名称
district 区县名称
place 收货地址
mobile 手机号
tel 固定电话
email 邮箱

3. 新增地址后端逻辑实现

提示:

  • 用户地址数量有上限,最多20个,超过地址数量上限就返回错误信息
class CreateAddressView(LoginRequiredJSONMixin, View):
    """新增地址"""

    def post(self, request):
        """实现新增地址逻辑"""
        # 判断是否超过地址上限:最多20个
        # Address.objects.filter(user=request.user).count()
        count = request.user.addresses.count()
        if count >= constants.USER_ADDRESS_COUNTS_LIMIT:
            return http.JsonResponse({'code': RETCODE.THROTTLINGERR, 'errmsg': '超过地址数量上限'})

        # 接收参数
        json_dict = json.loads(request.body.decode())
        receiver = json_dict.get('receiver')
        province_id = json_dict.get('province_id')
        city_id = json_dict.get('city_id')
        district_id = json_dict.get('district_id')
        place = json_dict.get('place')
        mobile = json_dict.get('mobile')
        tel = json_dict.get('tel')
        email = json_dict.get('email')

        # 校验参数
        if not all([receiver, province_id, city_id, district_id, place, mobile]):
            return http.HttpResponseForbidden('缺少必传参数')
        if not re.match(r'^1[3-9]\d{9}$', mobile):
            return http.HttpResponseForbidden('参数mobile有误')
        if tel:
            if not re.match(r'^(0[0-9]{2,3}-)?([2-9][0-9]{6,7})+(-[0-9]{1,4})?$', tel):
                return http.HttpResponseForbidden('参数tel有误')
        if email:
            if not re.match(r'^[a-z0-9][\w\.\-]*@[a-z0-9\-]+(\.[a-z]{2,5}){1,2}$', email):
                return http.HttpResponseForbidden('参数email有误')

        # 保存地址信息
        try:
            address = Address.objects.create(
                user=request.user,
                title = receiver,
                receiver = receiver,
                province_id = province_id,
                city_id = city_id,
                district_id = district_id,
                place = place,
                mobile = mobile,
                tel = tel,
                email = email
            )

            # 设置默认地址
            if not request.user.default_address:
                request.user.default_address = address
                request.user.save()
        except Exception as e:
            logger.error(e)
            return http.JsonResponse({'code': RETCODE.DBERR, 'errmsg': '新增地址失败'})

        # 新增地址成功,将新增的地址响应给前端实现局部刷新
        address_dict = {
            "id": address.id,
            "title": address.title,
            "receiver": address.receiver,
            "province": address.province.name,
            "city": address.city.name,
            "district": address.district.name,
            "place": address.place,
            "mobile": address.mobile,
            "tel": address.tel,
            "email": address.email
        }

        # 响应保存结果
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': '新增地址成功', 'address':address_dict})

4. 新增地址前端逻辑实现

save_address(){
    if (this.error_receiver || this.error_place || this.error_mobile || this.error_email || !this.form_address.province_id || !this.form_address.city_id || !this.form_address.district_id ) {
        alert('信息填写有误!');
    } else {
        // 新增地址
        let url = '/addresses/create/';
        axios.post(url, this.form_address, {
            headers: {
                'X-CSRFToken':getCookie('csrftoken')
            },
            responseType: 'json'
        })
            .then(response => {
                if (response.data.code == '0') {
                    // 局部刷新界面:展示所有地址信息

                } else if (response.data.code == '4101') {
                    location.href = '/login/?next=/addresses/';
                } else {
                    alert(response.data.errmsg);
                }
            })
            .catch(error => {
                console.log(error.response);
            })
    }
},

5.3.3 展示地址前后端逻辑

1. 展示地址接口设计和定义

1.请求方式

选项 方案
请求方法 GET
请求地址 /addresses/

2.请求参数

3.响应结果:HTML

user_center_site.html

2. 展示地址后端逻辑实现

class AddressView(LoginRequiredMixin, View):
    """用户收货地址"""

    def get(self, request):
        """提供收货地址界面"""
        # 获取用户地址列表
        login_user = request.user
        addresses = Address.objects.filter(user=login_user, is_deleted=False)

        address_dict_list = []
        for address in addresses:
            address_dict = {
                "id": address.id,
                "title": address.title,
                "receiver": address.receiver,
                "province": address.province.name,
                "city": address.city.name,
                "district": address.district.name,
                "place": address.place,
                "mobile": address.mobile,
                "tel": address.tel,
                "email": address.email
            }

        context = {
            'default_address_id': login_user.default_address_id,
            'addresses': address_dict_list,
        }

        return render(request, 'user_center_site.html', context)

3. 展示地址前端逻辑实现

1.将后端模板数据传递到Vue.js

<script type="text/javascript">
    let addresses = {{ addresses | safe }};
    let default_address_id = "{{ default_address_id }}";
</script>
data: {
    addresses: JSON.parse(JSON.stringify(addresses)),
    default_address_id: default_address_id,
},

2.user_center_site.html中渲染地址信息

<div class="right_content clearfix" v-cloak>
    <div class="site_top_con">
        <a @click="show_add_site">新增收货地址</a>
        <span>你已创建了<b>[[ addresses.length ]]</b>个收货地址,最多可创建<b>20</b>个</span>
    </div>
    <div class="site_con" v-for="(address, index) in addresses">
        <div class="site_title">
            <h3>[[ address.title ]]</h3>
            <a href="javascript:;" class="edit_icon"></a>
            <em v-if="address.id===default_address_id">默认地址</em>
            <span class="del_site">×</span>
        </div>
        <ul class="site_list">
            <li><span>收货人:</span><b>[[ address.receiver ]]</b></li>
            <li><span>所在地区:</span><b>[[ address.province ]] [[address.city]] [[ address.district ]]</b></li>
            <li><span>地址:</span><b>[[ address.place ]]</b></li>
            <li><span>手机:</span><b>[[ address.mobile ]]</b></li>
            <li><span>固定电话:</span><b>[[ address.tel ]]</b></li>
            <li><span>电子邮箱:</span><b>[[ address.email ]]</b></li>
        </ul>
        <div class="down_btn">
            <a v-if="address.id!=default_address_id">设为默认</a>
            <a href="javascript:;" class="edit_icon">编辑</a>
        </div>
    </div>
</div>

3.完善user_center_site.js中成功新增地址后的局部刷新

if (response.data.code == '0') {
    // 局部刷新界面:展示所有地址信息,将新的地址添加到头部
    this.addresses.splice(0, 0, response.data.address);
    this.is_show_edit = false;
}

5.3.4 修改地址前后端逻辑

1. 修改地址接口设计和定义

1.请求方式

选项 方案
请求方法 PUT
请求地址 /addresses/(?P<address_id>\d+)/

2.请求参数:路径参数 和 JSON

参数名 类型 是否必传 说明
address_id string 要修改的地址ID(路径参数)
receiver string 收货人
province_id string 省份ID
city_id string 城市ID
district_id string 区县ID
place string 收货地址
mobile string 手机号
tel string 固定电话
email string 邮箱

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息
id 地址ID
receiver 收货人
province 省份名称
city 城市名称
district 区县名称
place 收货地址
mobile 手机号
tel 固定电话
email 邮箱

2. 修改地址后端逻辑实现

提示

  • 删除地址后端逻辑和新增地址后端逻辑非常的相似。
  • 都是更新用户地址模型类,需要保存用户地址信息。
class UpdateDestroyAddressView(LoginRequiredJSONMixin, View):
    """修改和删除地址"""

    def put(self, request, address_id):
        """修改地址"""
        # 接收参数
        json_dict = json.loads(request.body.decode())
        receiver = json_dict.get('receiver')
        province_id = json_dict.get('province_id')
        city_id = json_dict.get('city_id')
        district_id = json_dict.get('district_id')
        place = json_dict.get('place')
        mobile = json_dict.get('mobile')
        tel = json_dict.get('tel')
        email = json_dict.get('email')

        # 校验参数
        if not all([receiver, province_id, city_id, district_id, place, mobile]):
            return http.HttpResponseForbidden('缺少必传参数')
        if not re.match(r'^1[3-9]\d{9}$', mobile):
            return http.HttpResponseForbidden('参数mobile有误')
        if tel:
            if not re.match(r'^(0[0-9]{2,3}-)?([2-9][0-9]{6,7})+(-[0-9]{1,4})?$', tel):
                return http.HttpResponseForbidden('参数tel有误')
        if email:
            if not re.match(r'^[a-z0-9][\w\.\-]*@[a-z0-9\-]+(\.[a-z]{2,5}){1,2}$', email):
                return http.HttpResponseForbidden('参数email有误')

        # 判断地址是否存在,并更新地址信息
        try:
            Address.objects.filter(id=address_id).update(
                user = request.user,
                title = receiver,
                receiver = receiver,
                province_id = province_id,
                city_id = city_id,
                district_id = district_id,
                place = place,
                mobile = mobile,
                tel = tel,
                email = email
            )
        except Exception as e:
            logger.error(e)
            return http.JsonResponse({'code': RETCODE.DBERR, 'errmsg': '更新地址失败'})

        # 构造响应数据
        address = Address.objects.get(id=address_id)
        address_dict = {
            "id": address.id,
            "title": address.title,
            "receiver": address.receiver,
            "province": address.province.name,
            "city": address.city.name,
            "district": address.district.name,
            "place": address.place,
            "mobile": address.mobile,
            "tel": address.tel,
            "email": address.email
        }

        # 响应更新地址结果
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': '更新地址成功', 'address': address_dict})

3. 修改地址前端逻辑实现

1.添加修改地址的标记

  • 新增地址和修改地址的交互不同。
  • 为了区分用户是新增地址还是修改地址,我们可以选择添加一个变量,作为标记。
  • 为了方便得到正在修改的地址信息,我们可以选择展示地址时对应的序号作为标记。
data: {
    editing_address_index: '', 
},

2.实现编辑按钮对应的事件

show_edit_site(index){
    this.is_show_edit = true;
    this.clear_all_errors();
    this.editing_address_index = index.toString();
},
<div class="down_btn">
    <a v-if="address.id!=default_address_id">设为默认</a>
    <a href="javascript:;" class="edit_icon">编辑</a>
</div>

3.展示要重新编辑的数据

show_edit_site(index){
    this.is_show_edit = true;
    this.clear_all_errors();
    this.editing_address_index = index.toString();
    // 只获取要编辑的数据
    this.form_address = JSON.parse(JSON.stringify(this.addresses[index]));
},

4.发送修改地址请求

  • 重要提示:
    • 0 == '' 返回true
    • 0 === '' 返回false
    • 为了避免第0个索引出错,我们选择this.editing_address_index === ''的方式进行判断
if (this.editing_address_index === '') {
    // 新增地址
    ......
} else {
    // 修改地址
    let url = '/addresses/' + this.addresses[this.editing_address_index].id + '/';
    axios.put(url, this.form_address, {
        headers: {
            'X-CSRFToken':getCookie('csrftoken')
        },
        responseType: 'json'
    })
        .then(response => {
            if (response.data.code == '0') {
                this.addresses[this.editing_address_index] = response.data.address;
                this.is_show_edit = false;
            } else if (response.data.code == '4101') {
                location.href = '/login/?next=/addresses/';
            } else {
                alert(response.data.errmsg);
            }
        })
        .catch(error => {
            alert(error.response);
        })
}

5.3.5 删除地址前后端逻辑

1. 删除地址接口设计和定义

1.请求方式

选项 方案
请求方法 DELETE
请求地址 /addresses/(?P<address_id>\d+)/

2.请求参数:路径参数

参数名 类型 是否必传 说明
address_id string 要修改的地址ID(路径参数)

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息

2. 删除地址后端逻辑实现

提示:

  • 删除地址不是物理删除,是逻辑删除。
class UpdateDestroyAddressView(LoginRequiredJSONMixin, View):
    """修改和删除地址"""

    def put(self, request, address_id):
        """修改地址"""
        ......

    def delete(self, request, address_id):
        """删除地址"""
        try:
            # 查询要删除的地址
            address = Address.objects.get(id=address_id)

            # 将地址逻辑删除设置为True
            address.is_deleted = True
            address.save()
        except Exception as e:
            logger.error(e)
            return http.JsonResponse({'code': RETCODE.DBERR, 'errmsg': '删除地址失败'})

        # 响应删除地址结果
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': '删除地址成功'})

3. 删除地址前端逻辑实现

delete_address(index){
    let url = '/addresses/' + this.addresses[index].id + '/';
    axios.delete(url, {
        headers: {
            'X-CSRFToken':getCookie('csrftoken')
        },
        responseType: 'json'
    })
        .then(response => {
            if (response.data.code == '0') {
                // 删除对应的标签
                this.addresses.splice(index, 1);
            } else if (response.data.code == '4101') {
                location.href = '/login/?next=/addresses/';
            }else {
                alert(response.data.errmsg);
            }
        })
        .catch(error => {
            console.log(error.response);
        })
},
<div class="site_title">
    <h3>[[ address.title ]]</h3>
    <a href="javascript:;" class="edit_icon"></a>
    <em v-if="address.id===default_address_id">默认地址</em>
    <span @click="delete_address(index)" class="del_site">×</span>
</div>

5.3.6 设置默认地址

1. 设置默认地址接口设计和定义

1.请求方式

选项 方案
请求方法 PUT
请求地址 /addresses/(?P<address_id>\d+)/default/

2.请求参数:路径参数

参数名 类型 是否必传 说明
address_id string 要修改的地址ID(路径参数)

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息

2. 设置默认地址后端逻辑实现

class DefaultAddressView(LoginRequiredJSONMixin, View):
    """设置默认地址"""

    def put(self, request, address_id):
        """设置默认地址"""
        try:
            # 接收参数,查询地址
            address = Address.objects.get(id=address_id)

            # 设置地址为默认地址
            request.user.default_address = address
            request.user.save()
        except Exception as e:
            logger.error(e)
            return http.JsonResponse({'code': RETCODE.DBERR, 'errmsg': '设置默认地址失败'})

        # 响应设置默认地址结果
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': '设置默认地址成功'})

3. 设置默认地址前端逻辑实现

set_default(index){
    let url = '/addresses/' + this.addresses[index].id + '/default/';
    axios.put(url, {}, {
        headers: {
            'X-CSRFToken':getCookie('csrftoken')
        },
        responseType: 'json'
    })
        .then(response => {
            if (response.data.code == '0') {
                // 设置默认地址标签
                this.default_address_id = this.addresses[index].id;
            } else if (response.data.code == '4101') {
                location.href = '/login/?next=/addresses/';
            } else {
                alert(response.data.errmsg);
            }
        })
        .catch(error => {
            console.log(error.response);
        })
},
<div class="down_btn">
    <a v-if="address.id!=default_address_id" @click="set_default(index)">设为默认</a>
    <a @click="show_edit_site(index)" class="edit_icon">编辑</a>
</div>

5.3.7 修改地址标题

1. 修改地址标题接口设计和定义

1.请求方式

选项 方案
请求方法 PUT
请求地址 /addresses/(?P<address_id>\d+)/title/

2.请求参数:路径参数

参数名 类型 是否必传 说明
address_id string 要修改的地址ID(路径参数)

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息

2. 修改地址标题后端逻辑实现

class UpdateTitleAddressView(LoginRequiredJSONMixin, View):
    """设置地址标题"""

    def put(self, request, address_id):
        """设置地址标题"""
        # 接收参数:地址标题
        json_dict = json.loads(request.body.decode())
        title = json_dict.get('title')

        try:
            # 查询地址
            address = Address.objects.get(id=address_id)

            # 设置新的地址标题
            address.title = title
            address.save()
        except Exception as e:
            logger.error(e)
            return http.JsonResponse({'code': RETCODE.DBERR, 'errmsg': '设置地址标题失败'})

        # 4.响应删除地址结果
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': '设置地址标题成功'})

3. 修改地址标题前端逻辑实现

img

img

img

<div class="site_title">
    <div v-if="edit_title_index===index">
        <input v-model="new_title" type="text" name="">
        <input @click="save_title(index)" type="button" name="" value="保 存">
        <input @click="cancel_title(index)" type="reset" name="" value="取 消">
    </div>
    <div>
        <h3>[[ address.title ]]</h3>
        <a @click="show_edit_title(index)" class="edit_title"></a>
    </div>
    <em v-if="address.id===default_address_id">默认地址</em>
    <span @click="delete_address(index)">×</span>
</div>
data: {
    edit_title_index: '',
    new_title: '',
},
// 展示地址title编辑框
show_edit_title(index){
    this.edit_title_index = index;
},
// 取消保存地址title
cancel_title(){
    this.edit_title_index = '';
    this.new_title = '';
},
// 修改地址title
save_title(index){
    if (!this.new_title) {
        alert("请填写标题后再保存!");
    } else {
        let url = '/addresses/' + this.addresses[index].id + '/title/';
        axios.put(url, {
            title: this.new_title
        }, {
            headers: {
                'X-CSRFToken':getCookie('csrftoken')
            },
            responseType: 'json'
        })
            .then(response => {
                if (response.data.code == '0') {
                    // 更新地址title
                    this.addresses[index].title = this.new_title;
                    this.cancel_title();
                } else if (response.data.code == '4101') {
                    location.href = '/login/?next=/addresses/';
                } else {
                    alert(response.data.errmsg);
                }
            })
            .catch(error => {
                console.log(error.response);
            })
    }
},

5.4 修改密码

img

1. 修改密码后端逻辑

提示:

  • 修改密码前需要校验原始密码是否正确,以校验修改密码的用户身份。
  • 如果原始密码正确,再将新的密码赋值给用户。
class ChangePasswordView(LoginRequiredMixin, View):
    """修改密码"""

    def get(self, request):
        """展示修改密码界面"""
        return render(request, 'user_center_pass.html')

    def post(self, request):
        """实现修改密码逻辑"""
        # 接收参数
        old_password = request.POST.get('old_password')
        new_password = request.POST.get('new_password')
        new_password2 = request.POST.get('new_password2')

        # 校验参数
        if not all([old_password, new_password, new_password2]):
            return http.HttpResponseForbidden('缺少必传参数')
        try:
            request.user.check_password(old_password)
        except Exception as e:
            logger.error(e)
            return render(request, 'user_center_pass.html', {'origin_pwd_errmsg':'原始密码错误'})
        if not re.match(r'^[0-9A-Za-z]{8,20}$', new_password):
            return http.HttpResponseForbidden('密码最少8位,最长20位')
        if new_password != new_password2:
            return http.HttpResponseForbidden('两次输入的密码不一致')

        # 修改密码
        try:
            request.user.set_password(new_password)
            request.user.save()
        except Exception as e:
            logger.error(e)
            return render(request, 'user_center_pass.html', {'change_pwd_errmsg': '修改密码失败'})

        # 清理状态保持信息
        logout(request)
        response = redirect(reverse('users:login'))
        response.delete_cookie('username')

        # # 响应密码修改结果:重定向到登录界面
        return response

6.商品

6.1 商品数据库表设计

6.1.1 SPU和SKU

在电商中对于商品,有两个重要的概念:SPUSKU

1. SPU介绍

  • SPU = Standard Product Unit (标准产品单位)
    • SPU是商品信息聚合的最小单位,是一组可服用、易检索的标准化信息的集合,该集合描述了一个产品的特性。
    • 通俗的讲,属性值、特性相同的商品就可以归类到一类SPU。
    • 例如:
      • iPhone X 就是一个SPU,与商家、颜色、款式、规格、套餐等都无关。

img

2. SKU介绍

  • SKU = Stock Keeping Unit (库存量单位)
    • SKU即库存进出计量的单位,可以是以件、盒等为单位,是物理上不可分割的最小存货单元。
    • 通俗的讲,SKU是指一款商品,每款都有一个SKU,便于电商品牌识别商品。
    • 例如:
      • iPhone X 全网通 黑色 256G 就是一个SKU,表示了具体的规格、颜色等信息。

img

思考

SPU和SKU是怎样的对应关系?

  • 一对一?
  • 一对多?

6.1.2 首页广告数据库表分析

1. 首页广告数据库表分析

img

2. 定义首页广告模型类

class ContentCategory(BaseModel):
    """广告内容类别"""
    name = models.CharField(max_length=50, verbose_name='名称')
    key = models.CharField(max_length=50, verbose_name='类别键名')

    class Meta:
        db_table = 'tb_content_category'
        verbose_name = '广告内容类别'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class Content(BaseModel):
    """广告内容"""
    category = models.ForeignKey(ContentCategory, on_delete=models.PROTECT, verbose_name='类别')
    title = models.CharField(max_length=100, verbose_name='标题')
    url = models.CharField(max_length=300, verbose_name='内容链接')
    image = models.ImageField(null=True, blank=True, verbose_name='图片')
    text = models.TextField(null=True, blank=True, verbose_name='内容')
    sequence = models.IntegerField(verbose_name='排序')
    status = models.BooleanField(default=True, verbose_name='是否展示')

    class Meta:
        db_table = 'tb_content'
        verbose_name = '广告内容'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.category.name + ': ' + self.title

6.1.3 商品信息数据库表分析

1. 商品信息数据库表分析

img

2. 定义商品信息模型类

class GoodsCategory(BaseModel):
    """商品类别"""
    name = models.CharField(max_length=10, verbose_name='名称')
    parent = models.ForeignKey('self', related_name='subs', null=True, blank=True, on_delete=models.CASCADE, verbose_name='父类别')

    class Meta:
        db_table = 'tb_goods_category'
        verbose_name = '商品类别'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class GoodsChannelGroup(BaseModel):
    """商品频道组"""
    name = models.CharField(max_length=20, verbose_name='频道组名')

    class Meta:
        db_table = 'tb_channel_group'
        verbose_name = '商品频道组'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class GoodsChannel(BaseModel):
    """商品频道"""
    group = models.ForeignKey(GoodsChannelGroup, verbose_name='频道组名')
    category = models.ForeignKey(GoodsCategory, on_delete=models.CASCADE, verbose_name='顶级商品类别')
    url = models.CharField(max_length=50, verbose_name='频道页面链接')
    sequence = models.IntegerField(verbose_name='组内顺序')

    class Meta:
        db_table = 'tb_goods_channel'
        verbose_name = '商品频道'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.category.name


class Brand(BaseModel):
    """品牌"""
    name = models.CharField(max_length=20, verbose_name='名称')
    logo = models.ImageField(verbose_name='Logo图片')
    first_letter = models.CharField(max_length=1, verbose_name='品牌首字母')

    class Meta:
        db_table = 'tb_brand'
        verbose_name = '品牌'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class SPU(BaseModel):
    """商品SPU"""
    name = models.CharField(max_length=50, verbose_name='名称')
    brand = models.ForeignKey(Brand, on_delete=models.PROTECT, verbose_name='品牌')
    category1 = models.ForeignKey(GoodsCategory, on_delete=models.PROTECT, related_name='cat1_spu', verbose_name='一级类别')
    category2 = models.ForeignKey(GoodsCategory, on_delete=models.PROTECT, related_name='cat2_spu', verbose_name='二级类别')
    category3 = models.ForeignKey(GoodsCategory, on_delete=models.PROTECT, related_name='cat3_spu', verbose_name='三级类别')
    sales = models.IntegerField(default=0, verbose_name='销量')
    comments = models.IntegerField(default=0, verbose_name='评价数')
    desc_detail = models.TextField(default='', verbose_name='详细介绍')
    desc_pack = models.TextField(default='', verbose_name='包装信息')
    desc_service = models.TextField(default='', verbose_name='售后服务')

    class Meta:
        db_table = 'tb_spu'
        verbose_name = '商品SPU'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class SKU(BaseModel):
    """商品SKU"""
    name = models.CharField(max_length=50, verbose_name='名称')
    caption = models.CharField(max_length=100, verbose_name='副标题')
    spu = models.ForeignKey(SPU, on_delete=models.CASCADE, verbose_name='商品')
    category = models.ForeignKey(GoodsCategory, on_delete=models.PROTECT, verbose_name='从属类别')
    price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='单价')
    cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='进价')
    market_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='市场价')
    stock = models.IntegerField(default=0, verbose_name='库存')
    sales = models.IntegerField(default=0, verbose_name='销量')
    comments = models.IntegerField(default=0, verbose_name='评价数')
    is_launched = models.BooleanField(default=True, verbose_name='是否上架销售')
    default_image_url = models.CharField(max_length=200, default='', null=True, blank=True, verbose_name='默认图片')

    class Meta:
        db_table = 'tb_sku'
        verbose_name = '商品SKU'
        verbose_name_plural = verbose_name

    def __str__(self):
        return '%s: %s' % (self.id, self.name)


class SKUImage(BaseModel):
    """SKU图片"""
    sku = models.ForeignKey(SKU, on_delete=models.CASCADE, verbose_name='sku')
    image = models.ImageField(verbose_name='图片')

    class Meta:
        db_table = 'tb_sku_image'
        verbose_name = 'SKU图片'
        verbose_name_plural = verbose_name

    def __str__(self):
        return '%s %s' % (self.sku.name, self.id)


class SPUSpecification(BaseModel):
    """商品SPU规格"""
    spu = models.ForeignKey(SPU, on_delete=models.CASCADE, related_name='specs', verbose_name='商品SPU')
    name = models.CharField(max_length=20, verbose_name='规格名称')

    class Meta:
        db_table = 'tb_spu_specification'
        verbose_name = '商品SPU规格'
        verbose_name_plural = verbose_name

    def __str__(self):
        return '%s: %s' % (self.spu.name, self.name)


class SpecificationOption(BaseModel):
    """规格选项"""
    spec = models.ForeignKey(SPUSpecification, related_name='options', on_delete=models.CASCADE, verbose_name='规格')
    value = models.CharField(max_length=20, verbose_name='选项值')

    class Meta:
        db_table = 'tb_specification_option'
        verbose_name = '规格选项'
        verbose_name_plural = verbose_name

    def __str__(self):
        return '%s - %s' % (self.spec, self.value)


class SKUSpecification(BaseModel):
    """SKU具体规格"""
    sku = models.ForeignKey(SKU, related_name='specs', on_delete=models.CASCADE, verbose_name='sku')
    spec = models.ForeignKey(SPUSpecification, on_delete=models.PROTECT, verbose_name='规格名称')
    option = models.ForeignKey(SpecificationOption, on_delete=models.PROTECT, verbose_name='规格值')

    class Meta:
        db_table = 'tb_sku_specification'
        verbose_name = 'SKU规格'
        verbose_name_plural = verbose_name

    def __str__(self):
        return '%s: %s - %s' % (self.sku, self.spec.name, self.option.value)

6.2 准备商品数据

提示:

  • 数据库表有了以后,我们现在需要准备商品信息数据和商品图片数据,以便查询和展示。
  • 商品信息数据:比如商品编号等都是字符串类型的,可以直接存储在MySQL数据库。
  • 商品图片数据:MySQL通常存储的是图片的地址字符串信息。
    • 所以图片数据需要进行其他的物理存储。

img

图片物理存储思考:

  • 需要提供图片上传和下载的机制。
  • 需要解决图片备份和扩容的问题。
  • 需要解决图片重名的问题等等。

图片物理存储方案:

  • FastDFS

6.2.1 文件存储方案FastDFS

1. FastDFS介绍

  • c语言编写的一款开源的轻量级分布式文件系统。
  • 功能包括:文件存储、文件访问(文件上传、文件下载)、文件同步等,解决了大容量存储和负载均衡的问题。特别适合以文件为载体的在线服务,如相册网站、视频网站等等。
  • 为互联网量身定制,充分考虑了冗余备份、负载均衡、线性扩容等机制,并注重高可用、高性能等指标。
  • 可以帮助我们搭建一套高性能的文件服务器集群,并提供文件上传、下载等服务。

img

  • FastDFS架构 包括ClientTracker serverStorage server.

    • Client请求Tracker进行文件上传、下载,Tracker再调度Storage完成文件上传和下载。
  • Client: 客户端,业务请求的发起方,通过专有接口,使用TCP/IP协议与TrackerStorage进行数据交互。FastDFS提供了uploaddownloaddelete等接口供客户端使用。

  • Tracker server:跟踪服务器,主要做调度工作,起负载均衡的作用。在内存中记录集群中所有存储组和存储服务器的状态信息,是客户端和数据服务器交互的枢纽。

  • Storage server

    :存储服务器(存储节点或数据服务器),文件和文件属性都保存到存储服务器上。Storage server直接利用OS的文件系统调用管理文件。

    • Storage群中的横向可以扩容,纵向可以备份

2. FastDFS上传和下载流程

img

img

3. FastDFS文件索引

img

  • FastDFS上传和下载流程 可以看出都涉及到一个数据叫文件索引(file_id)
    • 文件索引(file_id)是客户端上传文件后Storage返回给客户端的一个字符串,是以后访问该文件的索引信息。
  • 文件索引(file_id)信息包括:组名、虚拟磁盘路径、数据两级目录、文件名等信息。
    • 组名:文件上传后所在的 Storage 组名称。
    • 虚拟磁盘路径:Storage 配置的虚拟路径,与磁盘选项store_path*对应。如果配置了store_path0则是M00,如果配置了store_path1则是M01,以此类推。
    • 数据两级目录:Storage 服务器在每个虚拟磁盘路径下创建的两级目录,用于存储数据文件。
    • 文件名:由存储服务器根据特定信息生成,文件名包含:源存储服务器IP地址、文件创建时间戳、文件大小、随机数和文件拓展名等信息。

img

6.2.2 容器化方案Docker

思考:

  • FastDFS的安装步骤非常的多,涉及的依赖包也很多,当新的机器需要安装FastDFS时,是否需要从头开始安装。
  • 我们在学习时拿到ubuntu系统的镜像,在VM虚拟机中运行这个镜像后,为什么就可以直接进行开发,而不需要重新搭建开发环境。
  • 在工作中,如何高效的保证开发人员写代码的开发环境与应用程序要部署的生产环境一致性。如果要部署一台新的机器,是否需要从头开始部署。

结论:

  • 上述思考的问题,都涉及到相同的工作是否需要重复做
  • 避免相同的工作重复做是容器化技术应用之一。

容器化方案:

  • Docker
  • Docker的目标之一就是缩短代码从开发、测试到部署、上线运行的周期,让我们的应用程序具备可移植性、易于构建、并易于协作。

1. Docker介绍

  • Docker中文社区文档

  • Docker 是一个开源的软件部署解决方案。

  • Docker 也是轻量级的应用容器框架。

  • Docker 可以打包、发布、运行任何的应用。

  • Docker 就像一个盒子,里面可以装很多物件,如果需要某些物件,可以直接将该盒子拿走,而不需要从该盒子中一件一件的取。

  • Docker 是一个

    客户端-服务端(C/S)
    

    架构程序。

    • 客户端只需要向服务端发出请求,服务端处理完请求后会返回结果。

Docker 包括三个基本概念:

  • 镜像(Image)
    • Docker的镜像概念类似于虚拟机里的镜像,是一个只读的模板,一个独立的文件系统,包括运行容器所需的数据,可以用来创建新的容器。
    • 例如:一个镜像可以包含一个完整的 ubuntu 操作系统环境,里面仅安装了MySQL或用户需要的其它应用程序。
  • 容器(Container)
    • Docker容器是由Docker镜像创建的运行实例,类似VM虚拟机,支持启动,停止,删除等。
    • 每个容器间是相互隔离的,容器中会运行特定的应用,包含特定应用的代码及所需的依赖文件。
  • 仓库(Repository)
    • Docker的仓库功能类似于Github,是用于托管镜像的。

2. Docker安装(ubuntu 16.04)

1.源码安装Docker CE

$ cd docker源码目录
$ sudo apt-key add gpg
$ sudo dpkg -i docker-ce_17.03.2~ce-0~ubuntu-xenial_amd64.deb

img

2.检查Docker CE是否安装正确

$ sudo docker run hello-world

出现如下信息,表示安装成功

img

3.启动与停止

  • 安装完成Docker后,默认已经启动了docker服务。
# 启动docker
$ sudo service docker start
# 重启docker
$ sudo service docker restart
# 停止docker
$ sudo service docker stop

3. Docker镜像操作

1.镜像列表

$ sudo docker image ls

img

* REPOSITORY:镜像所在的仓库名称 
* TAG:镜像标签 
* IMAGEID:镜像ID 
* CREATED:镜像的创建日期(不是获取该镜像的日期) 
* SIZE:镜像大小

2.从仓库拉取镜像

# 官方镜像
$ sudo docker image pull 镜像名称 或者 sudo docker image pull library/镜像名称
$ sudo docker image pull ubuntu 或者 sudo docker image pull library/ubuntu
$ sudo docker image pull ubuntu:16.04 或者 sudo docker image pull library/ubuntu:16.04

# 个人镜像
$ sudo docker image pull 仓库名称/镜像名称
$ sudo docker image pull itcast/fastdfs

img

img

img

3.删除镜像

$ sudo docker image rm 镜像名或镜像ID
$ sudo docker image rm hello-world
$ sudo docker image rm fce289e99eb9

img

4. Docker容器操作

1.容器列表

# 查看正在运行的容器
$ sudo docker container ls
# 查看所有的容器
$ sudo docker container ls --all

img

2.创建容器

$ sudo docker run [option] 镜像名 [向启动容器中传入的命令]
常用可选参数说明:
* -i 表示以《交互模式》运行容器。
* -t 表示容器启动后会进入其命令行。加入这两个参数后,容器创建就能登录进去。即分配一个伪终端。
* --name 为创建的容器命名。
* -v 表示目录映射关系,即宿主机目录:容器中目录。注意:最好做目录映射,在宿主机上做修改,然后共享到容器上。 
* -d 会创建一个守护式容器在后台运行(这样创建容器后不会自动登录容器)。 
* -p 表示端口映射,即宿主机端口:容器中端口。
* --network=host 表示将主机的网络环境映射到容器中,使容器的网络与主机相同。

3.交互式容器

$ sudo docker run -it --name=ubuntu1 ubuntu /bin/bash

img

img

在容器中可以随意执行linux命令,就是一个ubuntu的环境。
当执行 exit 命令退出时,该容器随之停止。

4.守护式容器

# 开启守护式容器
$ sudo docker run -dit --name=ubuntu2 ubuntu

img

# 进入到容器内部交互环境
$ sudo docker exec -it 容器名或容器id 进入后执行的第一个命令
$ sudo docker exec -it ubuntu2 /bin/bash

img

如果对于一个需要长期运行的容器来说,我们可以创建一个守护式容器。
在容器内部执行 exit 命令退出时,该容器也随之停止。

5.停止和启动容器

# 停止容器
$ sudo docker container stop 容器名或容器id
# kill掉容器
$ sudo docker container kill 容器名或容器id
# 启动容器
$ sudo docker container start 容器名或容器id

img

6.删除容器

  • 正在运行的容器无法直接删除。
$ sudo docker container rm 容器名或容器id

img

7.容器制作成镜像

  • 为保证已经配置完成的环境可以重复利用,我们可以将容器制作成镜像。
# 将容器制作成镜像
$ sudo docker commit 容器名 镜像名

img

# 镜像打包备份
$ sudo docker save -o 保存的文件名 镜像名

img

# 镜像解压
$ sudo docker load -i 文件路径/备份文件

img

6.2.3 Docker和FastDFS上传和下载文件

1. Docker安装运行FastDFS

1.获取FastDFS镜像

# 从仓库拉取镜像
$ sudo docker image pull delron/fastdfs
# 解压教学资料中本地镜像
$ sudo docker load -i 文件路径/fastdfs_docker.tar

2.开启tracker容器

  • 我们将 tracker 运行目录映射到宿主机的 /var/fdfs/tracker目录中。
$ sudo docker run -dit --name tracker --network=host -v /var/fdfs/tracker:/var/fdfs delron/fastdfs tracker

img

3.开启storage容器

  • TRACKER_SERVER=Tracker的ip地址:22122(Tracker的ip地址不要使用127.0.0.1)
  • 我们将 storage 运行目录映射到宿主机的 /var/fdfs/storage目录中。
$ sudo docker run -dti --name storage --network=host -e TRACKER_SERVER=192.168.103.158:22122 -v /var/fdfs/storage:/var/fdfs delron/fastdfs storage

img

4.查看宿主机映射路径

img

img

注意:如果无法重启storage容器,可以删除/var/fdfs/storage/data目录下的fdfs_storaged.pid 文件,然后重新运行storage。

2. FastDFS客户端上传文件

1.安装FastDFS客户端扩展

  • 安装准备好的fdfs_client-py-master.zip到虚拟环境中
$ pip install fdfs_client-py-master.zip
$ pip install mutagen
$ pip isntall requests

这里会报错

(meiduo_project) gaotianhao@gaotianhao-virtual-machine:~/Desktop/temp-for-days/20210828$ pip install fdfs_client-py-master.zip
Looking in indexes: http://mirrors.aliyun.com/pypi/simple/
Processing ./fdfs_client-py-master.zip
Building wheels for collected packages: fdfs-client-py
  Building wheel for fdfs-client-py (setup.py) ... error
  ERROR: Command errored out with exit status 1:
   command: /home/gaotianhao/.virtualenvs/meiduo_project/bin/python -u -c 'import io, os, sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-req-build-nwbw1fp0/setup.py'"'"'; __file__='"'"'/tmp/pip-req-build-nwbw1fp0/setup.py'"'"';f = getattr(tokenize, '"'"'open'"'"', open)(__file__) if os.path.exists(__file__) else io.StringIO('"'"'from setuptools import setup; setup()'"'"');code = f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' bdist_wheel -d /tmp/pip-wheel-dkt_955r
       cwd: /tmp/pip-req-build-nwbw1fp0/
  Complete output (25 lines):
  running bdist_wheel
  running build
  running build_py
  creating build
  creating build/lib.linux-x86_64-3.6
  creating build/lib.linux-x86_64-3.6/fdfs_client
  copying fdfs_client/tracker_client.py -> build/lib.linux-x86_64-3.6/fdfs_client
  copying fdfs_client/exceptions.py -> build/lib.linux-x86_64-3.6/fdfs_client
  copying fdfs_client/storage_client.py -> build/lib.linux-x86_64-3.6/fdfs_client
  copying fdfs_client/utils.py -> build/lib.linux-x86_64-3.6/fdfs_client
  copying fdfs_client/__init__.py -> build/lib.linux-x86_64-3.6/fdfs_client
  copying fdfs_client/client.py -> build/lib.linux-x86_64-3.6/fdfs_client
  copying fdfs_client/connection.py -> build/lib.linux-x86_64-3.6/fdfs_client
  copying fdfs_client/fdfs_test.py -> build/lib.linux-x86_64-3.6/fdfs_client
  copying fdfs_client/fdfs_protol.py -> build/lib.linux-x86_64-3.6/fdfs_client
  running build_ext
  building 'fdfs_client.sendfile' extension
  creating build/temp.linux-x86_64-3.6
  creating build/temp.linux-x86_64-3.6/fdfs_client
  x86_64-linux-gnu-gcc -pthread -DNDEBUG -g -fwrapv -O2 -Wall -g -fdebug-prefix-map=/build/python3.6-t1GNlY/python3.6-3.6.14=. -specs=/usr/share/dpkg/no-pie-compile.specs -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -fPIC -I/home/gaotianhao/.virtualenvs/meiduo_project/include -I/usr/include/python3.6m -c fdfs_client/sendfilemodule.c -o build/temp.linux-x86_64-3.6/fdfs_client/sendfilemodule.o
  fdfs_client/sendfilemodule.c:43:10: fatal error: Python.h: 没有那个文件或目录
     43 | #include <Python.h>
        |          ^~~~~~~~~~
  compilation terminated.
  error: command 'x86_64-linux-gnu-gcc' failed with exit status 1
  ----------------------------------------
  ERROR: Failed building wheel for fdfs-client-py
  Running setup.py clean for fdfs-client-py
Failed to build fdfs-client-py
Installing collected packages: fdfs-client-py
    Running setup.py install for fdfs-client-py ... error
    ERROR: Command errored out with exit status 1:
     command: /home/gaotianhao/.virtualenvs/meiduo_project/bin/python -u -c 'import io, os, sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-req-build-nwbw1fp0/setup.py'"'"'; __file__='"'"'/tmp/pip-req-build-nwbw1fp0/setup.py'"'"';f = getattr(tokenize, '"'"'open'"'"', open)(__file__) if os.path.exists(__file__) else io.StringIO('"'"'from setuptools import setup; setup()'"'"');code = f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' install --record /tmp/pip-record-s_ttc3_e/install-record.txt --single-version-externally-managed --compile --install-headers /home/gaotianhao/.virtualenvs/meiduo_project/include/site/python3.6/fdfs-client-py
         cwd: /tmp/pip-req-build-nwbw1fp0/
    Complete output (25 lines):
    running install
    running build
    running build_py
    creating build
    creating build/lib.linux-x86_64-3.6
    creating build/lib.linux-x86_64-3.6/fdfs_client
    copying fdfs_client/tracker_client.py -> build/lib.linux-x86_64-3.6/fdfs_client
    copying fdfs_client/exceptions.py -> build/lib.linux-x86_64-3.6/fdfs_client
    copying fdfs_client/storage_client.py -> build/lib.linux-x86_64-3.6/fdfs_client
    copying fdfs_client/utils.py -> build/lib.linux-x86_64-3.6/fdfs_client
    copying fdfs_client/__init__.py -> build/lib.linux-x86_64-3.6/fdfs_client
    copying fdfs_client/client.py -> build/lib.linux-x86_64-3.6/fdfs_client
    copying fdfs_client/connection.py -> build/lib.linux-x86_64-3.6/fdfs_client
    copying fdfs_client/fdfs_test.py -> build/lib.linux-x86_64-3.6/fdfs_client
    copying fdfs_client/fdfs_protol.py -> build/lib.linux-x86_64-3.6/fdfs_client
    running build_ext
    building 'fdfs_client.sendfile' extension
    creating build/temp.linux-x86_64-3.6
    creating build/temp.linux-x86_64-3.6/fdfs_client
    x86_64-linux-gnu-gcc -pthread -DNDEBUG -g -fwrapv -O2 -Wall -g -fdebug-prefix-map=/build/python3.6-t1GNlY/python3.6-3.6.14=. -specs=/usr/share/dpkg/no-pie-compile.specs -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -fPIC -I/home/gaotianhao/.virtualenvs/meiduo_project/include -I/usr/include/python3.6m -c fdfs_client/sendfilemodule.c -o build/temp.linux-x86_64-3.6/fdfs_client/sendfilemodule.o
    fdfs_client/sendfilemodule.c:43:10: fatal error: Python.h: 没有那个文件或目录
       43 | #include <Python.h>
          |          ^~~~~~~~~~
    compilation terminated.
    error: command 'x86_64-linux-gnu-gcc' failed with exit status 1
    ----------------------------------------
ERROR: Command errored out with exit status 1: /home/gaotianhao/.virtualenvs/meiduo_project/bin/python -u -c 'import io, os, sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-req-build-nwbw1fp0/setup.py'"'"'; __file__='"'"'/tmp/pip-req-build-nwbw1fp0/setup.py'"'"';f = getattr(tokenize, '"'"'open'"'"', open)(__file__) if os.path.exists(__file__) else io.StringIO('"'"'from setuptools import setup; setup()'"'"');code = f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' install --record /tmp/pip-record-s_ttc3_e/install-record.txt --single-version-externally-managed --compile --install-headers /home/gaotianhao/.virtualenvs/meiduo_project/include/site/python3.6/fdfs-client-py Check the logs for full command output.

解决参考链接:https://zhuanlan.zhihu.com/p/265358480

解决方案:

(meiduo_project) gaotianhao@gaotianhao-virtual-machine:~/Desktop/temp-for-days/20210828$ sudo apt-get install python3.6-dev
[sudo] gaotianhao 的密码: 
正在读取软件包列表... 完成
正在分析软件包的依赖关系树       
正在读取状态信息... 完成       
将会同时安装下列软件:
  libpython3.6 libpython3.6-dev
下列【新】软件包将被安装:
  libpython3.6 libpython3.6-dev python3.6-dev
升级了 0 个软件包,新安装了 3 个软件包,要卸载 0 个软件包,有 40 个软件包未被升级。
需要下载 5,742 kB 的归档。
解压缩后会消耗 23.5 MB 的额外空间。
您希望继续执行吗? [Y/n] y
获取:1 http://ppa.launchpad.net/deadsnakes/ppa/ubuntu focal/main amd64 libpython3.6 amd64 3.6.14-1+focal1 [1,568 kB]
获取:2 http://ppa.launchpad.net/deadsnakes/ppa/ubuntu focal/main amd64 libpython3.6-dev amd64 3.6.14-1+focal1 [3,673 kB]                                                                     
获取:3 http://ppa.launchpad.net/deadsnakes/ppa/ubuntu focal/main amd64 python3.6-dev amd64 3.6.14-1+focal1 [501 kB]                                                                          
已下载 5,742 kB,耗时 22秒 (255 kB/s)                                                                                                                                                        
debconf: 无法初始化前端界面:Dialog
debconf: (对话框界面要求屏幕画面必须为至少 13 行高及 31 列宽.)
debconf: 返回前端界面:Readline
正在选中未选择的软件包 libpython3.6:amd64。
(正在读取数据库 ... 系统当前共安装有 204563 个文件和目录。)
准备解压 .../libpython3.6_3.6.14-1+focal1_amd64.deb  ...
正在解压 libpython3.6:amd64 (3.6.14-1+focal1) ...
正在选中未选择的软件包 libpython3.6-dev:amd64。
准备解压 .../libpython3.6-dev_3.6.14-1+focal1_amd64.deb  ...
正在解压 libpython3.6-dev:amd64 (3.6.14-1+focal1) ...
正在选中未选择的软件包 python3.6-dev。
准备解压 .../python3.6-dev_3.6.14-1+focal1_amd64.deb  ...
正在解压 python3.6-dev (3.6.14-1+focal1) ...
正在设置 libpython3.6:amd64 (3.6.14-1+focal1) ...
正在设置 libpython3.6-dev:amd64 (3.6.14-1+focal1) ...
正在设置 python3.6-dev (3.6.14-1+focal1) ...
正在处理用于 man-db (2.9.1-1) 的触发器 ...
正在处理用于 libc-bin (2.31-0ubuntu9.2) 的触发器 ...
(meiduo_project) gaotianhao@gaotianhao-virtual-machine:~/Desktop/temp-for-days/20210828$ pip install fdfs_client-py-master.zip
Looking in indexes: http://mirrors.aliyun.com/pypi/simple/
Processing ./fdfs_client-py-master.zip
Building wheels for collected packages: fdfs-client-py
  Building wheel for fdfs-client-py (setup.py) ... done
  Created wheel for fdfs-client-py: filename=fdfs_client_py-1.2.6-cp36-cp36m-linux_x86_64.whl size=52731 sha256=55960310657d89ed0fdd29ebca4e79a12e2ecdb4495829dfe453dc22ab371910
  Stored in directory: /home/gaotianhao/.cache/pip/wheels/56/1f/0c/cca6089b6f1f8b7eef1b6dcd5d5fb85a802e58592e3a5fe3a9
Successfully built fdfs-client-py
Installing collected packages: fdfs-client-py
Successfully installed fdfs-client-py-1.2.6
WARNING: You are using pip version 21.1.2; however, version 21.2.4 is available.
You should consider upgrading via the '/home/gaotianhao/.virtualenvs/meiduo_project/bin/python -m pip install --upgrade pip' command.

2.准备FastDFS客户端扩展的配置文件

  • meiduo_mall.utils.fastdfs.client.conf

img

base_path=FastDFS客户端存放日志文件的目录
tracker_server=运行Tracker服务的机器ip:22122

3.FastDFS客户端实现文件存储

# 使用 shell 进入 Python交互环境
$ python manage.py shell
# 1. 导入FastDFS客户端扩展
from fdfs_client.client import Fdfs_client
# 2. 创建FastDFS客户端实例
client = Fdfs_client('meiduo_mall/utils/fastdfs/client.conf')
# 3. 调用FastDFS客户端上传文件方法
ret = client.upload_by_filename('/Users/zhangjie/Desktop/kk.jpeg')
ret = {
'Group name': 'group1',
'Remote file_id': 'group1/M00/00/00/wKhnnlxw_gmAcoWmAAEXU5wmjPs35.jpeg',
'Status': 'Upload successed.',
'Local file name': '/Users/zhangjie/Desktop/kk.jpeg',
'Uploaded size': '69.00KB',
'Storage IP': '192.168.103.158'
 }
ret = {
'Group name': 'Storage组名',
'Remote file_id': '文件索引,可用于下载',
'Status': '文件上传结果反馈',
'Local file name': '上传文件全路径',
'Uploaded size': '文件大小',
'Storage IP': 'Storage地址'
 }

img

3. 浏览器下载并渲染图片

思考:如何才能找到在Storage中存储的图片?

  • 协议:

    • http
  • IP地址:192.168.103.158

    • Nginx服务器的IP地址。
  • 因为 FastDFS 擅长存储静态文件,但是不擅长提供静态文件的下载服务,所以我们一般会将 Nginx 服务器绑定到 Storage ,提升下载性能。

  • 端口:8888

    • Nginx服务器的端口。
  • 路径:group1/M00/00/00/wKhnnlxw_gmAcoWmAAEXU5wmjPs35.jpeg

    • 文件在Storage上的文件索引。
  • 完整图片下载地址

    • http://192.168.103.158:8888/group1/M00/00/00/wKhnnlxw_gmAcoWmAAEXU5wmjPs35.jpeg

编写测试代码:meiduo_mall.utils.fdfs_t.html

<img src="http://192.168.103.158:8888/group1/M00/00/00/wKhnnlxw_gmAcoWmAAEXU5wmjPs35.jpeg" width="320" height="480">

6.2.4 录入商品数据和图片数据

1. SQL脚本录入商品数据

$ mysql -h127.0.0.1 -uroot -pmysql meiduo_mall < 文件路径/goods_data.sql

2. FastDFS服务器录入图片数据

1.准备新的图片数据压缩包

img

2.删除 Storage 中旧的data目录

img

3.拷贝新的图片数据压缩包到 Storage,并解压

# 解压命令
sudo tar -zxvf data.tar.gz

img

4.查看新的data目录

img

img

6.3 首页广告

首页数据由 商品频道分类广告 组成

img

6.3.1 展示首页商品频道分类

1. 分析首页商品频道分类数据结构

img

{
    "1":{
        "channels":[
            {"id":1, "name":"手机", "url":"http://shouji.jd.com/"},
            {"id":2, "name":"相机", "url":"http://www.itcast.cn/"}
        ],
        "sub_cats":[
            {
                "id":38, 
                "name":"手机通讯", 
                "sub_cats":[
                    {"id":115, "name":"手机"},
                    {"id":116, "name":"游戏手机"}
                ]
            },
            {
                "id":39, 
                "name":"手机配件", 
                "sub_cats":[
                    {"id":119, "name":"手机壳"},
                    {"id":120, "name":"贴膜"}
                ]
            }
        ]
    },
    "2":{
        "channels":[],
        "sub_cats":[]
    }
}

2. 查询首页商品频道分类

class IndexView(View):
    """首页广告"""

    def get(self, request):
        """提供首页广告界面"""
        # 查询商品频道和分类
        categories = OrderedDict()
        channels = GoodsChannel.objects.order_by('group_id', 'sequence')
        for channel in channels:
            group_id = channel.group_id  # 当前组

            if group_id not in categories:
                categories[group_id] = {'channels': [], 'sub_cats': []}

            cat1 = channel.category  # 当前频道的类别

            # 追加当前频道
            categories[group_id]['channels'].append({
                'id': cat1.id,
                'name': cat1.name,
                'url': channel.url
            })
            # 构建当前类别的子类别
            for cat2 in cat1.subs.all():
                cat2.sub_cats = []
                for cat3 in cat2.subs.all():
                    cat2.sub_cats.append(cat3)
                categories[group_id]['sub_cats'].append(cat2)

        # 渲染模板的上下文
        context = {
            'categories': categories,
        }
        return render(request, 'index.html', context)

3. 渲染首页商品频道分类

index.html

<ul class="sub_menu">
    {% for group in categories.values() %}
    <li>
        <div class="level1">
            {% for channel in group.channels %}
            <a href="{{ channel.url }}">{{ channel.name }}</a>
            {% endfor %}
        </div>
        <div class="level2">
            {% for cat2 in group.sub_cats %}
            <div class="list_group">
                <div class="group_name fl">{{ cat2.name }} &gt;</div>
                <div class="group_detail fl">
                    {% for cat3 in cat2.sub_cats %}
                    <a href="/list/{{ cat3.id }}/1/">{{ cat3.name }}</a>
                    {% endfor %}
                </div>
            </div>
            {% endfor %}
        </div>
    </li>
    {% endfor %}
</ul>

4. 封装首页商品频道分类

1.封装首页商品频道分类到contents.utils.py文件

def get_categories():
    """
    提供商品频道和分类
    :return 菜单字典
    """
    # 查询商品频道和分类
    categories = OrderedDict()
    channels = GoodsChannel.objects.order_by('group_id', 'sequence')
    for channel in channels:
        group_id = channel.group_id  # 当前组

        if group_id not in categories:
            categories[group_id] = {'channels': [], 'sub_cats': []}

        cat1 = channel.category  # 当前频道的类别

        # 追加当前频道
        categories[group_id]['channels'].append({
            'id': cat1.id,
            'name': cat1.name,
            'url': channel.url
        })
        # 构建当前类别的子类别
        for cat2 in cat1.subs.all():
            cat2.sub_cats = []
            for cat3 in cat2.subs.all():
                cat2.sub_cats.append(cat3)
            categories[group_id]['sub_cats'].append(cat2)
    return categories

2.contents.view.py中使用contents.utils.py文件

class IndexView(View):
    """首页广告"""

    def get(self, request):
        """提供首页广告界面"""
        # 查询商品频道和分类
        categories = get_categories()

        # 广告数据
        contents = {}
        content_categories = ContentCategory.objects.all()
        for cat in content_categories:
            contents[cat.key] = cat.content_set.filter(status=True).order_by('sequence')

        # 渲染模板的上下文
        context = {
            'categories': categories,
            'contents': contents,
        }
        return render(request, 'index.html', context)

6.3.2 展示首页商品广告

1. 分析首页商品广告数据结构

img

img

img

结论:

  • 首页商品广告数据由广告分类和广告内容组成。
  • 广告分类带有标识符key,可以利用它确定广告展示的位置。
  • 确定广告展示的位置后,再查询和渲染出该位置的广告内容。
  • 广告的内容还有内部的排序字段,决定了广告内容的展示顺序。

2. 查询首页商品广告

class IndexView(View):
    """首页广告"""

    def get(self, request):
        """提供首页广告界面"""
        # 查询商品频道和分类
        ......

        # 广告数据
        contents = {}
        content_categories = ContentCategory.objects.all()
        for cat in content_categories:
            contents[cat.key] = cat.content_set.filter(status=True).order_by('sequence')

        # 渲染模板的上下文
        context = {
            'categories': categories,
            'contents': contents,
        }
        return render(request, 'index.html', context)

3. 渲染首页商品广告

1.轮播图广告

<ul class="slide">
    {% for content in contents.index_lbt %}
    <li><a href="{{ content.url }}"><img src="{{ content.image }}" alt="{{ content.title }}"></a></li>
    {% endfor %}
</ul>

2.快讯和页头广告

<div class="news">
    <div class="news_title">
        <h3>快讯</h3>
        <a href="#">更多 &gt;</a>
    </div>
    <ul class="news_list">
        {% for content in contents.index_kx %}
        <li><a href="{{ content.url }}">{{ content.title }}</a></li>
        {% endfor %}
    </ul>
    {% for content in contents.index_ytgg %}
    <a href="{{ content.url }}" class="advs"><img src="{{ content.image }}"></a>
    {% endfor %}
</div>

3.楼层广告(一楼)

<div class="list_model">
    <div class="list_title clearfix">
        <h3 class="fl" id="model01">1F 手机通讯</h3>
        <div class="subtitle fr">
            <a @mouseenter="f1_tab=1" :class="f1_tab===1?'active':''">时尚新品</a>
            <a @mouseenter="f1_tab=2" :class="f1_tab===2?'active':''">畅想低价</a>
            <a @mouseenter="f1_tab=3" :class="f1_tab===3?'active':''">手机配件</a>
        </div>
    </div>
    <div class="goods_con clearfix">
        <div class="goods_banner fl">
            <img src="{{ contents.index_1f_logo.0.image}}">
            <div class="channel">
                {% for content in contents.index_1f_pd %}
                <a href="{{ content.url }}">{{ content.title }}</a>
                {% endfor %}
            </div>
            <div class="key_words">
                {% for content in contents.index_1f_bq %}
                <a href="{{ content.url }}">{{ content.title }}</a>
                {% endfor %}
            </div>

        </div>
        <div class="goods_list_con">
            <ul v-show="f1_tab===1" class="goods_list fl">
                {% for content in contents.index_1f_ssxp %}
                <li>
                    <a href="{{ content.url }}" class="goods_pic"><img src="{{ content.image }}"></a>
                    <h4><a href="{{ content.url }}" title="{{ content.title }}">{{ content.title }}</a></h4>
                    <div class="price">{{ content.text }}</div>
                </li>
                {% endfor %}
            </ul>
            <ul v-show="f1_tab===2" class="goods_list fl">
                {% for content in contents.index_1f_cxdj %}
                <li>
                    <a href="{{ content.url }}" class="goods_pic"><img src="{{ content.image }}"></a>
                    <h4><a href="{{ content.url }}" title="{{ content.title }}">{{ content.title }}</a></h4>
                    <div class="price">{{ content.text }}</div>
                </li>
                {% endfor %}
            </ul>
            <ul v-show="f1_tab===3" class="goods_list fl">
                {% for content in contents.index_1f_sjpj %}
                <li>
                    <a href="{{ content.url }}" class="goods_pic"><img src="{{ content.image }}"></a>
                    <h4><a href="{{ content.url }}" title="{{ content.title }}">{{ content.title }}</a></h4>
                    <div class="price">{{ content.text }}</div>
                </li>
                {% endfor %}
            </ul>
        </div>
    </div>
</div>

4.楼层广告(二楼)

<div class="list_model model02">
    <div class="list_title clearfix">
        <h3 class="fl" id="model01">2F 电脑数码</h3>
        <div class="subtitle fr">
            <a @mouseenter="f2_tab=1" :class="f2_tab===1?'active':''">加价换购</a>
            <a @mouseenter="f2_tab=2" :class="f2_tab===2?'active':''">畅享低价</a>
        </div>
    </div>
    <div class="goods_con clearfix">
        <div class="goods_banner fl">
            <img src="{{ contents.index_2f_logo.0.image}}">
            <div class="channel">
                {% for content in contents.index_2f_pd %}
                <a href="{{ content.url }}">{{ content.title }}</a>
                {% endfor %}
            </div>
            <div class="key_words">
                {% for content in contents.index_2f_bq %}
                <a href="{{ content.url }}">{{ content.title }}</a>
                {% endfor %}
            </div>
        </div>
        <div class="goods_list_con">
            <ul v-show="f2_tab===1" class="goods_list fl">
                {% for content in contents.index_2f_cxdj %}
                <li>
                    <a href="{{ content.url }}" class="goods_pic"><img src="{{ content.image }}"></a>
                    <h4><a href="{{ content.url }}" title="{{ content.title }}">{{ content.title }}</a></h4>
                    <div class="price">{{ content.text }}</div>
                </li>
                {% endfor %}
            </ul>
            <ul v-show="f2_tab===2" class="goods_list fl">
                {% for content in contents.index_2f_jjhg %}
                <li>
                    <a href="{{ content.url }}" class="goods_pic"><img src="{{ content.image }}"></a>
                    <h4><a href="{{ content.url }}" title="{{ content.title }}">{{ content.title }}</a></h4>
                    <div class="price">{{ content.text }}</div>
                </li>
                {% endfor %}
            </ul>
        </div>
    </div>
</div>

5.楼层广告(三楼)

<div class="list_model model03">
    <div class="list_title clearfix">
        <h3 class="fl" id="model01">3F 家居家装</h3>
        <div class="subtitle fr">
            <a @mouseenter="f3_tab=1" :class="f3_tab===1?'active':''">生活用品</a>
            <a @mouseenter="f3_tab=2" :class="f3_tab===2?'active':''">厨房用品</a>
        </div>
    </div>
    <div class="goods_con clearfix">
        <div class="goods_banner fl">
            <img src="{{ contents.index_3f_logo.0.image }}">
            <div class="channel">
                {% for content in contents.index_3f_pd %}
                <a href="{{ content.url }}">{{ content.title }}</a>
                {% endfor %}
            </div>
            <div class="key_words">
                {% for content in contents.index_3f_bq %}
                <a href="{{ content.url }}">{{ content.title }}</a>
                {% endfor %}
            </div>
        </div>
        <div class="goods_list_con">
            <ul v-show="f3_tab===1" class="goods_list fl">
                {% for content in contents.index_3f_shyp %}
                <li>
                    <a href="{{ content.url }}" class="goods_pic"><img src="{{ content.image }}"></a>
                    <h4><a href="{{ content.url }}" title="{{ content.title }}">{{ content.title }}</a></h4>
                    <div class="price">{{ content.text }}</div>
                </li>
                {% endfor %}
            </ul>
            <ul v-show="f3_tab===2" class="goods_list fl">
                {% for content in contents.index_3f_cfyp %}
                <li>
                    <a href="{{ content.url }}" class="goods_pic"><img src="{{ content.image }}"></a>
                    <h4><a href="{{ content.url }}" title="{{ content.title }}">{{ content.title }}</a></h4>
                    <div class="price">{{ content.text }}</div>
                </li>
                {% endfor %}
            </ul>
        </div>
    </div>
</div>

6.3.3 自定义Django文件存储类

思考:

  • 下图首页页面中图片无法显示的原因。

img

img

img

img

结论:

  • 通过FastDFS上传文件后返回的'Remote file_id'字段是文件索引。
  • 文件索引会被我们存储到MySQL数据库。所以将来读取出来的也是文件索引,导致界面无法下载到图片。

解决:

  • 重写Django文件存储类的url()方法。
  • 在重写时拼接完整的图片下载地址(协议、IP、端口、文件索引)

1. Django文件存储类url()方法介绍

img

结论:

  • 文件存储类url()方法的作用:返回name所代表的文件内容的URL。

  • 文件存储类url()方法的触发:content.image.url

    • 虽然表面上调用的是ImageFieldurl方法。但是内部会去调用文件存储类的url()方法。
  • 文件存储类url()方法的使用:

    • 我们可以通过自定义Django文件存储类达到重写url()方法的目的。
  • 自定义Django文件存储类必须提供url()方法。

  • 返回name所指的文件对应的绝对URL。

2. 自定义Django文件存储类

自定义文件存储类的官方文档

class FastDFSStorage(Storage):
    """自定义文件存储系统"""

    def _open(self, name, mode='rb'):
        """
        用于打开文件
        :param name: 要打开的文件的名字
        :param mode: 打开文件方式
        :return: None
        """
        # 打开文件时使用的,此时不需要,而文档告诉说明必须实现,所以pass
        pass

    def _save(self, name, content):
        """
        用于保存文件
        :param name: 要保存的文件名字
        :param content: 要保存的文件的内容
        :return: None
        """
        # 保存文件时使用的,此时不需要,而文档告诉说明必须实现,所以pass
        pass

3. 重写Django文件存储类url()方法

1.重写url()方法

class FastDFSStorage(Storage):
    """自定义文件存储系统,修改存储的方案"""
    def __init__(self, fdfs_base_url=None):
        """
        构造方法,可以不带参数,也可以携带参数
        :param base_url: Storage的IP
        """
        self.fdfs_base_url = fdfs_base_url or settings.FDFS_BASE_URL

    def _open(self, name, mode='rb'):
        ......

    def _save(self, name, content):
        ......

    def url(self, name):
        """
        返回name所指文件的绝对URL
        :param name: 要读取文件的引用:group1/M00/00/00/wKhnnlxw_gmAcoWmAAEXU5wmjPs35.jpeg
        :return: http://192.168.103.158:8888/group1/M00/00/00/wKhnnlxw_gmAcoWmAAEXU5wmjPs35.jpeg
        """
        # return 'http://192.168.103.158:8888/' + name
        # return 'http://image.meiduo.site:8888/' + name
        return self.fdfs_base_url + name

2.相关配置参数

# 指定自定义的Django文件存储类
DEFAULT_FILE_STORAGE = 'meiduo_mall.utils.fastdfs.fdfs_storage.FastDFSStorage'

# FastDFS相关参数
# FDFS_BASE_URL = 'http://192.168.103.158:8888/'
FDFS_BASE_URL = 'http://image.meiduo.site:8888/'

3.添加访问图片的域名

  • /etc/hosts中添加访问Storage的域名
$ Storage的IP        域名
$ 192.168.103.158    image.meiduo.site

4.文件存储类url()方法的使用

  • 以图片轮播图为例:content.image.url
<ul class="slide">
    {% for content in contents.index_lbt %}
    <li><a href="{{ content.url }}"><img src="{{ content.image.url }}" alt="{{ content.title }}"></a></li>
    {% endfor %}
</ul>

img

6.4 商品列表页

6.4.1 商品列表页分析

img

1. 商品列表页组成结构分析

1.商品频道分类

  • 已经提前封装在contents.utils.py文件中,直接调用即可。

2.面包屑导航

  • 可以使用三级分类ID,查询出该类型商品的三级分类数据。

3.排序和分页

  • 无论如何排序和分页,商品的分类不能变。
  • 排序时需要知道当前排序方式。
  • 分页时需要知道当前分页的页码,且每页五条商品记录。

4.热销排行

  • 热销排行中的商品分类要和排序、分页的商品分类一致。
  • 热销排行是查询出指定分类商品销量前二的商品。
  • 热销排行使用Ajax实现局部刷新的效果。

2. 商品列表页接口设计和定义

1.请求方式

选项 方案
请求方法 GET
请求地址 /list/(?P<category_id>\d+)/(?P<page_num>\d+)/?sort=排序方式
# 按照商品创建时间排序
http://www.meiduo.site:8000/list/115/1/?sort=default
# 按照商品价格由低到高排序
http://www.meiduo.site:8000/list/115/1/?sort=price
# 按照商品销量由高到低排序
http://www.meiduo.site:8000/list/115/1/?sort=hot

2.请求参数:路径参数 和 查询参数

参数名 类型 是否必传 说明
category_id string 商品分类ID,第三级分类
page_num string 当前页码
sort string 排序方式

3.响应结果:HTML

list.html

4.接口定义

class ListView(View):
    """商品列表页"""

    def get(self, request, category_id, page_num):
        """提供商品列表页"""
        return render(request, 'list.html')

6.4.2 列表页面包屑导航

重要提示:路径参数category_id是商品第三级分类

1. 查询列表页面包屑导航数据

提示: 对包屑导航数据的查询进行封装,方便后续直接使用。

goods.utils.py
def get_breadcrumb(category):
    """
    获取面包屑导航
    :param category: 商品类别
    :return: 面包屑导航字典
    """
    breadcrumb = dict(
        cat1='',
        cat2='',
        cat3=''
    )
    if category.parent is None:
        # 当前类别为一级类别
        breadcrumb['cat1'] = category
    elif category.subs.count() == 0:
        # 当前类别为三级
        breadcrumb['cat3'] = category
        cat2 = category.parent
        breadcrumb['cat2'] = cat2
        breadcrumb['cat1'] = cat2.parent
    else:
        # 当前类别为二级
        breadcrumb['cat2'] = category
        breadcrumb['cat1'] = category.parent

    return breadcrumb
class ListView(View):
    """商品列表页"""

    def get(self, request, category_id, page_num):
        """提供商品列表页"""
        # 判断category_id是否正确
        try:
            category = models.GoodsCategory.objects.get(id=category_id)
        except models.GoodsCategory.DoesNotExist:
            return http.HttpResponseNotFound('GoodsCategory does not exist')

        # 查询商品频道分类
        categories = get_categories()
        # 查询面包屑导航
        breadcrumb = get_breadcrumb(category)

        # 渲染页面
        context = {
            'categories':categories,
            'breadcrumb':breadcrumb
        }
        return render(request, 'list.html', context)

2. 渲染列表页面包屑导航数据

<div class="breadcrumb">
    <a href="{{ breadcrumb.cat1.url }}">{{ breadcrumb.cat1.name }}</a>
    <span>></span>
    <a href="javascript:;">{{ breadcrumb.cat2.name }}</a>
    <span>></span>
    <a href="javascript:;">{{ breadcrumb.cat3.name }}</a>
</div>

6.4.3 列表页分页和排序

# 按照商品创建时间排序
http://www.meiduo.site:8000/list/115/1/?sort=default
# 按照商品价格由低到高排序
http://www.meiduo.site:8000/list/115/1/?sort=price
# 按照商品销量由高到低排序
http://www.meiduo.site:8000/list/115/1/?sort=hot

1. 查询列表页分页和排序数据

class ListView(View):
    """商品列表页"""

    def get(self, request, category_id, page_num):
        """提供商品列表页"""
        # 判断category_id是否正确
        try:
            category = models.GoodsCategory.objects.get(id=category_id)
        except models.GoodsCategory.DoesNotExist:
            return http.HttpResponseNotFound('GoodsCategory does not exist')
        # 接收sort参数:如果用户不传,就是默认的排序规则
        sort = request.GET.get('sort', 'default')

        # 查询商品频道分类
        categories = get_categories()
        # 查询面包屑导航
        breadcrumb = get_breadcrumb(category)

        # 按照排序规则查询该分类商品SKU信息
        if sort == 'price':
            # 按照价格由低到高
            sort_field = 'price'
        elif sort == 'hot':
            # 按照销量由高到低
            sort_field = '-sales'
        else:
            # 'price'和'sales'以外的所有排序方式都归为'default'
            sort = 'default'
            sort_field = 'create_time'
        skus = models.SKU.objects.filter(category=category, is_launched=True).order_by(sort_field)

        # 创建分页器:每页N条记录
        paginator = Paginator(skus, constants.GOODS_LIST_LIMIT)
        # 获取每页商品数据
        try:
            page_skus = paginator.page(page_num)
        except EmptyPage:
            # 如果page_num不正确,默认给用户404
            return http.HttpResponseNotFound('empty page')
        # 获取列表页总页数
        total_page = paginator.num_pages

        # 渲染页面
        context = {
            'categories': categories,   # 频道分类
            'breadcrumb': breadcrumb,   # 面包屑导航
            'sort': sort,               # 排序字段
            'category': category,       # 第三级分类
            'page_skus': page_skus,     # 分页后数据
            'total_page': total_page,   # 总页数
            'page_num': page_num,       # 当前页码
        }
        return render(request, 'list.html', context)

2. 渲染列表页分页和排序数据

1.渲染分页和排序数据

<div class="r_wrap fr clearfix">
    <div class="sort_bar">
        <a href="{{ url('goods:list', args=(category.id, page_num)) }}?sort=default" {% if sort == 'default' %}class="active"{% endif %}>默认</a>
        <a href="{{ url('goods:list', args=(category.id, page_num)) }}?sort=price" {% if sort == 'price' %}class="active"{% endif %}>价格</a>
        <a href="{{ url('goods:list', args=(category.id, page_num)) }}?sort=hot" {% if sort == 'hot' %}class="active"{% endif %}>人气</a>
    </div>
    <ul class="goods_type_list clearfix">
        {% for sku in page_skus %}
        <li>
        <a href="detail.html"><img src="{{ sku.default_image.url }}"></a>
        <h4><a href="detail.html">{{ sku.name }}</a></h4>
        <div class="operate">
            <span class="price">¥{{ sku.price }}</span>
            <span class="unit">台</span>
            <a href="#" class="add_goods" title="加入购物车"></a>
        </div>
        </li>
        {% endfor %}
    </ul>
</div>

2.列表页分页器

img

准备分页器标签

<div class="r_wrap fr clearfix">
    ......
    <div class="pagenation">
        <div id="pagination" class="page"></div>
    </div>
</div>
# 导入样式时放在最前面导入
<link rel="stylesheet" type="text/css" href="{{ static('css/jquery.pagination.css') }}">

准备分页器交互

<script type="text/javascript" src="{{ static('js/jquery.pagination.min.js') }}"></script>
<script type="text/javascript">
    $(function () {
        $('#pagination').pagination({
            currentPage: {{ page_num }},
            totalPage: {{ total_page }},
            callback:function (current) {
                {#location.href = '/list/115/1/?sort=default';#}
                location.href = '/list/{{ category.id }}/' + current + '/?sort={{ sort }}';
            }
        })
    });
</script>

6.4.4 列表页热销排行

根据路径参数category_id查询出该类型商品销量前二的商品。

使用Ajax实现局部刷新的效果。

1. 查询列表页热销排行数据

1.请求方式

选项 方案
请求方法 GET
请求地址 /hot/(?P<category_id>\d+)/

2.请求参数:路径参数

参数名 类型 是否必传 说明
category_id string 商品分类ID,第三级分类

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息
hot_skus[ ] 热销SKU列表
id SKU编号
default_image_url 商品默认图片
name 商品名称
price 商品价格
{
    "code":"0",
    "errmsg":"OK",
    "hot_skus":[
        {
            "id":6,
            "default_image_url":"http://image.meiduo.site:8888/group1/M00/00/02/CtM3BVrRbI2ARekNAAFZsBqChgk3141998",
            "name":"Apple iPhone 8 Plus (A1864) 256GB 深空灰色 移动联通电信4G手机",
            "price":"7988.00"
        },
        {
            "id":14,
            "default_image_url":"http://image.meiduo.site:8888/group1/M00/00/02/CtM3BVrRdMSAaDUtAAVslh9vkK04466364",
            "name":"华为 HUAWEI P10 Plus 6GB+128GB 玫瑰金 移动联通电信4G手机 双卡双待",
            "price":"3788.00"
        }
    ]
}

4.接口定义和实现

class HotGoodsView(View):
    """商品热销排行"""

    def get(self, request, category_id):
        """提供商品热销排行JSON数据"""
        # 根据销量倒序
        skus = models.SKU.objects.filter(category_id=category_id, is_launched=True).order_by('-sales')[:2]

        # 序列化
        hot_skus = []
        for sku in skus:
            hot_skus.append({
                'id':sku.id,
                'default_image_url':sku.default_image.url,
                'name':sku.name,
                'price':sku.price
            })

        return http.JsonResponse({'code':RETCODE.OK, 'errmsg':'OK', 'hot_skus':hot_skus})

2. 渲染列表页热销排行数据

1.模板数据category_id传递到Vue.js

<script type="text/javascript">
    let category_id = "{{ category.id }}";
</script>
data: {
    category_id: category_id,
},

2.Ajax请求商品热销排行JSON数据

get_hot_skus(){
    if (this.category_id) {
        let url = '/hot/'+ this.category_id +'/';
        axios.get(url, {
            responseType: 'json'
        })
            .then(response => {
                this.hot_skus = response.data.hot_skus;
                for(let i=0; i<this.hot_skus.length; i++){
                    this.hot_skus[i].url = '/detail/' + this.hot_skus[i].id + '/';
                }
            })
            .catch(error => {
                console.log(error.response);
            })
    }
},

3.渲染商品热销排行界面

<div class="new_goods" v-cloak>
    <h3>热销排行</h3>
    <ul>
        <li v-for="sku in hot_skus">
            <a :href="sku.url"><img :src="sku.default_image_url"></a>
            <h4><a :href="sku.url">[[ sku.name ]]</a></h4>
            <div class="price">¥[[ sku.price ]]</div>
        </li>
    </ul>
</div>

6.5 商品搜索

6.5.1 全文检索方案Elasticsearch

img

1. 全文检索和搜索引擎原理

商品搜索需求

  • 当用户在搜索框输入商品关键字后,我们要为用户提供相关的商品搜索结果。

商品搜索实现

  • 可以选择使用模糊查询like关键字实现。
  • 但是 like 关键字的效率极低。
  • 查询需要在多个字段中进行,使用 like 关键字也不方便。

全文检索方案

  • 我们引入全文检索的方案来实现商品搜索。
  • 全文检索即在指定的任意字段中进行检索查询。
  • 全文检索方案需要配合搜索引擎来实现。

搜索引擎原理

  • 搜索引擎进行全文检索时,会对数据库中的数据进行一遍预处理,单独建立起一份索引结构数据
  • 索引结构数据类似新华字典的索引检索页,里面包含了关键词与词条的对应关系,并记录词条的位置。
  • 搜索引擎进行全文检索时,将关键字在索引数据中进行快速对比查找,进而找到数据的真实存储位置

img

结论:

  • 搜索引擎建立索引结构数据,类似新华字典的索引检索页,全文检索时,关键字在索引数据中进行快速对比查找,进而找到数据的真实存储位置。

2. Elasticsearch介绍

实现全文检索的搜索引擎,首选的是Elasticsearch

  • Elasticsearch 是用 Java 实现的,开源的搜索引擎。
  • 它可以快速地储存、搜索和分析海量数据。维基百科、Stack Overflow、Github等都采用它。
  • Elasticsearch 的底层是开源库 Lucene。但是,没法直接使用 Lucene,必须自己写代码去调用它的接口。

分词说明

  • 搜索引擎在对数据构建索引时,需要进行分词处理。

  • 分词是指将一句话拆解成多个单字,这些字或词便是这句话的关键词。

  • 比如:

    我是中国人
    
    • 分词后:中国等等都可以是这句话的关键字。
  • Elasticsearch 不支持对中文进行分词建立索引,需要配合扩展elasticsearch-analysis-ik来实现中文分词处理。

3. 使用Docker安装Elasticsearch

1.获取Elasticsearch-ik镜像

# 从仓库拉取镜像
$ sudo docker image pull delron/elasticsearch-ik:2.4.6-1.0
# 解压教学资料中本地镜像
$ sudo docker load -i elasticsearch-ik-2.4.6_docker.tar

2.配置Elasticsearch-ik

  • 将教学资料中的elasticsearc-2.4.6目录拷贝到home目录下。
  • 修改/home/python/elasticsearc-2.4.6/config/elasticsearch.yml第54行。
  • 更改ip地址为本机真实ip地址。

img

3.使用Docker运行Elasticsearch-ik

$ sudo docker run -dti --name=elasticsearch --network=host -v /home/python/elasticse

img

img

6.5.2 Haystack扩展建立索引

提示:

  • Elasticsearch 的底层是开源库 Lucene。但是没法直接使用 Lucene,必须自己写代码去调用它的接口。

思考:

  • 我们如何对接 Elasticsearch服务端?

解决方案:

  • Haystack

1. Haystack介绍和安装配置

1.Haystack介绍

  • Haystack 是在Django中对接搜索引擎的框架,搭建了用户和搜索引擎之间的沟通桥梁。
    • 我们在Django中可以通过使用 Haystack 来调用 Elasticsearch 搜索引擎。
  • Haystack 可以在不修改代码的情况下使用不同的搜索后端(比如 ElasticsearchWhooshSolr等等)。

2.Haystack安装

$ pip install django-haystack
$ pip install elasticsearch==2.4.1

3.Haystack注册应用和路由

INSTALLED_APPS = [
    'haystack', # 全文检索
]
url(r'^search/', include('haystack.urls')),

4.Haystack配置

  • 在配置文件中配置Haystack为搜索引擎后端
# Haystack
HAYSTACK_CONNECTIONS = {
    'default': {
        'ENGINE': 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine',
        'URL': 'http://192.168.103.158:9200/', # Elasticsearch服务器ip地址,端口号固定为9200
        'INDEX_NAME': 'meiduo_mall', # Elasticsearch建立的索引库的名称
    },
}

# 当添加、修改、删除数据时,自动生成索引
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'

重要提示:

  • HAYSTACK_SIGNAL_PROCESSOR 配置项保证了在Django运行起来后,有新的数据产生时,Haystack仍然可以让Elasticsearch实时生成新数据的索引

2. Haystack建立数据索引

1.创建索引类

  • 通过创建索引类,来指明让搜索引擎对哪些字段建立索引,也就是可以通过哪些字段的关键字来检索数据。
  • 本项目中对SKU信息进行全文检索,所以在goods应用中新建search_indexes.py文件,用于存放索引类。
from haystack import indexes

from .models import SKU


class SKUIndex(indexes.SearchIndex, indexes.Indexable):
    """SKU索引数据模型类"""
    text = indexes.CharField(document=True, use_template=True)

    def get_model(self):
        """返回建立索引的模型类"""
        return SKU

    def index_queryset(self, using=None):
        """返回要建立索引的数据查询集"""
        return self.get_model().objects.filter(is_launched=True)
  • 索引类SKUIndex说明:
    • SKUIndex建立的字段,都可以借助HaystackElasticsearch搜索引擎查询。
    • 其中text字段我们声明为document=True,表名该字段是主要进行关键字查询的字段。
    • text字段的索引值可以由多个数据库模型类字段组成,具体由哪些模型类字段组成,我们用use_template=True表示后续通过模板来指明。

2.创建text字段索引值模板文件

  • templates目录中创建text字段使用的模板文件
  • 具体在templates/search/indexes/goods/sku_text.txt文件中定义
{{ object.id }}
{{ object.name }}
{{ object.caption }}
  • 模板文件说明:当将关键词通过text参数名传递时
    • 此模板指明SKU的idnamecaption作为text字段的索引值来进行关键字索引查询。

3.手动生成初始索引

$ python manage.py rebuild_index

img

3. 全文检索测试

1.准备测试表单

  • 请求方法:GET
  • 请求地址:/search/
  • 请求参数:q

img

<div class="search_wrap fl">
    <form method="get" action="/search/" class="search_con">
        <input type="text" class="input_text fl" name="q" placeholder="搜索商品">
        <input type="submit" class="input_btn fr" name="" value="搜索">
    </form>
    <ul class="search_suggest fl">
        <li><a href="#">索尼微单</a></li>
        <li><a href="#">优惠15元</a></li>
        <li><a href="#">美妆个护</a></li>
        <li><a href="#">买2免1</a></li>
    </ul>
</div>

2.全文检索测试结果

img

结论:

  • 错误提示告诉我们在templates/search/目录中缺少一个search.html文件
  • search.html文件作用就是接收和渲染全文检索的结果

6.5.3 渲染商品搜索结果

1. 准备商品搜索结果页面

img

2. 渲染商品搜索结果

Haystack返回的数据包括:

  • query:搜索关键字
  • paginator:分页paginator对象
  • page:当前页的page对象(遍历page中的对象,可以得到result对象)
  • result.objects: 当前遍历出来的SKU对象。
<div class="main_wrap clearfix">
    <div class=" clearfix">
        <ul class="goods_type_list clearfix">
            {% for result in page %}
            <li>
                {# object取得才是sku对象 #}
                <a href="/detail/{{ result.object.id }}/"><img src="{{ result.object.default_image.url }}"></a>
                <h4><a href="/detail/{{ result.object.id }}/">{{ result.object.name }}</a></h4>
                <div class="operate">
                    <span class="price">¥{{ result.object.price }}</span>
                    <span>{{ result.object.comments }}评价</span>
                </div>
            </li>
            {% else %}
                <p>没有找到您要查询的商品。</p>
            {% endfor %}
        </ul>
        <div class="pagenation">
            <div id="pagination" class="page"></div>
        </div>
    </div>
</div>

img

3. Haystack搜索结果分页

1.设置每页返回数据条数

  • 通过HAYSTACK_SEARCH_RESULTS_PER_PAGE可以控制每页显示数量
  • 每页显示五条数据:HAYSTACK_SEARCH_RESULTS_PER_PAGE = 5

2.准备搜索页分页器

<div class="main_wrap clearfix">
    <div class=" clearfix">
        ......
        <div class="pagenation">
            <div id="pagination" class="page"></div>
        </div>
    </div>
</div>
<script type="text/javascript">
    $(function () {
        $('#pagination').pagination({
            currentPage: {{ page.number }},
            totalPage: {{ paginator.num_pages }},
            callback:function (current) {
                {#window.location.href = '/search/?q=iphone&amp;page=1';#}
                window.location.href = '/search/?q={{ query }}&page=' + current;
            }
        })
    });
</script>

img

6.6 商品详情页

6.6.1 商品详情页分析和准备

img

1. 商品详情页组成结构分析

1.商品频道分类

  • 已经提前封装在contents.utils.py文件中,直接调用方法即可。

2.面包屑导航

  • 已经提前封装在goods.utils.py文件中,直接调用方法即可。

3.热销排行

  • 该接口已经在商品列表页中实现完毕,前端直接调用接口即可。

4.商品SKU信息(详情信息)

  • 通过sku_id可以找到SKU信息,然后渲染模板即可。
  • 使用Ajax实现局部刷新效果。

5.SKU规格信息

  • 通过SKU可以找到SPU规格和SKU规格信息。

6.商品详情介绍、规格与包装、售后服务

  • 通过SKU可以找到SPU信息,SPU中可以查询出商品详情介绍、规格与包装、售后服务。

7.商品评价

  • 商品评价需要在生成了订单,对订单商品进行评价后再实现,商品评价信息是动态数据。
  • 使用Ajax实现局部刷新效果。

2. 商品详情页接口设计和定义

1.请求方式

选项 方案
请求方法 GET
请求地址 /detail/(?P<sku_id>\d+)/

2.请求参数:路径参数

参数名 类型 是否必传 说明
sku_id string 商品SKU编号

3.响应结果:HTML

detail.html

4.接口定义

class DetailView(View):
    """商品详情页"""

    def get(self, request, sku_id):
        """提供商品详情页"""
        return render(request, 'detail.html')

3. 商品详情页初步渲染

渲染商品频道分类、面包屑导航、商品热销排行

  • 将原先在商品列表页实现的代码拷贝到商品详情页即可。
  • 添加detail.js
class DetailView(View):
    """商品详情页"""

    def get(self, request, sku_id):
        """提供商品详情页"""
        # 获取当前sku的信息
        try:
            sku = models.SKU.objects.get(id=sku_id)
        except models.SKU.DoesNotExist:
            return render(request, '404.html')

        # 查询商品频道分类
        categories = get_categories()
        # 查询面包屑导航
        breadcrumb = get_breadcrumb(sku.category)

        # 渲染页面
        context = {
            'categories':categories,
            'breadcrumb':breadcrumb,
            'sku':sku,
        }
        return render(request, 'detail.html', context)

提示:为了让前端在获取商品热销排行数据时,能够拿到商品分类ID,我们将商品分类ID从模板传入到Vue.js

<script type="text/javascript">
    let category_id = "{{ sku.category.id }}";
</script>
data: {
    category_id: category_id,
},

6.6.2 展示详情页数据

1. 查询和渲染SKU详情信息

# 渲染页面
context = {
    'categories':categories,
    'breadcrumb':breadcrumb,
    'sku':sku,
}
return render(request, 'detail.html', context)
<div class="goods_detail_con clearfix">
    <div class="goods_detail_pic fl"><img src="{{ sku.default_image.url }}"></div>
    <div class="goods_detail_list fr">
        <h3>{{ sku.name }}</h3>
        <p>{{ sku.caption }}</p>
        <div class="price_bar">
            <span class="show_pirce">¥<em>{{ sku.price }}</em></span>
            <a href="javascript:;" class="goods_judge">18人评价</a>
        </div>
        <div class="goods_num clearfix">
            <div class="num_name fl">数 量:</div>
            <div class="num_add fl">
                <input v-model="sku_count" @blur="check_sku_count" type="text" class="num_show fl">
                <a @click="on_addition" class="add fr">+</a>
                <a @click="on_minus" class="minus fr">-</a>
            </div> 
        </div>
        {#...商品规格...#}
        <div class="total" v-cloak>总价:<em>[[ sku_amount ]]元</em></div>
        <div class="operate_btn">
            <a href="javascript:;" class="add_cart" id="add_cart">加入购物车</a>                
        </div>
    </div>
</div>

提示:为了实现用户选择商品数量的局部刷新效果,我们将商品单价从模板传入到Vue.js

<script type="text/javascript">
    let sku_price = "{{ sku.price }}";
</script>
data: {
    sku_price: sku_price,
},

2. 查询和渲染SKU规格信息

1.查询SKU规格信息

class DetailView(View):
    """商品详情页"""

    def get(self, request, sku_id):
        """提供商品详情页"""
        # 获取当前sku的信息
        try:
            sku = models.SKU.objects.get(id=sku_id)
        except models.SKU.DoesNotExist:
            return render(request, '404.html')

        # 查询商品频道分类
        categories = get_categories()
        # 查询面包屑导航
        breadcrumb = get_breadcrumb(sku.category)

        # 构建当前商品的规格键
        sku_specs = sku.specs.order_by('spec_id')
        sku_key = []
        for spec in sku_specs:
            sku_key.append(spec.option.id)
        # 获取当前商品的所有SKU
        skus = sku.spu.sku_set.all()
        # 构建不同规格参数(选项)的sku字典
        spec_sku_map = {}
        for s in skus:
            # 获取sku的规格参数
            s_specs = s.specs.order_by('spec_id')
            # 用于形成规格参数-sku字典的键
            key = []
            for spec in s_specs:
                key.append(spec.option.id)
            # 向规格参数-sku字典添加记录
            spec_sku_map[tuple(key)] = s.id
        # 获取当前商品的规格信息
        goods_specs = sku.spu.specs.order_by('id')
        # 若当前sku的规格信息不完整,则不再继续
        if len(sku_key) < len(goods_specs):
            return
        for index, spec in enumerate(goods_specs):
            # 复制当前sku的规格键
            key = sku_key[:]
            # 该规格的选项
            spec_options = spec.options.all()
            for option in spec_options:
                # 在规格参数sku字典中查找符合当前规格的sku
                key[index] = option.id
                option.sku_id = spec_sku_map.get(tuple(key))
            spec.spec_options = spec_options

        # 渲染页面
        context = {
            'categories':categories,
            'breadcrumb':breadcrumb,
            'sku':sku,
            'specs': goods_specs,
        }
        return render(request, 'detail.html', context)

2.渲染SKU规格信息

{% for spec in specs %}
<div class="type_select">
    <label>{{ spec.name }}:</label>
    {% for option in spec.spec_options %}
        {% if option.sku_id == sku.id %}
        <a href="javascript:;" class="select">{{ option.value }}</a>
        {% elif option.sku_id %}
        <a href="{{ url('goods:detail', args=(option.sku_id, )) }}">{{ option.value }}</a>
        {% else %}
        <a href="javascript:;">{{ option.value }}</a>
        {% endif %}
    {% endfor %}
</div>
{% endfor %}

3. 查询和渲染详情、包装和售后信息

商品详情、包装和售后信息被归类到商品SPU中,sku.spu关联查询就可以找到该SKUSPU信息。

<div class="r_wrap fr clearfix">
    <ul class="detail_tab clearfix">
        <li @click="on_tab_content('detail')" :class="tab_content.detail?'active':''">商品详情</li>
        <li @click="on_tab_content('pack')" :class="tab_content.pack?'active':''">规格与包装</li>
        <li @click="on_tab_content('service')" :class="tab_content.service?'active':''">售后服务</li>
        <li @click="on_tab_content('comment')" :class="tab_content.comment?'active':''">商品评价(18)</li>
    </ul>
    <div @click="on_tab_content('detail')" class="tab_content" :class="tab_content.detail?'current':''">
        <dl>
            <dt>商品详情:</dt>
            <dd>{{ sku.spu.desc_detail|safe }}</dd>
        </dl>
    </div>
    <div @click="on_tab_content('pack')" class="tab_content" :class="tab_content.pack?'current':''">
        <dl>
            <dt>规格与包装:</dt>
            <dd>{{ sku.spu.desc_pack|safe }}</dd>
        </dl>
    </div>
    <div @click="on_tab_content('service')" class="tab_content" :class="tab_content.service?'current':''">
        <dl>
            <dt>售后服务:</dt>
            <dd>{{ sku.spu.desc_service|safe }}</dd>
        </dl>
    </div>
    <div @click="on_tab_content('comment')" class="tab_content" :class="tab_content.comment?'current':''">
        <ul class="judge_list_con">
            {#...商品评价...#}
        </ul>
    </div>
</div>

6.6.3 统计分类商品访问量

提示:

  • 统计分类商品访问量 是统计一天内该类别的商品被访问的次数。
  • 需要统计的数据,包括商品分类,访问次数,访问时间。
  • 一天内,一种类别,统计一条记录。

img

1. 统计分类商品访问量模型类

模型类定义在goods.models.py中,然后完成迁移建表。

class GoodsVisitCount(BaseModel):
    """统计分类商品访问量模型类"""
    category = models.ForeignKey(GoodsCategory, on_delete=models.CASCADE, verbose_name='商品分类')
    count = models.IntegerField(verbose_name='访问量', default=0)
    date = models.DateField(auto_now_add=True, verbose_name='统计日期')

    class Meta:
        db_table = 'tb_goods_visit'
        verbose_name = '统计分类商品访问量'
        verbose_name_plural = verbose_name

2. 统计分类商品访问量后端逻辑

1.请求方式

选项 方案
请求方法 POST
请求地址 /detail/visit/(?P<category_id>\d+)/

2.请求参数:路径参数

参数名 类型 是否必传 说明
category_id string 商品分类ID,第三级分类

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息

4.后端接口定义和实现

  • 如果访问记录存在,说明今天不是第一次访问,不新建记录,访问量直接累加。
  • 如果访问记录不存在,说明今天是第一次访问,新建记录并保存访问量。
class DetailVisitView(View):
    """详情页分类商品访问量"""

    def post(self, request, category_id):
        """记录分类商品访问量"""
        try:
            category = models.GoodsCategory.objects.get(id=category_id)
        except models.GoodsCategory.DoesNotExist:
            return http.HttpResponseForbidden('缺少必传参数')

        # 获取今天的日期
        t = timezone.localtime()
        today_str = '%d-%02d-%02d' % (t.year, t.month, t.day)
        today_date = datetime.datetime.strptime(today_str, '%Y-%m-%d')
        try:
            # 查询今天该类别的商品的访问量
            counts_data = category.goodsvisitcount_set.get(date=today_date)
        except models.GoodsVisitCount.DoesNotExist:
            # 如果该类别的商品在今天没有过访问记录,就新建一个访问记录
            counts_data = models.GoodsVisitCount()

        try:
            counts_data.category = category
            counts_data.count += 1
            counts_data.save()
        except Exception as e:
            logger.error(e)
            return http.HttpResponseServerError('服务器异常')

        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK'})

6.7 用户浏览记录

6.7.1 设计浏览记录存储方案

  • 当登录用户在浏览商品的详情页时,我们就可以把详情页这件商品信息存储起来,作为该登录用户的浏览记录。
  • 用户未登录,我们不记录其商品浏览记录。

img

1. 存储数据说明

  • 虽然浏览记录界面上要展示商品的一些SKU信息,但是我们在存储时没有必要存很多SKU信息。
  • 我们选择存储SKU信息的唯一编号(sku_id)来表示该件商品的浏览记录。
  • 存储数据:sku_id

2. 存储位置说明

  • 用户浏览记录是临时数据,且经常变化,数据量不大,所以我们选择内存型数据库进行存储。
  • 存储位置:Redis数据库 3号库
CACHES = {
    "history": { # 用户浏览记录
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/3",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    },
}

3. 存储类型说明

  • 由于用户浏览记录跟用户浏览商品详情的顺序有关,所以我们选择使用Redis中的list类型存储 sku_id
  • 每个用户维护一条浏览记录,且浏览记录都是独立存储的,不能共用。所以我们需要对用户的浏览记录进行唯一标识。
  • 我们可以使用登录用户的ID来唯一标识该用户的浏览记录。
  • 存储类型:'history_user_id' : [sku_id_1, sku_id_2, ...]

img

4. 存储逻辑说明

  • SKU信息不能重复。
  • 最近一次浏览的商品SKU信息排在最前面,以此类推。
  • 每个用户的浏览记录最多存储五个商品SKU信息。
  • 存储逻辑:先去重,再存储,最后截取。

6.7.2 保存和查询浏览记录

1. 保存用户浏览记录

1.请求方式

选项 方案
请求方法 POST
请求地址 /browse_histories/

2.请求参数:JSON

参数名 类型 是否必传 说明
sku_id string 商品SKU编号

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息

4.后端接口定义和实现

class UserBrowseHistory(LoginRequiredJSONMixin, View):
    """用户浏览记录"""

    def post(self, request):
        """保存用户浏览记录"""
        # 接收参数
        json_dict = json.loads(request.body.decode())
        sku_id = json_dict.get('sku_id')

        # 校验参数
        try:
            models.SKU.objects.get(id=sku_id)
        except models.SKU.DoesNotExist:
            return http.HttpResponseForbidden('sku不存在')

        # 保存用户浏览数据
        redis_conn = get_redis_connection('history')
        pl = redis_conn.pipeline()
        user_id = request.user.id

        # 先去重
        pl.lrem('history_%s' % user_id, 0, sku_id)
        # 再存储
        pl.lpush('history_%s' % user_id, sku_id)
        # 最后截取
        pl.ltrim('history_%s' % user_id, 0, 4)
        # 执行管道
        pl.execute()

        # 响应结果
        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK'})

2. 查询用户浏览记录

1.请求方式

选项 方案
请求方法 GET
请求地址 /browse_histories/

2.请求参数:

3.响应结果:JSON

字段 说明
code 状态码
errmsg 错误信息
skus[ ] 商品SKU列表数据
id 商品SKU编号
name 商品SKU名称
default_image_url 商品SKU默认图片
price 商品SKU单价
{
    "code":"0",
    "errmsg":"OK",
    "skus":[
        {
            "id":6,
            "name":"Apple iPhone 8 Plus (A1864) 256GB 深空灰色 移动联通电信4G手机",
            "default_image_url":"http://image.meiduo.site:8888/group1/M00/00/02/CtM3BVrRbI2ARekNAAFZsBqChgk3141998",
            "price":"7988.00"
        },
        ......
    ]
}

4.后端接口定义和实现

class UserBrowseHistory(LoginRequiredJSONMixin, View):
    """用户浏览记录"""

    def get(self, request):
        """获取用户浏览记录"""
        # 获取Redis存储的sku_id列表信息
        redis_conn = get_redis_connection('history')
        sku_ids = redis_conn.lrange('history_%s' % request.user.id, 0, -1)

        # 根据sku_ids列表数据,查询出商品sku信息
        skus = []
        for sku_id in sku_ids:
            sku = models.SKU.objects.get(id=sku_id)
            skus.append({
                'id': sku.id,
                'name': sku.name,
                'default_image_url': sku.default_image.url,
                'price': sku.price
            })

        return http.JsonResponse({'code': RETCODE.OK, 'errmsg': 'OK', 'skus': skus})

Vue渲染用户浏览记录

<div class="has_view_list" v-cloak>
    <ul class="goods_type_list clearfix">
        <li v-for="sku in histories">
            <a :href="sku.url"><img :src="sku.default_image_url"></a>
            <h4><a :href="sku.url">[[ sku.name ]]</a></h4>
            <div class="operate">
                <span class="price">¥[[ sku.price ]]</span>
                <span class="unit">台</span>
                <a href="javascript:;" class="add_goods" title="加入购物车"></a>
            </div>
        </li>
    </ul>
</div>

img

posted @ 2021-08-15 09:41  傀儡岁月  阅读(791)  评论(2编辑  收藏  举报