企业微信消息通知要怎么声明 Rust 结构体


是的,今天来看企业微信的消息通知了,跟各大平台的消息通知杠上了,实际上是跟 Rust 的结构体杠上了。

毕竟,数据结构的设计和声明是编程工作中非常重要的部分,数据结构和结构体声明好了,代码其实就已经走好了很大一段路了。

而跟 Python 接触这些年,发现大多数情况下可能趋于只关注逻辑,却忘记了去关注数据结构的声明,宁愿很多时候在代码里面做很多奇奇怪怪的一些临时数据操作,也很难认真去真真切切地看一下到底有哪些数据主体,他们的关系是什么样的。谁和谁是同一个层面的,却被分在了不同的地方;谁又和谁本质上不是同一个东西,却又在代码里面绕啊绕,绕成了一个东西。

企业微信的消息通知,比钉钉的消息通知要稍微复杂一点,除了基本的几种消息结构外,它还有“模版卡片”这种复杂的消息结构,它的数据结构,其实等于在它的内层又声明了好几种更细分的格式定义,这里我们参考的文档是这个 https://developer.work.weixin.qq.com/document/path/90372

惯例,先看它的 json 声明

// 文本消息
{
   "touser" : "UserID1|UserID2|UserID3",
   "toparty" : "PartyID1|PartyID2",
   "totag" : "TagID1 | TagID2",
   "msgtype" : "text",
   "agentid" : 1,
   "text" : {
       "content" : "你的快递已到,请携带工卡前往邮件中心领取。\n出发前可查看<a href=\"http://work.weixin.qq.com\">邮件中心视频实况</a>,聪明避开排队。"
   },
   "safe":0,
   "enable_id_trans": 0,
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

// 图片消息
{
   "touser" : "UserID1|UserID2|UserID3",
   "toparty" : "PartyID1|PartyID2",
   "totag" : "TagID1 | TagID2",
   "msgtype" : "image",
   "agentid" : 1,
   "image" : {
        "media_id" : "MEDIA_ID"
   },
   "safe":0,
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

// 语音消息
{
   "touser" : "UserID1|UserID2|UserID3",
   "toparty" : "PartyID1|PartyID2",
   "totag" : "TagID1 | TagID2",
   "msgtype" : "voice",
   "agentid" : 1,
   "voice" : {
        "media_id" : "MEDIA_ID"
   },
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

// 视频消息
{
   "touser" : "UserID1|UserID2|UserID3",
   "toparty" : "PartyID1|PartyID2",
   "totag" : "TagID1 | TagID2",
   "msgtype" : "video",
   "agentid" : 1,
   "video" : {
        "media_id" : "MEDIA_ID",
        "title" : "Title",
       "description" : "Description"
   },
   "safe":0,
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

// 文件消息
{
   "touser" : "UserID1|UserID2|UserID3",
   "toparty" : "PartyID1|PartyID2",
   "totag" : "TagID1 | TagID2",
   "msgtype" : "file",
   "agentid" : 1,
   "file" : {
        "media_id" : "1Yv-zXfHjSjU-7LH-GwtYqDGS-zz6w22KmWAT5COgP7o"
   },
   "safe":0,
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

// 文本卡片消息
{
   "touser" : "UserID1|UserID2|UserID3",
   "toparty" : "PartyID1 | PartyID2",
   "totag" : "TagID1 | TagID2",
   "msgtype" : "textcard",
   "agentid" : 1,
   "textcard" : {
            "title" : "领奖通知",
            "description" : "<div class=\"gray\">2016年9月26日</div> <div class=\"normal\">恭喜你抽中iPhone 7一台,领奖码:xxxx</div><div class=\"highlight\">请于2016年10月10日前联系行政同事领取</div>",
            "url" : "URL",
                        "btntxt":"更多"
   },
   "enable_id_trans": 0,
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

// 图文消息
{
   "touser" : "UserID1|UserID2|UserID3",
   "toparty" : "PartyID1 | PartyID2",
   "totag" : "TagID1 | TagID2",
   "msgtype" : "news",
   "agentid" : 1,
   "news" : {
       "articles" : [
           {
               "title" : "中秋节礼品领取",
               "description" : "今年中秋节公司有豪礼相送",
               "url" : "URL",
               "picurl" : "http://res.mail.qq.com/node/ww/wwopenmng/images/independent/doc/test_pic_msg1.png", 
			   "appid": "wx123123123123123",
        	   "pagepath": "pages/index?userid=zhangsan&orderid=123123123"
           }
        ]
   },
   "enable_id_trans": 0,
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

// 图文消息(mpnews)
{
   "touser" : "UserID1|UserID2|UserID3",
   "toparty" : "PartyID1 | PartyID2",
   "totag": "TagID1 | TagID2",
   "msgtype" : "mpnews",
   "agentid" : 1,
   "mpnews" : {
       "articles":[
           {
               "title": "Title", 
               "thumb_media_id": "MEDIA_ID",
               "author": "Author",
               "content_source_url": "URL",
               "content": "Content",
               "digest": "Digest description"
            }
       ]
   },
   "safe":0,
   "enable_id_trans": 0,
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

// markdown消息
{
   "touser" : "UserID1|UserID2|UserID3",
   "toparty" : "PartyID1|PartyID2",
   "totag" : "TagID1 | TagID2",
   "msgtype": "markdown",
   "agentid" : 1,
   "markdown": {
        "content": "您的会议室已经预定,稍后会同步到`邮箱` 
                                >**事项详情** 
                                >事 项:<font color=\"info\">开会</font> 
                                >组织者:@miglioguan 
                                >参与者:@miglioguan、@kunliu、@jamdeezhou、@kanexiong、@kisonwang 
                                > 
                                >会议室:<font color=\"info\">广州TIT 1楼 301</font> 
                                >日 期:<font color=\"warning\">2018年5月18日</font> 
                                >时 间:<font color=\"comment\">上午9:00-11:00</font> 
                                > 
                                >请准时参加会议。 
                                > 
                                >如需修改会议信息,请点击:[修改会议信息](https://work.weixin.qq.com)"
   },
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

// 小程序通知消息
{
   "touser" : "zhangsan|lisi",
   "toparty": "1|2",
   "totag": "1|2",
   "msgtype" : "miniprogram_notice",
   "miniprogram_notice" : {
        "appid": "wx123123123123123",
        "page": "pages/index?userid=zhangsan&orderid=123123123",
        "title": "会议室预订成功通知",
        "description": "4月27日 16:16",
        "emphasis_first_item": true,
        "content_item": [
            {
                "key": "会议室",
                "value": "402"
            },
            {
                "key": "会议地点",
                "value": "广州TIT-402会议室"
            },
            {
                "key": "会议时间",
                "value": "2018年8月1日 09:00-09:30"
            },
            {
                "key": "参与人员",
                "value": "周剑轩"
            }
        ]
    },
   "enable_id_trans": 0,
   "enable_duplicate_check": 0,
   "duplicate_check_interval": 1800
}

看到这里,我们分析一下啊,touser toparty totag enable_id_trans enable_duplicate_check duplicate_check_interval 这些字段虽然每个里面都有,但是它们是业务逻辑里面的字段,不是消息本身的字段数据,真正消息本身的字段就只有 msg type 和那个动态根据消息类型不同而不同的键,所以其实你发现又与钉钉的消息结构思路就一致了。

然后,就是更为复杂的模版卡片消息,不怕麻烦,json 我们也一样仔细看一下。

// 模版卡片消息 - 文本通知型
{
    "touser" : "UserID1|UserID2|UserID3",
    "toparty" : "PartyID1 | PartyID2",
    "totag" : "TagID1 | TagID2",
    "msgtype" : "template_card",
    "agentid" : 1,
    "template_card" : {
        "card_type" : "text_notice",
        "source" : {
            "icon_url": "图片的url",
            "desc": "企业微信",
            "desc_color": 1
        },
        "action_menu": {
            "desc": "卡片副交互辅助文本说明",
            "action_list": [
                {"text": "接受推送", "key": "A"},
                {"text": "不再推送", "key": "B"}
            ]
        },
        "task_id": "task_id",
        "main_title" : {
            "title" : "欢迎使用企业微信",
            "desc" : "您的好友正在邀请您加入企业微信"
        },
        "quote_area": {
            "type": 1,
            "url": "https://work.weixin.qq.com",
            "title": "企业微信的引用样式",
            "quote_text": "企业微信真好用呀真好用"
        },
        "emphasis_content": {
            "title": "100",
            "desc": "核心数据"
        },
        "sub_title_text" : "下载企业微信还能抢红包!",
        "horizontal_content_list" : [
            {
                "keyname": "邀请人",
                "value": "张三"
            },
            {
                "type": 1,
                "keyname": "企业微信官网",
                "value": "点击访问",
                "url": "https://work.weixin.qq.com"
            },
            {
                "type": 2,
                "keyname": "企业微信下载",
                "value": "企业微信.apk",
                "media_id": "文件的media_id"
            },
            {
                "type": 3,
                "keyname": "员工信息",
                "value": "点击查看",
                "userid": "zhangsan"
            }
        ],
        "jump_list" : [
            {
                "type": 1,
                "title": "企业微信官网",
                "url": "https://work.weixin.qq.com"
            },
            {
                "type": 2,
                "title": "跳转小程序",
                "appid": "小程序的appid",
                "pagepath": "/index.html"
            }
        ],
        "card_action": {
            "type": 2,
            "url": "https://work.weixin.qq.com",
            "appid": "小程序的appid",
            "pagepath": "/index.html"
        }
    },
    "enable_id_trans": 0,
    "enable_duplicate_check": 0,
    "duplicate_check_interval": 1800
}
// 模版卡片消息 - 图文展示型
{
    "touser" : "UserID1|UserID2|UserID3",
    "toparty" : "PartyID1 | PartyID2",
    "totag" : "TagID1 | TagID2",
    "msgtype" : "template_card",
    "agentid" : 1,
    "template_card" : {
        "card_type" : "news_notice",
        "source" : {
            "icon_url": "图片的url",
            "desc": "企业微信",
            "desc_color": 1
        },
        "action_menu": {
            "desc": "卡片副交互辅助文本说明",
            "action_list": [
                {"text": "接受推送", "key": "A"},
                {"text": "不再推送", "key": "B"}
            ]
        },
        "task_id": "task_id",
        "main_title" : {
            "title" : "欢迎使用企业微信",
            "desc" : "您的好友正在邀请您加入企业微信"
        },
        "quote_area": {
            "type": 1,
            "url": "https://work.weixin.qq.com",
            "title": "企业微信的引用样式",
            "quote_text": "企业微信真好用呀真好用"
        },
        "image_text_area": {
            "type": 1,
            "url": "https://work.weixin.qq.com",
            "title": "企业微信的左图右文样式",
            "desc": "企业微信真好用呀真好用",
            "image_url": "https://img.iplaysoft.com/wp-content/uploads/2019/free-images/free_stock_photo_2x.jpg"
        },
        "card_image": {
            "url": "图片的url",
            "aspect_ratio": 1.3
        },
        "vertical_content_list": [
            {
                "title": "惊喜红包等你来拿",
                "desc": "下载企业微信还能抢红包!"
            }
        ],
        "horizontal_content_list" : [
            {
                "keyname": "邀请人",
                "value": "张三"
            },
            {
                "type": 1,
                "keyname": "企业微信官网",
                "value": "点击访问",
                "url": "https://work.weixin.qq.com"
            },
            {
                "type": 2,
                "keyname": "企业微信下载",
                "value": "企业微信.apk",
                "media_id": "文件的media_id"
            },
            {
                "type": 3,
                "keyname": "员工信息",
                "value": "点击查看",
                "userid": "zhangsan"
            }
        ],
        "jump_list" : [
            {
                "type": 1,
                "title": "企业微信官网",
                "url": "https://work.weixin.qq.com"
            },
            {
                "type": 2,
                "title": "跳转小程序",
                "appid": "小程序的appid",
                "pagepath": "/index.html"
            }
        ],
        "card_action": {
            "type": 2,
            "url": "https://work.weixin.qq.com",
            "appid": "小程序的appid",
            "pagepath": "/index.html"
        }
    },
    "enable_id_trans": 0,
    "enable_duplicate_check": 0,
    "duplicate_check_interval": 1800
}
// 模版卡片消息 - 按钮交互型
{
    "touser" : "UserID1|UserID2|UserID3",
    "toparty" : "PartyID1 | PartyID2",
    "totag" : "TagID1 | TagID2",
    "msgtype" : "template_card",
    "agentid" : 1,
    "template_card" : {
        "card_type" : "button_interaction",
        "source" : {
            "icon_url": "图片的url",
            "desc": "企业微信",
            "desc_color": 1
        },
        "action_menu": {
            "desc": "卡片副交互辅助文本说明",
            "action_list": [
                {"text": "接受推送", "key": "A"},
                {"text": "不再推送", "key": "B"}
            ]
        },
        "main_title" : {
            "title" : "欢迎使用企业微信",
            "desc" : "您的好友正在邀请您加入企业微信"
        },
        "quote_area": {
            "type": 1,
            "url": "https://work.weixin.qq.com",
            "title": "企业微信的引用样式",
            "quote_text": "企业微信真好用呀真好用"
        },
        "sub_title_text" : "下载企业微信还能抢红包!",
        "horizontal_content_list" : [
            {
                "keyname": "邀请人",
                "value": "张三"
            },
            {
                "type": 1,
                "keyname": "企业微信官网",
                "value": "点击访问",
                "url": "https://work.weixin.qq.com"
            },
            {
                "type": 2,
                "keyname": "企业微信下载",
                "value": "企业微信.apk",
                "media_id": "文件的media_id"
            },
            {
                "type": 3,
                "keyname": "员工信息",
                "value": "点击查看",
                "userid": "zhangsan"
            }
        ],
        "card_action": {
            "type": 2,
            "url": "https://work.weixin.qq.com",
            "appid": "小程序的appid",
            "pagepath": "/index.html"
        },
        "task_id": "task_id",
        "button_selection": {
            "question_key": "btn_question_key1",
            "title": "企业微信评分",
            "option_list": [
                {
                    "id": "btn_selection_id1",
                    "text": "100分"
                },
                {
                    "id": "btn_selection_id2",
                    "text": "101分"
                }
            ],
            "selected_id": "btn_selection_id1"
        },
        "button_list": [
            {
                "text": "按钮1",
                "style": 1,
                "key": "button_key_1"
            },
            {
                "text": "按钮2",
                "style": 2,
                "key": "button_key_2"
            }
        ]
    },
    "enable_id_trans": 0,
    "enable_duplicate_check": 0,
    "duplicate_check_interval": 1800
}
// 模版卡片消息 - 投票选择型
{
    "touser" : "UserID1|UserID2|UserID3",
    "toparty" : "PartyID1 | PartyID2",
    "totag" : "TagID1 | TagID2",
    "msgtype" : "template_card",
    "agentid" : 1,
    "template_card" : {
        "card_type" : "vote_interaction",
        "source" : {
            "icon_url": "图片的url",
            "desc": "企业微信"
        },
        "main_title" : {
            "title" : "欢迎使用企业微信",
            "desc" : "您的好友正在邀请您加入企业微信"
        },
        "task_id": "task_id",
        "checkbox": {
            "question_key": "question_key1",
            "option_list": [
                {
                    "id": "option_id1",
                    "text": "选择题选项1",
                    "is_checked": true
                },
                {
                    "id": "option_id2",
                    "text": "选择题选项2",
                    "is_checked": false
                }
            ],
            "mode": 1
        },
        "submit_button": {
            "text": "提交",
            "key": "key"
        }
    },
    "enable_id_trans": 0,
    "enable_duplicate_check": 0,
    "duplicate_check_interval": 1800
}
// 模版卡片消息 - 多项选择型
{
    "touser" : "UserID1|UserID2|UserID3",
    "toparty" : "PartyID1 | PartyID2",
    "totag" : "TagID1 | TagID2",
    "msgtype" : "template_card",
    "agentid" : 1,
    "template_card" : {
        "card_type" : "multiple_interaction",
        "source" : {
            "icon_url": "图片的url",
            "desc": "企业微信"
        },
        "main_title" : {
            "title" : "欢迎使用企业微信",
            "desc" : "您的好友正在邀请您加入企业微信"
        },
        "task_id": "task_id",
        "select_list": [
            {
                "question_key": "question_key1",
                "title": "选择器标签1",
                "selected_id": "selection_id1",
                "option_list": [
                    {
                        "id": "selection_id1",
                        "text": "选择器选项1"
                    },
                    {
                        "id": "selection_id2",
                        "text": "选择器选项2"
                    }
                ]
            },
            {
                "question_key": "question_key2",
                "title": "选择器标签2",
                "selected_id": "selection_id3",
                "option_list": [
                    {
                        "id": "selection_id3",
                        "text": "选择器选项3"
                    },
                    {
                        "id": "selection_id4",
                        "text": "选择器选项4"
                    }
                ]
            }
        ],
        "submit_button": {
            "text": "提交",
            "key": "key"
        }
    },
    "enable_id_trans": 0,
    "enable_duplicate_check": 0,
    "duplicate_check_interval": 1800
}

可以看到,这模版卡片消息的复杂度可比基本消息类型复杂多了。

我们先把基本消息类型来实现一下,先看看效果。

这次很自然地,你会发现同样也用到了枚举类型进行综合性声明,并用到了 serdetagrename 属性,这基本和之前的枚举声明类似的套路,就不用再细说了吧。

model/msg/wework/mod.rs

pub mod template_card;

use crate::model::msg::wework::template_card::WeworkTemplateCardMsg;

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Ord, PartialOrd, Eq, PartialEq, Hash)]
pub enum WeworkMsgType {
    /// 文本消息
    #[serde(rename = "text")]
    Text,
    /// 图片消息
    #[serde(rename = "image")]
    Image,
    /// 语音消息
    #[serde(rename = "voice")]
    Voice,
    /// 视频消息
    #[serde(rename = "video")]
    Video,
    /// 文件消息
    #[serde(rename = "file")]
    File,
    /// 文本卡片消息
    #[serde(rename = "textcard")]
    TextCard,
    /// 图文消息
    #[serde(rename = "news")]
    News,
    /// 图文消息(mpnews)
    #[serde(rename = "mpnews")]
    MpNews,
    /// markdown 消息
    #[serde(rename = "markdown")]
    Markdown,
    /// 小程序通知消息
    #[serde(rename = "miniprogram_notice")]
    MiniProgramNotice,
    /// 模版卡片消息
    #[serde(rename = "template_card")]
    TemplateCard,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Ord, PartialOrd, Eq, PartialEq, Hash)]
pub enum WeworkMsgTemplateCardType {
    /// 文本通知型
    #[serde(rename = "text_notice")]
    TextNotice,
    /// 图文展示型
    #[serde(rename = "news_notice")]
    NewsNotice,
    /// 按钮交互型
    #[serde(rename = "button_interaction")]
    ButtonInteraction,
    /// 投票选择型
    #[serde(rename = "vote_interaction")]
    VoteInteraction,
    /// 多项选择型
    #[serde(rename = "multiple_interaction")]
    MultipleInteraction,
}


#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "msgtype")]
pub enum WeworkMsg {
    /// 文本消息
    #[serde(rename = "text")]
    Text(WeworkTextMsg),
    /// 图片消息
    #[serde(rename = "image")]
    Image(WeworkImageMsg),
    /// 语音消息
    #[serde(rename = "voice")]
    Voice(WeworkVoiceMsg),
    /// 视频消息
    #[serde(rename = "video")]
    Video(WeworkVideoMsg),
    /// 文件消息
    #[serde(rename = "file")]
    File(WeworkFileMsg),
    /// 文本卡片消息
    #[serde(rename = "textcard")]
    TextCard(WeworkTextCardMsg),
    /// 图文消息
    #[serde(rename = "news")]
    News(WeworkNewsMsg),
    /// 图文消息(mpnews)
    #[serde(rename = "mpnews")]
    MpNews(WeworkMpNewsMsg),
    /// markdown 消息
    #[serde(rename = "markdown")]
    Markdown(WeworkMarkdownMsg),
    /// 小程序通知消息
    #[serde(rename = "miniprogram_notice")]
    MiniProgramNotice(WeworkMiniProgramNoticeMsg),
    /// 模版卡片消息
    #[serde(rename = "template_card")]
    TemplateCard(WeworkTemplateCardMsg),
}

/// 文本消息 text
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkTextMsg {
    pub text: WeworkTextMsgBody,
}

/// 文本消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkTextMsgBody {
    /// 消息内容,最长不超过2048个字节,超过将截断(支持id转译)
    pub content: String,
}

/// 图片消息 image
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkImageMsg {
    pub image: WeworkImageMsgBody,
}

/// 图片消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkImageMsgBody {
    /// 图片媒体文件id,可以调用上传临时素材接口获取
    pub media_id: String,
}

/// 语音消息 voice
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkVoiceMsg {
    pub voice: WeworkVoiceMsgBody,
}

/// 语音消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkVoiceMsgBody {
    /// 语音文件id,可以调用上传临时素材接口获取
    pub media_id: String,
}

/// 视频消息 video
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkVideoMsg {
    pub video: WeworkVideoMsgBody,
}

/// 视频消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkVideoMsgBody {
    /// 视频媒体文件id,可以调用上传临时素材接口获取
    pub media_id: String,
    /// 视频消息的标题,不超过128个字节,超过会自动截断
    pub title: Option<String>,
    /// 视频消息的描述,不超过512个字节,超过会自动截断
    pub description: Option<String>,
}

/// 文件消息 file
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkFileMsg {
    pub file: WeworkFileMsgBody,
}

/// 文件消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkFileMsgBody {
    /// 文件id,可以调用上传临时素材接口获取
    pub media_id: String,
}

/// 文本卡片消息 textcard
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkTextCardMsg {
    pub textcard: WeworkTextCardMsgBody,
}

/// 文本卡片消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkTextCardMsgBody {
    /// 标题,不超过128个字节,超过会自动截断(支持id转译)
    pub title: String,
    /// 描述,不超过512个字节,超过会自动截断(支持id转译)
    pub description: String,
    /// 点击后跳转的链接。最长2048字节,请确保包含了协议头(http/https)
    pub url: String,
    /// 按钮文字。 默认为“详情”, 不超过4个文字,超过自动截断。
    pub btntxt: Option<String>,
}

/// 图文消息 news
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkNewsMsg {
    pub news: WeworkNewsMsgBody,
}

/// 图文消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkNewsMsgBody {
    /// 图文消息,一个图文消息支持1到8条图文
    pub articles: Vec<WeworkNewsMsgBodyArticle>,
}

