Django 從 0 到 1 打造完整電商平臺(tái):用戶注冊(cè)與手機(jī)號(hào)/郵箱驗(yàn)證

作者: IT策士
系列: 《Django 從 0 到 1 打造完整電商平臺(tái)》第 6 篇
標(biāo)簽: Django, 用戶認(rèn)證, 短信驗(yàn)證碼, 郵箱激活, 電商開發(fā)


前言

上一篇我們把靜態(tài)文件、模板骨架全部搞定,項(xiàng)目已經(jīng)可以呈現(xiàn)出漂亮的頁(yè)面。從今天開始,我們正式踏入業(yè)務(wù)邏輯開發(fā)的第一站——用戶注冊(cè)。

用戶注冊(cè)看似簡(jiǎn)單,但在電商項(xiàng)目里,它涉及表單驗(yàn)證、手機(jī)號(hào)唯一性校驗(yàn)、驗(yàn)證碼發(fā)送、郵箱激活、密碼加密存儲(chǔ)等一大堆細(xì)節(jié)。今天我會(huì)帶著大家一步一步寫出完整的注冊(cè)流程,并且讓手機(jī)驗(yàn)證碼和郵箱激活都跑通。


一、需求分析

我們的注冊(cè)功能需要支持兩種方式:

注冊(cè)方式 流程
手機(jī)號(hào)注冊(cè) 輸入手機(jī)號(hào) → 獲取短信驗(yàn)證碼 → 填寫驗(yàn)證碼 + 密碼 → 完成注冊(cè)
郵箱注冊(cè) 輸入郵箱 + 密碼 → 注冊(cè)成功 → 發(fā)送激活郵件 → 點(diǎn)擊鏈接激活賬號(hào)

開發(fā)環(huán)境說明: 由于沒有真實(shí)短信通道,我們采用控制臺(tái)模擬發(fā)送短信驗(yàn)證碼(生產(chǎn)環(huán)境換成阿里云/騰訊云 SDK 即可)。郵件方面,Django 提供了 console.EmailBackend,激活郵件會(huì)直接打印在終端里,非常方便調(diào)試。


二、配置郵件后端(開發(fā)環(huán)境)

django_ecommerce/settings.py 中添加郵件配置:

# 開發(fā)環(huán)境:將郵件打印到控制臺(tái)
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

# 生產(chǎn)環(huán)境才需要下面的真實(shí) SMTP 配置,現(xiàn)在注釋掉
# EMAIL_HOST = 'smtp.example.com'
# EMAIL_PORT = 587
# EMAIL_HOST_USER = 'your_email@example.com'
# EMAIL_HOST_PASSWORD = 'your_password'
# EMAIL_USE_TLS = True
# DEFAULT_FROM_EMAIL = '電商平臺(tái) <noreply@example.com>'

這樣所有發(fā)出的郵件都會(huì)顯示在 runserver 的終端輸出中,注冊(cè)后去終端復(fù)制激活鏈接即可。


三、編寫注冊(cè)表單

Django 的 Form 組件能幫我們處理前端數(shù)據(jù)校驗(yàn)。我們?cè)?apps/users/forms.py 中創(chuàng)建自定義注冊(cè)表單,支持手機(jī)號(hào)或郵箱兩種方式:

from django import forms
from django.core.validators import RegexValidator
from .models import User


