zoukankan      html  css  js  c++  java
  • 微信 JS-SDK 开发实现录音和图片上传功能


    title: 微信 JS-SDK 开发实现录音和图片上传功能
    date: 2018-08-13 19:00:00
    toc: true #是否显示目录 table of contents
    tags:

    • WeiXin
    • H5
      categories: C#.NET
      typora-root-url: ..

    现有一个 .NET 开发的 Wap 网站项目,需要用到录音和图片上传功能。目前该网站只部署在公众号中使用,并且手机录音功能的实现只能依赖于微信的接口(详见另一篇文章《HTML5 实现手机原生功能》),另外采用即时拍照来上传图片的功能也只能调用微信接口才能实现,所以本文简要地记录下后端 .NET 、前端 H5 开发的网站如何调用微信接口实现录音和即时拍照上传图片功能。

    准备工作

    微信公众平台配置

    1、(必须配置)打开微信公众平台-公众号设置-功能设置-JS接口安全域名 ,按提示配置。需要将网站发布到服务器上并绑定域名。加xx.com即可,yy.xx.com也能调用成功。

    JS接口安全域名

    设置JS接口安全域名后,公众号开发者可在该域名下调用微信开放的JS接口。
    注意事项:
    1、可填写三个域名或路径(例:wx.qq.com或wx.qq.com/mp),需使用字母、数字及“-”的组合,不支持IP地址、端口号及短链域名。
    2、填写的域名须通过ICP备案的验证。
    3、 将文件 MP_verify_iBVYET3obIwgppnr.txt(点击下载)上传至填写域名或路径指向的web服务器(或虚拟主机)的目录(若填写域名,将文件放置在域名根目录下,例如wx.qq.com/MP_verify_iBVYET3obIwgppnr.txt;若填写路径,将文件放置在路径目录下,例如wx.qq.com/mp/MP_verify_iBVYET3obIwgppnr.txt),并确保可以访问。
    4、 一个自然月内最多可修改并保存三次,本月剩余保存次数:3

    2、打开微信公众平台-公众号设置-功能设置-网页授权域名,按提示配置(这一步在当前开发需求中可能不需要)。

    3、(必须配置)打开微信公众平台-基本配置-公众号开发信息-IP白名单,配置网页服务器的公网IP(通过开发者IP及密码调用获取access_token 接口时,需要设置访问来源IP为白名单)。

    安装微信开发者工具,并绑定开发者微信号

    微信开发者工具方便公众号网页和微信小程序在PC上进行调试,下载地址说明文档

    1. 打开微信公众平台-开发者工具-Web开发者工具-绑定开发者微信号,将自己的微信号绑定上去(所绑定的微信号要先关注“公众平台安全助手”,绑定后可在此查看绑定记录),即可进行调试。若未绑定,则在打开公众号网页的时候,会报错“未绑定网页开发者”。
    2. 打开微信开发者工具,按提示配置。

    具体实现步骤

    参考微信公众平台技术文档,其中的《附录1-JS-SDK使用权限签名算法》(关于授权的文档找不到了)。

    1、先要获取 access_token,并缓存到Redis

    /// <summary>
    /// 获取AccessToken
    /// 参考 https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140183
    /// </summary>
    /// <returns>access_toke</returns>
    public string GetAccessToken()
    {
        var cache = JCache<string>.Instance;
        var config = Server.ServerManage.Config.Weixin;
    
        // 获取并缓存 access_token
        string access_token = cache.GetOrAdd("access_token", "weixin", (i, j) =>
        {
            var url = "https://api.weixin.qq.com/cgi-bin/token";// 注意这里不是用"https://api.weixin.qq.com/sns/oauth2/access_token"
            var result = HttpHelper.HttpGet(url, "appid=" + config.AppId + "&secret=" + config.AppSecret + "&grant_type=client_credential");
    
            // 正常情况返回 {"access_token":"ACCESS_TOKEN","expires_in":7200}
            // 错误时返回 {"errcode":40013,"errmsg":"invalid appid"}
            var token = result.FormJObject();
            if (token["errcode"] != null)
            {
                throw new JException("微信接口异常access_token:" + (string)token["errmsg"]);
            }
            return (string)token["access_token"];
        }, new TimeSpan(0, 0, 7200));
    
        return access_token;
    }
    

    2、通过 access_token 获取 jsapi_ticket,并缓存到Redis

    /// <summary>
    /// 获取jsapi_ticket
    /// jsapi_ticket是公众号用于调用微信JS接口的临时票据。
    /// 正常情况下,jsapi_ticket的有效期为7200秒,通过access_token来获取。
    /// 由于获取jsapi_ticket的api调用次数非常有限,频繁刷新jsapi_ticket会导致api调用受限,影响自身业务,开发者必须在自己的服务全局缓存jsapi_ticket 。
    /// </summary>
    public string GetJsapiTicket()
    {
        var cache = JCache<string>.Instance;
        var config = Server.ServerManage.Config.Weixin;
    
        // 获取并缓存 jsapi_ticket
        string jsapi_ticket = cache.GetOrAdd("jsapi_ticket", "weixin", (k, r) =>
        {
            var access_token = GetAccessToken();
            var url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket";
            var result = HttpHelper.HttpGet(url, "access_token=" + access_token + "&type=jsapi");
    
            // 返回格式 {"errcode":0,"errmsg":"ok","ticket":"字符串","expires_in":7200}
            var ticket = result.FormJObject();
            if ((string)ticket["errmsg"] != "ok")
            {
                throw new JException("微信接口异常ticket:" + (string)ticket["errmsg"]);
            }
            return (string)ticket["ticket"];
        }, new TimeSpan(0, 0, 7200));
    
        return jsapi_ticket;
    }
    

    3、用 SHA1 加密后,返回数据到页面上

    /// <summary>
    /// 微信相关的接口
    /// </summary>
    public class WeixinApi
    {
        /// <summary>
        /// 获取微信签名
        /// </summary>
        /// <param name="url">请求的页面地址</param>
        /// <param name="config">微信配置</param>
        /// <returns></returns>
        public static object GetSignature(string url)
        {
            var config = Park.Server.ServerManage.Config.Weixin;
            var jsapi_ticket = GetJsapiTicket();
    
            // SHA1加密
            // 对所有待签名参数按照字段名的ASCII 码从小到大排序(字典序)后,使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串
            var obj = new
            {
                jsapi_ticket = jsapi_ticket,
                //必须与wx.config中的nonceStr和timestamp相同
                noncestr = JString.GenerateNonceStr(),
                timestamp = JString.GenerateTimeStamp(),
                url = url, // 必须是调用JS接口页面的完整URL(location.href.split('#')[0])
            };
            var str = $"jsapi_ticket={obj.jsapi_ticket}&noncestr={obj.noncestr}&timestamp={obj.timestamp}&url={obj.url}";
            var signature = FormsAuthentication.HashPasswordForStoringInConfigFile(str, "SHA1");
    
            return new
            {
                appid = config.AppId,
                noncestr = obj.noncestr,
                timestamp = obj.timestamp,
                signature = signature,
            };
        }
    }
    
    /// <summary>
    /// 微信JS-SDK接口
    /// </summary>
    public class WeixinController : BaseController
    {
        /// <summary>
        /// 获取微信签名
        /// </summary>
        /// <param name="url">请求的页面地址</param>
        /// <returns>签名信息</returns>
        [HttpGet]
        public JResult GetSignature(string url)
        {
            return JResult.Invoke(() =>
            {
                return WeixinApi.GetSignature(url);
            });
        }
    }
    

    微信 JS 接口签名校验工具

    4、Ajax请求签名数据,并配置到 wx.config,进而实现对微信录音功能的调用。

    /*
            -----------------------调用微信JS-SDK接口的JS封装--------------------------------
    */
    
    if (Park && Park.Api) {
        Park.Weixin = {
            // 初始化配置
            initConfig: function (fn) {
                // todo: 读取本地wx.config信息,若没有或过期则重新请求
                var url = location.href.split('#')[0];
                Park.get("/Weixin/GetSignature", { url: url }, function (d) {
                    Park.log(d.Data);
                    if (d.Status) {
                        wx.config({
                            debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
                            appId: d.Data.appid,// 必填,公众号的唯一标识
                            nonceStr: d.Data.noncestr,// 必填,生成签名的随机串
                            timestamp: d.Data.timestamp,// 必填,生成签名的时间戳
                            signature: d.Data.signature,// 必填,签名,见附录1
                            jsApiList: [ // 必填,需要使用的JS接口列表,所有JS接口列表见附录2
                              'checkJsApi', 'translateVoice', 'startRecord', 'stopRecord', 'onVoiceRecordEnd', 'playVoice', 'onVoicePlayEnd', 'pauseVoice', 'stopVoice',
                              'uploadVoice', 'downloadVoice', 'chooseImage', 'previewImage', 'uploadImage', 'downloadImage', 'getNetworkType', 'openLocation', 'getLocation', ]
                        });
    
                        wx.ready(function () {
                            fn && fn();
                        });
                    }
                });
            },
    
            // 初始化录音功能
            initUploadVoice: function (selector, fn) {
                var voiceUpload = false; // 是否可上传(避免60秒自动停止录音和松手停止录音重复上传)
                
                // 用localStorage进行记录,之前没有授权的话,先触发录音授权,避免影响后续交互
                if (!localStorage.allowRecord || localStorage.allowRecord !== 'true') {
                    wx.startRecord({
                        success: function () {
                            localStorage.allowRecord = 'true';
                            // 仅仅为了授权,所以立刻停掉
                            wx.stopRecord();
                        },
                        cancel: function () {
                            alert('用户拒绝授权录音');
                        }
                    });
                }
    
                var btnRecord = $("" + selector);
                btnRecord.on('touchstart', function (event) {
                    event.preventDefault();
                    btnRecord.addClass('hold');
                    startTime = new Date().getTime();
                    // 延时后录音,避免误操作
                    recordTimer = setTimeout(function () {
                        wx.startRecord({
                            success: function () {
                                voiceUpload = true;
                            },
                            cancel: function () {
                                alert('用户拒绝授权录音');
                            }
                        });
                    }, 300);
                }).on('touchend', function (event) {
                    event.preventDefault();
                    btnRecord.removeClass('hold');
                    // 间隔太短
                    if (new Date().getTime() - startTime < 300) {
                        startTime = 0;
                        // 不录音
                        clearTimeout(recordTimer);
                        alert('录音时间太短');
                    } else { // 松手结束录音
                        if(voiceUpload){
                            voiceUpload = false;
                        	wx.stopRecord({
                            	success: function (res) {
                                	// 上传到本地服务器
                                	wxUploadVoice(res.localId, fn);
                            	},
                            	fail: function (res) {
                                	alert(JSON.stringify(res));
                            	}
                        	});
                        }
                    }
                });
    
                // 微信60秒自动触发停止录音
                wx.onVoiceRecordEnd({
                    // 录音时间超过一分钟没有停止的时候会执行 complete 回调
                    complete: function (res) {
                        voiceUpload = false;
                        alert("录音时长不超过60秒");
                        // 上传到本地服务器
                        wxUploadVoice(res.localId, fn);
                    }
                });
            },
    
            // 初始化图片功能
            initUploadImage: function (selector, fn, num) {
                // 图片上传功能
                // 参考 https://blog.csdn.net/fengqingtao2008/article/details/51469705
                // 本地预览及删除功能参考 https://www.cnblogs.com/clwhxhn/p/6688571.html
                $("" + selector).click(function () {
                    wx.chooseImage({
                        count: num || 1, // 默认9
                        sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图,默认二者都有
                        sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
                        success: function (res) {//微信返回了一个资源对象
                            //localIds = res.localIds;//把图片的路径保存在images[localId]中--图片本地的id信息,用于上传图片到微信浏览器时使用
                            // 上传到本地服务器
                            wxUploadImage(res.localIds, 0, fn);
                        }
                    });
                });
            },
    
        }
    }
    
    //上传录音到本地服务器,并做业务逻辑处理
    function wxUploadVoice(localId, fn) {
        //调用微信的上传录音接口把本地录音先上传到微信的服务器
        //不过,微信只保留3天,而我们需要长期保存,我们需要把资源从微信服务器下载到自己的服务器
        wx.uploadVoice({
            localId: localId, // 需要上传的音频的本地ID,由stopRecord接口获得
            isShowProgressTips: 1, // 默认为1,显示进度提示
            success: function (res) {
                //把录音在微信服务器上的id(res.serverId)发送到自己的服务器供下载。
                $.ajax({
                    url: Park.getApiUrl('/Weixin/DownLoadVoice'),
                    type: 'get',
                    data: res,
                    dataType: "json",
                    success: function (d) {
                        if (d.Status) {
                            fn && fn(d.Data);
                        }
                        else {
                            alert(d.Msg);
                        }
                    },
                    error: function (xhr, errorType, error) {
                        console.log(error);
                    }
                });
            },
            fail: function (res) {
                // 60秒的语音这里报错:{"errMsg":"uploadVoice:missing arguments"}
                alert(JSON.stringify(res));
            }
        });
    }
    
    //上传图片到微信,下载到本地,并做业务逻辑处理
    function wxUploadImage(localIds, i, fn) {
        var length = localIds.length; //本次要上传所有图片的数量
        wx.uploadImage({
            localId: localIds[i], //图片在本地的id
            success: function (res) {//上传图片到微信成功的回调函数   会返回一个媒体对象  存储了图片在微信的id
                //把录音在微信服务器上的id(res.serverId)发送到自己的服务器供下载。
                $.ajax({
                    url: Park.getApiUrl('/Weixin/DownLoadImage'),
                    type: 'get',
                    data: res,
                    dataType: "json",
                    success: function (d) {
                        if (d.Status) {
                            fn && fn(d.Data);
                        }
                        else {
                            alert(d.Msg);
                        }
                        i++;
                        if (i < length) {
                            wxUploadImage(localIds, i, fn);
                        }
                    },
                    error: function (xhr, errorType, error) {
                        console.log(error);
                    }
                });
            },
            fail: function (res) {
                alert(JSON.stringify(res));
            }
        });
    };
    
    // 页面上调用微信接口JS
    
    Park.Weixin.initConfig(function () {
        Park.Weixin.initUploadVoice("#voice-dp", function (d) {
            // 业务逻辑处理(d为录音文件在本地服务器的资源路径)
        });
        Park.Weixin.initUploadImage("#img-dp", function (d) {
            // 业务逻辑处理(d为图片文件在本地服务器的资源路径)
            $("#img").append("<img src='" + Park.getImgUrl(d) + "' />");
        })
    });
    

    5、微信录音完成、图片上传成功后,在后端下载录音、图片到本地服务器

    // 即第4步中的'/Weixin/DownLoadVoice'和'/Weixin/DownLoadImage'方法的后端实现
    
    /// <summary>
    /// 微信JS-SDK接口控制器
    /// </summary>
    public class WeixinController : BaseController
    {
        /// <summary>
        /// 下载微信语音文件 
        /// </summary>
        /// <link>https://www.cnblogs.com/hbh123/archive/2017/08/15/7368251.html</link>
        /// <param name="serverId">语音的微信服务器端ID</param>
        /// <returns>录音保存路径</returns>
        [HttpGet]
        public JResult DownLoadVoice(string serverId)
        {
            return JResult.Invoke(() =>
            {
                return WeixinApi.GetVoicePath(serverId);
            });
        }
    
        /// <summary>
        /// 下载微信语音文件 
        /// </summary>
        /// <link>https://blog.csdn.net/fengqingtao2008/article/details/51469705</link>
        /// <param name="serverId">图片的微信服务器端ID</param>
        /// <returns>图片保存路径</returns>
        [HttpGet]
        public JResult DownLoadImage(string serverId)
        {
            return JResult.Invoke(() =>
            {
                return WeixinApi.GetImagePath(serverId);
            });
        }
    }
    
    /// <summary>
    /// 微信相关的接口实现类
    /// </summary>
    public class WeixinApi
    {
        /// <summary>
        /// 将微信语音保存到本地服务器
        /// </summary>
        /// <param name="serverId">微信录音ID</param>
        /// <returns></returns>
        public static string GetVoicePath(string serverId)
        {
            var rootPath = Park.Server.ServerManage.Config.ResPath;
            string voice = "";
            //调用downloadmedia方法获得downfile对象
            Stream downFile = DownloadFile(serverId);
            if (downFile != null)
            {
                string fileName = Guid.NewGuid().ToString();
                string path = "\voice\" + DateTime.Now.ToString("yyyyMMdd");
                string phyPath = rootPath + path;
                if (!Directory.Exists(phyPath))
                {
                    Directory.CreateDirectory(phyPath);
                }
    
                // 异步处理(解决当文件稍大时页面上ajax请求没有返回的问题)
                Task task = new Task(() =>
                {
                	//生成amr文件
                	var armPath = phyPath + "\" + fileName + ".amr";
                	using (FileStream fs = new FileStream(armPath, FileMode.Create))
                	{
                    	byte[] datas = new byte[downFile.Length];
                    	downFile.Read(datas, 0, datas.Length);
                    	fs.Write(datas, 0, datas.Length);
                	}
    
                	//转换为mp3文件
                	string mp3Path = phyPath + "\" + fileName + ".mp3";
                	JFile.ConvertToMp3(rootPath, armPath, mp3Path);
                });
                task.Start();
    
                voice = path + "\" + fileName + ".mp3";
            }
            return voice;
        }
    
        /// <summary>
        /// 将微信图片保存到本地服务器
        /// </summary>
        /// <param name="serverId">微信图片ID</param>
        /// <returns></returns>
        public static string GetImagePath(string serverId)
        {
            var rootPath = Park.Server.ServerManage.Config.ResPath;
            string image = "";
            //调用downloadmedia方法获得downfile对象
            Stream downFile = DownloadFile(serverId);
            if (downFile != null)
            {
                string fileName = Guid.NewGuid().ToString();
                string path = "\image\" + DateTime.Now.ToString("yyyyMMdd");
                string phyPath = rootPath + path;
                if (!Directory.Exists(phyPath))
                {
                    Directory.CreateDirectory(phyPath);
                }
    
                //生成jpg文件
                var jpgPath = phyPath + "\" + fileName + ".jpg";
                using (FileStream fs = new FileStream(jpgPath, FileMode.Create))
                {
                    byte[] datas = new byte[downFile.Length];
                    downFile.Read(datas, 0, datas.Length);
                    fs.Write(datas, 0, datas.Length);
                }
    
                image = path + "\" + fileName + ".mp3";
            }
            return image;
        }
    
        /// <summary>
        /// 下载多媒体文件
        /// </summary>
        /// <param name="media_id"></param>
        /// <returns></returns>
        public static Stream DownloadFile(string media_id)
        {
            var access_token = GetAccessToken();
            string url = "http://file.api.weixin.qq.com/cgi-bin/media/get?";
            var action = url + $"access_token={access_token}&media_id={media_id}";
    
            HttpWebRequest myRequest = WebRequest.Create(action) as HttpWebRequest;
            myRequest.Method = "GET";
            myRequest.Timeout = 20 * 1000;
            HttpWebResponse myResponse = myRequest.GetResponse() as HttpWebResponse;
            var stream = myResponse.GetResponseStream();
            var ct = myResponse.ContentType;
            // 返回错误信息
            if (ct.IndexOf("json") >= 0 || ct.IndexOf("text") >= 0)
            {
                using (StreamReader sr = new StreamReader(stream))
                {
                    // 返回格式 {"errcode":0,"errmsg":"ok"}
                    var json = sr.ReadToEnd().FormJObject();
                    var errcode = (int)json["errcode"];
                    // 40001 被其他地方使用 || 42001 过期
                    if (errcode == 40001 || errcode == 42001)  
                    {
                        // 重新获取token
                        var cache = Park.Common.JCache.Instance;
                        cache.Remove("access_token", "weixin");
                        return DownloadFile(media_id);
                    }
                    else
                    {
                        throw new JException(json.ToString());
                    }
                }
            }
            // 成功接收到数据
            else
            {
                Stream MyStream = new MemoryStream();
                byte[] buffer = new Byte[4096];
                int bytesRead = 0;
                while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) != 0)
                    MyStream.Write(buffer, 0, bytesRead);
                MyStream.Position = 0;
                return MyStream;
            }
        }
    }
    
    /// <summary>
    /// 文件处理类
    /// </summary>
    public class JFile
    {
        /// <summary>
        /// 音频转换
        /// </summary>
        /// <link>https://www.cnblogs.com/hbh123/p/7368251.html</link>
        /// <param name="ffmpegPath">ffmpeg文件目录</param>
        /// <param name="soruceFilename">源文件</param>
        /// <param name="targetFileName">目标文件</param>
        /// <returns></returns>
        public static string ConvertToMp3(string ffmpegPath, string soruceFilename, string targetFileName)
        {
        	// 需事先将 ffmpeg.exe 放到 ffmpegPath 目录下
            string cmd = ffmpegPath + @"ffmpeg.exe -i " + soruceFilename + " -ar 44100 -ab 128k " + targetFileName;
            return ConvertWithCmd(cmd);
        }
    
        private static string ConvertWithCmd(string cmd)
        {
            try
            {
                System.Diagnostics.Process process = new System.Diagnostics.Process();
                process.StartInfo.FileName = "cmd.exe";
                process.StartInfo.UseShellExecute = false;
                process.StartInfo.CreateNoWindow = true;
                process.StartInfo.RedirectStandardInput = true;
                process.StartInfo.RedirectStandardOutput = true;
                process.StartInfo.RedirectStandardError = true;
                process.Start();
                process.StandardInput.WriteLine(cmd);
                process.StandardInput.AutoFlush = true;
                Thread.Sleep(1000);
                process.StandardInput.WriteLine("exit");
                process.WaitForExit();
                string outStr = process.StandardOutput.ReadToEnd();
                process.Close();
                return outStr;
            }
            catch (Exception ex)
            {
                return "error" + ex.Message;
            }
        }
    }
    

    参考文章

    微信jssdk录音功能开发记录
    微信语音上传下载
    ffmpeg.exe 下载地址

    报错及解决方案

    1、{"errcode":40001,"errmsg":"invalid credential, access_token is invalid or not latest hint: [2HYQIa0031ge10] "}

    情况1:获取到 access_token 后,去获取 jsapi_ticket 时报错。

    access_token 有两种。第一种是全局的和用户无关的access_token, 用 appid 和 appsecret 去获取(/cgi-bin/token)。第二种是和具体用户有关的,用 appid 和 appsecre 和 code 去获取 (/sns/oauth2/access_token)。这里需要的是第一种。

    情况2:完成配置,发起录音下载录音到本地时报错。

    原因:用同一个appid 和 appsecret 去获取 token,在不同的服务器去获取,导致前一个获取的token失效。解决方案:在下载录音到本地时,过滤报错信息,若为40001错误,则重新获取token。

    2、{"errMsg":"config:fail, Error: invalid signature"}、{"errMsg":"startRecord:fail, the permission value is offline verifying"}

    获取签名,并配置到 wx.config 之后,同时不报这两个错误信息。

    解决方案:生成的签名有误,注意各个签名参数的值和生成字符串时的顺序。

    3、{"errcode":40007,"errmsg":"invalid media_id hint: [7PNFFA01722884]"}

    在微信开发者工具中调试,上传录音的方法中从微信下载录音报错。

    解决方案: 在微信web开发者工具中调试会有这个问题,直接在微信调试则无此问题。

    4、{"errMsg":"uploadVoice:missing arguments"}

    录制60秒的语音自动停止后,上传语音到微信服务器报错,待解决。

  • 相关阅读:
    2019 SDN上机第5次作业
    SDN课程阅读作业(2)
    第05组 Alpha事后诸葛亮
    Ryu控制器编程开发——packet_in和packet_out简易交换机实现
    Ryu控制器安装部署和入门
    OpenDayLight Beryllium版本 下发流表实现hardtimeout
    Raspberry Pi 4B FTP服务器配置
    利用Wireshark抓取并分析OpenFlow协议报文
    基于OVS命令的VLAN实现
    利用Python脚本完成一个Fat-tree型的拓扑
  • 原文地址:https://www.cnblogs.com/ztpark/p/11075820.html
Copyright © 2011-2022 走看看