diff --git a/miniprogram/message/consts.go b/miniprogram/message/consts.go index a044078f4..c80976cc7 100644 --- a/miniprogram/message/consts.go +++ b/miniprogram/message/consts.go @@ -28,11 +28,25 @@ const ( DataTypeJSON = "json" ) +const ( + //EventSubscribePopup 用户操作订阅通知弹窗事件推送,用户在图文等场景内订阅通知的操作 + EventSubscribePopup EventType = "subscribe_msg_popup_event" + //EventSubscribeChange 用户管理订阅通知,用户在服务通知管理页面做通知管理时的操作 + EventSubscribeChange = "subscribe_msg_change_event" + //EventSubscribeSent 发送订阅通知,调用 bizsend 接口发送通知 + EventSubscribeSent = "subscribe_msg_sent_event" +) + +const ( + InfoTypeAcceptSubscribeMessage InfoType = "accept" + InfoTypeRejectSubscribeMessage = "reject" +) + // CommonToken 消息中通用的结构 type CommonToken struct { XMLName xml.Name `xml:"xml"` - ToUserName string `xml:"ToUserName"` - FromUserName string `xml:"FromUserName"` + ToUserName CDATA `xml:"ToUserName"` + FromUserName CDATA `xml:"FromUserName"` CreateTime int64 `xml:"CreateTime"` MsgType MsgType `xml:"MsgType"` } @@ -60,4 +74,36 @@ type MiniProgramMixMessage struct { // 进入会话事件 Event string `xml:"Event"` SessionFrom string `xml:"SessionFrom"` + + // 用户操作订阅通知弹窗消息回调 + SubscribeMsgPopupEvent struct { + List []SubscribeMessageList `xml:"List"` + } `xml:"SubscribeMsgPopupEvent"` + + // 用户管理订阅通知回调 + SubscribeMsgChangeEvent struct { + List []SubscribeMessageList `xml:"List"` + } `xml:"SubscribeMsgChangeEvent"` + + // 用户发送订阅通知回调 + SubscribeMsgSentEvent struct { + List []SubscribeMessageList `xml:"List"` + } `xml:"SubscribeMsgSentEvent"` +} + +// SubscribeMessageList 订阅消息事件列表 +type SubscribeMessageList struct { + TemplateId string `xml:"TemplateId"` + SubscribeStatusString string `xml:"SubscribeStatusString"` + PopupScene string `xml:"PopupScene"` } + +// EncryptedXMLMsg 安全模式下的消息体 +type EncryptedXMLMsg struct { + XMLName struct{} `xml:"xml" json:"-"` + ToUserName string `xml:"ToUserName" json:"toUserName"` + EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` +} + +// CDATA 使用这种类型,在序列化 xml 时文本会被解析器忽略 +type CDATA string diff --git a/miniprogram/message/reply.go b/miniprogram/message/reply.go new file mode 100644 index 000000000..04c21bc4f --- /dev/null +++ b/miniprogram/message/reply.go @@ -0,0 +1,15 @@ +package message + +import "errors" + +//ErrInvalidReply 无效的回复 +var ErrInvalidReply = errors.New("无效的回复信息") + +//ErrUnsupportedReply 不支持的回复类型 +var ErrUnsupportedReply = errors.New("不支持的回复消息") + +//Reply 消息回复 +type Reply struct { + MsgType MsgType + MsgData interface{} +} diff --git a/miniprogram/miniprogram.go b/miniprogram/miniprogram.go index f6adaee98..15d0ed288 100644 --- a/miniprogram/miniprogram.go +++ b/miniprogram/miniprogram.go @@ -15,6 +15,7 @@ import ( "github.com/silenceper/wechat/v2/miniprogram/order" "github.com/silenceper/wechat/v2/miniprogram/privacy" "github.com/silenceper/wechat/v2/miniprogram/qrcode" + "github.com/silenceper/wechat/v2/miniprogram/server" "github.com/silenceper/wechat/v2/miniprogram/riskcontrol" "github.com/silenceper/wechat/v2/miniprogram/security" "github.com/silenceper/wechat/v2/miniprogram/shortlink" @@ -24,6 +25,7 @@ import ( "github.com/silenceper/wechat/v2/miniprogram/urlscheme" "github.com/silenceper/wechat/v2/miniprogram/virtualpayment" "github.com/silenceper/wechat/v2/miniprogram/werun" + "net/http" ) // MiniProgram 微信小程序相关 API @@ -101,6 +103,14 @@ func (miniProgram *MiniProgram) GetWeRun() *werun.WeRun { return werun.NewWeRun(miniProgram.ctx) } +// GetServer 小程序微信回调处理,接收事件,回复消息管理 +func (miniProgram *MiniProgram) GetServer(req *http.Request, write http.ResponseWriter) *server.Server { + srv := server.NewServer(miniProgram.ctx) + srv.Request = req + srv.Write = write + return srv +} + // GetContentSecurity 内容安全接口 func (miniProgram *MiniProgram) GetContentSecurity() *content.Content { return content.NewContent(miniProgram.ctx) diff --git a/miniprogram/server/server.go b/miniprogram/server/server.go new file mode 100644 index 000000000..8fdc3073d --- /dev/null +++ b/miniprogram/server/server.go @@ -0,0 +1,199 @@ +package server + +import ( + "encoding/xml" + "errors" + "fmt" + "github.com/silenceper/wechat/v2/miniprogram/context" + "github.com/silenceper/wechat/v2/miniprogram/message" + "github.com/silenceper/wechat/v2/util" + "io/ioutil" + "net/http" + "reflect" + "runtime/debug" + "strconv" +) + +// Server struct +type Server struct { + *context.Context + Write http.ResponseWriter + Request *http.Request + skipValidate bool + openID string + + messageHandler func(mixMessage *message.MiniProgramMixMessage) *message.Reply + + RequestRawXMLMsg []byte + RequestMsg *message.MiniProgramMixMessage + + ResponseRawXMLMsg []byte + ResponseMsg interface{} + + isSafeMode bool + random []byte + nonce string + timestamp int64 +} + +func NewServer(context *context.Context) *Server { + srv := new(Server) + srv.Context = context + return srv +} + +func (srv *Server) Server() error { + if !srv.Validate() { + return fmt.Errorf("请求签名校验失败") + } + echoStr := srv.Query("echostr") + if echoStr != "" { + srv.SetResponseWrite(echoStr) + return nil + } + + response, err := srv.handleRequest() + if err != nil { + return err + } + + return srv.buildResponse(response) + +} + +// SkipValidate 设置跳过签名校验 +func (srv *Server) SkipValidate(skip bool) { + srv.skipValidate = skip +} + +// Validate 校验请求是否合法 +func (srv *Server) Validate() bool { + if srv.skipValidate { + return true + } + timestamp := srv.Query("timestamp") + nonce := srv.Query("nonce") + signature := srv.Query("signature") + return signature == util.Signature(srv.Token, timestamp, nonce) +} + +func (srv *Server) handleRequest() (reply *message.Reply, err error) { + //set isSafeMode + srv.isSafeMode = false + encryptType := srv.Query("encrypt_type") + if encryptType == "aes" { + srv.isSafeMode = true + } + //set openID + srv.openID = srv.Query("openid") + + var msg interface{} + msg, err = srv.getMessage() + if err != nil { + return + } + mixMessage, success := msg.(*message.MiniProgramMixMessage) + if !success { + err = errors.New("消息类型转换失败") + } + srv.RequestMsg = mixMessage + reply = srv.messageHandler(mixMessage) + return +} + +//GetOpenID return openID +func (srv *Server) GetOpenID() string { + return srv.openID +} + +func (srv *Server) getMessage() (interface{}, error) { + var rawXMLMsgBytes []byte + var err error + if srv.isSafeMode { + var encryptedXMLMsg message.EncryptedXMLMsg + if err := xml.NewDecoder(srv.Request.Body).Decode(&encryptedXMLMsg); err != nil { + return nil, fmt.Errorf("从body中解析xml失败,err=%v", err) + } + //验证消息签名 + timestamp := srv.Query("timestamp") + srv.timestamp, err = strconv.ParseInt(timestamp, 10, 32) + if err != nil { + return nil, err + } + nonce := srv.Query("nonce") + srv.nonce = nonce + msgSignature := srv.Query("msg_signature") + msgSignatureGen := util.Signature(srv.Token, timestamp, nonce, encryptedXMLMsg.EncryptedMsg) + if msgSignature != msgSignatureGen { + return nil, fmt.Errorf("消息不合法,验证签名失败") + } + + // 解密 + srv.random, rawXMLMsgBytes, err = util.DecryptMsg(srv.AppID, encryptedXMLMsg.EncryptedMsg, srv.EncodingAESKey) + if err != nil { + return nil, fmt.Errorf("消息解密失败, err=%v", err) + } + } else { + rawXMLMsgBytes, err = ioutil.ReadAll(srv.Request.Body) + if err != nil { + return nil, fmt.Errorf("从body中解析xml失败, err=%v", err) + } + } + srv.RequestRawXMLMsg = rawXMLMsgBytes + return srv.parseRequestMessage(rawXMLMsgBytes) +} + +func (srv *Server) parseRequestMessage(rawXMLMsgBytes []byte) (msg *message.MiniProgramMixMessage, err error) { + msg = &message.MiniProgramMixMessage{} + err = xml.Unmarshal(rawXMLMsgBytes, msg) + return +} + +func (srv *Server) SetMessageHandler(handler func(*message.MiniProgramMixMessage) *message.Reply) { + srv.messageHandler = handler +} + +func (srv *Server) buildResponse(reply *message.Reply) (err error) { + defer func() { + if e := recover(); e != nil { + err = fmt.Errorf("panic error: %v\n%s", e, debug.Stack()) + } + }() + if reply == nil { + return nil + } + msgType := reply.MsgType + switch msgType { + case message.MsgTypeEvent: + case message.MsgTypeImage: + case message.MsgTypeLink: + case message.MsgTypeText: + case message.MsgTypeMiniProgramPage: + default: + err = message.ErrUnsupportedReply + return + } + msgData := reply.MsgData + value := reflect.ValueOf(msgData) + //msgData must be a ptr + kind := value.Kind().String() + if kind != "ptr" { + return message.ErrUnsupportedReply + } + params := make([]reflect.Value, 1) + params[0] = reflect.ValueOf(srv.RequestMsg.FromUserName) + value.MethodByName("SetToUserName").Call(params) + + params[0] = reflect.ValueOf(srv.RequestMsg.ToUserName) + value.MethodByName("SetFromUserName").Call(params) + + params[0] = reflect.ValueOf(srv.RequestMsg.MsgType) + value.MethodByName("SetMsgType").Call(params) + + params[0] = reflect.ValueOf(util.GetCurrTS()) + value.MethodByName("SetCreateTime").Call(params) + + srv.ResponseMsg = msgData + srv.ResponseRawXMLMsg, err = xml.Marshal(msgData) + return +} diff --git a/miniprogram/server/util.go b/miniprogram/server/util.go new file mode 100644 index 000000000..d7de965af --- /dev/null +++ b/miniprogram/server/util.go @@ -0,0 +1,29 @@ +package server + +import "net/http" + +var textContentType = []string{"text/plain; charset=utf-8"} + +//Set http response Content-Type +func setContentType(w http.ResponseWriter, value []string) { + header := w.Header() + if val := header["Content-Type"]; len(val) == 0 { + header["Content-Type"] = value + } +} + +// Query 查询 URL query string +func (srv *Server) Query(key string) string { + req := srv.Request + return req.URL.Query().Get(key) +} + +// SetResponseWrite 设置回调返回值 +func (srv *Server) SetResponseWrite(str string) { + setContentType(srv.Write, textContentType) + srv.Write.WriteHeader(http.StatusOK) + _, err := srv.Write.Write([]byte(str)) + if err != nil { + panic(err) + } +}