class RegisterForm(forms.Form):
    # 手機(jī)號(hào)(可選,如果用郵箱注冊(cè)則留空)
    phone = forms.CharField(
        max_length=11,
        min_length=11,
        required=False,
        validators=[
            RegexValidator(r'^1[3-9]\d{9}$', message='請(qǐng)輸入有效的手機(jī)號(hào)')
        ],
        widget=forms.TextInput(attrs={
            'class': 'form-control',
            'placeholder': '手機(jī)號(hào)(選填)'
        })
    )

    # 郵箱
    email = forms.EmailField(
        required=False,
        widget=forms.EmailInput(attrs={
            'class': 'form-control',
            'placeholder': '郵箱(選填)'
        })
    )

    # 密碼
    password = forms.CharField(
        min_length=6,
        max_length=20,
        widget=forms.PasswordInput(attrs={
            'class': 'form-control',
            'placeholder': '密碼(至少6位)'
        })
    )

    password2 = forms.CharField(
        label='確認(rèn)密碼',
        widget=forms.PasswordInput(attrs={
            'class': 'form-control',
            'placeholder': '再次輸入密碼'
        })
    )

    # 手機(jī)驗(yàn)證碼(如果用手機(jī)注冊(cè)時(shí)必填)
    sms_code = forms.CharField(
        max_length=6,
        required=False,
        widget=forms.TextInput(attrs={
            'class': 'form-control',
            'placeholder': '短信驗(yàn)證碼'
        })
    )

    def clean(self):
        cleaned_data = super().clean()
        phone = cleaned_data.get('phone')
        email = cleaned_data.get('email')

        # 必須提供手機(jī)號(hào)或郵箱之一
        if not phone and not email:
            raise forms.ValidationError('請(qǐng)至少填寫手機(jī)號(hào)或郵箱')

        return cleaned_data

    def clean_phone(self):
        phone = self.cleaned_data.get('phone')
        if phone and User.objects.filter(phone=phone).exists():
            raise forms.ValidationError('該手機(jī)號(hào)已被注冊(cè)')
        return phone

    def clean_email(self):
        email = self.cleaned_data.get('email')
        if email and User.objects.filter(email=email).exists():
            raise forms.ValidationError('該郵箱已被注冊(cè)')
        return email

    def clean_password2(self):
        pwd = self.cleaned_data.get('password')
        pwd2 = self.cleaned_data.get('password2')
        if pwd and pwd2 and pwd != pwd2:
            raise forms.ValidationError('兩次密碼不一致')
        return pwd2

設(shè)計(jì)要點(diǎn)

  • 手機(jī)號(hào)和郵箱均設(shè)為 required=False,但通過 clean() 方法確保至少填一個(gè)

  • 自定義手機(jī)號(hào)正則校驗(yàn)^1[3-9]\d{9}$ 匹配中國(guó)大陸手機(jī)號(hào)格式

  • 唯一性校驗(yàn):在 clean_phoneclean_email 中檢查是否已被注冊(cè)

  • 密碼一致性校驗(yàn):在 clean_password2 中比對(duì)兩次輸入


四、編寫注冊(cè)視圖

apps/users/views.py 中實(shí)現(xiàn)注冊(cè)邏輯:

import random
from django.shortcuts import render, redirect
from django.contrib import messages
from django.core.mail import send_mail
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .forms import RegisterForm
from .models import User


def register(request):
    if request.method == 'POST':
        form = RegisterForm(request.POST)
        if form.is_valid():
            phone = form.cleaned_data.get('phone')
            email = form.cleaned_data.get('email')
            password = form.cleaned_data.get('password')

            # 驗(yàn)證手機(jī)驗(yàn)證碼(如果使用手機(jī)注冊(cè))
            if phone:
                sms_code_input = form.cleaned_data.get('sms_code')
                sms_code_session = request.session.get('sms_code')
                if not sms_code_session or sms_code_input != sms_code_session:
                    form.add_error('sms_code', '驗(yàn)證碼錯(cuò)誤或已過期')
                    return render(request, 'users/register.html', {'form': form})
                # 清空 session 中的驗(yàn)證碼
                request.session.pop('sms_code', None)

            # 創(chuàng)建用戶
            user = User.objects.create_user(
                username=phone or email.split('@')[0],  # 用手機(jī)號(hào)或郵箱前綴當(dāng)用戶名
                password=password,
                phone=phone if phone else None,
                email=email if email else None,
            )

            # 如果用郵箱注冊(cè),發(fā)送激活郵件
            if email:
                # 生成簡(jiǎn)單的 token(生產(chǎn)環(huán)境建議用 itsdangerous)
                token = str(random.randint(100000, 999999))
                request.session[f'email_token_{user.id}'] = token
                activate_url = request.build_absolute_uri(
                    f'/users/activate/{user.id}/{token}/'
                )
                send_mail(
                    subject='激活你的電商賬號(hào)',
                    message=f'點(diǎn)擊鏈接激活賬號(hào):{activate_url}',
                    from_email='noreply@example.com',
                    recipient_list=[email],
                )
                messages.success(request, '注冊(cè)成功!激活郵件已發(fā)送,請(qǐng)前往郵箱查收(查看終端輸出)。')
            else:
                messages.success(request, '注冊(cè)成功!您現(xiàn)在可以登錄了。')

            return redirect('home')  # 后續(xù)改為登錄頁(yè)
    else:
        form = RegisterForm()

    return render(request, 'users/register.html', {'form': form})


