diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/convert/message/MpAutoReplyConvert.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/convert/message/MpAutoReplyConvert.java
new file mode 100644
index 000000000..a14c5fee1
--- /dev/null
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/convert/message/MpAutoReplyConvert.java
@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.module.mp.convert.message;
+
+import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpAutoReplyDO;
+import cn.iocoder.yudao.module.mp.service.message.bo.MpMessageSendOutReqBO;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.Mappings;
+import org.mapstruct.factory.Mappers;
+
+@Mapper
+public interface MpAutoReplyConvert {
+
+    MpAutoReplyConvert INSTANCE = Mappers.getMapper(MpAutoReplyConvert.class);
+
+    @Mappings({
+            @Mapping(source = "reply.appId", target = "appId"),
+            @Mapping(source = "reply.responseMessageType", target = "type"),
+            @Mapping(source = "reply.responseContent", target = "content"),
+            @Mapping(source = "reply.responseMediaId", target = "mediaId"),
+            @Mapping(source = "reply.responseMediaUrl", target = "mediaUrl"),
+            @Mapping(source = "reply.responseTitle", target = "title"),
+            @Mapping(source = "reply.responseDescription", target = "description"),
+            @Mapping(source = "reply.responseArticle", target = "article"),
+    })
+    MpMessageSendOutReqBO convert(String openid, MpAutoReplyDO reply);
+
+}
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/convert/message/MpMessageConvert.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/convert/message/MpMessageConvert.java
index 9b7eae008..f440bf39b 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/convert/message/MpMessageConvert.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/convert/message/MpMessageConvert.java
@@ -1,20 +1,16 @@
 package cn.iocoder.yudao.module.mp.convert.message;
 
-import cn.hutool.core.util.StrUtil;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
 import cn.iocoder.yudao.module.mp.controller.admin.message.vo.MpMessageRespVO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO;
-import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpAutoReplyDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpMessageDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.user.MpUserDO;
+import cn.iocoder.yudao.module.mp.service.message.bo.MpMessageSendOutReqBO;
 import me.chanjar.weixin.common.api.WxConsts;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutNewsMessage;
 import me.chanjar.weixin.mp.builder.outxml.BaseBuilder;
-import me.chanjar.weixin.mp.builder.outxml.TextBuilder;
-import me.chanjar.weixin.mp.builder.outxml.VideoBuilder;
 import org.mapstruct.Mapper;
 import org.mapstruct.Mapping;
 import org.mapstruct.Mappings;
@@ -51,23 +47,27 @@ public interface MpMessageConvert {
         return message;
     }
 
