├── go.mod ├── .travis.yml ├── util ├── number.go ├── string.go ├── crypto.go └── http.go ├── .gitignore ├── doc.go ├── LICENSE ├── usermsg.go ├── oauth.go ├── menu.go ├── example_test.go ├── media.go ├── corp_checkin.go ├── mp_template.go ├── send.go ├── corp_approval.go ├── corp_dept.go ├── mp_user.go ├── pay.go ├── accesstoken.go ├── context.go ├── corp_tag.go ├── README.md ├── corp_user.go ├── type.go └── server.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/esap/wechat 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.9 5 | 6 | sudo: required 7 | 8 | script: 9 | - go test -v ./... 10 | -------------------------------------------------------------------------------- /util/number.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // Min golang min int 4 | func Min(first int, args ...int) int { 5 | for _, v := range args { 6 | if first > v { 7 | first = v 8 | } 9 | } 10 | return first 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | .idea 6 | 7 | # Folders 8 | _obj 9 | _test 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | *.exe 24 | *.test 25 | *.prof 26 | *.out 27 | 28 | go.sum 29 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package wechat provide wechat-sdk for go 3 | 4 | 5行代码,开启微信API示例: 5 | 6 | package main 7 | 8 | import ( 9 | "net/http" 10 | 11 | "github.com/esap/wechat" // 微信SDK包 12 | ) 13 | 14 | func main() { 15 | wechat.Debug = true 16 | 17 | cfg := &wechat.WxConfig{ 18 | Token: "yourToken", 19 | AppId: "yourAppID", 20 | Secret: "yourSecret", 21 | EncodingAESKey: "yourEncodingAesKey", 22 | } 23 | 24 | app := wechat.New(cfg) 25 | 26 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 27 | app.VerifyURL(w, r).NewText("客服消息1").Send().NewText("客服消息2").Send().NewText("查询OK").Reply() 28 | }) 29 | 30 | http.ListenAndServe(":9090", nil) 31 | } 32 | 33 | More info: https://github.com/esap/wechat 34 | 35 | */ 36 | package wechat 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 一零村长 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 | -------------------------------------------------------------------------------- /util/string.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/sha1" 6 | "fmt" 7 | "math/rand" 8 | "sort" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // Substr 截取字符串 start 起点下标 end 终点下标(不包括) 14 | func Substr(str string, start int, end int) string { 15 | rs := []rune(str) 16 | length := len(rs) 17 | 18 | if start < 0 || start > length || end < 0 { 19 | return "" 20 | } 21 | 22 | if end > length { 23 | return string(rs[start:]) 24 | } 25 | return string(rs[start:end]) 26 | } 27 | 28 | // SortSha1 排序并sha1,主要用于计算signature 29 | func SortSha1(s ...string) string { 30 | sort.Strings(s) 31 | h := sha1.New() 32 | h.Write([]byte(strings.Join(s, ""))) 33 | return fmt.Sprintf("%x", h.Sum(nil)) 34 | } 35 | 36 | // SortMd5 排序并md5,主要用于计算sign 37 | func SortMd5(s ...string) string { 38 | sort.Strings(s) 39 | h := md5.New() 40 | h.Write([]byte(strings.Join(s, ""))) 41 | return strings.ToUpper(fmt.Sprintf("%x", h.Sum(nil))) 42 | } 43 | 44 | // GetRandomString 获得随机字符串 45 | func GetRandomString(l int) string { 46 | str := "0123456789abcdefghijklmnopqrstuvwxyz" 47 | bytes := []byte(str) 48 | result := []byte{} 49 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 50 | for i := 0; i < l; i++ { 51 | result = append(result, bytes[r.Intn(len(bytes))]) 52 | } 53 | return string(result) 54 | } 55 | -------------------------------------------------------------------------------- /usermsg.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "encoding/xml" 5 | ) 6 | 7 | type ( 8 | // WxMsg 混合用户消息,业务判断的主体 9 | WxMsg struct { 10 | XMLName xml.Name `xml:"xml"` 11 | ToUserName string 12 | FromUserName string 13 | CreateTime int64 14 | MsgId int64 15 | MsgType string 16 | Content string // text 17 | AgentID int // corp 18 | PicUrl string // image 19 | MediaId string // image/voice/video/shortvideo 20 | Format string // voice 21 | Recognition string // voice 22 | ThumbMediaId string // video 23 | LocationX float32 `xml:"Latitude"` // location 24 | LocationY float32 `xml:"Longitude"` // location 25 | Precision float32 // LOCATION 26 | Scale int // location 27 | Label string // location 28 | Title string // link 29 | Description string // link 30 | Url string // link 31 | Event string // event 32 | EventKey string // event 33 | SessionFrom string // event|user_enter_tempsession 34 | Ticket string 35 | FileKey string 36 | FileMd5 string 37 | FileTotalLen string 38 | TaskId string 39 | 40 | ScanCodeInfo struct { 41 | ScanType string 42 | ScanResult string 43 | } 44 | } 45 | 46 | // WxMsgEnc 加密的用户消息 47 | WxMsgEnc struct { 48 | XMLName xml.Name `xml:"xml"` 49 | ToUserName string 50 | AgentID int 51 | Encrypt string 52 | AgentType string 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /util/crypto.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/rand" 8 | "io" 9 | ) 10 | 11 | // AesDecrypt AES-CBC解密,PKCS#7,传入密文和密钥,[]byte 12 | func AesDecrypt(src, key []byte) (dst []byte, err error) { 13 | block, err := aes.NewCipher(key) 14 | if err != nil { 15 | return nil, err 16 | } 17 | iv := make([]byte, aes.BlockSize) 18 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 19 | return nil, err 20 | } 21 | dst = make([]byte, len(src)) 22 | cipher.NewCBCDecrypter(block, iv).CryptBlocks(dst, src) 23 | 24 | return PKCS7UnPad(dst), nil 25 | } 26 | 27 | // PKCS7UnPad PKSC#7解包 28 | func PKCS7UnPad(msg []byte) []byte { 29 | length := len(msg) 30 | padlen := int(msg[length-1]) 31 | return msg[:length-padlen] 32 | } 33 | 34 | // AesEncrypt AES-CBC加密+PKCS#7打包,传入明文和密钥 35 | func AesEncrypt(src []byte, key []byte) ([]byte, error) { 36 | k := len(key) 37 | if len(src)%k != 0 { 38 | src = PKCS7Pad(src, k) 39 | } 40 | 41 | block, err := aes.NewCipher(key) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | iv := make([]byte, aes.BlockSize) 47 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 48 | return nil, err 49 | } 50 | 51 | dst := make([]byte, len(src)) 52 | cipher.NewCBCEncrypter(block, iv).CryptBlocks(dst, src) 53 | 54 | return dst, nil 55 | } 56 | 57 | // PKCS7Pad PKCS#7打包 58 | func PKCS7Pad(msg []byte, blockSize int) []byte { 59 | if blockSize < 1<<1 || blockSize >= 1<<8 { 60 | panic("unsupported block size") 61 | } 62 | padlen := blockSize - len(msg)%blockSize 63 | padding := bytes.Repeat([]byte{byte(padlen)}, padlen) 64 | return append(msg, padding...) 65 | } 66 | -------------------------------------------------------------------------------- /oauth.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/esap/wechat/util" 8 | ) 9 | 10 | // WXAPIOauth2 oauth2鉴权 11 | const ( 12 | WXAPIOauth2 = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=%v&redirect_uri=%v&response_type=code&scope=snsapi_base&state=110#wechat_redirect" 13 | WXAPIJscode2session = "https://api.weixin.qq.com/sns/jscode2session?appid=%v&secret=%v&js_code=%v&grant_type=authorization_code" 14 | CorpAPIJscode2session = "https://qyapi.weixin.qq.com/cgi-bin/miniprogram/jscode2session?access_token=%v&js_code=%v&grant_type=authorization_code" 15 | ) 16 | 17 | // WxSession 兼容企业微信和服务号 18 | type WxSession struct { 19 | WxErr 20 | SessionKey string `json:"session_key"` 21 | // corp 22 | CorpId string `json:"corpid"` 23 | UserId string `json:"userid"` 24 | // mp 25 | OpenId string `json:"openid"` 26 | UnionId string `json:"unionid"` 27 | } 28 | 29 | // GetOauth2Url 获取鉴权页面 30 | func GetOauth2Url(corpId, host string) string { 31 | return fmt.Sprintf(WXAPIOauth2, corpId, url.QueryEscape(host)) 32 | } 33 | 34 | // Jscode2Session code换session 35 | func (s *Server) Jscode2Session(code string) (ws *WxSession, err error) { 36 | url := fmt.Sprintf(WXAPIJscode2session, s.AppId, s.Secret, code) 37 | ws = new(WxSession) 38 | err = util.GetJson(url, ws) 39 | 40 | if ws.Error() != nil { 41 | err = ws.Error() 42 | } 43 | return 44 | } 45 | 46 | // Jscode2SessionEnt code换session(企业微信) 47 | func (s *Server) Jscode2SessionEnt(code string) (ws *WxSession, err error) { 48 | url := fmt.Sprintf(CorpAPIJscode2session, s.GetAccessToken(), code) 49 | ws = new(WxSession) 50 | err = util.GetJson(url, ws) 51 | 52 | if ws.Error() != nil { 53 | err = ws.Error() 54 | } 55 | return 56 | } 57 | -------------------------------------------------------------------------------- /menu.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/esap/wechat/util" 7 | ) 8 | 9 | // WXAPIMenuGet 微信菜单接口,兼容企业微信和服务号 10 | const ( 11 | WXAPIMenuGet = `menu/get?access_token=%s&agentid=%d` 12 | WXAPIMenuAdd = `menu/create?access_token=%s&agentid=%d` 13 | WXAPIMenuDel = `menu/delete?access_token=%s&agentid=%d` 14 | ) 15 | 16 | type ( 17 | // Button 按钮 18 | Button struct { 19 | Name string `json:"name"` 20 | Type string `json:"type"` 21 | Key string `json:"key"` 22 | Url string `json:"url"` 23 | AppId string `json:"appid"` 24 | PagePath string `json:"pagepath"` 25 | SubButton []struct { 26 | Name string `json:"name"` 27 | Type string `json:"type"` 28 | Key string `json:"key"` 29 | Url string `json:"url"` 30 | AppId string `json:"appid"` 31 | PagePath string `json:"pagepath"` 32 | } `json:"sub_button"` 33 | } 34 | // Menu 菜单 35 | Menu struct { 36 | WxErr 37 | Button []Button `json:"button"` 38 | 39 | Menu struct { 40 | Button []Button `json:"button"` 41 | } `json:"menu,omitempty"` 42 | } 43 | ) 44 | 45 | // GetMenu 获取应用菜单 46 | func (s *Server) GetMenu() (m *Menu, err error) { 47 | m = new(Menu) 48 | url := fmt.Sprintf(s.RootUrl+WXAPIMenuGet, s.GetAccessToken(), s.AgentId) 49 | if err = util.GetJson(url, m); err != nil { 50 | return 51 | } 52 | if len(m.Menu.Button) == 0 && len(m.Button) > 0 { 53 | m.Menu.Button = m.Button 54 | } 55 | err = m.Error() 56 | return 57 | } 58 | 59 | // AddMenu 创建应用菜单 60 | func (s *Server) AddMenu(m *Menu) (err error) { 61 | e := new(WxErr) 62 | url := fmt.Sprintf(s.RootUrl+WXAPIMenuAdd, s.GetAccessToken(), s.AgentId) 63 | if err = util.PostJsonPtr(url, m, e); err != nil { 64 | return 65 | } 66 | return e.Error() 67 | } 68 | 69 | // DelMenu 删除应用菜单 70 | func (s *Server) DelMenu() (err error) { 71 | e := new(WxErr) 72 | url := fmt.Sprintf(s.RootUrl+WXAPIMenuDel, s.GetAccessToken(), s.AgentId) 73 | if err = util.GetJson(url, e); err != nil { 74 | return 75 | } 76 | return e.Error() 77 | } 78 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package wechat_test 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/esap/wechat" // 微信SDK包 7 | // "github.com/labstack/echo" 8 | ) 9 | 10 | func Example() { 11 | wechat.Debug = true 12 | 13 | cfg := &wechat.WxConfig{ 14 | Token: "yourToken", 15 | AppId: "yourAppID", 16 | Secret: "yourSecret", 17 | EncodingAESKey: "yourEncodingAesKey", 18 | } 19 | 20 | app := wechat.New(cfg) 21 | 22 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 23 | ctx := app.VerifyURL(w, r) 24 | 25 | // 根据消息类型主动回复 26 | switch ctx.Msg.MsgType { 27 | case wechat.TypeText: 28 | ctx.NewText(ctx.Msg.Content).Reply() // 回复文字 29 | case wechat.TypeImage: 30 | ctx.NewImage(ctx.Msg.MediaId).Reply() // 回复图片 31 | case wechat.TypeVoice: 32 | ctx.NewVoice(ctx.Msg.MediaId).Reply() // 回复语音 33 | case wechat.TypeVideo: 34 | ctx.NewVideo(ctx.Msg.MediaId, "video title", "video description").Reply() //回复视频 35 | case wechat.TypeFile: 36 | ctx.NewFile(ctx.Msg.MediaId).Reply() // 回复文件,仅企业微信可用 37 | default: 38 | ctx.NewText("其他消息类型" + ctx.Msg.MsgType).Reply() // 回复模板消息 39 | } 40 | }) 41 | 42 | http.ListenAndServe(":9090", nil) 43 | } 44 | 45 | // func Example_echo() { 46 | // app := wechat.New("yourToken", "yourAppID", "yourSecret", "yourEncodingAesKey") 47 | // e := echo.New() 48 | // e.Any("/", func(c echo.Context) error { 49 | // ctx := app.VerifyURL(c.Response().Writer, c.Request()) 50 | 51 | // // 根据消息类型主动回复 52 | // switch ctx.Msg.MsgType { 53 | // case wechat.TypeText: 54 | // ctx.NewText(ctx.Msg.Content).Reply() // 回复文字 55 | // case wechat.TypeImage: 56 | // ctx.NewImage(ctx.Msg.MediaId).Reply() // 回复图片 57 | // case wechat.TypeVoice: 58 | // ctx.NewVoice(ctx.Msg.MediaId).Reply() // 回复语音 59 | // case wechat.TypeVideo: 60 | // ctx.NewVideo(ctx.Msg.MediaId, "video title", "video description").Reply() //回复视频 61 | // case wechat.TypeFile: 62 | // ctx.NewFile(ctx.Msg.MediaId).Reply() // 回复文件,仅企业微信可用 63 | // default: 64 | // ctx.NewText("其他消息类型" + ctx.Msg.MsgType).Reply() // 回复模板消息 65 | // } 66 | // return nil 67 | // }) 68 | // e.Start(":9090") 69 | // } 70 | -------------------------------------------------------------------------------- /media.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/esap/wechat/util" 8 | ) 9 | 10 | const ( 11 | // WXAPIMediaUpload 临时素材上传 12 | WXAPIMediaUpload = "media/upload?access_token=%s&type=%s" 13 | // WXAPIMediaGet 临时素材下载 14 | WXAPIMediaGet = "media/get?access_token=%s&media_id=%s" 15 | // WXAPIMediaGetJssdk 高清语言素材下载 16 | WXAPIMediaGetJssdk = "media/get/jssdk?access_token=%s&media_id=%s" 17 | ) 18 | 19 | // Media 上传回复体 20 | type Media struct { 21 | WxErr 22 | Type string `json:"type"` 23 | MediaID string `json:"media_id"` 24 | ThumbMediaId string `json:"thumb_media_id"` 25 | CreatedAt interface{} `json:"created_at"` // 企业微信是string,服务号是int,采用interface{}统一接收 26 | } 27 | 28 | // MediaUpload 临时素材上传,mediaType选项如下: 29 | // TypeImage = "image" 30 | // TypeVoice = "voice" 31 | // TypeVideo = "video" 32 | // TypeFile = "file" // 仅企业微信可用 33 | func (s *Server) MediaUpload(mediaType string, filename string) (media Media, err error) { 34 | uri := fmt.Sprintf(s.RootUrl+WXAPIMediaUpload, s.GetAccessToken(), mediaType) 35 | var b []byte 36 | b, err = util.PostFile("media", filename, uri) 37 | if err != nil { 38 | return 39 | } 40 | if err = json.Unmarshal(b, &media); err != nil { 41 | return 42 | } 43 | err = media.Error() 44 | return 45 | } 46 | 47 | // GetMedia 下载临时素材 48 | func (s *Server) GetMedia(filename, mediaId string) error { 49 | url := fmt.Sprintf(s.RootUrl+WXAPIMediaGet, s.GetAccessToken(), mediaId) 50 | return util.GetFile(filename, url) 51 | } 52 | 53 | // GetMediaBytes 下载临时素材,返回body字节 54 | func (s *Server) GetMediaBytes(mediaId string) ([]byte, error) { 55 | url := fmt.Sprintf(s.RootUrl+WXAPIMediaGet, s.GetAccessToken(), mediaId) 56 | return util.GetBody(url) 57 | } 58 | 59 | // GetJsMedia 下载高清语言素材(通过JSSDK上传) 60 | func (s *Server) GetJsMedia(filename, mediaId string) error { 61 | url := fmt.Sprintf(s.RootUrl+WXAPIMediaGetJssdk, s.GetAccessToken(), mediaId) 62 | return util.GetFile(filename, url) 63 | } 64 | 65 | // GetJsMediaBytes 下载高清语言素材,返回body字节 66 | func (s *Server) GetJsMediaBytes(mediaId string) ([]byte, error) { 67 | url := fmt.Sprintf(s.RootUrl+WXAPIMediaGetJssdk, s.GetAccessToken(), mediaId) 68 | return util.GetBody(url) 69 | } 70 | -------------------------------------------------------------------------------- /corp_checkin.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "github.com/esap/wechat/util" 5 | ) 6 | 7 | const ( 8 | // CorpAPICheckInGet 企业微信打开数据获取接口 9 | CorpAPICheckInGet = CorpAPI + "checkin/getcheckindata?access_token=" 10 | // CorpCheckInAgentID 打卡AgentId 11 | CorpCheckInAgentID = 3010011 12 | ) 13 | 14 | type ( 15 | // dkDataReq 审批请求数据 16 | dkDataReq struct { 17 | OpenCheckInDataType int64 `json:"opencheckindatatype"` 18 | Starttime int64 `json:"starttime"` 19 | Endtime int64 `json:"endtime"` 20 | UseridList []string `json:"useridlist"` 21 | } 22 | 23 | // DkDataRet 审批返回数据 24 | DkDataRet struct { 25 | WxErr `json:"-"` 26 | Result []DkData `json:"checkindata"` 27 | } 28 | 29 | // DkData 审批数据 30 | DkData struct { 31 | Userid string `json:"userid"` // 用户id 32 | GroupName string `json:"groupname"` // 打卡规则名称 33 | CheckinType string `json:"checkin_type"` // 打卡类型 34 | ExceptionType string `json:"exception_type"` // 异常类型,如果有多个异常,以分号间隔 35 | CheckinTime int64 `json:"checkin_time"` // 打卡时间。UTC时间戳 36 | LocationTitle string `json:"location_title"` // 打卡地点title 37 | LocationDetail string `json:"location_detail"` // 打卡地点详情 38 | WifiName string `json:"wifiname"` // 打卡wifi名称 39 | Notes string `json:"notes"` // 打卡备注 40 | WifiMac string `json:"wifimac"` // 打卡的MAC地址/bssid 41 | } 42 | ) 43 | 44 | // GetCheckIn 获取打卡数据,Namelist用户列表不超过100个。若用户超过100个,请分批获取 45 | func (s *Server) GetCheckIn(opType, start, end int64, Namelist []string) (dkdata []DkData, err error) { 46 | url := CorpAPICheckInGet + s.GetAccessToken() 47 | data := new(DkDataRet) 48 | if err = util.PostJsonPtr(url, dkDataReq{opType, start, end, Namelist}, data); err != nil { 49 | return 50 | } 51 | if data.ErrCode != 0 { 52 | err = data.Error() 53 | } 54 | dkdata = data.Result 55 | return 56 | } 57 | 58 | // GetAllCheckIn 获取所有人的打卡数据 59 | func (s *Server) GetAllCheckIn(opType, start, end int64) (dkdata []DkData, err error) { 60 | ul := s.GetUserIdList() 61 | l := len(ul) 62 | for i := 0; i < l; i += 100 { 63 | dk, e := s.GetCheckIn(opType, start, end, ul[i:util.Min(l, i+100)]) 64 | if e != nil { 65 | err = e 66 | return 67 | } 68 | dkdata = append(dkdata, dk...) 69 | } 70 | return 71 | } 72 | -------------------------------------------------------------------------------- /mp_template.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/esap/wechat/util" 8 | ) 9 | 10 | // MPTemplateGetAll 服务号模板消息接口 11 | const ( 12 | MPTemplateGetAll = WXAPI + "template/get_all_private_template?access_token=" 13 | MPTemplateAdd = WXAPI + "template/api_add_template?access_token=" 14 | MPTemplateDel = WXAPI + "template/del_private_template?access_token=" 15 | MPTemplateSendMsg = WXAPI + "message/template/send?access_token=" 16 | ) 17 | 18 | // MpTemplate 模板信息 19 | type MpTemplate struct { 20 | TemplateId string `json:"template_id"` 21 | Title string `json:"title"` 22 | PrimaryIndustry string `json:"primary_industry"` 23 | DeputyIndustry string `json:"deputy_industry"` 24 | Content string `json:"content"` 25 | Example string `json:"example"` 26 | } 27 | 28 | // AddTemplate 获取模板 29 | func (s *Server) AddTemplate(IdShort string) (id string, err error) { 30 | form := map[string]interface{}{"template_id_short": IdShort} 31 | 32 | ret := make(map[string]interface{}) 33 | err = util.PostJsonPtr(MPTemplateAdd+s.GetAccessToken(), form, ret) 34 | if err != nil { 35 | return 36 | } 37 | 38 | if fmt.Sprint(ret["errcode"]) != "0" { 39 | return "", errors.New(fmt.Sprint(ret["errcode"])) 40 | } 41 | 42 | return ret["template_id"].(string), nil 43 | } 44 | 45 | // DelTemplate 删除模板 46 | func (s *Server) DelTemplate(id string) (err error) { 47 | form := map[string]interface{}{"template_id": id} 48 | 49 | ret := make(map[string]interface{}) 50 | err = util.PostJsonPtr(MPTemplateDel+s.GetAccessToken(), form, ret) 51 | if err != nil { 52 | return 53 | } 54 | 55 | if fmt.Sprint(ret["errcode"]) != "0" { 56 | return errors.New(fmt.Sprint(ret["errcode"])) 57 | } 58 | 59 | return 60 | } 61 | 62 | // GetAllTemplate 获取模板 63 | func (s *Server) GetAllTemplate() (templist []MpTemplate, err error) { 64 | ret := make(map[string]interface{}) 65 | err = util.GetJson(MPTemplateGetAll+s.GetAccessToken(), ret) 66 | if err != nil { 67 | return 68 | } 69 | 70 | if fmt.Sprint(ret["errcode"]) != "0" { 71 | return nil, errors.New(fmt.Sprint(ret["errcode"])) 72 | } 73 | 74 | return ret["template_id"].([]MpTemplate), nil 75 | } 76 | 77 | // SendTemplate 发送模板消息,data通常是map[string]struct{value string,color string} 78 | func (s *Server) SendTemplate(to, id, url, appid, pagepath string, data interface{}) *WxErr { 79 | 80 | form := map[string]interface{}{ 81 | "touser": to, 82 | "template_id": id, 83 | "data": data, 84 | } 85 | if pagepath != "" { 86 | form["miniprogram"] = map[string]string{ 87 | "appid": appid, 88 | "pagepath": pagepath, 89 | } 90 | } else if url != "" { 91 | form["url"] = url 92 | } 93 | ret := new(WxErr) 94 | err := util.PostJsonPtr(MPTemplateSendMsg+s.GetAccessToken(), form, &ret) 95 | if err != nil { 96 | return &WxErr{ErrCode: -1, ErrMsg: err.Error()} 97 | } 98 | 99 | return ret 100 | } 101 | -------------------------------------------------------------------------------- /send.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "unicode/utf8" 7 | 8 | "github.com/esap/wechat/util" 9 | ) 10 | 11 | // AddMsg 添加队列消息 12 | func (s *Server) AddMsg(v interface{}) { 13 | s.MsgQueue <- v 14 | } 15 | 16 | // SendMsg 发送消息 17 | func (s *Server) SendMsg(v interface{}) *WxErr { 18 | url := s.MsgUrl + s.GetAccessToken() 19 | body, err := util.PostJson(url, v) 20 | if err != nil { 21 | return &WxErr{-1, err.Error()} 22 | } 23 | rst := new(WxErr) 24 | err = json.Unmarshal(body, rst) 25 | if err != nil { 26 | return &WxErr{-1, err.Error()} 27 | } 28 | Printf("[*] 发送消息:%+v\n[*] 回执:%+v", v, *rst) 29 | return rst 30 | } 31 | 32 | // SendText 发送客服text消息,过长时按500长度自动拆分 33 | func (s *Server) SendText(to, msg string) (e *WxErr) { 34 | leng := utf8.RuneCountInString(msg) 35 | n := leng/500 + 1 36 | 37 | if n == 1 { 38 | return s.SendMsg(s.NewText(to, msg)) 39 | } 40 | for i := 0; i < n; i++ { 41 | e = s.SendMsg(s.NewText(to, fmt.Sprintf("%s\n(%v/%v)", util.Substr(msg, i*500, (i+1)*500), i+1, n))) 42 | } 43 | 44 | return 45 | } 46 | 47 | // SendImage 发送客服Image消息 48 | func (s *Server) SendImage(to string, mediaId string) *WxErr { 49 | return s.SendMsg(s.NewImage(to, mediaId)) 50 | } 51 | 52 | // SendVoice 发送客服Voice消息 53 | func (s *Server) SendVoice(to string, mediaId string) *WxErr { 54 | return s.SendMsg(s.NewVoice(to, mediaId)) 55 | } 56 | 57 | // SendFile 发送客服File消息 58 | func (s *Server) SendFile(to string, mediaId string) *WxErr { 59 | return s.SendMsg(s.NewFile(to, mediaId)) 60 | } 61 | 62 | // SendVideo 发送客服Video消息 63 | func (s *Server) SendVideo(to string, mediaId, title, desc string) *WxErr { 64 | return s.SendMsg(s.NewVideo(to, mediaId, title, desc)) 65 | } 66 | 67 | // SendTextcard 发送客服extcard消息 68 | func (s *Server) SendTextcard(to string, title, desc, url, btntxt string) *WxErr { 69 | return s.SendMsg(s.NewTextcard(to, title, desc, url, btntxt)) 70 | } 71 | 72 | // SendMusic 发送客服Music消息 73 | func (s *Server) SendMusic(to string, mediaId, title, desc, musicUrl, qhMusicUrl string) *WxErr { 74 | return s.SendMsg(s.NewMusic(to, mediaId, title, desc, musicUrl, qhMusicUrl)) 75 | } 76 | 77 | // SendNews 发送客服news消息 78 | func (s *Server) SendNews(to string, arts ...Article) *WxErr { 79 | return s.SendMsg(s.NewNews(to, arts...)) 80 | } 81 | 82 | // SendMpNews 发送加密新闻mpnews消息(仅企业号可用) 83 | func (s *Server) SendMpNews(to string, arts ...MpArticle) *WxErr { 84 | return s.SendMsg(s.NewMpNews(to, arts...)) 85 | } 86 | 87 | // SendMpNewsId 发送加密新闻mpnews消息(直接使用mediaId) 88 | func (s *Server) SendMpNewsId(to string, mediaId string) *WxErr { 89 | return s.SendMsg(s.NewMpNewsId(to, mediaId)) 90 | } 91 | 92 | // SendMarkDown 发送加密新闻mpnews消息(直接使用mediaId) 93 | func (s *Server) SendMarkDown(to string, content string) *WxErr { 94 | return s.SendMsg(s.NewMarkDown(to, content)) 95 | } 96 | 97 | // SendTaskCard 发送任务卡片taskcard消息 98 | func (s *Server) SendTaskCard(to string, Title, Desc, Url, TaskId, Btn string) *WxErr { 99 | return s.SendMsg(s.NewTaskCard(to, Title, Desc, Url, TaskId, Btn)) 100 | } 101 | -------------------------------------------------------------------------------- /corp_approval.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/esap/wechat/util" 7 | ) 8 | 9 | const ( 10 | // CorpAPIGetApproval 企业微信审批数据获取接口 11 | CorpAPIGetApproval = CorpAPI + "corp/getapprovaldata?access_token=" 12 | // CorpApprovalAgentID 审批AgentId 13 | CorpApprovalAgentID = 3010040 14 | ) 15 | 16 | type ( 17 | // spDataReq 审批请求数据 18 | spDataReq struct { 19 | Starttime int64 `json:"starttime"` 20 | Endtime int64 `json:"endtime"` 21 | NextSpNum int64 `json:"next_spnum,omitempty"` 22 | } 23 | 24 | // SpDataRet 审批返回数据 25 | SpDataRet struct { 26 | WxErr `json:"-"` 27 | Count int64 `json:"count"` 28 | Total int64 `json:"total"` 29 | NextSpnum int64 `json:"next_spnum"` 30 | Data []struct { 31 | Spname string `json:"spname"` // 审批名称(请假,报销,自定义审批名称) 32 | ApplyName string `json:"apply_name"` // 申请人姓名 33 | ApplyOrg string `json:"apply_org"` // 申请人部门 34 | ApprovalName []string `json:"approval_name"` // 审批人姓名 35 | NotifyName []string `json:"notify_name"` // 抄送人姓名 36 | SpStatus int64 `json:"sp_status"` // 审批状态:1审批中;2 已通过;3已驳回;4已取消 37 | SpNum int64 `json:"sp_num"` // 审批单号 38 | Mediaids []string `json:"mediaids"` // 审批媒体 39 | ApplyTime int64 `json:"apply_time"` // 申请时间 40 | ApplyUserId string `json:"apply_user_id"` // 申请人 41 | 42 | Leave struct { 43 | Timeunit int64 `json:"timeunit"` // 请假时间单位:0半天;1小时 44 | LeaveType int64 `json:"leave_type"` // 请假类型:1年假;2事假;3病假;4调休假;5婚假;6产假;7陪产假;8其他 45 | StartTime int64 `json:"start_time"` // 请假开始时间,unix时间 46 | EndTime int64 `json:"end_time"` // 请假结束时间,unix时间 47 | Duration int64 `json:"duration"` // 请假时长,单位小时 48 | Reason string `json:"reason"` // 请假事由 49 | } `json:"leave"` // 请假类型 50 | 51 | Expense struct { 52 | ExpenseType int64 `json:"expense_type"` // 报销类型:1差旅费;2交通费;3招待费;4其他报销 53 | Reason string `json:"reason"` // 报销事由 54 | Item []struct { 55 | ExpenseitemType int64 `json:"expenseitem_type"` // 费用类型:1飞机票;2火车票;3的士费;4住宿费;5餐饮费;6礼品费;7活动费;8通讯费;9补助;10其他 56 | Time int64 `json:"time"` // 发生时间,unix时间 57 | Sums int64 `json:"sums"` // 费用金额,单位元 58 | Reason string `json:"reason"` // 明细事由 59 | } `json:"item"` // 报销明细 60 | } `json:"expense"` // 报销类型 61 | 62 | Comm struct { 63 | Data string `json:"apply_data"` // 自定义审批申请的单据数据 64 | } `json:"comm"` // 自定义类型 65 | } `json:"data"` 66 | } 67 | 68 | // MyField 自定义字段 69 | MyField struct { 70 | Title string `json:"title"` // 类目名 71 | Type string `json:"type"` // 类目类型【 text: "文本", textarea: "多行文本", number: "数字", date: "日期", datehour: "日期+时间", select: "选择框" 】 72 | Value interface{} `json:"value"` // 填写的内容,Type是图片或list时,value是一个数组 73 | DateHourValue int64 `json:"dateHourValue"` // 日期时间值 74 | } 75 | ) 76 | 77 | // GetApproval 获取审批数据 78 | func (s *Server) GetApproval(start, end, nextNum int64) (sdr *SpDataRet, err error) { 79 | url := CorpAPIGetApproval + s.GetAccessToken() 80 | sdr = new(SpDataRet) 81 | if err = util.PostJsonPtr(url, spDataReq{start, end, nextNum}, sdr); err != nil { 82 | log.Println("GetApproval:PostJsonPtr err:", err) 83 | return 84 | } 85 | if sdr.ErrCode != 0 { 86 | err = sdr.Error() 87 | } 88 | return 89 | } 90 | -------------------------------------------------------------------------------- /corp_dept.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/esap/wechat/util" 9 | ) 10 | 11 | // CorpAPIDeptList 企业微信部门列表接口 12 | const ( 13 | CorpAPIDeptList = CorpAPI + `department/list?access_token=%s` 14 | CorpAPIDeptAdd = CorpAPI + `department/create?access_token=` 15 | CorpAPIDeptUpdate = CorpAPI + `department/update?access_token=` 16 | CorpAPIDeptDel = CorpAPI + `department/delete?access_token=` 17 | ) 18 | 19 | type ( 20 | // DeptList 部门列表 21 | DeptList struct { 22 | WxErr 23 | Department []Department 24 | } 25 | 26 | // Department 部门 27 | Department struct { 28 | Id int `json:"id"` 29 | Name string `json:"name"` 30 | ParentId int `json:"parentid"` 31 | Order1 int64 `json:"order"` 32 | } 33 | ) 34 | 35 | // SyncDeptList 更新部门列表 36 | func (s *Server) SyncDeptList() (err error) { 37 | s.DeptList, err = s.GetDeptList() 38 | if err != nil { 39 | log.Printf("[%v::%v]获取部门列表失败:%v", s.AppId, s.AgentId, err) 40 | } 41 | return 42 | } 43 | 44 | // GetDeptList 获取部门列表 45 | func (s *Server) GetDeptList() (dl DeptList, err error) { 46 | url := fmt.Sprintf(CorpAPIDeptList, s.GetUserAccessToken()) 47 | if err = util.GetJson(url, &dl); err != nil { 48 | return 49 | } 50 | err = dl.Error() 51 | return 52 | } 53 | 54 | // GetDeptIdList 获取部门id列表 55 | func (s *Server) GetDeptIdList() (deptIdlist []int) { 56 | deptIdlist = make([]int, 0) 57 | s.SyncDeptList() 58 | for _, v := range s.DeptList.Department { 59 | deptIdlist = append(deptIdlist, v.Id) 60 | } 61 | return 62 | } 63 | 64 | // DeptAdd 获取部门列表 65 | func (s *Server) DeptAdd(dept *Department) (err error) { 66 | return s.doUpdate(CorpAPIDeptAdd, dept) 67 | } 68 | 69 | // DeptUpdate 获取部门列表 70 | func (s *Server) DeptUpdate(dept *Department) (err error) { 71 | return s.doUpdate(CorpAPIDeptUpdate, dept) 72 | } 73 | 74 | // DeptDelete 删除部门 75 | func (s *Server) DeptDelete(Id int) (err error) { 76 | e := new(WxErr) 77 | if err = util.GetJson(CorpAPIDeptDel+s.GetUserAccessToken()+"&id="+fmt.Sprint(Id), e); err != nil { 78 | return 79 | } 80 | return e.Error() 81 | } 82 | 83 | // GetDeptName 通过部门id获取部门名称 84 | func (s *Server) GetDeptName(id int) string { 85 | for _, v := range s.DeptList.Department { 86 | if v.Id == id { 87 | return v.Name 88 | } 89 | } 90 | return "" 91 | } 92 | 93 | // GetToParty 获取acl所包含的所有部门ID,结果形式:tagId1|tagId2|tagId3... 94 | func (s *Server) GetToParty(acl interface{}) string { 95 | s1 := strings.TrimSpace(acl.(string)) 96 | arr := strings.Split(toUserReplacer.Replace(s1), "|") 97 | for k, totag := range arr { 98 | for _, v := range s.DeptList.Department { 99 | if v.Name == totag { 100 | arr[k] = fmt.Sprint(v.Id) 101 | } 102 | } 103 | } 104 | return strings.Join(arr, "|") 105 | } 106 | 107 | // CheckDeptAcl 测试权限,对比user是否包含于acl 108 | func (s *Server) CheckDeptAcl(userid, acl string) bool { 109 | acl = strings.TrimSpace(acl) 110 | if acl == "" { 111 | return false 112 | } 113 | u := s.GetUser(userid) 114 | if u == nil { 115 | return false 116 | } 117 | acl = "|" + toUserReplacer.Replace(acl) + "|" 118 | for _, id := range u.Department { 119 | if strings.Contains(acl, "|"+s.GetDeptName(id)+"|") { 120 | return true 121 | } 122 | if strings.Contains(acl, "|"+fmt.Sprint(id)+"|") { 123 | return true 124 | } 125 | } 126 | 127 | return false 128 | } 129 | -------------------------------------------------------------------------------- /mp_user.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/esap/wechat/util" 7 | ) 8 | 9 | // MPUserGetList 公众号用户接口 10 | const ( 11 | MPUserGetList = WXAPI + "user/get?access_token=%s&next_openid=%s" 12 | MPUserBatchGet = WXAPI + "user/info/batchget?access_token=" 13 | MPUserInfo = WXAPI + "user/info?access_token=%s&openid=%v&lang=%v" 14 | ) 15 | 16 | type ( 17 | // MpUserInfoList 公众号用户信息列表 18 | MpUserInfoList struct { 19 | WxErr 20 | MpUserInfoList []MpUserInfo `json:"user_info_list"` 21 | } 22 | 23 | // MpUserInfo 公众号用户信息 24 | MpUserInfo struct { 25 | Subscribe int 26 | OpenId string 27 | NickName string 28 | Sex int 29 | Language string 30 | City string 31 | Province string 32 | Country string 33 | HeadImgUrl string 34 | SubscribeTime int `json:"subscribe_time"` 35 | UnionId string 36 | Remark string 37 | GroupId int 38 | TagIdList []int `json:"tagid_list"` 39 | } 40 | 41 | // MpUser 服务号用户 42 | MpUser struct { 43 | WxErr 44 | Total int 45 | Count int 46 | Data struct { 47 | OpenId []string 48 | } 49 | NextOpenId string 50 | } 51 | 52 | // MpUserListReq 公众号用户请求 53 | MpUserListReq struct { 54 | UserList interface{} `json:"user_list"` 55 | } 56 | ) 57 | 58 | // BatchGetAll 获取所有公众号用户 59 | func (s *Server) BatchGetAll() (ui []MpUserInfo, err error) { 60 | var ul []string 61 | ul, err = s.GetAllMpUserList() 62 | if err != nil { 63 | return 64 | } 65 | leng := len(ul) 66 | if leng <= 100 { 67 | return s.BatchGet(ul) 68 | } 69 | for i := 0; i < leng/100+1; i++ { 70 | end := (i + 1) * 100 71 | if end > leng { 72 | end = leng 73 | } 74 | 75 | ui2, err2 := s.BatchGet(ul[i*100 : end]) 76 | if err != nil { 77 | err = err2 78 | return 79 | } 80 | ui = append(ui, ui2...) 81 | } 82 | return 83 | } 84 | 85 | // BatchGet 批量获取公众号用户信息 86 | func (s *Server) BatchGet(ul []string) (ui []MpUserInfo, err error) { 87 | m := make([]map[string]interface{}, len(ul)) 88 | 89 | for k, v := range ul { 90 | m[k] = make(map[string]interface{}) 91 | m[k]["openid"] = v 92 | } 93 | ml := new(MpUserInfoList) 94 | err = util.PostJsonPtr(MPUserBatchGet+s.GetAccessToken(), MpUserListReq{m}, ml) 95 | return ml.MpUserInfoList, ml.Error() 96 | } 97 | 98 | // GetAllMpUserList 获取所有用户ID 99 | func (s *Server) GetAllMpUserList() (ul []string, err error) { 100 | ul = make([]string, 0) 101 | mul, err := s.GetMpUserList() 102 | if err != nil { 103 | return 104 | } 105 | if mul.Error() == nil { 106 | ul = append(ul, mul.Data.OpenId...) 107 | } 108 | for mul.Count == 10000 { 109 | mul, err = s.GetMpUserList(mul.NextOpenId) 110 | if err != nil { 111 | return 112 | } 113 | if mul.Error() == nil { 114 | ul = append(ul, mul.Data.OpenId...) 115 | } 116 | } 117 | return 118 | } 119 | 120 | // GetMpUserList 获取用户信息,根据openid 121 | func (s *Server) GetMpUserList(openid ...string) (ul *MpUser, err error) { 122 | if len(openid) == 0 { 123 | openid = append(openid, "") 124 | } 125 | mpuser := new(MpUser) 126 | url := fmt.Sprintf(MPUserGetList, s.GetAccessToken(), openid[0]) 127 | if err = util.GetJson(url, &mpuser); err != nil { 128 | return 129 | } 130 | return mpuser, mpuser.Error() 131 | } 132 | 133 | // GetMpUserInfo 获取用户详情 134 | func (s *Server) GetMpUserInfo(openid string, lang ...string) (user *MpUserInfo, err error) { 135 | if len(lang) == 0 { 136 | lang = append(lang, "zh_CN") 137 | } 138 | user = new(MpUserInfo) 139 | url := fmt.Sprintf(MPUserInfo, s.GetAccessToken(), openid, lang[0]) 140 | if err = util.GetJson(url, &user); err != nil { 141 | return 142 | } 143 | return 144 | } 145 | -------------------------------------------------------------------------------- /pay.go: -------------------------------------------------------------------------------- 1 | // Package wechat TODO:微信支付接口 2 | package wechat 3 | 4 | import ( 5 | "fmt" 6 | 7 | "time" 8 | 9 | "github.com/esap/wechat/util" 10 | ) 11 | 12 | // PayRoot 支付根URL 13 | const ( 14 | PayRoot = "weixin://wxpay/bizpayurl?" 15 | PayUrl = "weixin://wxpay/bizpayurl?sign=%s&appid=%s&mch_id=%s&product_id=%sX&time_stamp=%vX&nonce_str=%s" 16 | PayUnifiedOrderUrl = "https://api.mch.weixin.qq.com/pay/unifiedordefunc" 17 | ) 18 | 19 | // UnifiedOrderReq 统一下单请求体 20 | type UnifiedOrderReq struct { 21 | Appid string `xml:"appid"` 22 | MchId string `xml:"mch_id"` 23 | DeviceInfo string `xml:"device_info"` 24 | NonceStr string `xml:"nonce_str"` 25 | Sign string `xml:"sign"` 26 | SignType string `xml:"sign_type"` 27 | Body string `xml:"body"` 28 | Detail CDATA `xml:"detail"` 29 | Attach string `xml:"attach"` 30 | OutTradeNo string `xml:"out_trade_no"` 31 | FeeType string `xml:"fee_type"` 32 | TotalFee string `xml:"total_fee"` 33 | SpbillCreateIp string `xml:"spbill_create_ip"` 34 | TimeStart string `xml:"time_start"` 35 | TimeExpire string `xml:"time_expire"` 36 | GoodsTag string `xml:"goods_tag"` 37 | NotifyUrl string `xml:"notify_url"` 38 | TradeType string `xml:"trade_type"` 39 | ProductId string `xml:"product_id"` 40 | LimitPay string `xml:"limit_pay"` 41 | Openid string `xml:"openid"` 42 | SceneInfo string `xml:"scene_info"` 43 | } 44 | 45 | // UnifiedOrderRet 统一下单返回体 46 | type UnifiedOrderRet struct { 47 | ReturnCode string `xml:"return_code"` 48 | ReturnMsg string `xml:"return_msg"` 49 | // 以下字段在return_code为SUCCESS的时候有返回 50 | Appid string `xml:"appid"` 51 | MchId string `xml:"mch_id"` 52 | DeviceInfo string `xml:"device_info"` 53 | NonceStr string `xml:"nonce_str"` 54 | Sign string `xml:"sign"` 55 | ResultCode string `xml:"result_code"` 56 | ErrCode string `xml:"err_code"` 57 | ErrCodeDes string `xml:"err_code_des"` 58 | // 以下字段在return_code 和result_code都为SUCCESS的时候有返回 59 | TradeType string `xml:"trade_type"` 60 | PrepayId string `xml:"prepay_id"` 61 | CodeUrl string `xml:"code_url"` 62 | TimeExpire string `xml:"time_expire"` 63 | GoodsTag string `xml:"goods_tag"` 64 | NotifyUrl string `xml:"notify_url"` 65 | ProductId string `xml:"product_id"` 66 | LimitPay string `xml:"limit_pay"` 67 | Openid string `xml:"openid"` 68 | SceneInfo string `xml:"scene_info"` 69 | } 70 | 71 | // GetUnifedOrderUrl 获取统一下单URL,用于生成付款二维码等 72 | func (s *Server) GetUnifedOrderUrl(desc, tradeNo, fee, ip, callback, tradetype, productid string) string { 73 | noncestr := util.GetRandomString(16) 74 | r := &UnifiedOrderReq{ 75 | Appid: s.AppId, 76 | MchId: s.MchId, 77 | NonceStr: noncestr, 78 | Sign: util.SortMd5(noncestr), 79 | Body: desc, 80 | OutTradeNo: tradeNo, 81 | TotalFee: fee, 82 | SpbillCreateIp: ip, 83 | NotifyUrl: callback, 84 | TradeType: tradetype, 85 | ProductId: productid, 86 | } 87 | ret := new(UnifiedOrderRet) 88 | err := util.PostXmlPtr(PayUnifiedOrderUrl, r, ret) 89 | if err != nil { 90 | Println("GetUnifedOrderUrl err:", err) 91 | return "" 92 | } 93 | return ret.CodeUrl 94 | } 95 | 96 | // PayOrderScan 扫码付 97 | func (s *Server) PayOrderScan(mchId, ProductId string) string { 98 | nonceStr := util.GetRandomString(10) 99 | timeStamp := time.Now().Unix() 100 | strA := fmt.Sprintf("appid=%s&mch_id=%s&nonce_str=%s&product_id=%s&time_stamp=%v", s.AppId, mchId, nonceStr, ProductId, timeStamp) 101 | return PayRoot + strA + "&sign=" + util.SortMd5(strA) 102 | } 103 | -------------------------------------------------------------------------------- /accesstoken.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/esap/wechat/util" 9 | ) 10 | 11 | // FetchDelay 默认5分钟同步一次 12 | var FetchDelay time.Duration = 5 * time.Minute 13 | 14 | // AccessToken 回复体 15 | type AccessToken struct { 16 | AccessToken string `json:"access_token"` 17 | ExpiresIn int64 `json:"expires_in"` 18 | WxErr 19 | } 20 | 21 | // GetAccessToken 读取AccessToken 22 | func (s *Server) GetAccessToken() string { 23 | s.Lock() 24 | defer s.Unlock() 25 | var err error 26 | if s.accessToken == nil || s.accessToken.ExpiresIn < time.Now().Unix() { 27 | for i := 0; i < 3; i++ { 28 | err = s.getAccessToken() 29 | if err == nil { 30 | break 31 | } 32 | log.Printf("GetAccessToken[%v] %v", s.AgentId, err) 33 | time.Sleep(time.Second) 34 | } 35 | if err != nil { 36 | return "" 37 | } 38 | } 39 | return s.accessToken.AccessToken 40 | } 41 | 42 | // GetUserAccessToken 获取企业微信通讯录AccessToken 43 | func (s *Server) GetUserAccessToken() string { 44 | if us, ok := UserServerMap[s.AppId]; ok { 45 | return us.GetAccessToken() 46 | } 47 | return s.GetAccessToken() 48 | } 49 | 50 | func (s *Server) getAccessToken() (err error) { 51 | if s.ExternalTokenHandler != nil { 52 | s.accessToken = s.ExternalTokenHandler(s.AppId, s.AppName) 53 | Printf("***%v[%v]远程获取token:%v", util.Substr(s.AppId, 14, 30), s.AgentId, s.accessToken) 54 | return 55 | } 56 | url := fmt.Sprintf(s.TokenUrl, s.AppId, s.Secret) 57 | at := new(AccessToken) 58 | if err = util.GetJson(url, at); err != nil { 59 | return 60 | } 61 | if at.ErrCode > 0 { 62 | return at.Error() 63 | } 64 | at.ExpiresIn = time.Now().Unix() + at.ExpiresIn - 5 65 | s.accessToken = at 66 | Printf("***%v[%v]本地获取token:%v", util.Substr(s.AppId, 14, 30), s.AgentId, s.accessToken) 67 | return 68 | 69 | } 70 | 71 | // Ticket JS-SDK 72 | type Ticket struct { 73 | Ticket string `json:"ticket"` 74 | ExpiresIn int64 `json:"expires_in"` 75 | WxErr 76 | } 77 | 78 | // GetTicket 读取获取Ticket 79 | func (s *Server) GetTicket() string { 80 | if s.ticket == nil || s.ticket.ExpiresIn < time.Now().Unix() { 81 | for i := 0; i < 3; i++ { 82 | err := s.getTicket() 83 | if err != nil { 84 | log.Printf("getTicket[%v] err:%v", s.AgentId, err) 85 | time.Sleep(time.Second) 86 | continue 87 | } 88 | break 89 | } 90 | } 91 | return s.ticket.Ticket 92 | } 93 | 94 | func (s *Server) getTicket() (err error) { 95 | url := s.JsApi + s.GetAccessToken() 96 | at := new(Ticket) 97 | if err = util.GetJson(url, at); err != nil { 98 | return 99 | } 100 | if at.ErrCode > 0 { 101 | return at.Error() 102 | } 103 | Printf("[%v::%v-JsApi] >>> %+v", s.AppId, s.AgentId, *at) 104 | at.ExpiresIn = time.Now().Unix() + 500 105 | s.ticket = at 106 | return 107 | } 108 | 109 | // JsConfig Jssdk配置 110 | type JsConfig struct { 111 | Beta bool `json:"beta"` 112 | Debug bool `json:"debug"` 113 | AppId string `json:"appId"` 114 | Timestamp int64 `json:"timestamp"` 115 | Nonsestr string `json:"nonceStr"` 116 | Signature string `json:"signature"` 117 | JsApiList []string `json:"jsApiList"` 118 | Url string `json:"jsurl"` 119 | App int `json:"jsapp"` 120 | } 121 | 122 | // GetJsConfig 获取Jssdk配置 123 | func (s *Server) GetJsConfig(Url string) *JsConfig { 124 | jc := &JsConfig{Beta: true, Debug: Debug, AppId: s.AppId} 125 | jc.Timestamp = time.Now().Unix() 126 | jc.Nonsestr = "esap" 127 | jc.Signature = util.SortSha1(fmt.Sprintf("jsapi_ticket=%v&noncestr=%v×tamp=%v&url=%v", s.GetTicket(), jc.Nonsestr, jc.Timestamp, Url)) 128 | // TODO:可加入其他apilist 129 | jc.JsApiList = []string{"scanQRCode"} 130 | jc.Url = Url 131 | jc.App = s.AgentId 132 | Println("jsconfig:", jc) // Debug 133 | return jc 134 | } 135 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "encoding/xml" 5 | "errors" 6 | "net/http" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // Context 消息上下文 12 | type Context struct { 13 | *Server 14 | Timestamp string 15 | Nonce string 16 | Msg *WxMsg 17 | Resp interface{} 18 | Writer http.ResponseWriter 19 | Request *http.Request 20 | hasReply bool 21 | } 22 | 23 | // Reply 被动回复消息 24 | func (c *Context) Reply() (err error) { 25 | if c.hasReply { 26 | return errors.New("重复调用错误") 27 | } 28 | 29 | c.hasReply = true 30 | 31 | if c.Resp == nil { 32 | return nil 33 | } 34 | 35 | Printf("Wechat <== %+v", c.Resp) 36 | if c.SafeMode { 37 | b, err := xml.MarshalIndent(c.Resp, "", " ") 38 | if err != nil { 39 | return err 40 | } 41 | c.Resp, err = c.EncryptMsg(b, c.Timestamp, c.Nonce) 42 | if err != nil { 43 | return err 44 | } 45 | } 46 | c.Writer.Header().Set("Content-Type", "application/xml;charset=UTF-8") 47 | return xml.NewEncoder(c.Writer).Encode(c.Resp) 48 | } 49 | 50 | // Send 主动发送消息(客服) 51 | func (c *Context) Send() *Context { 52 | c.AddMsg(c.Resp) 53 | return c 54 | } 55 | 56 | func (c *Context) newResp(msgType string) wxResp { 57 | return wxResp{ 58 | FromUserName: CDATA(c.Msg.ToUserName), 59 | ToUserName: CDATA(c.Msg.FromUserName), 60 | MsgType: CDATA(msgType), 61 | CreateTime: time.Now().Unix(), 62 | AgentId: c.Msg.AgentID, 63 | Safe: c.Safe, 64 | } 65 | } 66 | 67 | // NewText Text消息 68 | func (c *Context) NewText(text ...string) *Context { 69 | c.Resp = &Text{ 70 | wxResp: c.newResp(TypeText), 71 | content: content{CDATA(strings.Join(text, ""))}} 72 | return c 73 | } 74 | 75 | // NewImage Image消息 76 | func (c *Context) NewImage(mediaId string) *Context { 77 | c.Resp = &Image{ 78 | wxResp: c.newResp(TypeImage), 79 | Image: media{CDATA(mediaId)}} 80 | return c 81 | } 82 | 83 | // NewVoice Voice消息 84 | func (c *Context) NewVoice(mediaId string) *Context { 85 | c.Resp = &Voice{ 86 | wxResp: c.newResp(TypeVoice), 87 | Voice: media{CDATA(mediaId)}} 88 | return c 89 | } 90 | 91 | // NewFile File消息 92 | func (c *Context) NewFile(mediaId string) *Context { 93 | c.Resp = &File{ 94 | wxResp: c.newResp(TypeFile), 95 | File: media{CDATA(mediaId)}} 96 | return c 97 | } 98 | 99 | // NewVideo Video消息 100 | func (c *Context) NewVideo(mediaId, title, desc string) *Context { 101 | c.Resp = &Video{ 102 | wxResp: c.newResp(TypeVideo), 103 | Video: video{CDATA(mediaId), CDATA(title), CDATA(desc)}} 104 | return c 105 | } 106 | 107 | // NewTextcard Textcard消息 108 | func (c *Context) NewTextcard(title, description, url, btntxt string) *Context { 109 | c.Resp = &Textcard{ 110 | wxResp: c.newResp(TypeTextcard), 111 | Textcard: textcard{CDATA(title), CDATA(description), CDATA(url), CDATA(btntxt)}} 112 | return c 113 | } 114 | 115 | // NewNews News消息 116 | func (c *Context) NewNews(arts ...Article) *Context { 117 | news := News{ 118 | wxResp: c.newResp(TypeNews), 119 | ArticleCount: len(arts), 120 | } 121 | news.Articles.Item = arts 122 | c.Resp = &news 123 | return c 124 | } 125 | 126 | // NewMpNews News消息 127 | func (c *Context) NewMpNews(mediaId string) *Context { 128 | news := MpNewsId{ 129 | wxResp: c.newResp(TypeMpNews), 130 | } 131 | news.MpNews.MediaId = CDATA(mediaId) 132 | c.Resp = &news 133 | return c 134 | } 135 | 136 | // NewMusic Music消息 137 | func (c *Context) NewMusic(mediaId, title, desc, musicUrl, hqMusicUrl string) *Context { 138 | c.Resp = &Music{ 139 | wxResp: c.newResp(TypeMusic), 140 | Music: music{CDATA(mediaId), CDATA(title), CDATA(desc), CDATA(musicUrl), CDATA(hqMusicUrl)}} 141 | return c 142 | } 143 | 144 | // Id 返回消息的来源与去向,可作为多应用管理时的用户组Id 145 | func (c *Context) Id() string { 146 | return c.Msg.FromUserName + "|" + c.Msg.ToUserName 147 | } 148 | -------------------------------------------------------------------------------- /corp_tag.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/esap/wechat/util" 9 | ) 10 | 11 | // CorpAPITagList 企业微信标签接口 12 | const ( 13 | CorpAPITagList = CorpAPI + `tag/list?access_token=` 14 | CorpAPITagAdd = CorpAPI + `tag/create?access_token=` 15 | CorpAPITagUpdate = CorpAPI + `tag/update?access_token=` 16 | CorpAPITagDel = CorpAPI + `tag/delete?access_token=` 17 | 18 | // CorpAPITagUsers 企业微信标签用户接口 19 | CorpAPITagUsers = CorpAPI + `tag/get?access_token=` 20 | CorpAPIAddTagUsers = CorpAPI + `tag/addtagusers?access_token=` 21 | CorpAPIDelTagUsers = CorpAPI + `tag/deltagusers?access_token=` 22 | ) 23 | 24 | type ( 25 | // TagList 标签列表 26 | TagList struct { 27 | WxErr 28 | Taglist []Tag 29 | } 30 | 31 | // Tag 标签 32 | Tag struct { 33 | TagId int `json:"tagid"` 34 | TagName string `json:"tagname"` 35 | } 36 | 37 | // TagUsers 标签成员 38 | TagUsers struct { 39 | WxErr 40 | TagId int `json:"tagid"` 41 | TagName string 42 | UserList []UserInfo 43 | PartyList []int 44 | } 45 | 46 | // TagUserBody 标签成员(请求body格式) 47 | TagUserBody struct { 48 | TagId int `json:"tagid"` 49 | UserList []string `json:"userlist"` 50 | PartyList []int `json:"partylist"` 51 | } 52 | 53 | // TagErr 标签获取错误 54 | TagErr struct { 55 | WxErr 56 | InvalidList string 57 | InvalidParty []int 58 | } 59 | ) 60 | 61 | // SyncTagList 更新标签列表 62 | func (s *Server) SyncTagList() (err error) { 63 | s.TagList, err = s.GetTagList() 64 | if err != nil { 65 | log.Printf("[%v::%v]获取标签列表失败:%v", s.AppId, s.AgentId, err) 66 | } 67 | return 68 | } 69 | 70 | // GetTagList 获取标签列表 71 | func (s *Server) GetTagList() (l TagList, err error) { 72 | l = TagList{} 73 | url := CorpAPITagList + s.GetUserAccessToken() 74 | if err = util.GetJson(url, &l); err != nil { 75 | return 76 | } 77 | err = l.Error() 78 | return 79 | } 80 | 81 | // GetTagIdList 获取标签id列表 82 | func (s *Server) GetTagIdList() (tagIdlist []int) { 83 | tagIdlist = make([]int, 0) 84 | for _, v := range s.TagList.Taglist { 85 | tagIdlist = append(tagIdlist, v.TagId) 86 | } 87 | return 88 | } 89 | 90 | // TagAdd 获取标签列表 91 | func (s *Server) TagAdd(Tag *Tag) (err error) { 92 | return s.doUpdate(CorpAPITagAdd, Tag) 93 | } 94 | 95 | // TagUpdate 获取标签列表 96 | func (s *Server) TagUpdate(Tag *Tag) (err error) { 97 | return s.doUpdate(CorpAPITagUpdate, Tag) 98 | } 99 | 100 | // TagDelete 删除用户 101 | func (s *Server) TagDelete(TagId int) (err error) { 102 | e := new(WxErr) 103 | if err = util.GetJson(CorpAPITagDel+s.GetUserAccessToken()+"&tagid="+fmt.Sprint(TagId), e); err != nil { 104 | return 105 | } 106 | return e.Error() 107 | } 108 | 109 | // GetTagUsers 获取标签下的成员 110 | func (s *Server) GetTagUsers(id int) (tu *TagUsers, err error) { 111 | tu = new(TagUsers) 112 | err = util.GetJson(CorpAPITagUsers+s.GetUserAccessToken()+"&tagid="+fmt.Sprint(id), tu) 113 | return 114 | } 115 | 116 | // AddTagUsers 添加标签成员 117 | func (s *Server) AddTagUsers(id int, userlist []string, partylist []int) error { 118 | leng := len(userlist) 119 | e := new(TagErr) 120 | for i := 0; i < leng/1000+1; i++ { 121 | end := (i + 1) * 1000 122 | if end > leng { 123 | end = leng 124 | } 125 | b := TagUserBody{TagId: id, UserList: userlist[i*1000 : end], PartyList: partylist} 126 | url := CorpAPIAddTagUsers + s.GetUserAccessToken() 127 | if err := util.PostJsonPtr(url, b, e); err != nil { 128 | return err 129 | } 130 | } 131 | return e.Error() 132 | } 133 | 134 | // DelTagUsers 删除标签成员 135 | func (s *Server) DelTagUsers(id int, userlist []string) error { 136 | b := TagUserBody{TagId: id, UserList: userlist} 137 | return s.doUpdate(CorpAPIDelTagUsers, b) 138 | } 139 | 140 | // GetTagName 通过标签id获取标签名称 141 | func (s *Server) GetTagName(id int) string { 142 | for _, v := range s.TagList.Taglist { 143 | if v.TagId == id { 144 | return v.TagName 145 | } 146 | } 147 | return "" 148 | } 149 | 150 | // GetTagId 通过标签名称获取标签名称 151 | func (s *Server) GetTagId(name string) int { 152 | for _, v := range s.TagList.Taglist { 153 | if fmt.Sprint(v.TagId) == name || v.TagName == name { 154 | return v.TagId 155 | } 156 | } 157 | return 0 158 | } 159 | 160 | // GetToTag 获取acl所包含的所有标签ID,结果形式:tagId1|tagId2|tagId3... 161 | func (s *Server) GetToTag(acl interface{}) string { 162 | s1 := strings.TrimSpace(acl.(string)) 163 | arr := strings.Split(toUserReplacer.Replace(s1), "|") 164 | for k, totag := range arr { 165 | for _, v := range s.TagList.Taglist { 166 | if v.TagName == totag { 167 | arr[k] = fmt.Sprint(v.TagId) 168 | } 169 | } 170 | } 171 | return strings.Join(arr, "|") 172 | } 173 | 174 | // CheckTagAcl 测试权限,对比user是否包含于acl 175 | func (s *Server) CheckTagAcl(userid, acl string) bool { 176 | acl = strings.TrimSpace(acl) 177 | if acl == "" { 178 | return false 179 | } 180 | arr := strings.Split(toUserReplacer.Replace(acl), "|") 181 | for _, idOrName := range arr { 182 | tu, err := s.GetTagUsers(s.GetTagId(idOrName)) 183 | if err != nil { 184 | continue 185 | } 186 | for _, u := range tu.UserList { 187 | if u.UserId == userid { 188 | return true 189 | } 190 | } 191 | } 192 | return false 193 | } 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WeChat SDK 2 | [![Build Status](https://travis-ci.org/esap/wechat.svg?branch=master)](https://travis-ci.org/esap/wechat) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/esap/wechat)](https://goreportcard.com/report/github.com/esap/wechat) 4 | [![GoDoc](http://godoc.org/github.com/esap/wechat?status.svg)](http://godoc.org/github.com/esap/wechat) 5 | 6 | **微信SDK的golang实现,短小精悍,同时兼容【企业微信/服务号/订阅号/小程序】** 7 | 8 | ## 快速开始 9 | 10 | 5行代码,链式消息,快速开启微信API示例: 11 | 12 | ```go 13 | package main 14 | 15 | import ( 16 | "net/http" 17 | 18 | "github.com/esap/wechat" // 微信SDK包 19 | ) 20 | 21 | func main() { 22 | wechat.Debug = true 23 | 24 | cfg := &wechat.WxConfig{ 25 | Token: "yourToken", 26 | AppId: "yourAppID", 27 | Secret: "yourSecret", 28 | EncodingAESKey: "yourEncodingAesKey", 29 | } 30 | 31 | app := wechat.New(cfg) 32 | app.SendText("@all", "Hello,World!") 33 | 34 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 35 | app.VerifyURL(w, r).NewText("客服消息1").Send().NewText("客服消息2").Send().NewText("查询OK").Reply() 36 | }) 37 | 38 | http.ListenAndServe(":9090", nil) 39 | } 40 | 41 | ``` 42 | ## 配置方式 43 | 44 | ```go 45 | // 创建公众号实例(服务号/订阅号/小程序) 不带aesKey则为明文模式 46 | cfg := &wechat.WxConfig{ 47 | Token: "yourToken", 48 | AppId: "yourAppID", 49 | Secret: "yourSecret", 50 | } 51 | 52 | // 创建公众号实例(服务号/订阅号/小程序) 53 | cfg := &wechat.WxConfig{ 54 | Token: "yourToken", 55 | AppId: "yourAppID", 56 | Secret: "yourSecret", 57 | EncodingAESKey: "yourEncodingAesKey", 58 | } 59 | 60 | // 创建企业微信实例 61 | cfg := &wechat.WxConfig{ 62 | Token: "yourToken", 63 | AppId: "yourCorpID", 64 | AgentId: "yourAgentId", 65 | Secret: "yourSecret", 66 | EncodingAESKey: "yourEncodingAesKey", 67 | AppType: 1, 68 | } 69 | ``` 70 | 71 | ## 主动推送消息 72 | 73 | 用户关注后,企业微信可以主动推送消息,服务号需要用户48小时内进入过。 74 | 75 | ```go 76 | app.SendText(to, msg) 77 | app.SendImage(to, mediaId) 78 | app.SendVoice(to, mediaId) 79 | app.SendFile(to, mediaId) 80 | app.SendVideo(to, mediaId, title, desc) 81 | app.SendTextcard(to, title, desc, url) 82 | app.SendMusic(to, mediaId, title, desc, musicUrl, qhMusicUrl) 83 | app.SendNews(to, arts...) 84 | app.SendMpNews(to, arts...) 85 | app.SendMpNewsId(to, mediaId) 86 | app.SendMarkDown(to, content) 87 | ``` 88 | 89 | ## 消息回调 90 | 91 | * 通常将`app.VerifyURL(http.ResponseWriter, *http.Request)`嵌入http handler 92 | 93 | 该函数返回`*wechat.Context`基本对象,其中的Msg为用户消息: 94 | 95 | ```go 96 | // 混合用户消息,业务判断的主体 97 | WxMsg struct { 98 | XMLName xml.Name `xml:"xml"` 99 | ToUserName 100 | FromUserName 101 | CreateTime 64 102 | MsgId 64 103 | MsgType 104 | Content // text 105 | AgentID // corp 106 | PicUrl // image 107 | MediaId // image/voice/video/shortvideo 108 | Format // voice 109 | Recognition // voice 110 | ThumbMediaId // video 111 | LocationX float32 `xml:"Latitude"` // location 112 | LocationY float32 `xml:"Longitude"` // location 113 | Precision float32 // LOCATION 114 | Scale // location 115 | Label // location 116 | Title // link 117 | Description // link 118 | Url // link 119 | Event // event 120 | EventKey // event 121 | SessionFrom // event|user_enter_tempsession 122 | Ticket 123 | FileKey 124 | FileMd5 125 | FileTotalLen 126 | 127 | ScanCodeInfo struct { 128 | ScanType 129 | ScanResult 130 | } 131 | } 132 | 133 | ``` 134 | 135 | * 如果使用其他web框架,例如echo/gin/beego等,则把VerifyURL()放入controller或handler 136 | 137 | ```go 138 | // echo示例 公众号回调接口 139 | func wxApiPost(c echo.Context) error { 140 | ctx := app.VerifyURL(c.Response().Writer, c.Request()) 141 | 142 | // TODO: 这里是其他业务操作 143 | 144 | return nil 145 | } 146 | ``` 147 | 148 | ### 回调回复消息 149 | 150 | 回调回复消息有两种方式: 151 | 152 | * 被动回复,采用XML格式编码返回(Reply); 153 | 154 | * 客服消息,采用json格式编码返回(Send); 155 | 156 | * 两种方式都可先调用`*wechat.Context`对象的New方法创建消息,然后调用Reply()或Send()。 157 | 158 | * 支持链式调用,但Reply()只有第一次有效。 159 | 160 | ```go 161 | ctx.NewText("正在查询中...").Reply() 162 | ctx.NewText("客服消息1").Send().NewText("客服消息2").Send() 163 | ``` 164 | 165 | * 被动回复可直接调用Reply(),表示已收到,然后调用客服消息。 166 | 167 | #### 文本消息 168 | 169 | ```go 170 | ctx.NewText("content") 171 | ``` 172 | 173 | #### 图片/语言/文件消息 174 | 175 | ```go 176 | // mediaID 可通过素材管理-上上传多媒体文件获得 177 | ctx.NewImage("mediaID") 178 | ctx.NewVoice("mediaID") 179 | 180 | // 仅企业号支持 181 | ctx.NewFile("mediaID") 182 | ``` 183 | 184 | #### 视频消息 185 | 186 | ```go 187 | ctx.NewVideo("mediaID", "title", "description") 188 | ``` 189 | 190 | #### 音乐消息 191 | 192 | ```go 193 | ctx.NewMusic("thumbMediaID","title", "description", "musicURL", "hqMusicURL") 194 | ``` 195 | 196 | #### 图文消息 197 | 198 | ```go 199 | // 先创建三个文章 200 | art1 := wechat.NewArticle("拥抱AI,享受工作", 201 | "来自村长的ESAP系统最新技术分享", 202 | "http://ylin.wang/img/esap18-1.png", 203 | "http://ylin.wang/2017/07/13/esap18/") 204 | art2 := wechat.NewArticle("用企业微信代替pda实现扫描入库", 205 | "来自村长的ESAP系统最新技术分享", 206 | "http://ylin.wang/img/esap17-2.png", 207 | "http://ylin.wang/2017/06/23/esap17/") 208 | art3 := wechat.NewArticle("大道至简的哲学", 209 | "来自村长的工作日志", 210 | "http://ylin.wang/img/golang.jpg", 211 | "http://ylin.wang/2017/01/29/log7/") 212 | // 打包成新闻 213 | ctx.NewNews(art1, art2, art3) 214 | ``` 215 | 216 | #### 模板消息 217 | 218 | [相关issue](https://github.com/esap/wechat/issues/20#issue-451068915) 219 | 220 | ```go 221 | tlpdata := map[string]struct { 222 | Value `json:"value"` 223 | Color `json:"color"` 224 | }{ 225 | "first": {Value: "我是渣渣涛", Color: "#173177"}, 226 | "keyword1": {Value: "这是一个你从没有玩过的全新游戏", Color: "#173177"}, 227 | "keyword2": {Value: "只要你跟着我一起试玩一下", Color: "#173177"}, 228 | "keyword3": {Value: "你就会爱上这款游戏", Color: "#4B1515"}, 229 | "remark": {Value: "是兄弟就来砍我", Color: "#071D42"}, 230 | } 231 | ctx.SendTemplate( 232 | ctx.Msg.FromUserName, 233 | "tempid", // 模板ID 234 | c.Request.Host, // 跳转url 235 | ctx.AppId, // 跳转小程序,比url优先 236 | "", // 小程序页面 237 | tlpdata, 238 | ) 239 | ``` 240 | 241 | ## License 242 | 243 | MIT 244 | -------------------------------------------------------------------------------- /util/http.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "encoding/xml" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "mime/multipart" 11 | "net/http" 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | // TimeOut 全局请求超时设置,默认1分钟 19 | var TimeOut time.Duration = 60 * time.Second 20 | 21 | // SetTimeOut 设置全局请求超时 22 | func SetTimeOut(d time.Duration) { 23 | TimeOut = d 24 | } 25 | 26 | // httpClient() 带超时的http.Client 27 | func httpClient() *http.Client { 28 | return &http.Client{Timeout: TimeOut} 29 | } 30 | 31 | // GetJson 发送GET请求解析json 32 | func GetJson(uri string, v interface{}) error { 33 | 34 | r, err := httpClient().Get(uri) 35 | if err != nil { 36 | return err 37 | } 38 | defer r.Body.Close() 39 | return json.NewDecoder(r.Body).Decode(v) 40 | } 41 | 42 | // GetXml 发送GET请求并解析xml 43 | func GetXml(uri string, v interface{}) error { 44 | r, err := httpClient().Get(uri) 45 | if err != nil { 46 | return err 47 | } 48 | defer r.Body.Close() 49 | return xml.NewDecoder(r.Body).Decode(v) 50 | } 51 | 52 | // GetBody 发送GET请求,返回body字节 53 | func GetBody(uri string) ([]byte, error) { 54 | resp, err := httpClient().Get(uri) 55 | if err != nil { 56 | return nil, err 57 | } 58 | defer resp.Body.Close() 59 | 60 | if resp.StatusCode != http.StatusOK { 61 | return nil, fmt.Errorf("http get err: uri=%v , statusCode=%v", uri, resp.StatusCode) 62 | } 63 | return ioutil.ReadAll(resp.Body) 64 | } 65 | 66 | // GetRawBody 发送GET请求,返回body字节 67 | // func GetRawBody(uri string) (io.ReadCloser, error) { 68 | // resp, err := httpClient().Get(uri) 69 | // if err != nil { 70 | // return nil, err 71 | // } 72 | 73 | // if resp.StatusCode != http.StatusOK { 74 | // return nil, fmt.Errorf("http get err: uri=%v , statusCode=%v", uri, resp.StatusCode) 75 | // } 76 | // return resp.Body, nil 77 | // } 78 | 79 | //PostJson 发送Json格式的POST请求 80 | func PostJson(uri string, obj interface{}) ([]byte, error) { 81 | buf := new(bytes.Buffer) 82 | enc := json.NewEncoder(buf) 83 | enc.SetEscapeHTML(false) 84 | err := enc.Encode(obj) 85 | if err != nil { 86 | return nil, err 87 | } 88 | resp, err := httpClient().Post(uri, "application/json;charset=utf-8", buf) 89 | if err != nil { 90 | return nil, err 91 | } 92 | defer resp.Body.Close() 93 | 94 | if resp.StatusCode != http.StatusOK { 95 | return nil, fmt.Errorf("http post error : uri=%v , statusCode=%v", uri, resp.StatusCode) 96 | } 97 | return ioutil.ReadAll(resp.Body) 98 | } 99 | 100 | //PostJsonPtr 发送Json格式的POST请求并解析结果到result指针 101 | func PostJsonPtr(uri string, obj interface{}, result interface{}, contentType ...string) (err error) { 102 | buf := new(bytes.Buffer) 103 | enc := json.NewEncoder(buf) 104 | // enc.SetEscapeHTML(false) 105 | err = enc.Encode(obj) 106 | if err != nil { 107 | return 108 | } 109 | ct := "application/json;charset=utf-8" 110 | if len(contentType) > 0 { 111 | ct = strings.Join(contentType, ";") 112 | } 113 | // fmt.Println("post buf:", buf.String()) // Debug 114 | resp, err := httpClient().Post(uri, ct, buf) 115 | if err != nil { 116 | return err 117 | } 118 | defer resp.Body.Close() 119 | 120 | if resp.StatusCode != http.StatusOK { 121 | return fmt.Errorf("http post error : uri=%v , statusCode=%v", uri, resp.StatusCode) 122 | } 123 | return json.NewDecoder(resp.Body).Decode(result) 124 | } 125 | 126 | //PostXmlPtr 发送Xml格式的POST请求并解析结果到result指针 127 | func PostXmlPtr(uri string, obj interface{}, result interface{}) (err error) { 128 | buf := new(bytes.Buffer) 129 | enc := xml.NewEncoder(buf) 130 | // enc.SetEscapeHTML(false) 131 | err = enc.Encode(obj) 132 | if err != nil { 133 | return 134 | } 135 | 136 | resp, err := httpClient().Post(uri, "application/xml;charset=utf-8", buf) 137 | if err != nil { 138 | return err 139 | } 140 | defer resp.Body.Close() 141 | 142 | if resp.StatusCode != http.StatusOK { 143 | return fmt.Errorf("http post error : uri=%v , statusCode=%v", uri, resp.StatusCode) 144 | } 145 | return xml.NewDecoder(resp.Body).Decode(result) 146 | } 147 | 148 | // PostFile 上传文件 149 | func PostFile(fieldname, filename, uri string) ([]byte, error) { 150 | fields := []MultipartFormField{ 151 | { 152 | IsFile: true, 153 | Fieldname: fieldname, 154 | Filename: filename, 155 | }, 156 | } 157 | return PostMultipartForm(fields, uri) 158 | } 159 | 160 | // GetFile 下载文件 161 | func GetFile(filename, uri string) error { 162 | resp, err := httpClient().Get(uri) 163 | if err != nil { 164 | return err 165 | } 166 | defer resp.Body.Close() 167 | file, err := os.Create(filename) 168 | if err != nil { 169 | return err 170 | } 171 | defer file.Close() 172 | _, err = io.Copy(file, resp.Body) 173 | return err 174 | } 175 | 176 | // MultipartFormField 文件或其他表单数据 177 | type MultipartFormField struct { 178 | IsFile bool 179 | Fieldname string 180 | Value []byte 181 | Filename string 182 | } 183 | 184 | // PostMultipartForm 上传文件或其他表单数据 185 | func PostMultipartForm(fields []MultipartFormField, uri string) (respBody []byte, err error) { 186 | bodyBuf := &bytes.Buffer{} 187 | bodyWriter := multipart.NewWriter(bodyBuf) 188 | 189 | for _, field := range fields { 190 | if field.IsFile { 191 | fileWriter, e := bodyWriter.CreateFormFile(field.Fieldname, filepath.Base(field.Filename)) 192 | if e != nil { 193 | err = fmt.Errorf("error writing to buffer , err=%v", e) 194 | return 195 | } 196 | 197 | fh, e := os.Open(field.Filename) 198 | if e != nil { 199 | err = fmt.Errorf("error opening file , err=%v", e) 200 | return 201 | } 202 | defer fh.Close() 203 | 204 | if _, err = io.Copy(fileWriter, fh); err != nil { 205 | return 206 | } 207 | } else { 208 | partWriter, e := bodyWriter.CreateFormField(field.Fieldname) 209 | if e != nil { 210 | err = e 211 | return 212 | } 213 | valueReader := bytes.NewReader(field.Value) 214 | if _, err = io.Copy(partWriter, valueReader); err != nil { 215 | return 216 | } 217 | } 218 | } 219 | 220 | contentType := bodyWriter.FormDataContentType() 221 | bodyWriter.Close() 222 | 223 | resp, e := httpClient().Post(uri, contentType, bodyBuf) 224 | if e != nil { 225 | err = e 226 | return 227 | } 228 | defer resp.Body.Close() 229 | 230 | if resp.StatusCode != http.StatusOK { 231 | return nil, err 232 | } 233 | return ioutil.ReadAll(resp.Body) 234 | } 235 | -------------------------------------------------------------------------------- /corp_user.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | "github.com/esap/wechat/util" 10 | ) 11 | 12 | const ( 13 | // CorpAPIGetUserOauth 企业微信用户oauth2认证接口 14 | CorpAPIGetUserOauth = CorpAPI + "user/getuserinfo?access_token=%s&code=%s" 15 | 16 | // CorpAPIUserList 企业微信用户列表 17 | CorpAPIUserList = CorpAPI + `user/list?access_token=%s&department_id=1&fetch_child=1` 18 | CorpAPIUserSimpleList = CorpAPI + `user/simplelist?access_token=%s&department_id=1&fetch_child=1` 19 | 20 | // CorpAPIUserGet 企业微信用户接口 21 | CorpAPIUserGet = CorpAPI + "user/get?access_token=%s&userid=%s" 22 | CorpAPIUserAdd = CorpAPI + `user/create?access_token=` 23 | CorpAPIUserUpdate = CorpAPI + `user/update?access_token=` 24 | CorpAPIUserDel = CorpAPI + `user/delete?access_token=` 25 | ) 26 | 27 | // UserOauth 用户鉴权信息 28 | type UserOauth struct { 29 | WxErr 30 | UserId string 31 | DeviceId string 32 | OpenId string 33 | } 34 | 35 | // GetUserOauth 通过code鉴权 36 | func (s *Server) GetUserOauth(code string) (o UserOauth, err error) { 37 | url := fmt.Sprintf(CorpAPIGetUserOauth, s.GetAccessToken(), code) 38 | if err = util.GetJson(url, &o); err != nil { 39 | return 40 | } 41 | err = o.Error() 42 | return 43 | } 44 | 45 | // UserInfo 用户信息 46 | type UserInfo struct { 47 | WxErr `json:"-"` 48 | UserId string `json:"userid"` 49 | Name string `json:"name"` 50 | Alias string `json:"alias"` 51 | Department []int `json:"department"` 52 | IsLeaderInDept []int `json:"is_leader_in_dept,omitempty"` 53 | Order []int `json:"order"` 54 | Dept int `json:"dept"` 55 | DeptName string `json:"deptname"` 56 | Position string `json:"position,omitempty"` 57 | Mobile string `json:"mobile"` 58 | Gender string `json:"gender,omitempty"` 59 | Email string `json:"email,omitempty"` 60 | IsLeader int `json:"isleader,omitempty"` // old attr 61 | AavatarMediaid string `json:"avatar_mediaid,omitempty"` 62 | Enable int `json:"enable,omitempty"` 63 | Telephone string `json:"telephone,omitempty"` 64 | WeixinId string `json:"-"` 65 | Avatar string `json:"avatar,omitempty"` 66 | Status int `json:"-"` 67 | ToInvite bool `json:"to_invite"` 68 | ExtAttr struct { 69 | Attrs []Extattr `json:"attrs"` 70 | } `json:"extattr"` 71 | } 72 | 73 | // Extattr 额外属性 74 | type Extattr struct { 75 | Name string `json:"name"` 76 | Value interface{} `json:"value"` 77 | } 78 | 79 | // UserAdd 添加用户 80 | func (s *Server) UserAdd(user *UserInfo) (err error) { 81 | return s.doUpdate(CorpAPIUserAdd, user) 82 | } 83 | 84 | // UserUpdate 添加用户 85 | func (s *Server) UserUpdate(user *UserInfo) (err error) { 86 | return s.doUpdate(CorpAPIUserUpdate, user) 87 | } 88 | 89 | // UserDelete 删除用户 90 | func (s *Server) UserDelete(user string) (err error) { 91 | e := new(WxErr) 92 | if err = util.GetJson(CorpAPIUserDel+s.GetUserAccessToken()+"&userid="+user, e); err != nil { 93 | return 94 | } 95 | return e.Error() 96 | } 97 | 98 | // GetUserInfo 从企业号通过userId获取用户信息 99 | func (s *Server) GetUserInfo(userId string) (user UserInfo, err error) { 100 | url := fmt.Sprintf(CorpAPIUserGet, s.GetUserAccessToken(), userId) 101 | if err = util.GetJson(url, &user); err != nil { 102 | return 103 | } 104 | err = user.Error() 105 | return 106 | } 107 | 108 | // GetUser 从缓存获取用户信息 109 | func (s *Server) GetUser(userid string) *UserInfo { 110 | for _, v := range s.UserList.UserList { 111 | if v.UserId == userid { 112 | v.DeptName = s.GetDeptName(v.Department[0]) 113 | return &v 114 | } 115 | } 116 | return &UserInfo{} 117 | } 118 | 119 | // GetUserName 通过账号获取用户信息 120 | func (s *Server) GetUserName(userid string) string { 121 | for _, v := range s.UserList.UserList { 122 | if v.UserId == userid { 123 | return v.Name 124 | } 125 | } 126 | return " " 127 | } 128 | 129 | // userList 用户列表 130 | type userList struct { 131 | WxErr 132 | UserList []UserInfo 133 | } 134 | 135 | // FetchUserList 定期获取AccessToken 136 | func (s *Server) FetchUserList() { 137 | i := 0 138 | go func() { 139 | for { 140 | if s.SyncDeptList() == nil { 141 | if s.SyncUserList() != nil && i < 2 { 142 | i++ 143 | Println("尝试再次获取用户列表(", i, ")") 144 | continue 145 | } 146 | i = 0 147 | } 148 | s.SyncTagList() 149 | time.Sleep(FetchDelay) 150 | } 151 | }() 152 | } 153 | 154 | // SyncUserList 获取用户列表 155 | func (s *Server) SyncUserList() (err error) { 156 | s.UserList, err = s.GetUserList() 157 | if err != nil { 158 | log.Printf("[%v::%v]获取用户列表失败:%v", s.AppId, s.AgentId, err) 159 | } 160 | return 161 | } 162 | 163 | // GetUserList 获取用户详情列表 164 | func (s *Server) GetUserList() (u userList, err error) { 165 | url := fmt.Sprintf(CorpAPIUserList, s.GetUserAccessToken()) 166 | if err = util.GetJson(url, &u); err != nil { 167 | return 168 | } 169 | err = u.Error() 170 | return 171 | } 172 | 173 | // GetUserSimpleList 获取用户列表 174 | func (s *Server) GetUserSimpleList() (u userList, err error) { 175 | url := fmt.Sprintf(CorpAPIUserSimpleList, s.GetUserAccessToken()) 176 | if err = util.GetJson(url, &u); err != nil { 177 | return 178 | } 179 | err = u.Error() 180 | return 181 | } 182 | 183 | // GetUserIdList 获取用户列表 184 | func (s *Server) GetUserIdList() (userlist []string) { 185 | userlist = make([]string, 0) 186 | ul, err := s.GetUserSimpleList() 187 | if err != nil { 188 | return 189 | } 190 | for _, v := range ul.UserList { 191 | userlist = append(userlist, v.UserId) 192 | } 193 | return 194 | } 195 | 196 | func (s *Server) doUpdate(uri string, i interface{}) (err error) { 197 | url := uri + s.GetUserAccessToken() 198 | e := new(WxErr) 199 | if err = util.PostJsonPtr(url, i, e); err != nil { 200 | return 201 | } 202 | return e.Error() 203 | } 204 | 205 | // GetGender 获取性别 206 | func GetGender(s string) string { 207 | if s == "1" { 208 | return "男" 209 | } 210 | if s == "2" { 211 | return "女" 212 | } 213 | return "未定义" 214 | } 215 | 216 | var toUserReplacer = strings.NewReplacer(",", "|", ",", "|") 217 | 218 | // GetToUser 获取acl所包含的所有用户ID,结果形式:userId1|userId2|userId3... 219 | func (s *Server) GetToUser(acl interface{}) string { 220 | s1 := strings.TrimSpace(acl.(string)) 221 | if strings.ToLower(s1) == "@all" { 222 | return "@all" 223 | } 224 | arr := strings.Split(toUserReplacer.Replace(s1), "|") 225 | for k, toUser := range arr { 226 | for _, v := range s.UserList.UserList { 227 | if v.Name == toUser { 228 | arr[k] = v.UserId 229 | } 230 | } 231 | } 232 | return strings.Join(arr, "|") 233 | } 234 | 235 | // CheckUserAcl 测试权限,对比user的账号,姓名是否包含于acl 236 | func (s *Server) CheckUserAcl(userid, acl string) bool { 237 | acl = strings.TrimSpace(acl) 238 | if acl == "" { 239 | return false 240 | } 241 | if strings.ToLower(acl) == "@all" { 242 | return true 243 | } 244 | acl = "|" + toUserReplacer.Replace(acl) + "|" 245 | u := s.GetUser(userid) 246 | if u == nil { 247 | return false 248 | } 249 | 250 | return strings.Contains(acl, "|"+u.Name+"|") || strings.Contains(acl, "|"+u.UserId+"|") 251 | } 252 | -------------------------------------------------------------------------------- /type.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | // Type io类型汇总 11 | const ( 12 | TypeText = "text" 13 | TypeImage = "image" 14 | TypeVoice = "voice" 15 | TypeMusic = "music" 16 | TypeVideo = "video" 17 | TypeTextcard = "textcard" // 仅企业微信可用 18 | TypeWxCard = "wxcard" // 仅服务号可用 19 | TypeMarkDown = "markdown" // 仅企业微信可用 20 | TypeTaskCard = "taskcard" // 仅企业微信可用 21 | TypeFile = "file" // 仅企业微信可用 22 | TypeNews = "news" 23 | TypeMpNews = "mpnews" // 仅企业微信可用 24 | TypeEvent = "event" // 订阅或取消订阅 25 | ) 26 | 27 | const ( 28 | EventSubscribe = "subscribe" 29 | EventUnsubscribe = "unsubscribe" 30 | ) 31 | 32 | // WxErr 通用错误 33 | type WxErr struct { 34 | ErrCode int 35 | ErrMsg string 36 | } 37 | 38 | func (w *WxErr) Error() error { 39 | if w.ErrCode != 0 { 40 | return fmt.Errorf("err: errcode=%v , errmsg=%v", w.ErrCode, w.ErrMsg) 41 | } 42 | return nil 43 | } 44 | 45 | // CDATA 标准规范,XML编码成 `` 46 | type CDATA string 47 | 48 | // MarshalXML 自定义xml编码接口,实现讨论: http://stackoverflow.com/q/41951345/7493327 49 | func (c CDATA) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 50 | return e.EncodeElement(struct { 51 | string `xml:",cdata"` 52 | }{string(c)}, start) 53 | } 54 | 55 | // wxResp 响应消息共用字段 56 | // 响应消息被动回复为XML结构,文本类型采用CDATA编码规范 57 | // 响应消息主动发送为json结构,即客服消息 58 | type wxResp struct { 59 | XMLName xml.Name `xml:"xml" json:"-"` 60 | ToUserName CDATA `json:"touser"` 61 | ToParty CDATA `xml:"-" json:"toparty"` // 企业号专用 62 | ToTag CDATA `xml:"-" json:"totag"` // 企业号专用 63 | FromUserName CDATA `json:"-"` 64 | CreateTime int64 `json:"-"` 65 | MsgType CDATA `json:"msgtype"` 66 | AgentId int `xml:"-" json:"agentid"` 67 | Safe int `xml:"-" json:"safe"` 68 | } 69 | 70 | // to字段格式:"userid1|userid2 deptid1|deptid2 tagid1|tagid2" 71 | func (s *Server) newWxResp(msgType, to string) (r wxResp) { 72 | toArr := strings.Split(to, " ") 73 | r = wxResp{ 74 | ToUserName: CDATA(toArr[0]), 75 | MsgType: CDATA(msgType), 76 | AgentId: s.AgentId, 77 | Safe: s.Safe} 78 | if len(toArr) > 1 { 79 | r.ToParty = CDATA(toArr[1]) 80 | } 81 | if len(toArr) > 2 { 82 | r.ToTag = CDATA(toArr[2]) 83 | } 84 | return 85 | } 86 | 87 | // Text 文本消息 88 | type ( 89 | Text struct { 90 | wxResp 91 | content `xml:"Content" json:"text"` 92 | } 93 | 94 | content struct { 95 | Content CDATA `json:"content"` 96 | } 97 | ) 98 | 99 | // NewText Text 文本消息 100 | func (s *Server) NewText(to string, msg ...string) Text { 101 | return Text{ 102 | s.newWxResp(TypeText, to), 103 | content{CDATA(strings.Join(msg, ""))}, 104 | } 105 | } 106 | 107 | // Image 图片消息 108 | type ( 109 | Image struct { 110 | wxResp 111 | Image media `json:"image"` 112 | } 113 | 114 | media struct { 115 | MediaId CDATA `json:"media_id"` 116 | } 117 | ) 118 | 119 | // NewImage Image 消息 120 | func (s *Server) NewImage(to, mediaId string) Image { 121 | return Image{ 122 | s.newWxResp(TypeImage, to), 123 | media{CDATA(mediaId)}, 124 | } 125 | } 126 | 127 | // Voice 语音消息 128 | type Voice struct { 129 | wxResp 130 | Voice media `json:"voice"` 131 | } 132 | 133 | // NewVoice Voice消息 134 | func (s *Server) NewVoice(to, mediaId string) Voice { 135 | return Voice{ 136 | s.newWxResp(TypeVoice, to), 137 | media{CDATA(mediaId)}, 138 | } 139 | } 140 | 141 | // File 文件消息,仅企业号支持 142 | type File struct { 143 | wxResp 144 | File media `json:"file"` 145 | } 146 | 147 | // NewFile File消息 148 | func (s *Server) NewFile(to, mediaId string) File { 149 | return File{ 150 | s.newWxResp(TypeFile, to), 151 | media{CDATA(mediaId)}, 152 | } 153 | } 154 | 155 | // Video 视频消息 156 | type ( 157 | Video struct { 158 | wxResp 159 | Video video `json:"video"` 160 | } 161 | 162 | video struct { 163 | MediaId CDATA `json:"media_id"` 164 | Title CDATA `json:"title"` 165 | Description CDATA `json:"description"` 166 | } 167 | ) 168 | 169 | // NewVideo Video消息 170 | func (s *Server) NewVideo(to, mediaId, title, desc string) Video { 171 | return Video{ 172 | s.newWxResp(TypeVideo, to), 173 | video{CDATA(mediaId), CDATA(title), CDATA(desc)}, 174 | } 175 | } 176 | 177 | // Textcard 卡片消息,仅企业微信客户端有效 178 | type ( 179 | Textcard struct { 180 | wxResp 181 | Textcard textcard `json:"textcard"` 182 | } 183 | 184 | textcard struct { 185 | Title CDATA `json:"title"` 186 | Description CDATA `json:"description"` 187 | Url CDATA `json:"url"` 188 | Btn CDATA `json:"btntxt"` 189 | } 190 | ) 191 | 192 | // NewTextcard Textcard消息 193 | func (s *Server) NewTextcard(to, title, description, url, btntxt string) Textcard { 194 | return Textcard{ 195 | s.newWxResp(TypeTextcard, to), 196 | textcard{CDATA(title), CDATA(description), CDATA(url), CDATA(btntxt)}, 197 | } 198 | } 199 | 200 | // Music 音乐消息,企业微信不支持 201 | type ( 202 | Music struct { 203 | wxResp 204 | Music music `json:"music"` 205 | } 206 | 207 | music struct { 208 | Title CDATA `json:"title"` 209 | Description CDATA `json:"description"` 210 | MusicUrl CDATA `json:"musicurl"` 211 | HQMusicUrl CDATA `json:"hqmusicurl"` 212 | ThumbMediaId CDATA `json:"thumb_media_id"` 213 | } 214 | ) 215 | 216 | // NewMusic Music消息 217 | func (s *Server) NewMusic(to, mediaId, title, desc, musicUrl, qhMusicUrl string) Music { 218 | return Music{ 219 | s.newWxResp(TypeMusic, to), 220 | music{CDATA(title), CDATA(desc), CDATA(musicUrl), CDATA(qhMusicUrl), CDATA(mediaId)}, 221 | } 222 | } 223 | 224 | // News 新闻消息 225 | type News struct { 226 | wxResp 227 | ArticleCount int 228 | Articles struct { 229 | Item []Article `xml:"item" json:"articles"` 230 | } `json:"news"` 231 | } 232 | 233 | // NewNews news消息 234 | func (s *Server) NewNews(to string, arts ...Article) (news News) { 235 | news.wxResp = s.newWxResp(TypeNews, to) 236 | news.ArticleCount = len(arts) 237 | news.Articles.Item = arts 238 | return 239 | } 240 | 241 | // Article 文章 242 | type Article struct { 243 | Title CDATA `json:"title"` 244 | Description CDATA `json:"description"` 245 | PicUrl CDATA `json:"picurl"` 246 | Url CDATA `json:"url"` 247 | } 248 | 249 | // NewArticle 先创建文章,再传给NewNews() 250 | func NewArticle(title, desc, picUrl, url string) Article { 251 | return Article{CDATA(title), CDATA(desc), CDATA(picUrl), CDATA(url)} 252 | } 253 | 254 | type ( 255 | // MpNews 加密新闻消息,仅企业微信支持 256 | MpNews struct { 257 | wxResp 258 | MpNews struct { 259 | Articles []MpArticle `json:"articles"` 260 | } `json:"mpnews"` 261 | } 262 | 263 | // MpNewsId 加密新闻消息(通过mediaId直接发) 264 | MpNewsId struct { 265 | wxResp 266 | MpNews struct { 267 | MediaId CDATA `json:"media_id"` 268 | } `json:"mpnews"` 269 | } 270 | ) 271 | 272 | // NewMpNews 加密新闻mpnews消息(仅企业微信可用) 273 | func (s *Server) NewMpNews(to string, arts ...MpArticle) (news MpNews) { 274 | news.wxResp = s.newWxResp(TypeMpNews, to) 275 | news.MpNews.Articles = arts 276 | return 277 | } 278 | 279 | // NewMpNewsId 加密新闻mpnews消息(仅企业微信可用) 280 | func (s *Server) NewMpNewsId(to string, mediaId string) (news MpNewsId) { 281 | news.wxResp = s.newWxResp(TypeMpNews, to) 282 | news.MpNews.MediaId = CDATA(mediaId) 283 | return 284 | } 285 | 286 | // MpArticle 加密文章 287 | type MpArticle struct { 288 | Title string `json:"title"` 289 | ThumbMediaId string `json:"thumb_media_id"` 290 | Author string `json:"author"` 291 | Url string `json:"content_source_url"` 292 | Content string `json:"content"` 293 | Digest string `json:"digest"` 294 | } 295 | 296 | // NewMpArticle 先创建加密文章,再传给NewMpNews() 297 | func NewMpArticle(title, mediaId, author, url, content, digest string) MpArticle { 298 | return MpArticle{title, mediaId, author, url, content, digest} 299 | } 300 | 301 | // WxCard 卡券 302 | type WxCard struct { 303 | wxResp 304 | WxCard struct { 305 | CardId string `json:"card_id"` 306 | } `json:"wxcard"` 307 | } 308 | 309 | // NewWxCard 卡券消息,服务号可用 310 | func (s *Server) NewWxCard(to, cardId string) (c WxCard) { 311 | c.wxResp = s.newWxResp(TypeWxCard, to) 312 | c.WxCard.CardId = cardId 313 | return 314 | } 315 | 316 | // MarkDown markdown消息,仅企业微信支持,上限2048字节,utf-8编码 317 | type MarkDown struct { 318 | wxResp 319 | MarkDown struct { 320 | Content string `json:"content"` 321 | } `json:"markdown"` 322 | } 323 | 324 | // NewMarkDown markdown消息,企业微信可用 325 | func (s *Server) NewMarkDown(to, content string) (md MarkDown) { 326 | md.wxResp = s.newWxResp(TypeMarkDown, to) 327 | md.MarkDown.Content = content 328 | return 329 | } 330 | 331 | // TaskCard 任务卡片消息,仅企业微信支持,支持一到两个按钮设置 332 | type TaskCard struct { 333 | wxResp 334 | TaskCard struct { 335 | Title string `json:"title"` 336 | Description string `json:"description"` 337 | Url string `json:"url"` 338 | TaskId string `json:"task_id"` 339 | Btn []map[string]interface{} `json:"btn"` 340 | } `json:"taskcard"` 341 | } 342 | 343 | // NewTaskCard 任务卡片消息,企业微信可用 344 | func (s *Server) NewTaskCard(to, Title, Desc, Url, TaskId, Btn string) (tc TaskCard) { 345 | tc.wxResp = s.newWxResp(TypeTaskCard, to) 346 | tc.TaskCard.Title = Title 347 | tc.TaskCard.Description = Desc 348 | tc.TaskCard.Url = Url 349 | tc.TaskCard.TaskId = TaskId 350 | mp := make([]map[string]interface{}, 0) 351 | if Btn != "" { 352 | if err := json.Unmarshal([]byte(Btn), &mp); err != nil { 353 | fmt.Println("create taskcard btn err:", err) 354 | } else { 355 | tc.TaskCard.Btn = mp 356 | } 357 | } 358 | return 359 | } 360 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | // 目前官方未提供golang版,本SDK实现参考了php版官方库 2 | // @woylin, since 2016-1-6 3 | 4 | package wechat 5 | 6 | import ( 7 | "bytes" 8 | "encoding/base64" 9 | "encoding/binary" 10 | "encoding/json" 11 | "encoding/xml" 12 | "errors" 13 | "fmt" 14 | "io" 15 | "log" 16 | "net/http" 17 | "sync" 18 | 19 | "github.com/esap/wechat/util" 20 | ) 21 | 22 | // WXAPI 订阅号,服务号,小程序接口,相关接口常量统一以此开头 23 | const ( 24 | WXAPI = "https://api.weixin.qq.com/cgi-bin/" 25 | WXAPIToken = WXAPI + "token?grant_type=client_credential&appid=%s&secret=%s" 26 | WXAPIMsg = WXAPI + "message/custom/send?access_token=" 27 | WXAPIJsapi = WXAPI + "get_jsapi_ticket?access_token=" 28 | ) 29 | 30 | // CorpAPI 企业微信接口,相关接口常量统一以此开头 31 | const ( 32 | CorpAPI = "https://qyapi.weixin.qq.com/cgi-bin/" 33 | CorpAPIToken = CorpAPI + "gettoken?corpid=%s&corpsecret=%s" 34 | CorpAPIMsg = CorpAPI + "message/send?access_token=" 35 | CorpAPIJsapi = CorpAPI + "get_jsapi_ticket?access_token=" 36 | ) 37 | 38 | const ( 39 | DataFormatXML = "XML" // default format 40 | DataFormatJSON = "JSON" 41 | ) 42 | 43 | var ( 44 | // Debug is a flag to Println() 45 | Debug bool = false 46 | 47 | // UserServerMap 通讯录实例集,用于企业微信 48 | UserServerMap = make(map[string]*Server) 49 | ) 50 | 51 | // WxConfig 配置,用于New() 52 | type WxConfig struct { 53 | AppId string 54 | Token string 55 | Secret string 56 | EncodingAESKey string 57 | AgentId int 58 | MchId string 59 | AppName string 60 | AppType int // 0-公众号,小程序; 1-企业微信 61 | ExternalTokenHandler func(string, ...string) *AccessToken // 外部token获取函数 62 | DataFormat string // 数据格式:JSON、XML 63 | } 64 | 65 | // Server 微信服务容器 66 | type Server struct { 67 | AppId string 68 | MchId string // 商户id,用于微信支付 69 | AgentId int 70 | Secret string 71 | 72 | Token string 73 | EncodingAESKey string 74 | 75 | AppName string // 唯一标识,主要用于企业微信多应用区分 76 | AppType int // 0-公众号,小程序; 1-企业微信 77 | AesKey []byte // 解密的AesKey 78 | SafeMode bool 79 | EntMode bool 80 | DataFormat string // 通讯数据格式:JSON、XML 81 | 82 | RootUrl string 83 | MsgUrl string 84 | TokenUrl string 85 | JsApi string 86 | 87 | Safe int 88 | accessToken *AccessToken 89 | ticket *Ticket 90 | UserList userList 91 | DeptList DeptList 92 | TagList TagList 93 | MsgQueue chan interface{} 94 | sync.Mutex // accessToken读取锁 95 | 96 | ExternalTokenHandler func(appId string, appName ...string) *AccessToken // 通过外部方法统一获取access token ,避免集群情况下token失效 97 | } 98 | 99 | func Set(wc *WxConfig) *Server { 100 | return &Server{ 101 | AppId: wc.AppId, 102 | Secret: wc.Secret, 103 | AgentId: wc.AgentId, 104 | MchId: wc.MchId, 105 | AppName: wc.AppName, 106 | AppType: wc.AppType, 107 | Token: wc.Token, 108 | EncodingAESKey: wc.EncodingAESKey, 109 | ExternalTokenHandler: wc.ExternalTokenHandler, 110 | DataFormat: wc.DataFormat, 111 | } 112 | } 113 | 114 | // New 微信服务容器 115 | func New(wc *WxConfig) *Server { 116 | s := Set(wc) 117 | 118 | // Set XML as default when data format is no setting. 119 | if s.DataFormat == "" { 120 | s.DataFormat = DataFormatXML 121 | } 122 | 123 | switch wc.AppType { 124 | case 1: 125 | s.RootUrl = CorpAPI 126 | s.MsgUrl = CorpAPIMsg 127 | s.TokenUrl = CorpAPIToken 128 | s.JsApi = CorpAPIJsapi 129 | s.EntMode = true 130 | default: 131 | s.RootUrl = WXAPI 132 | s.MsgUrl = WXAPIMsg 133 | s.TokenUrl = WXAPIToken 134 | s.JsApi = WXAPIJsapi 135 | } 136 | 137 | err := s.getAccessToken() 138 | if err != nil { 139 | log.Println("getAccessToken err:", err) 140 | } 141 | 142 | // 存在EncodingAESKey则开启加密安全模式 143 | if len(s.EncodingAESKey) > 0 && s.EncodingAESKey != "" { 144 | s.SafeMode = true 145 | if s.AesKey, err = base64.StdEncoding.DecodeString(s.EncodingAESKey + "="); err != nil { 146 | log.Println("AesKey解析错误:", err) 147 | } 148 | Println("启用加密模式") 149 | } 150 | 151 | if s.AgentId == 9999999 { 152 | UserServerMap[s.AppId] = s // 这里约定传入企业微信通讯录secret时,agentId=9999999 153 | } 154 | 155 | if s.AppType == 1 { 156 | s.FetchUserList() 157 | } 158 | 159 | s.MsgQueue = make(chan interface{}, 1000) 160 | go func() { 161 | for { 162 | msg := <-s.MsgQueue 163 | e := s.SendMsg(msg) 164 | if e.ErrCode != 0 { 165 | log.Println("MsgSend err:", e.ErrMsg) 166 | } 167 | } 168 | }() 169 | 170 | return s 171 | } 172 | 173 | // 依据交互数据类型,从请求体中解析消息体 174 | func (s *Server) DecodeMsgFromRequest(r *http.Request, msg interface{}) error { 175 | if s.DataFormat == DataFormatXML { 176 | return xml.NewDecoder(r.Body).Decode(msg) 177 | } else if s.DataFormat == DataFormatJSON { 178 | return json.NewDecoder(r.Body).Decode(msg) 179 | } else { 180 | panic(fmt.Errorf("invalid DataFormat:%s", s.DataFormat)) 181 | } 182 | } 183 | 184 | // 依据交互数据类型,从字符串中解析消息体 185 | func (s *Server) DecodeMsgFromString(str string, msg interface{}) error { 186 | if s.DataFormat == DataFormatXML { 187 | return xml.Unmarshal([]byte(str), msg) 188 | } else if s.DataFormat == DataFormatJSON { 189 | return json.Unmarshal([]byte(str), msg) 190 | } else { 191 | panic(fmt.Errorf("invalid DataFormat:%s", s.DataFormat)) 192 | } 193 | } 194 | 195 | // VerifyURL 验证URL,验证成功则返回标准请求载体(Msg已解密) 196 | func (s *Server) VerifyURL(w http.ResponseWriter, r *http.Request) (ctx *Context) { 197 | Println(r.Method, "|", r.URL.String()) 198 | ctx = &Context{ 199 | Server: s, 200 | Writer: w, 201 | Request: r, 202 | Timestamp: r.FormValue("timestamp"), 203 | Nonce: r.FormValue("nonce"), 204 | Msg: new(WxMsg), 205 | } 206 | 207 | // 明文模式可直接解析body->消息 208 | if !s.SafeMode && r.Method == "POST" { 209 | if err := s.DecodeMsgFromRequest(r, ctx.Msg); err != nil { 210 | Println("Decode WxMsg err:", err) 211 | } 212 | } 213 | 214 | // 密文模式,消息在body.Encrypt 215 | echostr := r.FormValue("echostr") 216 | if s.SafeMode && r.Method == "POST" { 217 | msgEnc := new(WxMsgEnc) 218 | if err := s.DecodeMsgFromRequest(r, msgEnc); err != nil { 219 | Println("Decode MsgEnc err:", err) 220 | } 221 | echostr = msgEnc.Encrypt 222 | } 223 | 224 | // 验证signature 225 | signature := r.FormValue("signature") 226 | if signature == "" { 227 | signature = r.FormValue("msg_signature") 228 | } 229 | if s.EntMode && signature != util.SortSha1(s.Token, ctx.Timestamp, ctx.Nonce, echostr) { 230 | log.Println("Signature验证错误!(企业微信)", s.Token, ctx.Timestamp, ctx.Nonce, echostr) 231 | return 232 | } else if !s.EntMode && signature != util.SortSha1(s.Token, ctx.Timestamp, ctx.Nonce) { 233 | log.Println("Signature验证错误!(公众号)", util.SortSha1(s.Token, ctx.Timestamp, ctx.Nonce)) 234 | return 235 | } 236 | 237 | // 密文模式,解密echostr中的消息 238 | if s.EntMode || (s.SafeMode && r.Method == "POST") { 239 | var err error 240 | echostr, err = s.DecryptMsg(echostr) 241 | if err != nil { 242 | log.Println("DecryptMsg error:", err) 243 | return 244 | } 245 | } 246 | 247 | if r.Method == "GET" { 248 | Println("api echostr:", echostr) 249 | w.Write([]byte(echostr)) 250 | return 251 | } 252 | 253 | Println("Wechat ==>", echostr) 254 | if s.SafeMode { 255 | if err := s.DecodeMsgFromString(echostr, ctx.Msg); err != nil { 256 | log.Println("Msg parse err:", err) 257 | } 258 | } 259 | 260 | return 261 | } 262 | 263 | // DecryptMsg 解密微信消息,密文string->base64Dec->aesDec->去除头部随机字串 264 | // AES加密的buf由16个字节的随机字符串、4个字节的msg_len(网络字节序)、msg和$AppId组成 265 | func (s *Server) DecryptMsg(msg string) (string, error) { 266 | aesMsg, err := base64.StdEncoding.DecodeString(msg) 267 | if err != nil { 268 | return "", err 269 | } 270 | 271 | buf, err := util.AesDecrypt(aesMsg, s.AesKey) 272 | if err != nil { 273 | return "", err 274 | } 275 | 276 | var msgLen int32 277 | binary.Read(bytes.NewBuffer(buf[16:20]), binary.BigEndian, &msgLen) 278 | if msgLen < 0 || msgLen > 1000000 { 279 | return "", errors.New("AesKey is invalid") 280 | } 281 | if string(buf[20+msgLen:]) != s.AppId { 282 | return "", errors.New("AppId is invalid") 283 | } 284 | return string(buf[20 : 20+msgLen]), nil 285 | } 286 | 287 | // wxRespEnc 加密回复体 288 | type wxRespEnc struct { 289 | XMLName xml.Name `xml:"xml"` 290 | Encrypt CDATA 291 | MsgSignature CDATA 292 | TimeStamp string 293 | Nonce CDATA 294 | } 295 | 296 | // EncryptMsg 加密普通回复(AES-CBC),打包成xml格式 297 | // AES加密的buf由16个字节的随机字符串、4个字节的msg_len(网络字节序)、msg和$AppId组成 298 | func (s *Server) EncryptMsg(msg []byte, timeStamp, nonce string) (re *wxRespEnc, err error) { 299 | buf := new(bytes.Buffer) 300 | err = binary.Write(buf, binary.BigEndian, int32(len(msg))) 301 | if err != nil { 302 | return 303 | } 304 | l := buf.Bytes() 305 | 306 | rd := []byte(util.GetRandomString(16)) 307 | 308 | plain := bytes.Join([][]byte{rd, l, msg, []byte(s.AppId)}, nil) 309 | ae, _ := util.AesEncrypt(plain, s.AesKey) 310 | encMsg := base64.StdEncoding.EncodeToString(ae) 311 | re = &wxRespEnc{ 312 | Encrypt: CDATA(encMsg), 313 | MsgSignature: CDATA(util.SortSha1(s.Token, timeStamp, nonce, encMsg)), 314 | TimeStamp: timeStamp, 315 | Nonce: CDATA(nonce), 316 | } 317 | return 318 | } 319 | 320 | // SetLog 设置log 321 | func SetLog(l io.Writer) { 322 | log.SetOutput(l) 323 | } 324 | 325 | // SafeOpen 设置密保模式 326 | func (s *Server) SafeOpen() { 327 | s.Safe = 1 328 | } 329 | 330 | // SafeClose 关闭密保模式 331 | func (s *Server) SafeClose() { 332 | s.Safe = 0 333 | } 334 | 335 | // Println Debug输出 336 | func Println(v ...interface{}) { 337 | if Debug { 338 | log.Println(v...) 339 | } 340 | } 341 | 342 | // Printf Debug输出 343 | func Printf(s string, v ...interface{}) { 344 | if Debug { 345 | log.Printf(s, v...) 346 | } 347 | } 348 | --------------------------------------------------------------------------------