前后端分离项目-DRF+VUE->媒体宝

媒体宝项目

01 创建项目(后端)

  • 前端vue.js项目:city

    https://gitee.com/wupeiqi/city
    
  • 后端django项目:mtb

    https://gitee.com/wupeiqi/mtb
    

项目代码的git上会同步更新,大家下载下来后,可以根据提交记录来进行回滚,查看看各个版本。

1.准备环境

1.1 虚拟环境&项目

  • 在pycharm中创建项目【空Python项目】+【虚拟环境】

    1661261547172

1661262041587

  • 安装django

    pip install django==3.2
    
  • 创建django项目到当前目录,后面加上绝对路径避免出现嵌套目录

    django-admin startproject mtb  D:\data\1045699\Desktop\luffy代码\mtb
    

    1661262308419

1.2 创建多app

  • 项目根目录下创建apps目录

  • 在apps目录下创建文件夹(app)

    apps
    	- task
    	- msg
    	- base
    
  • 执行命令

    python manage.py startapp task apps/task
    python manage.py startapp msg apps/msg
    python manage.py startapp base apps/base
    

1661262627705

1.3 注册app

  • 在app目录下的app.py中修改

    from django.apps import AppConfig
    
    class TaskConfig(AppConfig):
        name = 'task'
    
    from django.apps import AppConfig
    
    class TaskConfig(AppConfig):
        name = 'apps.task'
    
  • 在settings.py中注册

    INSTALLED_APPS = [
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
        "apps.task.apps.TaskConfig"
    ]
    

1661263178980

1.4 基本配置

  • 移除无用的app

    INSTALLED_APPS = [
        # 'django.contrib.admin',
        # 'django.contrib.auth',
        # 'django.contrib.contenttypes',
        # 'django.contrib.sessions',
        # 'django.contrib.messages',
        'django.contrib.staticfiles',
        "apps.base.apps.BaseConfig"
    ]
    
  • 移除无用的中间件

    MIDDLEWARE = [
        'django.middleware.security.SecurityMiddleware',
        # 'django.contrib.sessions.middleware.SessionMiddleware',
        'django.middleware.common.CommonMiddleware',
        # 'django.middleware.csrf.CsrfViewMiddleware',
        # 'django.contrib.auth.middleware.AuthenticationMiddleware',
        # 'django.contrib.messages.middleware.MessageMiddleware',
        'django.middleware.clickjacking.XFrameOptionsMiddleware',
    ]
    
  • 数据库相关(MySQL)

    • 创建数据库

      create database mtb DEFAULT CHARSET utf8 COLLATE utf8_general_ci;
      
    • 设置数据库连接

      DATABASES = {
          'default': {
              'ENGINE': 'django.db.backends.mysql',
              'NAME': 'mtb',
              'USER': 'root',
              'PASSWORD': 'root123',
              'HOST': '127.0.0.1',
              'PORT': 3306,
          }
      }
      
    • 安装python操作MySQL模块,不用pymsql原因是在Django3中pymsql支持不那么友好

      pip install mysqlclient
      

      1661274730588

1.5 本地配置

避免泄漏数据库信息,将敏感内容放置local_settings.py中

  • settings.py

    try:
        from .local_settings import *
    except ImportError:
        pass
    
  • local_settings.py

    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.mysql',
            'NAME': 'mydatabase',
            'USER': 'mydatabaseuser',
            'PASSWORD': 'mypassword',
            'HOST': '127.0.0.1',
            'PORT': '5432',
        }
    }
    

1.6 代码仓库

如果想要代码共享给他人 或 多人协同开发,来会发文件不方便。

如果想要保留自己之前编写功能的的版本,每次都生成一个文件夹也不方便。

程序员一般都用git来解决上述问题,想要用git需要两个步骤:

  • 【自己电脑上】安装git + 学习git相关的命令。

    Git 全局设置:
    git config --global user.name "fumiadder"
    git config --global user.email "2521532473@qq.com"
    
    创建 git 仓库:
    mkdir ceshi
    cd ceshi
    git init 
    touch README.md
    git add README.md
    git commit -m "first commit"
    git remote rm origin	# 删除别名,没有别名请忽略这一步
    git remote add origin git@gitee.com:fumiadder/ceshi.git	# 起别名
    git push -u origin "master"
    
    已有仓库?
    cd existing_git_repo
    git remote add origin git@gitee.com:fumiadder/ceshi.git
    git push -u origin "master"
    

    将不需要上传的文件名称添加至.gitignore文件中,可以去GitHub或gitee上找python的gitignore文件:

    # Byte-compiled / optimized / DLL files
    __pycache__/
    *.py[cod]
    *$py.class
    
    # C extensions
    *.so
    
    # Distribution / packaging
    .Python
    build/
    develop-eggs/
    dist/
    downloads/
    eggs/
    .eggs/
    lib/
    lib64/
    parts/
    sdist/
    var/
    wheels/
    share/python-wheels/
    *.egg-info/
    .installed.cfg
    *.egg
    MANIFEST
    
    # PyInstaller
    #  Usually these files are written by a python script from a template
    #  before PyInstaller builds the exe, so as to inject date/other infos into it.
    *.manifest
    *.spec
    
    # Installer logs
    pip-log.txt
    pip-delete-this-directory.txt
    
    # Unit test / coverage reports
    htmlcov/
    .tox/
    .nox/
    .coverage
    .coverage.*
    .cache
    nosetests.xml
    coverage.xml
    *.cover
    *.py,cover
    .hypothesis/
    .pytest_cache/
    cover/
    
    # Translations
    *.mo
    *.pot
    
    # Django stuff:
    *.log
    local_settings.py
    db.sqlite3
    db.sqlite3-journal
    
    # Flask stuff:
    instance/
    .webassets-cache
    
    # Scrapy stuff:
    .scrapy
    
    # Sphinx documentation
    docs/_build/
    
    # PyBuilder
    .pybuilder/
    target/
    
    # Jupyter Notebook
    .ipynb_checkpoints
    
    # IPython
    profile_default/
    ipython_config.py
    
    # pyenv
    #   For a library or package, you might want to ignore these files since the code is
    #   intended to run in multiple environments; otherwise, check them in:
    # .python-version
    
    # pipenv
    #   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
    #   However, in case of collaboration, if having platform-specific dependencies or dependencies
    #   having no cross-platform support, pipenv may install dependencies that don't work, or not
    #   install all needed dependencies.
    #Pipfile.lock
    
    # PEP 582; used by e.g. github.com/David-OConnor/pyflow
    __pypackages__/
    
    # Celery stuff
    celerybeat-schedule
    celerybeat.pid
    
    # SageMath parsed files
    *.sage.py
    
    # Environments
    .env
    .venv
    env/
    venv/
    ENV/
    env.bak/
    venv.bak/
    
    # Spyder project settings
    .spyderproject
    .spyproject
    
    # Rope project settings
    .ropeproject
    
    # mkdocs documentation
    /site
    
    # mypy
    .mypy_cache/
    .dmypy.json
    dmypy.json
    
    # Pyre type checker
    .pyre/
    
    # pytype static type analyzer
    .pytype/
    
    # Cython debug symbols
    cython_debug/
    
  • 【代码仓库】创建项目,将本地项目推送上去,可共享给他人。 gitee

    https://gitee.com/fumiadder/mtb/
    

1.7 启动&运行项目

配置Pycharm去运行。

1661276386335

1661276445413

2.认证

image-20220319010010702

2.1 后端API

  • 基于token,drf案例中的项目。
    image-20220319010136683
  • 基于jwt【推荐】
    image-20220319010207841

jwt的原理是什么呢?https://www.cnblogs.com/wupeiqi/p/11854573.html

pip install pyjwt==2.3.0
  • 生成jwt token

    import jwt
    import datetime
    from jwt import exceptions
    
    SALT = 'iv%x6xo7l7_u9bf_u!9#g#m*)*=ej@bek5)(@u3kh*72+unjv='
    
    
    def create_token():
        # 构造header
        headers = {
            'typ': 'jwt',
            'alg': 'HS256'
        }
        # 构造payload
        payload = {
            'user_id': 1,  # 自定义用户ID
            'username': 'wupeiqi',  # 自定义用户名
            'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=5)  # 超时时间
        }
        result = jwt.encode(payload=payload, key=SALT, algorithm="HS256", headers=headers)
        return result
    
    
    if __name__ == '__main__':
        token = create_token()
        print(token)
    	# eyJ0eXAiOiJqd3QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6Ind1cGVpcWkiLCJleHAiOjE2NDc2MjMzMDR9.mC409LXIl1RZu4OX5J01hvCxWEOJcK7C4P3zKzedXdU
    
  • 校验

    import jwt
    from jwt import exceptions
    
    SALT = 'iv%x6xo7l7_u9bf_u!9#g#m*)*=ej@bek5)(@u3kh*72+unjv='
    
    
    def get_payload(token):
        """
        根据token获取payload
        :param token:
        :return:
        """
        try:
            # 从token中获取payload【校验合法性,并获取payload】
            verified_payload = jwt.decode(token, SALT, ["HS256"])
            return verified_payload
        except exceptions.ExpiredSignatureError:
            print('token已失效')
        except jwt.DecodeError:
            print('token认证失败')
        except jwt.InvalidTokenError:
            print('非法的token')
    
    
    if __name__ == '__main__':
        token = "eyJ0eXAiOiJqd3QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6Ind1cGVpcWkiLCJleHAiOjE2NDc2MjMzMDR9.mC409LXIl1RZu4OX5J01hvCxWEOJcK7C4P3zKzedXdU"
        payload = get_payload(token)
        print(payload)
    
    

2.1.1 创建表和数据

  • 创建相关表结构并录入基本数据。

    from django.db import models
    
    class UserInfo(models.Model):
        username = models.CharField(verbose_name="用户名", max_length=32)
        password = models.CharField(verbose_name="密码", max_length=64)
    
    
  • 离线脚本,创建用户

    import os
    import sys
    import django
    
    base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    sys.path.append(base_dir)
    
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mtb.settings")
    django.setup()  # 启动Django
    
    from apps.base import models
    
    """
    离线创建用户脚本
    """
    
    models.UserInfo.objects.create(
        username="fumi",
        password="123"
    )
    
    

2.1.2 登录

pip install djangorestframework==3.12.4
pip install pyjwt==2.3.0

  • 编写URL

    主路由

    from django.urls import path, include
    from apps import base
    
    urlpatterns = [
        path('api/base/', include('apps.base.urls')),   # 路由分发
    ]
    
    

    子路由

    from django.urls import path
    from rest_framework import routers
    from .views import account
    
    router = routers.SimpleRouter()
    
    # 评论
    # router.register(r'comment', comment.CommentView)
    
    urlpatterns = [
        # path('register/', account.RegisterView.as_view({"post": "create"})),
        path('auth/', account.AuthView.as_view()),
    ]
    
    urlpatterns += router.urls
    
    
  • 编写视图 & 生成jwt token

    base/views/account.py

    import jwt
    import datetime
    from rest_framework.views import APIView
    from rest_framework.response import Response
    
    from ..serializers import account
    from util import return_code
    from .. import models
    from mtb import settings
    
    
    class AuthView(APIView):
        authentication_classes = []
        permission_classes = []
    
        def post(self, request, *args, **kwargs):
            # print(request.user)
            # print(request.auth)
            # 1.获取前端用户名和密码进行数据校验
            serializer = account.AuthSerializer(data=request.data)
            if not serializer.is_valid():
                return Response({'code': return_code.VALIDATE_ERROR, 'errors': serializer.errors})
    
            # 2.数据库校验
            username = serializer.validated_data.get('username')
            password = serializer.validated_data.get('password')
            user_obj = models.UserInfo.objects.filter(username=username, password=password).first()
            if not user_obj:
                return Response({'code': return_code.AUTH_FAILED, 'detail': '用户名或密码错误'})
    
            # 3.生成jwt token返回
            # 构造header
            headers = {
                'typ': 'jwt',
                'alg': 'HS256'
            }
            # 构造payload
            payload = {
                'user_id': user_obj.id,  # 自定义用户ID
                'username': user_obj.username,  # 自定义用户名
                'exp': datetime.datetime.now() + datetime.timedelta(minutes=5)  # 超时时间
            }
            token = jwt.encode(payload=payload, key=settings.SECRET_KEY, algorithm="HS256", headers=headers)
            return Response({'code': return_code.SUCCESS, 'data': {'token': token, 'username': username}})
    
    
    

    util/return_code.py

    # 成功
    SUCCESS = 0
    
    # 用户提交数据校验失败
    VALIDATE_ERROR = 1001
    
    # 认证失败
    AUTH_FAILED = 2000
    
    # 认证过期
    AUTH_OVERDUE = 2001
    
    # 无权访问
    PERMISSION_DENIED = 3000
    
    
    # 无权访问
    TOO_MANY_REQUESTS = 4000
    
    

    base/serializers/account.py

    from rest_framework import serializers
    
    
    class AuthSerializer(serializers.Serializer):
        username = serializers.CharField(label='用户名', required=True)
        password = serializers.CharField(label='密码', min_length=3, required=True)
    
    

