网站对接 QQ第三方登录
腾讯应用开放平台
https://app.open.qq.com/p/developer/reg
QQ 互联开发者申请
网站开发流程
- 申请appid和appkey
- 放置“QQ登录”按钮_OAuth2.0
- 使用Authorization_Code获取Access_Token
- 1、获取Authorization Code;
- 2、通过Authorization Code获取Access Token
- 获取用户OpenID_OAuth2.0
具体可见:网站应用接入流程参考文档
https://wiki.connect.qq.com/%e7%bd%91%e7%ab%99%e5%ba%94%e7%94%a8%e6%8e%a5%e5%85%a5%e6%b5%81%e7%a8%8b
特别提醒:创建应用时要在网站回调域添加本地测试 uri:http://127.0.0.1:8000/oauth/qq/callback

总体目标

实现下面这条前端调用链:
- 登录页 QQ 按钮:
login.js→GET /oauth/qq/login/ - 后端返回 QQ 授权 URL,浏览器跳转到 QQ 登录页
- QQ 登录成功后回调:
GET /oauth/qq/callback/?code=xxx - 后端:
- 通过
code换取access_token - 再换取
openid - 查询 / 创建
OAuthQQUser记录 - 没绑定 → 返回绑定页面(
oauth_callback.html) - 已绑定 → 直接登录,跟普通登录一样写 session + cookie
- 通过
准备工作:准备 QQ SDK & itsdangerous
安装 QQ SDK 和 itsdangerous
在你的虚拟环境执行:
pip install QQLoginTool itsdangerous==1.1.0
第 1 步:创建 oauth 应用,并注册到 INSTALLED_APPS
在项目根目录执行:
cd meiduo_mall/apps
python ../manage.py startapp oauth
注册到 INSTALLED_APPS
修改 meiduo_mall/settings.py:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
'apps.users.apps.UsersConfig',
"celery_tasks",
# 新增
"apps.oauth.apps.OauthConfig",
]
第 2 步:复制 & 调整 OAuthQQUser 模型
在 apps/oauth/models.py 中写入代码
from django.db import models
from django.utils import timezone
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
class OAuthQQUser(BaseModel):
"""QQ登录用户数据"""
user = models.ForeignKey('users.User', on_delete=models.CASCADE, verbose_name='用户')
openid = models.CharField(max_length=64, verbose_name='openid', db_index=True)
class Meta:
db_table = 'tb_oauth_qq'
verbose_name = 'QQ登录用户数据'
verbose_name_plural = verbose_name
迁移数据库
python manage.py makemigrations oauth
python manage.py migrate
第 3 步:配置 QQ 登录参数
你已经从课程 settings 里把 QQ 那几行贴过来了,但现在这个 settings 里面还没有,我们加上:
在 meiduo_mall/settings.py 末尾附近,加上:
# QQ 登录相关(使用你自己在 QQ 互联申请的 appid / appkey / 回调地址)
QQ_CLIENT_ID = '10XXXXXX19'
QQ_CLIENT_SECRET = 'teDXXXXXXXXXGv'
# 注意:回调地址要和 QQ 平台配置一致,否则会报 redirect_uri mismatch,因为我特意强调要添加本地测试地址,所以填写本地测试回调地址即可,线上环境后续需要修改
QQ_REDIRECT_URI = 'http://127.0.0.1:8000/oauth/qq/callback'

