Django项目QQ登录后端接口实现

QQ登录,亦即我们所说的第三方登录,是指用户可以不在本项目中输入密码,而直接通过第三方的验证,成功登录本项目。

1.准备工作的步骤:

QQ登录网站开发流程准备工作:
1.打开下列腾讯开放平台网址,注册申请appid和appkey。
http://wiki.connect.qq.com/准备工作_oauth2-0

2.下载“QQ登录”按钮图片,并将按钮放置在页面合适的位置,并为“QQ登录”按钮添加前台代码。详见:
http://wiki.connect.qq.com/放置qq登录按钮_oauth2-0
3.理解QQ登录的流程

2.正式开始QQ登录开发流程:

  • 创建模型类,用于存放网址用户和用户的QQ获取到的openid的绑定关系表。
from django.db import modelsclass OAuthQQUser(models.Model):"""QQ登录用户数据"""create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")user = models.ForeignKey('users.User', on_delete=models.CASCADE, verbose_name='用户')   # 此处的users为项目的一个子应用,User为项目的用户模型类openid = models.CharField(max_length=64, verbose_name='openid', db_index=True)class Meta:db_table = 'tb_oauth_qq'verbose_name = 'QQ登录用户数据'verbose_name_plural = verbose_name

执行项目的数据库迁移,在项目启动文件manage.py的同级目录下进行数据库迁移操作。

