DJANGO 12 分钟 13,727 字

Django + Vue 微信扫码登录完整实现指南

0阅读

我将为你整理一篇完整的微信扫码登录实现博客文章。


前言

微信扫码登录是PC端常用的第三方登录方式,本文将详细介绍如何基于Django REST Framework和Vue 3实现完整的前后端分离微信扫码登录方案。需要注意的是, 微信扫码登录需要企业资质的微信开放平台账号,个人开发者无法申请。

一、准备工作

1.1 注册微信开放平台账号

访问 微信开放平台,注册企业账号并完成认证。

1.2 创建网站应用

在管理中心创建"网站应用",填写网站信息并通过审核。审核通过后获得:

  • AppID
  • AppSecret

1.3 配置授权回调域

在网站应用的"接口权限"中设置授权回调域,例如:your-domain.com

二、后端实现(Django)

2.1 项目配置

# settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'corsheaders',
    'wechat_auth',
]

# 微信开放平台配置
WECHAT_OPEN_APP_ID = 'your_app_id'
WECHAT_OPEN_APP_SECRET = 'your_app_secret'
WECHAT_REDIRECT_URI = 'http://your-domain.com/api/wechat/callback/'

# CORS配置
CORS_ALLOWED_ORIGINS = [
    "http://localhost:8080",
    "http://localhost:3000",
]

# 缓存配置(开发环境)
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
    }
}

2.2 自定义用户模型

# models.py
import uuid
from django.contrib.auth.models import AbstractUser, Group, Permission
from django.db import models


class User(AbstractUser):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    openid = models.CharField(max_length=100, unique=True, null=True, blank=True)
    unionid = models.CharField(max_length=100, unique=True, null=True, blank=True)
    nickname = models.CharField(max_length=100, blank=True)
    avatar_url = models.URLField(blank=True)
    phone = models.CharField(max_length=20, blank=True)
    is_wechat_bound = models.BooleanField(default=False)

    # 解决权限冲突
    groups = models.ManyToManyField(
        Group,
        related_name='wechat_user_groups',
        blank=True,
    )
    user_permissions = models.ManyToManyField(
        Permission,
        related_name='wechat_user_permissions',
        blank=True,
    )

    class Meta:
        db_table = 'users'

2.3 微信API工具类

# wechat_utils.py
import requests
import uuid
from django.core.cache import cache
from django.conf import settings


class WeChatOpenAPI:
    """微信开放平台API封装"""
    BASE_URL = "https://api.weixin.qq.com"

    @classmethod
    def get_access_token(cls, code):
        """通过code获取access_token"""
        url = f"{cls.BASE_URL}/sns/oauth2/access_token"
        params = {
            'appid': settings.WECHAT_OPEN_APP_ID,
            'secret': settings.WECHAT_OPEN_APP_SECRET,
            'code': code,
            'grant_type': 'authorization_code'
        }
        response = requests.get(url, params=params)
        data = response.json()

        if 'errcode' in data:
            raise Exception(f"WeChat API Error: {data.get('errmsg')}")
        return data

    @classmethod
    def get_user_info(cls, access_token, openid):
        """获取用户信息"""
        url = f"{cls.BASE_URL}/sns/userinfo"
        params = {
            'access_token': access_token,
            'openid': openid,
            'lang': 'zh_CN'
        }
        response = requests.get(url, params=params)
        return response.json()


class QRCodeManager:
    """二维码场景管理"""
    SCENE_PREFIX = "wechat_qr_scene_"
    EXPIRE_SECONDS = 300

    @classmethod
    def generate_scene_id(cls):
        return str(uuid.uuid4()).replace('-', '')[:16]

    @classmethod
    def create_scene(cls, scene_id):
        cache_key = f"{cls.SCENE_PREFIX}{scene_id}"
        cache.set(cache_key, {
            'status': 'pending',
            'user_id': None,
        }, cls.EXPIRE_SECONDS)
        return scene_id

    @classmethod
    def get_scene(cls, scene_id):
        cache_key = f"{cls.SCENE_PREFIX}{scene_id}"
        return cache.get(cache_key)

    @classmethod
    def update_scene(cls, scene_id, **kwargs):
        cache_key = f"{cls.SCENE_PREFIX}{scene_id}"
        data = cache.get(cache_key) or {}
        data.update(kwargs)
        cache.set(cache_key, data, cls.EXPIRE_SECONDS)
        return data