2.1.3 校验

在请求其他页面时,对jwt token进行校验(认证组件)。

  • 测试API
  • 编写认证组件
  • 全局配置

base/urls.py

from django.urls import path
from rest_framework import routers
from .views import account

router = routers.SimpleRouter()

urlpatterns = [
    path('auth/', account.AuthView.as_view()),
    path('test/', account.TestView.as_view())
]

urlpatterns += router.urls

util/extension/auth.py (认证校验组件)

import jwt
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from jwt import exceptions

from .. import return_code
from mtb import settings


# 自定义封装类,将校验合法的verified_payload封装成对象返回
class CurrentUser(object):
    def __init__(self, user_id, username, exp):
        self.user_id = user_id
        self.username = username
        self.exp = exp


class JwtTokenAuthentication(BaseAuthentication):
    def authenticate(self, request):
        # 读取用户提交的jwt token
        # token = request.query_params.get("token")       # 这是从url中获取
        token = request.META.get('HTTP_AUTHORIZATION')  # 这是从请求头中获取    格式 Authorization: Jwt Token

        if not token:
            raise AuthenticationFailed({"code": return_code.AUTH_FAILED, "error": "无token认证失败"})
        # jwt token校验
        try:
            # 从token中获取payload【校验合法性,并获取payload】
            verified_payload = jwt.decode(token, settings.SECRET_KEY, ["HS256"])
            # print(verified_payload)       # {'user_id': 1, 'username': 'fumi', 'exp': 1661311234}
            return CurrentUser(**verified_payload), token

        except exceptions.ExpiredSignatureError:
            print('token已失效')
            raise AuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token已失效'})
        except jwt.DecodeError:
            print('token认证失败')
            raise AuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token认证失败'})
        except jwt.InvalidTokenError:
            print('非法的token')
            raise AuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token非法'})
        except Exception as e:
            raise AuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token不合法导致认证失败'})

    def authenticate_header(self, request):
        return 'Bearer realm="API"'

account.py

# 校验测试API
class TestView(APIView):
    def get(self, request, *args, **kwargs):
        print(request.user.user_id)  # 1
        print(request.user.username)  # fumi
        print(request.user.exp)  # 1661311234
        return Response('test')

settings.py

REST_FRAMEWORK = {
  
    # 认证配置
    "DEFAULT_AUTHENTICATION_CLASSES": ["util.extension.auth.JwtTokenAuthentication", ],

    # 配置上解决报错RuntimeError: Model class django.contrib.contenttypes.models.ContentType
    # doesn't declare an explicit app_label and isn't in an application in INSTALLED_APPS.
    "UNAUTHENTICATED_USER": lambda: None,
    "UNAUTHENTICATED_TOKEN": lambda: None,

  
}

2.2 前端vue

  • 打开页面:在本地的cookie中读取 username,写入到 state (便于后续页面使用)
  • 路由拦截:在本地的cookie中读取 jwt token,如果有则继续访问,没有就跳转登录页面。
  • 登录:
    • 发送请求,验证用户名密码合法性
    • 写入cookie和state
    • 跳转
  • 其他API请求(每个请求都要在请求头中 携带jwt token)

2.2.1 打开页面

浏览器打开平台页面时,自动去cookie中读取之前登录写入到cookie中的用户名和token

npm install vue-cookie

plugins/cookie.js

import Vue from 'vue'
import VueCookie from 'vue-cookie'

Vue.use(VueCookie)

export const getToken = () => {
    return Vue.cookie.get("token");
}

export const getUserName = () => {
    return Vue.cookie.get("username");
}

he plugin is available through this.$cookie in components or Vue.cookie

// From some method in one of your Vue components
this.$cookie.set('test', 'Hello world!', 1);
// This will set a cookie with the name 'test' and the value 'Hello world!' that expires in one day

// To get the value of a cookie use
this.$cookie.get('test');

// To delete a cookie use
this.$cookie.delete('test');

store/index.js

import Vue from 'vue'
import Vuex from 'vuex'

import {getToken, getUserName} from '@/plugins/cookie'

Vue.use(Vuex)


export default new Vuex.Store({
    state: {
        username: getUserName(),
        token: getToken(),
    },
    mutations: {},
    actions: {},
    modules: {}
})

main.js

import Vue from 'vue'
import App from './App.vue'
import './plugins/cookie.js'

import store from './store'
import router from './router'
import './plugins/element.js'

import HighchartsVue from 'highcharts-vue'

Vue.use(HighchartsVue)


Vue.config.productionTip = false

new Vue({
    router,
    store,
    render: h => h(App)
}).$mount('#app')

views/Layout.vue

<template>
    <div>
        <el-menu  class="el-menu-demo" mode="horizontal" :default-active=defaultActiveRouter
                  background-color="#545c64"  text-color="#fff"  active-text-color="#ffd04b" router>    <!--是否使用 vue-router 的模式,启用该模式会在激活导航时以 index 作为 path 进行路由跳转-->
                <el-menu-item >媒体宝logo</el-menu-item>
                <el-menu-item index="Task"  :route="{name:'Sblb'}">任务宝</el-menu-item>
                <el-menu-item index="Msg"  :route="{name:'Xxgl'}">消息宝</el-menu-item>      <!--当前激活菜单的 index-->
                <el-menu-item index="Auth"  :route="{name:'Sqgl'}">授权宝</el-menu-item>
            <el-submenu index="5" style="float: right">
                <template slot="title">{{username}}</template>
                <el-menu-item index="5-1">个人信息</el-menu-item>
                <el-menu-item index="5-2">注销</el-menu-item>
            </el-submenu>
        </el-menu>

        <div>
            <router-view></router-view>
        </div>

    </div>
</template>

<script>
    export default {
        name: "Layout",
        data(){
            return{
                defaultActiveRouter:"",
            }
        },
        mounted(){
           this.defaultActiveRouter = this.$route.matched[1].name;      // 拿到路由中第2个参数名即index解决默认选中和刷新选中问题
        },
        computed:{
            username(){
                return this.$store.state.username;  // 只有get属性直接简写
            }
        }
    }
</script>

<style scoped>

</style>

2.2.2 路由拦截器

如果cookie中的 token 为空,则重定向到登录页面。

router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import {getToken} from '@/plugins/cookie'

Vue.use(VueRouter)

...

router.beforeEach((to, from, next) => {
    let token = getToken();

    // 如果已登录,则可以继续访问目标地址
    if (token) {
        next();
        return;
    }
    // 未登录,访问登录页面
    if (to.name === "Login") {
        next();
        return;
    }

    // 未登录,跳转登录页面
    // next(false); 保持当前所在页面,不跳转
    next({name: 'Login'});
})

1661296077954

2.2.3 登录

npm install axios
npm install vue-axios

plugins/axios.js

import Vue from 'vue'
import axios from 'axios'
import VueAxios from 'vue-axios'

Vue.use(VueAxios, axios)

this.axios.get(
	"URL地址",
	{
		headers:{
			....
		},
		params:{
			...
		}
	}
)

this.axios.post(
	URL,
	{},
	{
		headers:{
			....
		},
		params:{
			...
		}
	}
)

main.js

import Vue from 'vue'
import App from './App.vue'

import './plugins/cookie' // 放在router和store前面,这样他们需要使用就可以导入
import './plugins/axios'

import router from './router'
import store from './store'
import './plugins/element.js'
import HighchartsVue from 'highcharts-vue'

Vue.use(HighchartsVue)

Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

views/Login.vue

     <div v-show="tabSelected===0">
                <el-form :rules="userInforules" :model="userInfo" ref="userInfo">   <!-- 点击提交表单必须是ref, rules指规则, model指存放数据-->
                    <el-form-item prop="username"  style="margin-top: 24px;" :error="userFormError.username">       <!--prop指对哪一项规则进行校验-->   <!--:error绑定错误信息为了后端错误在标签展示-->
                        <el-input placeholder="手机号或邮箱" v-model="userInfo.username"></el-input>
                    </el-form-item>
                    <el-form-item prop="password" :error="userFormError.password">
                        <el-input placeholder="密码" v-model="userInfo.password"  show-password></el-input>
                    </el-form-item>
                    <el-form-item size="large">
                        <el-button type="primary" size="small" @click="submitForm('userInfo')">登 录</el-button>  <!--提交表单将ref里的表单名传入到点击方法里-->
                    </el-form-item>
                </el-form>
            </div><el-form :model="userForm" :rules="rules" ref="userForm">
    <el-form-item prop="username" class="row-item" :error="userFormError.username">
        <el-input v-model="userForm.username" placeholder="用户名或手机号或邮箱"></el-input>
    </el-form-item>

    <el-form-item prop="password" class="row-item" :error="userFormError.password">
        <el-input placeholder="请输入密码" v-model="userForm.password" show-password></el-input>
    </el-form-item>

    <el-form-item class="row-item">
        <el-button type="primary" size="medium" @click="submitForm('userForm')">登 录</el-button>
    </el-form-item>
</el-form>

 userFormError: {
     username: "",
     password: "",
 },

 submitForm(formName){
                // 1.在执行校验规则前先清空字段错误信息
                this.clearCustomFormError();


                // 2.根据传入的表单名不同分别执行他们的验证规则
                this.$refs[formName].validate((valid)=>{
                    if (!valid){
                        // console.log('验证未通过');
                        return false
                    }
                    // console.log('验证通过');
                    // 验证通过,向后端API发送ajax请求
                    this.axios.post(' http://127.0.0.1:8000/api/base/auth/', this.userInfo).then(res=>{
                        // 登录成功
                        if (res.data.code === 0){
                            // 写入cookie, 写入state 后端返回数据{code:0, data:{username:'用户名', token:"jwt token"}}
                            this.$store.commit('login', res.data.data.username, res.data.data.token);
                            this.$router.push({path:'/'});      // 跳转到主页
                            return
                        }

                        // 1000, 字段错误, 前端在标签上进行展示
                        if (res.data.code === 1000){
                            // detail = {username:[错误1,], password:[x1,]}
                            this.validateFormFailed(res.data.detail);
                        }
                        // 1001, 整体错误, 前端整体弹窗展示
                        if (res.data.code === 1001){
                            this.$message.error(res.data.detail);
                        }else {
                            this.$message.error('请求错误');
                        }
                    })
                });
            },

            // 字段错误自定义函数
            validateFormFailed(errorData) {
                for (let fieldName in errorData) {
                    let error = errorData[fieldName][0];
                    this.userFormError[fieldName] = error;
                }
            },

            // 自定义函数
            clearCustomFormError() {
                for (let key in this.formErrorDict) {
                    this.userFormError[key] = ""
                }

            },

后端认证返回数据优化

base/views/account.py