-    default MpMessageDO convert(MpAutoReplyDO reply, MpAccountDO account, MpUserDO user) {
+    default MpMessageDO convert(MpMessageSendOutReqBO sendReqBO, MpAccountDO account, MpUserDO user) {
         // 构建消息
         MpMessageDO message = new MpMessageDO();
-        message.setType(reply.getResponseMessageType());
-        // 1. 文本
-        if (StrUtil.equals(reply.getResponseMessageType(), WxConsts.XmlMsgType.TEXT)) {
-            message.setContent(reply.getResponseContent());
-        } else if (ObjectUtils.equalsAny(reply.getResponseMessageType(), WxConsts.XmlMsgType.IMAGE,  // 2. 图片
-                WxConsts.XmlMsgType.VOICE)) { // 3. 语音
-            message.setMediaId(reply.getResponseMediaId()).setMediaUrl(reply.getResponseMediaUrl());
-        } else if (StrUtil.equals(reply.getResponseMessageType(), WxConsts.XmlMsgType.VIDEO)) { // 4. 视频
-            message.setMediaId(reply.getResponseMediaId()).setMediaUrl(reply.getResponseMediaUrl())
-                    .setTitle(reply.getResponseTitle()).setDescription(reply.getResponseDescription());
-        } else if (StrUtil.equals(reply.getResponseMessageType(), WxConsts.XmlMsgType.NEWS)) { // 5. 图文
-            message.setArticles(Collections.singletonList(reply.getResponseArticle()));
-        } else {
-            throw new IllegalArgumentException("不支持的消息类型:" + message.getType());
+        message.setType(sendReqBO.getType());
+        switch (sendReqBO.getType()) {
+            case WxConsts.XmlMsgType.TEXT: // 1. 文本
+                message.setContent(sendReqBO.getContent());
+                break;
+            case WxConsts.XmlMsgType.IMAGE: // 2. 图片
+            case WxConsts.XmlMsgType.VOICE: // 3. 语音
+                message.setMediaId(sendReqBO.getMediaId()).setMediaUrl(sendReqBO.getMediaUrl());
+                break;
+            case WxConsts.XmlMsgType.VIDEO: // 4. 视频
+                message.setMediaId(sendReqBO.getMediaId()).setMediaUrl(sendReqBO.getMediaUrl())
+                        .setTitle(sendReqBO.getTitle()).setDescription(sendReqBO.getDescription());
+                break;
+            case WxConsts.XmlMsgType.NEWS: // 5. 图文
+                message.setArticles(Collections.singletonList(sendReqBO.getArticle()));
+                break;
+            default:
+                throw new IllegalArgumentException("不支持的消息类型:" + message.getType());
         }
 
         // 其它字段
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/menu/MpMenuDO.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/menu/MpMenuDO.java
index 3d1c2bba4..24889116c 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/menu/MpMenuDO.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/menu/MpMenuDO.java
@@ -1,15 +1,19 @@
 package cn.iocoder.yudao.module.mp.dal.dataobject.menu;
 
 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpMessageDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
 import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler;
-import lombok.*;
-
-import com.baomidou.mybatisplus.annotation.*;
-import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
-import me.chanjar.weixin.common.bean.menu.WxMenu;
-import me.chanjar.weixin.common.bean.menu.WxMenuButton;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import me.chanjar.weixin.common.api.WxConsts;
+import me.chanjar.weixin.common.api.WxConsts.MenuButtonType;
 
 import java.util.List;
 
@@ -48,8 +52,8 @@ public class MpMenuDO extends BaseDO {
     /**
      * 按钮列表
      */
-    @TableField(typeHandler = WxMenuButtonTypeHandler.class)
-    private List<WxMenuButton> buttons;
+    @TableField(typeHandler = ButtonTypeHandler.class)
+    private List<Button> buttons;
     /**
      * 同步状态
      *
@@ -58,16 +62,113 @@ public class MpMenuDO extends BaseDO {
      */
     private Boolean syncStatus;
 
+    /**
+     * 按钮
+     */
+    @Data
+    public static class Button {
+
+        /**
+         * 类型
+         *
+         * 枚举 {@link MenuButtonType}
+         */
+        private String type;
+        /**
+         * 消息类型
+         *
+         * 当 {@link #type} 为 CLICK、SCANCODE_WAITMSG
+         *
+         * 枚举 {@link WxConsts.XmlMsgType} 中的 TEXT、IMAGE、VOICE、VIDEO、NEWS
+         */
+        private String messageType;
+        /**
+         * 名称
+         */
+        private String name;
+        /**
+         * 标识
+         */
+        private String key;
+        /**
+         * 二级菜单列表
+         */
+        private List<Button> subButtons;
+        /**
+         * 网页链接
+         *
+         * 用户点击菜单可打开链接,不超过 1024 字节
+         *
+         * 类型为 {@link WxConsts.XmlMsgType} 的 VIEW、MINIPROGRAM
+         */
+        private String url;
+
+        /**
+         * 小程序的 appId
+         *
+         * 类型为 {@link WxConsts.XmlMsgType} 的 MINIPROGRAM
+         */
+        private String appId;
+
+        /**
+         * 小程序的页面路径
+         *
+         * 类型为 {@link WxConsts.XmlMsgType} 的 MINIPROGRAM
+         */
+        private String pagePath;
+
+        /**
+         * 消息内容
+         *
+         * 消息类型为 {@link WxConsts.XmlMsgType} 的 TEXT
+         */
+        private String content;
+
+        /**
+         * 媒体 id
+         *
+         * 消息类型为 {@link WxConsts.XmlMsgType} 的 IMAGE、VOICE、VIDEO
+         */
+        private String mediaId;
+        /**
+         * 媒体 URL
+         *
+         * 消息类型为 {@link WxConsts.XmlMsgType} 的 IMAGE、VOICE、VIDEO
+         */
+        private String mediaUrl;
+
+        /**
+         * 回复的标题
+         *
+         * 消息类型为 {@link WxConsts.XmlMsgType} 的 VIDEO
+         */
+        private String title;
+        /**
+         * 回复的描述
+         *
+         * 消息类型为 {@link WxConsts.XmlMsgType} 的 VIDEO
+         */
+        private String description;
+
+        /**
+         * 图文消息
+         *
+         * 消息类型为 {@link WxConsts.XmlMsgType} 的 NEWS
+         */
+        private MpMessageDO.Article article;
+
+    }
+
     // TODO @芋艿:可以找一些新的思路
-    public static class WxMenuButtonTypeHandler extends AbstractJsonTypeHandler<List<WxMenuButton>> {
+    public static class ButtonTypeHandler extends AbstractJsonTypeHandler<List<Button>> {
 
         @Override
-        protected List<WxMenuButton> parse(String json) {
-            return JsonUtils.parseArray(json, WxMenuButton.class);
+        protected List<Button> parse(String json) {
+            return JsonUtils.parseArray(json, Button.class);
         }
 
         @Override
-        protected String toJson(List<WxMenuButton> obj) {
+        protected String toJson(List<Button> obj) {
             return JsonUtils.toJsonString(obj);
         }
 
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/message/MpAutoReplyDO.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/message/MpAutoReplyDO.java
index c07cc2eb6..1a9ed08c6 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/message/MpAutoReplyDO.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/message/MpAutoReplyDO.java
@@ -16,7 +16,6 @@ import lombok.ToString;
 import me.chanjar.weixin.common.api.WxConsts;
 import me.chanjar.weixin.common.api.WxConsts.XmlMsgType;
 
-import java.util.List;
 import java.util.Set;
 
 /**
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/message/MpMessageDO.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/message/MpMessageDO.java
index 1aea757ba..d69b58f35 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/message/MpMessageDO.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/dataobject/message/MpMessageDO.java
@@ -12,7 +12,9 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler;
 import lombok.*;
 import me.chanjar.weixin.common.api.WxConsts;
+import me.chanjar.weixin.mp.builder.kefu.NewsBuilder;
 
+import javax.validation.constraints.NotEmpty;
 import java.io.Serializable;
 import java.util.List;
 
@@ -199,20 +201,24 @@ public class MpMessageDO extends BaseDO {
         /**
          * 图文消息标题
          */
+        @NotEmpty(message = "图文消息标题不能为空", groups = NewsBuilder.class)
         private String title;
         /**
          * 图文消息描述
          */
+        @NotEmpty(message = "图文消息描述不能为空", groups = NewsBuilder.class)
         private String description;
         /**
          * 图片链接
          *
-         * 支持JPG、PNG格式,较好的效果为大图 360*200,小图 200*200
+         * 支持 JPG、PNG 格式,较好的效果为大图 360*200,小图 200*200
          */
+        @NotEmpty(message = "图片链接不能为空", groups = NewsBuilder.class)
         private String picUrl;
         /**
          * 点击图文消息跳转链接
          */
+        @NotEmpty(message = "点击图文消息跳转链接不能为空", groups = NewsBuilder.class)
         private String url;
 
     }
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/mysql/menu/MpMenuMapper.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/mysql/menu/MpMenuMapper.java
index 251aa8a53..d374e6a15 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/mysql/menu/MpMenuMapper.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/dal/mysql/menu/MpMenuMapper.java
@@ -7,4 +7,8 @@ import org.apache.ibatis.annotations.Mapper;
 @Mapper
 public interface MpMenuMapper extends BaseMapperX<MpMenuDO> {
 
+    default MpMenuDO selectByAppId(String appId) {
+        return selectOne(MpMenuDO::getAppId, appId);
+    }
+
 }
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/util/MpUtils.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/util/MpUtils.java
new file mode 100644
index 000000000..4a01b09ba
--- /dev/null
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/framework/mp/core/util/MpUtils.java
@@ -0,0 +1,75 @@
+package cn.iocoder.yudao.module.mp.framework.mp.core.util;
+
+import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.api.WxConsts;
+
+import javax.validation.Validator;
+
+/**
+ * 公众号工具类
+ *
+ * @author 芋道源码
+ */
+@Slf4j
+public class MpUtils {
+
+    /**
+     * Text 类型的消息,参数校验 Group
+     */
+    public interface TextGroup {}
+
+    /**
+     * Image 类型的消息,参数校验 Group
+     */
+    public interface ImageGroup {}
+
+    /**
+     * Voice 类型的消息,参数校验 Group
+     */
+    public interface VoiceGroup {}
+
+    /**
+     * Video 类型的消息,参数校验 Group
+     */
+    public interface VideoGroup {}
+
+    /**
+     * News 类型的消息,参数校验 Group
+     */
+    public interface NewsGroup {}
+
+    /**
+     * 校验消息的格式是否符合要求
+     *
+     * @param type 类型
+     * @param message 消息
+     */
+    public static void validateMessage(Validator validator, String type, Object message) {
+        // 获得对应的校验 group
+        Class<?> group;
+        switch (type) {
+            case WxConsts.XmlMsgType.TEXT:
+                group = TextGroup.class;
+                break;
+            case WxConsts.XmlMsgType.IMAGE:
+                group = ImageGroup.class;
+                break;
+            case WxConsts.XmlMsgType.VOICE:
+                group = VoiceGroup.class;
+                break;
+            case WxConsts.XmlMsgType.VIDEO:
+                group = VideoGroup.class;
+                break;
+            case WxConsts.XmlMsgType.NEWS:
+                group = NewsGroup.class;
+                break;
+            default:
+                log.error("[validateMessage][未知的消息类型({})]", message);
+                throw new IllegalArgumentException("不支持的消息类型:" + type);
+        }
+        // 执行校验
+        ValidationUtils.validate(validator, message, group);
+    }
+
+}
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/handler/menu/MenuHandler.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/handler/menu/MenuHandler.java
index f04efedcb..24d471fbf 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/handler/menu/MenuHandler.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/handler/menu/MenuHandler.java
@@ -1,12 +1,16 @@
 package cn.iocoder.yudao.module.mp.service.handler.menu;
 
+import cn.iocoder.yudao.module.mp.framework.mp.core.context.MpContextHolder;
+import cn.iocoder.yudao.module.mp.service.menu.MpMenuService;
 import me.chanjar.weixin.common.session.WxSessionManager;
+import me.chanjar.weixin.mp.api.WxMpMenuService;
 import me.chanjar.weixin.mp.api.WxMpMessageHandler;
 import me.chanjar.weixin.mp.api.WxMpService;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
 import org.springframework.stereotype.Component;
 
+import javax.annotation.Resource;
 import java.util.Map;
 
 import static me.chanjar.weixin.common.api.WxConsts.MenuButtonType;
@@ -14,24 +18,20 @@ import static me.chanjar.weixin.common.api.WxConsts.MenuButtonType;
 /**
  * 自定义菜单的事件处理器
  *
- * // TODO 芋艿:待实现
+ * 逻辑:用户点击菜单时,触发对应的回复
+ *
+ * @author 芋道源码
  */
 @Component
 public class MenuHandler implements WxMpMessageHandler {
 
+    @Resource
+    private MpMenuService mpMenuService;
+
     @Override
-    public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
-                                    Map<String, Object> context, WxMpService weixinService,
-                                    WxSessionManager sessionManager) {
-
-        String msg = wxMessage.getEventKey();
-        if (MenuButtonType.VIEW.equals(wxMessage.getEvent())) {
-            return null;
-        }
-
-        return WxMpXmlOutMessage.TEXT().content(msg)
-            .fromUser(wxMessage.getToUser()).toUser(wxMessage.getFromUser())
-            .build();
+    public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map<String, Object> context,
+                                    WxMpService weixinService, WxSessionManager sessionManager) {
+        return mpMenuService.reply(MpContextHolder.getAppId(), wxMessage.getEventKey(), wxMessage.getFromUser());
     }
 
 }
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/handler/message/MessageReceiveHandler.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/handler/message/MessageReceiveHandler.java
index ef5fde52e..4e4a4ebf4 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/handler/message/MessageReceiveHandler.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/handler/message/MessageReceiveHandler.java
@@ -29,7 +29,7 @@ public class MessageReceiveHandler implements WxMpMessageHandler {
     public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map<String, Object> context,
                                     WxMpService wxMpService, WxSessionManager sessionManager) {
         log.info("[handle][接收到请求消息,内容:{}]", wxMessage);
-        mpMessageService.createFromUser(MpContextHolder.getAppId(), wxMessage);
+        mpMessageService.receiveMessage(MpContextHolder.getAppId(), wxMessage);
         return null;
     }
 
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/menu/MpMenuService.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/menu/MpMenuService.java
index 1bb45a381..439783951 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/menu/MpMenuService.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/menu/MpMenuService.java
@@ -4,6 +4,8 @@ import javax.validation.*;
 
 import cn.iocoder.yudao.module.mp.controller.admin.menu.vo.*;
 import cn.iocoder.yudao.module.mp.dal.dataobject.menu.MpMenuDO;
+import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
+import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
 
 /**
  * 微信菜单 Service 接口
@@ -35,4 +37,14 @@ public interface MpMenuService {
      */
     MpMenuDO getMenu(Long id);
 
+    /**
+     * 用户点击菜单按钮时,回复对应的消息
+     *
+     * @param appId 公众号 AppId
+     * @param key 菜单按钮的标识
+     * @param openid 用户的 openid
+     * @return 消息
+     */
+    WxMpXmlOutMessage reply(String appId, String key, String openid);
+
 }
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/menu/MpMenuServiceImpl.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/menu/MpMenuServiceImpl.java
index 823a1dad7..f2728d5f5 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/menu/MpMenuServiceImpl.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/menu/MpMenuServiceImpl.java
@@ -1,11 +1,18 @@
 package cn.iocoder.yudao.module.mp.service.menu;
 
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.util.StrUtil;
 import cn.iocoder.yudao.module.mp.convert.menu.MpMenuConvert;
+import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.menu.MpMenuDO;
 import cn.iocoder.yudao.module.mp.framework.mp.core.MpServiceFactory;
+import cn.iocoder.yudao.module.mp.service.account.MpAccountService;
+import lombok.extern.slf4j.Slf4j;
 import me.chanjar.weixin.common.bean.menu.WxMenu;
 import me.chanjar.weixin.common.error.WxErrorException;
 import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 
@@ -27,6 +34,7 @@ import static cn.iocoder.yudao.module.mp.enums.ErrorCodeConstants.*;
  */
 @Service
 @Validated
+@Slf4j
 public class MpMenuServiceImpl implements MpMenuService {
 
     @Resource
@@ -77,4 +85,49 @@ public class MpMenuServiceImpl implements MpMenuService {
         return mpMenuMapper.selectById(id);
     }
 
+    @Override
+    public WxMpXmlOutMessage reply(String appId, String key, String openid) {
+        // 获得菜单
+        MpMenuDO menu = mpMenuMapper.selectByAppId(appId);
+        if (menu == null) {
+            log.error("[reply][appId({}) 找不到对应的菜单]", appId);
+            return null;
+        }
+        // 匹配对应的按钮
+        MpMenuDO.Button button = getMenuButton(menu, key);
+        if (button == null) {
+            log.error("[reply][appId({}) key({}) 找不到对应的菜单按钮]", appId, key);
+            return null;
+        }
+        // 按钮必须要有消息类型,不然后续无法回复消息
+        if (StrUtil.isEmpty(button.getMessageType())) {
+            log.error("[reply][appId({}) key({}) 不存在消息类型({})]", appId, key, button);
+            return null;
+        }
+
+        // 回复消息
+        return null;
+    }
+
+    private MpMenuDO.Button getMenuButton(MpMenuDO menu, String key) {
+        // 先查询子按钮
+        for (MpMenuDO.Button button : menu.getButtons()) {
+            if (CollUtil.isEmpty(button.getSubButtons())) {
+                continue;
+            }
+            for (MpMenuDO.Button subButton : button.getSubButtons()) {
+                if (StrUtil.equals(subButton.getKey(), key)) {
+                    return subButton;
+                }
+            }
+        }
+        // 再查询父按钮
+        for (MpMenuDO.Button button : menu.getButtons()) {
+            if (StrUtil.equals(button.getKey(), key)) {
+                return button;
+            }
+        }
+        return null;
+    }
+
 }
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpAutoReplyServiceImpl.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpAutoReplyServiceImpl.java
index 8d4f63d63..d5f323aef 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpAutoReplyServiceImpl.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpAutoReplyServiceImpl.java
@@ -2,11 +2,13 @@ package cn.iocoder.yudao.module.mp.service.message;
 
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.lang.Assert;
+import cn.iocoder.yudao.module.mp.convert.message.MpAutoReplyConvert;
 import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpAutoReplyDO;
 import cn.iocoder.yudao.module.mp.dal.mysql.message.MpAutoReplyMapper;
 import cn.iocoder.yudao.module.mp.enums.message.MpAutoReplyTypeEnum;
 import cn.iocoder.yudao.module.mp.service.account.MpAccountService;
+import cn.iocoder.yudao.module.mp.service.message.bo.MpMessageSendOutReqBO;
 import me.chanjar.weixin.common.api.WxConsts;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
@@ -58,7 +60,8 @@ public class MpAutoReplyServiceImpl implements MpAutoReplyService {
         MpAutoReplyDO reply = CollUtil.getFirst(replies);
 
         // 第二步,基于自动回复,创建消息
-        return mpMessageService.createFromAutoReply(wxMessage.getFromUser(), reply);
+        MpMessageSendOutReqBO sendReqBO = MpAutoReplyConvert.INSTANCE.convert(wxMessage.getFromUser(), reply);
+        return mpMessageService.sendOutMessage(sendReqBO);
     }
 
     @Override
@@ -69,7 +72,8 @@ public class MpAutoReplyServiceImpl implements MpAutoReplyService {
                 : buildDefaultSubscribeAutoReply(appId); // 如果不存在,提供一个默认末班
 
         // 第二步,基于自动回复,创建消息
-        return mpMessageService.createFromAutoReply(wxMessage.getFromUser(), reply);
+        MpMessageSendOutReqBO sendReqBO = MpAutoReplyConvert.INSTANCE.convert(wxMessage.getFromUser(), reply);
+        return mpMessageService.sendOutMessage(sendReqBO);
     }
 
     private MpAutoReplyDO buildDefaultSubscribeAutoReply(String appId) {
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpMessageService.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpMessageService.java
index 4270cbe8a..8082f7e60 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpMessageService.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpMessageService.java
@@ -2,11 +2,13 @@ package cn.iocoder.yudao.module.mp.service.message;
 
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.module.mp.controller.admin.message.vo.MpMessagePageReqVO;
-import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpAutoReplyDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpMessageDO;
+import cn.iocoder.yudao.module.mp.service.message.bo.MpMessageSendOutReqBO;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
 
+import javax.validation.Valid;
+
 /**
  * 粉丝消息表 Service 接口
  *
@@ -24,20 +26,23 @@ public interface MpMessageService {
     PageResult<MpMessageDO> getWxFansMsgPage(MpMessagePageReqVO pageReqVO);
 
     /**
-     * 保存粉丝消息,来自用户发送
+     * 从公众号,接收到用户消息
      *
      * @param appId 微信公众号 appId
      * @param wxMessage 消息
      */
-    void createFromUser(String appId, WxMpXmlMessage wxMessage);
+    void receiveMessage(String appId, WxMpXmlMessage wxMessage);
 
     /**
-     * 创建粉丝消息,通过自动回复
+     * 使用公众号,给用户回复消息
      *
-     * @param openid 公众号粉丝 openid
-     * @param reply 自动回复
+     * 例如说:自动回复、客服消息、菜单回复消息等场景
+     *
+     * 注意,该方法只是返回 WxMpXmlOutMessage 对象,不会真的发送消息
+     *
+     * @param sendReqBO 消息内容
      * @return 微信回复消息 XML
      */
-    WxMpXmlOutMessage createFromAutoReply(String openid, MpAutoReplyDO reply);
+    WxMpXmlOutMessage sendOutMessage(@Valid MpMessageSendOutReqBO sendReqBO);
 
 }
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpMessageServiceImpl.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpMessageServiceImpl.java
index dbf52ebe9..ee7b8c46c 100644
--- a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpMessageServiceImpl.java
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/MpMessageServiceImpl.java
@@ -2,21 +2,22 @@ package cn.iocoder.yudao.module.mp.service.message;
 
 import cn.hutool.core.io.FileTypeUtil;
 import cn.hutool.core.io.FileUtil;
-import cn.hutool.core.io.IoUtil;
 import cn.hutool.core.lang.Assert;
 import cn.hutool.core.util.StrUtil;
-import cn.iocoder.yudao.framework.common.util.io.FileUtils;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.module.infra.api.file.FileApi;
+import cn.iocoder.yudao.module.mp.controller.admin.message.vo.MpMessagePageReqVO;
 import cn.iocoder.yudao.module.mp.convert.message.MpMessageConvert;
 import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO;
-import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpAutoReplyDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpMessageDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.user.MpUserDO;
+import cn.iocoder.yudao.module.mp.dal.mysql.message.MpMessageMapper;
 import cn.iocoder.yudao.module.mp.enums.message.MpMessageSendFromEnum;
 import cn.iocoder.yudao.module.mp.framework.mp.core.MpServiceFactory;
+import cn.iocoder.yudao.module.mp.framework.mp.core.util.MpUtils;
 import cn.iocoder.yudao.module.mp.service.account.MpAccountService;
+import cn.iocoder.yudao.module.mp.service.message.bo.MpMessageSendOutReqBO;
 import cn.iocoder.yudao.module.mp.service.user.MpUserService;
-import lombok.SneakyThrows;
 import lombok.extern.slf4j.Slf4j;
 import me.chanjar.weixin.common.error.WxErrorException;
 import me.chanjar.weixin.mp.api.WxMpService;
@@ -24,20 +25,12 @@ import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
-
-import javax.annotation.Resource;
-
 import org.springframework.validation.annotation.Validated;
 
-import cn.iocoder.yudao.module.mp.controller.admin.message.vo.*;
-import cn.iocoder.yudao.framework.common.pojo.PageResult;
-
-import cn.iocoder.yudao.module.mp.dal.mysql.message.MpMessageMapper;
-
+import javax.annotation.Resource;
+import javax.validation.Validator;
 import java.io.File;
 
-import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
-
 /**
  * 粉丝消息表 Service 实现类
  *
@@ -64,13 +57,16 @@ public class MpMessageServiceImpl implements MpMessageService {
     @Resource
     private FileApi fileApi;
 
+    @Resource
+    private Validator validator;
+
     @Override
     public PageResult<MpMessageDO> getWxFansMsgPage(MpMessagePageReqVO pageReqVO) {
         return mpMessageMapper.selectPage(pageReqVO);
     }
 
     @Override
-    public void createFromUser(String appId, WxMpXmlMessage wxMessage) {
+    public void receiveMessage(String appId, WxMpXmlMessage wxMessage) {
         WxMpService mpService = mpServiceFactory.getRequiredMpService(appId);
         // 获得关联信息
         MpAccountDO account = mpAccountService.getAccountFromCache(appId);
@@ -88,24 +84,21 @@ public class MpMessageServiceImpl implements MpMessageService {
             message.setThumbMediaUrl(mediaDownload(mpService, message.getThumbMediaId()));
         }
         mpMessageMapper.insert(message);
-
-//        WxConsts.MenuButtonType.VIEW TODO 芋艿:待测试
-//        wxMessage.getEventKey()
-
-//        WxConsts.MenuButtonType.CLICK
-//          wxMessage.getEventKey()
     }
 
     @Override
-    public WxMpXmlOutMessage createFromAutoReply(String openid, MpAutoReplyDO reply) {
+    public WxMpXmlOutMessage sendOutMessage(MpMessageSendOutReqBO sendReqBO) {
+        // 校验消息格式
+        MpUtils.validateMessage(validator, sendReqBO.getType(), sendReqBO);
+
         // 获得关联信息
-        MpAccountDO account = mpAccountService.getAccountFromCache(reply.getAppId());
-        Assert.notNull(account, "公众号账号({}) 不存在", reply.getAppId());
-        MpUserDO user = mpUserService.getUser(reply.getAppId(), openid);
-        Assert.notNull(user, "公众号粉丝({}/{}) 不存在", reply.getAppId(), openid);
+        MpAccountDO account = mpAccountService.getAccountFromCache(sendReqBO.getAppId());
+        Assert.notNull(account, "公众号账号({}) 不存在", sendReqBO.getAppId());
+        MpUserDO user = mpUserService.getUser(sendReqBO.getAppId(), sendReqBO.getOpenid());
+        Assert.notNull(user, "公众号粉丝({}/{}) 不存在", sendReqBO.getAppId(), sendReqBO.getOpenid());
 
         // 记录消息
-        MpMessageDO message = MpMessageConvert.INSTANCE.convert(reply, account, user);
+        MpMessageDO message = MpMessageConvert.INSTANCE.convert(sendReqBO, account, user);
         message.setSendFrom(MpMessageSendFromEnum.MP_TO_USER.getFrom());
         mpMessageMapper.insert(message);
 
diff --git a/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/bo/MpMessageSendOutReqBO.java b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/bo/MpMessageSendOutReqBO.java
new file mode 100644
index 000000000..3c5eeb97e
--- /dev/null
+++ b/yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/message/bo/MpMessageSendOutReqBO.java
@@ -0,0 +1,90 @@
+package cn.iocoder.yudao.module.mp.service.message.bo;
+
+import cn.iocoder.yudao.module.mp.dal.dataobject.message.MpMessageDO;
+import cn.iocoder.yudao.module.mp.framework.mp.core.util.MpUtils;
+import cn.iocoder.yudao.module.mp.framework.mp.core.util.MpUtils.*;
+import lombok.Data;
+import me.chanjar.weixin.common.api.WxConsts;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+
+/**
+ * 公众号消息发送 Request BO
+ *
+ * 为什么要有该 BO 呢?在自动回复、客服消息、菜单回复消息等场景,都涉及到 MP 给用户发送消息,所以使用该 BO 统一承接
+ *
+ * @author 芋道源码
+ */
+@Data
+public class MpMessageSendOutReqBO {
+
+    /**
+     * 公众号 appId
+     */
+    @NotEmpty(message = "公众号 appId 不能为空")
+    private String appId;
+    /**
+     * 公众号用户 openid
+     */
+    @NotEmpty(message = "公众号用户 openid 不能为空")
+    private String openid;
+
+    // ========== 消息内容 ==========
+    /**
+     * 消息类型
+     *
+     * 枚举 {@link WxConsts.XmlMsgType} 中的 TEXT、IMAGE、VOICE、VIDEO、NEWS
+     */
+    @NotEmpty(message = "消息类型不能为空")
+    public String type;
+
+    /**
+     * 消息内容
+     *
+     * 消息类型为 {@link WxConsts.XmlMsgType} 的 TEXT
+     */
+    @NotEmpty(message = "消息内容不能为空", groups = TextGroup.class)
+    private String content;
+
+    /**
+     * 媒体 id
+     *
+     * 消息类型为 {@link WxConsts.XmlMsgType} 的 IMAGE、VOICE、VIDEO
+     */
+    @NotEmpty(message = "消息内容不能为空", groups = {ImageGroup.class, VoiceGroup.class, VideoGroup.class})
+    private String mediaId;
+    /**
+     * 媒体 URL
+     *
+     * 消息类型为 {@link WxConsts.XmlMsgType} 的 IMAGE、VOICE、VIDEO
+     */
+    @NotEmpty(message = "消息内容不能为空", groups = {ImageGroup.class, VoiceGroup.class, VideoGroup.class})
+    private String mediaUrl;
+
+    /**
+     * 标题
+     *
+     * 消息类型为 {@link WxConsts.XmlMsgType} 的 VIDEO
+     */
+    @NotEmpty(message = "消息内容不能为空", groups = VideoGroup.class)
+    private String title;
+    /**
+     * 描述
+     *
+     * 消息类型为 {@link WxConsts.XmlMsgType} 的 VIDEO
+     */
+    @NotEmpty(message = "消息内容不能为空", groups = VideoGroup.class)
+    private String description;
+
+    /**
+     * 图文消息
+     *
+     * 消息类型为 {@link WxConsts.XmlMsgType} 的 NEWS
+     */
+    @Valid
+    @NotNull(message = "图文消息不能为空", groups = NewsGroup.class)
+    private MpMessageDO.Article article;
+
+}