2.4 JWT工具函数

# jwt_utils.py
import jwt
import uuid
from datetime import datetime, timedelta
from django.conf import settings


def generate_tokens(user):
    """生成JWT token"""
    now = datetime.utcnow()

    access_payload = {
        'token_type': 'access',
        'user_id': str(user.id),
        'username': user.username,
        'exp': now + timedelta(hours=24),
        'iat': now,
        'jti': str(uuid.uuid4())
    }

    refresh_payload = {
        'token_type': 'refresh',
        'user_id': str(user.id),
        'exp': now + timedelta(days=7),
        'iat': now,
        'jti': str(uuid.uuid4())
    }

    access_token = jwt.encode(access_payload, settings.SECRET_KEY, algorithm='HS256')
    refresh_token = jwt.encode(refresh_payload, settings.SECRET_KEY, algorithm='HS256')

    return {
        'access': access_token,
        'refresh': refresh_token
    }

2.5 核心视图实现

# views.py
import qrcode
import io
import base64
from datetime import datetime, timedelta
from django.conf import settings
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated

from .models import User, WechatQRCode
from .wechat_utils import WeChatOpenAPI, QRCodeManager
from .jwt_utils import generate_tokens


class GenerateQRCodeView(APIView):
    """生成微信登录二维码"""
    permission_classes = [AllowAny]

    def get(self, request):
        scene_id = QRCodeManager.generate_scene_id()
        QRCodeManager.create_scene(scene_id)

        expires_at = datetime.now() + timedelta(seconds=300)
        WechatQRCode.objects.create(
            scene_id=scene_id,
            expires_at=expires_at,
            status='pending'
        )

        # 构造微信授权URL(网站应用必须使用snsapi_login)
        redirect_uri = settings.WECHAT_REDIRECT_URI
        scope = "snsapi_login"  # 网站应用专用

        wechat_auth_url = (
            f"https://open.weixin.qq.com/connect/qrconnect?"
            f"appid={settings.WECHAT_OPEN_APP_ID}"
            f"&redirect_uri={redirect_uri}"
            f"&response_type=code"
            f"&scope={scope}"
            f"&state={scene_id}#wechat_redirect"
        )

        # 生成二维码图片
        qr = qrcode.QRCode(version=1, box_size=10, border=4)
        qr.add_data(wechat_auth_url)
        qr.make(fit=True)

        img = qr.make_image(fill_color="black", back_color="white")
        buffer = io.BytesIO()
        img.save(buffer, format='PNG')
        qr_base64 = base64.b64encode(buffer.getvalue()).decode()

        return Response({
            'scene_id': scene_id,
            'qr_code': f'data:image/png;base64,{qr_base64}',
            'expires_in': 300,
            'auth_url': wechat_auth_url
        })


class CheckQRStatusView(APIView):
    """轮询检查二维码状态"""
    permission_classes = [AllowAny]

    def get(self, request):
        scene_id = request.query_params.get('scene_id')
        if not scene_id:
            return Response({'error': 'Scene ID required'}, status=400)

        scene_data = QRCodeManager.get_scene(scene_id)

        if not scene_data:
            try:
                qr_record = WechatQRCode.objects.get(scene_id=scene_id)
                if qr_record.status == 'confirmed' and qr_record.user:
                    tokens = generate_tokens(qr_record.user)
                    return Response({
                        'status': 'confirmed',
                        'access': tokens['access'],
                        'refresh': tokens['refresh'],
                        'user': {
                            'id': str(qr_record.user.id),
                            'username': qr_record.user.username,
                            'nickname': qr_record.user.nickname,
                            'avatar_url': qr_record.user.avatar_url,
                        }
                    })
                return Response({'status': qr_record.status})
            except WechatQRCode.DoesNotExist:
                return Response({'status': 'expired'})

        return Response({
            'status': scene_data.get('status', 'pending')
        })