# 登录API
class AuthView(APIView):
    authentication_classes = []
    permission_classes = []

    def post(self, request, *args, **kwargs):
        # print(request.user)
        # print(request.auth)
        # 1.获取前端用户名和密码进行数据校验
        serializer = account.AuthSerializer(data=request.data)
        if not serializer.is_valid():
            # 1000, 字段错误, 返回前端在标签上进行展示
            return Response({'code': return_code.FIELD_ERROR, 'errors': serializer.errors})

        # 2.数据库校验
        username = serializer.validated_data.get('username')
        password = serializer.validated_data.get('password')
        user_obj = models.UserInfo.objects.filter(username=username, password=password).first()
        if not user_obj:
            # 1001, 整体错误, 返回前端整体弹窗展示
            return Response({'code': return_code.VALIDATE_ERROR, 'detail': '用户名或密码错误'})

        # 3.生成jwt token返回
        # 构造header
        headers = {
            'typ': 'jwt',
            'alg': 'HS256'
        }
        # 构造payload
        payload = {
            'user_id': user_obj.id,  # 自定义用户ID
            'username': user_obj.username,  # 自定义用户名
            'exp': datetime.datetime.utcnow() + datetime.timedelta(seconds=20)  # 超时时间
        }
        token = jwt.encode(payload=payload, key=settings.SECRET_KEY, algorithm="HS256", headers=headers)
        return Response({'code': return_code.SUCCESS, 'data': {'token': token, 'username': username}})

return_code.py

# 用户提交数据校验失败(字段错误)
FIELD_ERROR = 1000

# 用户提交数据校验失败(整体用户名或密码错误)
VALIDATE_ERROR = 1001

后端API需要解决跨域问题:可以编写

1661349781333

关于跨域:

- 响应头
- 复杂请求(发送2个请求)
	- options预检
	- post请求
- 简单请求
	- post请求

https://www.cnblogs.com/wupeiqi/articles/5703697.html

后端mtb/util/middleware/cors.py

from django.utils.deprecation import MiddlewareMixin


# 为了解决前端同源策略问题,在此自定义中间件
class CorsMiddleware(MiddlewareMixin):
    def process_response(self, request, response):
        response['Access-Control-Allow-Origin'] = '*'
        response['Access-Control-Allow-Headers'] = '*'
        response['Access-Control-Allow-Method'] = '*'
        return response


settings.py

MIDDLEWARE = [
    ....
    'util.middleware.cors.CorsMiddleware',  # 为了解决前端同源策略问题,在此自定义中间件
]

BUG

mutation中的参数。

// this.$store.commit('login', res.data.data.username, res.data.data.token);  //有bug,后面的token传不过去导致mutations里的token是undefined
this.$store.commit("login", res.data.data);


import Vue from 'vue'
import Vuex from 'vuex'
import {getUserName, getToken, setUserToken} from "@/plugins/cookie"

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        username: getUserName(),
        token: getToken(),
    },
    mutations: {
        // {username, token}加括号解包,也可以直接info.username,info.token
        login: function (state, {username, token}) {
            state.username = username;
            state.token = token;
            // Vue.cookie.set("username",username);
            // Vue.cookie.set("token",token);
            setUserToken(username, token);
        }
    },
    actions: {},
    modules: {}
})

2.2.4 axios默认值和拦截器

探讨2个问题:

  • 其他的页面中是不是也会需要发送请求,发送时要携带token,怎么携带?

    • 默认值

    • 请求拦截器

      1661353707574

  • 如果token过期了怎么办?【有人主动在cookie随机设置了jwt token】

    • 响应拦截器,每次请求返回结果时,先执行的代码。

      判断返回值的内容。
      	- 状态码401,内容 code==="2000",跳转到登录界面 + 清空cookie中的数据+ state中的数据
      	- 其他,继续向后执行,正常结果的处理。
      
      
  • 设备列表 vue放入created方法

views/task/sanji/Sblb.vue

created() {
    this.axios.get("http://127.0.0.1:8000/api//base/test/").then(res => {
        console.log("请求成功", res);
    }).catch(reason => {
        console.log('请求失败', reason);
    })
}

  • 默认值,登录成功后,以后每次发请求,都应该在请求头中携带token,每次发送请求都携带吗?

  • 拦截器,如果有人伪造token向API发送了请求,跳转到登录页面(只要认证失败,跳到登录页面)。

  • 新增友好展示element ui 中局部引入Message, Message.error("认证过期,请重新登录...");

    plugins/axios.js

    import Vue from 'vue'
    import axios from 'axios'
    import VueAxios from 'vue-axios'
    import {getToken} from './cookie'
    import router from "../router/index"
    import store from "../store/index"
    import {Message} from "element-ui"
    
    Vue.use(VueAxios, axios)
    
    
    // 设置默认值
    axios.defaults.baseURL = 'http://127.0.0.1:8000/api/';      // 简写,方便访问不同的测试api接口
    axios.defaults.headers.common['Authorization'] = getToken();    // 只在页面刷新时执行
    // axios.defaults.headers.post['Content-Type'] = 'application/json';    // 跟进不同请求设置不同的请求头
    // axios.defaults.headers.put['Content-Type'] = 'application/json';
    
    
    // 请求拦截器,axios发送请求时候,每次请求
    axios.interceptors.request.use(function (config) {
        // 在发送请求之前做些什么
      const token = getToken();
        if (token) {
            // 表示用户已登录
            config.headers.common['Authorization'] = token;
        }
        return config;
    });
    
    // 响应拦截器
    axios.interceptors.response.use(function (response) {
        // API请求执行成功,响应状态码200,自动执行
        // 若返回code为2000,执行
        if (response.data.code === '2000'){
            // 1.执行store中的logout方法清空cookie和state中的token
            store.commit("logout");
    
            // 2.重定向登录页面  [Login,]
            // router.push({name:"Login"});
            router.replace({name: "Login"});    // replace比push更友好,push是直接在数组中添加login,replace直接替换
    
            // 3.页面提示
            Message.error("认证过期,请重新登录...");
            return Promise.reject('状态码200,但code为2000')
        }
        return response;
    }, function (error) {
        // API请求执行失败,响应状态码400/500,自动执行
        if (error.response.status === 401){
            // 1.执行store中的logout方法清空cookie和state中的token
            store.commit("logout");
    
            // 2.重定向登录页面  [Login,]
            // router.push({name:"Login"});
            router.replace({name: "Login"});    // replace比push更友好,push是直接在数组中添加login,replace直接替换
    
            // 3.页面提示
            Message.error("认证过期,请重新登录...");
        }
    
        return Promise.reject(error);   // 下一个相应拦截器的第二个函数
    })
    
    

    store/index.js

    import Vue from 'vue'
    import Vuex from 'vuex'
    import {getUserName, getToken} from '@/plugins/cookie'
    import {clearUserToken, setUserToken} from "../plugins/cookie";
    
    Vue.use(Vuex)
    
    export default new Vuex.Store({
      state: {
        username: getUserName(),
        token: getToken(),
      },
      mutations: {
          // {username, token}加括号解包,也可以直接info.username,info.token
        login: function (state, {username, token}) {
            // 写入state
            state.username = username;
          state.token = token;
    
            // 写入cookie,为了更好维护,自定义函数设置
            // Vue.cookie.set('username', username);
            // Vue.cookie.set('token', token);
            setUserToken(username, token);
        },
        logout: function (state) {
            state.username = "";
            state.token = "";
            clearUserToken();
        }
      },
      actions: {
      },
      modules: {
      }
    })
    
    

    plugins/cookie.js

    import Vue from 'vue'
    import VueCookie from 'vue-cookie'
    
    Vue.use(VueCookie)
    
    export const getToken = () => {
        return Vue.cookie.get("token");
    }
    
    export const getUserName = () => {
        return Vue.cookie.get("username");
    }
    
    // 自定义将token设置到cookie中并设置有效期
    export const setUserToken = (username, token) => {
        Vue.cookie.set('username', username, {expires: '7D'});
        Vue.cookie.set('token', token, {expires: '7D'});
    }
    
    
    // 删除cookie中的token等值
    export const clearUserToken = () => {
        Vue.cookie.delete('username');
        Vue.cookie.delete('token');
    }
    
    
  • 后端

    util/extension/auth.py

    import jwt
    from rest_framework.authentication import BaseAuthentication
    from rest_framework.exceptions import AuthenticationFailed
    from jwt import exceptions
    
    from .. import return_code
    from mtb import settings
    
    # 自定义认证失败状态码200,根据返回code判断
    class MtbAuthenticationFailed(AuthenticationFailed):
        status_code = 200
    
    
    class JwtTokenAuthentication(BaseAuthentication):
        def authenticate(self, request):
            # 读取用户提交的jwt token
            # token = request.query_params.get("token")       # 这是从url中获取
            token = request.META.get('HTTP_AUTHORIZATION')  # 这是从请求头中获取    格式 Authorization: Jwt Token
    
            if not token:
                # 自定义状态码200,内容{'code':2000, 'error':'无token认证失败'},前端根据code=2000判断
                # raise MtbAuthenticationFailed({"code": return_code.AUTH_FAILED, "error": "无token认证失败"})
    
                # 状态码401,内容{'code':2000, 'error':'无token认证失败'}
                raise AuthenticationFailed({"code": return_code.AUTH_FAILED, "error": "无token认证失败"})
    
            # jwt token校验
            try:
                # 从token中获取payload【校验合法性,并获取payload】
                verified_payload = jwt.decode(token, settings.SECRET_KEY, ["HS256"])
                # print(verified_payload)       # {'user_id': 1, 'username': 'fumi', 'exp': 1661311234}
                return CurrentUser(**verified_payload), token
    
            except exceptions.ExpiredSignatureError:
                print('token已失效')
                # 自定义状态码200,内容{'code':2000, 'error':'无token认证失败'},前端根据code=2000判断
                raise MtbAuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token已失效'})
            except jwt.DecodeError:
                print('token认证失败')
                raise AuthenticationFailed({'code': return_code.AUTH_FAILED, 'error': 'jwt token认证失败'})
            ....
    
    

    settings.py

    MIDDLEWARE = [
        'util.middleware.cors.CorsMiddleware',  # 为了解决前端同源策略问题,在此自定义中间件,同时解决预检返回401问题,排序第一
    
       ....
    ]
    

小结

  • jwt是什么?与传统的token有什么不同。
  • django的中间件 vs drf的认证组件
  • vue-cookie组件:读、写、删除 + 自定义导出函数(把cookie相关功能写在一起)
  • vue-router组件:路由守卫,读取本地本地cookie => 没有就返回登录界面。
  • vuex组件:state中存储用户信息,在mutation中操作:state和cookie
  • axios组件:
    • 基本发送请求
    • 默认值
    • 请求拦截器
    • 相应拦截器

注意:短信登录不在此实现,可以参考我在路飞上讲的《轻量级Bug管理平台》,里面有短信登录的逻辑和处理过程。

02 业务开发:媒体宝

image-20220322130820868

媒体宝,新媒体营销平台,让平台帮你管理微信公众号,实现对粉丝、活动、消息等管理。

  • 授权管理,打通平台与企业微信公众号的管理,只有授权后,平台才能接入微信,并自动化获取公众号的关注、取关、接收、发送消息等。
  • 任务宝,发布微信活动任务,扫码转发并邀请好友助力,做裂变任务并配备相关奖励。
  • 消息宝,基于平台实现批量的公众号消息管理,例如:消息发送、图文消息、定时任务等。

image-20220322125234528

提示:想要开发此项目,你必须准备以下东西。

  • 注册一个域名
  • 购买一台云服务器,必须有公网IP(win和linux都可以)
  • 将域名解析到此公网IP上
  • 微信开发平台:企业的营业执照,这种平台只有企业才能申请出权限,个人不开放。
  • 注册一个 已认证的公众号服务号,用于自己开发测试。

1.服务器和域名

1.1 购买云服务器

自己可以去阿里云和腾讯云购买(促销活动比较便宜)

关于系统:Linux或windows,根据自己的情况去选择。

image-20220322115945670

image-20220322120114289

1.2 设置密码

设置密码,通过远程桌面连接,例如:

服务器公网IP:124.222.193.204
服务器账户:Administrator
服务器密码:Buxuyaomima*


服务器公网IP:42.143.28.82
服务器账户:Administrator
服务器密码:zg6208848000.

image-20220322120245602

image-20220322120534063

1.3 远程登录

win

image-20220322120905627

Mac

image-20220322121034628

image-20220322121119723

image-20220322121218595

其实,这就相当于你有了另外一个电脑了,不过这个电脑具有外网IP,别人可以根据IP找到他。

1.4 Python和Django

咱们在这台服务器上安装python和django并运行起来。

image-20220322123245573

image-20220322123813487

创建项目并启动

image-20220322124413966

外网访问:

image-20220322124511307

image-20220322124610701

再次启动:

image-20220322124641296

1.4 购买域名

image-20220322121538743

1.5 解析

就是让域名和我们刚才买的服务器绑定,以后通过域名就可以找到那台服务器,不需要再使用IP了。

image-20220322121751891

image-20220322121906853

image-20220322122037913

1661446038146

解析成功后,基于域名就可以访问了。

1661446065667

2.必备物料-开放平台

2.1 注册开放平台

平台:https://open.weixin.qq.com/

文档:https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/2.0/operation/open/create.html

注册微信开发者平台并填写企业资质。

image-20220322104717562

image-20220322104753183

image-20220322105931766

2.2 资质认证

注册成功后需要进行开发者资质认证。

image-20220322111117031

image-20220322111139988

2.3 创建第三方平台

image-20220322111312684

根据提示,按照自己的公司信息去填写即可。注意:选择平台型服务商

2.4 配置第三方平台

image-20220322111513552

2.4.1 公众号集权

把公众号中的所有权限打开。

image-20220322111658051

2.4.2 开发资料(重要)

设置好集权后,继续向下拉,就可以看到开发者资料配置。

image-20220322112512411

下载校验文件(留着一会用)

image-20220322113139989

1661447460171

3.必备物料-公众号

网址:https://mp.weixin.qq.com

选择:订阅号(认证)、服务号

image-20220322142725024

4.媒体宝-环境

由于代码需要放在服务器上才能让所有的功能正常运行,所以,开发测试时也需要将代码同步到服务器。可以用的代码同步方案有三种:

  • 基于IDE的Deployment的功能实现
    image-20220323085237508
  • 基于git + 手动pull的方案
    image-20220323094007064
  • 基于git + hook + 自动git pull(模拟公司的持续集成&持续交付)
    image-20220323094126228

4.1 本地->Gitee

image-20220323100647151

  • 第一步:【本机】在自己电脑上安装git(一路next即可)

    https://git-scm.com/
    
    
  • 第二步:【gitee】在gitee上注册账号并创建仓库

    • city,vue项目
    • mtb,django项目
  • 第三步:【本机】将代码push到gitee仓库

    只需在git动作中执行一次

    git config --global user.name "武沛齐"
    git config --global user.email "wupeiqi@live.com"
    
    

    在每个仓库执行一次

    cd 项目目录
    git init 
    git remote add origin https://gitee.com/fumiadder/city.git
    
    
    cd 项目目录
    git add .
    git commit -m '提交信息'
    
    
    git push origin master
    
    

    image-20220323165920413
    image-20220323165936564

4.2 Gitee的Hook

image-20220323094126228

在gitee的项目中可以设置hook,当你向项目提交代码时,他可以自动向某个地址发送请求,表示有新代码提交了。

image-20220323170327711

image-20220324092011122

4.3 部署脚本

image-20220323094126228

在服务器上基于Flask开发一个持续集成的程序,监听gitee的提交,只要提交,就在服务器上去更新项目代码。基于git去gitee拉最新代码:

在hook脚本中体现若不存在项目目录,用clone下载全部代码

cd 项目上级目录
git clone git项目的网址
git clone https://gitee.com/fumiadder/mtb.git

在hook脚本中体现若存在项目目录,用pull下载部分代码

cd 项目目录
git pull origin master

所以需要提前:

  • 在服务器上安装git

    https://registry.npmmirror.com/binary.html?path=git-for-windows/v2.37.2.windows.2/
    
    
  • 在服务器上安装python + flask

    pip3 install flask -i http://pypi.douban.com/simple --trusted-host pypi.douban.com
    
    

基于Flask编写的部署脚本:

import os
import subprocess
from flask import Flask, request

app = Flask(__name__)


@app.route("/hook", methods=["POST"])
def hook():
    repo_dict = {
        "city": {
            "folder": r"C:\code",
            "install": "npm install"
        },
        "mtb": {
            "folder": r"C:\code",
            "install": "pip3.9 install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt --user"
        }
    }
    # 项目名称:mtb  city
    repo_name = request.json['project']['path']
    # 放在服务器的那个目录?
    local_info = repo_dict.get(repo_name)
    if not local_info:
        return "error"
	# 项目的父级
    parent_folder_path = local_info['folder']
    install_command = local_info['install']
	
    # 项目的目录
    project_file_path = os.path.join(parent_folder_path, repo_name)
    
    # git仓库地址 https://gitee.com/wupeiqi/mtb.git
    git_http_url = request.json['project']['git_http_url']
    
    # 项目目录是否存在
    if not os.path.exists(project_file_path):
        # cd c:\code
        # git clone https://gitee.com/wupeiqi/mtb.git
        subprocess.check_call('git clone {}'.format(git_http_url), shell=True, cwd=parent_folder_path)
    else:
        # cd c:\code\mtb
        # git pull origin master
        subprocess.check_call('git pull origin master', shell=True, cwd=project_file_path)

    # 安装依赖包
    # cd c:\code\mtb
    # pip3.9 install -r requirements.txt
    subprocess.check_call(install_command, shell=True, cwd=project_file_path)
    return "success"


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=9000, debug=True)


请求确保已完成的步骤:

  • gitee上设置了webhook

  • 服务器安全组加上了9000端口

  • 服务器上已安装git

  • 服务器上已安装python + node.js

  • 服务器上的python已安装flask框架

    1661449311357

4.4 数据库

image-20220323094007064

image-20220323094007064

由于我们程序组要运行在服务器上,所以MySQL必须安在服务器上,才能正常运行,所以,接下来你需要做两步:

  • 服务器上安装MySQL & 创建用户 & 授权 & 创建数据库mtb

    create database mtb DEFAULT CHARSET utf8 COLLATE utf8_general_ci;
    
    create user mtb@'%' identified by 'root123';
    
    grant all privileges on mtb.* to 'mtb'@'%';
    
    flush privileges;
    
    
    
  • mtb项目连接数据库配置修改 & 重新初始化数据

    # 若服务器中mtb项目中没有local_settings,记得添加,这样才能连上数据库
    
    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.mysql',
            'NAME': 'mtb',
            'USER': 'mtb',
            'PASSWORD': 'root123',
            'HOST': '43.143.28.82',
            'PORT': 3306,
        }
    }
    
    
    
    python manage.py makemigrations
    python manage.py migrate	# 因为本地还有migrations,所以直接migrate,连上的服务器数据库会自动创建相应表,以后若push时未将migrations上传,就得重新执行makemigrations
    
    
    

注意:在服务器的安全组中一定要打开3306端口。

4.5 运行&开发

image-20220323094007064

环境准备好后,接下来就需要让程序运行起来并测试,后续开发只需要做到一下几点:

  • 在服务器上开启部署的Hook脚本。

  • 在服务器上运行起来django程序

    • 修改ALLOWED_HOSTS = []

      ALLOWED_HOSTS = [*]
      
      
      
    • 手动在服务器上runserver启动django(修改文件自动重启)

      python manage.py runserver 0.0.0.0:8000
      
      
      
    • 在腾讯云开启8000端口

  • 在服务器上运行vue程序

    • 修改axios发送请求的地址

      127.0.0.1:xxxx  改为   124.222.193.204:8000
      
      
      
    • 启动vue程序

      npm run serve -- --port 80
      
      

后续开发时,编写 + git提交。

小结

以后本地只做代码的编写,想要测试看效果,就需要将代码提交到gitee,由gitee和服务器hook脚本进行代码更新,这样就可以看到同步开发效果。

image-20220323094126228

本质是:搞了一个线上开发机。

他与公司线上服务器的正式环境不同点有:

  • 线上服务器Linux操作系统 vs windows操作系统
  • 线上代码Nginx+uwsgi运行项目 vs 项目用django、npm自带的功能运行(性能差)
  • 线上持续集成&交付用的jekins vs 用自己写的hook脚本

5.媒体宝-授权

image-20220325191245522

授权,让公众号的商户管理员扫码,授予我们平台来管理公众号。

功能的角度来看,我们的程序需要为用户提交一个二维码页面,公众号管理员扫码授权,之后如果公众再收到消息、关注、取消关注都会向我们指定的URL发送请求,从而实现管理公众号。

从技术上,如果想要实现此功能,需要好几个步骤(主要用于去微信获取凭证、token等过程),划分为三类:

  • 校验
  • 授权 + 令牌
  • 消息回调

校验的过程:

  • 校验,在开发平台设置时需要处理
    image-20220326102114098

授权+令牌的过程:

  • component_verify_ticket

    在第三方平台创建审核通过后,微信服务器每隔10分钟会向我们指定的接口推送一次 component_verify_ticket。
    
    component_verify_ticket有效期为12h
    
    用于获取第三方平台接口调用凭据 component_access_token 。
    
    

    image-20220326093947439

  • component_access_token

    component_access_token有效期2h.
    
    当遭遇异常没有及时收到component_verify_ticket时,建议以上一次可用的component_verify_ticket继续生成component_access_token。避免出现因为 component_verify_ticket 接收失败而无法更新 component_access_token 的情况。
    
    
  • pre_auth_code

    基于component_access_token生成pre_auth_code
    用于生成扫码授权二维码或者链接需要的pre_auth_code
    
    根据pre_auth_code -> 浏览器跳转
    
    
  • 根据pre_auth_code生成URL,跳转到微信页面去扫码授权

    生成的URL中需要传入一个redirect_uri的参数(一个地址),用户扫码成功后会自动向此地址发请求。
    
    
  • authorization_code

    扫码授权成功后,微信向redirect_uri发送请求时,会携带 authorization_code + 过期时间。
    
    注意:authorization_code是扫码后微信给我我们的。
    
    
  • authorizer_access_token【调用令牌】

    获取授权码authorization_code后,可根据authorization_code去获取authorizer_access_token,例如:
    {
      "authorization_info": {
        "authorizer_appid": "wxf8b4f85f3a794e77",
        "authorizer_access_token": "QXjUqNqfYVH0yBE1iI_",
        "expires_in": 7200,
        "authorizer_refresh_token": "dTo-YCXPL4llX-u1W1pPpnp8Hgm4wpJtlR6iV0doKdY",
        "func_info":[...]
        }
    }
    
    authorizer_access_token 非常重要,后续获取公众号信息都会用到。
    
    注意:
    authorizer_access_token的有效期2小时(有限额需缓存),在过期后用authorizer_refresh_token重新去获取。
    
    

消息回调的过程:

image-20220326103154456

5.1 校验

这个其实不需要写任何代码来实现,后续再项目部署时,只需要在nginx中配置URL和内容返回即可。

正式在Linux项目部署时:

image-20220326122440793

开发机服务器:

image-20220326122220781

所以,在开发机搞了一个mysite。

from django.urls import path, include
from apps.base.views import wx

urlpatterns = [
    path('api/base/', include('apps.base.urls')),
    path('<str:filename>.txt', wx.file_verify),
]

from django.http import FileResponse


def file_verify(request, filename):
    file = open('4631992212.txt', 'rb')
    response = FileResponse(file)
    response['Content-Type'] = 'application/octet-stream'
    response['Content-Disposition'] = 'attachment;filename="%s.txt"' % filename
    return response

image-20220326133839562

image-20220326133859035

在根目录下创建文件vue.config.js,然后填入如下内容

// 免除域名校验,支持域名解析到ip访问

module.exports = {
    devServer: {
        disableHostCheck: true,
    }
}


5.2 授权+令牌

5.2.1 component_verify_ticket

在第三方平台创建审核通过后,微信服务器每隔10分钟会向我们指定的接口推送一次 component_verify_ticket(开启ticket推送)且有效期component_verify_ticket有效期为12h。

接收 POST 请求后,只需直接返回字符串 success。为了加强安全性,postdata 中的 xml 将使用服务申请时的加解密 key 来进行加密,具体请见《加密解密技术方案》, 在收到推送后需进行解密(详细请见《消息加解密接入指引》)。


image-20220326140954934

我们需要做以下几个步骤:

  • 编写 /auth/ 的地址接收推送消息(加密)
  • 对推送消息解密获取 component_verify_ticket(SDK-py2 + key)
  • 将 component_verify_ticket 持久化(写入 redis 或 数据库 )