/// 一条图文
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkNewsMsgBodyArticle {
    /// 标题,不超过128个字节,超过会自动截断(支持id转译)
    pub title: String,
    /// 描述,不超过512个字节,超过会自动截断(支持id转译)
    pub description: Option<String>,
    /// 点击后跳转的链接。 最长2048字节,请确保包含了协议头(http/https),小程序或者url必须填写一个
    pub url: Option<String>,
    /// 图文消息的图片链接,最长2048字节,支持JPG、PNG格式,较好的效果为大图 1068*455,小图150*150。
    pub picurl: Option<String>,
    /// 小程序appid,必须是与当前应用关联的小程序,appid和pagepath必须同时填写,填写后会忽略url字段
    pub appid: Option<String>,
    /// 点击消息卡片后的小程序页面,最长128字节,仅限本小程序内的页面。appid和pagepath必须同时填写,填写后会忽略url字段
    pub pagepath: Option<String>,
}

/// 图文消息 mpnews(图文内容存储在企业微信)
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkMpNewsMsg {
    pub mpnews: WeworkMpNewsMsgBody,
}

/// 图文消息体 mpnews
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkMpNewsMsgBody {
    /// 图文消息,一个图文消息支持1到8条图文
    pub articles: Vec<WeworkMpNewsMsgBodyArticle>,
}

