2023年2月

我在前面一篇随笔《
Socket开发框架之框架设计及分析
》中,介绍了整个Socket开发框架的总体思路,对各个层次的基类进行了一些总结和抽象,已达到重用、简化代码的目的。本篇继续分析其中重要的协议设计部分,对其中消息协议的设计,以及数据的拆包和封包进行了相关的介绍,使得我们在更高级别上更好利用Socket的特性。

1、协议设计思路

对Socket传输消息的封装和拆包,一般的Socket应用,多数采用基于顺序位置和字节长度的方式来确定相关的内容,这样的处理方式可以很好减少数据大小,但是这些处理对我们分析复杂的协议内容,简直是一场灾难。对跟踪解决过这样协议的开发人员来说会很好理解其中的难处,协议位置一旦变化或者需要特殊的处理,就是很容易出错的,而且大多数代码充斥着很多位置的数值变量,分析和理解都是非常不便的。随着网络技术的发展,有时候传输的数据稍大一点,损失一些带宽来传输数据,但是能成倍提高开发程序的效率,是我们值得追求的目标。例如,目前Web API在各种设备大行其道,相对Socket消息来说,它本身在数据大小上不占优势,但是开发的便利性和高效性,是众所周知的。

借鉴了Web API的特点来考虑Socket消息的传输,如果对于整体的内容,Socket应用也使用一种比较灵活的消息格式,如JSON格式来传输数据,那么我们可以很好的把消息封装和消息拆包解析两个部分,交给第三方的JSON解析器来进行,我们只需要关注具体的消息处理逻辑就可以了,而且对于协议的扩展,就如JSON一样,可以自由灵活,这样瞬间,整个世界都会很清静了。

对于Socket消息的安全性和完整性,加密处理方面我们可以采用 RSA公钥密码系统。平台通过发送平台RSA公钥消息向终端告知自己的RSA公钥,终端回复终端RSA公钥消息,这样平台和终端的消息,就可以通过自身的私钥加密,让对方根据接收到的公钥解密就可以了,虽然加密的数据长度会增加不少,但是对于安全性要求高的,采用这种方式也是很有必要的。

对于数据的完整性,传统意义的CRC校验码其实没有太多的用处了,因为我们的数据不会发生部分的丢失,而我们更应该关注的是数据是否被篡改过,这点我想到了微信公众号API接口的设计,它们带有一个安全签名的加密字符串,也就是对其中内容进行同样规则的加密处理,然后对比两个签名内容是否一致即可。不过对于非对称的加密传输,这种数据完整性的校验也可以不必要。

前面介绍了,我们可以参照Web API的方式,以JSON格式作为我们传输的内容,方便序列号和反序列化,这样我们可以大大降低Socket协议的分析难度和出错几率,降低Socket开发难度并提高开发应用的速度。那么我们应该如何设计这个格式呢?

首先我们需要为Socket消息,定义好开始标识和结束标识,中间部分就是整个通用消息的JSON内容。这样,一条完整的Socket消息内容,除了开始和结束标识位外,剩余部分是一个JSON格式的字符串数据。

我们准备根据需要,设计好整个JSON字符串的内容,而且最好设计的较为通用一些,这样便于我们承载更多的数据信息。

2、协议设计分析和演化

参考微信的API传递消息的定义,我设计了下面的消息格式,包括了送达用户ID,发送用户ID、消息类型、创建时间,以及一个通用的内容字段,这个通用的字段应该是另外一个消息实体的JSON字符串,这样我们整个消息格式不用变化,但是具体的内容不同,我们把这个对象类称之BaseMessage,常用字段如下所示。

上面的Content字段就是用来承载具体的消息数据的,它会根据不同的消息类型,传送不同的内容的,而这些内容也是具体的实体类序列化为JSON字符串的,我们为了方便,也设计了这些类的基类,也就是Socket传递数据的实体类基类BaseEntity。

我们在不同的请求和应答消息,都继承于它即可。我们为了方便让它转换为我们所需要的BaseMessage消息,为它增加一个MsgType协议类型的标识,同时增加PackData的方法,让它把实体类转换为JSON字符串。

例如我们一般情况下的请求Request和应答Response的消息对象,都是继承自BaseEntity的,我们可以把这两类消息对象放在不同的目录下方便管理。

继承关系示例如下所示。

其中子类都可以使用基类的PackData方法,直接序列号为JSON字符串即可,那个PacketData的函数主要就是用来组装好待发送的对象BaseMessage的,函数代码如下所示:

        /// <summary>
        ///封装数据进行发送/// </summary>
        /// <returns></returns>
        publicBaseMessage PackData()
{
BaseMessage info
= newBaseMessage()
{
MsgType
= this.MsgType,
Content
= this.SerializeObject()
};
returninfo;
}

有时候我们需要根据请求的信息,用来构造返回的应答消息,因为需要把发送者ID和送达者ID逆反过来。

        /// <summary>
        ///封装数据进行发送(复制请求部分数据)/// </summary>
        /// <returns></returns>
        publicBaseMessage PackData(BaseMessage request)
{
BaseMessage info
= newBaseMessage()
{
MsgType
= this.MsgType,
Content
= this.SerializeObject(),
CallbackID
=request.CallbackID
};
if(!string.IsNullOrEmpty(request.ToUserId))
{
info.ToUserId
=request.FromUserId;
info.FromUserId
=request.ToUserId;
}
returninfo;
}

以登陆请求的数据实体对象介绍,它继承自BaseEntity,同时指定好对应的消息类型即可。

    /// <summary>
    ///登陆请求消息实体/// </summary>
    public classAuthRequest : BaseEntity
{
#region 字段信息 /// <summary> ///用户帐号/// </summary> public string UserId { get; set; }/// <summary> ///用户密码/// </summary> public string Password { get; set; }#endregion /// <summary> ///默认构造函数/// </summary> publicAuthRequest()
{
this.MsgType =DataTypeKey.AuthRequest;
}
/// <summary> ///参数化构造函数/// </summary> /// <param name="userid">用户帐号</param> /// <param name="password">用户密码</param> public AuthRequest(string userid, string password) : this()
{
this.UserId =userid;this.Password =password;
}
}

这样我们的消息内容就很简单,方便我们传递及处理了。

3、消息的接收和发送

前面我们介绍过了一些基类,包括Socket客户端基类,和数据接收的基类设计,这些封装能够给我提供很好的便利性。

在上面的BaseSocketClient里面,我们为了能够解析不同协议的Socket消息,把它转换为我们所需要的基类对象,那么我们这里引入一个解析器MessageSplitter,这个类主要的职责就是用来分析字节数据,并进行整条消息的提取的。

因此我们把BaseSocketClient的类定义的代码设计如下所示。

    /// <summary>
    ///基础的Socket操作类,提供连接、断开、接收和发送等相关操作。/// </summary>
    /// <typeparam name="TSplitter">对应的消息解析类,继承自MessageSplitter</typeparam>
    public class BaseSocketClient<TSplitter>  where TSplitter : MessageSplitter, new()