官方文档:https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/2.0/api/ThirdParty/token/component_verify_ticket.html

image-20220830033514795

实现步骤:

  • 编写URL mtb/urls.py

    from django.urls import path, include
    from apps.base.views import wx
    
    urlpatterns = [
        path('api/base/', include('apps.base.urls')),
        path('<str:filename>.txt', wx.file_verify),
        path('auth/', wx.component_verify_ticket),
    ]
    
    
    
  • 编写配置 mtb/local_settings.py

    WX_TOKEN = "c32b4c29-ac7e-4ebc-b7f4-6125159bbf11"
    WX_KEY = "a4537fb5112343a48bb9af33e8074d636125159bbf1"
    WX_APP_ID = "wx89d0d065c7b25a06"
    WX_APP_SECRET = "e359cf673dae224e976d75d00dbec0a6"
    
    
    

    image-20220326215640678

    image-20220326220008527

  • 编写视图apps/base/views/wx.py

    注意:依赖微信的SDK,但是他支持py2,不支持py3(需要手动修改)
    pip install pycryptodome==3.14.1
    
    
    
    import time
    import xml.etree.cElementTree as ET
    
    from django.http import FileResponse, HttpResponse
    from django.conf import settings
    from utils.wx2.WXBizMsgCrypt import WXBizMsgCrypt
    from .. import models
    
    
    def file_verify(request, filename):
        """ 文件校验 """
        file = open('4631992212.txt', 'rb')
        response = FileResponse(file)
        response['Content-Type'] = 'application/octet-stream'
        response['Content-Disposition'] = 'attachment;filename="%s.txt"' % filename
        return response
    
    
    def component_verify_ticket(request):
        """ 微信每隔10分钟以POST请求发送一次 """
        if request.method != "POST":
            return HttpResponse('error')
        # 获取请求体数据
        body = request.body.decode("utf-8")
    
        # 获取URL中的数据
        nonce = request.GET.get("nonce")
        timestamp = request.GET.get('timestamp')
        msg_sign = request.GET.get('msg_signature')
    
        # 解密
        decrypt_test = WXBizMsgCrypt(settings.WX_TOKEN, settings.WX_KEY, settings.WX_APP_ID)
        code, decrypt_xml = decrypt_test.DecryptMsg(body, msg_sign, timestamp, nonce)
    
        # code=0时 解密成功
        if code != 0:
            return HttpResponse('error')
    
        # 解析decrypt_xml,格式如下
        """
        <xml>
            <AppId><![CDATA[wx89d0d065c7b25a06]]></AppId>
            <CreateTime>1648305909</CreateTime>
            <InfoType><![CDATA[component_verify_ticket]]></InfoType>
            <ComponentVerifyTicket><![CDATA[ticket@@@fAovP2Qo9vbcdJ_O6sw6r2APV2jTQZJkeV73OBnazo6rTDhC85I8ywcY_wqXhthC5AFRNHg_aNuiAl7xljFf-w]]></ComponentVerifyTicket>
        </xml>
        """
        xml_tree = ET.fromstring(decrypt_xml)
        # 没获取节点
        verify_ticket = xml_tree.find("ComponentVerifyTicket")
        if verify_ticket is None:
            return HttpResponse('error')
        # 节点为空
        verify_ticket_text = verify_ticket.text
        if not verify_ticket_text:
            return HttpResponse('error')
    
        # 写入数据库(过期时间12h)
        period_time = int(time.time()) + 12 * 60
        models.WxCode.objects.update_or_create(defaults={"value": verify_ticket_text, "period": period_time}, code_type=1)
        return HttpResponse('success')
    
    
    

接下来,就要等待了,10分钟后会有请求发到服务器。

image-20220326222920109

image-20220326231856773

5.2.2 显示二维码

点点击添加公众号后:

先根据 component_verify_ticket 生成 component_access_token【有效期2小时】
再根据 component_access_token  生成 pre_auth_code【有效期10分钟】
最后用 pre_auth_code 拼接一个URL,返回给前端
前端页面,获取URL再进行跳转。


image-20220327001401320

image-20220327011703167

1.vue页面
<el-card class="box-card flex-row-center" shadow="hover" >
    <div class="flex-col-center">
        <i @click="toAuthorization()" class="el-icon-circle-plus-outline icon"></i>
        <div class="text">添加公众号</div>
    </div>
</el-card>

<script>
    export default {
        name: 'Auth',
        created: function () {

        },
        methods: {
            toAuthorization() {
                // 发送请求获取 微信二维码URL的页面,跳转过去
                this.axios.get("/base/wxurl/").then(res => {
                    if (res.data.code === 0) {
                        window.location.href = res.data.data.url;
                    } else {
                        this.$message.error("请求失败");
                    }
                })
            }
        }
    }
</script>

2.drf接口
  • 依赖包

    pip install requests
    pip freeze > requirements.txt
    
    注意:建议提交一次并push一次。
    
    
  • 数据库字段

    from django.db import models
    
    
    class WxCode(models.Model):
        """ 微信授权相关的码 """
        code_type_choices = (
            (1, "component_verify_ticket"),
            (2, "component_access_token"),
            (3, "pre_auth_code"),
        )
        code_type = models.IntegerField(verbose_name="类型", choices=code_type_choices)
    
        # value = models.CharField(verbose_name="值", max_length=128)
        value = models.CharField(verbose_name="值", max_length=255)
        period = models.PositiveIntegerField(verbose_name="过期时间")
    
    
    class UserInfo(models.Model):
        username = models.CharField(verbose_name="用户名", max_length=32)
        password = models.CharField(verbose_name="密码", max_length=64)
    
    
    
    
  • apps/base/urls.py

    urlpatterns = [
        path('auth/', account.AuthView.as_view()),
        path('test/', account.TestView.as_view()),
        # http://mtb.pythonav.com/api/base/wxurl/
        path('wxurl/', wx.WxUrlView.as_view()),
        path('wxcallback/', wx.WxCallBackView.as_view(), name='wx_callback'),
    ]
    
    
    
  • apps/base/views/wx.py

    import time
    import xml.etree.cElementTree as ET
    
    import requests
    
    from django.conf import settings
    from django.urls import reverse
    from django.http import FileResponse, HttpResponse
    
    from rest_framework.views import APIView
    from rest_framework.response import Response
    from utils.wx2.WXBizMsgCrypt import WXBizMsgCrypt
    from utils import return_code
    from .. import models
    
    
    def file_verify(request, filename):
        """ 文件校验 """
        file = open('4631992212.txt', 'rb')
        response = FileResponse(file)
        response['Content-Type'] = 'application/octet-stream'
        response['Content-Disposition'] = 'attachment;filename="%s.txt"' % filename
        return response
    
    
    def component_verify_ticket(request):
        """ 微信每隔10分钟以POST请求发送一次 """
        if request.method != "POST":
            return HttpResponse('error')
        # 获取请求体数据
        body = request.body.decode("utf-8")
    
        # 获取URL中的数据
        nonce = request.GET.get("nonce")
        timestamp = request.GET.get('timestamp')
        msg_sign = request.GET.get('msg_signature')
    
        # 解密 pip install pycryptodome
        decrypt_test = WXBizMsgCrypt(settings.WX_TOKEN, settings.WX_KEY, settings.WX_APP_ID)
        code, decrypt_xml = decrypt_test.DecryptMsg(body, msg_sign, timestamp, nonce)
    
        # code=0时 解密成功
        if code != 0:
            return HttpResponse('error')
    
        # 解析decrypt_xml,格式如下
        """
        <xml>
            <AppId><![CDATA[wx89d0d065c7b25a06]]></AppId>
            <CreateTime>1648305909</CreateTime>
            <InfoType><![CDATA[component_verify_ticket]]></InfoType>
            <ComponentVerifyTicket><![CDATA[ticket@@@fAovP2Qo9vbcdJ_O6sw6r2APV2jTQZJkeV73OBnazo6rTDhC85I8ywcY_wqXhthC5AFRNHg_aNuiAl7xljFf-w]]></ComponentVerifyTicket>
        </xml>
        """
        xml_tree = ET.fromstring(decrypt_xml)
        # 没获取节点
        verify_ticket = xml_tree.find("ComponentVerifyTicket")
        if verify_ticket is None:
            return HttpResponse('error')
        # 节点为空
        verify_ticket_text = verify_ticket.text
        if not verify_ticket_text:
            return HttpResponse('error')
    
        # 写入数据库(过期时间12h)
        period_time = int(time.time()) + 12 * 60 * 60
        # 不存在,写;存在,更新。
        models.WxCode.objects.update_or_create(defaults={"value": verify_ticket_text, "period": period_time}, code_type=1)
        return HttpResponse('success')
    
    
    class WxUrlView(APIView):
    
        def create_component_access_token(self):
            """ 根据 component_verify_ticket 生成新的component_access_token并写入数据库"""
            verify_ticket_object = models.WxCode.objects.filter(code_type=1).first()
            res = requests.post(
                url="https://api.weixin.qq.com/cgi-bin/component/api_component_token",
                json={
                    "component_appid": settings.WX_APP_ID,
                    "component_appsecret": settings.WX_APP_SECRET,
                    "component_verify_ticket": verify_ticket_object.value
                }
            )
            data_dict = res.json()
            print(data_dict)
            access_token = data_dict["component_access_token"]
            period_time = int(data_dict["expires_in"]) + int(time.time())
            models.WxCode.objects.update_or_create(defaults={"value": access_token, "period": period_time}, code_type=2)
            return access_token
    
        def create_pre_auth_code(self, access_token):
            # 生成预授权码
            res = requests.post(
                url="https://api.weixin.qq.com/cgi-bin/component/api_create_preauthcode",
                params={
                    "component_access_token": access_token
                },
                json={
                    "component_appid": settings.WX_APP_ID
                }
            )
    
            data_dict = res.json()
            pre_auth_code = data_dict["pre_auth_code"]
            period_time = int(data_dict["expires_in"]) + int(time.time())
            models.WxCode.objects.update_or_create(defaults={"value": pre_auth_code, "period": period_time}, code_type=3)
            return pre_auth_code
    
        def create_qr_code_url(self, pre_auth_code):
            redirect_uri = "{}{}".format("http://mtb.pythonav.com", reverse("wx_callback"))
            auth_url = "https://mp.weixin.qq.com/cgi-bin/componentloginpage?component_appid={}&pre_auth_code={}&redirect_uri={}&auth_type=1"
            target_url = auth_url.format(settings.WX_APP_ID, pre_auth_code, redirect_uri)
            return target_url
    
        def get(self, request, *args, **kwargs):
            """ 生成URL并返回,用户跳转到微信扫码授权页面 """
    
            # 去数据库获取预授权码(10分钟有效期)
            pre_auth_code_object = models.WxCode.objects.filter(code_type=3).first()
            pre_exp_time = pre_auth_code_object.period if pre_auth_code_object else 0
            # 预授权码还在有效期,直接生成URL返回
            if int(time.time()) < pre_exp_time:
                # redirect_uri = "http://mtb.pythonav.com/api/base/wxcallback/"
                url = self.create_qr_code_url(pre_auth_code_object.value)
                return Response({"code": return_code.SUCCESS, "data": {"url": url}})
    
            # 根据 component_verify_ticket 获取 component_access_token(有效期2小时)
            access_token_object = models.WxCode.objects.filter(code_type=2).first()
            expiration_time = access_token_object.period if access_token_object else 0
            if int(time.time()) >= expiration_time:
                # 已过期或没有
                access_token = self.create_component_access_token()
            else:
                # 未过期
                access_token = access_token_object.value
    
            # 根据 component_access_token 生成预pre_auth_code授权码(10分钟有效期)
            pre_auth_code = self.create_pre_auth_code(access_token)
            url = self.create_qr_code_url(pre_auth_code)
            return Response({"code": return_code.SUCCESS, "data": {"url": url}})
    
    
    class WxCallBackView(APIView):
        def get(self, request, *args, **kwargs):
            auth_code = request.GET.get("auth_code")
            expires_in = request.GET.get("expires_in")
            print(auth_code, expires_in)
            return Response(auth_code)
    
    
    

5.2.3 扫码授权

image-20220830033614395

1.登录公众平台【测试】

登录微信公众平台,获取自己公众号或服务号的原始ID

