微信公众号的模式是将所有公众号的交互全部转发到监听URL上,并将HTTP Response返给用户,或者直接由后端调用相关API进行系统管理。没有前端部分,或者说微信公众号的界面就是前端。与之对应,微信WEB开发和小程序开发就需要公众号管理者连前端一起做了。

微信WEB开发其实就是常规意义上的网站开发,另外还包含了和微信有关的关键功能,这部功能由jsapi来实现,比如:用户信息/授权(用于会员和账号系统)、订单/支付、分享、定位等等功能。

小程序的前端部分与网站前端开发本质一样,甚至js部分也一样,目录结构略有差异,不过只支持微信web开发工具(这个工具其实就是个封装好的chromium,也能拿来调试微信web应用)。

因为各种类型的后端开发流程、部署完全一样,都是传统意义web后端的内容,所以就不做介绍了。这一篇只介绍一下微信WEB/小程序的前端部分。

关于账号类型和微信认证

不同公众号账号类型对应不同的应用场景,订阅号适用于消息、文章发布;服务号专注于为用户提供各类服务,侧重点在于交互上,发布消息的能力被极大地限制。详情可参考:https://kf.qq.com/faq/170815aUZjeQ170815mU7bI7.html

场景区别:

功能区别:

微信认证是指微信官方对公众号运营主体进行资质审核认证的一项服务,如上表所列,认证与否的区别在于是否可以使用高级接口,详细的接口列表可以在 https://kf.qq.com/faq/170104AJ3y26170104Yj673y.html 找到。需要特别留心的是,运营主体如果是个人,将无法进行微信认证。

微信WEB应用

微信WEB应用的开发流程和传统的前端开发完全没分别,看个人喜好随意使用MVC、前后端分离、asp/jsp/php动静混杂……甚至还可以先开发网站,再做微信接入。

开发、部署流程:

  • 开发阶段
    • 可以用自己熟悉的环境、熟悉的框架按普通的WEB开发过程进行前后端开发;
    • 在需要使用功能的前端页面引入核心JS-SDK;
    • 通过wx.config接口注入权限验证配置;
    • 调用微信JS接口时,需要发起网站的域名存在于公众号后台的JS接口安全域名列表中,设置方法为:设置菜单 -> 公众号设置菜单项 -> 功能设置标签中的JS接口安全域名选项(每个月可修改最多5次)。

    <script type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js" ></script>
    <script>
    wx.config({
      debug: true,
      appId: '公众号appid',
      timestamp: '时间戳',
      nonceStr: '随机字符串',
      signature: '签名',/*由access_token获取jsapi_ticket,由jsapi_ticket, timestamp, nonceStr, url获取signature*/
      jsApiList: ['updateAppMessageShareData','updateTimelineShareData']
    });
    wx.ready(function(){
      wx.updateAppMessageShareData({
        title: '微信WEB DEMO 微信分享',
        desc: '微信分享',
        link: 'https://www.chenxin.info/wx',
        imgUrl: 'https://www.chenxin.info/icon.png',
        /*success: function () {    }*/});
      wx.updateTimelineShareData({
        title: '微信WEB DEMO 朋友圈分享',
        link: 'https://www.chenxin.info/wx',
        imgUrl: 'https://www.chenxin.info/icon.png',
        /*success: function () {    }*/});
    });
</script>

微信网页应用的调试可以用微信开发者工具,该工具支持公众号网页/小程序的调试、小程序开发。