@require_POST
def send_sms_code(request):
    """發(fā)送短信驗(yàn)證碼(模擬)"""
    phone = request.POST.get('phone')
    if not phone:
        return JsonResponse({'ok': False, 'msg': '手機(jī)號(hào)不能為空'}, status=400)

    # 生成 6 位隨機(jī)驗(yàn)證碼
    code = str(random.randint(100000, 999999))
    # 存入 session
    request.session['sms_code'] = code
    request.session.set_expiry(300)  # 5分鐘有效

    # 控制臺(tái)模擬發(fā)送
    print(f"\n{'='*40}")
    print(f"【模擬短信】驗(yàn)證碼:{code},發(fā)送至手機(jī)號(hào):{phone}")
    print(f"{'='*40}\n")

    return JsonResponse({'ok': True, 'msg': '驗(yàn)證碼已發(fā)送'})

關(guān)鍵點(diǎn)說明

功能 實(shí)現(xiàn)方式
驗(yàn)證碼校驗(yàn) 手機(jī)注冊(cè)時(shí),比對(duì)用戶輸入與 request.session['sms_code'],通過后立即清空
用戶創(chuàng)建 使用 User.objects.create_user(),自動(dòng)處理密碼加密;用戶名取手機(jī)號(hào)或郵箱前綴
郵箱激活 生成 6 位隨機(jī) token 存入 session,構(gòu)建激活鏈接并通過 send_mail() 發(fā)送
短信模擬 send_sms_code 是獨(dú)立 AJAX 視圖,驗(yàn)證碼存 session 并設(shè)置 5 分鐘過期,同時(shí)在控制臺(tái)打印

五、URL 路由配置

5.1 應(yīng)用級(jí)路由:apps/users/urls.py

from django.urls import path
from . import views

app_name = 'users'

urlpatterns = [
    path('register/', views.register, name='register'),
    path('send_sms/', views.send_sms_code, name='send_sms'),
]

5.2 項(xiàng)目級(jí)路由:django_ecommerce/urls.py

from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import TemplateView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', TemplateView.as_view(template_name='home.html'), name='home'),
    path('users/', include('apps.users.urls')),  # 注意路徑前綴
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

六、注冊(cè)頁(yè)面模板

創(chuàng)建 apps/users/templates/users/register.html