https://mp.weixin.qq.com/cgi-bin/loginpage

image-20220328091405190

2.登录开放平台【测试】

登录开放平台,填写测试公众号或服务号原始ID。

image-20220328090902742

3.扫码授权
4.获取authorization_code

在我们的回调地址中可以获取到authorization_code(需要解决登录状态)。

5.获取authorizer_access_token
  • authorization_code获取 authorizer_access_tokenauthorizer_refresh_token

    res = requests.post(
        url="https://api.weixin.qq.com/cgi-bin/component/api_query_auth",
        params={
            "component_access_token": access_token
        },
        json={
            "component_appid": settings.WX_APP_ID,  # 固定的APP_ID
            "authorization_code": auth_code
        }
    )
    result = res.json()
    authorizer_appid = result['authorization_info']['authorizer_appid']
    authorizer_access_token = result['authorization_info']['authorizer_access_token']
    authorizer_refresh_token = result['authorization_info']['authorizer_refresh_token']
    authorizer_period = int(result['authorization_info']['expires_in']) + int(time.time())
    
    
    
    文档:https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/2.0/api/ThirdParty/token/authorization_info.html
    
    
    
  • 获取公众号信息

    res = requests.post(
        url="https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_info",
        params={
            "component_access_token": access_token
        },
        json={
            "component_appid": settings.WX_APP_ID,
            "authorizer_appid": authorizer_appid
        }
    ).json()
    
    nick_name = res["authorizer_info"]["nick_name"]
    user_name = res["authorizer_info"]["user_name"]  # 原始ID
    avatar = res["authorizer_info"]["head_img"]
    service_type_info = res["authorizer_info"]["service_type_info"]["id"]
    verify_type_info = res["authorizer_info"]["verify_type_info"]["id"]
    
    
    # 文档:https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/2.0/api/ThirdParty/token/api_get_authorizer_info.html
    
    
    
  • 保存

    class PublicNumbers(models.Model):
        authorizer_app_id = models.CharField(verbose_name="授权ID", max_length=64)
        authorizer_access_token = models.CharField(verbose_name="授权token", max_length=255)
        authorizer_refresh_token = models.CharField(verbose_name="授权更新token", max_length=64)
        authorizer_period = models.PositiveIntegerField(verbose_name="过期时间")
    
        nick_name = models.CharField(verbose_name="公众号名称", max_length=32)
        user_name = models.CharField(verbose_name="公众号原始ID", max_length=64)
        avatar = models.CharField(verbose_name="公众号头像", max_length=128)
        service_type_info = models.IntegerField(
            verbose_name="公众号类型",
            choices=(
                (0, "订阅号"),
                (1, "由历史老帐号升级后的订阅号"),
                (2, "服务号")
            )
        )
        verify_type_info = models.IntegerField(
            verbose_name="认证类型",
            choices=(
                (-1, "未认证"),
                (0, "微信认证"),
                (1, "新浪微博认证"),
                (2, "腾讯微博认证"),
                (3, "已资质认证通过但还未通过名称认证"),
                (4, "已资质认证通过、还未通过名称认证,但通过了新浪微博认证"),
                (5, "已资质认证通过、还未通过名称认证,但通过了腾讯微博认证"),)
        )
    
        mtb_user = models.ForeignKey(verbose_name="媒体宝用户", to="UserInfo", on_delete=models.CASCADE)
    
    
    
    
            models.PublicNumbers.objects.update_or_create(
                defaults={
                    "authorizer_app_id": authorizer_appid,
                    "authorizer_access_token": authorizer_access_token,
                    "authorizer_refresh_token": authorizer_refresh_token,
                    "authorizer_period": authorizer_period,  # 2小时
                    "nick_name": nick_name,
                    "avatar": avatar,
                    "service_type_info": service_type_info,
                    "verify_type_info": verify_type_info,
                },
                mtb_user_id=request.user.user_id,
                user_name=user_name
            )
    
    
    

5.2.4 展示 授权公众号

image-20220328152716113

{
    "code": 0,
    "data": [
        {
            "id": 1,
            "nick_name": "公众号名称...",
            "avatar": "http://wx.qlogo.cn/mmop...ia/0",
            "service_type_info_text": "服务号",
            "verify_type_info_text": "未认证"
        }
    ]
}


1.vue页面

加载页面时发送请求当前用户已授权的公众号信息。

<el-card v-for="item in dataList" :key="item.id" class="box-card box-item" shadow="hover" :body-style="{width:'100%',padding:'20px'}">
    <div class="item flex-row-center">
        <el-avatar size="large" :src="item.avatar"></el-avatar>
    </div>
    <div class="item flex-row-center">{{item.nick_name}}</div>
    <div class="item flex-row-center">
        <div class="flex-row-between" style="width: 100px;font-size: 12px;">
            <div style="color: gray">{{item.service_type_info_text}}</div>
            <div style="color: #0c8eff;">{{item.verify_type_info_text}}</div>
        </div>
    </div>
    <el-divider></el-divider>
    <div class="item small flex-row-between">
        <div><i class="el-icon-position"></i> 任务包</div>
        <div class="date">永久</div>
    </div>
    <div class="item small flex-row-between">
        <div><i class="el-icon-bell"></i> 消息宝</div>
        <div class="date">永久</div>
    </div>
</el-card>
<script>
    export default {
        name: 'Auth',
        data() {
            return {
                dataList: []
            }
        },
        created: function () {
            this.initDataList();
        },
        methods: {
            toAuthorization() {
                // 发送请求获取 微信二维码URL的页面,跳转过去
                // http://mtb.pythonav.com/api/base/wxurl/
                this.axios.get("/base/wxurl/").then(res => {
                    // {code:0,data:{url:"微信跳转URL"}}
                    if (res.data.code === 0) {
                        window.location.href = res.data.data.url;
                    } else {
                        this.$message.error("请求失败");
                    }
                })
            },
            initDataList() {
                this.axios.get("/base/public/").then(res => {
                    // {code:0,data:[]}
                    if (res.data.code === 0) {
                        this.dataList = res.data.data;
                    } else {
                        this.$message.error("请求失败");
                    }
                })
            }
        }
    }
</script>


2.drf接口

代码太多,详细请参考源码

5.2.5 取消授权

如果用户在公众平台中取消了授权,我们的平台怎么知道呢?

image-20220328161103407

其实当用户取消授权后,会自动执行我们之前在开放平台定义的(就是没10分钟执行一次的请求)。

image-20220328161305942

<xml>
    <AppId><![CDATA[wx89d0d065c7b25a06]]></AppId>
    <CreateTime>1648305909</CreateTime>
    <InfoType><![CDATA[component_verify_ticket]]></InfoType>
    <ComponentVerifyTicket><![CDATA[ticket@@@fAovP2Q8ywcY_wqXhthC5AFRNHg_aNuiAl7xljFf-w]]></ComponentVerifyTicket>
</xml>


<xml>
	<AppId><![CDATA[wx89d0d065c7b25a06]]></AppId>
	<CreateTime>1648455334</CreateTime>
	<InfoType><![CDATA[unauthorized]]></InfoType>
	<AuthorizerAppid><![CDATA[wx75cd30b4c2693497]]></AuthorizerAppid>
</xml>


5.2.6 获取数据

当授权公众号之后,我们平台就可以接管公众号,接收数据。

image-20220328164629925

from django.urls import path, include
from apps.base.views import wx

urlpatterns = [
    path('api/base/', include('apps.base.urls')),

    path('<str:filename>.txt', wx.file_verify),  # 微信调用
    path('auth/', wx.component_verify_ticket),  # 微信调用
    path('<str:authorizer_app_id>/callback', wx.event_callback),  # 微信调用
]


def event_callback(request, authorizer_app_id):
    """ 公众号的消息与事件接收配置 """
    if request.method != "POST":
        return HttpResponse('error')
    # 获取请求体数据
    body = request.body.decode("utf-8")

    # 获取URL中的数据
    nonce = request.GET.get("nonce")
    timestamp = request.GET.get('timestamp')
    msg_sign = request.GET.get('msg_signature')

    # 解密 pip install pycryptodome
    decrypt_test = WXBizMsgCrypt(settings.WX_TOKEN, settings.WX_KEY, settings.WX_APP_ID)
    code, decrypt_xml = decrypt_test.DecryptMsg(body, msg_sign, timestamp, nonce)

    # code=0时 解密成功
    if code != 0:
        return HttpResponse('error')
    print('--------')
    print(authorizer_app_id, decrypt_xml)
    return HttpResponse("success")


<xml>
	<ToUserName><![CDATA[gh_f2e17c23c9ca]]></ToUserName>
    <FromUserName><![CDATA[oko631YbM3Mq-0tewUUVH1rOAAJY]]></FromUserName>
    <CreateTime>1648457984</CreateTime>
    <MsgType><![CDATA[text]]></MsgType>
    <Content><![CDATA[武沛齐]]></Content>
    <MsgId>23600239816888806</MsgId>
</xml>