由于生成签名时所需的参数包含敏感信息(access_token、jsapi_ticket),所以推荐这段配置所用的JS由后端动态生成,主要流程包含:获取code -> 换取access_token -> 计算签名。虽然流程简单,后端要做的判断有点繁琐:不在微信打开、不带code、code无效均需要进行验证。


    public class WXConfigData
    {//封装签名方法,自动生成参数并直接计算签名
        public string TimeStamp;
        public string NonceStr;
        public string Signature;
        public WXConfigData(string url = "")
        {
            NonceStr = Guid.NewGuid().ToString("N").Substring(0, 16);
            TimeStamp = Convert.ToInt64((DateTime.Now - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds).ToString();
            var data = $"jsapi_ticket={WXHelper.JSAPI_Ticket}&noncestr={NonceStr}&timestamp={TimeStamp}&url={url}";
            using SHA1 sha1 = new SHA1CryptoServiceProvider();
            Signature = BitConverter.ToString(sha1.ComputeHash(Encoding.UTF8.GetBytes(data))).Replace("-", "");
        }
    }
  • 部署阶段
    • 在后端服务器托管商处备案域名(必须使用已备案域名);
    • 添加DNS A记录指向服务器,如wx.abc.com指向108.108.108.108;
    • 按正常流程将WEB应用部署到指定服务器。

下面就借一个简单的项目来演示微信WEB开发:访客授权网页读取用户信息后,系统为用户自动登录;获取用户同意使用地理位置后,上报地理位置,同时展示附近的人列表(方圆10公里以内没人陪我玩,最后做成直接展示所有人了)。

前端只有一个页面,点击按钮上报自己地理位置,并刷新附近的人列表。

后端只有两个接口:更新位置和个性签名,返回所有符合要求的用户列表。

因为wx.config和请求access_token获取用户信息流程均包含敏感内容,所以由后端拼出JS,并在验证状态没问题以后,直接为用户登录。

后端部分:

  • WxUser类、WxHelper类

    public class WxUser
    {
        public string Openid { get; set; }
        public string Nickname { get; set; }
        /// <summary>
        /// 注册时间
        /// </summary>
        public DateTime Created { get; set; }
        /// <summary>
        /// 坐标
        /// </summary>
        public double X { get; set; } = 10000;
        public double Y { get; set; } = 0;
        public int Message { get; set; }
        /// <summary>
        /// 最后上传时间
        /// </summary>
        public DateTime LastUpdate { get; set; }
        public string Avatar { get; set; }
    }

    public static class WXHelper
    {
        public static string APPID { get; set; } = "……";
        public static string APPSecret { get; set; } = "……";
        public static string Access_token;
        public static DateTime Access_token_expires = DateTime.Now;
        public static string JSAPI_Ticket;
        public static DateTime JSAPI_Ticket_expires = DateTime.Now;
        public static List<string> CodeHistory = new List<string>();
        public static void InitWechatToken()
        {
            using WebClient client = new WebClient();
            string resp = client.DownloadString($"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={APPID}&secret={APPSecret}");
            Console.WriteLine("请求Access_Token\t" + resp);
            var acc = JsonConvert.DeserializeObject<AccessToken>(resp);
            Access_token = acc.access_token;
            Access_token_expires = DateTime.Now.AddSeconds(acc.expires_in);
            resp = client.DownloadString($"https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token={Access_token}&type=jsapi");
            Console.WriteLine("请求JSAPI_Ticket\t" + resp);
            var ticket = JsonConvert.DeserializeObject<JSAPI_Ticket>(resp);
            JSAPI_Ticket = ticket.ticket;
            JSAPI_Ticket_expires = DateTime.Now.AddSeconds(ticket.expires_in);
        }
    }
    public class WXConfigData
    {//封装签名方法,自动生成参数并直接计算签名
        public string TimeStamp;
        public string NonceStr;
        public string Signature;
        public WXConfigData(string url = "")
        {
            NonceStr = Guid.NewGuid().ToString("N").Substring(0, 16);
            TimeStamp = Convert.ToInt64((DateTime.Now - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds).ToString();
            var data = $"jsapi_ticket={WXHelper.JSAPI_Ticket}&noncestr={NonceStr}&timestamp={TimeStamp}&url={url}";
            using SHA1 sha1 = new SHA1CryptoServiceProvider();
            Signature = BitConverter.ToString(sha1.ComputeHash(Encoding.UTF8.GetBytes(data))).Replace("-", "");
        }
    }
    public class AccessToken
    {
        public string access_token { get; set; }
        public int expires_in { get; set; }
    }
    public class JSAPI_Ticket
    {
        public int errcode { get; set; }
        public string errmsg { get; set; }
        public string ticket { get; set; }
        public int expires_in { get; set; }
    }
  • 更新位置接口:/Home/Upload

        public static Dictionary<int, string> Words =
            new Dictionary<int, string>(){
                { 0, "打卡纪念。" },
                { 1, "香蕉大,则香蕉皮也大。" },
                { 2, "齐天大圣到此一游。" },
                { 3, "Run, Forrest, run!" },
                { 4, "布莱恩铜须到此一游。" },
                { 5, "盘丝洞?五百年前?" },
                { 6, "正正正正T。" },
                { 7, "锂钠钾铷铯钫铍镁钙锶钡镭。" },
                { 8,  "三碗不过岗。" },
                { 9, "呸,吉尔尼斯那口喷泉能实现比这多十倍的愿望!" },
                { 10, "北京皮条胡同叶赫那拉氏老拉家。" },
                { 11, "帮我挡一挡,我要到树后面去一趟。" },
                { 12, "哞~~~你现在开心了吗?" },
                { 13, "哦,我讨厌雷霆崖,你根本找不到好吃的汉堡!" },
                { 14, "耿浩.对不起.康小雨。" },
                { 15, "啊,我刚跨进翡翠梦境,就又得起来方便。" },
                { 16, "冰箱彩电洗衣机,磨剪子嘞戗菜刀。" },
                { 17, "理性的四环路。" },
                { 18, "激情的平安大道。" },
                { 19, "算了,我硬挺着吧。" },
                { 20, "不用记不用记,根本不用记,工作的事儿记什么呢?也就是点儿人事安排。" },
                { 21, "去,杨继红波波娃家。抄,抄李挺!" }
            };
            
        public class UploadViewModel
        {
            [Required, Range(-90, 90)]
            public double X { get; set; }
            [Required, Range(-180, 180)]
            public double Y { get; set; }
            [Required, Range(0, 22)]
            public int Message { get; set; }
        }
    
        [HttpPost]
        public ContentResult Upload(UploadViewModel model)
        {
            if (ModelState.IsValid)
            {
                WxUser user = General.GetUser(HttpContext);
                if (user is null) return Content("未登录");
                user.LastUpdate = DateTime.Now;
                user.Location = (model.X, model.Y);
                user.Message = model.Message;
                return Content("succ");
            }
            else
            {
                return Content("参数无效");
            }
        }
        
        public List<string> Words() => General.Words.Values.ToList();
  • 获取附近的人接口:/Home/Nearby

        public class NearbyUsers
        {
            public string Nickname { get; set; }
            public string Distance { get; set; }
            public int Message { get; set; }
            public string Avatar { get; set; }
        }
    
        public List<NearbyUsers> Ulist()
        {
            WxUser user = General.GetUser(HttpContext);
            if (user is null) return null;
            List<NearbyUsers> ulist = new List<NearbyUsers>();
            foreach (var u in General.Users)
            {
                if (u.Openid == user.Openid)
                {
                    ulist.Add(new NearbyUsers() { Message = u.Message, Distance = "(自己)", Nickname = u.Nickname, Avatar = u.Avatar });
                }
                else
                {
                    if (u.X < 10000)
                    {
                        ulist.Add(new NearbyUsers() { Message = u.Message, Distance = $"{General.DistanceKM(user.X, user.Y, u.X, u.Y)}", Nickname = u.Nickname, Avatar = u.Avatar });
                    }
                    else
                    {
                        ulist.Add(new NearbyUsers() { Message = u.Message, Distance = "未知", Nickname = u.Nickname, Avatar = u.Avatar });
                    }
                }
            }
            return ulist;
        }
        
        //General静态类方法,获取两坐标距离
        public static double Rad(double degree) => degree * Math.PI / 180;
        public static double DistanceKM(double x1, double y1, double x2, double y2)
        {
            double RadY1 = Rad(y1);
            double RadY2 = Rad(y2);
            double a = RadY1 - RadY2;
            double b = Rad(x1) - Rad(x2);
            double s = 2 * Math.Asin(Math.Sqrt(Math.Pow(Math.Sin(a / 2), 2) + Math.Cos(RadY1) * Math.Cos(RadY2) * Math.Pow(Math.Sin(b / 2), 2)));
            return s * 6371;
        }

前端部分:

一个页面:/Home/Index;

两个JS(涉及到敏感参数,和登录状态控制,所以由后端生成):/Home/WXAuth,/Home/WXConfig。


        public static WxUser GetUser(HttpContext context)
        {
            if (Users.Count == 0 || context.User.Claims is null) return null;
            IEnumerable<Claim> claims = context.User.Claims.Where(c => c.Type == ClaimTypes.Sid);
            if (claims.Count() == 0) return null;
            try
            {
                return Users.First(u => u.Openid == claims.First().Value);
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.ToString());
                return null;
            }
        }
        public IActionResult Index()
        {
            WxUser user = General.GetUser(HttpContext);
            ViewBag.User = user;
            return View();
        }

@using Newtonsoft.Json;
@using wechat_demo.Classes;
@{
    Layout = null;
    WxUser user = ViewBag.User as WxUser;
}
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>微信web demo</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
    <script type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
    <script type="text/javascript" src="/Home/WXAuth"></script>
    <script type="text/javascript" src="/Home/WXConfig"></script>
    <link rel="stylesheet" href="~/css/site.css" />
    <script>
        var users=@Html.Raw(JsonConvert.SerializeObject(General.Users));
</script>
</head>
<body>
    <div class="container">
        @if (user != null)
        {
            <div class="media">
                <div class="media-left">
                    <a href="#">
                        <img class="media-object img64" src="@user.Avatar" alt="@user.Nickname">
                    </a>
                </div>
                <div class="media-body">
                    <h4 class="media-heading">@user.Nickname<small> <span class="glyphicon glyphicon-map-marker"></span>(@user.X, @user.Y)</small></h4>
                    <blockquote><p style="font-size:14px;">@General.Words[user.Message]</p></blockquote>
                </div>
            </div>
            <div>
                <h4>附近的人<small> <a href="#" onclick="users()">刷新</a></small></h4>
                <div class="border-dark">
                    <ul id="users" class="list-group">
                    </ul>
                </div>
            </div>
            <div>
                <h4>更新位置/留言<small> 点击文字</small></h4>
                <div>
                    <ul class="list-group">
                        @foreach (var kv in General.Words)
                        {
                            <li class="list-group-item"><a href="#" onclick="upload(@kv.Key)">@kv.Value</a></li>
                        }
                    </ul>
                </div>
            </div>
        }
    </div>
    <footer><a href="http://www.beian.miit.gov.cn/" target="_blank" class="small">苏ICP备20033082-1号</a></footer>
    <script>
        function upload(msg) {
            wx.getLocation({
                type: 'wgs84', // 默认为wgs84的gps坐标,如果要返回直接给openLocation用的火星坐标,可传入'gcj02'
                success: function (res) {
                    $.post("/Home/Upload",
                        {
                            X: res.latitude,
                            Y: res.longitude,
                            Message: msg
                        }, function (e) {
                            if (e == "succ") {
                                window.location.reload();
                            } else {
                                alert(e);
                            }
                        });
                }
            });
        }
        function users() {
            $.get("/Home/Nearby", function (e) {
                console.log(e);
                $("#users li").remove();
                $.each(e, function (i, val) {
                    $("#users").append('<li class="list-group-item">            <div class="media">' +
                        '                <div class="media-left">' +
                        '                    <a href="#">' +
                        '                        <img class="media-object img32" src="' + val.avatar + '" alt="' + val.nickname + '">' +
                        '                    </a>' +
                        '                </div>' +
                        '                <div class="media-body">' +
                        '<h5 class="media-heading">' + val.nickname +
                        '<small> <span class="glyphicon glyphicon-map-marker"></span>(' + val.distance + ')</small></h5>' +
                        '                    <p>' + words[val.message] + '</p>' +
                        '                </div>' +
                        '            </div></li>');
                    console.log(i);
                    console.log(val);
                });
            });
        }
        users();
</script>
</body>
</html>
       public ContentResult WXConfig()
        {
            ContentResult js = new ContentResult
            {
                ContentType = "text/javascript"
            };
            string url = Request.Headers["Referer"].FirstOrDefault();
            string urled = url.Split('#')[0];
            WXConfigData config = new WXConfigData(urled);
            js.Content =
                "wx.config({\r\n" +
                "  debug: false, \r\n" +
                $"  appId: '{WXHelper.APPID}', \r\n" +
                $"  timestamp: '{config.TimeStamp}', \r\n" +
                $"  nonceStr: '{config.NonceStr}',\r\n" +
                $"  signature: '{config.Signature}',\r\n" +
                $"  jsApiList: ['updateAppMessageShareData','updateTimelineShareData','getLocation'] \r\n" +
                "});\r\n" +
                "wx.ready(function(){\r\n" +
                "  wx.updateAppMessageShareData({ \r\n" +
                "    title: '微信WEB DEMO - 微信分享', \r\n" +
                "    desc: '微信WEB DEMO - 微信分享',\r\n" +
                $"    link: '{urled.Split('#')[0]}',\r\n" +
                "    imgUrl: 'https://www.chenxin.info/wx/icon.jpg',\r\n" +
                "    success: function () {    }});\r\n" +
                "  wx.updateTimelineShareData({ \r\n" +
                "    title: '微信WEB DEMO 朋友圈分享', \r\n" +
                $"  link: '{urled.Split('#')[0]}', \r\n" +
                "    imgUrl: 'https://www.chenxin.info/wx/icon.jpg',\r\n" +
                "    success: function () {    }});\r\n" +
                "});\r\n" +
                "wx.error(function(res){" +
                "    alert(res.errMsg);console.log(res);" +
                "});";
            return js;
        }
        public async Task<ContentResult> WXAuth()
        {
            ContentResult js = new ContentResult
            {
                ContentType = "text/javascript"
            };
            string open_id = "";
            string nick = "";
            //Referer即为引用该JS的页面
            string url = Request.Headers["Referer"].FirstOrDefault();
            Console.WriteLine("Referer:\r\n" + url);
            WxUser user = General.GetUser(HttpContext);
            if (General.Users.Count(u => u.Openid == user?.Openid) == 0 && HttpContext.User.Identity.IsAuthenticated) await HttpContext.SignOutAsync();
            if (user is null)
            {
                //检测是否在微信中打开
                bool isMicroMsg = false;
                string ua = Request.Headers["User-Agent"].FirstOrDefault();
                if (ua.ToLower().Contains("micromessenger")) isMicroMsg = true;
                if (!isMicroMsg)
                {
                    Console.WriteLine("非微信:" + ua);
                    return Content("alert('请在微信中打开')");
                }

                if (string.IsNullOrEmpty(url)) return Content("bad request.");

                string code = Regex.Match(url, "(?<=code=).*?(?=(&|$))").Value;
                if (string.IsNullOrEmpty(code))
                {
                    //URL不带code时,刷新授权页面,scope=snsapi_base
                    js.Content = "window.location.href=" +
                        $"'https://open.weixin.qq.com/connect/oauth2/authorize?" +
                        $"appid={WXHelper.APPID}&" +
                        $"redirect_uri={HttpUtility.UrlEncode(url)}&" +
                        $"response_type=code&scope=snsapi_base&state=0#wechat_redirect';";
                    Console.WriteLine("无CODE,跳转snsapi_base\r\n" + js.Content);
                    return js;
                }
                if (WXHelper.CodeHistory.Contains(code))
                {
                    //CODE一次性有效,可以存起来备验,避免拿无效code额外请求一次接口
                    //如果CODE用过,直接删除URL参数部分刷新页面
                    js.Content = "window.location.href=" +
                     $"'https://open.weixin.qq.com/connect/oauth2/authorize?" +
                     $"appid={WXHelper.APPID}&" +
                     $"redirect_uri={HttpUtility.UrlEncode(url.Split('?')[0].Split('#')[0])}&" +
                     $"response_type=code&scope=snsapi_base&state=0#wechat_redirect';";
                    Console.WriteLine("用过的CODE,跳转snsapi_base\r\n" + js.Content);
                    return js;
                }

                //请求access_token,同时也会一并返回open_id,至此就可以进行用户识别了
                using WebClient client = new WebClient();
                string resp = client.DownloadString(
                      $"https://api.weixin.qq.com/sns/oauth2/access_token?" +
                      $"appid={WXHelper.APPID}&" +
                      $"secret={WXHelper.APPSecret}&" +
                      $"code={code}&grant_type=authorization_code");
                Console.WriteLine("ACCESS_TOKEN_REQUEST:\t" + resp);
                string access_token = Regex.Match(resp, @"(?<=access_token"":"").*?(?="")").Value;
                if (string.IsNullOrEmpty(access_token))
                {//用code请求access_token失败时,重新刷新页面
                    js.Content = "window.location.href=" +
                        $"'https://open.weixin.qq.com/connect/oauth2/authorize?" +
                        $"appid={WXHelper.APPID}&" +
                        $"redirect_uri={HttpUtility.UrlEncode(url.Split('?')[0].Split('#')[0])}&" +
                        $"response_type=code&scope=snsapi_base&state=0#wechat_redirect';";
                    Console.WriteLine("获取access_token失败,跳转snsapi_base\r\n" + js.Content);
                    return js;
                }

                //获取到access_token,就可以正常进行业务了
                open_id = Regex.Match(resp, @"(?<=openid"":"").*?(?="")").Value;
                if (General.Users.Count(u => u.Openid == open_id) == 0)
                {//未登记过的用户,先获取信息,成功则下载头像,失败则重新请求snsapi_userinfo
                    resp = client.DownloadString(
                       $"https://api.weixin.qq.com/sns/userinfo?access_token={access_token}&openid={open_id}&lang=zh_CN");
                    Console.WriteLine("获取userinfo\r\n" + resp);
                    nick = Regex.Match(resp, @"(?<=nickname"":"").*?(?="")").Value;
                    string avatar = Regex.Match(resp, @"(?<=headimgurl"":"").*?(?="")").Value;
                    if (!string.IsNullOrEmpty(nick))
                    {//成功获取到昵称时,保存并登录                        
                        General.Users.Add(
                            new WxUser() { Openid = open_id, Avatar = avatar.Replace("\\", ""), Created = DateTime.Now, Nickname = nick }
                            );
                        var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
                        identity.AddClaim(new Claim(ClaimTypes.Sid, open_id));
                        await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity)).ConfigureAwait(false);
                        //登录以后需要再刷新一次页面,更新登录状态
                        js.Content = "window.location.reload()";
                        return js;
                    }
                    else
                    {//未成功取到昵称时,重新请求snsapi_userinfo
                        js.Content =
                            $"\r\nwindow.location.href=" +
                            $"'https://open.weixin.qq.com/connect/oauth2/authorize?" +
                            $"appid={WXHelper.APPID}&" +
                            $"redirect_uri={HttpUtility.UrlEncode(url.Split('?')[0].Split('#')[0])}&" +
                            $"response_type=code&scope=snsapi_userinfo&state=0&connect_redirect=1#wechat_redirect';";
                        return js;
                    }
                }
            }
            if (url.ToLower().Contains("code="))
            {
                js.Content = $"window.location.href='/'";
            }
            else
            {
                js.Content = $"var words={JsonConvert.SerializeObject(General.Words)}";
            }
            return js;
        }

最终效果

分类: articles