/// 一条图文消息 mpnews
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkMpNewsMsgBodyArticle {
    /// 标题,不超过128个字节,超过会自动截断(支持id转译)
    pub title: String,
    /// 图文消息缩略图的media_id, 可以通过素材管理接口获得。此处thumb_media_id即上传接口返回的media_id
    pub thumb_media_id: String,
    /// 图文消息的作者,不超过64个字节
    pub author: Option<String>,
    /// 图文消息点击“阅读原文”之后的页面链接
    pub content_source_url: Option<String>,
    /// 图文消息的内容,支持html标签,不超过666 K个字节(支持id转译)
    pub content: String,
    /// 图文消息的描述,不超过512个字节,超过会自动截断(支持id转译)
    pub digest: Option<String>,
}

/// Markdown 消息
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkMarkdownMsg {
    pub markdown: WeworkMarkdownMsgBody,
}

/// Markdown 消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkMarkdownMsgBody {
    /// markdown内容,最长不超过2048个字节,必须是utf8编码
    pub content: String,
}

/// 小程序消息
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkMiniProgramNoticeMsg {
    pub miniprogram_notice: WeworkMiniProgramNoticeMsgBody,
}

/// 小程序消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkMiniProgramNoticeMsgBody {
    /// 小程序appid,必须是与当前应用关联的小程序
    pub appid: String,
    /// 点击消息卡片后的小程序页面,最长1024个字节,仅限本小程序内的页面。该字段不填则消息点击后不跳转。
    pub page: Option<String>,
    /// 消息标题,长度限制4-12个汉字(支持id转译)
    pub title: String,
    /// 消息描述,长度限制4-12个汉字(支持id转译)
    pub description: Option<String>,
    /// 是否放大第一个content_item
    pub emphasis_first_item: Option<bool>,
    /// 消息内容键值对,最多允许10个item
    pub content_item: Option<Vec<WeworkMiniProgramNoticeMsgBodyContentItem>>,
}

