github.com/fastwego/offiaccount@v1.0.1/client.go (about) 1 // Copyright 2020 FastWeGo 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package offiaccount 16 17 import ( 18 "encoding/json" 19 "errors" 20 "fmt" 21 "io" 22 "io/ioutil" 23 "net/http" 24 "net/url" 25 "strings" 26 "sync" 27 "time" 28 ) 29 30 var ( 31 WXServerUrl = "https://api.weixin.qq.com" // 微信 api 服务器地址 32 UserAgent = "fastwego/offiaccount" 33 ErrorAccessTokenExpire = errors.New("access token expire") 34 ErrorSystemBusy = errors.New("system busy") 35 ) 36 37 /* 38 HttpClient 用于向公众号接口发送请求 39 */ 40 type Client struct { 41 Ctx *OffiAccount 42 } 43 44 // HTTPGet GET 请求 45 func (client *Client) HTTPGet(uri string) (resp []byte, err error) { 46 newUrl, err := client.applyAccessToken(uri) 47 if err != nil { 48 return 49 } 50 51 req, err := http.NewRequest(http.MethodGet, WXServerUrl+newUrl, nil) 52 if err != nil { 53 return 54 } 55 56 return client.httpDo(req) 57 } 58 59 //HTTPPost POST 请求 60 func (client *Client) HTTPPost(uri string, payload io.Reader, contentType string) (resp []byte, err error) { 61 newUrl, err := client.applyAccessToken(uri) 62 if err != nil { 63 return 64 } 65 66 req, err := http.NewRequest(http.MethodPost, WXServerUrl+newUrl, payload) 67 if err != nil { 68 return 69 } 70 71 req.Header.Add("Content-Type", contentType) 72 73 return client.httpDo(req) 74 } 75 76 //httpDo 执行 请求 77 func (client *Client) httpDo(req *http.Request) (resp []byte, err error) { 78 req.Header.Add("User-Agent", UserAgent) 79 80 if client.Ctx.Logger != nil { 81 client.Ctx.Logger.Printf("%s %s Headers %v", req.Method, req.URL.String(), req.Header) 82 } 83 84 response, err := http.DefaultClient.Do(req) 85 if err != nil { 86 return 87 } 88 defer response.Body.Close() 89 90 resp, err = responseFilter(response) 91 92 // 发现 access_token 过期 93 if err == ErrorAccessTokenExpire { 94 95 // 主动 通知 access_token 过期 96 err = client.Ctx.AccessToken.NoticeAccessTokenExpireHandler(client.Ctx) 97 if err != nil { 98 return 99 } 100 101 // 通知到位后 access_token 会被刷新,那么可以 retry 了 102 var accessToken string 103 accessToken, err = client.Ctx.AccessToken.GetAccessTokenHandler(client.Ctx) 104 if err != nil { 105 return 106 } 107 108 // 换新 109 q := req.URL.Query() 110 q.Set("access_token", accessToken) 111 req.URL.RawQuery = q.Encode() 112 113 if client.Ctx.Logger != nil { 114 client.Ctx.Logger.Printf("%v retry %s %s Headers %v", ErrorAccessTokenExpire, req.Method, req.URL.String(), req.Header) 115 } 116 117 response, err = http.DefaultClient.Do(req) 118 if err != nil { 119 return 120 } 121 defer response.Body.Close() 122 123 resp, err = responseFilter(response) 124 } 125 126 // -1 系统繁忙,此时请开发者稍候再试 127 // 重试一次 128 if err == ErrorSystemBusy { 129 130 if client.Ctx.Logger != nil { 131 client.Ctx.Logger.Printf("%v : retry %s %s Headers %v", ErrorSystemBusy, req.Method, req.URL.String(), req.Header) 132 } 133 134 response, err = http.DefaultClient.Do(req) 135 if err != nil { 136 return 137 } 138 defer response.Body.Close() 139 140 resp, err = responseFilter(response) 141 } 142 143 return 144 } 145 146 /* 147 在请求地址上附加上 access_token 148 */ 149 func (client *Client) applyAccessToken(oldUrl string) (newUrl string, err error) { 150 accessToken, err := client.Ctx.AccessToken.GetAccessTokenHandler(client.Ctx) 151 if err != nil { 152 return 153 } 154 if strings.Contains(oldUrl, "?") { 155 newUrl = oldUrl + "&access_token=" + accessToken 156 } else { 157 newUrl = oldUrl + "?access_token=" + accessToken 158 } 159 return 160 } 161 162 /* 163 筛查微信 api 服务器响应,判断以下错误: 164 165 - http 状态码 不为 200 166 167 - 接口响应错误码 errcode 不为 0 168 */ 169 func responseFilter(response *http.Response) (resp []byte, err error) { 170 if response.StatusCode != http.StatusOK { 171 err = fmt.Errorf("Status %s", response.Status) 172 return 173 } 174 175 resp, err = ioutil.ReadAll(response.Body) 176 if err != nil { 177 return 178 } 179 180 errorResponse := struct { 181 Errcode int64 `json:"errcode"` 182 Errmsg string `json:"errmsg"` 183 }{} 184 err = json.Unmarshal(resp, &errorResponse) 185 if err != nil { 186 return 187 } 188 189 // 40001(覆盖刷新超过5min后,使用旧 access_token 报错) 获取 access_token 时 AppSecret 错误,或者 access_token 无效。请开发者认真比对 AppSecret 的正确性,或查看是否正在为恰当的公众号调用接口 190 // 42001(超过 7200s 后 报错) - access_token 超时,请检查 access_token 的有效期,请参考基础支持 - 获取 access_token 中,对 access_token 的详细机制说明 191 if errorResponse.Errcode == 42001 || errorResponse.Errcode == 40001 { 192 err = ErrorAccessTokenExpire 193 return 194 } 195 196 // -1 系统繁忙,此时请开发者稍候再试 197 if errorResponse.Errcode == -1 { 198 err = ErrorSystemBusy 199 return 200 } 201 202 if errorResponse.Errcode != 0 { 203 err = errors.New(string(resp)) 204 return 205 } 206 return 207 } 208 209 // 防止多个 goroutine 并发刷新冲突 210 var refreshAccessTokenLock sync.Mutex 211 212 /* 213 从 公众号实例 的 AccessToken 管理器 获取 access_token 214 215 如果没有 access_token 或者 已过期,那么刷新 216 217 获得新的 access_token 后 过期时间设置为 0.9 * expiresIn 提供一定冗余 218 */ 219 func GetAccessToken(ctx *OffiAccount) (accessToken string, err error) { 220 accessToken, err = ctx.AccessToken.Cache.Fetch(ctx.Config.Appid) 221 if accessToken != "" { 222 return 223 } 224 225 refreshAccessTokenLock.Lock() 226 defer refreshAccessTokenLock.Unlock() 227 228 accessToken, err = ctx.AccessToken.Cache.Fetch(ctx.Config.Appid) 229 if accessToken != "" { 230 return 231 } 232 233 accessToken, expiresIn, err := refreshAccessTokenFromWXServer(ctx.Config.Appid, ctx.Config.Secret) 234 if err != nil { 235 return 236 } 237 238 // 本地缓存 access_token 239 d := time.Duration(expiresIn) * time.Second 240 _ = ctx.AccessToken.Cache.Save(ctx.Config.Appid, accessToken, d) 241 242 if ctx.Logger != nil { 243 ctx.Logger.Printf("%s %s %d\n", "refreshAccessTokenFromWXServer", accessToken, expiresIn) 244 } 245 246 return 247 } 248 249 /* 250 NoticeAccessTokenExpire 只需将本地存储的 access_token 删除,即完成了 access_token 已过期的 主动通知 251 252 retry 请求的时候,会发现本地没有 access_token ,从而触发refresh 253 */ 254 func NoticeAccessTokenExpire(ctx *OffiAccount) (err error) { 255 if ctx.Logger != nil { 256 ctx.Logger.Println("NoticeAccessTokenExpire") 257 } 258 259 err = ctx.AccessToken.Cache.Delete(ctx.Config.Appid) 260 return 261 } 262 263 /* 264 从微信服务器获取新的 AccessToken 265 266 See: https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html 267 */ 268 func refreshAccessTokenFromWXServer(appid string, secret string) (accessToken string, expiresIn int, err error) { 269 params := url.Values{} 270 params.Add("appid", appid) 271 params.Add("secret", secret) 272 params.Add("grant_type", "client_credential") 273 url := WXServerUrl + "/cgi-bin/token?" + params.Encode() 274 275 response, err := http.Get(url) 276 if err != nil { 277 return 278 } 279 280 defer response.Body.Close() 281 if response.StatusCode != http.StatusOK { 282 err = fmt.Errorf("GET %s RETURN %s", url, response.Status) 283 return 284 } 285 286 resp, err := ioutil.ReadAll(response.Body) 287 if err != nil { 288 return 289 } 290 291 var result = struct { 292 AccessToken string `json:"access_token"` 293 ExpiresIn int `json:"expires_in"` 294 Errcode float64 `json:"errcode"` 295 Errmsg string `json:"errmsg"` 296 }{} 297 298 err = json.Unmarshal(resp, &result) 299 if err != nil { 300 err = fmt.Errorf("Unmarshal error %s", string(resp)) 301 return 302 } 303 304 if result.AccessToken == "" { 305 err = fmt.Errorf("%s", string(resp)) 306 return 307 } 308 309 return result.AccessToken, result.ExpiresIn, nil 310 }