从零玩转第三方登录之WeChat公众号扫码关注登陆 -wechatgzh

Posted 杨不易呀

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从零玩转第三方登录之WeChat公众号扫码关注登陆 -wechatgzh相关的知识,希望对你有一定的参考价值。

title: 从零玩转第三方登录之WeChat公众号扫码关注登陆 
date: 2022-09-27 22:46:53.362
updated: 2023-03-30 13:28:41.359
url: https://www.yby6.com/archives/wechatgzh
categories: 
- 从零玩转系列
tags: 
- 第三方登录
- 从零玩转系列

前言

由于看见了面试鸭的登陆方式,我也想来整一个.注意: 只能微信认证的公众号才能有二维码扫码的权限,那么我们将使用 微信的测试账户来玩转扫码(沙箱)

1. 大致流程思路:

一、用户打开网页进行登陆/注册 扫码(微信的)
二、用户扫码成功后 微信会根据我们配置的回调地址访问我们的回调并且传递某些参数
三、用户扫码成功并且进行了关注我们的公众号 微信也会访问回调 传递参数
四、++域名使用内网穿透(我这里使用花生壳)++

思路地址: 接收事件推送
在微信用户和公众号产生交互的过程中,用户的某些操作会使得微信服务器通过事件推送的形式通知到开发者在开发者中心处设置的服务器地址,从而开发者可以获取到该信息。其中,某些事件推送在发生后,是允许开发者回复用户的,某些则不允许,详细内容如下:
1、关注/取消关注事件
2、 扫描带参数二维码事件
3、上报地理位置事件
4、自定义菜单事件
5、点击菜单拉取消息时的事件推送
6、点击菜单跳转链接时的事件推送

根据上述六点我们PC端只需要 1、2点即可只是来扫码公众号并且关注后登录

2. 进入测试号页面

微信测试号地址

测试号接口配置

接口信息配置: 将会get方法来进行验签你服务器的请求 和 post来回调推送信息到服务器
参考: 接口信息配置
JS接口安全配置:我们在日常当中经常可以看见js接口安全域名。那么,js接口安全域名是什么?js接口安全域名主要用于微信公众号,如果大家要进行微信的开发,创建公众号是需要填写js接口安全域名的。当我们运用程序的时候,网络是会自动验证安全域名的,它可以解决服务器终端的语言问题,能够让访问正常的运行,只有使用好js接口安全域名,网上的用户才能够访问到网页。
参考:JS接口安全配置

3. 介绍

获取 AccessToken

用于请求微信API 需要用到的认证信息
参考: 获取AccessToken

临时二维码

  1. 用户扫描带场景值二维码时,可能推送以下两种事件:
    如果用户还未关注公众号,则用户可以关注公众号,关注后微信会将带场景值关注事件推送给开发者。
  2. 如果用户已经关注公众号,在用户扫描后会自动进入会话,微信也会将带场景值扫描事件推送给开发者。
  3. 获取带参数的二维码的过程包括两步,首先创建二维码ticket,然后凭借 ticket 到指定 URL 换取二维码。
    正确的 Json 返回结果:
    "ticket":"gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm
    3sUw==","expire_seconds":60,"url":"http://weixin.qq.com/q/kZgfwMTm72WWPkovabbI"
  4. 参考: 临时二维码

4. 代码操作

编写接口配置以便能修改接口

    /***
     * 微信服务器触发get请求用于检测签名-
     * 如果需要绝对的安全就按照微信来进行验签
     */
    @GetMapping("/weChatScanCodeCallback")
    @ResponseBody
    public String weChatScan(HttpServletRequest request) 
        log.info("验签章:", request.getParameterMap());
        return request.getParameter("echostr");
    

解析微信返回参数
使用DOM4J将微信返回XML格式转换一下


/**
 * @Author yang shuai
 * @Date 2022/9/3
 */
public class XmlUtil 

    /**
     * 读取xml标签内容存放map当中
     */
    public static Map<String,Object> parseXML(InputStream in)
        Map<String,Object> map=new HashMap<>();
        try 
            SAXReader saxReader = new SAXReader();
            Document document = saxReader.read(in);
            Element root = document.getRootElement();
            Iterator iterator = root.elementIterator();
            while (iterator.hasNext())
                Element element = (Element) iterator.next();
                map.put(element.getName(),element.getStringValue());
            
         catch (DocumentException e) 
            e.printStackTrace();
        
        return map;
    