本地调试可以把回调地址改成 http://127.0.0.1:8000/oauth/qq/callback,但要记得在 QQ 后台也改成一致。
第 4 步:实现 itsdangerous 工具
项目里新建一个 apps/oauth/utils.py
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer, BadData
from meiduo_mall import settings
def generate_access_token(openid, expires=600):
"""
对 openid 进行加密,生成 access_token(10 分钟有效)
"""
s = Serializer(secret_key=settings.SECRET_KEY, expires_in=expires)
data = {"openid": openid}
token_bytes = s.dumps(data)
return token_bytes.decode() # 返回 str
def check_access_token(access_token):
"""
对 access_token 进行解密,获取 openid
"""
s = Serializer(secret_key=settings.SECRET_KEY)
try:
data = s.loads(access_token)
except BadData:
return None
else:
return data.get("openid")
第 5 步:编写 oauth 视图(views)并适配当前项目
apps/oauth/views.py 中
from django import http
from django.contrib.auth import login
from django.shortcuts import render, redirect
from django.urls import reverse
from django.views import View
from QQLoginTool.QQtool import OAuthQQ
from apps.oauth.models import OAuthQQUser
from apps.oauth.utils import generate_access_token, check_access_token
from apps.users.models import User
from meiduo_mall import settings
class OauthQQURLView(View):
"""
拼接 QQ 登录 URL,前端点击 QQ 按钮时调用这个接口
"""
def get(self, request):
# state 一般用 next 参数或随机字符串,这里先用简单写法
state = request.GET.get('next', '/') # 支持传 next
qqoauth = OAuthQQ(
client_secret=settings.QQ_CLIENT_SECRET,
client_id=settings.QQ_CLIENT_ID,
redirect_uri=settings.QQ_REDIRECT_URI,
state=state
)
login_url = qqoauth.get_qq_url()
return http.JsonResponse({'login_url': login_url})
class OauthQQUserView(View):
"""
处理 QQ 回调 + 绑定逻辑
"""
def get(self, request):
"""
GET /oauth/qq/callback/?code=xxx
1. 获取 code
2. 用 code 换 access_token
3. 用 access_token 换 openid
4. 查询是否已绑定
"""
code = request.GET.get('code')
if code is None:
return render(request, 'oauth_callback.html', context={'errmsg': '缺少 code 参数'})
# 2. 换取 access_token
qqoauth = OAuthQQ(
client_secret=settings.QQ_CLIENT_SECRET,
client_id=settings.QQ_CLIENT_ID,
redirect_uri=settings.QQ_REDIRECT_URI
)
try:
token = qqoauth.get_access_token(code)
openid = qqoauth.get_open_id(token)
except Exception as e:
# 调试时可以打印一下
return render(request, 'oauth_callback.html', context={'errmsg': 'QQ 认证失败'})
# 4. 根据 openid 查询绑定记录
try:
qquser = OAuthQQUser.objects.get(openid=openid)
except OAuthQQUser.DoesNotExist:
# 没绑定:对 openid 加密,传给前端绑定页
openid_access_token = generate_access_token(openid)
return render(request, 'oauth_callback.html', context={'openid_access_token': openid_access_token})
else:
# 已绑定:直接登录
user = qquser.user
response = redirect(reverse('contents:index'))
login(request, user)
response.set_cookie('username', user.username, max_age=14 * 24 * 3600)
return response
def post(self, request):
"""
绑定用户:
1. 手机号 + 密码 + 短信验证码 + access_token(openid)
2. 检查短信 / 手机格式 / 密码格式(可以简化)
3. 解密 access_token 得到 openid
4. 根据手机号查用户:
- 没有:创建用户
- 有:校验密码
5. 创建 OAuthQQUser 绑定记录
6. 登录 + 写 cookie
"""
data = request.POST
mobile = data.get('mobile')
password = data.get('pwd')
sms_code = data.get('sms_code')
access_token = data.get('access_token')
# 简单校验(你可按课程完善)
if not all([mobile, password, sms_code, access_token]):
return http.HttpResponseBadRequest('缺少必要参数')
# TODO: 短信验证码校验(可以直接复用你注册时的逻辑,这里先略)
# 4.解密 openid
openid = check_access_token(access_token)
if openid is None:
return http.HttpResponseBadRequest('access_token 无效或已过期')
# 5. 根据手机号查用户
try:
user = User.objects.get(mobile=mobile)
except User.DoesNotExist:
# 没注册过,创建新用户
user = User.objects.create_user(
username=mobile,
password=password,
mobile=mobile
)
else:
# 已存在用户,校验密码
if not user.check_password(password):
return http.HttpResponseBadRequest('密码错误')
# 绑定 openid
OAuthQQUser.objects.create(
user=user,
openid=openid
)
# 登录 + cookie
login(request, user)
response = redirect(reverse('contents:index'))
response.set_cookie('username', user.username, max_age=14 * 24 * 3600)
return response
第 6 步:配置 oauth 路由并挂到全局 urls
apps/oauth/urls.py
# apps/oauth/urls.py
from django.urls import path
from . import views
app_name = "oauth"
urlpatterns = [
# 获取 QQ 登录 URL
path('qq/login/', views.OauthQQURLView.as_view(), name='qq_login'),
# QQ 回调
path('qq/callback/', views.OauthQQUserView.as_view(), name='qq_callback'),
]
在全局 meiduo_mall/urls.py 中 include
urlpatterns = [
path("admin/", admin.site.urls),
path("users/", include(("apps.users.urls", "users"), namespace="users")),
path("", include(("apps.contents.urls", "contents"), namespace="contents")),
# 新增:OAuth
path("oauth/", include(("apps.oauth.urls", "oauth"), namespace="oauth")),
]
第 7 步:前端对接(login.js & oauth_callback)
修改登录页的 qq_login 按钮逻辑
static/js/login.js 中:
qq_login: function () {
var next = get_query_string('next') || '/';
var url = this.host + '/qq/login/?next=' + next;
axios.get(url, {
responseType: 'json'
})
.then(response => {
location.href = response.data.login_url;
})
.catch(error => {
console.log(error.response);
});
}
我们现在后端的路由是 /oauth/qq/login/,需要改成
qq_login: function () {
var next = get_query_string('next') || '/';
// 注意:host 里一般配置的是线环境地址(见host.js) 或 http://127.0.0.1:8000(生产环境)
var url = this.host + '/oauth/qq/login/?next=' + encodeURIComponent(next);
axios.get(url, {
responseType: 'json'
})
.then(response => {
location.href = response.data.login_url;
})
.catch(error => {
console.log(error.response);
});
}
修改后的static/js/login.js:
// static/js/login.js
var vm = new Vue({
el: '#app',
// 修改Vue变量的读取语法,避免和django模板语法冲突
delimiters: ['[[', ']]'],
data: {
host: typeof host !== 'undefined' ? host : '',
error_username: false,
error_password: false,
error_username_message: '请输入5-20个字符的用户名',
error_password_message: '请输入8-20位的密码',
username: '',
password: '',
remembered: true
},
methods: {
// 检查账号
check_username: function () {
var re1 = /^[a-zA-Z0-9_-]{5,20}$/; // 用户名
var re2 = /^1[3-9]\d{9}$/; // 手机号
if (re1.test(this.username) || re2.test(this.username)) {
this.error_username = false;
} else {
this.error_username = true;
this.error_username_message = "请输入正确的用户名或手机号";
}
},
// 检查密码
check_password: function () {
var re = /^[0-9A-Za-z]{8,20}$/;
if (re.test(this.password)) {
this.error_password = false;
} else {
this.error_password_message = '密码必须是8-20位字母或数字';
this.error_password = true;
}
},
// 表单提交
on_submit: function (event) {
// 执行前端校验
this.check_username();
this.check_password();
// 只要有一个错误,就阻止表单提交
if (this.error_username || this.error_password) {
event.preventDefault();
}
// 否则让表单正常提交,后端再做最终校验
},
// qq登录(暂时你后端没写可以先留着)
qq_login: function () {
var next = get_query_string('next') || '/';
// 注意:host 里一般配置的是线环境地址(见host.js) 或 http://127.0.0.1:8000(生产环境)
var url = this.host + '/oauth/qq/login/?next=' + encodeURIComponent(next);
axios.get(url, {
responseType: 'json'
})
.then(response => {
location.href = response.data.login_url;
})
.catch(error => {
console.log(error.response);
});
}
}
});
static/js/host.js 里修改配置为 var host = 'http://127.0.0.1:8000'; 这样在本地前端会请求到正确的后端服务器。
让 oauth_callback.html 走 Django 模板路径
oauth_callback.html 搬到 templates 根目录(templates/oauth_callback.html)下
修改 html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<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>
<body>
<div id="app" v-cloak>
<div class="register_con">
<div class="l_con fl">
<a href="/" class="reg_logo"><img src="{{ static('images/logo.png') }}"></a>
<div class="reg_slogan">商品美 · 种类多 · 欢迎光临</div>
<div class="reg_banner"></div>
</div>
<div class="r_con fr">
<div class="reg_title clearfix">
<h1>绑定用户</h1>
</div>
<div class="reg_form clearfix">
<!-- ☆☆☆ 必须 POST 到 /oauth/qq/callback/ 才能绑定 openid ☆☆☆ -->
<form id="reg_form" method="post" action="/oauth/qq/callback/" @submit="on_submit">
{{ csrf_field(request) | safe }}
<!-- ☆☆☆ 最重要:openid 的加密值 ☆☆☆ -->
<input type="hidden" name="access_token" value="{{ openid_access_token }}">
<ul>
<!-- 手机号 -->
<li>
<label>手机号:</label>
<input type="text" name="mobile" v-model="mobile" @blur="check_mobile">
<span class="error_tip" v-show="error_mobile">[[ error_mobile_message ]]</span>
</li>
<!-- 密码 -->
<li>
<label>密码:</label>
<input type="password" name="pwd" v-model="password" @blur="check_password">
<span class="error_tip" v-show="error_password">请输入8-20位密码</span>
</li>
<!-- 图形验证码(动态刷新版) -->
<li>
<label>图形验证码:</label>
<input type="text" name="pic_code" v-model="image_code" @blur="check_image_code" class="msg_input">
<img :src="image_code_url"
@click="generate_image_code"
class="pic_code"
title="点击刷新">
<span class="error_tip" v-show="error_image_code">[[ error_image_code_message ]]</span>
</li>
<!-- 短信验证码 -->
<li>
<label>短信验证码:</label>
<input type="text" name="sms_code" v-model="sms_code" @blur="check_sms_code" class="msg_input">
<a href="javascript:;" class="get_msg_code" @click="send_sms_code">
[[ sms_code_tip ]]
</a>
<span class="error_tip" v-show="error_sms_code">[[ error_sms_code_message ]]</span>
</li>
<li class="reg_sub">
<input type="submit" value="保 存">
</li>
</ul>
</form>
</div>
</div>
</div>
<div class="footer no-mp">
<div class="foot_link">
<a href="#">关于我们</a>
<span>|</span>
<a href="#">联系我们</a>
<span>|</span>
<a href="#">招聘人才</a>
<span>|</span>
<a href="#">友情链接</a>
</div>
<p>CopyRight © 2016 北京美多商业股份有限公司 All Rights Reserved</p>
<p>电话:010-****888 京ICP备*******8号</p>
</div>
</div>
<script type="text/javascript" src="{{ static('js/host.js') }}"></script>
<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>
<script type="text/javascript" src="{{ static('js/common.js') }}"></script>
<script type="text/javascript" src="{{ static('js/oauth_callback.js') }}"></script>
</body>
</html>
关键点:
1. action="/oauth/qq/callback" —— 绑定走 POST 同一个 view
你的后端 oauth 路由写的是:
path('qq/callback', OauthQQUserView.as_view())
课程逻辑也是:
GET = 处理 code
POST = 绑定 openid
所以表单必须写:
<form method="post" action="/oauth/qq/callback">
2. 后端给的 openid_access_token 必须注入隐藏字段
<input type="hidden" name="access_token" value="{{ openid_access_token }}">
如果缺少这个,后端无法解密 openid → 绑定失败。
3. 图形验证码必须用 Django 动态 URL
原来写的是静态图片,修改代码,让 Vue 生成 /users/image_codes/<uuid>/。
<img :src="image_code_url" @click="generate_image_code">
4. 确保Vue 挂载点、字段、校验字段全部保持一致
当前的 oauth_callback.js 中使用:
v-model="mobile"
v-model="password"
v-model="image_code"
v-model="sms_code"
确保模板中全部对齐
修正 oauth_callback.js 里的后端 URL 前缀
现在 oauth_callback.js 里有:
this.image_code_url = "/image_codes/" + this.uuid + "/";
...
let url = '/sms_codes/' + this.mobile + '/?image_code=' + this.image_code+'&image_code_id='+ this.uuid;
但是 Django 中 ImageCodeView 和 SmsCodeView 的 URL 都是挂在 /users/ 下的:
真实访问路径是:
- 图形验证码:/users/image_codes/<uuid>/
- 短信验证码:/users/sms_codes/<mobile>/
因此,需要 统一加上 /users 前缀:
// 生成图形验证码的请求地址
generate_image_code(){
this.uuid = generateUUID();
this.image_code_url = "/users/image_codes/" + this.uuid + "/";
},
...
// 短信
let url = '/users/sms_codes/' + this.mobile + '/?image_code=' + this.image_code + '&image_code_id=' + this.uuid;
这样就能复用现有的 SmsCodeView / ImageCodeView
修改后的代码:
let vm = new Vue({
el: '#app',
delimiters: ['[[', ']]'],
data: {
mobile: '',
password: '',
image_code: '',
sms_code: '',
error_mobile: false,
error_password: false,
error_image_code: false,
error_sms_code: false,
error_mobile_message: '',
error_image_code_message: '',
error_sms_code_message: '',
error_password_message:'',
uuid: '',
image_code_url: '',
sms_code_tip: '获取短信验证码',
sending_flag: false,
},
mounted(){
// 界面获取图形验证码
this.generate_image_code();
},
methods: {
// 生成图形验证码的请求地址
generate_image_code(){
// 生成一个编号 : 严格一点的使用uuid保证编号唯一, 不是很严谨的情况下,也可以使用时间戳
this.uuid = generateUUID();
// 设置页面中图形验证码img标签的src属性
this.image_code_url = "/users/image_codes/" + this.uuid + "/";
},
// 检查手机号
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_password(){
let re = /^[0-9A-Za-z]{8,20}$/;
if (re.test(this.password)) {
this.error_password = false;
} else {
this.error_password = true;
}
},
// 检查图片验证码
check_image_code(){
if(!this.image_code) {
this.error_image_code_message = '请填写图片验证码';
this.error_image_code = true;
} else {
this.error_image_code = false;
}
},
// 检查短信验证码
check_sms_code(){
if(!this.sms_code){
this.error_sms_code_message = '请填写短信验证码';
this.error_sms_code = true;
} else {
this.error_sms_code = false;
}
},
// 发送手机短信验证码
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 = '/users/sms_codes/' + this.mobile + '/?image_code=' + this.image_code+'&image_code_id='+ this.uuid;
axios.get(url, {
responseType: 'json'
})
.then(response => {
// 表示后端发送短信成功
if (response.data.code == '0') {
// 倒计时60秒,60秒后允许用户再次点击发送短信验证码的按钮
let num = 60;
// 设置一个计时器
let t = setInterval(() => {
if (num == 1) {
// 如果计时器到最后, 清除计时器对象
clearInterval(t);
// 将点击获取验证码的按钮展示的文本回复成原始文本
this.sms_code_tip = '获取短信验证码';
// 将点击按钮的onclick事件函数恢复回去
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;
})
},
// 绑定openid
on_submit(){
this.check_mobile();
this.check_password();
this.check_sms_code();
if(this.error_mobile == true || this.error_password == true || this.error_sms_code == true) {
// 不满足条件:禁用表单
window.event.returnValue = false
}
}
}
});
配置 QQ 回调地址匹配项目
在 QQ 互联后台,你应该配置的回调地址包含:
http://127.0.0.1:8000/oauth/qq/callback
在 QQ 互联后台,把回调地址也改成完全一样的字符串。
修改 settings.py
QQ_REDIRECT_URI = "http://127.0.0.1:8000/oauth/qq/callback"
测试
启动 Django
python manage.py runserver 0.0.0.0:8000
- 浏览器打开:http://127.0.0.1:8000/users/login/ 前端点击 QQ
- QQ 登录成功 → 回调
/oauth/qq/callback/?code=xxx - 后端发现未绑定 → 渲染
oauth_callback.html并附带openid_access_token - 用户填写手机号 + 密码 + 图形验证码 + 短信验证码 → POST
/oauth/qq/callback/ - 后端创建用户或验证密码
- 写 session、cookie
- 跳首页

跳转到:https://graph.qq.com/oauth2.0/show?which=Login&display=pc&response_type=code&client_id=102xxxxxx&redirect_uri=http%3A%2F%2F127.0.0.1%3A8000%2Foauth%2Fqq%2Fcallback&state=%2F


QQ扫码授权跳转到绑定页面:
http://127.0.0.1:8000/oauth/qq/callback/?code=A170DXXXXXXXXXXXXXXXXXXXXE9A&state=%2F

保存后登录并跳转到首页

查看 user_id 和 openid 绑定情况

下次再用 QQ 登录,无需绑定,直接登录到主页面
用户个人中心页面+邮箱第三方登录

目前我们还需要完善的部分:
- User 模型目前只有 mobile,可以支持mobile + email_active
- UserCenterInfoView当前代码只有 mobile,还需要注入 user 数据
- 模板 user_center_info.html目前还是静态写死的
- 想实现 Email 注册,还需要完善EmailView(PUT /emails/ + Celery)、Email 激活(token + 回调)、浏览记录(Redis + JSON后端需完善)
第一步:User 模型对齐课程
邮箱需具备激活的数据库能力(需要单独定义一个字段email_active来判断邮件激活状态)
apps/users/models.py
# apps/users/models.py
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"
然后执行迁移:
python manage.py makemigrations users
python manage.py migrate
第二步:UserCenterInfoView 喂数据
后端 GET:把用户数据送给模板
修改 UserCenterInfoView
apps/users/views.py
class UserCenterInfoView(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, "users/user_center_info.html", context)
这里再次强调:
LoginRequiredMixin
它保证:未登录 → 自动跳转登录页
第三步:模板层 = Django → Vue 的“桥梁”
Django 和 Vue 没接上,页面渲染时,我们打算把用户数据“塞进 JS 变量”
修改templates/users/user_center_info.html
<head> 中:注入 Django → JS 变量(ES6 语法):
<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">
<script type="text/javascript" src="../../static/js/host.js"></script>
<script type="text/javascript" src="../../static/js/common.js"></script>
<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>
<!--【新增】Django → JS → Vue 的数据桥梁 -->
<script type="text/javascript">
let username = "{{ username }}";
let mobile = "{{ mobile }}";
let email = "{{ email }}";
let email_active = "{{ email_active }}";
</script>
</head>
然后模板正文中,用 Vue 变量:
修改顶部欢迎语
现在是:
<div class="login_info fl">
欢迎您:<em>张 山</em>
</div>
修改为:
<div class="login_info fl">
欢迎您:<em>[[ username ]]</em>
</div>
同理,修改基本信息
<li><span>用户名:</span>[[ username ]]</li>
<li><span>联系方式:</span>[[ mobile ]]</li>
第四步:用户中心/users/emails/ 接口,完善 JS
在 apps/users/urls.py 中新增:
from .views import EmailView
urlpatterns = [
...
path("emails/", EmailView.as_view(), name="emails"),
]
修改:static/js/user_center_info.js
var url = this.host + '/emails/';
改成
var url = this.host + '/users/emails/';
还有一些修改细节不再赘述,修改后的 static/js/user_center_info.js 直接粘贴即可:
var vm = new Vue({
el: '#app',
// 避免与 Django 模板语法冲突
delimiters: ['[[', ']]'],
data: {
host: host,
// 后端注入的初始数据
username: username,
mobile: mobile,
email: email,
email_active: email_active,
// 页面状态控制
set_email: false,
// 邮箱校验状态
error_email: false,
error_email_message: '',
// 发送按钮状态
send_email_btn_disabled: false,
send_email_tip: '重新发送验证邮件',
// 浏览记录
histories: []
},
mounted() {
// 处理后端传来的字符串 True / False
this.email_active = (this.email_active === 'True' || this.email_active === true);
// 如果没有邮箱,进入编辑状态
this.set_email = !this.email;
// ❌ 初始化阶段不做任何邮箱校验(非常重要)
this.error_email = false;
this.error_email_message = '';
// 浏览记录(等你后面打开)
// this.browse_histories();
},
methods: {
/**
* 校验邮箱格式
* 只在【保存】或【失焦】时调用
*/
check_email() {
// 空值不校验(防止初始化就报错)
if (!this.email) {
this.error_email = false;
this.error_email_message = '';
return true;
}
var re = /^[a-z0-9][\w.-]*@[a-z0-9-]+(\.[a-z]{2,5}){1,2}$/;
if (!re.test(this.email)) {
this.error_email_message = '邮箱格式错误';
this.error_email = true;
return false;
}
// 校验通过,清空错误
this.error_email = false;
this.error_email_message = '';
return true;
},
/**
* 取消编辑邮箱
*/
cancel_email() {
this.email = '';
this.error_email = false;
this.error_email_message = '';
this.set_email = true;
},
/**
* 保存邮箱
*/
save_email() {
// 先校验
if (!this.check_email()) {
return;
}
var url = this.host + '/users/emails/';
axios.put(
url,
{ email: this.email },
{
headers: {
'X-CSRFToken': getCookie('csrftoken')
},
responseType: 'json'
}
)
.then(response => {
if (response.data.code === '0') {
// 保存成功
this.set_email = false;
this.send_email_btn_disabled = true;
this.send_email_tip = '已发送验证邮件';
} else if (response.data.code === '4101') {
// 未登录
location.href = '/users/login/?next=/users/center/';
} else {
// 其他错误
this.error_email_message = response.data.errmsg;
this.error_email = true;
}
})
.catch(error => {
console.log(error);
});
},
/**
* 获取浏览历史(后面用)
*/
browse_histories() {
var url = this.host + '/browse_histories/';
axios.get(url, { responseType: 'json' })
.then(response => {
this.histories = response.data.skus;
for (var i = 0; i < this.histories.length; i++) {
this.histories[i].url = '/goods/' + this.histories[i].id + '.html';
}
})
.catch(error => {
console.log(error);
});
}
}
});
完善 email 模块依赖的文件
新建文件,这个文件等会会被apps/users/views.py导入:from utils.response_code import RETCODE
utils/response_code.py
class RETCODE:
OK = "0" # 成功
IMAGECODEERR = "4001" # 验证码错误
THROTTLINGERR = "4002" # 短信发送频繁
NECESSARYPARAMERR = "4003" # 缺少必须的参数
USERERR = "4004" # 用户错误
PWDERR = "4005" # 密码错误
CPWDERR = "4006" # 确认密码错误
MOBILEERR = "4007" # 手机号错误
SMSCODERR = "4008" # 短信验证码错误
ALLOWERR = "4009" # 未同意协议错误
SESSIONERR = "4101" # 未登录
DBERR = "5000" # 数据库错误
EMAILERR = "5001" # 邮箱错误
TELERR = "5002" # 电话错误
NODATAERR = "5003" # 无匹配数据
NEWPWDERR = "5004" # 新密码错误
OPENIDERR = "5005" # 第三方认证错误
PARAMERR = "5006" # 参数错误
STOCKERR = "5007" # 库存不足
apps/users/utils.py 新增 EmailActiveView 以及check_email_active_token,会被apps/users/views.py导入
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from itsdangerous import BadSignature
from django.conf import settings
from apps.users.models import User
def active_email_url(email, user_id):
"""
生成邮箱激活链接
"""
s = Serializer(secret_key=settings.SECRET_KEY, expires_in=3600)
data = {
"email": email,
"id": user_id
}
token = s.dumps(data)
# ⚠️ 注意:这里不要写死域名
return f"{settings.EMAIL_VERIFY_URL}?token={token.decode()}"
def check_email_active_token(token):
"""
校验邮箱激活 token
"""
s = Serializer(secret_key=settings.SECRET_KEY, expires_in=3600)
try:
result = s.loads(token)
except BadSignature:
return None
email = result.get("email")
user_id = result.get("id")
try:
return User.objects.get(id=user_id, email=email)
except User.DoesNotExist:
return None
增加 email celery 功能,让 apps/users/views.py 引用: from celery_tasks.email.tasks import send_active_email
修改 celery_tasks/main.py
# celery_tasks/main.py
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meiduo_mall.settings")
app = Celery("meiduo")
app.config_from_object("celery_tasks.celeryconfig")
# ⭐ 同时扫描 sms + email
app.autodiscover_tasks([
"celery_tasks.sms",
"celery_tasks.email",
])
这一步之后,Celery 会扫描:
celery_tasks/sms/tasks.py
celery_tasks/email/tasks.py
celery_tasks目录下新增 email 软件包并添加文件:celery_tasks/email/tasks.py
celery_tasks/
├── main.py
├── celeryconfig.py
├── sms/
│ ├── __init__.py
│ └── tasks.py
└── email/
├── __init__.py
└── tasks.py
celery_tasks/email/tasks.py
from celery_tasks.main import app
from django.core.mail import send_mail
from django.conf import settings
from email.header import Header
from email.utils import formataddr
import logging
logger = logging.getLogger("celery")
@app.task(
bind=True,
default_retry_delay=10, # 每次失败后 10 秒再试
max_retries=5 # 最多重试 5 次
)
def send_active_email(self, email, verify_url):
"""
发送邮箱激活邮件(支持中文发件人 + 自动重试)
"""
try:
logger.info(f"开始发送激活邮件: {email}")
# 正确处理【中文发件人】
from_email = formataddr((
str(Header("美多商城", "utf-8")),
settings.EMAIL_HOST_USER
))
# 正确处理【中文主题】
subject = str(Header("美多商城邮箱激活", "utf-8"))
html_message = (
"<p>尊敬的用户您好!</p>"
"<p>感谢您使用 <strong>美多商城</strong>。</p>"
f"<p>您的邮箱为:{email}</p>"
f"<p>请点击以下链接激活邮箱:</p>"
f'<p><a href="{verify_url}">{verify_url}</a></p>'
)
send_mail(
subject=subject,
message="", # 纯 HTML 邮件
from_email=from_email,
recipient_list=[email],
html_message=html_message,
fail_silently=False
)
logger.info(f"激活邮件发送成功: {email}")
except Exception as e:
logger.error(f"激活邮件发送失败: {email}, err={e}")
# Celery 自动重试(不阻塞 worker)
raise self.retry(exc=e)
meiduo_mall/settings.py 中配置:
增加邮件配置:
# ================= 邮件配置 =================
# 使用 SMTP 发送邮件
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# SMTP 服务器
EMAIL_HOST = "smtp.qq.com"
# SSL 端口(QQ 邮箱必须用 465)
EMAIL_PORT = 465
EMAIL_USE_SSL = True
# 你的发件邮箱(例如QQ 邮箱)
EMAIL_HOST_USER = "xxxxxx@qq.com"
# QQ 邮箱的「授权码」,不是登录密码!
EMAIL_HOST_PASSWORD = "你的QQ邮箱SMTP授权码"
# 发件人显示的地址(Celery 正在用的)
EMAIL_FROM = EMAIL_HOST_USER
以及增加激活回调地址
# 邮箱激活回调地址
EMAIL_VERIFY_URL = "http://127.0.0.1:8000/users/emails/verify/"
一般公司都会有单独的邮件服务器,这里就拿QQ邮箱简单说下SMTP授权码申请方法:
登录 QQ 邮箱网页版:https://mail.qq.com
设置 → 账号与安全->安全设置->开启 SMTP 服务并生成授权码

第五步:apps/users/views.py 新增EmailView模块
import json
import re
from django.views import View
from django import http
from django.contrib.auth.mixins import LoginRequiredMixin
from utils.response_code import RETCODE
from apps.users.utils import active_email_url
from celery_tasks.email.tasks import send_active_email
class EmailView(LoginRequiredMixin, View):
"""
用户设置邮箱 + 发送激活邮件
PUT /users/emails/
"""
def put(self, request):
# 1. 解析 JSON 数据
try:
data = json.loads(request.body.decode())
except Exception:
return http.JsonResponse({
"code": RETCODE.PARAMERR,
"errmsg": "参数格式错误"
})
email = data.get("email")
# 2. 校验 email
if not email:
return http.JsonResponse({
"code": RETCODE.PARAMERR,
"errmsg": "缺少 email"
})
if not re.match(r'^[a-z0-9][\w\.\-]*@[a-z0-9\-]+(\.[a-z]{2,5}){1,2}$', email):
return http.JsonResponse({
"code": RETCODE.PARAMERR,
"errmsg": "邮箱格式错误"
})
# 3. 保存 email(未激活)
user = request.user
user.email = email
user.email_active = False
user.save()
# 4. 生成激活链接
verify_url = active_email_url(email, user.id)
# 5. Celery 异步发送邮件
send_active_email.delay(email, verify_url)
# 6. 返回成功
return http.JsonResponse({
"code": RETCODE.OK,
"errmsg": "OK"
})
验证Celery Email:
重启 Celery worker
celery -A celery_tasks.main worker -l info
观察启动日志

第六步:邮箱激活回调(EmailActiveView)
apps/users/views.py
from django.shortcuts import redirect
from django.urls import reverse
from apps.users.utils import check_email_active_token
class EmailActiveView(View):
"""
邮箱激活回调
"""
def get(self, request):
token = request.GET.get("token")
if not token:
return http.HttpResponseBadRequest("缺少 token")
# 校验 token
user = check_email_active_token(token)
if not user:
return http.HttpResponseBadRequest("无效或过期的激活链接")
# 激活邮箱
user.email_active = True
user.save()
# 跳转用户中心
return redirect(reverse("users:center"))
URL 路由接上
修改 apps/users/urls.py
from .views import EmailView, EmailActiveView
urlpatterns = [
# ...你已有的
# 保存邮箱(PUT)
path("emails/", EmailView.as_view(), name="emails"),
# 邮箱激活
path("emails/verify/", EmailActiveView.as_view(), name="email_verify"),
]
最后完善前端Email 逻辑:
templates/users/user_center_info.html 直接替换这一段
<li>
<span>Email:</span>
<!-- 未设置邮箱 or 正在编辑 -->
<div v-if="set_email">
<input
type="text"
name="email"
class="email"
v-model="email"
@blur="check_email"
>
<input
type="button"
value="保 存"
@click="save_email"
>
<input
type="reset"
value="取 消"
@click="cancel_email"
>
<div v-show="error_email" class="error_email_tip">
[[ error_email_message ]]
</div>
</div>
<!-- 已设置邮箱 -->
<div v-else>
<input
type="text"
class="email"
v-model="email"
readonly
>
<div v-if="email_active">
已验证
</div>
<div v-else>
待验证
<input
type="button"
:disabled="send_email_btn_disabled"
:value="send_email_tip"
@click="save_email"
>
</div>
</div>
</li>
完善后的templates/users/user_center_info.html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<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">
<script type="text/javascript" src="../../static/js/host.js"></script>
<script type="text/javascript" src="../../static/js/common.js"></script>
<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>
<script>
let username = "{{ username }}";
let mobile = "{{ mobile }}";
let email = "{{ email }}";
let email_active = "{{ email_active }}";
</script>
</head>
<body>
<div id="app" v-cloak>
<div class="header_con">
<div class="header">
<div class="welcome fl">欢迎来到美多商城!</div>
<div class="fr">
<div class="login_info fl">
欢迎您:<em>[[ username ]]</em>
</div>
<div class="login_btn fl">
<a href="login.html">登录</a>
<span>|</span>
<a href="register.html">注册</a>
</div>
<div class="user_link fl">
<span>|</span>
<a href="user_center_info.html">用户中心</a>
<span>|</span>
<a href="../../static/cart.html">我的购物车</a>
<span>|</span>
<a href="../../static/user_center_order.html">我的订单</a>
</div>
</div>
</div>
</div>
<div class="search_bar clearfix">
<a href="../index.html" class="logo fl"><img src="../../static/images/logo.png"></a>
<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>
</div>
<div class="main_con clearfix">
<div class="left_menu_con clearfix">
<h3>用户中心</h3>
<ul>
<li><a href="user_center_info.html" class="active">· 个人信息</a></li>
<li><a href="../../static/user_center_order.html">· 全部订单</a></li>
<li><a href="../../static/user_center_site.html">· 收货地址</a></li>
<li><a href="../../static/user_center_pass.html">· 修改密码</a></li>
</ul>
</div>
<div class="right_content clearfix">
<div class="info_con clearfix">
<h3 class="common_title2">基本信息</h3>
<ul class="user_info_list">
<li><span>用户名:</span>[[ username ]]</li>
<li><span>联系方式:</span>[[ mobile ]]</li>
<li>
<span>Email:</span>
<!-- 未设置邮箱 or 正在编辑 -->
<div v-if="set_email">
<input
type="text"
name="email"
class="email"
v-model="email"
@blur="check_email"
>
<input
type="button"
value="保 存"
@click="save_email"
>
<input
type="reset"
value="取 消"
@click="cancel_email"
>
<div v-show="error_email" class="error_email_tip">
[[ error_email_message ]]
</div>
</div>
<!-- 已设置邮箱 -->
<div v-else>
<input
type="text"
class="email"
v-model="email"
readonly
>
<div v-if="email_active">
已验证
</div>
<div v-else>
待验证
<input
type="button"
:disabled="send_email_btn_disabled"
:value="send_email_tip"
@click="save_email"
>
</div>
</div>
</li>
</ul>
</div>
<h3 class="common_title2">最近浏览</h3>
<div class="has_view_list">
<ul class="goods_type_list clearfix">
<li>
<a href="../../static/detail.html"><img src="../../static/images/goods/goods003.jpg"></a>
<h4><a href="../../static/detail.html">360手机 N6 Pro 全网通</a></h4>
<div class="operate">
<span class="price">¥2699.00</span>
<span class="unit">台</span>
<a href="#" class="add_goods" title="加入购物车"></a>
</div>
</li>
<li>
<a href="../../static/detail.html"><img src="../../static/images/goods/goods004.jpg"></a>
<h4><a href="#">360手机 N6 Pro 全网通</a></h4>
<div class="operate">
<span class="price">¥2699.00</span>
<span class="unit">台</span>
<a href="#" class="add_goods" title="加入购物车"></a>
</div>
</li>
<li>
<a href="../../static/detail.html"><img src="../../static/images/goods/goods005.jpg"></a>
<h4><a href="#">360手机 N6 Pro 全网通</a></h4>
<div class="operate">
<span class="price">¥2699.00</span>
<span class="unit">台</span>
<a href="#" class="add_goods" title="加入购物车"></a>
</div>
</li>
<li>
<a href="../../static/detail.html"><img src="../../static/images/goods/goods006.jpg"></a>
<h4><a href="#">360手机 N6 Pro 全网通</a></h4>
<div class="operate">
<span class="price">¥2699.00</span>
<span class="unit">台</span>
<a href="#" class="add_goods" title="加入购物车"></a>
</div>
</li>
<li>
<a href="../../static/detail.html"><img src="../../static/images/goods/goods007.jpg"></a>
<h4><a href="#">急速路由</a></h4>
<div class="operate">
<span class="price">¥64.5</span>
<span class="unit">6.45/500g</span>
<a href="#" class="add_goods" title="加入购物车"></a>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="footer">
<div class="foot_link">
<a href="#">关于我们</a>
<span>|</span>
<a href="#">联系我们</a>
<span>|</span>
<a href="#">招聘人才</a>
<span>|</span>
<a href="#">友情链接</a>
</div>
<p>CopyRight © 2016 北京美多商业股份有限公司 All Rights Reserved</p>
<p>电话:010-****888 京ICP备*******8号</p>
</div>
</div>
<script type="text/javascript" src="../../static/js/user_center_info.js"></script>
</body>
</html>
个人中心+邮箱测试
开启 celery 以及 Django
python manage.py runserver
celery -A celery_tasks.main worker -l info
网页测试
登录并打开用户中心页面:http://127.0.0.1:8000/users/center/
你现在能看到:
- 用户名
- 手机号
- Email 输入框

点击“保存”邮件,查看 Response
正确返回应该是:
{
"code": "0"
}

登录数据库确认
进 Django shell:
python manage.py shell
from apps.users.models import User
u = User.objects.get(username="testuser001")
u.email
u.email_active

Celery 是否真的执行了任务
看 Celery 终端输出

QQ 邮箱里是否收到邮件

测试激活链接是否真的生效
自动跳转到 用户中心

再次查看数据库:
python manage.py shell
u = User.objects.get(username="testuser001")
u.email_active

发布者:LJH,转发请注明出处:https://www.ljh.cool/44376.html