MessageSplitter对象,给我们处理低层次的协议解析,前面介绍了我们除了协议头和协议尾标识外,其余部分就是一个JSON的,那么它就需要根据这个规则来实现字节数据到对象级别的转换。

首先需要把字节数据进行拆分,把它完整的一条数据加到列表里面后续进行处理。

其中结尾部分,我们就是需要提取缓存的直接数据到一个具体的对象上了。

RawMessage msg = this.ConvertMessage(MsgBufferCache, from);

这个转换的大概规则如下所示。

这样我们在收到消息后,利用TSplitter对象来进行解析就可以了,如下所示就是对Socket消息的处理。

                    TSplitter splitter = newTSplitter();
splitter.InitParam(
this.Socket, this.StartByte, this.EndByte);//指定分隔符,用来拆包 splitter.DataReceived += splitter_DataReceived;//如果有完整的包处理,那么通过事件通知

数据接收并获取一条消息的直接数据对象后,我们就进一步把直接对象转换为具体的消息对象了

        /// <summary>
        ///消息分拆类收到消息事件/// </summary>
        /// <param name="data">原始消息对象</param>
        voidsplitter_DataReceived(RawMessage data)
{
ReceivePackCount
+= 1;//增加收到的包数量 OnReadRaw(data);
}
/// <summary> ///接收数据后的处理,可供子类重载/// </summary> /// <param name="data">原始消息对象(包含原始的字节数据)</param> protected virtual voidOnReadRaw(RawMessage data)
{
//提供默认的包体处理:假设整个内容为Json的方式;//如果需要处理自定义的消息体,那么需要在子类重写OnReadMessage方法。 if (data != null && data.Buffer != null)
{
var json =EncodingGB2312.GetString(data.Buffer);var msg = JsonTools.DeserializeObject<BaseMessage>(json); OnReadMessage(msg);//给子类重载 }
}

在更高一层的数据解析上面,我们就可以对对象级别的消息进行处理了

例如我们收到消息后,它本身解析为一个实体类BaseMessage的,那么我们就可以利用BaseMessage的消息内容,也可以把它的Content内容转换为对应的实体类进行处理,如下代码所示是接收对象后的处理。

        voidTextMsgAnswer(BaseMessage message)
{
var msg = string.Format("来自【{0}】的消息:", message.FromUserId);var request = JsonTools.DeserializeObject<TextMsgRequest>(message.Content);if (request != null)
{
msg
+= string.Format("{0} {1}", request.Message, message.CreateTime.IntToDateTime());
}
//MessageUtil.ShowTips(msg); Portal.gc.MainDialog.AppendMessage(msg);
}

对于消息的发送处理,我们可以举一个例子,如果客户端登陆后,需要获取在线用户列表,那么可以发送一个请求命令,那么服务器需要根据这个命令返回列表信息给终端,如下代码所示。

        /// <summary>
        ///处理客户端请求用户列表的应答/// </summary>
        /// <param name="data">具体的消息对象</param>
        private voidUserListProcess(BaseMessage data)
{
CommonRequest request
= JsonTools.DeserializeObject<CommonRequest>(data.Content);if (request != null)
{
Log.WriteInfo(
string.Format("############\r\n{0}", data.SerializeObject()));

List
<CListItem> list = new List<CListItem>();foreach(ClientOfShop client in Singleton<ShopClientManager>.Instance.LoginClientList.Values)
{
list.Add(
newCListItem(client.Id, client.Id));
}

UserListResponse response
= newUserListResponse(list);
Singleton
<ShopClientManager>.Instance.AddSend(data.FromUserId, response.PackData(data), true);
}
}

在前面两篇介绍了Socket框架的设计思路以及数据传输方面的内容,整个框架的设计指导原则就是易于使用及安全性较好,可以用来从客户端到服务端的数据安全传输,那么实现这个目标就需要设计好消息的传输和数据加密的处理。本篇主要介绍如何利用Socket传输协议来实现数据加密和数据完整性校验的处理,数据加密我们可以采用基于RSA非对称加密的方式来实现,数据的完整性,我们可以对传输的内容进行MD5数据的校验对比。

1、Socket框架传输内容分析

前面介绍过Socket的协议,除了起止标识符外,整个内容是一个JSON的字符串内容,这种格式如下所示。

上述消息内容,我们可以通过开始标识位和结束标识位,抽取出一个完整的Socket消息,这样我们对其中的JSON内容进行序列号就可以得到对应的实体类,我们定义实体类的内容如下所示。

我们把消息对象分为请求消息对象和应答消息对象,他们对应的是Request和Response的消息,也就是一个是发起的消息,一个是应答的消息。其中上图的“承载的JSON内容就是我们另一个传输对象的JSON字符串,这样我们通过这种字符串来传输不同对象的信息,就构造出了一个通用的消息实体对象。

另外这些传输的消息对象,它本身可以继承于一个实体类的基类,这样方便我们对它们的统一处理,如下图所示,就是一个通用的消息对象BaseMessage和其中JSON内容的对象关系图,如AuthRequest是登陆验证请求,AuthorRepsonse是登陆验证的应答。

当然,我们整个Socket应用,可以派生出很多类似的Request和Response的消息对象,如下所示是部分消息的定义。

对于非对称加密的处理,一般来说会有一些性能上的损失,不过我们考虑到如果是安全环境的数据传输处理的话,我们使用非对称加密还是比较好的。

当然也有人建议采用非对称加密部分内容,如双方采用约定的对称加密键,通过非对称加密的方式来传输这个加密键,然后两边采用对称加密算法来处理也是可以的。不过本框架主要介绍采用非对称加密的方式来加密其中的JSON内容,其他部分常规的信息不进行加密。

2、非对称加密的公钥传递

消息加密数据的传输前,我们需要交换算法的公钥,也就是服务器把自己公钥给客户端,客户端收到服务器的公钥请求后,返回客户端的公钥给服务器,实现两者的交换,以后双方的消息都通过对方公钥加密,把加密内容通过标准的Socket消息对象传递,这样对方收到的加密内容,就可以通过自身的私钥进行解密了。

那么要在传递消息前处理这个公钥交换的话,我们可以设计在服务器接入一个新的客户端连接后(在登录处理前),向客户端发送服务器的公钥,客户端受到服务器的公钥后,回应自己的公钥信息,并存储服务器的公钥。这样我们就可以在登陆的时候以及后面的消息传递过程中,使用对方公钥进行加密数据,实现较好的安全性。

公钥传递的过程如下图所示,也就是客户端发起连接服务器请求后,由服务器主动发送一个公钥请求命令,客户端收到后进行响应,发送自身的公钥给服务器,服务器把客户端的公钥信息存储在对应的Socket对象上,以后所有消息都通过客户端公钥加密,然后发送给客户端。

前面我们介绍过,我们所有的自定义Socket对象,都是继承于一个BaseSocketClient这样的基类对象,那么我们只需要在它的对象里面增加几个属性几个,一个是自己的公钥、私钥,一个是对方的公钥信息,如下所示。

在程序的启动后,包括客户端启动,服务器启动,我们都需要构建好自己的公钥私钥信息,如下代码是产生对应的公钥私钥信息,并存储在属性里面。

            using (RSACryptoServiceProvider rsa = newRSACryptoServiceProvider())
{
this.RSAPublicKey = rsa.ToXmlString(false);//公钥 this.RSAPrivateKey = rsa.ToXmlString(true);//私钥 }

例如在服务器端,在客户端Socket成功接入后,我们就给对应的客户端发送公钥请求消息,如下代码所示。

        /// <summary>
        ///客户端连接后的处理(如发送公钥秘钥)/// </summary>
        /// <param name="client">连接客户端</param>
        protected override voidOnAfterClientConnected(ClientOfShop client)
{
//先记录服务端的公钥,私钥 client.RSAPrivateKey =Portal.gc.RSAPrivateKey;
client.RSAPublicKey
=Portal.gc.RSAPublicKey;//发送一个公钥交换命令 var request = newRsaKeyRequest(Portal.gc.RSAPublicKey);var data =request.PackData();
client.SendData(data);
Thread.Sleep(
100);
}

那么在客户端,接收到服务端的消息后,对消息类型判断,如果是公钥请求,那么我们需要进行回应,把自己的公钥发给服务器,否则就进行其他的业务处理了。

        /// <summary>
        ///重写读取消息的处理/// </summary>
        /// <param name="message">获取到的完整Socket消息对象</param>
        protected override voidOnMessageReceived(BaseMessage message)
{
if (message.MsgType ==DataTypeKey.RSARequest)
{
var info = JsonTools.DeserializeObject<RsaKeyRequest>(message.Content);if (info != null)
{
//记录对方的公钥到Socket对象里面 this.PeerRSAPublicKey = Portal.gc.UseRSAEncrypt ? info.RSAPublicKey : "";
Console.WriteLine(
"使用RAS加密:{0},获取到加密公钥为:{1}", Portal.gc.UseRSAEncrypt, info.RSAPublicKey);//公钥请求应答 var publicKey = Portal.gc.UseRSAEncrypt ? Portal.gc.RSAPublicKey : "";var data = new RsaKeyResponse(publicKey);//返回客户端的公钥 var msg =data.PackData(message);

SendData(msg);
Thread.Sleep(
100);//暂停下 }
}
else{//交给业务消息处理过程 this.MessageReceiver.AppendMessage(message);this.MessageReceiver.Check();
}
}

如果我们交换成功后,我们后续的消息,就可以通过RSA非对称加密进行处理了,如下代码所示。

data.Content = RSASecurityHelper.RSAEncrypt(this.PeerRSAPublicKey, data.Content);

而解密消息,则是上面代码的逆过程,如下所示。

message.Content = RSASecurityHelper.RSADecrypt(this.RSAPrivateKey, message.Content);

最后我们把加密后的内容组成一个待发送的Socket消息,包含起止标识符,如下所示。

                //转为JSON,并组装为发送协议格式
                var json =JsonTools.ObjectToJson(data);
toSendData
= string.Format("{0}{1}{2}", (char)this.StartByte, json, (char)this.EndByte);

这样就是我们需要发送的消息内容了,我们拦截内容,可以看到大概的内容如下所示。

上面红色框的内容,必须使用原有的私钥才能进行解密,也就是在网络上,被谁拦截了,也无法进行解开,保证了数据的安全性。

3、数据完整性检查

数据的完整性,我们可以通过消息内容的MD5值进行比对,实现检查是否内容被篡改过,不过如果是采用了非对称加密,这种 完整性检查也可以忽略,不过我们可以保留它作为一个检查处理。

因此在封装数据的时候,就把内容部分MD5值计算出来,如下所示。

data.MD5 = MD5Util.GetMD5_32(data.Content);//获取内容的MD5值

然后在获得消息,并进行解密后(如果有),那么在服务器端计算一下MD5值,并和传递过来的MD5值进行比对,如果一致则说明没有被篡改过,如下代码所示。

                var md5 =MD5Util.GetMD5_32(message.Content);if (md5 ==message.MD5)
{
OnMessageReceived(message);
//给子类重载 }else{
Log.WriteInfo(
string.Format("收到一个被修改过的消息:\r\n{0}", message.Content));
}

以上就是我在Socket开发框架里面,实现传输数据的非对称加密,以及数据完整性校验的
处理过程。

在一般的Socket应用里面,很多时候数据的发送和接收是分开处理的,也就是我们发送一个消息,不知道这个请求消息什么时候得到应答消息,而且收到对应的应答消息的时候,如果操作界面的内容,也是需要特别处理的,因为它们和界面线程是不在一起的。如果我们在发送消息的时候,能够给一段回调的代码给收到应答消息的时候处理,那么就会方便很多。本文主要介绍如何在Socket应用里面,通过回调函数的处理,实现收到应答消息的时候能够调用对应的函数。

1、回调函数的设计

在上一篇的随笔里面,介绍了基于Json的Socket消息的实体类设计,其中包括了消息回调ID和是否在调用后移除回调两个属性,这个是用来为回调处理服务的,如下所示。

也就是在通用消息对象BaseMessage类里面添加下面两个属性。

但我们需要发送消息的时候,我们把回调的ID添加到本地集合里面,得到应答的时候,在从集合里面提出来,执行就可以了。

        /// <summary>
        ///发送通用格式的数据对象/// </summary>
        /// <param name="data">通用的消息对象</param>
        /// <returns></returns>
        public bool SendData(BaseMessage data, Delegate callBack = null)
{
AddCallback(callBack, data);
//添加回调集合 var toSendData =PackMessage(data);var result =SendData(toSendData);returnresult;
}
        /// <summary>
        ///记录回调的函数信息/// </summary>
        /// <param name="callBack"></param>
        /// <param name="msg"></param>
        private voidAddCallback(Delegate callBack, BaseMessage msg)
{
if (callBack != null)
{
Guid callbackID
=Guid.NewGuid();
ResponseCallbackObject responseCallback
= newResponseCallbackObject()
{
ID
=callbackID,
CallBack
=callBack
};

msg.CallbackID
=callbackID;
callBackList.Add(responseCallback);
}
}

在服务端,需要根据请求的消息构建应答内容,因此我们在应答请求的时候,需要把请求的回调ID给复制到应答的消息体里面,如下所示。

        /// <summary>
        ///封装数据进行发送(复制请求部分数据)/// </summary>
        /// <returns></returns>
        publicBaseMessage PackData(BaseMessage request)
{
BaseMessage info
= newBaseMessage()
{
MsgType
= this.MsgType,
Content
= this.SerializeObject(),
CallbackID
=request.CallbackID
};
if(!string.IsNullOrEmpty(request.ToUserId))
{
info.ToUserId
=request.FromUserId;
info.FromUserId
=request.ToUserId;
}
returninfo;
}

2、本地回调函数的处理及界面处理

调用方在收到服务器的应答消息的时候,会根据回调的ID ,从本地集合里面调出来并执行处理,实现了我们回调的操作。

                var md5 =MD5Util.GetMD5_32(message.Content);if (md5 ==message.MD5)
{
InvokeMessageCallback(message, message.DeleteCallbackAfterInvoke);
//触发回调 OnMessageReceived(message);//给子类重载 }
        /// <summary>
        ///执行回调函数/// </summary>
        /// <param name="msg">消息基础对象</param>
        /// <param name="deleteCallback">是否移除回调</param>
        private void InvokeMessageCallback(BaseMessage msg, booldeleteCallback)
{
var callBackObject = callBackList.SingleOrDefault(x => x.ID ==msg.CallbackID);if (callBackObject != null)
{
if(deleteCallback)
{
callBackList.Remove(callBackObject);
}
callBackObject.CallBack.DynamicInvoke(msg);
}
}

这样,我们在调用的时候,传入一个回调的Action,让收到消息后进行动态执行就可以了。例如在登陆的时候,我们如果需要在登陆成功后显示主窗体,那么可以执行下面的处理代码。

            var request = newAuthRequest(userNo, password);var message =request.PackData();
Singleton
<CommonManager>.Instance.Send(message, (msg) =>{var instance = Singleton<CommonManager>.Instance;try{var response = JsonTools.DeserializeObject<AuthResponse>(msg.Content);if (response.ValidateResult == 0)
{
instance.ShowLoadFormText(
"登录成功,加载基础数据。。。。");//放置初始化代码 Portal.gc.User = newUser(userNo);
instance.SetClientId(userNo);

instance.ShowMainForm();
instance.CloseLoadForm();
}
else{
instance.CloseLoadForm();
instance.ShowMessage(
"登录失败:" +response.Message);
instance.ShowLogin();
}
}
catch(Exception ex)
{
instance.ShowMessage(
"初始化异常:" +ex.Message);
instance.Exit();
}
});

或者我们来看看另外一个例子,这个例子是在用户登陆的时候,请求一次在线用户列表,如果用户在线,那么在界面上展示列表,具体操作代码如下所示,也是利用了回调函数的处理方式。

        /// <summary>
        ///发送获取在线用户列表的请求,并在收到应答数据后进行本地界面更新/// </summary>
        private voidRefreshUser()
{
CommonRequest request
= newCommonRequest(DataTypeKey.UserListRequest);var data =request.PackData();

Singleton
<CommonManager>.Instance.Send(data, (msg) =>{
UserListResponse response
= JsonTools.DeserializeObject<UserListResponse>(msg.Content);if (response != null)
{
this.InvokeUI(() =>{this.listView1.Items.Clear();foreach (CListItem item inresponse.UserList)
{
if (item.Value !=Portal.gc.User.UserNo)
{
this.listView1.Items.Add(item.Text, 0);
}
}
});
}
});
}

例如,客户端登陆几个用户后,用户可以获得在线用户列表,界面展示如下所示。

以上就是我们在Socket应用里面处理回调函数的实现过程,这样处理可以很好利用回调代码来封装处理的细节,对于理解相关的应答操作也是很直观的。

有一段时间没有接着微信的主题继续介绍里面的功能模块了,这段时间来,微信也做了不少的变化改动,针对这些特性我全面核对了一下相关的微信公众号和企业号的接口,对原有的微信API和系统管理做了全面的更新,本随笔以及后面的随笔就是基于这个工作上的总结,以期把微信涉及的功能模块,都使用C#实现的方式来介绍。本随笔主要介绍微信公众号的个性化菜单的实现,虽然目前微信公众号和企业号已经在功能上接近一致,不过在企业号上还没有个性化菜单的相关接口。

1、个性化菜单介绍

我们先了解一下个性化菜单的介绍,根据官方的资料,如下介绍。

为了帮助公众号实现灵活的业务运营,微信公众平台新增了个性化菜单接口,开发者可以通过该接口,让公众号的不同用户群体看到不一样的自定义菜单。该接口开放给已认证订阅号和已认证服务号。

开发者可以通过以下条件来设置用户看到的菜单:

1、用户分组(开发者的业务需求可以借助用户分组来完成)
2、性别
3、手机操作系统
4、地区(用户在微信客户端设置的地区)
5、语言(用户在微信客户端设置的语言)

个性化菜单接口说明:

1、个性化菜单要求用户的微信客户端版本在iPhone6.2.2,Android 6.2.4以上。
2、菜单的刷新策略是,在用户进入公众号会话页或公众号profile页时,如果发现上一次拉取菜单的请求在5分钟以前,就会拉取一下菜单,如果菜单有更新,就会刷新客户端的菜单。测试时可以尝试取消关注公众账号后再次关注,则可以看到创建后的效果。
3、普通公众号的个性化菜单的新增接口每日限制次数为2000次,删除接口也是2000次,测试个性化菜单匹配结果接口为20000次
4、出于安全考虑,一个公众号的所有个性化菜单,最多只能设置为跳转到3个域名下的链接
5、创建个性化菜单之前必须先创建默认菜单(默认菜单是指使用普通自定义菜单创建接口创建的菜单)。如果删除默认菜单,个性化菜单也会全部删除

个性化菜单匹配规则说明:

当公众号创建多个个性化菜单时,将按照发布顺序,由新到旧逐一匹配,直到用户信息与matchrule相符合。如果全部个性化菜单都没有匹配成功,则返回默认菜单。
例如公众号先后发布了默认菜单,个性化菜单1,个性化菜单2,个性化菜单3。那么当用户进入公众号页面时,将从个性化菜单3开始匹配,如果个性化菜单3匹配成功,则直接返回个性化菜单3,否则继续尝试匹配个性化菜单2,直到成功匹配到一个菜单。
根据上述匹配规则,为了避免菜单生效时间的混淆,决定不予提供个性化菜单编辑API,开发者需要更新菜单时,需将完整配置重新发布一轮。

除正常的默认菜单外,个性化菜单提供了下面几个处理操作。

1 创建个性化菜单

2 删除个性化菜单

3 测试个性化菜单匹配结果

4 查询个性化菜单

5 删除所有菜单

个性化自定义菜单功能,是指商家可以根据粉丝的分组、性别、手机操作系统,甚至地区,来分别展示公众号的菜单。

比如,一家健身会所的公众号,将自定义菜单根据粉丝性别进行了分开设置。男性粉丝进入公众号会展示男生们比较感兴趣菜单内容,跆拳道、搏击等;若是女性粉丝,则会展示瑜伽、舞蹈、防身术等女性粉丝感兴趣的菜单课程等。

2、个性化菜单的C#设计处理

微信的菜单是微信处理中很重要的一环,因为它是微信给用户入口体验重要的一环,也是很直观的界面呈现之一,菜单的展示就是需要我们绞尽脑汁来精简的界面元素了,由于菜单的重要性以及功能的丰富性,我在之前好几篇随笔专门介绍了菜单的各种处理。

在《
C#开发微信门户及应用(6)--微信门户菜单的管理操作
》、《
C#开发微信门户及应用(9)-微信门户菜单管理及提交到微信服务器
》、《
C#开发微信门户及应用(11)--微信菜单的多种表现方式介绍
》、《
C#开发微信门户及应用(20)-微信企业号的菜单管理
》分别对相关的菜单有过介绍,有需要可以参考了解一下。

菜单模块,在微信公众号和企业号,它们都可以共用一个模型,因此我们把这些内容都放在微信共用模块里面,包括它的实体类信息(如菜单模块),虽然有些地方,部分字段内容没有,但是我们可以通过实体类的JSON标识来进行处理,从而实现比较弹性化的实体类信息承载。

如下标识代码所示。

    /// <summary>
    ///菜单基本信息(公众号、企业号公用,其他部分一样)/// </summary>
    public classMenuJson : BaseJsonResult
{
/// <summary> ///按钮描述,既按钮名字,不超过16个字节,子菜单不超过40个字节/// </summary> public string name { get; set; }/// <summary> ///按钮类型(click或view)/// </summary> [JsonProperty(NullValueHandling =NullValueHandling.Ignore)]public string type { get; set; }/// <summary> ///按钮KEY值,用于消息接口(event类型)推送,不超过128字节/// </summary> [JsonProperty(NullValueHandling =NullValueHandling.Ignore)]public string key { get; set; }/// <summary> ///网页链接,用户点击按钮可打开链接,不超过256字节/// </summary> [JsonProperty(NullValueHandling =NullValueHandling.Ignore)]public string url { get; set; }/// <summary> ///media_id类型和view_limited类型必须,调用新增永久素材接口返回的合法media_id/// </summary> [JsonProperty(NullValueHandling =NullValueHandling.Ignore)]public string media_id { get; set; }/// <summary> ///子按钮数组,按钮个数应为2~5个/// </summary> [JsonProperty(NullValueHandling =NullValueHandling.Ignore)]public List<MenuJson> sub_button { get; set; }

上面这个实体类是菜单信息的基础信息,我们需要从这个基础上扩展出来几个实体类,方便在构建,获取菜单信息的时候进行信息转换,如下几个类图的设计所示。

通过这几个类的关系,我们就可以适应所有的默认菜单和个性化菜单,以及企业号的菜单信息转换处理了,通过JSON转换为对应的实体类,实现信息的强类型处理。

其中我们菜单处理的API实现类代码如下所示。

    /// <summary>
    ///菜单的相关操作/// </summary>
    public class MenuApi : IMenuApi

这样我们在IMenuApi的基础上增加几个个性化的接口。

        /// <summary>
        ///创建个性化菜单/// </summary>
        /// <param name="accessToken">调用接口凭证</param>
        /// <param name="menuJson">菜单对象</param>
        /// <returns></returns>
        string CreateConditionalMenu(stringaccessToken, MenuListJson menuJson);/// <summary>
        ///根据菜单ID删除个性化菜单/// </summary>
        /// <param name="accessToken">调用接口凭证</param>
        /// <param name="menuid">菜单ID</param>
        /// <returns></returns>
        CommonResult DeleteConditionalMenu(string accessToken, stringmenuid);/// <summary>
        ///测试个性化菜单匹配结果/// </summary>
        /// <param name="accessToken">调用接口凭证</param>
        /// <returns></returns>
        MenuListJson TryMatchConditionalMenu(string accessToken, string user_id);

然后在MenuApi实现类里面实现相关的逻辑处理即可。

下面是创建个性化菜单的接口说明。

创建个性化菜单

http请求方式:POST(请使用https协议)

https://api.weixin.qq.com/cgi-bin/menu/addconditional?access_token=ACCESS_TOKEN

请求示例

{
 	"button":[
 	{	
    	"type":"click",
    	"name":"今日歌曲",
     	"key":"V1001_TODAY_MUSIC" 
	},
	{ 
		"name":"菜单",
		"sub_button":[
		{	
			"type":"view",
			"name":"搜索",
			"url":"http://www.soso.com/"
		},
		{
			"type":"view",
			"name":"视频",
			"url":"http://v.qq.com/"
		},
		{
			"type":"click",
			"name":"赞一下我们",
			"key":"V1001_GOOD"
		}]
 }],
"matchrule":{
  "group_id":"2",
  "sex":"1",
  "country":"中国",
  "province":"广东",
  "city":"广州",
  "client_platform_type":"2"
  "language":"zh_CN"
  }
}

其中上面的信息,转换为我们的实体类对象信息就是如下所示。

MenuListJsonmenuJson

它API返回结果如下所示。

{
	"menuid":"208379533"
}

为了承载这个信息,我们需要定义一个实体类来承载这个信息。

    /// <summary>
    ///返回MenuId的结果/// </summary>
    public classMenuIdResult
{
/// <summary> ///菜单的ID/// </summary> public string menuid { get; set; }
}

最后我们的API接口只需要获取里面的菜单ID值,字符串类型的就可以了,API具体实现代码如下所示。

        /// <summary>
        ///创建个性化菜单/// </summary>
        /// <param name="accessToken">调用接口凭证</param>
        /// <param name="menuJson">菜单对象</param>
        /// <returns></returns>
        public string CreateConditionalMenu(stringaccessToken, MenuListJson menuJson)
{
string result = "";var url = string.Format("https://api.weixin.qq.com/cgi-bin/menu/addconditional?access_token={0}", accessToken);//matchrule不能为空 ArgumentValidation.CheckForNullReference(menuJson.matchrule, "matchrule");string postData =menuJson.ToJson();
MenuIdResult list
= JsonHelper<MenuIdResult>.ConvertJson(url, postData);if (list != null)
{
result
=list.menuid;
}
returnresult;
}

而删除个性化菜单的接口也是类似的处理,如下所示。

        /// <summary>
        ///根据菜单ID删除个性化菜单/// </summary>
        /// <param name="accessToken">调用接口凭证</param>
        /// <param name="menuid">菜单ID</param>
        /// <returns></returns>
        public CommonResult DeleteConditionalMenu(string accessToken, stringmenuid)
{
var url = string.Format("https://api.weixin.qq.com/cgi-bin/menu/delconditional?access_token={0}", accessToken);var data = new{
menuid
=menuid
};
string postData =data.ToJson();returnHelper.GetExecuteResult(url, postData);
}

另外,对于个性化菜单来说,查询、删除所有的菜单操作接口和普通默认菜单是一样的,因此可以重用这两个接口,不过由于我们扩展了查询返回的信息类,所以如果是个性化菜单,那么它返回的JSON字符串里面,包含了conditionalmenu的属性,这个是一个集合 ,也就是说个性化菜单是多个规则的个性化菜单的集合。

菜单的JSON数据结构如下所示。

3、个性化菜单封装接口的使用

有了上面接口的封装,我们可以在创建个性化菜单的时候很方便了,构建个性化菜单的时候,我们需要把全部需要出现的菜单都需要放在一个conditionalmenu的单元里面,虽然这样的单元在集合里面出现多个。

如下面是构建一个个性化的菜单项目
,和普通的默认菜单不同,它需要指定一个matchrule的对象
,如下代码所示。

            MenuJson conditional = new MenuJson("相关链接", newMenuJson[] {new MenuJson("个性化菜单", ButtonType.click, "event_company")
});
MatchRule rule
= newMatchRule()
{
sex
= "1"};

MenuListJson menuJson
= newMenuListJson();
menuJson.button.AddRange(
newMenuJson[] { conditional });
menuJson.matchrule
= rule;

通过上面的代码了解,我们如果需要增加多个个性化菜单,那么就是通过这个方法,每次创建一个个性化菜单的方式进行构建,当然如果我们使用查询个性化菜单的结果可以看到,如果创建多个,那么
conditionalmenu集合里面是多个对象的。

调用代码每次添加一个个性化菜单的操作如下所示。

                string menuid =menuBLL.CreateConditionalMenu(token, menuJson);
Console.WriteLine(
"创建菜单:" + (!string.IsNullOrEmpty(menuid) ? "成功:" + menuid : "失败"));

我们创建多个规则后,可以通过TryMatch的方式来检查匹配的结果,也就是对应不同用户(openid)会出现不同的个性化菜单列表了。

                    var myResult =menuBLL.TryMatchConditionalMenu(token, openId);if (myResult != null)
{
Console.WriteLine(myResult.ToJson());
}
var userid_female = "oSiLnt2J4mYkhVG3aLTdMIF1hv-s";//女性的ID myResult =menuBLL.TryMatchConditionalMenu(token, userid_female);if (myResult != null)
{
Console.WriteLine(myResult.ToJson());
}

如果对这个《C#开发微信门户及应用》系列感兴趣,可以关注我的其他文章,系列随笔如下所示:

C#开发微信门户及应用(28)--微信“摇一摇·周边”功能的使用和接口的实现

C#开发微信门户及应用(27)-公众号模板消息管理

C#开发微信门户及应用(26)-公众号微信素材管理

C#开发微信门户及应用(25)-微信企业号的客户端管理功能

C#开发微信门户及应用(24)-微信小店货架信息管理

C#开发微信门户及应用(23)-微信小店商品管理接口的封装和测试

C#开发微信门户及应用(22)-微信小店的开发和使用

C#开发微信门户及应用(21)-微信企业号的消息和事件的接收处理及解密

C#开发微信门户及应用(20)-微信企业号的菜单管理

C#开发微信门户及应用(19)-微信企业号的消息发送(文本、图片、文件、语音、视频、图文消息等)

C#开发微信门户及应用(18)-微信企业号的通讯录管理开发之成员管理

C#开发微信门户及应用(17)-微信企业号的通讯录管理开发之部门管理

C#开发微信门户及应用(16)-微信企业号的配置和使用

C#开发微信门户及应用(15)-微信菜单增加扫一扫、发图片、发地理位置功能

C#开发微信门户及应用(14)-在微信菜单中采用重定向获取用户数据

C#开发微信门户及应用(13)-使用地理位置扩展相关应用

C#开发微信门户及应用(12)-使用语音处理

C#开发微信门户及应用(11)--微信菜单的多种表现方式介绍

C#开发微信门户及应用(10)--在管理系统中同步微信用户分组信息

C#开发微信门户及应用(9)-微信门户菜单管理及提交到微信服务器

C#开发微信门户及应用(8)-微信门户应用管理系统功能介绍

C#开发微信门户及应用(7)-微信多客服功能及开发集成

C#开发微信门户及应用(6)--微信门户菜单的管理操作

C#开发微信门户及应用(5)--用户分组信息管理

C#开发微信门户及应用(4)--关注用户列表及详细信息管理

C#开发微信门户及应用(3)--文本消息和图文消息的应答


C#开发微信门户及应用(2)--微信消息的处理和应答


C#开发微信门户及应用(1)--开始使用微信接口

在很多场合下,我们可能需要利用微信公众号的优势,定期给指定用户群发送一些推广消息或者新闻内容,以便给关注客户一种经常更新公众号内容的感觉,同时也方便我们经常和用户进行互动。微信公众号的高级群发接口就是为了处理这个场景的,本文介绍在C#代码中如何封装消息的群发和预览等功能。

1、消息群发的功能和限制

对于公众号中的服务号和订阅号,群发的消息有一定的限制,具体规则如下所示。

1、对于认证订阅号,群发接口每天可成功调用1次,此次群发可选择发送给全部用户或某个分组;
2、对于认证服务号虽然开发者使用高级群发接口的每日调用限制为100次,但是用户每月只能接收4条,无论在公众平台网站上,还是使用接口群发,用户每月只能接收4条群发消息,多于4条的群发将对该用户发送失败;
3、具备微信支付权限的公众号,在使用群发接口上传、群发图文消息类型时,可使用<a>标签加入外链;
4、开发者可以使用预览接口校对消息样式和排版,通过预览接口可发送编辑好的消息给指定用户校验效果。

群发图文消息的过程如下:

1、首先,预先将图文消息中需要用到的图片,使用上传图文消息内图片接口,上传成功并获得图片URL
2、上传图文消息素材,需要用到图片时,请使用上一步获取的图片URL
3、使用对用户分组的群发,或对OpenID列表的群发,将图文消息群发出去
4、在上述过程中,如果需要,还可以预览图文消息、查询群发状态,或删除已群发的消息等

群发图片、文本等其他消息类型的过程如下:

1、如果是群发文本消息,则直接根据下面的接口说明进行群发即可
2、如果是群发图片、视频等消息,则需要预先通过素材管理接口准备好mediaID

2、消息的群发处理

虽然群发的消息类型有几种,如包括图文消息、文本消息、图片、视频、语音、卡劵等等,不过消息群发方式分为两类:根据群组发送消息和根据OpenID发送消息两种。

根据微信接口的定义,我们设计了对上面两种不同方式的发送接口,我们把不同类型的消息放到枚举MassMessageType 进行定义。

        /// <summary>
        ///根据分组进行群发消息(图文消息、文本消息、语音消息、视频消息、图片、卡劵等)/// </summary>
        /// <param name="accessToken">访问凭证</param>
        /// <param name="mediaIdOrContent">群发媒体文件时传入mediaId,群发文本消息时传入content,群发卡券时传入cardId</param>
        /// <param name="groupId">群发到的分组的group_id</param>
        /// <param name="isToAll">
        ///使用is_to_all为true且成功群发,会使得此次群发进入历史消息列表。///设置is_to_all为false时是可以多次群发的,但每个用户只会收到最多4条,且这些群发不会进入历史消息列表</param>
        /// <returns></returns>
        MassMessageResult SendByGroup(string accessToken, MassMessageType messageType, string mediaIdOrContent, string groupId, bool isToAll = false);/// <summary>
        ///根据OpenId进行群发消息(视频消息需要单独)/// </summary>
        /// <param name="accessToken">访问凭证</param>
        /// <param name="messageType">消息类型</param>
        /// <param name="mediaIdOrContent">用于群发的消息的media_id</param>
        /// <param name="openIdList">openId字符串数组</param>        
        /// <returns></returns>
        MassMessageResult SendByOpenId(string accessToken, MassMessageType messageType, string mediaIdOrContent, List<string> openIdList);

其中枚举MassMessageType定义代码如下所示。

   /// <summary>
    ///群发消息的类型/// </summary>
    public enumMassMessageType
{
/// <summary> ///图文消息/// </summary> mpnews,/// <summary> ///文本消息/// </summary> text,/// <summary> ///图片/// </summary> image,/// <summary> ///语音/// </summary> voice,/// <summary> ///音乐/// </summary> music,/// <summary> ///视频/// </summary> video,/// <summary> ///卡劵/// </summary> wxcard
}

然后我们根据上面的接口实现相关的处理函数,群发消息的类定义代码如下所示。

    /// <summary>
    ///消息群发.///在公众平台网站上,为订阅号提供了每天1条的群发权限,为服务号提供每月(自然月)4条的群发权限。///而对于某些具备开发能力的公众号运营者,可以通过高级群发接口,实现更灵活的群发能力。/// </summary>
    public class MassSendApi : IMassSendApi

对于图文消息的群发规则,微信接口定义如下。

接口调用请求说明

http请求方式: POST
https://api.weixin.qq.com/cgi-bin/message/mass/sendall?access_token=ACCESS_TOKEN

POST数据说明

POST数据示例如下:

图文消息(注意图文消息的media_id需要通过上述方法来得到):

{"filter":{"is_to_all":false,"group_id":2},"mpnews":{"media_id":"123dsdajkasd231jhksad"},"msgtype":"mpnews"}

其他类似文本消息、图片、视频、语音、卡劵等发送方式类似,都是提供一个不同的JSON字符串,然后提交到对应的连接地址就可以了,因此我们可以把它们进行统一的封装处理。

我们可以在一个条件语句里面对内容进行组装,例如对于图文消息的处理代码如下所示。

switch(messageType)
{
case MassMessageType.mpnews://图文消息 postData = new{
filter
= new{
is_to_all
= isToAll, //是否让此次群发进入历史消息列表 group_id = groupId //群发到的分组的group_id },
mpnews
= new{
media_id
= mediaIdOrContent //用于群发的消息的media_id },
msgtype
= "mpnews"}.ToJson();break;

对于文本消息的组装如下所示。

                case MassMessageType.text://文本消息
                    postData = new{
filter
= new{
is_to_all
= isToAll, //是否让此次群发进入历史消息列表 group_id = groupId //群发到的分组的group_id },
text
= new{
content
= mediaIdOrContent //用于群发的消息的内容 },
msgtype
= "text"}.ToJson();break;

最后我们通过代码进行提交JSON数据,并获取返回结果即可,如下代码所示。

            string url = string.Format("https://api.weixin.qq.com/cgi-bin/message/mass/sendall?access_token={0}", accessToken);

MassMessageResult result
= JsonHelper<MassMessageResult>.ConvertJson(url, postData);return result;

这样,整合各个消息类型的处理,我们就可以得到一个完整的消息群发操作了。

群发给openid的操作也是类似上面的处理方式,也是通过一个switch的条件语句,进行不同内容的构建,然后统一发送即可。

请注意:
在返回成功时,意味着群发任务提交成功,并不意味着此时群发已经结束
,所以,仍有可能在后续的发送过程中出现异常情况导致用户未收到消息,如消息有时会进行审核、服务器不稳定等。此外,
群发任务一般需要较长的时间才能全部发送完毕,请耐心等待

由于群发任务提交后,群发任务可能在一定时间后才完成,因此,群发接口调用时,仅会给出群发任务是否提交成功的提示,若群发任务提交成功,则在群发任务结束时,会向开发者在公众平台填写的开发者URL(callback URL)推送事件。

推送的XML结构如下(发送成功时):

<xml>
<ToUserName><![CDATA[gh_3e8adccde292]]></ToUserName>
<FromUserName><![CDATA[oR5Gjjl_eiZoUpGozMo7dbBJ362A]]></FromUserName>
<CreateTime>1394524295</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[MASSSENDJOBFINISH]]></Event>
<MsgID>1988</MsgID>
<Status><![CDATA[sendsuccess]]></Status>
<TotalCount>100</TotalCount>
<FilterCount>80</FilterCount>
<SentCount>75</SentCount>
<ErrorCount>5</ErrorCount>
</xml>

对应的字段说明如下所示。

参数 说明
ToUserName 公众号的微信号
FromUserName 公众号群发助手的微信号,为mphelper
CreateTime 创建时间的时间戳
MsgType 消息类型,此处为event
Event 事件信息,此处为MASSSENDJOBFINISH
MsgID 群发的消息ID
Status 群发的结构,为“send success”或“send fail”或“err(num)”。但send success时,也有可能因用户拒收公众号的消息、系统错误等原因造成少量用户接收失败。err(num)是审核失败的具体原因
TotalCount group_id下粉丝数;或者openid_list中的粉丝数
FilterCount 过滤(过滤是指特定地区、性别的过滤、用户设置拒收的过滤,用户接收已超4条的过滤)后,准备发送的粉丝数,原则上,FilterCount = SentCount + ErrorCount
SentCount 发送成功的粉丝数
ErrorCount 发送失败的粉丝数

因此我们需要通过处理消息群发的发送完成操作,定义一个实体类来承载这个消息。

    public classRequestMassSendJobFinish : BaseEvent
{
publicRequestMassSendJobFinish()
{
this.MsgType =RequestMsgType.Event.ToString().ToLower();this.Event =RequestEvent.MASSSENDJOBFINISH.ToString();
}
/// <summary> ///群发的消息ID/// </summary> public int MsgID { get; set; }/// <summary> ///返回状态。/// </summary> public string Status { get; set; }/// <summary> ///group_id下粉丝数;或者openid_list中的粉丝数/// </summary> public int TotalCount { get; set; }/// <summary> ///过滤(过滤是指,有些用户在微信设置不接收该公众号的消息)后,准备发送的粉丝数,原则上,FilterCount = SentCount + ErrorCount/// </summary> public int FilterCount { get; set; }/// <summary> ///发送成功的粉丝数/// </summary> public int SendCount { get; set; }/// <summary> ///发送失败的粉丝数/// </summary> public int ErrorCount { get; set; }
}

在我们需要记录或者更新处理这种群发消息的状态的时候,我们可以在整个微信的消息链里面对这样的请求事件进行处理,如下代码是处理这种群发消息的通知的。

                            caseRequestEvent.MASSSENDJOBFINISH:
{
//由于群发任务彻底完成需要较长时间,将会在群发任务即将完成的时候,就推送群发结果,此时的推送人数数据将会与实际情形存在一定误差 RequestMassSendJobFinish info = XmlConvertor.XmlToObject(postStr, typeof(RequestMassSendJobFinish)) asRequestMassSendJobFinish;if(info != null)
{
//在此记录群发完成的处理 }
LogTextHelper.Info(eventName
+ ((info == null) ? "info is null": info.ToJson()));
}
break;

3、待群发消息的预览

在很多时候,我们群发消息之前,我们希望通过自己的微信号来看看具体的群发消息效果,如果没有问题我们在统一群发,相当于一个真实的审核过程,这样对于我们发送高质量的消息是一个很好的习惯。

对于普通的消息预览,我们定义的接口如下所示。

        /// <summary>
        ///预览接口【订阅号与服务号认证后均可用】。///开发者可通过该接口发送消息给指定用户,在手机端查看消息的样式和排版。///为了满足第三方平台开发者的需求,在保留对openID预览能力的同时,增加了对指定微信号发送预览的能力,但该能力每日调用次数有限制(100次),请勿滥用。/// </summary>
        /// <param name="accessToken">访问凭证</param>
        /// <param name="messageType">消息类型</param>
        /// <param name="media_id">用于群发的消息的media_id</param>
        /// <param name="touserOpenId">接收消息用户对应该公众号的openid</param>
        /// <param name="towxname">可以针对微信号进行预览(而非openID),towxname和touser同时赋值时,以towxname优先</param>
        /// <returns></returns>
        MassMessageResult PreviewMessage(string accessToken, MassMessageType messageType, string media_id, string touserOpenId, string towxname = null);

具体的实现也就是针对不同的消息类型,构建一个不同的处理机制,把它们差异性的JSON构造出来,然后统一调用就可以了,具体代码如下所示。

        public MassMessageResult PreviewMessage(string accessToken, MassMessageType messageType, string media_id, string touserOpenId, string towxname = null)
{
string postData = "";switch(messageType)
{
case MassMessageType.mpnews://图文消息 postData = new{
touser
=touserOpenId,
towxname
=towxname,
mpnews
= new{
media_id
=media_id
},
msgtype
= "mpnews"}.ToJson();break;case MassMessageType.text://文本消息 postData = new{
touser
=touserOpenId,
towxname
=towxname,
text
= new{
content
=media_id
},
msgtype
= "text"}.ToJson();break;case MassMessageType.voice://语音 postData = new{
touser
=touserOpenId,
towxname
=towxname,
voice
= new{
media_id
=media_id
},
msgtype
= "voice"}.ToJson();break;case MassMessageType.image://图片 postData = new{
touser
=touserOpenId,
towxname
=towxname,
image
= new{
media_id
=media_id
},
msgtype
= "image"}.ToJson();break;case MassMessageType.video://视频 postData = new{
touser
=touserOpenId,
towxname
=towxname,
mpvideo
= new{
media_id
=media_id
},
msgtype
= "mpvideo"}.ToJson();break;case MassMessageType.wxcard: //卡劵 throw new WeixinException("发送卡券息请使用PreviewCardMessage方法。");break;
}
var url = string.Format("https://api.weixin.qq.com/cgi-bin/message/mass/preview?access_token={0}", accessToken);return JsonHelper<MassMessageResult>.ConvertJson(url, postData);
}

消息的预览在我们正式群发消息前的审核是比较有用的,我们可以通过接口进行一个消息的预览,可以在微信公众号上看到的效果与正式群发后的消息是一样的。

例如我们通过下面的代码进行一个简单的预览消息操作。

        /// <summary>
        ///群发消息的预览/// </summary>
        private void btnPreviewMass_Click(objectsender, EventArgs e)
{
//上传图片 btnUpload_Click(null, null);//上传图文消息 btnUploadNews_Click(null, null);//消息群发前的预览操作 List<string> list = new List<string>() { openId };
IMassSendApi api
= newMassSendApi();var mediaId = this.news_mediaId;
MassMessageResult result
=api.PreviewMessage(token, MassMessageType.mpnews, mediaId, openId);if (result != null)
{
Console.WriteLine(result.msg_id);
}
}

最后可以看到例子代码的预览效果如下所示。

如果对这个《C#开发微信门户及应用》系列感兴趣,可以关注我的其他文章,系列随笔如下所示:

C#开发微信门户及应用(29)--微信个性化菜单的实现

C#开发微信门户及应用(28)--微信“摇一摇·周边”功能的使用和接口的实现

C#开发微信门户及应用(27)-公众号模板消息管理

C#开发微信门户及应用(26)-公众号微信素材管理

C#开发微信门户及应用(25)-微信企业号的客户端管理功能

C#开发微信门户及应用(24)-微信小店货架信息管理

C#开发微信门户及应用(23)-微信小店商品管理接口的封装和测试

C#开发微信门户及应用(22)-微信小店的开发和使用

C#开发微信门户及应用(21)-微信企业号的消息和事件的接收处理及解密

C#开发微信门户及应用(20)-微信企业号的菜单管理

C#开发微信门户及应用(19)-微信企业号的消息发送(文本、图片、文件、语音、视频、图文消息等)

C#开发微信门户及应用(18)-微信企业号的通讯录管理开发之成员管理

C#开发微信门户及应用(17)-微信企业号的通讯录管理开发之部门管理

C#开发微信门户及应用(16)-微信企业号的配置和使用

C#开发微信门户及应用(15)-微信菜单增加扫一扫、发图片、发地理位置功能

C#开发微信门户及应用(14)-在微信菜单中采用重定向获取用户数据

C#开发微信门户及应用(13)-使用地理位置扩展相关应用

C#开发微信门户及应用(12)-使用语音处理

C#开发微信门户及应用(11)--微信菜单的多种表现方式介绍

C#开发微信门户及应用(10)--在管理系统中同步微信用户分组信息

C#开发微信门户及应用(9)-微信门户菜单管理及提交到微信服务器

C#开发微信门户及应用(8)-微信门户应用管理系统功能介绍

C#开发微信门户及应用(7)-微信多客服功能及开发集成

C#开发微信门户及应用(6)--微信门户菜单的管理操作

C#开发微信门户及应用(5)--用户分组信息管理

C#开发微信门户及应用(4)--关注用户列表及详细信息管理

C#开发微信门户及应用(3)--文本消息和图文消息的应答


C#开发微信门户及应用(2)--微信消息的处理和应答


C#开发微信门户及应用(1)--开始使用微信接口