python manage.py makemigrations
python manage.py migrate`
  • urllib使用说明
    在后端接口中,我们需要向QQ服务器发送请求,查询用户的QQ信息,Python提供了标准模块urllib可以帮助我们发送http请求。

  • urllib.parse.urlencode(query)

将query字典转换为url路径中的查询字符串

  • urllib.parse.parse_qs(qs)

将qs查询字符串格式数据转换为python的字典

  • urllib.request.urlopen(url, data=None)

发送http请求,如果data为None,发送GET请求,如果data不为None,发送POST请求

返回response响应对象,可以通过read()读取响应体数据,需要注意读取出的响应体数据为bytes类型

2.1第一步:

客户端点击QQ登录按钮时向后端发起请求,服务器返回给客户端一个QQ登陆的网址。即

1.后端接口设计

  • 请求方式: GET /oauth/qq/authorization/?next=xxx

  • 请求参数: 查询字符串
    参数名:next
    类型:str
    是否必须:否
    说明:用户QQ登录成功后进入美多商城的哪个网址
    返回数据:json
    返回数据示例:

{"login_url": "https://graph.qq.com/oauth2.0/show?which=Login&display=pc&response_type=code&client_id=101474184&redirect_uri=http%3A%2F%2Fwww.meiduo.site%3A8080%2Foauth_callback.html&state=%2F&scope=get_user_info"
}

返回值:login_ur
类型:str
是否必须:是
说明:qq登录网址

  • 1.1.在配置文件settings中添加关于QQ登录的应用开发信息
QQ_CLIENT_ID = '101474184'    # appid
QQ_CLIENT_SECRET = 'c6ce949e04e12ecc909ae6a8b09b637c'    # appkey
QQ_REDIRECT_URI = 'http://www.meiduo.site:8080/oauth_callback.html'   # 成功授权后的回调地址,必须是注册appid时填写的主域名下的地址,建议设置为网站首页或网站的用户中心。
QQ_STATE = '/'  # 用户登陆成功以后需要跳转的当前开发的项目的某个指定的地址,这里默认首页
  • 1.2创建一个QQ登录的子应用,终端cd到manage.py目录下,例如oauth应用。
python manag.py startapp oauth
  • 1.3 settings文件中注册子应用
INSTALLED_APPS = (...'oauth.apps.OauthConfig',...
)
  • 1.4 新建oauth/utils.py文件,创建QQ登录辅助工具类
from urllib.parse import urlencode, parse_qs
from urllib.request import urlopen
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer, BadData
from django.conf import settings
import json
import loggingfrom . import constantslogger = logging.getLogger('django')    # django先前配置了相关日志名称和对应的等级class OAuthQQ(object):"""QQ认证辅助工具类"""def __init__(self, client_id=None, client_secret=None, redirect_uri=None, state=None):self.client_id = client_id or settings.QQ_CLIENT_IDself.client_secret = client_secret or settings.QQ_CLIENT_SECRETself.redirect_uri = redirect_uri or settings.QQ_REDIRECT_URIself.state = state or settings.QQ_STATE  # 用于保存登录成功后的跳转页面路径def get_qq_login_url(self):"""获取qq登录的网址:return: url网址"""url = 'https://graph.qq.com/oauth2.0/authorize?'  # qq开发对外提供的地址加问号用于拼接查询参数params = {'response_type': 'code','client_id': self.client_id,'redirect_uri': self.redirect_uri,'state': self.state,'scope': 'get_user_info',}url += urlencode(params) return url
  • 1.5 在oauth/views.py中实现视图
#  url(r'^qq/authorization/$', views.QQAuthURLView.as_view()),
class QQAuthURLView(APIView):"""获取QQ登录的url"""def get(self, request):"""提供用于qq登录的url"""# 获取查询参数next即登录成功后跳转的网址next = request.query_params.get('next')# 传入下一跳地址,创建OauthQQ的实例对象oauth = OAuthQQ(state=next)# 获取QQ登录网址并json格式返回login_url = oauth.get_qq_login_url()return Response({'login_url': login_url})

2.2 第二步:QQ登录回调处理

用户在QQ登录成功后,QQ会将用户重定向回我们配置的回调callback网址,例如,我们申请QQ登录开发资质时配置的回调地址为:

http://www.XXXX.site:8080/oauth_callback.html

在QQ将用户重定向到此网页的时候,重定向的网址会携带QQ提供的code参数,用于获取用户信息使用,我们需要将这个code参数发送给后端,在后端中使用code参数向QQ请求用户的身份信息,并查询与该QQ用户绑定的用户。

2.2.1后端接口设计

请求方式 :

GET /oauth/qq/user/?code=xxx

请求参数: 查询字符串参数

  • 参数:code
  • 类型:str
  • 是否必传:是
  • 说明:qq返回的授权凭证code
  • 返回数据: JSON
{"access_token": xxxx,
}

{"token": "xxx","username": "python","user_id": 1
}

返回值

  • 在OAuthQQ辅助类中添加方法:

import json
import refrom django.conf import settings
import urllib.parse
import urllib.request
import loggingfrom itsdangerous import BadData
from itsdangerous import TimedJSONWebSignatureSerializer as TJWSSerializerfrom oauth.constants import BIND_USER_ACCESS_TOKEN_EXPIRES
from oauth.exceptions import OaAuthQQAPIErrorlogger = logging.getLogger("django")class OauthQQ(object):"""QQ认证辅助工具类"""def __init__(self, client_id=None, client_secret=None, redirect_uri=None, state=None):self.client_id = client_id if client_id else settings.QQ_CLIENT_IDself.redirect_uri = redirect_uri if redirect_uri else settings.QQ_REDIRECT_URI# self.state = state if state else settings.QQ_STATEself.state = state or settings.QQ_STATE   # 两种写法self.client_secret = client_secret if client_secret else settings.QQ_CLIENT_SECRETdef get_login_url(self):"""获取登录地址"""url = "https://graph.qq.com/oauth2.0/authorize?"params = {'response_type': 'code','client_id': self.client_id,'redirect_uri': self.redirect_uri,'state': self.state}url += urllib.parse.urlencode(params)return urldef get_access_token(self, code):"""获取access_token"""url = 'https://graph.qq.com/oauth2.0/token?'params = {"grant_type": "authorization_code","client_id": self.client_id,"client_secret": self.client_secret,"code": code,"redirect_uri": self.redirect_uri}url += urllib.parse.urlencode(params)# 发送请求try:resp = urllib.request.urlopen(url)# 读取响应体数据resp_data = resp.read()# 将得到的bytes类型转为strresp_str = resp_data.decode()# access_token=FE04****CCE2&expires_in=7776000&refresh_token=88E4*****BE14# 解析access_tokenresp_dict = urllib.parse.parse_qs(resp_str)except Exception as e:logger.error("获取access_token异常:%s" % e)raise OaAuthQQAPIErrorelse:access_token = resp_dict.get('access_token')# print(access_token)return access_token[0]def get_openid(self, access_token):"""根据access_token获取qq用户的openid"""url = 'https://graph.qq.com/oauth2.0/me?access_token=' + access_tokentry:# 发送请求resp = urllib.request.urlopen(url)# 读取响应数据resp_data = resp.read()resp_str = resp_data.decode()# callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );# 解析openidresp_dict_str = re.match(r'.*(\{.*\}).*', resp_str).group(1)   # 匹配出字典resp_dict = json.loads(resp_dict_str)   # str转换为字典格式except Exception as e:logger.error("获取openid异常:%s" % e)raise OaAuthQQAPIErrorelse:openid = resp_dict.get("openid")return openid@staticmethoddef generate_user_bind_access_token(openid):"""生成用户绑定令牌token:param openid: 从qq获取到的openid:return:"""# serializer = Serializer(秘钥, 有效期秒)serializer = TJWSSerializer(settings.SECRET_KEY, BIND_USER_ACCESS_TOKEN_EXPIRES)# serializer.dumps(数据), 返回bytes类型token = serializer.dumps({"openid": openid})token = token.decode()return token

在oauth/views.py中实现视图

from rest_framework import status
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_jwt.settings import api_settingsfrom oauth.exceptions import OaAuthQQAPIError
from oauth.models import OauthQQUser
from oauth.serializers import OAuthQQUserSerializer
from .utils import OauthQQ#  url(r'^qq/authorization/$', views.QQAuthURLView.as_view()),
class QQAuthURLView(APIView):"""获取QQ登录的url    ?next=xxxxxx"""def get(self, request):# 获取next参数next = request.query_params.get("next")# 拼接qq登录的url的路径oauth_qq = OauthQQ(state=next)login_url = oauth_qq.get_login_url()# 返回urlreturn Response({'login_url': login_url})class QQAuthUserView(CreateAPIView):"""通过QQ按钮登录的视图"""serializer_class = OAuthQQUserSerializerdef get(self, request):# 获取codecode = request.query_params.get('code')if not code:return Response({"meaasge": "缺少code"}, status=status.HTTP_400_BAD_REQUEST)oauth_qq = OauthQQ()try:# 根据code,获取access_tokenaccess_token = oauth_qq.get_access_token(code)# 根据access_token 获取openidopenid = oauth_qq.get_openid(access_token)except OaAuthQQAPIError:return Response({"message": "访问QQ接口异常"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)# 根据openid查询数据库,判断用户是否存在try:oauth_qq_user = OauthQQUser.objects.get(openid=openid)except OauthQQUser.DoesNotExist:# 如果数据不存在,处理openid(加密)并返回new_access_token = oauth_qq.generate_user_bind_access_token(openid)return Response({"access_token": new_access_token})else:# 如果数据存在,表示用户已经绑定过身份,签发JWT_token# 签发jwt tokenjwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLERjwt_encode_handler = api_settings.JWT_ENCODE_HANDLERuser = oauth_qq_user.userpayload = jwt_payload_handler(user)token = jwt_encode_handler(payload)return Response({"username": user.username,"user_id": user.id,"token": token})

2.3绑定用户身份接口

如果用户是首次使用QQ登录,则需要绑定用户
业务逻辑:

  • 用户需要填写手机号、密码、图片验证码、短信验证码
  • 如果用户未注册过,则会将手机号作为用户名为用户创建一个账户,并绑定用户
  • 如果用户已在注册过,则检验密码后直接绑定用户
  • 绑定QQ身份的处理流程

2.3.1后端接口设计


在OAuthQQ辅助类中增加

import json
import refrom django.conf import settings
import urllib.parse
import urllib.request
import loggingfrom itsdangerous import BadData
from itsdangerous import TimedJSONWebSignatureSerializer as TJWSSerializerfrom oauth.constants import BIND_USER_ACCESS_TOKEN_EXPIRES
from oauth.exceptions import OaAuthQQAPIErrorlogger = logging.getLogger("django")class OauthQQ(object):"""QQ认证辅助工具类"""def __init__(self, client_id=None, client_secret=None, redirect_uri=None, state=None):self.client_id = client_id if client_id else settings.QQ_CLIENT_IDself.redirect_uri = redirect_uri if redirect_uri else settings.QQ_REDIRECT_URI# self.state = state if state else settings.QQ_STATEself.state = state or settings.QQ_STATE   # 两种写法self.client_secret = client_secret if client_secret else settings.QQ_CLIENT_SECRETdef get_login_url(self):"""获取登录地址"""url = "https://graph.qq.com/oauth2.0/authorize?"params = {'response_type': 'code','client_id': self.client_id,'redirect_uri': self.redirect_uri,'state': self.state}url += urllib.parse.urlencode(params)return urldef get_access_token(self, code):"""获取access_token"""url = 'https://graph.qq.com/oauth2.0/token?'params = {"grant_type": "authorization_code","client_id": self.client_id,"client_secret": self.client_secret,"code": code,"redirect_uri": self.redirect_uri}url += urllib.parse.urlencode(params)# 发送请求try:resp = urllib.request.urlopen(url)# 读取响应体数据resp_data = resp.read()# 将得到的bytes类型转为strresp_str = resp_data.decode()# access_token=FE04****CCE2&expires_in=7776000&refresh_token=88E4*****BE14# 解析access_tokenresp_dict = urllib.parse.parse_qs(resp_str)except Exception as e:logger.error("获取access_token异常:%s" % e)raise OaAuthQQAPIErrorelse:access_token = resp_dict.get('access_token')# print(access_token)return access_token[0]def get_openid(self, access_token):"""根据access_token获取qq用户的openid"""url = 'https://graph.qq.com/oauth2.0/me?access_token=' + access_tokentry:# 发送请求resp = urllib.request.urlopen(url)# 读取响应数据resp_data = resp.read()resp_str = resp_data.decode()# callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );# 解析openidresp_dict_str = re.match(r'.*(\{.*\}).*', resp_str).group(1)   # 匹配出字典resp_dict = json.loads(resp_dict_str)   # str转换为字典格式except Exception as e:logger.error("获取openid异常:%s" % e)raise OaAuthQQAPIErrorelse:openid = resp_dict.get("openid")return openid@staticmethoddef generate_user_bind_access_token(openid):"""生成用户绑定令牌token:param openid: 从qq获取到的openid:return:"""# serializer = Serializer(秘钥, 有效期秒)serializer = TJWSSerializer(settings.SECRET_KEY, BIND_USER_ACCESS_TOKEN_EXPIRES)# serializer.dumps(数据), 返回bytes类型token = serializer.dumps({"openid": openid})token = token.decode()return token@staticmethoddef check_user_bind_access_token(access_token):"""解析用户令牌token,从中获取QQ用户的openid:param access_token::return:"""# serializer = Serializer(秘钥, 有效期秒)serializer = TJWSSerializer(settings.SECRET_KEY, BIND_USER_ACCESS_TOKEN_EXPIRES)# serializer.load(数据)try:data = serializer.loads(access_token)except BadData:return Nonereturn data.get("openid")

新建oauth/serializers.py文件,

from django_redis import get_redis_connection
from rest_framework import serializers
from rest_framework_jwt.settings import api_settingsfrom oauth.models import OauthQQUser
from users.models import User
from .utils import OauthQQclass OAuthQQUserSerializer(serializers.ModelSerializer):sms_code = serializers.CharField(label='短信验证码', write_only=True)access_token = serializers.CharField(label='操作凭证', write_only=True)token = serializers.CharField(read_only=True)mobile = serializers.RegexField(label="手机号", regex=r'^1[3-9]\d{9}$')class Meta:model = Userfields = ['mobile', 'password', 'sms_code', 'access_token', 'username', 'id', 'token']extra_kwargs = {"username": {"read_only": True   # 用户名在QQ登录时是手机号,加上条件使反序列化时不尽进行校验},"password": {'write_only': True,'min_length': 8,'max_length': 20,'error_messages': {'min_length': '仅允许8-20个字符的密码','max_length': '仅允许8-20个字符的密码',}}}def validate(self, attrs):# 检验access_tokenaccess_token = attrs["access_token"]# 解析出openidopenid = OauthQQ.check_user_bind_access_token(access_token)if not openid:raise serializers.ValidationError('无效的access_token')# 添加数据attrs['openid'] = openid# 检验短信验证码mobile = attrs['mobile']sms_code = attrs["sms_code"]redis_conn = get_redis_connection('verify_codes')real_sms_code = redis_conn.get('sms_%s' % mobile)  # type:bytesif real_sms_code.decode() != sms_code:raise serializers.ValidationError('短信验证码错误')# 判断用户是否存在try:user = User.objects.get(mobile=mobile)except User.DoesNotExist:passelse:# 用户存在,校验密码password = attrs["password"]if not user.check_password(password):raise serializers.ValidationError('密码错误')# 把用户对象添加到参数字典attrs['user'] = userreturn attrsdef create(self, validated_data):openid = validated_data['openid']user = validated_data.get('user')mobile = validated_data['mobile']password = validated_data['password']if not user:# 如果用户不存在,则创建User用户,再创建OauthQQUser用户user = User.objects.create_user(username=mobile, mobile=mobile, password=password)# 如果用户存在,绑定用户,创建OauthQQUser用户OauthQQUser.objects.create(user=user, openid=openid)# 签发jwt tokenjwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLERjwt_encode_handler = api_settings.JWT_ENCODE_HANDLERpayload = jwt_payload_handler(user)token = jwt_encode_handler(payload)# 给user添加token属性user.token = tokenreturn user

在oauth/views.py修改QQAuthUserView 视图

from rest_framework import status
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_jwt.settings import api_settingsfrom oauth.exceptions import OaAuthQQAPIError
from oauth.models import OauthQQUser
from oauth.serializers import OAuthQQUserSerializer
from .utils import OauthQQ#  url(r'^qq/authorization/$', views.QQAuthURLView.as_view()),
class QQAuthURLView(APIView):"""获取QQ登录的url    ?next=xxxxxx"""def get(self, request):# 获取next参数next = request.query_params.get("next")# 拼接qq登录的url的路径oauth_qq = OauthQQ(state=next)login_url = oauth_qq.get_login_url()# 返回urlreturn Response({'login_url': login_url})class QQAuthUserView(CreateAPIView):"""通过QQ按钮登录的视图"""serializer_class = OAuthQQUserSerializerdef get(self, request):# 获取codecode = request.query_params.get('code')if not code:return Response({"meaasge": "缺少code"}, status=status.HTTP_400_BAD_REQUEST)oauth_qq = OauthQQ()try:# 根据code,获取access_tokenaccess_token = oauth_qq.get_access_token(code)# 根据access_token 获取openidopenid = oauth_qq.get_openid(access_token)except OaAuthQQAPIError:return Response({"message": "访问QQ接口异常"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)# 根据openid查询数据库,判断用户是否存在try:oauth_qq_user = OauthQQUser.objects.get(openid=openid)except OauthQQUser.DoesNotExist:# 如果数据不存在,处理openid(加密)并返回new_access_token = oauth_qq.generate_user_bind_access_token(openid)return Response({"access_token": new_access_token})else:# 如果数据存在,表示用户已经绑定过身份,签发JWT_token# 签发jwt tokenjwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLERjwt_encode_handler = api_settings.JWT_ENCODE_HANDLERuser = oauth_qq_user.userpayload = jwt_payload_handler(user)token = jwt_encode_handler(payload)return Response({"username": user.username,"user_id": user.id,"token": token})

Django项目QQ登录后端接口实现相关推荐

  1. QQ登录第三方接口研究(1)----协议要点

    此文内容摘抄自QQ登录的协议,因为要写QQ登录的第三方接口,所以第一件事就是认真研究协议,所以把重点和要点摘抄出来了.基本是按原文写的,不过有些地方是按个人理解写的,所以如果真要做这方面的接口,还是到 ...

  2. Django实现QQ登录

    软件开发之实现QQ登录 问题:为什么实现QQ登录? ​ QQ登录:即我们所说的第三方登录,是指用户可以不在本项目中输入密码,而直接通过第三方的验证,成功登录本项目. QQ互联开发者申请 若想实现QQ登 ...

  3. sql在insert的同时把某个字段返回来_项目实践:后端接口统一规范的同时,如何优雅得扩展规范?...

    推荐学习 春招指南之"性能调优":MySQL+Tomcat+JVM,还怕面试官的轰炸? 这是什么神仙面试宝典?半月看完25大专题,居然斩获阿里P7offer 前言 之前写过如何通过 ...

  4. 【Django】—QQ登录

    目录 1.QQ登录逻辑分析 2.若想实现 QQ 登录,需要成为 QQ 互联的开发者,审核通过才可实现.[<申请链接>](https://wiki.connect.qq.com/%E6%88 ...

  5. vue 项目 前端 模拟后端接口数据(vue2,vue3)

    项目中或者平常自己创建demo的过程中,往往需要后端配合,但是有时候没有后端,又需要数据,此时就展示了我们前端的强大之处,自己模拟后端接口数据. 如果自定义一个模拟后端数据, 首先创建一个文件夹放置后 ...

  6. 一、ubuntu-django+nginx+uwsgi:ubuntu系统部署django项目,前后端不分离项目

    一.创建用户和文件夹 #创建www文件夹,所有网站项目都放到这里 $ sudo mkdir /www #创建用户组 sudo groupadd www -g 666 #创建用户 $ sudo user ...

  7. QQ登录第三方接口研究(2)-接入规范

    本规范旨在对申请QQ登录的合作方信息进行快速审核,建立审核机制,即对申请者各项步骤进行审核,并提供相关整改建议. 审核的目标为保护QQ用户的账号信息.个人信息和隐私的安全,保护用户虚拟财产和数据等方面 ...

  8. 简易django项目之登录验证

    该项目没有使用orm views.py from django.shortcuts import render import pymysql# Create your views here. def ...

  9. Django项目:前后端联调/ModelViewSet

    1.创建项目pycharm文件/cmd命令:django-admin startproject +项目名 2.创建子应用/命令:python manage.py startapp +子应用名 3.项目 ...

最新文章

  1. SCALA中类的继承
  2. 数据结构--插入排序
  3. ThinkPHP框架整合phpqrcode生成二维码DEMO
  4. Linux下tar.xz结尾的文件的解压方法
  5. php html asp .net iis tomcat,iis+apache+tomcat 整合共享80口 支持ASP .NET JSP PHP全能WEB服务...
  6. 微型计算机的字节取决于什么的宽度,计算机的字长取决于什么?
  7. 浅谈EventBus的使用原理
  8. 在ORACLE中找出并批量编译失效的对象
  9. 数据库SQL(介绍)
  10. 01 安装STEP7软件和USB驱动
  11. window下ruby的下载与gem安装
  12. 高速硬盘和固态硬盘的区别
  13. 安装新操作系统需要注意的问题
  14. 计算机毕业设计Java“臻宝”书画竞拍系统(源码+系统+mysql数据库+lw文档)
  15. Layer 父窗口如何获得子窗口的标签元素值
  16. 微信小程序入门day1-1
  17. 鸡和兔关在一个笼子里,鸡有2只脚,兔有4只脚,没有例外。已知现在可以看到笼子里m个头和n只脚,求鸡和兔子各有多少只?(输出一组数据)
  18. python预测波士顿房价代码
  19. 【笔记】DNS、IP地址、端口(Port)
  20. 无视Win11 TPM/英特尔芯片等配置,强制升级Win11

热门文章

  1. cad直线和圆弧倒角不相切_CAD倒角技巧
  2. Redhat Linux 8.3 安装方法
  3. [已解决]阿里云安全组开放端口,宝塔面板仍无法访问
  4. 创龙Xilinx Zynq-7000系列SoC高性能处理器开发板的SFP+接口、FMC接口
  5. MyEclipse中如何修改项目的编码格式
  6. Go sync.Pool 浅析
  7. C++ Primer Plus 第九章答案 内存模型和名称空间
  8. 网络正常连接,浏览器无法打开网页的解决方法
  9. 算法题--递归解法(化整思想、24点、全排列、单词迷宫解法加步骤)
  10. excel数据分析--仪表板制作