接收微信回调

    /**
     * 接收微信推送事件
     */
    @PostMapping("/weChatScanCodeCallback")
    @ResponseBody
    public String weChatCallback(HttpServletRequest request) 
        try 
            InputStream inputStream = request.getInputStream();
            Map<String, Object> map = XmlUtil.parseXML(inputStream);
            log.info("接收参数:", map);
         catch (IOException e) 
            e.printStackTrace();
        
        return "success";

Last

注入restTemplate请求

/**
 * @Author yang shuai
 * @Date 2022/9/3
 * 注入restTemplate用于http请求
 */
@Configuration
public class RestTemplateConfig 

    @Resource
    private RestTemplateBuilder templateBuilder;

    @Bean
    public RestTemplate restTemplate()

        return templateBuilder.build();
    


生成微信二维码

/**
 * @Author yang shuai
 * @Date 2022/9/3
 */
public interface WeChatService 
    /**
     * 获取token
     *
     * @return
     */
    String getAccessToken();

    /**
     * 获取生成二维码参数
     *
     * @return
     */
    Map<String, Object> getQrCode();

实现


/**
 * @Author yang shuai
 * @Date 2022/9/3
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class WeChatServiceImpl implements WeChatService 

    @Value("$weChat.gzh.appid:\'\'")
    private String appid;

    @Value("$weChat.gzh.secret:\'\'")
    private String secret;

    private final RestTemplate restTemplate;

    private final RedisCache redisCacheManager;

    /**
     * 获取token用于操作微信接口
     */
    @Override
    public String getAccessToken() 
        String key = "wx_access_token";
        if (redisCacheManager.hashKey(key)) 
            return redisCacheManager.getCacheObject(key);
        
        // 获取微信扫码 token
        String url = String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", appid, secret);
        ResponseEntity<String> result = restTemplate.getForEntity(url, String.class);
        if (result.getStatusCode() == HttpStatus.OK) 
            JSONObject jsonObject = JSON.parseObject(result.getBody());
            String access_token = jsonObject.getString("access_token");
            Long expires_in = jsonObject.getLong("expires_in");
            redisCacheManager.setCacheObject(key, access_token, expires_in, TimeUnit.SECONDS);
            return access_token;
        
        return null;
    

    /**
     * 获取微信公众号二维码
     */
    @Override
    public Map<String, Object> getQrCode() 
        // 获取临时二维码
        String url = String.format("https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=%s", getAccessToken());
        ResponseEntity<String> result = restTemplate.postForEntity(url, "\\"expire_seconds\\": 604800, \\"action_name\\": \\"QR_STR_SCENE\\", \\"action_info\\": \\"scene\\": \\"scene_str\\": \\"test\\"", String.class);

        log.info("二维码:", result.getBody());

        JSONObject jsonObject = JSON.parseObject(result.getBody());
        Map<String, Object> map = new HashMap<>();
        map.put("ticket", jsonObject.getString("ticket"));
        map.put("url", jsonObject.getString("url"));

        return map;
    


5. 改造Controller

新增获取二维码

    /**
     * 获取二维码参数
     *
     * @return
     */
    @GetMapping("/getQrCode")
    @ResponseBody
    public Object getQrCode() 
        return weChatService.getQrCode();
    

6.编写前段Demo

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登陆</title>
</head>
<body>

<div >
    <div id="qrcode"></div>
    <div id="msg" >
        扫码成功!
    </div>