class WechatCallbackView(APIView):
    """微信回调处理"""
    permission_classes = [AllowAny]

    def get(self, request):
        code = request.query_params.get('code')
        state = request.query_params.get('state')

        if not code:
            return Response({'error': 'Authorization failed'}, status=400)

        try:
            # 获取access_token和openid
            token_data = WeChatOpenAPI.get_access_token(code)
            access_token = token_data['access_token']
            openid = token_data['openid']
            unionid = token_data.get('unionid')

            # 获取用户信息
            user_info = WeChatOpenAPI.get_user_info(access_token, openid)

            # 查找或创建用户
            user, created = User.objects.get_or_create(
                openid=openid,
                defaults={
                    'unionid': unionid,
                    'username': f'wx_{openid[:8]}',
                    'nickname': user_info.get('nickname', ''),
                    'avatar_url': user_info.get('headimgurl', ''),
                    'is_wechat_bound': True
                }
            )

            if not created:
                user.nickname = user_info.get('nickname', user.nickname)
                user.avatar_url = user_info.get('headimgurl', user.avatar_url)
                user.save()

            # 更新场景状态
            QRCodeManager.update_scene(state, status='confirmed', user_id=str(user.id))
            WechatQRCode.objects.filter(scene_id=state).update(
                status='confirmed',
                user=user
            )

            # 生成token
            tokens = generate_tokens(user)

            return Response({
                'success': True,
                'access': tokens['access'],
                'refresh': tokens['refresh'],
                'user': {
                    'id': str(user.id),
                    'username': user.username,
                    'nickname': user.nickname,
                    'avatar_url': user.avatar_url,
                }
            })

        except Exception as e:
            QRCodeManager.update_scene(state, status='expired')
            return Response({'error': str(e)}, status=500)

2.6 URL配置

# urls.py
from django.urls import path
from .views import *

urlpatterns = [
    path('qrcode/generate/', GenerateQRCodeView.as_view(), name='generate_qr'),
    path('qrcode/status/', CheckQRStatusView.as_view(), name='check_status'),
    path('callback/', WechatCallbackView.as_view(), name='wechat_callback'),
]

三、前端实现(Vue 3)

3.1 Axios封装

// utils/request.js
import axios from 'axios'

const request = axios.create({
    baseURL: '/api',
    timeout: 10000
})

// 请求拦截器
request.interceptors.request.use(config => {
    const token = localStorage.getItem('access_token')
    if (token) {
        config.headers.Authorization = `Bearer ${token}`
    }
    return config
})

// 响应拦截器
request.interceptors.response.use(
    response => response,
    async error => {
        const originalRequest = error.config

        if (error.response?.status === 401 && !originalRequest._retry) {
            originalRequest._retry = true
            try {
                const refreshToken = localStorage.getItem('refresh_token')
                const response = await axios.post('/api/auth/refresh/', {
                    refresh: refreshToken
                })
                const {access} = response.data
                localStorage.setItem('access_token', access)
                originalRequest.headers.Authorization = `Bearer ${access}`
                return request(originalRequest)
            } catch (refreshError) {
                localStorage.clear()
                window.location.href = '/login'
                return Promise.reject(refreshError)
            }
        }
        return Promise.reject(error)
    }
)

export default request

3.2 登录组件

<!-- WechatLogin.vue -->
<template>
  <div class="wechat-login">
    <div class="login-container">
      <h2>微信扫码登录</h2>

      <div class="qr-section" v-if="!isLoggedIn">
        <div v-if="loading" class="loading">加载中...</div>

        <div v-else-if="qrCodeUrl" class="qr-wrapper">
          <img :src="qrCodeUrl" alt="微信登录二维码" class="qr-image"/>
          <p class="qr-tip">{{ statusText }}</p>

          <button
              v-if="status === 'expired'"
              @click="refreshQRCode"
              class="refresh-btn"
          >
            刷新二维码
          </button>
        </div>
      </div>

      <div v-else class="success-section">
        <img :src="userInfo.avatar_url" class="avatar"/>
        <h3>欢迎,{{ userInfo.nickname || userInfo.username }}</h3>
        <button @click="logout" class="logout-btn">退出登录</button>
      </div>
    </div>
  </div>
</template>

