美多商城课件在线地址
http://47.101.37.192/%E8%AF%BE%E4%BB%B6/%E7%BE%8E%E5%A4%9A%E5%95%86%E5%9F%8E%E8%AF%BE%E4%BB%B6/project-preparation/project-create-configuration/mysql.html
https://blog.csdn.net/qq_35709559/article/details/86544093
需求分析
-
用户部分
-
注册
-
用户名,密码,确认密码,手机号码,图形验证码,短信验证码
-
勾选 同意商城协议
-
注册按钮
-
-
登录
-
用户名/手机号码,密码
-
可以勾选'记录登录'
-
登录按钮,QQ/微信登录
-
-
个人信息
-
邮箱填写与验证
-
浏览的商品历史记录
-
-
地址管理
-
省市区地址信息加载
-
新增修改删除地址
-
设置默认地址
-
-
修改密码
- 当前密码,新密码,确认密码
-
-
商品部分
-
首页
-
商品分类
-
广告控制
-
-
商品列表
-
商品详情
-
商品搜索
-
-
购物车部分
- 购物车管理
-
订单部分
-
提交订单
-
我的订单
-
订单评价
-
-
支付部分
- 支付宝支付
前端安装 live-server
- 可以使用前端node.js 提供的服务器live-server作为前端开发服务器使用
- 安装步骤
- windows安装 node.js 版本控制工具 nvm(node version manage)
- https://github.com/coreybutler/nvm-windows/releases/download/1.1.10/nvm-setup.zip
- nvm 常用命令
- nvm ls-remote:列出所有可以安装的node版本号
- nvm install v15.1.0:安装指定版本号的node
- 安装的版本和教程保持一致: nvm install v10.4.1
- 此时,会自动帮你安装 npm(node package manage),node 包/模块 管理工具
- nvm use v15.1.0:切换node的版本,这个是全局的
- nvm current:当前node版本
- nvm ls:列出所有已经安装的node版本
- 安装 live-server
- npm install -g live-server
- 测试:切换到前端目录,输入命令: live-server
拓展django自带的User表,自定义用户模型
- 新建 users app,建模代码如下
from django.db import models
from django.contrib.auth.models import AbstractUser
class UserInfo(AbstractUser):
# 新增手机号字段
mobile = models.CharField(max_length=11, unique=True, verbose_name='手机号')
class Meta:
db_table = 'tb_users' # 自定义数据库表名
verbose_name = '用户' # admin站点显示以及显示中文名
verbose_name_plural = verbose_name
## settings配置
INSTALLED_APPS = [
......
'rest_framework',
'apps.users.apps.UsersConfig', # 新增
]
# AUTH_USER_MODEL = 'apps.users.models.UserInfo' 写成这种形式会报错,django内部会自动添加models路径,进行拼接
AUTH_USER_MODEL = 'users.UserInfo'
os模块和 sys模块辨析
- sys.path: 查询导包路径
- os.path: 拼接路径(自动添加'\',)
-
在django项目的应用,以 settings 为例,把apps加入 sys.path
-
好处一: 书写(代码)更简洁
-
好处二: 修改django默认的认证类时,必须写成这种形式: app名+模型类名(让django找到)
-
import os
import sys
from pathlib import Path
# 以下这句,一样的效果
# BASE_DIR = Path(__file__).resolve().parent.parent
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 往list最前面,插入apps路径
sys.path.insert(0,os.path.join(BASE_DIR,'apps'))
# 若不使用os模块,斜杠需要自己添加,代码看上去没那么漂亮...
# sys.path.insert(0,BASE_DIR + '\\' + 'apps')
......
INSTALLED_APPS = [
......
'rest_framework',
# 这种写法比较长,略不爽
# 'apps.users.apps.UsersConfig',
# 由于加入了导包路径,所以代码更简洁
'users.apps.UsersConfig',
]
- 当后端解决跨域问题以后,再次测试请求,观察"响应头"
......
Access-Control-Allow-Credentials: true # 允许跨域携带cookie
Access-Control-Allow-Origin: http://127.0.0.1:8080 # 后端的地址
Allow: GET, HEAD, OPTIONS # 允许这么几种请求(默认是options,尝试性的发请求)
......
需求:实现手机号码只能一分钟发送一次
-
当前手机号码发送短信以后,在 redis存储一个此手机号标记(标记有效期60s)
-
每次发送短信之前,先从redis取此手机号码标记
-
有标记,就提示发送短信频繁
-
无标记,正常发送短信
-
class SMSCodeView(APIView):
def get(self,request,mobile):
# 以前的写法
# sms_form = SendSmsForm(request, data=request.GET)
# if sms_form.is_valid():
# mobile = sms_form.cleaned_data.get('mobile')
# code = random.randrange(1000, 9999)
# conn = get_redis_connection()
# conn.set(mobile, code, ex=60)
# ### 调用发送短信的API
# return JsonResponse({'status': True})
# return JsonResponse({'status': False, 'error': sms_form.errors})
redis_conn = get_redis_connection('verify_codes')
sms_flag = redis_conn.get('sms_flag_{}'.format(mobile)) # 首先获取标志,如果有,代码就不继续往下走
if sms_flag:
# 返回啥样的状态码,要和前端商议好!
return Response({'msg':'短信发送太频繁'},status=status.HTTP_400_BAD_REQUEST)
sms_code = random.randint(1000,9998)
logger.info(sms_code)
redis_conn.set(mobile,sms_code,ex=60)
redis_conn.set('sms_flag_{}'.format(mobile),200,ex=60) # 首次发送短信以后,设置'短信标志',值200可以随便设置
print('已发送短信')
return Response({'msg':'OK'})
- 前端示例代码:
......
// 向后端发起短信请求
axios.get('http://127.0.0.1:8000/sms_code/' + this.mobile + '/')
.then(response => {
var num = 60;
var t = setInterval(function () {
if (num == 1) {
clearInterval(t);
vm.sms_code_tip = '获取短信验证码';
vm.send_flag = false;
} else {
num -= 1;
vm.sms_code_tip = '再过 ' + num + ' 秒后发送';
}
}, 1000);
})
.catch(error => { // 关注之处
this.send_flag = false;
this.error_sms_code = true;
this.sms_code_error_tip = error.response.data.msg;
});
......
<li>
<label>短信验证码:</label>
<input type="text" v-model="sms_code" @blur="check_sms_code" name="msg_code" id="msg_code" class="msg_input">
<a href="javascript:;" @click="send_sms_code" class="get_msg_code">{{ sms_code_tip }}</a>
<!--关注之处-->
<span v-show="error_sms_code" class="error_tip">{{ sms_code_error_tip }}</span>
</li>
redis优化
-
基于上述逻辑,每发送一次短信,需要连接 redis 3次
-
假如网站是100万人的浏览量,那么就300万次 redis 连接,缺点很明显,效率低,服务器鸭梨大
......
redis_conn = get_redis_connection('verify_codes')
......
redis_conn.set(mobile,sms_code,ex=60)
redis_conn.set('sms_flag_{}'.format(mobile),200,ex=60) # 三次连接
......
- 基于 redis 管道的优化逻辑:相当于windows批处理/mysql事务,要做的事情写好,一次性连接执行完毕,实现redis连接次数的优化
......
class SMSCodeView(APIView):
def get(self,request,mobile):
......
pl = redis_conn.pipeline() # 生成管道实例
pl.set(mobile, sms_code, ex=60) # 要执行的语句,两句
pl.set('sms_flag_{}'.format(mobile), 200, ex=60)
pl.execute() # 执行管道
......
......
-
redis管道注意事项
-
若是立即需要获取数据,尽量不要用管道,直接连接获取数据即可
-
一般是用到 set()方法的时候,才使用管道;若是get()方法,一般不使用管道
-
get()方法,示例代码如下
-
......
redis_conn = get_redis_connection('verify_codes')
pl = redis_conn.pipeline() # 新建管道
pl.get('sms_flag_{}'.format(mobile)) # 获取数据
sms_flag = pl.execute()[0] # p1.execute返回的是一个tuple,所有加上索引0
if sms_flag:
return Response({'msg':'短信发送太频繁'},status=status.HTTP_400_BAD_REQUEST)
......
celery: 处理'耗时'和'定时'任务
-
引入场景: 在发短信的逻辑中,若是遇到网络问题,造成发短信的逻辑阻塞了
程序堵在那边,此时,用户的web界面中,发短信的按钮不会立即显示倒计时
会等到程序恢复运行以后,再显示倒计时效果- 缺陷很明显,用户体验很差
def get(self,request,mobile):
......
pl.execute()
time.sleep(5) # 模拟耗时操作
print('已发送短信')
return Response({'msg':'OK'})
-
解决办法,把'耗时'操作,变成'异步请求操作'
即执行到'耗时操作'以后,不等结果,立即返回Response
这样就不会让用户等了...(实质就是一个'假象'...),从而优化用户体验 -
celery 可以分为三部分
-
客户端: 把任务发送给'任务队列(broker)',代码逻辑就放在这里
-
任务队列(broker,经纪人,中间人): 存放一个个任务队列
-
任务处理者(worker):从broker获取任务并处理它
-
-
客户端配置
- 项目根目录新建'celery_tasks包'(一般都是叫这个名字,没有硬性规定)
- 新建 main.py: celery 主程序入口
- 新建 config.py: 存放 celery 配置
- 在 celery_tasks包目录下,新建'sms包'(任务包)
- sms包底下,新建'tasks.py'(一定要叫这个名字,硬性规定)
- main.py的写法
from celery import Celery
# 创建实例对象
celery_app = Celery('meiduo')
# 加载配置文件
celery_app.config_from_object('celery_tasks.config')
# 自动注册异步任务
celery_app.autodiscover_tasks(['celery_tasks.sms'])
# celery_app.autodiscover_tasks(['celery_tasks.sms','celery_tasks.html']) # 如果有多个任务,可以这么写
- config.py的写法
# 指定任务队列存储的位置
broker_url = "redis://192.168.11.38:6379/7"
- 现在,运行命令(终端切换到celery_tasks父目录),把celery跑起来
celery -A celery_tasks.main worker -l info
- 如果是windows系统,此时可能会报错
......
import grp ModuleNotFoundError: No module named 'grp'
- 原因及解决办法
- 原因
- 使用的是 PyPi 软件包django-celery-beat,会安装所需软件包celery 的最新版本
- 而不是安装兼容版本(到我发布此内容时,即 2021 年 5 月 25 日,这时是v5.1.0)
- 它似乎与django-celery-beat版本2.2.0(最新)以及 Windows 操作系统存在兼容性问题
- 解决办法
pip uninstall celery
pip install celery==5.0.5
- 再次运行命令,可以看到 celery 跑起来了: celery -A celery_tasks.main worker -l info
- 在tasks.py中,自定义任务(所谓'任务',就是一个'函数')
# sms.tasks
from celery_tasks.main import celery_app
# 使用装饰器注册任务,不要写成"@celery_app.tasks",多了一个's'就会报错...
@celery_app.task(name='send_sms_code') # 给任务函数取一个名字(celery默认的任务名很长...)
def send_sms_code(mobile,sms_code):
# 把短信api搞到sms包即可
print('发送短信')
- 修改项目中,发送短信的接口,变成celery任务
from celery_tasks.sms.tasks import send_sms_code
......
# print('已发送短信')
# 导入任务函数,使用delay传参并调用celery任务
send_sms_code.delay(mobile,sms_code)
......
- 重新启动celery,可以看到如下信息
-------------- celery@DESKTOP-SM4B347 v5.0.5 (singularity)
--- ***** -----
-- ******* ---- Windows-10-10.0.22000-SP0 2022-11-25 13:44:23
- *** --- * ---
- ** ---------- [config]
- ** ---------- .> app: meiduo:0x163186ef550
- ** ---------- .> transport: redis://192.168.11.38:6379/7
- ** ---------- .> results: disabled://
- *** --- * --- .> concurrency: 4 (prefork)
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
--- ***** -----
-------------- [queues]
.> celery exchange=celery(direct) key=celery
[tasks]
. send_sms_code # 任务名称(刚才取名)
[2022-11-25 13:44:23,881: INFO/MainProcess] Connected to redis://192.168.11.38:6379/7
[2022-11-25 13:44:23,896: INFO/MainProcess] mingle: searching for neighbors
[2022-11-25 13:44:24,193: INFO/SpawnPoolWorker-3] child process 6872 calling self.run()
[2022-11-25 13:44:24,203: INFO/SpawnPoolWorker-1] child process 13232 calling self.run()
[2022-11-25 13:44:24,234: INFO/SpawnPoolWorker-2] child process 4552 calling self.run()
[2022-11-25 13:44:24,250: INFO/SpawnPoolWorker-4] child process 12772 calling self.run()
[2022-11-25 13:44:24,914: INFO/MainProcess] mingle: all alone
[2022-11-25 13:44:24,945: INFO/MainProcess] celery@DESKTOP-SM4B347 ready.
- 当用户点击获取验证码的时候,celery提示信息如下,显示已经完成发送短信的逻辑
......
[2022-11-25 13:46:03,093: INFO/MainProcess] Received task: send_sms_code[99ee18a0-d3a7-4d46-af15-30b5c63ec7f0]
[2022-11-25 13:46:03,095: WARNING/SpawnPoolWorker-3] 发送短信
[2022-11-25 13:46:03,096: INFO/SpawnPoolWorker-3] Task send_sms_code[99ee18a0-d3a7-4d46-af15-30b5c63ec7f0] succeeded in 0.0s: None
......
验证码部分
- 新建'captcha包',并把'captcha.py'脚本扔进去
必须先安装'Pillow'依赖库,字体文件也要扔进去(和 captcha.py 同级)
# captcha.py
import random
import string
import os.path
from io import BytesIO
from PIL import Image
from PIL import ImageFilter
from PIL.ImageDraw import Draw
from PIL.ImageFont import truetype
class Bezier:
def __init__(self):
self.tsequence = tuple([t / 20.0 for t in range(21)])
self.beziers = {}
def pascal_row(self, n):
""" Returns n-th row of Pascal's triangle
"""
result = [1]
x, numerator = 1, n
for denominator in range(1, n // 2 + 1):
x *= numerator
x /= denominator
result.append(x)
numerator -= 1
if n & 1 == 0:
result.extend(reversed(result[:-1]))
else:
result.extend(reversed(result))
return result
def make_bezier(self, n):
""" Bezier curves:
http://en.wikipedia.org/wiki/B%C3%A9zier_curve#Generalization
"""
try:
return self.beziers[n]
except KeyError:
combinations = self.pascal_row(n - 1)
result = []
for t in self.tsequence:
tpowers = (t ** i for i in range(n))
upowers = ((1 - t) ** i for i in range(n - 1, -1, -1))
coefs = [c * a * b for c, a, b in zip(combinations,
tpowers, upowers)]
result.append(coefs)
self.beziers[n] = result
return result
class Captcha(object):
def __init__(self):
self._bezier = Bezier()
self._dir = os.path.dirname(__file__)
# self._captcha_path = os.path.join(self._dir, '..', 'static', 'captcha')
@staticmethod
def instance():
if not hasattr(Captcha, "_instance"):
Captcha._instance = Captcha()
return Captcha._instance
def initialize(self, width=200, height=75, color=None, text=None, fonts=None):
# self.image = Image.new('RGB', (width, height), (255, 255, 255))
self._text = text if text else random.sample(string.ascii_uppercase + string.ascii_uppercase + '3456789', 4)
self.fonts = fonts if fonts else \
[os.path.join(self._dir, 'fonts', font) for font in ['Arial.ttf', 'Georgia.ttf', 'actionj.ttf']]
self.width = width
self.height = height
self._color = color if color else self.random_color(0, 200, random.randint(220, 255))
@staticmethod
def random_color(start, end, opacity=None):
red = random.randint(start, end)
green = random.randint(start, end)
blue = random.randint(start, end)
if opacity is None:
return red, green, blue
return red, green, blue, opacity
# draw image
def background(self, image):
Draw(image).rectangle([(0, 0), image.size], fill=self.random_color(238, 255))
return image
@staticmethod
def smooth(image):
return image.filter(ImageFilter.SMOOTH)
def curve(self, image, width=4, number=6, color=None):
dx, height = image.size
dx /= number
path = [(dx * i, random.randint(0, height))
for i in range(1, number)]
bcoefs = self._bezier.make_bezier(number - 1)
points = []
for coefs in bcoefs:
points.append(tuple(sum([coef * p for coef, p in zip(coefs, ps)])
for ps in zip(*path)))
Draw(image).line(points, fill=color if color else self._color, width=width)
return image
def noise(self, image, number=50, level=2, color=None):
width, height = image.size
dx = width / 10
width -= dx
dy = height / 10
height -= dy
draw = Draw(image)
for i in range(number):
x = int(random.uniform(dx, width))
y = int(random.uniform(dy, height))
draw.line(((x, y), (x + level, y)), fill=color if color else self._color, width=level)
return image
def text(self, image, fonts, font_sizes=None, drawings=None, squeeze_factor=0.75, color=None):
color = color if color else self._color
fonts = tuple([truetype(name, size)
for name in fonts
for size in font_sizes or (65, 70, 75)])
draw = Draw(image)
char_images = []
for c in self._text:
font = random.choice(fonts)
c_width, c_height = draw.textsize(c, font=font)
char_image = Image.new('RGB', (c_width, c_height), (0, 0, 0))
char_draw = Draw(char_image)
char_draw.text((0, 0), c, font=font, fill=color)
char_image = char_image.crop(char_image.getbbox())
for drawing in drawings:
d = getattr(self, drawing)
char_image = d(char_image)
char_images.append(char_image)
width, height = image.size
offset = int((width - sum(int(i.size[0] * squeeze_factor)
for i in char_images[:-1]) -
char_images[-1].size[0]) / 2)
for char_image in char_images:
c_width, c_height = char_image.size
mask = char_image.convert('L').point(lambda i: i * 1.97)
image.paste(char_image,
(offset, int((height - c_height) / 2)),
mask)
offset += int(c_width * squeeze_factor)
return image
# draw text
@staticmethod
def warp(image, dx_factor=0.27, dy_factor=0.21):
width, height = image.size
dx = width * dx_factor
dy = height * dy_factor
x1 = int(random.uniform(-dx, dx))
y1 = int(random.uniform(-dy, dy))
x2 = int(random.uniform(-dx, dx))
y2 = int(random.uniform(-dy, dy))
image2 = Image.new('RGB',
(width + abs(x1) + abs(x2),
height + abs(y1) + abs(y2)))
image2.paste(image, (abs(x1), abs(y1)))
width2, height2 = image2.size
return image2.transform(
(width, height), Image.QUAD,
(x1, y1,
-x1, height2 - y2,
width2 + x2, height2 + y2,
width2 - x2, -y1))
@staticmethod
def offset(image, dx_factor=0.1, dy_factor=0.2):
width, height = image.size
dx = int(random.random() * width * dx_factor)
dy = int(random.random() * height * dy_factor)
image2 = Image.new('RGB', (width + dx, height + dy))
image2.paste(image, (dx, dy))
return image2
@staticmethod
def rotate(image, angle=25):
return image.rotate(
random.uniform(-angle, angle), Image.BILINEAR, expand=1)
def captcha(self, path=None, fmt='JPEG'):
"""Create a captcha.
Args:
path: save path, default None.
fmt: image format, PNG / JPEG.
Returns:
A tuple, (name, text, StringIO.value).
For example:
('fXZJN4AFxHGoU5mIlcsdOypa', 'JGW9', '\x89PNG\r\n\x1a\n\x00\x00\x00\r...')
"""
image = Image.new('RGB', (self.width, self.height), (255, 255, 255))
image = self.background(image)
image = self.text(image, self.fonts, drawings=['warp', 'rotate', 'offset'])
image = self.curve(image)
image = self.noise(image)
image = self.smooth(image)
name = "".join(random.sample(string.ascii_lowercase + string.ascii_uppercase + '3456789', 24))
text = "".join(self._text)
out = BytesIO()
image.save(out, format=fmt)
if path:
image.save(os.path.join(path, name), fmt)
return name, text, out.getvalue()
def generate_captcha(self):
self.initialize()
return self.captcha("")
captcha = Captcha.instance()
if __name__ == '__main__':
print(captcha.generate_captcha())
......
- fonts 目录
- actionj.ttf
- Arial.ttf
- Georgia.ttf
### 测试脚本demo.py
from captcha.captcha import captcha
# name是文件名称ID
# text 文本验证码
# image 验证码图片二进制流
name,text, image = captcha.generate_captcha()
print('名称是',name) # TdLDlWyaXZMPptHi6jNu4Jqh
print('验证码的内容是:', text) # UXZR
# 将验证码图片二进制流 存入图片(点击刚生成的图片,看效果)
with open('demo.png', 'wb') as f:
f.write(image)
- 后端视图逻辑如下
### urls
url('^image_codes/(?P<image_code_id>[\w-]+)/$', views.ImageCodeView.as_view())
### views
class ImageCodeView(APIView):
'''
- 前端传来随机码
- 根据随机码,把验证码文本放入redis并设置过期时间
- 把图形码返回给前端展示
'''
def get(self,request,image_code_id):
_,text,image = captcha.generate_captcha()
redis_conn = get_redis_connection('verify_codes')
redis_conn.set(image_code_id,text,ex=60)
return HttpResponse(image,content_type='images/jpg')
- 前端代码
<li>
<label>手机号:</label>
......
</li>
<li>
<label>图形验证码:</label>
<input type="text" v-model="image_code" @blur="check_image_code" name="pic_code" id="pic_code" class="msg_input">
<!--this.host + "/image_codes/" + this.image_code_id + "/";-->
<!--每次点击图片,src就发生改变(因为uuid变了),相当于重新发起请求-->
<img :src="image_code_url" @click="generate_image_code" alt="图形验证码" class="pic_code">
<span v-show="error_image_code" class="error_tip">{{ error_image_code_message }}</span>
</li>
<li>
<label>短信验证码:</label>
......
# js代码
var vm = new Vue({
el: '#app',
data: {
......
error_image_code: false,
error_image_code_message: '',
error_sms_code_message: '',
error_sms_code: false,
......
image_code_id: '', // 图片验证码编号
image_code_url: '', // 验证码图片路径
......
image_code: '',
send_flag: false,
sms_code_tip: '获取短信验证码',
sms_code_error_tip: '短信验证码错误',
host: host,
},
// 页面加载完毕,就向后端发请求,生成验证码
mounted: function () {
this.generate_image_code();
},
methods: {
......
// 检查图形验证码
check_image_code: function (){
if(!this.image_code) {
this.error_image_code_message = '请填写图片验证码';
this.error_image_code = true;
} else {
this.error_image_code = false;
}
},
......
// 生成uuid
generate_uuid: function () {
var d = new Date().getTime();
if (window.performance && typeof window.performance.now === "function") {
d += performance.now(); //use high-precision timer if available
}
//生成的uuid如下: /image_codes/f3f3901f-30c9-4da9-9521-b021fbb9ac7c/
var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
return uuid;
},
// 生成一个图片验证码的编号,并设置页面中图片验证码img标签的src属性
generate_image_code: function(){
// 生成一个编号
// 严格一点的使用uuid保证编号唯一, 不是很严谨的情况下,也可以使用时间戳
this.image_code_id = this.generate_uuid();
// 设置页面中图片验证码img标签的src属性
this.image_code_url = this.host + "/image_codes/" + this.image_code_id + "/";
},
//发送短信验证码
send_sms_code: function () {
......
// 向后端发起短信请求
// axios.get('http://127.0.0.1:8000/sms_code/' + this.mobile + '/')
axios.get('http://127.0.0.1:8000/sms_code/' + this.mobile + '/?text=' + this.image_code+'&image_code_id='+ this.image_code_id,{
responseType: 'json'
})
.then(response => {
var num = 60;
var t = setInterval(function () {
if (num == 1) {
clearInterval(t);
vm.sms_code_tip = '获取短信验证码';
vm.send_flag = false;
} else {
num -= 1;
vm.sms_code_tip = '再过 ' + num + ' 秒后发送';
}
}, 1000);
})
.catch(error => {
// this.send_flag = false;
// this.error_sms_code = true;
// this.sms_code_error_tip = error.response.data.msg;
if (error.response.status == 400) {
this.error_image_code_message = '图片验证码有误';
this.error_image_code = true;
this.generate_image_code();
} else {
console.log(error.response.data);
}
this.send_flag = false;
});
}
}
});
- 后端发送短信的逻辑
### serializers
from rest_framework import serializers
from django_redis import get_redis_connection
# mobile的逻辑,放到view处理
# 访问方式: GET /sms_codes/(?P<mobile>1[3-9]\d{9})/?image_code_id=xxx&text=xxx
class ImageCodeCheckSerializer(serializers.Serializer):
# Invalid input of type: 'UUID'. Convert to a bytes, string, int or float first.
# 这种格式,redis无法识别,暂时没找到解决办法 ???
# image_code_id = serializers.UUIDField()
image_code_id = serializers.CharField(max_length=128)
text = serializers.CharField(max_length=4,min_length=4)
def validate(self,attrs):
image_code_id = attrs['image_code_id']
text = attrs['text']
redis_conn = get_redis_connection('verify_codes')
redis_byte_code = redis_conn.get(image_code_id)
if not redis_byte_code:
raise serializers.ValidationError('验证码无效')
str_code = redis_byte_code.decode('utf-8').lower()
if str_code != text.lower():
raise serializers.ValidationError('验证码错误')
# 这里取mobile,可以通过 context 去获取
# mobile = self.context['view'].kwargs['mobile']
# send_flag = redis_conn.get("send_flag_%s" % mobile)
# if send_flag:
# raise serializers.ValidationError('请求次数过于频繁')
return attrs
### views
class SMSCodeView(GenericAPIView):
serializer_class = ImageCodeCheckSerializer
def get(self,request,mobile):
# 校验
serializer = self.get_serializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
# 发送验证码
redis_conn = get_redis_connection('verify_codes')
sms_flag = redis_conn.get('sms_flag_{}'.format(mobile))
if sms_flag:
return Response({'msg':'短信发送太频繁'},status=status.HTTP_400_BAD_REQUEST)
# 在日志中输出验证码
sms_code = random.randint(1000,9998)
logger.info(sms_code)
pl = redis_conn.pipeline()
pl.set(mobile, sms_code, ex=60) # 保存验证码
pl.set('sms_flag_{}'.format(mobile), 200, ex=60) # 保存发送短信标志
pl.execute()
send_sms_code.delay(mobile,sms_code) # 异步耗时任务
'''
- 发送短信以后,要返回什么样的信息,完全取决于需求...
'''
return Response({'msg':'OK'})
### urls
# 访问方式: GET /sms_codes/(?P<mobile>1[3-9]\d{9})/?image_code_id=xxx&text=xxx
url(r'^sms_code/(?P<mobile>1[3-9]\d{9})/$',views.SMSCodeView.as_view(),),
Django REST framework JWT 使用
- 安装
pip install djangorestframework-jwt
- 配置
JWT_AUTH = {
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1) # 配置有效期
}
- 手动签发令牌: 根据以下代码得知,给它传模型对象即可
from rest_framework_jwt.settings import api_settings
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
payload = jwt_payload_handler(user)
token = jwt_encode_handler(payload)
- 内部部分源码解析
### 载体部分
def jwt_payload_handler(user):
# 获取username,赋值给 payload
username_field = get_username_field()
username = get_username(user)
......
payload = {
'user_id': user.pk,
'username': username,
'exp': datetime.utcnow() + api_settings.JWT_EXPIRATION_DELTA
}
# 如果模型中有email/user_id,也一起返回...
if hasattr(user, 'email'):
payload['email'] = user.email
if isinstance(user.pk, uuid.UUID):
payload['user_id'] = str(user.pk)
payload[username_field] = username
......
# 最终返回payload载体...
return payload
......
### 加密部分
def jwt_encode_handler(payload):
key = api_settings.JWT_PRIVATE_KEY or jwt_get_secret_key(payload)
return jwt.encode(
payload,
key,
api_settings.JWT_ALGORITHM
).decode('utf-8')
- 功能实现: 注册完成后,即时登录
class RegisterSerializer(serializers.ModelSerializer):
......
allow = serializers.CharField(write_only=True, label='是否同意协议')
# 新增token字段
token = serializers.CharField(read_only=True, label='token')
class Meta:
model = UserInfo
# 加入token
fields = [......,'token']
......
def create(self,validated_data):
......
user.save()
# 手动签发令牌(调用拓展库提供的两个函数,生成payload并签发token)
# 把模型对象传给jwt
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
payload = jwt_payload_handler(user)
token = jwt_encode_handler(payload)
user.token = token # 把token加到user字段
return user
- debug模式下,payload 和 jwt_token 如下:
{'user_id': 2, 'username': 'allen', 'exp': 1670032494, 'email': ''}
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyLCJ1c2VybmFtZSI6ImFsbGVuIiwiZXhwIjoxNjcwMDMyNDk0LCJlbWFpbCI6IiJ9.jkkkbCbq7T6c95CEk1q6nkBTSSx7BQbZHKcvIzI0-fg'
- 在前端观察后端返回的数据(在原有数据的基础上,加了token字段)
......
- response.data
id: 4
mobile: "15260581944"
token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0LCJ1c2VybmFtZSI6ImFsbGVuIiwiZXhwIjoxNjcwMDMzMTQ5LCJlbWFpbCI6IiJ9.IHye76uGLYFCyUfCyOPEyl1K1oddugr1gU-ya-B_rAg"
username: "allen"
前端如何存储token
- token存储位置:浏览器,js代码演示如下
> localStorage.a = 123
123
> sessionStorage.a = 456
456
- localStorage: 可以永久存储(除非清空浏览器缓存)
- sessionStorage: 是临时性存储,页面/浏览器关掉,就消失了
- 现在,把后端返回的token存入浏览器
......
then(response => {
// 保存后端返回的信息,记录用户的登录状态
sessionStorage.clear();
localStorage.clear();
localStorage.token = response.data.token;
localStorage.username = response.data.username;
localStorage.user_id = response.data.id;
location.href = '/index.html'; // 之前的代码
})
......
- 用户注册完成后,观察一下浏览器的存储,果然有保存3个键值对......
登录逻辑的实现
-
流程: 前端提交'用户名'/'密码'两个字段
-
验证失败,提示错误信息: 只返回400状态码,前端自己写错误信息
-
验证成功,为用户签发jwt,返回给用户三个字段'token','username','user_id'
前端接收到这三个字段,前端自己处理
-
后端接口设计
- urls
urlpatterns = [
......
# 登录
url(r'^authorizations/$', obtain_jwt_token), # jwt内部已经实现登录视图,调用即可
]
- jwt 登录视图源码解析:只返回了token,我们还需要返回 username/user_id
......
class JSONWebTokenAPIView(APIView):
permission_classes = ()
authentication_classes = ()
def get_serializer_context(self):
......
def get_serializer_class(self):
......
def get_serializer(self, *args, **kwargs):
......
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
user = serializer.object.get('user') or request.user
token = serializer.object.get('token')
# 关注之处......重写此方法即可
response_data = jwt_response_payload_handler(token, user, request)
response = Response(response_data)
......
return response
# 如果校验不通过,返回400错误码
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
......
def jwt_response_payload_handler(token, user=None, request=None):
"""
自定义jwt认证成功返回数据,这里只返回token,我们给它加点数据即可满足需求
"""
return {
'token': token,
}
......
# utils.auth
def jwt_response_payload_handler(token, user=None, request=None):
"""
自定义jwt认证成功返回数据
"""
return {
'token': token,
'user_id': user.id, # 新增两个字段
'username': user.username
}
- 配置方法分析
......
# 碰到这种写法,意味我们改写导包路径,导入我们自定义的方法路径即可
jwt_response_payload_handler = api_settings.JWT_RESPONSE_PAYLOAD_HANDLER
- settings
#---------JWT-----------------#
JWT_AUTH = {
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1), # 令牌有效期1天
'JWT_RESPONSE_PAYLOAD_HANDLER': 'utils.auth.jwt_response_payload_handler', # 重写导包路径
}
前端逻辑
......
if (this.error_username == false && this.error_pwd == false) {
axios.post(this.host + '/authorizations/', { // 带着用户名和密码,向后端发起请求
username: this.username,
password: this.password
}, {
responseType: 'json', // 期望的响应数据是json类型
withCredentials: true // 允许携带cookie,后端 CORS_ALLOW_CREDENTIALS = True
})
.then(response => {
// 使用浏览器本地存储保存token
if (this.remember) { // 用户是否勾选
// 记住登录
sessionStorage.clear();
localStorage.token = response.data.token;
localStorage.user_id = response.data.user_id;
localStorage.username = response.data.username;
} else {
// 未记住登录(网页关掉,就没了)
localStorage.clear();
sessionStorage.token = response.data.token;
sessionStorage.user_id = response.data.user_id;
sessionStorage.username = response.data.username;
}
......
})
.catch(error => {
// 对状态码进行判断,自定义错误消息
if (error.response.status == 400) {
this.error_pwd_message = '用户名或密码错误';
} else {
this.error_pwd_message = '服务器错误';
}
this.error_pwd = true;
})
}
- 小结: 登录的逻辑实际上非常简单...在拓展库原有的基础上,加点东西即可
使用手机号码/用户的都可以登录
- JWT扩展的登录视图,在收到用户名与密码时,也是调用Django的认证系统中提供的authenticate()来检查用户名与密码是否正确
- 我们可以通过修改Django认证系统的认证后端(主要是authenticate方法)来支持登录账号既可以是用户名也可以是手机
- 修改Django认证系统的认证后端需要继承django.contrib.auth.backends.ModelBackend,并重写authenticate方法
-
authenticate(self, request, username=None, password=None, **kwargs)方法的参数说明:
- request 本次认证的请求对象
- username 本次认证提供的用户账号
- password 本次认证提供的密码
-
我们想要让用户既可以以用户名登录,也可以以手机号登录,那么对于authenticate方法而言,username参数即表示用户名或者手机号
重写authenticate方法的思路:
- 根据username参数查找用户User对象,username参数可能是用户名,也可能是手机号
- 若查找到User对象,调用User对象的check_password方法检查密码是否正确
### utils.py
from users.models import UserInfo
from django.contrib.auth.backends import ModelBackend
def jwt_response_payload_handler(token, user=None, request=None):
......
class UsernameMobileAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
# 验证username是手机号还是用户名
# # 1.方法1
# try:
# if re.match(r'^1[3-9]\d{9}$', username):
# # 如果是手机号,则与mobile属性对比
# user = User.objects.get(mobile=username)
# else:
# # 如果是用户名,则与username属性对比
# user = User.objects.get(username=username)
# except:
# return None
# else:
# # 如果查询到用户对象,则判断密码是否正确
# if user.check_password(password):
# return user
# else:
# return None
# 优化-1
try:
# 如果是手机号,则与mobile属性对比
user = UserInfo.objects.get(mobile=username)
except:
try:
# 如果是用户名,则与username属性对比
user = UserInfo.objects.get(username=username)
except:
return None
# 如果查询到用户对象,则判断密码是否正确
if user.check_password(password):
return user
else:
return None
- 在配置文件中告知Django使用我们自定义的认证后端
AUTHENTICATION_BACKENDS = [
'...utils.UsernameMobileAuthBackend',
]
- django内置的 authenticate()方法源码
......
class ModelBackend(BaseBackend):
"""
Authenticates against settings.AUTH_USER_MODEL.
"""
def authenticate(self, request, username=None, password=None, **kwargs):
......
'''
- 获取 user 实例对象
- 若出错,则给user设置密码,流程就此终止
- 若成功获取实例对象,那么校验密码是否正确,并检查该实例是否被'激活',返回user
- 自定义我们自己的逻辑,则校验用户名和密码是否都正确
- 都正确则返回user
- 错误就模仿源码,返回 None
'''
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (#20760).
UserModel().set_password(password)
else:
if user.check_password(password) and self.user_can_authenticate(user):
return user
- 现在,可以用手机号登录了
前端实现登录后的跳转回原页面
- 演示场景
- 首页
- 用户个人中心
- 登录页
- 当从首页点击'用户个人中心'后,跳转到登录页,登录验证无误以后,页面跳转到'用户个人中心'
- url演示: http://127.0.0.1:8080/login.html?next=/user_center_info.html
- 实现思路
- 用户个人中心页面添加token判断
- 验证token和user_id是否都有
- 校验通过,就正常返回页面
- 如果校验不通过,就携带当前url路径,向login页面发起请求
......
<!--写在head里面,优先执行-->
<head>
......
<script>
var user_id = sessionStorage.user_id || localStorage.user_id;
var token = sessionStorage.token || localStorage.token;
if (!(user_id && token)) {
// 带着当前路径,作为查询字符串,向login发起请求
location.href = '/login.html?next=/user_center_info.html';
}
</script>
</head>
- 登录页的逻辑: http://127.0.0.1:8080/login.html?next=/user_center_info.html
......
// 获取url路径参数
get_query_string: function (name) {
var reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i');
var r = window.location.search.substr(1).match(reg);
if (r != null) {
return decodeURI(r[2]);
}
return null;
},
......
// 前提是登录成功,才有以下判断
// 跳转页面,获取查询字符串,进行判断,有就跳转,没有就转到首页
var return_url = this.get_query_string('next');
if (!return_url) {
return_url = '/index.html';
}
location.href = return_url;
QQ登录
-
基本流程,扫码登录QQ,QQ服务器返回一个'openID'(每一个QQ号码的唯一标识)
-
注意,必须先申请成为QQ互联的开发者(免费,微信的开发者要300块...)
-
申请成功以后,会返回 '应用ID'和'secretKey',QQ服务器会根据这两个字段生成 openID
所以,虽然 openID是唯一的标识,但是每个网址收到的 openID 绝对是不一样的!
-
-
查询项目用户是否有这个字段(用户模型还需要自己拓展字段来存储)
-
如果有这个字段,就允许用户登录成功
-
如果没有这个字段,返回给用户表单数据,用户补充完毕后,再实现登录
-
最后,网页的跳转,从哪里来,回哪里去...
-
如何获取openid(根据QQ互联提供的文档)
-
向 https://graph.qq.com/oauth2.0/authorize 发起请求
-
必须携带四个参数
-
所以,首先,前端必须向后端发起请求,来获取"携带了四个参数"的上述地址
-
如果QQ服务器验证无误,会返回code(在查询字符串那边有)
-
-
拿到code以后,让前端把code传递给后端,再次向QQ发起请求,获取 access token
-
拿到access token以后,再次向QQ发起请求,获取 openid
-
代码演示,先引入 QQ登录的SDK(逻辑很简单)
from django.conf import settings
from urllib.parse import urlencode, parse_qs
import json
import requests
class OAuthQQ(object):
"""
QQ认证辅助工具类
"""
def __init__(self, client_id=None, client_secret=None, redirect_uri=None, state=None):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.state = state # 用于保存登录成功后的跳转页面路径
def get_qq_url(self):
# QQ登录url参数组建
data_dict = {
'response_type': 'code', # 文档写死的固定值
'client_id': self.client_id,
'redirect_uri': self.redirect_uri, # 回调地址(登录成功以后,向项目的哪个地址发响应数据)
'state': self.state
}
# 构建url: urlencode()的作用是把 dict 转换为 查询字符串的形式:www.baidu.com/?response_type=code...
qq_url = 'https://graph.qq.com/oauth2.0/authorize?' + urlencode(data_dict)
return qq_url
# 获取access_token值
def get_access_token(self, code):
# 构建参数数据
data_dict = {
'grant_type': 'authorization_code',
'client_id': self.client_id,
'client_secret': self.client_secret,
'redirect_uri': self.redirect_uri,
'code': code
}
# 构建url
access_url = 'https://graph.qq.com/oauth2.0/token?' + urlencode(data_dict)
# 发送请求
try:
response = requests.get(access_url)
# 提取数据
# access_token=FE04************************CCE2&expires_in=7776000&refresh_token=88E4************************BE14
data = response.text
# 转化为字典
data = parse_qs(data)
except:
raise Exception('qq请求失败')
# 提取access_token
access_token = data.get('access_token', None)
if not access_token:
raise Exception('access_token获取失败')
return access_token[0]
# 获取open_id值
def get_open_id(self, access_token):
# 构建请求url
url = 'https://graph.qq.com/oauth2.0/me?access_token=' + access_token
# 发送请求
try:
response = requests.get(url)
# 提取数据
# callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
# code=asdasd&msg=asjdhui 错误的时候返回的结果
data = response.text
data = data[10:-3]
except:
raise Exception('qq请求失败')
# 转化为字典
try:
data_dict = json.loads(data)
# 获取openid
openid = data_dict.get('openid')
except:
raise Exception('openid获取失败')
return openid
- 第一步,后端拼接url,返回这个url路径给前端
前端收到以后,向这个url发起QQ登录请求
- settings 配置一些QQ要求的参数
......
#--------QQ参数-------------#
QQ_CLIENT_ID = 'xxxxx' # APP_ID
QQ_CLIENT_SECRET = 'yyyyyyy' # APP_Key
QQ_REDIRECT_URI = 'http://www.meiduo.site:8080/oauth_callback.html' # 网站回调域
QQ_STATE = '/' # 登陆成功返回的地址
- views
class QQAuthUrlView(APIView):
def get(self,request):
'''
获取next值,生成SDK QQ登录实例,调用实例方法,返回最终拼接完成的路径
'''
next = request.query_params.get('next','/')
qq_auth = OAuthQQ(client_id=settings.QQ_CLIENT_ID,client_secret=settings.QQ_CLIENT_SECRET,redirect_uri=settings.QQ_REDIRECT_URI,state=next)
login_url = qq_auth.get_qq_url()
return Response({'login_url':login_url})
- 前端代码
......
// qq登录
qq_login: function () {
var next = this.get_query_string('next') || '/';
axios.get(this.host + '/oauth/qq/authorization/?next=' + next, {
responseType: 'json'
})
.then(response => { // 若请求成功,则跳转到QQ登录页
location.href = response.data.login_url;
})
.catch(error => {
console.log(error.response.data);
})
}