/// 小程序消息体内容
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkMiniProgramNoticeMsgBodyContentItem {
    /// 长度10个汉字以内
    pub key: String,
    /// 长度30个汉字以内(支持id转译)
    pub value: String,
}

虽然“模版卡片”的消息复杂,但是无论是外层结构还是内层结构,都一样还是那个套路,那这事就清晰明朗了。

由于“模版卡片”的消息结构毕竟复杂,这写到差不多的时候,自然就会想要把它独立出去为一个单独的文件,这样在代码查看和整体结构上会有一个更好的体验和设计,这时候就要用到 Rust 的 ”模块“ mod 这个概念了。

先说在前面,如果你用过 Python 的话,你会发现 Rust 的模块 mod.rs 与 Python 中的模块 __init__.py 它俩非常相似,可以说几乎雷同了。

model/msg/wework/template_card.rs


/// 模版卡片消息
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct WeworkTemplateCardMsg {
    /// 模版卡片消息体
    pub template_card: TemplateCard,
}

/// 模版卡片消息体
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "card_type")]
pub enum TemplateCard {
    /// 文本通知型
    #[serde(rename = "text_notice")]
    TextNotice(TextNotice),
    /// 图文展示型
    #[serde(rename = "news_notice")]
    NewsNotice(NewsNotice),
    /// 按钮交互型
    #[serde(rename = "button_interaction")]
    ButtonInteraction(ButtonInteraction),
    /// 投票选择型
    #[serde(rename = "vote_interaction")]
    VoteInteraction(VoteInteraction),
    /// 多项选择型
    #[serde(rename = "multiple_interaction")]
    MultipleInteraction(MultipleInteraction),
}