<script setup>
  import {ref, onMounted, onUnmounted} from 'vue'
  import request from '@/utils/request'

  const loading = ref(false)
  const qrCodeUrl = ref('')
  const sceneId = ref('')
  const status = ref('pending')
  const statusText = ref('请使用微信扫一扫登录')
  const isLoggedIn = ref(false)
  const userInfo = ref(null)
  let pollTimer = null

  // 生成二维码
  const generateQRCode = async () => {
    loading.value = true
    try {
      const {data} = await request.get('/wechat/qrcode/generate/')
      qrCodeUrl.value = data.qr_code
      sceneId.value = data.scene_id
      status.value = 'pending'
      statusText.value = '请使用微信扫一扫登录'
      startPolling()
    } catch (error) {
      console.error('生成二维码失败:', error)
      statusText.value = '生成二维码失败,请重试'
    } finally {
      loading.value = false
    }
  }

  // 刷新二维码
  const refreshQRCode = () => {
    stopPolling()
    generateQRCode()
  }

  // 轮询检查状态
  const startPolling = () => {
    pollTimer = setInterval(async () => {
      try {
        const {data} = await request.get('/wechat/qrcode/status/', {
          params: {scene_id: sceneId.value}
        })

        status.value = data.status

        switch (data.status) {
          case 'pending':
            statusText.value = '请使用微信扫一扫登录'
            break
          case 'scanned':
            statusText.value = '已扫码,请在手机上确认登录'
            break
          case 'confirmed':
            stopPolling()
            handleLoginSuccess(data)
            break
          case 'expired':
            statusText.value = '二维码已过期,请点击刷新'
            stopPolling()
            break
          case 'cancelled':
            statusText.value = '用户取消登录'
            stopPolling()
            break
        }
      } catch (error) {
        console.error('检查状态失败:', error)
      }
    }, 2000)
  }

  const stopPolling = () => {
    if (pollTimer) {
      clearInterval(pollTimer)
      pollTimer = null
    }
  }

  const handleLoginSuccess = (data) => {
    localStorage.setItem('access_token', data.access)
    localStorage.setItem('refresh_token', data.refresh)
    request.defaults.headers.common['Authorization'] = `Bearer ${data.access}`

    userInfo.value = data.user
    isLoggedIn.value = true
  }

  const logout = () => {
    localStorage.removeItem('access_token')
    localStorage.removeItem('refresh_token')
    delete request.defaults.headers.common['Authorization']
    isLoggedIn.value = false
    userInfo.value = null
    generateQRCode()
  }

  onMounted(() => {
    const token = localStorage.getItem('access_token')
    if (token) {
      // 可添加获取用户信息逻辑
      isLoggedIn.value = true
    } else {
      generateQRCode()
    }
  })

  onUnmounted(() => {
    stopPolling()
  })
</script>

<style scoped>
  .wechat-login {
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    background: #f5f5f5;
  }

  .login-container {
    background: white;
    padding: 40px;
    border-radius: 12px;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    text-align: center;
    min-width: 300px;
  }

  .qr-image {
    width: 200px;
    height: 200px;
    border: 1px solid #eee;
    padding: 10px;
    border-radius: 8px;
  }

  .qr-tip {
    margin-top: 15px;
    color: #666;
    font-size: 14px;
  }

  .refresh-btn {
    margin-top: 15px;
    padding: 8px 20px;
    background: #07c160;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }

  .avatar {
    width: 80px;
    height: 80px;
    border-radius: 50%;
    margin-bottom: 15px;
  }

  .logout-btn {
    margin-top: 20px;
    padding: 10px 30px;
    background: #ff4d4f;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }
</style>

3.3 Vite代理配置

// vite.config.js
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
    plugins: [vue()],
    server: {
        port: 8080,
        proxy: {
            '/api': {
                target: 'http://localhost:8000',
                changeOrigin: true,
            }
        }
    }
})

四、常见问题

4.1 "没有scope参数错误或没有scope权限"

原因:使用了错误的scope或应用类型不对。

解决:网站应用必须使用 snsapi_login,且必须在微信开放平台创建网站应用而非公众号。

4.2 回调地址不匹配

确保GitHub OAuth App设置中的Authorization callback URL与代码中的redirect_uri完全一致,包括协议、域名、端口和路径。

4.3 二维码过期处理

前端需要实现过期检测和刷新机制,通常二维码有效期为5分钟。

五、个人开发者替代方案

由于微信扫码登录需要企业资质,个人开发者可以考虑:

  1. GitHub登录(完全免费)
  2. Google登录
  3. 手机号+短信验证码
  4. 邮箱+密码登录

具体实现可参考GitHub登录方案,架构类似,只是OAuth接口不同。

六、总结

本文介绍了完整的微信扫码登录实现,包括:

  • Django后端API设计
  • 二维码生成与状态管理
  • 微信OAuth流程处理
  • Vue前端轮询机制
  • JWT认证集成

关键点在于理解微信开放平台的网站应用权限和正确的scope使用。对于企业级应用,这是一个稳定可靠的登录方案。


本文代码基于Django 4.2和Vue 3,可根据实际需求调整。