{% extends 'base.html' %}
{% block title %}用戶注冊(cè){% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6 col-lg-5">
        <div class="card shadow-sm">
            <div class="card-body p-4">
                <h3 class="text-center mb-4">?? 創(chuàng)建賬號(hào)</h3>

                <form method="post" novalidate>
                    {% csrf_token %}

                    <!-- 手機(jī)號(hào) -->
                    <div class="mb-3">
                        <label class="form-label">手機(jī)號(hào)</label>
                        {{ form.phone }}
                        {% if form.phone.errors %}
                            <div class="text-danger small">{{ form.phone.errors.0 }}</div>
                        {% endif %}
                    </div>

                    <!-- 驗(yàn)證碼(僅手機(jī)注冊(cè)時(shí)顯示) -->
                    <div class="mb-3" id="sms-code-group" style="display:none;">
                        <label class="form-label">驗(yàn)證碼</label>
                        <div class="input-group">
                            {{ form.sms_code }}
                            <button class="btn btn-outline-secondary" type="button" id="get-sms-btn">
                                獲取驗(yàn)證碼
                            </button>
                        </div>
                        {% if form.sms_code.errors %}
                            <div class="text-danger small">{{ form.sms_code.errors.0 }}</div>
                        {% endif %}
                    </div>

                    <!-- 郵箱 -->
                    <div class="mb-3">
                        <label class="form-label">郵箱</label>
                        {{ form.email }}
                        {% if form.email.errors %}
                            <div class="text-danger small">{{ form.email.errors.0 }}</div>
                        {% endif %}
                    </div>

                    <!-- 密碼 -->
                    <div class="mb-3">
                        <label class="form-label">密碼</label>
                        {{ form.password }}
                        {% if form.password.errors %}
                            <div class="text-danger small">{{ form.password.errors.0 }}</div>
                        {% endif %}
                    </div>

                    <div class="mb-3">
                        <label class="form-label">確認(rèn)密碼</label>
                        {{ form.password2 }}
                        {% if form.password2.errors %}
                            <div class="text-danger small">{{ form.password2.errors.0 }}</div>
                        {% endif %}
                    </div>

                    <!-- 全局錯(cuò)誤(如未填手機(jī)號(hào)或郵箱) -->
                    {% if form.non_field_errors %}
                        <div class="alert alert-danger">
                            {{ form.non_field_errors.0 }}
                        </div>
                    {% endif %}

                    <button type="submit" class="btn btn-primary w-100">注冊(cè)</button>
                </form>

                <p class="text-center mt-3">
                    已有賬號(hào)?<<a href="#">立即登錄</a>
                </p>
            </div>
        </div>
    </div>
</div>
{% endblock %}

{% block extra_js %}
<script>
    const phoneInput = document.querySelector('#id_phone');
    const emailInput = document.querySelector('#id_email');
    const smsGroup = document.querySelector('#sms-code-group');
    const getSmsBtn = document.querySelector('#get-sms-btn');

    // 根據(jù)手機(jī)號(hào)輸入框是否有內(nèi)容來顯示/隱藏驗(yàn)證碼區(qū)域
    function toggleSmsGroup() {
        if (phoneInput.value.trim().length > 0) {
            smsGroup.style.display = 'block';
        } else {
            smsGroup.style.display = 'none';
        }
    }

    phoneInput.addEventListener('input', toggleSmsGroup);
    // 頁(yè)面初始化
    toggleSmsGroup();

    // 獲取驗(yàn)證碼
    getSmsBtn.addEventListener('click', function() {
        const phone = phoneInput.value.trim();
        if (!phone) {
            alert('請(qǐng)先輸入手機(jī)號(hào)');
            return;
        }
        // 簡(jiǎn)單的前端倒計(jì)時(shí)
        let countdown = 60;
        getSmsBtn.disabled = true;
        getSmsBtn.textContent = countdown + '秒后重試';
        const timer = setInterval(() => {
            countdown--;
            getSmsBtn.textContent = countdown + '秒后重試';
            if (countdown <= 0) {
                clearInterval(timer);
                getSmsBtn.disabled = false;
                getSmsBtn.textContent = '獲取驗(yàn)證碼';
            }
        }, 1000);

        // 發(fā)送請(qǐng)求
        fetch('{% url "users:send_sms" %}', {
            method: 'POST',
            headers: {
                'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: 'phone=' + encodeURIComponent(phone)
        })
        .then(response => response.json())
        .then(data => {
            if (!data.ok) {
                alert(data.msg);
            }
        });
    });
</script>
{% endblock %}

前端交互亮點(diǎn)

  • 動(dòng)態(tài)顯示驗(yàn)證碼區(qū)域:監(jiān)聽手機(jī)號(hào)輸入框,有內(nèi)容時(shí)自動(dòng)顯示驗(yàn)證碼輸入框

  • 60 秒倒計(jì)時(shí)防重復(fù)點(diǎn)擊:點(diǎn)擊"獲取驗(yàn)證碼"后按鈕禁用并倒計(jì)時(shí)

  • AJAX 發(fā)送驗(yàn)證碼:使用 fetch() 發(fā)送 POST 請(qǐng)求,自動(dòng)攜帶 CSRF Token


七、郵箱激活功能

為了完成完整的郵箱注冊(cè)流程,我們?cè)偬砑右粋€(gè)激活視圖。

7.1 激活視圖:apps/users/views.py

from django.http import Http404
from django.shortcuts import get_object_or_404

def activate_email(request, user_id, token):
    user = get_object_or_404(User, id=user_id)
    session_token = request.session.get(f'email_token_{user.id}')

    if not session_token or session_token != token:
        raise Http404('激活鏈接無(wú)效或已過期')

    user.email_active = True
    user.save(update_fields=['email_active'])
    # 激活成功后清理 token
    request.session.pop(f'email_token_{user.id}', None)

    messages.success(request, '郵箱激活成功!現(xiàn)在可以登錄了。')
    return redirect('home')  # 后續(xù)改為登錄頁(yè)

7.2 添加激活路由:apps/users/urls.py

path('activate/<int:user_id>/<str:token>/', views.activate_email, name='activate_email'),

八、測(cè)試完整流程

啟動(dòng)開發(fā)服務(wù)器:

python manage.py runserver

8.1 手機(jī)號(hào)注冊(cè)測(cè)試

  1. 訪問 http://127.0.0.1:8000/users/register/

  2. 輸入手機(jī)號(hào) 13800138000,此時(shí)驗(yàn)證碼輸入框會(huì)出現(xiàn)

  3. 點(diǎn)擊"獲取驗(yàn)證碼",查看終端輸出:

[20/May/2026 14:35:22] "POST /users/send_sms/ HTTP/1.1" 200 27

========================================
【模擬短信】驗(yàn)證碼:384729,發(fā)送至手機(jī)號(hào):13800138000
========================================
  1. 輸入收到的驗(yàn)證碼(如 384729),設(shè)置密碼,提交注冊(cè)

  2. 注冊(cè)成功,跳轉(zhuǎn)到首頁(yè)并顯示提示

驗(yàn)證數(shù)據(jù)庫(kù):

SELECT id, username, phone, is_active FROM tb_users;

結(jié)果示例:

1|admin|13800138001|1        ← 超級(jí)管理員(之前創(chuàng)建)
2|13800138000|13800138000|1  ← 剛注冊(cè)的用戶

8.2 郵箱注冊(cè)測(cè)試

  1. 填寫郵箱 test@example.com,密碼,確認(rèn)密碼,提交

  2. 終端會(huì)輸出激活郵件內(nèi)容:

Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: 激活你的電商賬號(hào)
From: noreply@example.com
To: test@example.com
Date: Wed, 20 May 2026 14:40:00 -0000
Message-ID: <...>
X-Mailer: Django Mailer

點(diǎn)擊鏈接激活賬號(hào):http://127.0.0.1:8000/users/activate/3/123456/
  1. 復(fù)制終端中的鏈接(注意你的 token 和用戶 ID 可能不同),用瀏覽器打開

  2. 提示"郵箱激活成功!",用戶 email_active 變?yōu)?True