/// 文本通知型
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct TextNotice {
    /// 卡片来源样式信息,不需要来源样式可不填写
    pub source: Option<Source>,
    /// 卡片右上角更多操作按钮
    pub action_menu: Option<ActionMenu>,
    /// 任务id,同一个应用任务id不能重复,只能由数字、字母和“_-@”组成,最长128字节,填了action_menu字段的话本字段必填
    pub task_id: Option<String>,
    /// 一级标题
    pub main_title: Option<MainTile>,
    /// 引用文献样式
    pub quote_area: Option<QuoteArea>,
    /// 关键数据样式
    pub emphasis_content: Option<EmphasisContent>,
    /// 二级普通文本,建议不超过160个字,(支持id转译)
    pub sub_title_text: Option<String>,
    /// 二级标题+文本列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过6
    pub horizontal_content_list: Option<Vec<HorizontalContentItem>>,
    /// 跳转指引样式的列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过3
    pub jump_list: Option<Vec<JumpItem>>,
    /// 整体卡片的点击跳转事件,text_notice必填本字段
    pub card_action: CardAction,
}

/// 图文展示型
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct NewsNotice {
    /// 卡片来源样式信息,不需要来源样式可不填写
    pub source: Option<Source>,
    /// 卡片右上角更多操作按钮
    pub action_menu: Option<ActionMenu>,
    /// 任务id,同一个应用任务id不能重复,只能由数字、字母和“_-@”组成,最长128字节
    pub task_id: String,
    /// 一级标题
    pub main_title: MainTile,
    /// 引用文献样式
    pub quote_area: Option<QuoteArea>,
    /// 左图右文样式,news_notice类型的卡片,card_image和image_text_area两者必填一个字段,不可都不填
    pub image_text_area: Option<ImageTextArea>,
    ///图片样式,news_notice类型的卡片,card_image和image_text_area两者必填一个字段,不可都不填
    pub card_image: Option<CardImage>,
    /// 卡片二级垂直内容,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过4
    pub vertical_content_list: Option<Vec<VerticalContentItem>>,
    /// 二级标题+文本列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过6
    pub horizontal_content_list: Option<Vec<HorizontalContentItem>>,
    /// 跳转指引样式的列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过3
    pub jump_list: Vec<JumpItem>,
    /// 整体卡片的点击跳转事件,news_notice必填本字段
    pub card_action: CardAction,
}