<xml>
 	<ToUserName><![CDATA[gh_f2e17c23c9ca]]></ToUserName>
	<FromUserName><![CDATA[oko631YbM3Mq-0tewUUVH1rOAAJY]]></FromUserName>
	<CreateTime>1648458242</CreateTime>
	<MsgType><![CDATA[image]]></MsgType>
	<PicUrl><![CDATA[http://mmbiz.qpic.cn/mmbiz_jpg/1cPbuONfU6P3WuLnOAVg/0]]></PicUrl>
	<MsgId>23600244842458443</MsgId>
	<MediaId><![CDATA[RanDds2osD-3NBMlFC5NRQCG1b9jKmeYhujUleqe]]></MediaId>
</xml>


<xml>
	<ToUserName><![CDATA[gh_f2e17c23c9ca]]></ToUserName>
	<FromUserName><![CDATA[oko631YbM3Mq-0tewUUVH1rOAAJY]]></FromUserName>
	<CreateTime>1648458550</CreateTime>
	<MsgType><![CDATA[event]]></MsgType>
	<Event><![CDATA[VIEW]]></Event>
	<EventKey><![CDATA[https://m.ke.qq.com/agencyHome.html?_bid=1rom=wechat]]></EventKey>
	<MenuId>454021062</MenuId>
</xml>


<xml>
    <ToUserName><![CDATA[gh_f2e17c23c9ca]]></ToUserName>
    <FromUserName><![CDATA[oko631YbM3Mq-0tewUUVH1rOAAJY]]></FromUserName>
    <CreateTime>1648458077</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[subscribe]]></Event>
    <EventKey><![CDATA[]]></EventKey>
</xml>


<xml>
    <ToUserName><![CDATA[gh_f2e17c23c9ca]]></ToUserName>
    <FromUserName><![CDATA[oko631YbM3Mq-0tewUUVH1rOAAJY]]></FromUserName>
    <CreateTime>1648458011</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[unsubscribe]]></Event>
    <EventKey><![CDATA[]]></EventKey>
</xml>


6.媒体宝-消息宝

消息宝的核心功能就是代替公众号向粉丝发送消息,微信为公众号向粉丝发送消息提供了两类:

  • 模板消息

    https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Template_Message_Interface.html
    
    
  • 客服消息,只有48小时内有互动的才能发送客服消息。

    https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html#%E5%AE%A2%E6%9C%8D%E6%8E%A5%E5%8F%A3-%E5%8F%91%E6%B6%88%E6%81%AF
    

6.1 模板消息

在公众平台上可以选择添加消息模板。

https://mp.weixin.qq.com/advanced/tmplmsg?action=list&t=tmplmsg/list&token=180795866&lang=zh_CN

image-20220329230717984

image-20220329230550008

image-20220329230615367

调用微信提供的接口去发送模板消息:

http请求方式: POST https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN

{
    "touser":"OPENID",
    "template_id":"ngqIpbwh8bUfcSsECmogfXcV14J0tQlEpBO27izEYtY",      
    "data":{
        "first": {
            "value":"恭喜你购买成功!",
            "color":"#173177"
        },
        "keyword1":{
            "value":"巧克力",
            "color":"#173177"
        },
        "keyword2": {
            "value":"39.8元",
            "color":"#173177"
        },
        "keyword3": {
            "value":"2014年9月22日",
            "color":"#173177"
        },
        "remark":{
            "value":"欢迎再次购买!",
            "color":"#173177"
        }
    }
}

6.1.1 获取所有模板

https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Template_Message_Interface.html

获取已添加至帐号下所有模板列表,可在微信公众平台后台中查看模板列表信息。为方便第三方开发者,提供通过接口调用的方式来获取帐号下所有模板信息,具体如下:

接口调用请求说明

http请求方式:GET https://api.weixin.qq.com/cgi-bin/template/get_all_private_template?access_token=ACCESS_TOKEN

参数说明

参数 是否必须 说明
access_token 接口调用凭证

返回说明

正确调用后的返回示例:

{	
     "template_list": [{
      "template_id": "iPk5sOIt5X_flOVKn5GrTFpncEYTojx6ddbt8WYoV5s",
      "title": "领取奖金提醒",
      "primary_industry": "IT科技",
      "deputy_industry": "互联网|电子商务",
      "content": "{ {result.DATA} }\n\n领奖金额:{ {withdrawMoney.DATA} }\n领奖  时间:    { {withdrawTime.DATA} }\n银行信息:{ {cardInfo.DATA} }\n到账时间:  { {arrivedTime.DATA} }\n{ {remark.DATA} }",
      "example": "您已提交领奖申请\n\n领奖金额:xxxx元\n领奖时间:2013-10-10 12:22:22\n银行信息:xx银行(尾号xxxx)\n到账时间:预计xxxxxxx\n\n预计将于xxxx到达您的银行卡"
   }]
}

6.1.2 获取粉丝OpenID

https://developers.weixin.qq.com/doc/offiaccount/User_Management/Getting_a_User_List.html

公众号可通过本接口来获取帐号的关注者列表,关注者列表由一串OpenID(加密后的微信号,每个用户对每个公众号的OpenID是唯一的)组成。一次拉取调用最多拉取10000个关注者的OpenID,可以通过多次拉取的方式来满足需求。

接口调用请求说明

http请求方式: GET(请使用https协议)
https://api.weixin.qq.com/cgi-bin/user/get?access_token=ACCESS_TOKEN&next_openid=NEXT_OPENID

参数 是否必须 说明
access_token 调用接口凭证
next_openid 第一个拉取的OPENID,不填默认从头开始拉取

返回说明

正确时返回JSON数据包:

{
    "total":2,
    "count":2,
    "data":{
    	"openid":["OPENID1","OPENID2"]
    },
    "next_openid":"NEXT_OPENID"
}

6.1.3 发送消息

调用微信提供的接口去发送模板消息:

http请求方式: POST https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN

{
    "touser":"OPENID",
    "template_id":"ngqIpbwh8bUfcSsECmogfXcV14J0tQlEpBO27izEYtY",      
    "data":{
        "first": {
            "value":"恭喜你购买成功!",
            "color":"#173177"
        },
        "keyword1":{
            "value":"巧克力",
            "color":"#173177"
        },
        "keyword2": {
            "value":"39.8元",
            "color":"#173177"
        },
        "keyword3": {
            "value":"2014年9月22日",
            "color":"#173177"
        },
        "remark":{
            "value":"欢迎再次购买!",
            "color":"#173177"
        }
    }
}

6.2 客服消息

https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html

当用户给公众号发送消息时,可以将其信息记录下来,48小时以内都可以向此人推送客服消息。

image-20220330001246891

6.2.1 互动表

image-20220330012434076

6.2.2 发送消息

接口调用请求说明

http请求方式: POST https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=ACCESS_TOKEN

各消息类型所需的JSON数据包如下:

发送文本消息

{
    "touser":"OPENID",
    "msgtype":"text",
    "text":
    {
         "content":"Hello World"
    }
}

发送图片消息(图片上传到微信,返回MEDIA_ID)

{
    "touser":"OPENID",
    "msgtype":"image",
    "image":
    {
      "media_id":"MEDIA_ID"
    }
}


6.3 互动信息更新

6.3.1 表结构

在msg的app中创建表结构。

from django.db import models


class Interaction(models.Model):
    """ 互动表,用于记录最近48小时内有互动的人 """
    authorizer_app_id = models.CharField(verbose_name="公众号授权ID", max_length=64)
    user_open_id = models.CharField(max_length=64, verbose_name="互动粉丝ID")
    end_date = models.PositiveIntegerField(verbose_name="互动截止时间戳", help_text="互动时间+48小时")



6.3.2 触发更新

import importlib
event_list = [
    "apps.msg.event.handler",
    # "apps.task.event.handler",
]
for path in event_list:
    module_path, func_name = path.rsplit(".", maxsplit=1)
    module = importlib.import_module(module_path)
    func = getattr(module, func_name)
    func(authorizer_app_id, decrypt_xml)

apps.msg.event.py

import time
import xml.etree.cElementTree as ET
from . import models


def handler(authorizer_app_id, decrypt_xml):
    """ 接收消息 """
    xml_tree = ET.fromstring(decrypt_xml)
    msg_type = xml_tree.find("MsgType").text

    # 更新互动表
    if msg_type in {"text", "image", "voice", "video"}:
        from_user_open_id = xml_tree.find("FromUserName").text
        models.Interaction.objects.update_or_create(
            defaults={"end_date": int(time.time()) + 48 * 60 * 60},
            authorizer_app_id=authorizer_app_id,
            user_open_id=from_user_open_id,
        )

6.4 发送消息

image-20220330021724058

6.4.1 表结构

image-20220330023638960

6.4.2 vue页面(客服消息)

image-20220330201753134

6.4.3 drf接口(客服消息)

  • 接收请求
  • 表单验证
  • 如果是图片,就上传到素材库获取media_id
  • 执行celery任务,去执行消息发送(创建任务生成任务ID) - 暂时先不做
  • 写入数据库(待执行)

6.4.4 Celery

Celery是由Python开发的一个简单、灵活、可靠的处理大量任务的分发系统。

image-20220330215711343

  • 在服务器上安装redis并启动(记得腾讯云设置安全组6379端口)

    参考文档:https://pythonav.com/wiki/detail/10/82/
    
    
    
  • 安装python操作redis模块

    pip install redis
    
    
    
  • 安装celery(基于Python开发)

    参考文档:https://docs.celeryq.dev/en/stable/index.html
    
    
    
    pip install celery
    
    
    
    如果是windows请在安装一个eventlet
    pip install eventlet
    
    
    

6.4.5 Celery(客服消息)

当准备工作都安装成功后,接下来就需要在django项目中配置,配置成功后才能运行。配置步骤如下:
image-20220330224113798

image-20220330224138156

image-20220330224605078

image-20220330235802389

image-20220330232416404

image-20220330234729121

celery -A  mtb worker -l info -P eventlet


6.4.6 vue页面(模板消息)

image-20220331091957215

6.4.7 drf接口(模板消息)

  • 接口请求
  • 数据校验
  • 全部粉丝 or 48小时互动粉丝
  • 模板消息发送(celery)+ 状态更新
template_list = 
[
    
    {
      "template_id": "iPk5sOIt5X_flOVKn5GrTFpncEYTojx6ddbt8WYoV5s",
      "title": "领取奖金提醒",
      "primary_industry": "IT科技",
      "deputy_industry": "互联网|电子商务",
      "content": "{ {result.DATA} }\n\n领奖金额:{ {withdrawMoney.DATA} }\n领奖  时间:    { {withdrawTime.DATA} }\n银行信息:{ {cardInfo.DATA} }\n到账时间:  { {arrivedTime.DATA} }\n{ {remark.DATA} }",
      "example": "您已提交领奖申请\n\n领奖金额:xxxx元\n领奖时间:2013-10-10 12:22:22\n银行信息:xx银行(尾号xxxx)\n到账时间:预计xxxxxxx\n\n预计将于xxxx到达您的银行卡",
        "item_dict":{"result":"", "withdrawMoney":"", ....}
	},
        {
      "template_id": "iPk5sOIt5X_flOVKn5GrTFpncEYTojx6ddbt8WYoV5s",
      "title": "领取奖金提醒",
      "primary_industry": "IT科技",
      "deputy_industry": "互联网|电子商务",
      "content": "{ {result.DATA} }\n\n领奖金额:{ {withdrawMoney.DATA} }\n领奖  时间:    { {withdrawTime.DATA} }\n银行信息:{ {cardInfo.DATA} }\n到账时间:  { {arrivedTime.DATA} }\n{ {remark.DATA} }",
      "example": "您已提交领奖申请\n\n领奖金额:xxxx元\n领奖时间:2013-10-10 12:22:22\n银行信息:xx银行(尾号xxxx)\n到账时间:预计xxxxxxx\n\n预计将于xxxx到达您的银行卡",
             "item_dict":{"result":"", "withdrawMoney":"", ....}
	}
]


{
    "iPk5sOIt5X_flOVKn5GrTFpncEYTojx6ddbt8WYoV5s":{
      "template_id": "iPk5sOIt5X_flOVKn5GrTFpncEYTojx6ddbt8WYoV5s",
      "title": "领取奖金提醒",
      "primary_industry": "IT科技",
      "deputy_industry": "互联网|电子商务",
      "content": "{ {result.DATA} }\n\n领奖金额:{ {withdrawMoney.DATA} }\n领奖  时间:    { {withdrawTime.DATA} }\n银行信息:{ {cardInfo.DATA} }\n到账时间:  { {arrivedTime.DATA} }\n{ {remark.DATA} }",
      "example": "您已提交领奖申请\n\n领奖金额:xxxx元\n领奖时间:2013-10-10 12:22:22\n银行信息:xx银行(尾号xxxx)\n到账时间:预计xxxxxxx\n\n预计将于xxxx到达您的银行卡",
        "item_dict":{"result":"", "withdrawMoney":"", ....}
	},
    "iPk5sOIt5X_flOVKn5GrTFpncEYTojx6ddbt8WYoV5s":{
      "template_id": "iPk5sOIt5X_flOVKn5GrTFpncEYTojx6ddbt8WYoV5s",
      "title": "领取奖金提醒",
      "primary_industry": "IT科技",
      "deputy_industry": "互联网|电子商务",
      "content": "{ {result.DATA} }\n\n领奖金额:{ {withdrawMoney.DATA} }\n领奖  时间:    { {withdrawTime.DATA} }\n银行信息:{ {cardInfo.DATA} }\n到账时间:  { {arrivedTime.DATA} }\n{ {remark.DATA} }",
      "example": "您已提交领奖申请\n\n领奖金额:xxxx元\n领奖时间:2013-10-10 12:22:22\n银行信息:xx银行(尾号xxxx)\n到账时间:预计xxxxxxx\n\n预计将于xxxx到达您的银行卡",
        "item_dict":{"result":"", "withdrawMoney":"", ....}
	},
    
}


6.4.8 消息页面

image-20220331122300055

6.5 SOP

SOP的核心功能是就是实现定时消息的发送(发送模板消息)。

image-20220331145542336

小结

至此,消息宝的功能完成了。

7.媒体宝-任务宝

任务包的核心功能:

  • 媒体宝用户,创建活动并与绑定公众号。
  • 粉丝向公众号发送指定关键词,公众号自动回复:活动介绍、生成海报连接。
  • 粉丝点击连接自动生成海报(含头像、二维码),粉丝发朋友圈或群发等找好友助力。
  • 其他人看到朋友圈,扫描海报上的二维码,自动跳转到公众号关注页面,只要关注,助力成功。
  • 助力的人数越多,参与活动的人就可以获得更多的奖励。

7.1 表结构

from django.db import models


class Activity(models.Model):
    """活动表"""
    mtb_user = models.ForeignKey("base.UserInfo", verbose_name="创建人", on_delete=models.CASCADE)
    name = models.CharField(max_length=16, verbose_name="活动名称")
    start_time = models.DateTimeField(verbose_name="活动开始时间", )
    end_time = models.DateTimeField(verbose_name="活动结束时间", )

    # 开启拉新保护后, 只算扫第一个人的
    protect_switch = models.SmallIntegerField(verbose_name="拉新保护", choices=((1, "开"), (2, "关"),), default=1)


class PublicJoinActivity(models.Model):
    """ 参与获得公众号 """
    public = models.ForeignKey("base.PublicNumbers", verbose_name="公众号", on_delete=models.CASCADE)
    activity = models.ForeignKey(Activity, verbose_name="活动", on_delete=models.CASCADE, related_name="publics")


class Award(models.Model):
    """ 活动奖励表 """
    activity = models.ForeignKey(Activity, verbose_name="参与活动", on_delete=models.CASCADE, related_name="awards")
    level = models.IntegerField(verbose_name="任务等级", choices=(
        (1, "一阶任务"),
        (2, "二阶任务"),
        (3, "三阶任务"),
    ), default=1)
    number = models.IntegerField(verbose_name="任务数量", default=0)
    good = models.CharField(verbose_name="奖品", max_length=255)


class PosterSetting(models.Model):
    """海报配置表"""
    activity = models.OneToOneField(Activity, verbose_name="活动", on_delete=models.CASCADE, related_name="poster")
    background_img = models.CharField(verbose_name="背景图片", max_length=128)

    key_word = models.CharField(verbose_name="海报生成关键字", max_length=10)
    rule = models.CharField(verbose_name="活动规则描述", max_length=256)


class TakePartIn(models.Model):
    """ 参与活动 """
    activity = models.ForeignKey(Activity, verbose_name="活动", on_delete=models.CASCADE)
    public_number = models.ForeignKey("base.PublicNumbers", verbose_name="公众号", on_delete=models.CASCADE)

    open_id = models.CharField(verbose_name="粉丝OpenID", max_length=64)

    nick_name = models.CharField(verbose_name="粉丝昵称", max_length=64, null=True, blank=True)
    avatar = models.FileField(verbose_name="粉丝头像", null=True, blank=True)

    origin = models.IntegerField(verbose_name="粉丝来源", choices=(
        (0, "其他粉丝"),
        (1, "推广码"),
        (2, "其他")
    ), default=2)

    looking = models.IntegerField(verbose_name="关注状态", choices=(
        (0, "关注中"),
        (1, "已取关"),
    ), default=0)

    part_in = models.IntegerField(verbose_name="参与活动", choices=(
        (0, "参与"),
        (1, "不参与"),  # 助力别人
    ), default=0)

    poster = models.CharField(verbose_name="专属拉新海报", max_length=128, null=True,blank=True)

    black = models.IntegerField(verbose_name="黑名单开关", choices=(
        (0, "未加入黑名单"),
        (1, "加入黑名单"),  # 加入黑名单的人不能参与
    ), default=0)

    level = models.IntegerField(verbose_name="裂变层级", default=1)
    origin_open_id = models.CharField(verbose_name="来源ID", max_length=64, null=True)  # 是推广码的ID, 或者是用户的ID

    number = models.IntegerField(verbose_name="邀请人数", default=0)
    task_progress = models.IntegerField(verbose_name="任务完成度", choices=(
        (0, "参与"),
        (1, "完成任务一"),
        (2, "完成任务二"),
        (3, "完成任务三"),
    ), default=0)
    ctime = models.DateTimeField(verbose_name="参与时间", auto_now_add=True)
    subscribe_time = models.DateTimeField(verbose_name="关注时间")

7.2 创建活动

image-20220401083904157

image-20220401083928612

image-20220401083938409

image-20220401083948414

  • 海报背景上传

  • 提交活动

    # 1.接收数据
    # 2.分别校验 活动表/公众号/奖励/海报
    # 3.在数据库新增
    

7.3 活动列表

  • 数据的展示
  • 数据的筛选

7.4 参与活动

  • 创建一个活动
  • 粉丝发送数据 & 微信转发给我们
  • 判断输入的内容是否是指定的关键字,如果是则
    • 回复活动规则。(客服消息)
    • 回复链接(图文消息)。

接口调用请求说明

https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html#7

http请求方式: POST https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=ACCESS_TOKEN

各消息类型所需的JSON数据包如下:

{
    "touser":"OPENID",
    "msgtype":"news",
    "news":{
        "articles": [
         {
             "title":"Happy Day",
             "description":"Is Really A Happy Day",
             "url":"URL",
             "picurl":"PIC_URL"
         }
         ]
    }
}

发送图文消息(点击跳转到外链) 图文消息条数限制在1条以内,注意,如果图文数超过1,则将会返回错误码45008。

requests.post(
    url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
    params={"access_token": access_token},
    data=json.dumps({
        "touser": user_open_id,
        "msgtype": "news",
        "news": {
            "articles": [
                {
                    "title": "点击链接生成我的专属海报",
                    "description": "邀请好友助力即可领取奖励",
                    "url": "http://mtb.pythonav.com/task/get_t...",
                    "picurl": ""
                }
            ]
        }
    }, ensure_ascii=False).encode('utf-8')
)

7.5 参与活动&生成海报

  • 参与活动时,创建参与活动表(无昵称、无头像)

  • 构造跳转的连接

    # 发送图文消息(用于引导用户授权并获取昵称和头像)
    url = "https://open.weixin.qq.com/connect/oauth2/authorize?appid={}&redirect_uri={}&response_type=code&scope=snsapi_userinfo&state={}&component_appid={}#wechat_redirect"
    auth_url = url.format(authorizer_app_id, quote_plus("http://mtb.pythonav.com/api/task/oauth"), 11111,
                          settings.WX_APP_ID)
    
    requests.post(
        url="https://api.weixin.qq.com/cgi-bin/message/custom/send",
        params={"access_token": access_token},
        data=json.dumps({
            "touser": user_open_id,
            "msgtype": "news",
            "news": {
                "articles": [
                    {
                        "title": "点击链接生成我的专属海报",
                        "description": "邀请好友助力即可领取奖励",
                        "url": auth_url,
                        "picurl": ""
                    }
                ]
            }
        }, ensure_ascii=False).encode('utf-8')
    )
    

    https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html#0

    https://open.weixin.qq.com/connect/oauth2/authorize?appid=wxf0e81c3bee622d60&redirect_uri=http%3A%2F%2Fnba.bluewebgame.com%2Foauth_response.php&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect
    
    "https://open.weixin.qq.com/connect/oauth2/authorize?appid={}&redirect_uri=http%3A%2F%2Fmtb.pythonav.com%2Ftask%2Ftemp%2F&response_type=code&scope=snsapi_userinfo&state={}&component_appid=wx89d0d065c7b25a06#wechat_redirect"
    

    image-20220401184134890

  • 授权成功后,微信会直接跳转回我们指定的redirect_uri + 携带参数

    {
    	'code': '001vjeGa165zUC02xrFa1uxx1p0vjeG6', 
    	'state': '111', 
    	'appid': 'wx71bf291c758aaabf'
    }
    
  • 根据微信携带的参数获取头像和昵称

  • 写生成海报

  • 写入数据库

7.6 助力

image-20220830034221576

生成海报之后,就可以发朋友圈,让我的朋友开始帮我助力了。

  • 扫码

  • 关注公众号

    <xml>
        <ToUserName><![CDATA[gh_0f3fe2860e8f]]></ToUserName>
        <FromUserName><![CDATA[oXEWb6Q-9-IbljWfq32a9RL8t3Sc]]></FromUserName>
        <CreateTime>1648828935</CreateTime>
        <MsgType><![CDATA[event]]></MsgType>
        <Event><![CDATA[subscribe]]></Event>
        <EventKey><![CDATA[qrscene_1]]></EventKey>
        <Ticket><![CDATA[gQGT7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluMXkAAgRkIEdiAwQAjScA]]></Ticket>
    </xml>
    
    
    
     <xml>
         <ToUserName><![CDATA[gh_0f3fe2860e8f]]></ToUserName>
        <FromUserName><![CDATA[oXEWb6Q-9-IbljWfq32a9RL8t3Sc]]></FromUserName>
        <CreateTime>1648829021</CreateTime>
        <MsgType><![CDATA[event]]></MsgType>
        <Event><![CDATA[SCAN]]></Event>
        <EventKey><![CDATA[1]]></EventKey>
        <Ticket><![CDATA[gQGT7zwAAAAAAAAAeGluLnFxLmNvbKS2h5MXkAAgRkIEdiAwQAjScA]]></Ticket>
    </xml>
    
    
    <xml>
         <ToUserName><![CDATA[gh_0f3fe2860e8f]]></ToUserName>
        <FromUserName><![CDATA[oXEWb6Q-9-IbljWfq32a9RL8t3Sc]]></FromUserName>
        <CreateTime>1648830102</CreateTime>
        <MsgType><![CDATA[event]]></MsgType>
        <Event><![CDATA[subscribe]]></Event>
        <EventKey><![CDATA[qrscene_1_1]]></EventKey>
        <Ticket><![CDATA[gQEh7zwAAAAAAAAAAS5odHR5Y2wAAgRNJkdiAwQAjScA]]></Ticket>
    </xml>
    
    

7.7 取关

如果用户取关。

image-20220402085317124

<xml>
    <ToUserName><![CDATA[gh_0f3fe2860e8f]]></ToUserName>
	<FromUserName><![CDATA[oXEWb6Q-9-IbljWfq32a9RL8t3Sc]]></FromUserName>
	<CreateTime>1648860835</CreateTime>
	<MsgType><![CDATA[event]]></MsgType>
	<Event><![CDATA[unsubscribe]]></Event>
	<EventKey><![CDATA[]]></EventKey>
</xml>

7.8 其他问题

  • 粉丝参与
  • 活动时间

7.9 推广码

如果我们有一些合作渠道,他们可以帮我们去引流,例如:搞一个二维码放到他们网站上,直接扫码关注我们公众号,那么就需要给他生成推广码。

class Promo(models.Model):
    """渠道表"""
    name = models.CharField(verbose_name="渠道名称", max_length=16)
    qr = models.CharField(verbose_name="二维码", max_length=128,null=True,blank=True)
    public = models.ForeignKey("base.PublicNumbers", verbose_name="公众号", on_delete=models.CASCADE)
    mtb_user = models.ForeignKey("base.UserInfo", verbose_name="创建人", on_delete=models.CASCADE)

7.9.1 新建推广码

image-20220402103544631

  • 打开页面,加载公众号列表。
  • 提交表单 -> 后端API -> 生成二维码(media) -> 放在数据库地址
  • 后期页面展示就是拿到二维码

7.9.2 推广码列表

image-20220402121500579

功能:

  • 搜索 & 筛选
  • 分页
  • 数据展示

7.9.3 推广码删除

image-20220402133953344

7.9.4 推广码编辑

image-20220402135147087

7.9.5 推广码来源-统计

如果用户通过推广码进行关注公众号,需要进行记录。

<xml>
    <ToUserName><![CDATA[gh_0f3fe2860e8f]]></ToUserName>
    <FromUserName><![CDATA[oXEWb6Q-9-IbljWfq32a9RL8t3Sc]]></FromUserName>
    <CreateTime>1648881233</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[subscribe]]></Event>
    <EventKey><![CDATA[qrscene_2_7]]></EventKey>
    <Ticket><![CDATA[gQHS8DwAAAAAAAckYxMDAwMHcwN3UAAgQQxUdiAwQAAAAA]]></Ticket>
</xml>

7.10 参与粉丝

其实就是对参与活动和推广码过的人员进行筛选。

image-20220402181141282

7.11 数据统计

image-20220403144119241

总结

此项目的本质是结合微信开发平台实现新媒体的运营平台(微信裂变)。

媒体宝,新媒体营销平台,让平台帮你管理微信公众号,实现对粉丝、活动、消息等管理。

  • 授权管理,打通平台与企业微信公众号的管理,只有授权后,平台才能接入微信,并自动化获取公众号的关注、取关、接收、发送消息等。
  • 任务宝,发布微信活动任务,扫码转发并邀请好友助力,做裂变任务并配备相关奖励。
  • 消息宝,基于平台实现批量的公众号消息管理,例如:消息发送、图文消息、定时任务等。

共享信息

  • 代码

    https://gitee.com/wupeiqi/mtb
    https://gitee.com/wupeiqi/city
    
    
  • 开发机(服务器)

    服务器公网IP:124.222.193.204
    服务器账户:Administrator
    服务器密码:Buxuyaomima*
    
    
posted @ 2022-09-27 22:19  凫弥  阅读(138)  评论(0编辑  收藏  举报