微信 JS-SDK 開發實現錄音和圖片上傳功能


title: 微信 JS-SDK 開發實現錄音和圖片上傳功能
date: 2018-08-13 19:00:00
toc: true #是否顯示目錄 table of contents
tags:html

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

現有一個 .NET 開發的 Wap 網站項目,須要用到錄音和圖片上傳功能。目前該網站只部署在公衆號中使用,而且手機錄音功能的實現只能依賴於微信的接口(詳見另外一篇文章《HTML5 實現手機原生功能》),另外採用即時拍照來上傳圖片的功能也只能調用微信接口才能實現,因此本文簡要地記錄下後端 .NET 、前端 H5 開發的網站如何調用微信接口實現錄音和即時拍照上傳圖片功能。前端

準備工做

微信公衆平臺配置

一、(必須配置)打開微信公衆平臺-公衆號設置-功能設置-JS接口安全域名 ,按提示配置。須要將網站發佈到服務器上並綁定域名。加xx.com便可,yy.xx.com也能調用成功。web

JS接口安全域名ajax

設置JS接口安全域名後,公衆號開發者可在該域名下調用微信開放的JS接口。
注意事項:
一、可填寫三個域名或路徑(例:wx.qq.com或wx.qq.com/mp),需使用字母、數字及「-」的組合,不支持IP地址、端口號及短鏈域名。
二、填寫的域名須經過ICP備案的驗證。
三、 將文件 MP_verify_iBVYET3obIwgppnr.txt(點擊下載)上傳至填寫域名或路徑指向的web服務器(或虛擬主機)的目錄(若填寫域名,將文件放置在域名根目錄下,例如wx.qq.com/MP_verify_iBVYET3obIwgppnr.txt;若填寫路徑,將文件放置在路徑目錄下,例如wx.qq.com/mp/MP_verify_iBVYET3obIwgppnr.txt),並確保能夠訪問。
四、 一個天然月內最多可修改並保存三次,本月剩餘保存次數:3redis

二、打開微信公衆平臺-公衆號設置-功能設置-網頁受權域名,按提示配置(這一步在當前開發需求中可能不須要)。算法

三、(必須配置)打開微信公衆平臺-基本配置-公衆號開發信息-IP白名單,配置網頁服務器的公網IP(經過開發者IP及密碼調用獲取access_token 接口時,須要設置訪問來源IP爲白名單)。json

安裝微信開發者工具,並綁定開發者微信號

微信開發者工具方便公衆號網頁和微信小程序在PC上進行調試,下載地址說明文檔小程序

  1. 打開微信公衆平臺-開發者工具-Web開發者工具-綁定開發者微信號,將本身的微信號綁定上去(所綁定的微信號要先關注「公衆平臺安全助手」,綁定後可在此查看綁定記錄),便可進行調試。若未綁定,則在打開公衆號網頁的時候,會報錯「未綁定網頁開發者」。
  2. 打開微信開發者工具,按提示配置。

具體實現步驟

參考微信公衆平臺技術文檔,其中的《附錄1-JS-SDK使用權限簽名算法》(關於受權的文檔找不到了)。c#

一、先要獲取 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;
}

二、經過 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;
}

三、用 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 接口簽名校驗工具後端

四、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) + "' />");
    })
});

五、微信錄音完成、圖片上傳成功後,在後端下載錄音、圖片到本地服務器

// 即第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 下載地址

報錯及解決方案

一、{"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。

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

獲取簽名,並配置到 wx.config 以後,同時不報這兩個錯誤信息。

解決方案:生成的簽名有誤,注意各個簽名參數的值和生成字符串時的順序。

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

在微信開發者工具中調試,上傳錄音的方法中從微信下載錄音報錯。

解決方案: 在微信web開發者工具中調試會有這個問題,直接在微信調試則無此問題。

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

錄製60秒的語音自動中止後,上傳語音到微信服務器報錯,待解決。

相關文章
相關標籤/搜索