</div>
<script type=\'text/javascript\' src=\'http://cdn.staticfile.org/jquery/2.1.1/jquery.min.js\'></script>
<script src="https://cdn.bootcss.com/jquery.qrcode/1.0/jquery.qrcode.min.js"></script>
<script>
    $(function () 
        let count = 0;
        //获取二维码参数
            $.get(\'https://34i33045l8.oicp.vip/weChat/getQrCode\', function (res) 
            //生成二维码
            $(\'#qrcode\').qrcode(res.url);
        )
    )

</script>
</body>
</html>

7.启动后端查看效果

1、使用idea打开html挂载一个node

2、打开前面要求设置的内网穿透用于接收微信的回调

3、进行扫码-查看后台打印参数数据

4、扫码后查看控制台

推送 XML 数据包示例:

  1. 用户未关注时,进行关注后的事件推送
<xml>
  <ToUserName><![CDATA[toUser]]></ToUserName>
  <FromUserName><![CDATA[FromUser]]></FromUserName>
  <CreateTime>123456789</CreateTime>
  <MsgType><![CDATA[event]]></MsgType>
  <Event><![CDATA[subscribe]]></Event>
  <EventKey><![CDATA[qrscene_123123]]></EventKey>
  <Ticket><![CDATA[TICKET]]></Ticket>
</xml>

示例:

在这里大家应该大致的知道下面的该如何实现了!

  1. 微信回调会一直存在 Ticket 字段 用于表示每次二维码的唯一标识
    我们将它进行存储redis当中并且可以看到 Event 我们利用它来区分当前是否为扫码还是关注的推送
  2. 则前段进行段轮训来请求校验当前为什么状态?

参数说明:

参数 描述
ToUserName 开发者微信号
FromUserName 发送方帐号(一个OpenID)
CreateTime 消息创建时间 (整型)
MsgType 消息类型,event
Event 事件类型,subscribe(扫码关注) or SCAN (扫码)
EventKey 事件 KEY 值,qrscene_为前缀,后面为二维码的参数值
Ticket 二维码的ticket,可用来换取二维码图片

8. 改造Controller

新增短轮询检查扫码状态


    /**
     * 用于检测扫码和关注状态
     *
     * @return
     */
    @PostMapping("/checkLogin")
    @ResponseBody
    public Object checkLogin(String ticket) 
        // 存在该信息并且为关注了公众号
        if (redisCache.hashKey(ticket)) 
            if (!redisCache.getCacheObject(ticket).equals("subscribe")) 
                return AjaxResult.error(201, "扫码成功");
            
            //扫码通过则删除
            redisCache.deleteObject(ticket);
            return AjaxResult.success();

        
        return AjaxResult.error("无动作");
    

修改微信回调完善业务


    /**
     * 接收微信推送事件
     *
     * @param request
     * @return
     */
    @PostMapping("/weChatScanCodeCallback")
    @ResponseBody
    public String weChatCallback(HttpServletRequest request) 
        try 
            InputStream inputStream = request.getInputStream();
            Map<String, Object> map = XmlUtil.parseXML(inputStream);
            log.info("接收参数:", map);
            String userOpenId = (String) map.get("FromUserName");
            String event = (String) map.get("Event");
           //  自己生成的二维码不管是关注还是扫码都能取到ticket凭证,这里我使用Ticket作为每次二维码的唯一标识
            String ticket = (String) map.get("Ticket");
            if ("subscribe".equals(event)) 
                //  根据openid判断用户是否存在,不存在则获取新增用户
                // 或者根据前段传递手机号或者用户名称来进行openId绑定 看你自己的业务.
                
                
                redisCache.setCacheObject(ticket, "subscribe", (long) (10 * 60), TimeUnit.SECONDS);
                log.info("用户关注:", userOpenId);
             else if ("SCAN".equals(event)) 
                redisCache.setCacheObject(ticket, "scan", (long) (10 * 60), TimeUnit.SECONDS);
                log.info("用户扫码:", userOpenId);
            
         catch (IOException e) 
              log.error("回调异常:",e);
        
        return "success";
    

新增前段短轮询

替换你自己的内网穿透

$(function () 
        let count = 0;
        //获取二维码参数
            $.get(\'https://34i33045l8.oicp.vip/weChat/getQrCode\', function (res) 
            //生成二维码
            $(\'#qrcode\').qrcode(res.url);
            // 轮训获取用户扫码登陆状态
            let task = setInterval(function () 
                $.post(\'https://34i33045l8.oicp.vip/weChat/checkLogin\', ticket: res.ticket, function (code,msg) 
                    console.log(code);
                    if (code === 200)  // 扫码并且关注成功
                        clearInterval(task)
                        location.href = \'http://yby6.com\'
                     else if (code === 201)  // 扫码成功
                        $("#msg").text(msg);
                        document.querySelector("#msg").style.display = "block"
                     else 

                    
                    count ++;
                )
            , 2000)
        )
    )

最后操作流程

注: 前端记得整扫码超时!

实现支持多公众号的微信公众号扫码登录服务

实现支持多公众号的微信公众号扫码登录服务

最近,在公司的通行证项目开发过程中,需求方提出了支持微信公众号扫码登录,并且可以支持多公众号接入的需求。研究了一下微信公众号的开发文档,实现微信公众号扫码登录并不难,但是要支持多公众号接入就得好好斟酌一下了。

理清思路,微信公众号扫码登录的实现关键就是appid、openid获取,appid用来识别公众号,openid用来识别用户,能理解这两点需求就应该不难实现了。

流程

我们先整理一下流程,用户在前端页面点击扫描登录,后端服务接收到前端页面请求之后调用微信官方api创建二维码,并将二维码的ticket和url返回给前端页面,前端页面展示二维码,然后用户用手机微信扫描二维码,微信官方后台监听到扫描事件,将事件推送给后端服务,后端服务缓存ticket和openid,前端页面轮询后端服务判断缓存中是否存在ticket对应的openid,有则表示扫描成功,如果openid没有绑定用户,则跳转至绑定页面,否则直接跳转到登录成功页面。

实战

我们主要有两个开发步骤:

  • 生成二维码
  • 扫码登录

1、生成二维码

参考微信官方文档生成带参数的二维码的说明。

  • 创建二维码ticket
    每次创建二维码ticket需要提供一个开发者自行设定的参数(scene_id),分别介绍临时二维码和永久二维码的创建二维码ticket过程。临时二维码请求说明
  • 临时二维码请求说明
    http请求方式: POST URL: https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=TOKEN POST数据格式:json POST数据例子:{"expire_seconds": 604800, "action_name": "QR_SCENE", "action_info": {"scene": {"scene_id": 123}}} 或者也可以使用以下POST数据创建字符串形式的二维码参数:{"expire_seconds": 604800, "action_name": "QR_STR_SCENE", "action_info": {"scene": {"scene_str": "test"}}}
    废话不多说,直接撸代码。先获取AccessToken,再创建二维码。ticket是新生成的二维码的唯一标识,可以用来判断二维码是否被扫描。另外,缓存returnUrl用于登录成功后重定向。
        /// <summary>
        /// 生成二维码
        /// </summary>
        /// <param name="returnUrl"></param>
        /// <param name="appid"></param>
        /// <param name="secret"></param>
        /// <returns></returns>
        [HttpGet("/api/mpwechat/qrcode"), AllowAnonymous]
        public async Task<IActionResult> QrCodeAsync(string returnUrl, string appid, string secret)
        {
            var accessToken = await GetAccessTokenAsync(appid, secret);
            var jsonContent = await CreateQrCodeAsync(accessToken);

            var ticket = jsonContent["ticket"].Value<string>();
            // 缓存returnUrl
            var returnUrlCacheKey = MpwechatLoginReturnUrlCacheKey(ticket);
            if (!(await _cache.ExistsAsync(returnUrlCacheKey)))
                await _cache.AddAsync(returnUrlCacheKey, returnUrl, TimeSpan.FromMinutes(30));

            return Ok(ResponseResult.Execute(new { Url = $"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={UrlEncoder.Default.Encode(ticket)}", Ticket = ticket }));
        }

        /// <summary>
        /// 获取AccessToken
        /// </summary>
        /// <param name="appid"></param>
        /// <param name="secret"></param>
        /// <returns></returns>
        private async Task<string> GetAccessTokenAsync(string appid, string secret)
        {
            // 从缓存获取AccessToken
            var cacheKey = $"mpwechat:{appid}";
            if ((await _cache.ExistsAsync(cacheKey)))
                return (await _cache.GetAsync(cacheKey)).ToString();

            var response = await _httpClient.GetAsync($"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}");
            response.EnsureSuccessStatusCode();
            var content = await response.Content.ReadAsStringAsync();
            var jsonContent = JObject.Parse(content);
            var accessToken = jsonContent["access_token"].Value<string>();
            var expiresIn = jsonContent["expires_in"].Value<int>();
            
            // 缓存AccessToken
            await _cache.AddAsync($"mpwechat:{appid}", accessToken, TimeSpan.FromSeconds(expiresIn - 60));
            return accessToken;
        }
        
         /// <summary>
        /// 创建二维码
        /// </summary>
        /// <param name="accessToken"></param>
        /// <param name="sceneStr"></param>
        /// <returns></returns>
        private async Task<JObject> CreateQrCodeAsync(string accessToken, string sceneStr = null)
        {
            var stringContent = new StringContent(JsonConvert.SerializeObject(
                new
                {
                    expire_seconds = 600, 
                    action_name = "QR_STR_SCENE",
                    action_info = new
                    {
                        scene = new
                        {
                            scene_str = sceneStr
                        }
                    }
                }), Encoding.UTF8, "application/json");
            var response = await _httpClient.PostAsync($"https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={accessToken}", stringContent);
            response.EnsureSuccessStatusCode();
            var content = await response.Content.ReadAsStringAsync();
            return JObject.Parse(content);
        }

        private string MpwechatLoginReturnUrlCacheKey(string ticket) => $"mpwechat_login_returnUrl:{ticket}";


2、扫码登录

微信公众号的扫码登录实现方式与微信的扫码登录实现方式不同,它是采用订阅通知的方式实现的。参考微信官方文档事件推送的说明。

  • 首先我们要准备两个RestApi,路由地址相同,一个Get方法,一个Post方法。Get方法用于微信官方检测服务器配置,Post方法用于接收事件推送。为了识别通知是从哪个微信公众号发送的,我们将Url定义为api/mpwechat/{appid},用动态路由接收appid。敲黑板,这里是关键。另外,如有事件需要其他处理(如自动回复),可转发事件到EventBus,其他应用可自行订阅EventBus的消息作处理。
         /// <summary>
        /// 验证微信公众号签名(微信公众号调用)
        /// </summary>
        [HttpGet("/api/mpwechat/{appid}"), AllowAnonymous]
        public Task<string> CheckMpwechatSignature(string appid, string signature, string timestamp, string nonce, string echostr)
        {
            if (!CheckMpwechatSignature(signature, timestamp, nonce))
                throw new Exception("签名验证不通过");
                
            return Task.FromResult(echostr);
        }

        /// <summary>
        /// 订阅微信公众号事件(微信公众号调用)
        /// </summary>
        [HttpPost("/api/mpwechat/{appid}"), AllowAnonymous]
        public async Task<IActionResult> SubscribeMpwechatEvent(string appid, string signature, string timestamp, string nonce)
        {
            if (!CheckMpwechatSignature(signature, timestamp, nonce))
                throw new Exception("签名验证不通过");

            using StreamReader sr = new(Request.Body, Encoding.UTF8);
            var data = await sr.ReadToEndAsync();
            var xmlDoc = new XmlDocument();
            xmlDoc.LoadXml(data);

            // 如果是密文则需要解密
            var encryptNode = xmlDoc.DocumentElement.SelectSingleNode("Encrypt");
            if (encryptNode != null)
            {
                var encrypt = encryptNode.InnerText;
                data = DecryptMpwechatMsg(appid, data, signature, timestamp, nonce);
                if (data == null)
                    throw new Exception("密文解密异常");

                xmlDoc.LoadXml(data);
            }
            // todo 推送消息到EventBus,如有事件需要其他处理(如自动回复),可订阅EventBus的消息。

            // 扫码登录
            var openId = xmlDoc.DocumentElement.SelectSingleNode("FromUserName").InnerText;
            var eventType = xmlDoc.DocumentElement.SelectSingleNode("Event").InnerText;
            var ticketNode = xmlDoc.DocumentElement.SelectSingleNode("Ticket");

            if (ticketNode != null)
            {
                var ticket = ticketNode.InnerText;
                if (eventType == "subscribe" || eventType == "SCAN")
                {
                    // 缓存openid,标记扫码登录
                    var cacheKey = MpwechatLoginOpenIdCacheKey(ticket);
                    if (!(await _cache.ExistsAsync(cacheKey)))
                        await _cache.AddAsync(cacheKey, openId, TimeSpan.FromMinutes(10));
                }
            }

            return Ok();
        }

        /// <summary>
        /// 轮询检查扫码状态(前端调用)
        /// </summary>
        /// <param name="ticket"></param>
        /// <returns></returns>
        [HttpGet("/api/mpwechat/checkscan"), AllowAnonymous]
        public async Task<IActionResult> MpwechatCheckscanAsync(string ticket)
        {
            var openIdCacheKey = MpwechatLoginOpenIdCacheKey(ticket);
            if ((await _cache.ExistsAsync(openIdCacheKey)))
            {
                var openId = (await _cache.GetAsync(openIdCacheKey)).ToString();
                
                var returnUrlCacheKey = MpwechatLoginReturnUrlCacheKey(ticket);
                var returnUrl = (await _cache.GetAsync(returnUrlCacheKey)).ToString();

                return Ok(ResponseResult.Execute(new { ReturnUrl = returnUrl, OpenId = openId }));
            }
            return Ok(ResponseResult.Execute("-1", "未扫码"));
        }

        /// <summary>
        /// 微信公众号基本设置中设置的Token
        /// </summary>
        private const string Token = "Token";

        /// <summary>
        /// 微信公众号基本设置中设置的EncodingAESKey
        /// </summary>
        private const string EncodingAESKey = "zJULaJfu8NVIXvmKVMYfvdM2inlh4YrKkO3BvCmDOt8";

        /// <summary>
        /// 验证微信公众号签名
        /// </summary>
        /// <param name="signature"></param>
        /// <param name="timestamp"></param>
        /// <param name="nonce"></param>
        /// <returns></returns>
        private bool CheckMpwechatSignature(string signature, string timestamp, string nonce)
        {
             // 拼接排序Sha1加密
            var orderJoinString = string.Join("", new string[] { Token, timestamp, nonce }.OrderBy(t => t));
            return signature == Encrypt.Sha1(orderJoinString);
        }

        /// <summary>
        /// 解密微信公众号内容
        /// </summary>
        /// <param name="appId"></param>
        /// <param name="data"></param>
        /// <param name="signature"></param>
        /// <param name="timestamp"></param>
        /// <param name="nonce"></param>
        /// <returns></returns>
        private string DecryptMpwechatMsg(string appId, string data, string signature, string timestamp, string nonce)
        {
             // 利用微信官方示例代码
            Tencent.WXBizMsgCrypt wxcpt = new(Token, EncodingAESKey, appId);
            var content = "";
            var ret = wxcpt.DecryptMsg(signature, timestamp, nonce, data, ref content);
            if (ret == 0)
                return content;
            return null;
        }
        
        private string MpwechatLoginOpenIdCacheKey(string ticket) => $"mpwechat_login_openId:{ticket}";
  • 在微信公众号的基本配置里面配置Url、Token、EncodingAESKey和消息加密方式。Token用来验证微信公众号签名,EncodingAESKey用来解密消息内容,配置必须与代码一致。

这样后端代码就完成了,前端代码请各位看官自行脑补!:)测试一下,完美通过!

最后

总体来说微信的官方文档和示例还是不错的,按照它一步步来很容易实现扫码登录功能。另外,由于时间仓促,写得不太细致,但是核心的思想和代码都在上面,希望可以给大家带来帮助!

福禄·研发中心 福小皮

以上是关于从零玩转第三方登录之WeChat公众号扫码关注登陆 -wechatgzh的主要内容,如果未能解决你的问题,请参考以下文章

实现支持多公众号的微信公众号扫码登录服务

从零玩转Docker之docker-compose-azdocker-compose

微信公众号扫码登录—— 获取微信公众号二维码

从零玩转Websocket实时通讯服务之前后端分离版本-websocket

从零玩转设计模式之抽象工厂设计模式-chouxiangshejimoshi

01- 从零玩转Vue 开篇