├── wx-gateway.png ├── Makefile ├── go.mod ├── handlers ├── json-call.go ├── channels-ec-handler.go └── wx-msg-handler.go ├── make.inc ├── common-endpoints ├── get-user-info.go ├── short-url.go ├── channels-ec-order-detail.go ├── channels-ec-refund-detail.go ├── sign-jsapi.go ├── sns-api.go ├── create-qr.go └── send-tmpl-msg.go ├── main.go ├── LICENSE ├── sample.conf.json ├── router.go ├── conf └── conf.go ├── go.sum └── README.md /wx-gateway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rosbit/go-wx-gateway/HEAD/wx-gateway.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | 3 | EXE = wx-gateway 4 | 5 | all: 6 | @echo "building $(EXE) ..." 7 | @$(MAKE) -s -f make.inc s=static 8 | 9 | clean: 10 | rm -f $(EXE) 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module wx-gateway 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/rosbit/gnet v0.2.0 7 | github.com/rosbit/go-wx-api/v2 v2.2.0 8 | github.com/rosbit/mgin v0.1.3 9 | gopkg.in/yaml.v2 v2.2.8 // indirect; indrect 10 | ) 11 | -------------------------------------------------------------------------------- /handlers/json-call.go: -------------------------------------------------------------------------------- 1 | package gwhandlers 2 | 3 | import ( 4 | "github.com/rosbit/gnet" 5 | "os" 6 | ) 7 | 8 | type Res struct { 9 | Type string `json:"type"` 10 | Msg string `json:"msg"` 11 | Title string `json:"title"` 12 | Desc string `json:"desc"` 13 | } 14 | 15 | func JsonCall(url string, method string, postData interface{}, res *Res) (err error) { 16 | _, err = gnet.JSONCallJ(url, &res, gnet.M(method), gnet.Params(postData), gnet.BodyLogger(os.Stderr)) 17 | return 18 | } 19 | -------------------------------------------------------------------------------- /make.inc: -------------------------------------------------------------------------------- 1 | build: 2 | @if [ "$o" == "macos" ]; then \ 3 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'main.buildTime=`TZ=UTC-8 date '+%F %T'`' -X 'main.osInfo=`uname -sr`' -X 'main.goInfo=`go version`' -extldflags -static"; \ 4 | elif [ "$s" == "static" ]; then \ 5 | go build -ldflags "-X 'main.buildTime=`TZ=UTC-8 date '+%F %T'`' -X 'main.osInfo=`uname -sr`' -X 'main.goInfo=`go version`' -linkmode external -extldflags -static" -tags timetzdata; \ 6 | else \ 7 | go build -ldflags "-X 'main.buildTime=`TZ=UTC-8 date '+%F %T'`' -X 'main.osInfo=`uname -sr`' -X 'main.goInfo=`go version`'"; \ 8 | fi 9 | -------------------------------------------------------------------------------- /common-endpoints/get-user-info.go: -------------------------------------------------------------------------------- 1 | package ce 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/tools" 5 | "github.com/rosbit/mgin" 6 | "net/http" 7 | ) 8 | 9 | // GET ${commonEndpoints.WxUser}?s=&o= 10 | func GetWxUserInfo(c *mgin.Context) { 11 | var params struct { 12 | Service string `query:"s"` 13 | OpenId string `query:"o"` 14 | } 15 | if code, err := c.ReadParams(¶ms); err != nil { 16 | c.Error(code, err.Error()) 17 | return 18 | } 19 | 20 | userInfo, err := wxtools.GetUserInfo(params.Service, params.OpenId) 21 | if err != nil { 22 | c.Error(http.StatusInternalServerError, err.Error()) 23 | return 24 | } 25 | c.JSON(http.StatusOK, map[string]interface{}{ 26 | "code": http.StatusOK, 27 | "msg": "OK", 28 | "userInfo": userInfo, 29 | }) 30 | } 31 | 32 | -------------------------------------------------------------------------------- /common-endpoints/short-url.go: -------------------------------------------------------------------------------- 1 | package ce 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/tools" 5 | "github.com/rosbit/mgin" 6 | "net/http" 7 | ) 8 | 9 | // POST ${commonEndpoints.ShortUrl} 10 | // s=&u= 11 | func CreateShorturl(c *mgin.Context) { 12 | var params struct { 13 | Service string `form:"s"` 14 | LongUrl string `form:"u"` 15 | } 16 | if code, err := c.ReadParams(¶ms); err != nil { 17 | c.Error(code, err.Error()) 18 | return 19 | } 20 | 21 | shortUrl, err := wxtools.MakeShorturl(params.Service, params.LongUrl) 22 | if err != nil { 23 | c.Error(http.StatusInternalServerError, err.Error()) 24 | return 25 | } 26 | c.JSON(http.StatusOK, map[string]interface{}{ 27 | "code": http.StatusOK, 28 | "msg": "OK", 29 | "short-url": shortUrl, 30 | }) 31 | } 32 | 33 | -------------------------------------------------------------------------------- /common-endpoints/channels-ec-order-detail.go: -------------------------------------------------------------------------------- 1 | package ce 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/channels-ec-order" 5 | "github.com/rosbit/mgin" 6 | "net/http" 7 | ) 8 | 9 | // GET ${commonEndpoints.ChannelsEcOrderDetail}?s=&o= 10 | func ChannelsEcOrderDetail(c *mgin.Context) { 11 | var params struct { 12 | Service string `query:"s"` 13 | OrderId string `query:"o"` 14 | } 15 | if code, err := c.ReadParams(¶ms); err != nil { 16 | c.Error(code, err.Error()) 17 | return 18 | } 19 | 20 | ord, err := ceord.GetOrderDetail(params.Service, params.OrderId) 21 | if err != nil { 22 | c.Error(http.StatusInternalServerError, err.Error()) 23 | return 24 | } 25 | c.JSON(http.StatusOK, map[string]interface{}{ 26 | "code": http.StatusOK, 27 | "msg": "OK", 28 | "order": ord, 29 | }) 30 | } 31 | 32 | -------------------------------------------------------------------------------- /common-endpoints/channels-ec-refund-detail.go: -------------------------------------------------------------------------------- 1 | package ce 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/channels-ec-order" 5 | "github.com/rosbit/mgin" 6 | "net/http" 7 | ) 8 | 9 | // GET ${commonEndpoints.ChannelsEcRefundDetail}?s=&o= 10 | func ChannelsEcRefundDetail(c *mgin.Context) { 11 | var params struct { 12 | Service string `query:"s"` 13 | AftersaleOrderId string `query:"o"` 14 | } 15 | if code, err := c.ReadParams(¶ms); err != nil { 16 | c.Error(code, err.Error()) 17 | return 18 | } 19 | 20 | ord, err := ceord.GetRefundOrderDetail(params.Service, params.AftersaleOrderId) 21 | if err != nil { 22 | c.Error(http.StatusInternalServerError, err.Error()) 23 | return 24 | } 25 | c.JSON(http.StatusOK, map[string]interface{}{ 26 | "code": http.StatusOK, 27 | "msg": "OK", 28 | "aftersaleOrder": ord, 29 | }) 30 | } 31 | 32 | -------------------------------------------------------------------------------- /common-endpoints/sign-jsapi.go: -------------------------------------------------------------------------------- 1 | package ce 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/tools" 5 | "github.com/rosbit/mgin" 6 | "net/http" 7 | ) 8 | 9 | // POST ${commonEndpoints.SignJSAPI} 10 | // s=&u= 11 | func SignJSAPI(c *mgin.Context) { 12 | var params struct { 13 | Service string `form:"s"` 14 | Url string `form:"u"` 15 | } 16 | if code, err := c.ReadParams(¶ms); err != nil { 17 | c.Error(code, err.Error()) 18 | return 19 | } 20 | 21 | nonce, timestamp, signature, err := wxtools.SignJSAPI(params.Service, params.Url) 22 | if err != nil { 23 | c.Error(http.StatusInternalServerError, err.Error()) 24 | return 25 | } 26 | c.JSON(http.StatusOK, map[string]interface{}{ 27 | "code": http.StatusOK, 28 | "msg": "OK", 29 | "params": map[string]interface{}{ 30 | "nonce": nonce, 31 | "timestamp": timestamp, 32 | "signature": signature, 33 | }, 34 | }) 35 | } 36 | 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /** 2 | * main process 3 | * Usage: wx-gateway[ -v] 4 | * Rosbit Xu 5 | */ 6 | package main 7 | 8 | import ( 9 | "wx-gateway/conf" 10 | "os" 11 | "fmt" 12 | ) 13 | 14 | // variables set via go build -ldflags 15 | var ( 16 | buildTime string 17 | osInfo string 18 | goInfo string 19 | ) 20 | 21 | func main() { 22 | if len(os.Args) > 1 && os.Args[1] == "-v" { 23 | ShowInfo("name", os.Args[0]) 24 | ShowInfo("build time", buildTime) 25 | ShowInfo("os name", osInfo) 26 | ShowInfo("compiler", goInfo) 27 | return 28 | } 29 | 30 | if err := gwconf.CheckGlobalConf(); err != nil { 31 | fmt.Printf("Failed to check conf: %v\n", err) 32 | os.Exit(3) 33 | return 34 | } 35 | gwconf.DumpConf() 36 | 37 | if err := StartWxGateway(); err != nil { 38 | fmt.Printf("%v\n", err) 39 | os.Exit(4) 40 | } 41 | os.Exit(0) 42 | } 43 | 44 | func ShowInfo(prompt, info string) { 45 | if info != "" { 46 | fmt.Printf("%10s: %s\n", prompt, info) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Rosbit Xu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /sample.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "listen-host": "", 3 | "listen-port": 7080, 4 | "services": [ 5 | { 6 | "is-channels-ec": false, 7 | "name": "echo_server", 8 | "workerNum": 5, 9 | "timeout": 0, 10 | "wx-params": { 11 | "token": "your_token_at_weixin_admin", 12 | "app-id": "your_appId_at_weixin_admin", 13 | "app-secret": "your_appSecret_at_weixin_admin", 14 | "aes-key": "let_this_empty_or_null_if_plain_text_transfer" 15 | }, 16 | "listen-endpoints": { 17 | "service-path": "/wx", 18 | "redirect-path": "/redirect" 19 | }, 20 | "msg-proxy-pass": "http://yourhost.or.ip.here/empty_is_ok", 21 | "redirect-url": "http://yourhost.io.ip.here/path/to/redirect", 22 | "redirect-userinfo-flag": "login, register or any-strings else if you want use snsapi_userinfo" 23 | } 24 | ], 25 | "token-cache-dir": "/path/to/cache_dir", 26 | "common-endpoints": { 27 | "health-check": "/health", 28 | "wx-qr": "/qr", 29 | "wx-user": "/user", 30 | "sns-auth2": "/sns-auth2", 31 | "short-url": "/short-url", 32 | "tmpl-msg": "/tmpl-msg", 33 | "sign-jsapi": "/sign-jsapi", 34 | "channels-ec-order-detail": "/channles-ec-order-detail", 35 | "channels-ec-refund-detail": "/channels-ec-refund-detail" 36 | }, 37 | "dont-append-userinfo": false 38 | } 39 | -------------------------------------------------------------------------------- /common-endpoints/sns-api.go: -------------------------------------------------------------------------------- 1 | package ce 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/oauth2" 5 | "github.com/rosbit/go-wx-api/v2/auth" 6 | "github.com/rosbit/mgin" 7 | "net/http" 8 | ) 9 | 10 | // GET ${commonEndpoints.SnsAPI}?s=&code=&scope={userinfo|base} 11 | func SnsAPI(c *mgin.Context) { 12 | var params struct { 13 | Service string `query:"s"` 14 | Code string `query:"code"` 15 | Scope string `query:"scope" optional:"true"` 16 | } 17 | if code, err := c.ReadParams(¶ms); err != nil { 18 | c.Error(code, err.Error()) 19 | return 20 | } 21 | 22 | switch params.Scope { 23 | case "userinfo","base": 24 | case "", "snsapi_base": 25 | params.Scope = "base" 26 | case "snsapi_userinfo": 27 | params.Scope = "userinfo" 28 | default: 29 | c.Error(http.StatusBadRequest, `scope must be "useinfo", "base", "sns_userinfo" or "sns_base"`) 30 | return 31 | } 32 | 33 | wxUser := wxoauth2.NewWxUser(params.Service) 34 | openId, err := wxUser.GetOpenId(params.Code) 35 | if err != nil { 36 | c.Error(http.StatusInternalServerError, err.Error()) 37 | return 38 | } 39 | 40 | var userInfo *wxauth.WxUserInfo 41 | if params.Scope == "base" { 42 | userInfo, err = wxUser.GetInfoByAccessToken() 43 | } else { 44 | if err = wxUser.GetInfo(); err == nil { 45 | userInfo = wxUser.UserInfo 46 | } 47 | } 48 | 49 | c.JSON(http.StatusOK, map[string]interface{}{ 50 | "code": http.StatusOK, 51 | "msg": "OK", 52 | "openId": openId, 53 | "userInfo": userInfo, 54 | "error": func()string{if err == nil {return ""}; return err.Error()}(), 55 | }) 56 | } 57 | 58 | -------------------------------------------------------------------------------- /common-endpoints/create-qr.go: -------------------------------------------------------------------------------- 1 | package ce 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/tools" 5 | "github.com/rosbit/mgin" 6 | "net/http" 7 | ) 8 | 9 | // GET ${commonEndpoints.WxQr}?s=&t=[&sceneid=xx][&e=] 10 | func CreateWxQr(c *mgin.Context) { 11 | var params struct { 12 | Service string `query:"s"` 13 | QrType string `query:"t"` 14 | SceneId string `query:"sceneid" optional:"true"` 15 | ExpireSecs int `query:"e" optional:"true"` 16 | } 17 | if code, err := c.ReadParams(¶ms); err != nil { 18 | c.Error(code, err.Error()) 19 | return 20 | } 21 | 22 | switch params.QrType { 23 | case "temp", "forever": 24 | default: 25 | c.Error(http.StatusBadRequest, `t(ype) value must be "temp" or "forever"`) 26 | return 27 | } 28 | 29 | if params.SceneId == "" { 30 | params.SceneId = "0" 31 | } 32 | 33 | var ticketURL2ShowQrCode, urlIncluedInQrcode string 34 | var err error 35 | switch params.QrType { 36 | case "temp": 37 | expireSecs := 30 38 | if params.ExpireSecs > 0 { 39 | expireSecs = params.ExpireSecs 40 | } 41 | ticketURL2ShowQrCode, urlIncluedInQrcode, err = wxtools.CreateTempQrStrScene(params.Service, params.SceneId, expireSecs) 42 | case "forever": 43 | ticketURL2ShowQrCode, urlIncluedInQrcode, err = wxtools.CreateQrStrScene(params.Service, params.SceneId) 44 | } 45 | 46 | if err != nil { 47 | c.Error(http.StatusInternalServerError, err.Error()) 48 | return 49 | } 50 | 51 | c.JSON(http.StatusOK, map[string]interface{}{ 52 | "code": http.StatusOK, 53 | "msg": "OK", 54 | "result": map[string]string { 55 | "ticketURL2ShowQrCode": ticketURL2ShowQrCode, 56 | "urlIncluedInQrcode": urlIncluedInQrcode, 57 | }, 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /common-endpoints/send-tmpl-msg.go: -------------------------------------------------------------------------------- 1 | package ce 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/tools" 5 | "github.com/rosbit/mgin" 6 | "net/http" 7 | ) 8 | 9 | // POST ${commonEndpoints.TmplMsg} 10 | // { 11 | // "s": "service-name-in-conf", 12 | // "to": "to-user-id", 13 | // "tid": "template-id", 14 | // "url": "optional url to jump", 15 | // "mp": { 16 | // "appid": "mini program appid", 17 | // "pagepath": "pagepath", 18 | // }, 19 | // "data": { 20 | // "k1": "v1", 21 | // "k2": "v2", 22 | // "....": "..." 23 | // } 24 | // } 25 | func SendTmplMsg(c *mgin.Context) { 26 | var params struct { 27 | Service string `json:"s"` 28 | ToUserId string `json:"to"` 29 | TmplId string `json:"tid"` 30 | Url string `json:"url"` 31 | MiniProg struct { 32 | AppId string `json:"appid"` 33 | PagePath string `json:"pagepath"` 34 | } `json:"mp"` 35 | Data map[string]interface{} `json:"data"` 36 | } 37 | if status, err := c.ReadJSON(¶ms); err != nil { 38 | c.Error(status, err.Error()) 39 | return 40 | } 41 | if params.Service == "" { 42 | c.Error(http.StatusBadRequest, "s(ervice) parameter expected") 43 | return 44 | } 45 | 46 | if params.ToUserId == "" { 47 | c.Error(http.StatusBadRequest, "to(user id) parameter expected") 48 | return 49 | } 50 | if params.TmplId == "" { 51 | c.Error(http.StatusBadRequest, "tid(template id) parameter expected") 52 | return 53 | } 54 | if len(params.Data) == 0 { 55 | c.Error(http.StatusBadRequest, "data parameter as a map expected") 56 | return 57 | } 58 | 59 | res, err := wxtools.SendTemplateMessage(params.Service, params.ToUserId, params.TmplId, params.Data, params.Url, params.MiniProg.AppId, params.MiniProg.PagePath) 60 | if err != nil { 61 | c.Error(http.StatusInternalServerError, err.Error()) 62 | return 63 | } 64 | c.JSON(http.StatusOK, res) 65 | } 66 | 67 | -------------------------------------------------------------------------------- /handlers/channels-ec-handler.go: -------------------------------------------------------------------------------- 1 | package gwhandlers 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/msg" 5 | "fmt" 6 | ) 7 | 8 | type ChannelsEcEventhandler struct { 9 | service string 10 | proxyPass string 11 | } 12 | 13 | func NewChannelsEcHandler(service, proxyPass string) *ChannelsEcEventhandler { 14 | return &ChannelsEcEventhandler{service:service, proxyPass:proxyPass} 15 | } 16 | 17 | func (h *ChannelsEcEventhandler) jsonCall(fromUser, toUser, url string, receivedMsg wxmsg.ReceivedJSONEvent) []byte { 18 | var res Res 19 | if err := JsonCall(url, "POST", receivedMsg, &res); err != nil { 20 | fmt.Printf("failed to JsonCall(%s): %v\n", url, err) 21 | return nil 22 | } 23 | msg := res.Msg 24 | return []byte(msg) 25 | } 26 | 27 | func (h *ChannelsEcEventhandler) jsonCallEvent(fromUser, toUser, eventType string, receivedMsg wxmsg.ReceivedJSONEvent) []byte { 28 | url := fmt.Sprintf("%s/event/%s", h.proxyPass, eventType) 29 | return h.jsonCall(fromUser, toUser, url, receivedMsg) 30 | } 31 | 32 | func (h *ChannelsEcEventhandler) HandleOrderCancelEvent(event *wxmsg.OrderCancelEvent) []byte { 33 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 34 | } 35 | 36 | func (h *ChannelsEcEventhandler) HandleOrderPayEvent(event *wxmsg.OrderPayEvent) []byte { 37 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 38 | } 39 | 40 | func (h *ChannelsEcEventhandler) HandleOrderConfirmEvent(event *wxmsg.OrderConfirmEvent) []byte { 41 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 42 | } 43 | 44 | func (h *ChannelsEcEventhandler) HandleOrderSettleEvent(event *wxmsg.OrderSettleEvent) []byte { 45 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 46 | } 47 | 48 | func (h *ChannelsEcEventhandler) HandleAftersaleUpdateEvent (event *wxmsg.AftersaleUpdateEvent) []byte { 49 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 50 | } 51 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | /** 2 | * REST API router 3 | * Rosbit Xu 4 | */ 5 | package main 6 | 7 | import ( 8 | "github.com/rosbit/go-wx-api/v2/msg" 9 | "github.com/rosbit/go-wx-api/v2/log" 10 | "github.com/rosbit/go-wx-api/v2" 11 | "github.com/rosbit/mgin" 12 | "wx-gateway/common-endpoints" 13 | "wx-gateway/handlers" 14 | "wx-gateway/conf" 15 | "fmt" 16 | "os" 17 | "net/http" 18 | ) 19 | 20 | func StartWxGateway() error { 21 | handlers := []mgin.Handler{mgin.WithLogger("wx-gateway")} 22 | 23 | serviceConf := gwconf.ServiceConf 24 | if len(serviceConf.TokenCacheDir) > 0 { 25 | wxapi.InitWx(serviceConf.TokenCacheDir) 26 | } 27 | wxlog.SetLogger(os.Stderr) 28 | 29 | for _, service := range serviceConf.Services { 30 | paramConf := &service.WxParams 31 | if err := wxapi.SetWxParams(service.Name, paramConf.Token, paramConf.AppId, paramConf.AppSecret, paramConf.AesKey, service.IsChannelsEc); err != nil { 32 | return err 33 | } 34 | 35 | endpoints := &service.Endpoints 36 | // add uri signature checker as Handler 37 | signatureChecker := wxapi.NewWxSignatureChecker(paramConf.Token, service.Timeout, []string{endpoints.ServicePath}) 38 | handlers = append(handlers, mgin.WrapMiddleFunc(signatureChecker)) 39 | } 40 | api := mgin.NewMgin(handlers...) 41 | 42 | for _, service := range serviceConf.Services { 43 | paramConf := &service.WxParams 44 | endpoints := &service.Endpoints 45 | 46 | // set echo handler 47 | api.Get(endpoints.ServicePath, wxapi.CreateEcho(paramConf.Token)) 48 | 49 | if !service.IsChannelsEc { 50 | // set msg handlers 51 | var msgHandler wxmsg.WxMsgHandler 52 | if len(service.MsgProxyPass) > 0 { 53 | msgHandler = gwhandlers.NewMsgHandler(service.Name, service.MsgProxyPass, serviceConf.DontAppendUserInfo) 54 | } else { 55 | msgHandler = wxmsg.MsgHandler 56 | } 57 | api.Post(endpoints.ServicePath, wxapi.CreateMsgHandler(service.Name, service.WorkerNum, msgHandler)) 58 | } else { 59 | // set channel ec handlers 60 | var channelsEcHandler wxmsg.ChannelsEcEventHandler 61 | if len(service.MsgProxyPass) > 0 { 62 | channelsEcHandler = gwhandlers.NewChannelsEcHandler(service.Name, service.MsgProxyPass) 63 | } else { 64 | channelsEcHandler = wxmsg.CEEventHandler 65 | } 66 | api.Post(endpoints.ServicePath, wxapi.CreateChannelsEcHandler(service.Name, service.WorkerNum, channelsEcHandler)) 67 | } 68 | 69 | // set oauth2 rediretor 70 | if len(service.RedirectURL) > 0 { 71 | if len(endpoints.RedirectPath) == 0 { 72 | return fmt.Errorf("listen-endpoints/redirect-path in servie %s must be specfied if you want to use redirect-url", service.Name) 73 | } 74 | api.Get(endpoints.RedirectPath, wxapi.CreateOAuth2Redirector(service.Name, service.WorkerNum, service.RedirectURL, service.RedirectUserInfoFlag)) 75 | } 76 | } 77 | 78 | commonEndpoints := &serviceConf.CommonEndpoints 79 | if len(commonEndpoints.HealthCheck) > 0 { 80 | api.GET(commonEndpoints.HealthCheck, func(c *mgin.Context) { 81 | c.String(http.StatusOK, "OK\n") 82 | }) 83 | } 84 | if len(commonEndpoints.WxQr) > 0 { 85 | api.GET(commonEndpoints.WxQr, ce.CreateWxQr) 86 | } 87 | if len(commonEndpoints.WxUser) > 0 { 88 | api.GET(commonEndpoints.WxUser, ce.GetWxUserInfo) 89 | } 90 | if len(commonEndpoints.SnsAPI) > 0 { 91 | api.GET(commonEndpoints.SnsAPI, ce.SnsAPI) 92 | } 93 | if len(commonEndpoints.ShortUrl) > 0 { 94 | api.POST(commonEndpoints.ShortUrl, ce.CreateShorturl) 95 | } 96 | if len(commonEndpoints.TmplMsg) > 0 { 97 | api.POST(commonEndpoints.TmplMsg, ce.SendTmplMsg) 98 | } 99 | if len(commonEndpoints.SignJSAPI) > 0 { 100 | api.POST(commonEndpoints.SignJSAPI, ce.SignJSAPI) 101 | } 102 | 103 | if len(commonEndpoints.ChannelsEcOrderDetail) > 0 { 104 | api.GET(commonEndpoints.ChannelsEcOrderDetail, ce.ChannelsEcOrderDetail) 105 | } 106 | if len(commonEndpoints.ChannelsEcRefundDetail) > 0 { 107 | api.GET(commonEndpoints.ChannelsEcRefundDetail, ce.ChannelsEcRefundDetail) 108 | } 109 | 110 | listenParam := fmt.Sprintf("%s:%d", serviceConf.ListenHost, serviceConf.ListenPort) 111 | fmt.Printf("%v\n", http.ListenAndServe(listenParam, api)) 112 | return nil 113 | } 114 | 115 | -------------------------------------------------------------------------------- /conf/conf.go: -------------------------------------------------------------------------------- 1 | /** 2 | * global conf 3 | * ENV: 4 | * CONF_FILE --- 配置文件名 5 | * TZ --- 时区名称"Asia/Shanghai" 6 | * 7 | * JSON: 8 | * { 9 | "listen-host": "", 10 | "listen-port": 7080, 11 | "services": [ 12 | { 13 | "is-channels-ec": false, 14 | "name": "echo_server", 15 | "workerNum": 5, 16 | "timeout": 0, 17 | "wx-params": { 18 | "token": "hello_rosbit", 19 | "app-id": "", 20 | "app-secret": "", 21 | "aes-key": null 22 | }, 23 | "listen-endpoints": { 24 | "service-path": "/wx", 25 | "redirect-path": "/redirect" 26 | }, 27 | "msg-proxy-pass": "http://yourhost.or.ip.here", 28 | "redirect-url": "http://yourhost.or.ip/path/to/redirect", 29 | "redirect-userinfo-flag": "login, register or any-strings else if you want use snsapi_userinfo", 30 | } 31 | ], 32 | "token-cache-dir": "/root/dir/to/save/token", 33 | "common-endpoints": { 34 | "health-check": "/health", 35 | "wx-qr": "/qr", 36 | "wx-user": "/userinfo", 37 | "sns-auth2": "/sns-auth2", 38 | "short-url": "/short-url", 39 | "tmpl-msg": "/tmpl-msg", 40 | "sign-jsapi": "/sign-jsapi", 41 | "channels-ec-order-detail": "/channles-ec-order-detail", 42 | "channels-ec-refund-detail": "/channels-ec-refund-detail" 43 | }, 44 | "dont-append-userinfo": true 45 | } 46 | * 47 | * Rosbit Xu 48 | */ 49 | package gwconf 50 | 51 | import ( 52 | "fmt" 53 | "os" 54 | "time" 55 | "encoding/json" 56 | ) 57 | 58 | type WxParamsConf struct { 59 | Token string `json:"token"` 60 | AppId string `json:"app-id"` 61 | AppSecret string `json:"app-secret"` 62 | AesKey string `json:"aes-key"` 63 | } 64 | 65 | type WxServiceConf struct { 66 | ListenHost string `json:"listen-host"` 67 | ListenPort int `json:"listen-port"` 68 | Services []struct { 69 | IsChannelsEc bool `json:"is-channels-ec"` 70 | Name string `json:"name"` 71 | WorkerNum int `json:"workerNum"` 72 | Timeout int `json:"timeout"` 73 | WxParams WxParamsConf `json:"wx-params"` 74 | Endpoints struct { 75 | ServicePath string `json:"service-path"` 76 | RedirectPath string `json:"redirect-path"` 77 | } `json:"listen-endpoints"` 78 | MsgProxyPass string `json:"msg-proxy-pass"` 79 | RedirectURL string `json:"redirect-url"` 80 | RedirectUserInfoFlag string `json:"redirect-userinfo-flag"` 81 | } `json:"services"` 82 | TokenCacheDir string `json:"token-cache-dir"` 83 | CommonEndpoints struct { 84 | HealthCheck string `json:"health-check"` 85 | WxQr string `json:"wx-qr"` 86 | WxUser string `json:"wx-user"` 87 | SnsAPI string `json:"sns-auth2"` 88 | ShortUrl string `json:"short-url"` 89 | TmplMsg string `json:"tmpl-msg"` 90 | SignJSAPI string `json:"sign-jsapi"` 91 | ChannelsEcOrderDetail string `json:"channels-ec-order-detail"` 92 | ChannelsEcRefundDetail string `json:"channels-ec-refund-detail"` 93 | } `json:"common-endpoints"` 94 | DontAppendUserInfo bool `json:"dont-append-userinfo"` 95 | } 96 | 97 | var ( 98 | ServiceConf WxServiceConf 99 | Loc = time.FixedZone("UTC+8", 8*60*60) 100 | ) 101 | 102 | func getEnv(name string, result *string, must bool) error { 103 | s := os.Getenv(name) 104 | if s == "" { 105 | if must { 106 | return fmt.Errorf("env \"%s\" not set", name) 107 | } 108 | } 109 | *result = s 110 | return nil 111 | } 112 | 113 | func CheckGlobalConf() error { 114 | var p string 115 | getEnv("TZ", &p, false) 116 | if p != "" { 117 | if loc, err := time.LoadLocation(p); err == nil { 118 | Loc = loc 119 | } 120 | } 121 | 122 | var confFile string 123 | if err := getEnv("CONF_FILE", &confFile, true); err != nil { 124 | return err 125 | } 126 | 127 | fp, err := os.Open(confFile) 128 | if err != nil { 129 | return err 130 | } 131 | defer fp.Close() 132 | 133 | dec := json.NewDecoder(fp) 134 | if err := dec.Decode(&ServiceConf); err != nil { 135 | return err 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func DumpConf() { 142 | fmt.Printf("conf: %v\n", ServiceConf) 143 | fmt.Printf("TZ time location: %v\n", Loc) 144 | } 145 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= 2 | github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 6 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 7 | github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= 8 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 9 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 10 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 11 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 12 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 13 | github.com/go-playground/validator/v10 v10.5.0 h1:X9rflw/KmpACwT8zdrm1upefpvdy6ur8d1kWyq6sg3E= 14 | github.com/go-playground/validator/v10 v10.5.0/go.mod h1:xm76BBt941f7yWdGnI2DVPFFg1UK3YY04qifoXU3lOk= 15 | github.com/go-zoo/bone v1.3.0 h1:PY6sHq37FnQhj+4ZyqFIzJQHvrrGx0GEc3vTZZC/OsI= 16 | github.com/go-zoo/bone v1.3.0/go.mod h1:HI3Lhb7G3UQcAwEhOJ2WyNcsFtQX1WYHa0Hl4OBbhW8= 17 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 18 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 19 | github.com/mroth/weightedrand v0.4.1 h1:rHcbUBopmi/3x4nnrvwGJBhX9d0vk+KgoLUZeDP6YyI= 20 | github.com/mroth/weightedrand v0.4.1/go.mod h1:3p2SIcC8al1YMzGhAIoXD+r9olo/g/cdJgAD905gyNE= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/rosbit/gnet v0.2.0 h1:lu9TMoagncmf7YWqHlR4CCfmkCdlJDsShx1DN3ZSQx0= 24 | github.com/rosbit/gnet v0.2.0/go.mod h1:lnAsUvFsBjdpvA+9FU7xSm7xlf52yhdr8iMu+oUOKow= 25 | github.com/rosbit/go-wx-api/v2 v2.2.0 h1:WL8cB3hi1ng5zOeEuQq24MXUTM/SXRRSR3et39kyDwE= 26 | github.com/rosbit/go-wx-api/v2 v2.2.0/go.mod h1:EeoGpSFPMDv1I6lOMQRRbEJBP8ITVZSeXoqlfP+hx+c= 27 | github.com/rosbit/mgin v0.1.3 h1:Rf/f7XRMHopNxPrT/sZCJMNCi6iEDQv4hOaJnJvxvEo= 28 | github.com/rosbit/mgin v0.1.3/go.mod h1:UZEGsCHI4K/Ss1RTax5kTD6mOZbyxHgPJ7gEmxRCSYM= 29 | github.com/rosbit/reader-logger v0.1.1 h1:ARzVlezh7D49iyemHgiOnLDJnhNtS0bFkYCdhLCoo9g= 30 | github.com/rosbit/reader-logger v0.1.1/go.mod h1:oOiaR7g4igbkceD9HUTGViwhE1A8eI2xHnNyWiik+MM= 31 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 32 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 33 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 34 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 35 | github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= 36 | github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= 37 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 38 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= 39 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 40 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 41 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 42 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 43 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 45 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 46 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 47 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 51 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 52 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 53 | -------------------------------------------------------------------------------- /handlers/wx-msg-handler.go: -------------------------------------------------------------------------------- 1 | package gwhandlers 2 | 3 | import ( 4 | "github.com/rosbit/go-wx-api/v2/msg" 5 | "github.com/rosbit/go-wx-api/v2/auth" 6 | "encoding/json" 7 | "io/ioutil" 8 | "bytes" 9 | "fmt" 10 | ) 11 | 12 | type WxMsgHandler struct { 13 | service string 14 | proxyPass string 15 | dontAppendUserInfo bool 16 | } 17 | 18 | func NewMsgHandler(service, proxyPass string, dontAppendUserInfo bool) *WxMsgHandler { 19 | return &WxMsgHandler{service, proxyPass, dontAppendUserInfo} 20 | } 21 | 22 | func (h *WxMsgHandler) jsonCall(fromUser, toUser, url string, receivedMsg wxmsg.ReceivedMsg) wxmsg.ReplyMsg { 23 | var res Res 24 | var err error 25 | if h.dontAppendUserInfo { 26 | err = JsonCall(url, "POST", receivedMsg, &res) 27 | } else { 28 | var ui *wxauth.WxUserInfo 29 | ui, err = wxauth.GetUserInfo(h.service, fromUser) 30 | b := &bytes.Buffer{} // b: 31 | je := json.NewEncoder(b) 32 | je.Encode(receivedMsg) // b: {JSON}\n 33 | b.Truncate(b.Len()-2) // b: {JSON // "}\n" removed 34 | b.WriteString(`,"userInfo":`) // b: {JSON,"userInfo": 35 | je.Encode(ui) // b: {JSON,"userInfo":JSON\n 36 | b.WriteString(`,"userInfoError":`) // b: {JSON,"userInfo":JSON\n,"userInfoError": 37 | je.Encode(func()string{ 38 | if err == nil { 39 | return "" 40 | } 41 | return err.Error() 42 | }()) // b: {JSON,"userInfo":JSON\n,"userInfoError":"xxx"\n 43 | b.WriteByte('}') // b: {JSON,"userInfo":JSON\n,"userInfoError":"xxx"\n} 44 | err = JsonCall(url, "POST", ioutil.NopCloser(b), &res) 45 | } 46 | if err != nil { 47 | fmt.Printf("failed to JsonCall(%s): %v\n", url, err) 48 | return nil 49 | } 50 | typ := res.Type 51 | if len(typ) == 0 { 52 | typ = "text" 53 | } 54 | msg := res.Msg 55 | if len(msg) == 0 { 56 | fmt.Printf("no \"msg\" item in %#v\n", res) 57 | return nil 58 | } 59 | switch typ { 60 | case "text": 61 | return wxmsg.NewReplyTextMsg(fromUser, toUser, msg) 62 | case "image": 63 | return wxmsg.NewReplyImageMsg(fromUser, toUser, msg) 64 | case "voice": 65 | return wxmsg.NewReplyVoiceMsg(fromUser, toUser, msg) 66 | case "video": 67 | title := res.Title 68 | if len(title) == 0 { 69 | title = "[no title]" 70 | } 71 | desc := res.Desc 72 | if len(desc) == 0 { 73 | desc = "[no desc]" 74 | } 75 | return wxmsg.NewReplyVideoMsg(fromUser, toUser, msg, title, desc) 76 | case "success": 77 | return wxmsg.NewSuccessMsg() 78 | default: 79 | fmt.Printf("unknwon type %s", typ) 80 | return nil 81 | } 82 | } 83 | 84 | func (h *WxMsgHandler) jsonCallMsg(fromUser, toUser, msgType string, receivedMsg wxmsg.ReceivedMsg) wxmsg.ReplyMsg { 85 | url := fmt.Sprintf("%s/msg/%s", h.proxyPass, msgType) 86 | return h.jsonCall(fromUser, toUser, url, receivedMsg) 87 | } 88 | 89 | func (h *WxMsgHandler) jsonCallEvent(fromUser, toUser, eventType string, receivedMsg wxmsg.ReceivedMsg) wxmsg.ReplyMsg { 90 | url := fmt.Sprintf("%s/event/%s", h.proxyPass, eventType) 91 | return h.jsonCall(fromUser, toUser, url, receivedMsg) 92 | } 93 | 94 | func (h *WxMsgHandler) HandleTextMsg(msg *wxmsg.TextMsg) wxmsg.ReplyMsg { 95 | return h.jsonCallMsg(msg.FromUserName, msg.ToUserName, msg.MsgType, msg) 96 | } 97 | 98 | func (h *WxMsgHandler) HandleImageMsg(msg *wxmsg.ImageMsg) wxmsg.ReplyMsg { 99 | return h.jsonCallMsg(msg.FromUserName, msg.ToUserName, msg.MsgType, msg) 100 | } 101 | 102 | func (h *WxMsgHandler) HandleVoiceMsg(msg *wxmsg.VoiceMsg) wxmsg.ReplyMsg { 103 | return h.jsonCallMsg(msg.FromUserName, msg.ToUserName, msg.MsgType, msg) 104 | } 105 | 106 | func (h *WxMsgHandler) HandleVideoMsg(msg *wxmsg.VideoMsg) wxmsg.ReplyMsg { 107 | return h.jsonCallMsg(msg.FromUserName, msg.ToUserName, msg.MsgType, msg) 108 | } 109 | 110 | func (h *WxMsgHandler) HandleLocationMsg(msg *wxmsg.LocationMsg) wxmsg.ReplyMsg { 111 | return h.jsonCallMsg(msg.FromUserName, msg.ToUserName, msg.MsgType, msg) 112 | } 113 | 114 | func (h *WxMsgHandler) HandleLinkMsg(msg *wxmsg.LinkMsg) wxmsg.ReplyMsg { 115 | return h.jsonCallMsg(msg.FromUserName, msg.ToUserName, msg.MsgType, msg) 116 | } 117 | 118 | func (h *WxMsgHandler) HandleClickEvent(event *wxmsg.ClickEvent) wxmsg.ReplyMsg { 119 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.MsgType, event) 120 | } 121 | 122 | func (h *WxMsgHandler) HandleViewEvent(event *wxmsg.ViewEvent) wxmsg.ReplyMsg { 123 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 124 | } 125 | 126 | func (h *WxMsgHandler) HandleScancodePushEvent(event *wxmsg.ScancodeEvent) wxmsg.ReplyMsg { 127 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 128 | } 129 | 130 | func (h *WxMsgHandler) HandleScancodeWaitEvent(event *wxmsg.ScancodeEvent) wxmsg.ReplyMsg { 131 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 132 | } 133 | 134 | func (h *WxMsgHandler) HandleSubscribeEvent(event *wxmsg.SubscribeEvent) wxmsg.ReplyMsg { 135 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 136 | } 137 | 138 | func (h *WxMsgHandler) HandleUnsubscribeEvent(event *wxmsg.SubscribeEvent) wxmsg.ReplyMsg { 139 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 140 | } 141 | 142 | func (h *WxMsgHandler) HandleScanEvent(event *wxmsg.SubscribeEvent) wxmsg.ReplyMsg { 143 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 144 | } 145 | 146 | func (h *WxMsgHandler) HandleWhereEvent(event *wxmsg.WhereEvent) wxmsg.ReplyMsg { 147 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 148 | } 149 | 150 | func (h *WxMsgHandler) HandlePhotoEvent(event *wxmsg.PhotoEvent) wxmsg.ReplyMsg { 151 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 152 | } 153 | 154 | func (h *WxMsgHandler) HandleLocationEvent(event *wxmsg.LocationEvent) wxmsg.ReplyMsg { 155 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 156 | } 157 | 158 | func (h *WxMsgHandler) HandleMassSentEvent(event *wxmsg.MassSentEvent) wxmsg.ReplyMsg { 159 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 160 | } 161 | 162 | func (h *WxMsgHandler) HandleTemplateSentEvent(event *wxmsg.TemplateSentEvent) wxmsg.ReplyMsg { 163 | return h.jsonCallEvent(event.FromUserName, event.ToUserName, event.Event, event) 164 | } 165 | 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 通用微信公众号网关服务 2 | 3 | 1. `go-wx-gateway`是基于[go-wx-api](https://github.com/rosbit/go-wx-api)的实现的**微信公众号网关**服务。 4 | 1. 类比nginx是HTTP的反向代理程序,`go-wx-gateway`是**微信公众号平台API接口**的反向代理程序 5 | 1. `go-wx-gateway`不是简单地把微信公众号API相关的数据进行传递,而是把它发送和接收的包做了拆包、打包的封装, 6 | 使得`go-wx-gateway`反向代理后面的业务实现大大的简化了 7 | 1. `go-wx-gateway`运行后,通过http与后端业务服务通讯,任何支持http服务的语言都可以实现业务代码。 8 | 9 | ## 架构图 10 | 11 | ![架构图](wx-gateway.png) 12 | 13 | 1. 有了`go-wx-gateway`,微信公众号服务的开发就是普通的web服务开发 14 | 1. 微信公众号服务主要处理两类数据流: 15 | - 消息/事件处理: 包括文本输入、图片上传、二维码扫描等等 16 | - 微信网页授权: 任何微信网页授权的处理都可以指向`go-wx-gateway` 17 | - 比如公众号菜单可以指向一个网页授权URL,让`go-wx-gateway`统一接管,`go-wx-gateway`会把用户信息、菜单state值转发给菜单处理服务 18 | - 如果不想让`go-wx-gateway`处理网页授权,公众号也可以指向自己所需要的服务,然后把得到的code转给`go-wx-gateway`换取访问用户的 19 | openId获取完整的用户信息。该方式参考配置信息"sns-auth2"。 20 | 1. `go-wx-gateway`和业务代码间使用HTTP传输数据,数据的格式为JSON。 21 | 22 | ## 下载、编译方法 23 | 1. 前提:已经安装go 1.11.x及以上、git、make 24 | 2. 进入任一文件夹,执行命令 25 | 26 | ```bash 27 | $ git clone https://github.com/rosbit/go-wx-gateway 28 | $ cd go-wx-gateway 29 | $ make 30 | ``` 31 | 3. 编译成功,会得到`wx-gateway`的可执行程序。可以执行`./wx-gateway -v`显示程序信息。 32 | 4. Linux的二进制版本可以直接进入[releases](https://github.com/rosbit/go-wx-gateway/releases)下载 33 | 34 | ## 运行方法 35 | 1. 环境变量 36 | - CONF_FILE: 指明配置的路径,格式见下文 37 | 1. 配置文件格式 38 | - 是一个JSON 39 | - 例子,可以通过`sample.conf.json`进行修改: 40 | 41 | ```json 42 | { 43 | "listen-host": "", 44 | "listen-port": 7080, 45 | "services": [ 46 | { 47 | "name": "一个名字,随便取", 48 | "workerNum": 5, 49 | "timeout": 0, 50 | "wx-params": { 51 | "token": "开发/服务器配置中的Token", 52 | "app-id": "开发/公众号开发信息中的AppId", 53 | "app-secret": "开发/公众号开发信息中的AppSecret", 54 | "aes-key": "开发/服务器配置中的EncodingAESKey。如果是明文传输,则该串为空或null" 55 | }, 56 | "listen-endpoints": { 57 | "service-path": "/wx --开发/服务器配置/服务器地址中的路径部分", 58 | "redirect-path": "/redirect --这个是微信网页授权用到的,设置菜单时都用这个路径,可选" 59 | }, 60 | "msg-proxy-pass": "http://yourhost.or.ip.here --这个地址指向消息/事件处理的服务,如果不处理可以为空", 61 | "redirect-url": "http://yourhost.or.ip/path/to/redirect --完全转发http请求,响应将返回微信服务号,可选", 62 | "redirect-userinfo-flag": "如果通过 snsapi_userinfo 获取参数,在redrect-url中加上特殊字符串参数,用于区分,比如 login。如果为空,使用 snsapi_base 方式获取用户参数" 63 | }, 64 | { 65 | "name": "如果有其它的公众号服务,可以参考上面的信息配置", 66 | "注意": "listen-endpoints中的路径 不能 相同", 67 | "视频号小店支持": "如果是视频号小店,加上 \"is-channels-ec\": true 配置项,其中的 \"msg-proxy-pass\" 配置的是接收视频号小店推送的事件" 68 | } 69 | ], 70 | "token-cache-dir": "缓存access的根路径,这是可选的配置", 71 | "common-endpoints": { 72 | "health-check": "/health --这是可选的配置,用于http健康检查,该路由配置成内部可访问", 73 | "wx-qr": "/qr --这是可选的路由配置,可以配置成内部可访问的,用于生成微信二维码链接", 74 | "wx-qr的参数说明": "s=<服务名,对应services中的name>&t=temp|forever[&sceneid=<场景id>][&e=]", 75 | "wx-user": "/user --这是可选的路由配置,可以配置成内部可访问的,用于获取用户信息,参数:s=<服务名>&o=", 76 | "sns-auth2": "/sns-auth2 -- 这是可选的路由配置,如果网页授权由其它服务接收,可以通过网页授权参数code获取用户信息", 77 | "sns-auth2参数说明": "s=<服务名,对应services中的name>&code=<网页授权得到的code>&[scope=userinfo|base|snsapi_userinfo|snsn_api_base]", 78 | "short-url": "/short-url -- 这是可选的路由配置,用于把长url生成短链接。【注】微信已经停止该服务", 79 | "short-url参数说明": "访问方法POST, POST body: s=<服务名,对应services中的name>&u=", 80 | "tmpl-msg": "/tmpl-msg -- 这是可选的路由配置,用于发送模版消息", 81 | "tmpl-msg参数说明": "访问方法POST, POST body是JSON", 82 | "tmpl-msg body例子": {"s":"服务名,对应services中的name","to":"用户openid","tid":"模版id","url":"可选,跳转url","mp":{"说明":"可选的小程序参数","appid":"小程序appid","pagepath":"页面路径"}, "data":{"模版数据key":"数据","数据key2":"..."}}, 83 | "sign-jsapi": "/sign-jsapi -- 这是可选的路由配置,用于生产jsapi签名", 84 | "sign-jsapi参数说明": "访问方法POST, POST body: s=&u=,结果会返回noncestr, timestamp, signature等结果", 85 | "channels-ec-order-detail": "/channles-ec-order-detail -- 可选,用于获取视频号小店订单详情", 86 | "channels-ec-order-detail参数说明": "s=<服务名,对应services中的name>&o=<订单id>", 87 | "channels-ec-refund-detail": "/channels-ec-refund-detail -- 可选,用于获取视频号小店售后单详情", 88 | "channels-ec-refund-detail参数说明": "s=<服务名,对应services中的name>&o=<售后订单id>", 89 | }, 90 | "dont-append-userinfo": "true|false, 各种消息事件是否不增加用户信息,缺省是false,表示追加" 91 | } 92 | ``` 93 | 1. 运行wx-gateway 94 | - `$ CONF_FILE=./wx-gateway-conf.json ./wx-gateway` 95 | 96 | ## 与业务代码间的通讯 97 | 1. 如果没有配置`msg-proxy-pass`,则`wx-gateway`可以作为工具**启用**`开发/服务器配置`,并可以对关注公众号 98 | 的用户通过文本框实现回声应答,是公众号开发必备的调试工具。 99 | 2. 与`msg-proxy-pass`的通讯 100 | - `msg-porxy-pass`配置的是一个URL地址前缀,`wx-gateway`会把消息、事件类型加到后面合成完整的URL。比如: 101 | - `msg-proxy-pass`配置的是`http://wx.myhost.com/msghandler` 102 | - 当`wx-gateway`收到用户的文本消息时,则会把消息转发给`http://wx.myhost.com/msghandler/msg/text` 103 | - 当`wx-gateway`收到新用户关注事件时,则会把消息转发给`http://wx.myhost.com/msghandler/event/subscribe` 104 | - 上例中,`msg`、`event`是转发类型、`text`、`subscribe`是具体的消息或事件名称,所有名称都对应微信公众平台对应的消息 105 | - 所有HTTP请求都是`POST`,所有的请求/响应结果都是`JSON`,请求体的JSON是对微信公众号API消息的XML格式做了转换,具体字段仍然可以参考微信公众号API 106 | - `msg`消息名称有**text**、**image**、**voice**、**video**、**shortvideo**、**location**、**link** 107 | - `event`事件名称有**CLICK**、**VIEW**、**SCAN**、**subscribe**、**unsubscribe**、**location**、**LOCATION**、**pic_sysphoto**、**pic_photo_or_album**、**pic_weixin**、**scancode_waitmsg**、**scancode_push**、**MASSSENDJOBFINISH**、**TEMPLATESENDJOBFINISH** 108 | - `msg`消息请求/响应举例,名称`text` 109 | - url: <msg-proxy-pass>/msg/text 110 | - 请求消息体: 111 | 112 | ```json 113 | { 114 | "ToUserName": "公众号的id", 115 | "FromUserName": "发送消息的用户的openId", 116 | "CreateTime": 1556088649, 117 | "MsgType": "text", 118 | "Content": "用户发送的文本内容", 119 | "MsgId": "22277834746191186", 120 | "说明": "以上为必传信息,如果配置文件中dont-append-userinfo为false,则有下面的信息", 121 | "userInfo": { 122 | "openid": "", 123 | "nickname": "", 124 | "headimgurl": "", 125 | "city":"", "province":"", "country":"", 126 | "sex":1, 127 | "language":"", 128 | "等等": "各种信息" 129 | }, 130 | "userInfoError": "如果取userInfo发生错误,则有错误信息,否则该值为空" 131 | } 132 | ``` 133 | 134 | - 响应 135 | - 如果成功,一定是回复200消息,消息体格式 136 | 137 | ```json 138 | { 139 | "type": "text", 140 | "msg": "需要返回给用户的消息内容" 141 | } 142 | ``` 143 | - "type"可以是"voice"、"video"、"image"等,"msg"则是它们对应的"mediaId" 144 | - `event`消息请求/响应举例,名称`subscribe` 145 | - 请求消息体: 146 | 147 | ```json 148 | { 149 | "ToUserName": "公众号的id", 150 | "FromUserName": "关注公众号的用户的openId", 151 | "CreateTime": 1556088649, 152 | "MsgType": "event", 153 | "Event": "subscribe", 154 | "EventKey": "qrscene_场景ID(新关注) 或 场景ID(扫码)", 155 | "Ticket": "如果是二维码扫码,是二维码的票据", 156 | "说明": "以上为必传信息,如果配置文件中dont-append-userinfo为false,则有下面的信息", 157 | "userInfo": { 158 | "openid": "", 159 | "nickname": "", 160 | "headimgurl": "", 161 | "city":"", "province":"", "country":"", 162 | "sex":1, 163 | "language":"", 164 | "等等": "各种信息" 165 | }, 166 | "userInfoError": "如果取userInfo发生错误,则有错误信息,否则该值为空" 167 | } 168 | ``` 169 | 170 | - 响应 171 | - 与消息是一样的 172 | - 如果成功,一定是回复200消息,消息体格式 173 | 174 | ```json 175 | { 176 | "type": "text", 177 | "msg": "需要返回给用户的消息内容" 178 | } 179 | ``` 180 | - "type"可以是"voice"、"video"、"image"等,"msg"则是它们对应的"mediaId" 181 | - 视频号小店 `event`消息请求/响应举例,名称`channels_ec_order_pay` 182 | - 请求消息体: 183 | 184 | ```json 185 | { 186 | "ToUserName": "视频号小店的id", 187 | "FromUserName": "在视频号小店购物的用户的openId", 188 | "CreateTime": 1556088649, 189 | "MsgType": "event", 190 | "Event": "channels_ec_order_pay", 191 | "order_info": { 192 | "order_id": 3705115058471208928, // NOTE: 文档处是字符串,实际返回的整数 193 | "pay_time": 1658509200 194 | } 195 | } 196 | ``` 197 | 198 | - 响应 199 | - 随意 200 | 201 | 3. 与`redirect-url`的通讯 202 | - 处理网页授权请求。公众号的相关配置请参考微信文档。回调URL只需`wx-gateway`所在的域名 203 | - `redirect-url`配置的是一个URL,比如 204 | - `redirect-url`配置的是`http://wx.myhost.com/menu/path/to/redirect` 205 | - 当`wx-gateway`接收到网页请求时,则会把消息转发给上面的URL 206 | - HTTP请求的方法是`POST`,响应结果完全由转发处理服务决定,它的HTTP响应结果将反映到公众号浏览器 207 | - 为了让`wx-gateway`收到菜单点击事件,要按公众号网页授权的格式写URL: 208 | - `https://open.weixin.qq.com/connect/oauth2/authorize?appid=在这里填公众号的AppId&redirect_uri=http%3A//wx.myhost.com/这是redirect-path配置的值&response_type=code&scope=snsapi_base&state=这个值用于区分菜单项#wechat_redirect` 209 | - 只有配置正确`wx-gateway`才能收到菜单事件,并通过code获取到点击菜单的用户的openId,并转发给`redirect-url` 210 | - 请求`redirect-url`的请求消息格式 211 | 212 | ```json 213 | { 214 | "requestURI": "转发请求的URI,这是微信服务器访问gateway的URI,可以根据实际情况做进一步判断", 215 | "appId": "公众号的AppId,如果同时处理多个公众号,可以用来区分来源", 216 | "openId": "点击菜单的用户的openId", 217 | "state": "在菜单配置中的state的值,用于区分菜单项", 218 | "userInfo": { 219 | "subscribe": 1, 220 | "openid": "", 221 | "nickname": "", 222 | "sex": 1, 223 | "language": "", 224 | "province": "", 225 | "city": "", 226 | "country": "", 227 | "headimgurl": "", 228 | "subscribe_time": 1386160805, 229 | }, 230 | "userInfoError": "请求userInfo时的错误信息,如果为空表示没有错误" 231 | } 232 | ``` 233 | 234 | 所有的HTTP请求头、Cookie都会转发给`redirect-url`,它可以根据需求进行处理 235 | 236 | - 响应结果消息格式 237 | - 响应结果完全有`redirect-url`自主决定,包括 238 | - 设置响应头、设置Cookie 239 | - 或者跳转到另外的URL 240 | - 响应内容会直接输出到公众号浏览器 241 | --------------------------------------------------------------------------------