/// 按钮交互型
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ButtonInteraction {
    /// 卡片来源样式信息,不需要来源样式可不填写
    pub source: Option<Source>,
    /// 卡片右上角更多操作按钮
    pub action_menu: Option<ActionMenu>,
    /// 一级标题
    pub main_title: MainTile,
    /// 引用文献样式
    pub quote_area: Option<QuoteArea>,
    /// 二级普通文本,建议不超过160个字,(支持id转译)
    pub sub_title_text: Option<String>,
    /// 二级标题+文本列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过6
    pub horizontal_content_list: Option<Vec<HorizontalContentItem>>,
    /// 整体卡片的点击跳转事件
    pub card_action: Option<CardAction>,
    /// 任务id,同一个应用任务id不能重复,只能由数字、字母和“_-@”组成,最长128字节
    pub task_id: String,
    /// 下拉式的选择器
    pub button_selection: ButtonSelection,
    /// 按钮列表,列表长度不超过6
    pub button_list: Vec<ButtonItem>,
}

/// 投票选择型
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct VoteInteraction {
    /// 卡片来源样式信息,不需要来源样式可不填写
    pub source: Option<Source>,
    /// 一级标题
    pub main_title: MainTile,
    /// 任务id,同一个应用任务id不能重复,只能由数字、字母和“_-@”组成,最长128字节
    pub task_id: String,
    /// 选择题样式
    pub checkbox: Option<Checkbox>,
    /// 提交按钮样式
    pub submit_button: Option<SubmitButton>,
}