九、當(dāng)前注冊(cè)的不足與改進(jìn)方向

問題 現(xiàn)狀 改進(jìn)方案
手機(jī)驗(yàn)證碼防刷 沒有對(duì)同一手機(jī)號(hào)頻繁發(fā)送做限制 后續(xù)使用 Redis 緩存記錄發(fā)送頻率
激活 Token 安全性 使用 6 位隨機(jī)數(shù),易被暴力破解 生產(chǎn)環(huán)境使用 itsdangerous 或 Django 自帶的 PasswordResetTokenGenerator 生成有時(shí)效的簽名 token
用戶名沖突 郵箱前綴可能重復(fù) 后續(xù)在 clean 中增加唯一性校驗(yàn),或改用 UUID 作為 username

這些優(yōu)化會(huì)在后續(xù)篇(第 24~26 篇)中逐步完善。


十、總結(jié)與下集預(yù)告

今天我們實(shí)現(xiàn)了用戶注冊(cè)的核心功能,涵蓋:

? 自定義注冊(cè)表單,支持手機(jī)號(hào)或郵箱雙通道注冊(cè)
? 模擬短信驗(yàn)證碼的發(fā)送與驗(yàn)證,使用 session 存儲(chǔ)驗(yàn)證碼
? 郵箱注冊(cè)的激活流程,通過控制臺(tái)郵件后端完成激活
? 前端動(dòng)態(tài)顯示驗(yàn)證碼輸入?yún)^(qū)域,AJAX 請(qǐng)求發(fā)送驗(yàn)證碼

注冊(cè)搞定了,登錄自然是下一步。 第 7 篇,我將帶大家實(shí)現(xiàn)登錄與登出功能,包括 Django 內(nèi)置認(rèn)證系統(tǒng)的使用、登錄裝飾器、記住我功能,以及登錄后導(dǎo)航欄的狀態(tài)變化。


?? 想了解更多? 去公眾號(hào)、今日頭條搜索「IT策士」,一起升級(jí) IT 思維!
本文為《Django 從 0 到 1 打造完整電商平臺(tái)》系列第 6 篇,作者:IT策士,未經(jīng)授權(quán)禁止轉(zhuǎn)載。


?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容