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  }