/// 多项选择型
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct MultipleInteraction {
    /// 卡片来源样式信息,不需要来源样式可不填写
    pub source: Option<Source>,
    /// 一级标题
    pub main_title: MainTile,
    /// 任务id,同一个应用任务id不能重复,只能由数字、字母和“_-@”组成,最长128字节
    pub task_id: String,
    /// 下拉式的选择器列表,multiple_interaction类型的卡片该字段不可为空,一个消息最多支持 3 个选择器
    pub select_list: Vec<SelectItem>,
    /// 提交按钮样式
    pub submit_button: SubmitButton,
}

/// 卡片来源样式信息
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Source {
    /// 来源图片的url,来源图片的尺寸建议为72*72
    pub icon_url: Option<String>,
    /// 来源图片的描述,建议不超过20个字,(支持id转译)
    pub desc: Option<String>,
    /// 来源文字的颜色,目前支持:0(默认) 灰色,1 黑色,2 红色,3 绿色
    pub desc_color: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ActionMenu {
    /// 更多操作界面的描述
    pub desc: Option<String>,
    /// 操作列表,列表长度取值范围为 [1, 3]
    pub action_list: Vec<ActionMenuItem>,
}

/// 操作
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ActionMenuItem {
    /// 操作的描述文案
    pub text: String,
    /// 操作key值,用户点击后,会产生回调事件将本参数作为EventKey返回,回调事件会带上该key值,最长支持1024字节,不可重复
    pub key: String,
}


#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct MainTile {
    /// 一级标题,建议不超过36个字,文本通知型卡片本字段非必填,但不可本字段和sub_title_text都不填,(支持id转译)
    pub title: Option<String>,
    /// 标题辅助信息,建议不超过44个字,(支持id转译)
    pub desc: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct QuoteArea {
    /// 引用文献样式区域点击事件,0或不填代表没有点击事件,1 代表跳转url,2 代表跳转小程序
    #[serde(rename = "type")]
    pub type_: Option<i32>,
    /// 点击跳转的url,quote_area.type是1时必填
    pub url: Option<String>,
    /// 点击跳转的小程序的appid,必须是与当前应用关联的小程序,quote_area.type是2时必填
    pub appid: Option<String>,
    /// 点击跳转的小程序的pagepath,quote_area.type是2时选填
    pub pagepath: Option<String>,
    /// 引用文献样式的标题
    pub title: Option<String>,
    /// 引用文献样式的引用文案
    pub quote_text: Option<String>,
}

/// 左图右文样式
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ImageTextArea {
    /// 左图右文样式区域点击事件,0或不填代表没有点击事件,1 代表跳转url,2 代表跳转小程序
    #[serde(rename = "type")]
    pub type_: Option<i32>,
    /// 点击跳转的url,image_text_area.type是1时必填
    pub url: Option<String>,
    /// 点击跳转的小程序的appid,必须是与当前应用关联的小程序,image_text_area.type是2时必填
    pub appid: Option<String>,
    /// 点击跳转的小程序的pagepath,image_text_area.type是2时选填
    pub pagepath: Option<String>,
    /// 左图右文样式的标题
    pub title: Option<String>,
    /// 左图右文样式的描述
    pub desc: Option<String>,
    /// 左图右文样式的图片url
    pub image_url: String,
}

/// 图片样式
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct CardImage {
    /// 图片的url
    pub url: String,
    /// 图片的宽高比,宽高比要小于2.25,大于1.3,不填该参数默认1.3
    pub aspect_ratio: Option<f64>,
}

/// 卡片二级垂直内容
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct VerticalContentItem {
    /// 卡片二级标题,建议不超过38个字
    pub title: String,
    /// 二级普通文本,建议不超过160个字
    pub desc: Option<String>,
}

/// 关键数据样式
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct EmphasisContent {
    /// 关键数据样式的数据内容,建议不超过14个字
    pub title: Option<String>,
    /// 关键数据样式的数据描述内容,建议不超过22个字
    pub desc: Option<String>,
}

/// 二级标题+文本列表
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct HorizontalContentItem {
    /// 链接类型,0或不填代表不是链接,1 代表跳转url,2 代表下载附件,3 代表点击跳转成员详情
    #[serde(rename = "type")]
    pub type_: Option<String>,
    /// 二级标题,建议不超过5个字
    pub keyname: String,
    /// 二级文本,如果horizontal_content_list.type是2,该字段代表文件名称(要包含文件类型),建议不超过30个字,(支持id转译)
    pub value: Option<String>,
    /// 链接跳转的url,horizontal_content_list.type是1时必填
    pub url: Option<String>,
    /// 附件的media_id,horizontal_content_list.type是2时必填
    pub media_id: Option<String>,
    /// 成员详情的userid,horizontal_content_list.type是3时必填
    pub userid: Option<String>,
}

/// 跳转指引样式的列表
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct JumpItem {
    /// 跳转链接类型,0或不填代表不是链接,1 代表跳转url,2 代表跳转小程序
    #[serde(rename = "type")]
    pub type_: Option<String>,
    /// 跳转链接样式的文案内容,建议不超过18个字
    pub title: String,
    /// 跳转链接的url,jump_list.type是1时必填
    pub url: Option<String>,
    /// 跳转链接的小程序的appid,必须是与当前应用关联的小程序,jump_list.type是2时必填
    pub appid: Option<String>,
    /// 跳转链接的小程序的pagepath,jump_list.type是2时选填
    pub pagepath: Option<String>,
}

/// 整体卡片的点击跳转事件
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct CardAction {
    /// 跳转事件类型,1 代表跳转url,2 代表打开小程序。text_notice卡片模版中该字段取值范围为[1,2]
    #[serde(rename = "type")]
    pub type_: String,
    /// 跳转事件的url,card_action.type是1时必填
    pub url: String,
    /// 跳转事件的小程序的appid,必须是与当前应用关联的小程序,card_action.type是2时必填
    pub appid: String,
    /// 跳转事件的小程序的pagepath,card_action.type是2时选填
    pub pagepath: String,
}

/// 下拉式的选择器
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ButtonSelection {
    /// 下拉式的选择器的key,用户提交选项后,会产生回调事件,回调事件会带上该key值表示该题,最长支持1024字节
    pub questioin_key: String,
    /// 下拉式的选择器左边的标题
    pub title: Option<String>,
    /// 选项列表,下拉选项不超过 10 个,最少1个
    pub option_list: Vec<ButtonSelectionOptionItem>,
    /// 默认选定的id,不填或错填默认第一个
    pub selected_id: Option<String>,
}

/// 下拉式的选择器 选项列表
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ButtonSelectionOptionItem {
    /// 下拉式的选择器选项的id,用户提交后,会产生回调事件,回调事件会带上该id值表示该选项,最长支持128字节,不可重复
    pub id: String,
    /// 下拉式的选择器选项的文案,建议不超过16个字
    pub text: String,
}

/// 按钮
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ButtonItem {
    /// 按钮点击事件类型,0 或不填代表回调点击事件,1 代表跳转url
    #[serde(rename = "type")]
    pub type_: Option<String>,
    /// 按钮文案,建议不超过10个字
    pub text: String,
    /// 按钮样式,目前可填1~4,不填或错填默认1
    pub style: Option<i32>,
    /// 按钮key值,用户点击后,会产生回调事件将本参数作为EventKey返回,回调事件会带上该key值,最长支持1024字节,不可重复,button_list.type是0时必填
    pub key: Option<String>,
    /// 跳转事件的url,button_list.type是1时必填
    pub url: Option<String>,
}

/// 选择题样式
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Checkbox {
    /// 选择题key值,用户提交选项后,会产生回调事件,回调事件会带上该key值表示该题,最长支持1024字节
    pub question_key: String,
    /// 选择题模式,单选:0,多选:1,不填默认0
    pub mode: Option<i32>,
    /// 选项list,选项个数不超过 20 个,最少1个
    pub option_list: Vec<CheckboxOptionItem>,
}

/// 选项
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct CheckboxOptionItem {
    /// 选项id,用户提交选项后,会产生回调事件,回调事件会带上该id值表示该选项,最长支持128字节,不可重复
    pub id: String,
    /// 选项文案描述,建议不超过17个字
    pub text: String,
    /// 该选项是否要默认选中
    pub is_checked: bool,
}

/// 提交按钮
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct SubmitButton {
    /// 按钮文案,建议不超过10个字,不填默认为提交
    pub text: String,
    /// 提交按钮的key,会产生回调事件将本参数作为EventKey返回,最长支持1024字节
    pub key: String,
}

/// 下拉式的选择器
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct SelectItem {
    /// 下拉式的选择器题目的key,用户提交选项后,会产生回调事件,回调事件会带上该key值表示该题,最长支持1024字节,不可重复
    pub question_key: String,
    /// 下拉式的选择器上面的title
    pub title: Option<String>,
    /// 默认选定的id,不填或错填默认第一个
    pub selected_id: Option<String>,
    /// 选项列表,下拉选项不超过 10 个,最少1个
    pub option_list: Vec<SelectItemOptionItem>,
}

/// 下拉式的选择器 选项列表
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct SelectItemOptionItem {
    /// 下拉式的选择器选项的id,用户提交选项后,会产生回调事件,回调事件会带上该id值表示该选项,最长支持128字节,不可重复
    pub id: String,
    /// 下拉式的选择器选项的文案,建议不超过16个字
    pub text: String,
}

这次又是类似,无论多么复杂的结构,层层分解,每次声明局部的一个小部分的时候,就关注局部的这个小部分就可以了,取个合理的名称,如果名称太长,考虑合理简化一些,字段名称是什么,序列化和反序列化时候的名称要不要变化,它是一个可选字段还是必选字段,它的数据类型又是什么,如果是一个对象,那么就又再给它命一个结构出来即可,循环往复这个动作,最终完成所有结构的声明即可。

这次除了新增了关于 “模块“ mod.rs 的知识之外,其实就没有更多的要细说的了,编程,基本知识学到位之后,到最后,其实就是一个需要细心和耐心的体力活,只要是自己手写出来的代码,你都始终会有印象的,那时候才算知识的真正学会了。

顺便提一嘴,你想想,如果不是去声明这些结构体,而是面对着层层嵌套的 json 直接进行操作,你觉得会有什么样的体验?

还有哦,结构体的文档说明一定要有,否则过一段时间你就忘记了它是什么意思了。同时呢,这些结构体上的文档说明,也会被其他工具所用来自动化地去展示非常详尽的接口文档,包括字段值的类型,例如 OpenAPI 或 Swagger 等,这是我在 Python 或者 Golang 从未得到过完善解决甚至有时根本无法解决,而在 Rust 这里,被做到了极致。