美多商城项目-03

网站对接 QQ第三方登录

腾讯应用开放平台

https://app.open.qq.com/p/developer/reg

QQ 互联开发者申请

https://connect.qq.com

网站开发流程

  • 申请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

美多商城项目-03

总体目标

美多商城项目-03

实现下面这条前端调用链:

  • 登录页 QQ 按钮:login.jsGET /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'
美多商城项目-03

本地调试可以把回调地址改成 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 中 ImageCodeViewSmsCodeView 的 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
  • 跳首页
美多商城项目-03

跳转到: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

美多商城项目-03
美多商城项目-03

QQ扫码授权跳转到绑定页面:

http://127.0.0.1:8000/oauth/qq/callback/?code=A170DXXXXXXXXXXXXXXXXXXXXE9A&state=%2F

美多商城项目-03

保存后登录并跳转到首页

美多商城项目-03

查看 user_id 和 openid 绑定情况

美多商城项目-03

下次再用 QQ 登录,无需绑定,直接登录到主页面

用户个人中心页面+邮箱第三方登录

美多商城项目-03

目前我们还需要完善的部分:

  • 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 服务并生成授权码

美多商城项目-03

第五步: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

观察启动日志

美多商城项目-03

第六步:邮箱激活回调(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 输入框
美多商城项目-03

点击“保存”邮件,查看 Response

正确返回应该是:

{
  "code": "0"
}
美多商城项目-03

登录数据库确认

进 Django shell:

python manage.py shell

from apps.users.models import User

u = User.objects.get(username="testuser001")
u.email
u.email_active
美多商城项目-03

Celery 是否真的执行了任务

看 Celery 终端输出

美多商城项目-03

QQ 邮箱里是否收到邮件

美多商城项目-03

测试激活链接是否真的生效

自动跳转到 用户中心

美多商城项目-03

再次查看数据库:

python manage.py shell
u = User.objects.get(username="testuser001")
u.email_active
美多商城项目-03

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

Like (0)
LJH的头像LJH
Previous 2025年12月5日 上午1:54
Next 2020年10月23日 下午3:42

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注