소스 검색

merge master

silenceper 6 년 전
부모
커밋
688bca7436
22개의 변경된 파일1468개의 추가작업 그리고 61개의 파일을 삭제
  1. 2 2
      .travis.yml
  2. 111 6
      README.md
  3. 74 0
      cache/memory.go
  4. 11 0
      context/access_token.go
  5. 30 0
      context/access_token_test.go
  6. 221 0
      context/component_access_token.go
  7. 19 0
      context/component_test.go
  8. 3 0
      context/context.go
  9. 1 1
      material/material.go
  10. 305 0
      miniprogram/analysis.go
  11. 93 0
      miniprogram/decrypt.go
  12. 17 0
      miniprogram/miniprogram.go
  13. 91 0
      miniprogram/qrcode.go
  14. 40 0
      miniprogram/sns.go
  15. 16 5
      oauth/oauth.go
  16. 8 8
      pay/pay.go
  17. 109 0
      pay/refund.go
  18. 122 0
      qr/qr.go
  19. 64 39
      user/user.go
  20. 95 0
      util/http.go
  21. 24 0
      vendor/vendor.json
  22. 12 0
      wechat.go

+ 2 - 2
.travis.yml

@@ -1,9 +1,9 @@
 language: go
 
 go:
+  - 1.12.x
   - 1.11.x
   - 1.10.x
-  - 1.9.x
 
 services:
   - memcached
@@ -11,7 +11,7 @@ services:
 
 before_script:
   - GO_FILES=$(find . -iname '*.go' -type f | grep -v /vendor/)
-  - go get github.com/golang/lint/golint
+  - go get golang.org/x/lint/golint
     
 script:
   - go test -v -race ./...

+ 111 - 6
README.md

@@ -96,6 +96,7 @@ Cache主要用来保存全局access_token以及js-sdk中的ticket:
 		- 检验access_token是否有效
 	- 获取js-sdk配置
 - [素材管理](#素材管理)
+- [小程序开发](#小程序开发)
 
 ## 消息管理
 
@@ -335,14 +336,14 @@ Url	:点击图文消息跳转链接
 
 ## 自定义菜单
 
-通过` wechat.GetMenu(req, writer)`获取menu的实例
+通过` wechat.GetMenu()`获取menu的实例
 
 ### 自定义菜单创建接口
 
 以下是一个创建二级菜单的例子
 
 ```go
-mu := wc.GetMenu(c.Request, c.Writer)
+mu := wc.GetMenu()
 
 buttons := make([]*menu.Button, 1)
 btn := new(menu.Button)
@@ -402,7 +403,7 @@ func (btn *Button) SetViewLimitedButton(name, mediaID string) {
 ### 自定义菜单查询接口
 
 ```go
-mu := wc.GetMenu(c.Request, c.Writer)
+mu := wc.GetMenu()
 resMenu,err:=mu.GetMenu()
 ```
 >返回结果 resMenu 结构参考 ./menu/menu.go 中ResMenu 结构体
@@ -410,7 +411,7 @@ resMenu,err:=mu.GetMenu()
 ### 自定义菜单删除接口
 
 ```go
-mu := wc.GetMenu(c.Request, c.Writer)
+mu := wc.GetMenu()
 err:=mu.DeleteMenu()
 ```
 
@@ -458,7 +459,7 @@ func (menu *Menu) GetCurrentSelfMenuInfo() (resSelfMenuInfo ResSelfMenuInfo, err
 **1.发起授权**
 
 ```go
-oauth := wc.GetOauth(c.Request, c.Writer)
+oauth := wc.GetOauth()
 err := oauth.Redirect("跳转的绝对地址", "snsapi_userinfo", "123dd123")
 if err != nil {
 	fmt.Println(err)
@@ -505,7 +506,7 @@ func (oauth *Oauth) CheckAccessToken(accessToken, openID string) (b bool, err er
 ### 获取js-sdk配置
 
 ```go
-js := wc.GetJs(c.Request, c.Writer)
+js := wc.GetJs()
 cfg, err := js.GetConfig("传入需要的调用js-sdk的url地址")
 if err != nil {
 	fmt.Println(err)
@@ -529,6 +530,110 @@ type Config struct {
 
 [素材管理API](https://godoc.org/github.com/silenceper/wechat/material#Material)
 
+## 小程序开发
+
+获取小程序操作对象
+
+``` go
+memCache=cache.NewMemcache("127.0.0.1:11211")
+config := &wechat.Config{
+	AppID:     "xxx",
+	AppSecret: "xxx",
+	Cache:     memCache=cache.NewMemcache("127.0.0.1:11211"),
+}
+wc := wechat.NewWechat(config)
+
+wxa := wc.GetMiniProgram()
+```
+
+### 小程序登录凭证校验
+
+``` go
+func (wxa *MiniProgram) Code2Session(jsCode string) (result ResCode2Session, err error)
+```
+
+### 小程序数据统计
+
+**获取用户访问小程序日留存**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisDailyRetain(beginDate, endDate string) (result ResAnalysisRetain, err error)
+```
+
+**获取用户访问小程序月留存**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisMonthlyRetain(beginDate, endDate string) (result ResAnalysisRetain, err error)
+```
+
+**获取用户访问小程序周留存**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisWeeklyRetain(beginDate, endDate string) (result ResAnalysisRetain, err error)
+```
+
+**获取用户访问小程序数据概况**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisDailySummary(beginDate, endDate string) (result ResAnalysisDailySummary, err error)
+```
+
+**获取用户访问小程序数据日趋势**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisDailyVisitTrend(beginDate, endDate string) (result ResAnalysisVisitTrend, err error)
+```
+
+**获取用户访问小程序数据月趋势**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisMonthlyVisitTrend(beginDate, endDate string) (result ResAnalysisVisitTrend, err error)
+```
+
+**获取用户访问小程序数据周趋势**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisWeeklyVisitTrend(beginDate, endDate string) (result ResAnalysisVisitTrend, err error)
+```
+
+**获取小程序新增或活跃用户的画像分布数据**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisUserPortrait(beginDate, endDate string) (result ResAnalysisUserPortrait, err error)
+```
+
+**获取用户小程序访问分布数据**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisVisitDistribution(beginDate, endDate string) (result ResAnalysisVisitDistribution, err error)
+```
+
+**获取小程序页面访问数据**
+
+``` go
+func (wxa *MiniProgram) GetAnalysisVisitPage(beginDate, endDate string) (result ResAnalysisVisitPage, err error)
+```
+
+### 小程序二维码生成
+
+**获取小程序二维码,适用于需要的码数量较少的业务场景**
+
+``` go
+func (wxa *MiniProgram) CreateWXAQRCode(coderParams QRCoder) (response []byte, err error)
+```
+
+**获取小程序码,适用于需要的码数量较少的业务场景**
+
+``` go
+func (wxa *MiniProgram) GetWXACode(coderParams QRCoder) (response []byte, err error)
+```
+
+**获取小程序码,适用于需要的码数量极多的业务场景**
+
+``` go
+func (wxa *MiniProgram) GetWXACodeUnlimit(coderParams QRCoder) (response []byte, err error)
+```
+
 
 更多API使用请参考 godoc :
 [https://godoc.org/github.com/silenceper/wechat](https://godoc.org/github.com/silenceper/wechat)

+ 74 - 0
cache/memory.go

@@ -0,0 +1,74 @@
+package cache
+
+import (
+	"sync"
+	"time"
+)
+
+//Memory struct contains *memcache.Client
+type Memory struct {
+	sync.Mutex
+
+	data map[string]*data
+}
+
+type data struct {
+	Data    interface{}
+	Expired time.Time
+}
+
+//NewMemory create new memcache
+func NewMemory() *Memory {
+	return &Memory{
+		data: map[string]*data{},
+	}
+}
+
+//Get return cached value
+func (mem *Memory) Get(key string) interface{} {
+	if ret, ok := mem.data[key]; ok {
+		if ret.Expired.Before(time.Now()) {
+			mem.deleteKey(key)
+			return nil
+		}
+		return ret.Data
+	}
+	return nil
+}
+
+// IsExist check value exists in memcache.
+func (mem *Memory) IsExist(key string) bool {
+	if ret, ok := mem.data[key]; ok {
+		if ret.Expired.Before(time.Now()) {
+			mem.deleteKey(key)
+			return false
+		}
+		return true
+	}
+	return false
+}
+
+//Set cached value with key and expire time.
+func (mem *Memory) Set(key string, val interface{}, timeout time.Duration) (err error) {
+	mem.Lock()
+	defer mem.Unlock()
+
+	mem.data[key] = &data{
+		Data:    val,
+		Expired: time.Now().Add(timeout),
+	}
+	return nil
+}
+
+//Delete delete value in memcache.
+func (mem *Memory) Delete(key string) error {
+	return mem.deleteKey(key)
+}
+
+// deleteKey
+func (mem *Memory) deleteKey(key string) error {
+	mem.Lock()
+	defer mem.Unlock()
+	delete(mem.data, key)
+	return nil
+}

+ 11 - 0
context/access_token.go

@@ -22,16 +22,27 @@ type ResAccessToken struct {
 	ExpiresIn   int64  `json:"expires_in"`
 }
 
+//GetAccessTokenFunc 获取 access token 的函数签名
+type GetAccessTokenFunc func(ctx *Context) (accessToken string, err error)
+
 //SetAccessTokenLock 设置读写锁(一个appID一个读写锁)
 func (ctx *Context) SetAccessTokenLock(l *sync.RWMutex) {
 	ctx.accessTokenLock = l
 }
 
+//SetGetAccessTokenFunc 设置自定义获取accessToken的方式, 需要自己实现缓存
+func (ctx *Context) SetGetAccessTokenFunc(f GetAccessTokenFunc) {
+	ctx.accessTokenFunc = f
+}
+
 //GetAccessToken 获取access_token
 func (ctx *Context) GetAccessToken() (accessToken string, err error) {
 	ctx.accessTokenLock.Lock()
 	defer ctx.accessTokenLock.Unlock()
 
+	if ctx.accessTokenFunc != nil {
+		return ctx.accessTokenFunc(ctx)
+	}
 	accessTokenCacheKey := fmt.Sprintf("access_token_%s", ctx.AppID)
 	val := ctx.Cache.Get(accessTokenCacheKey)
 	if val != nil {

+ 30 - 0
context/access_token_test.go

@@ -0,0 +1,30 @@
+package context
+
+import (
+	"sync"
+	"testing"
+)
+
+func TestContext_SetCustomAccessTokenFunc(t *testing.T) {
+	ctx := Context{
+		accessTokenLock: new(sync.RWMutex),
+	}
+	f := func(ctx *Context) (accessToken string, err error) {
+		return "fake token", nil
+	}
+	ctx.SetGetAccessTokenFunc(f)
+	res, err := ctx.GetAccessToken()
+	if res != "fake token" || err != nil {
+		t.Error("expect fake token but error")
+	}
+}
+
+func TestContext_NoSetCustomAccessTokenFunc(t *testing.T) {
+	ctx := Context{
+		accessTokenLock: new(sync.RWMutex),
+	}
+
+	if ctx.accessTokenFunc != nil {
+		t.Error("error accessTokenFunc")
+	}
+}

+ 221 - 0
context/component_access_token.go

@@ -0,0 +1,221 @@
+package context
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"github.com/silenceper/wechat/util"
+)
+
+const (
+	componentAccessTokenURL = "https://api.weixin.qq.com/cgi-bin/component/api_component_token"
+	getPreCodeURL           = "https://api.weixin.qq.com/cgi-bin/component/api_create_preauthcode?component_access_token=%s"
+	queryAuthURL            = "https://api.weixin.qq.com/cgi-bin/component/api_query_auth?component_access_token=%s"
+	refreshTokenURL         = "https://api.weixin.qq.com/cgi-bin/component/api_authorizer_token?component_access_token=%s"
+	getComponentInfoURL     = "https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_info?component_access_token=%s"
+	getComponentConfigURL   = "https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_option?component_access_token=%s"
+)
+
+// ComponentAccessToken 第三方平台
+type ComponentAccessToken struct {
+	AccessToken string `json:"component_access_token"`
+	ExpiresIn   int64  `json:"expires_in"`
+}
+
+// GetComponentAccessToken 获取 ComponentAccessToken
+func (ctx *Context) GetComponentAccessToken() (string, error) {
+	accessTokenCacheKey := fmt.Sprintf("component_access_token_%s", ctx.AppID)
+	val := ctx.Cache.Get(accessTokenCacheKey)
+	if val == nil {
+		return "", fmt.Errorf("cann't get component access token")
+	}
+	return val.(string), nil
+}
+
+// SetComponentAccessToken 通过component_verify_ticket 获取 ComponentAccessToken
+func (ctx *Context) SetComponentAccessToken(verifyTicket string) (*ComponentAccessToken, error) {
+	body := map[string]string{
+		"component_appid":         ctx.AppID,
+		"component_appsecret":     ctx.AppSecret,
+		"component_verify_ticket": verifyTicket,
+	}
+	respBody, err := util.PostJSON(componentAccessTokenURL, body)
+	if err != nil {
+		return nil, err
+	}
+
+	at := &ComponentAccessToken{}
+	if err := json.Unmarshal(respBody, at); err != nil {
+		return nil, err
+	}
+
+	accessTokenCacheKey := fmt.Sprintf("component_access_token_%s", ctx.AppID)
+	expires := at.ExpiresIn - 1500
+	ctx.Cache.Set(accessTokenCacheKey, at.AccessToken, time.Duration(expires)*time.Second)
+	return at, nil
+}
+
+// GetPreCode 获取预授权码
+func (ctx *Context) GetPreCode() (string, error) {
+	cat, err := ctx.GetComponentAccessToken()
+	if err != nil {
+		return "", err
+	}
+	req := map[string]string{
+		"component_appid": ctx.AppID,
+	}
+	uri := fmt.Sprintf(getPreCodeURL, cat)
+	body, err := util.PostJSON(uri, req)
+	if err != nil {
+		return "", err
+	}
+
+	var ret struct {
+		PreCode string `json:"pre_auth_code"`
+	}
+	if err := json.Unmarshal(body, &ret); err != nil {
+		return "", err
+	}
+
+	return ret.PreCode, nil
+}
+
+// ID 微信返回接口中各种类型字段
+type ID struct {
+	ID int `json:"id"`
+}
+
+// AuthBaseInfo 授权的基本信息
+type AuthBaseInfo struct {
+	AuthrAccessToken
+	FuncInfo []AuthFuncInfo `json:"func_info"`
+}
+
+// AuthFuncInfo 授权的接口内容
+type AuthFuncInfo struct {
+	FuncscopeCategory ID `json:"funcscope_category"`
+}
+
+// AuthrAccessToken 授权方AccessToken
+type AuthrAccessToken struct {
+	Appid        string `json:"authorizer_appid"`
+	AccessToken  string `json:"authorizer_access_token"`
+	ExpiresIn    int64  `json:"expires_in"`
+	RefreshToken string `json:"authorizer_refresh_token"`
+}
+
+// QueryAuthCode 使用授权码换取公众号或小程序的接口调用凭据和授权信息
+func (ctx *Context) QueryAuthCode(authCode string) (*AuthBaseInfo, error) {
+	cat, err := ctx.GetComponentAccessToken()
+	if err != nil {
+		return nil, err
+	}
+
+	req := map[string]string{
+		"component_appid":    ctx.AppID,
+		"authorization_code": authCode,
+	}
+	uri := fmt.Sprintf(queryAuthURL, cat)
+	body, err := util.PostJSON(uri, req)
+	if err != nil {
+		return nil, err
+	}
+
+	var ret struct {
+		Info *AuthBaseInfo `json:"authorization_info"`
+	}
+
+	if err := json.Unmarshal(body, &ret); err != nil {
+		return nil, err
+	}
+
+	return ret.Info, nil
+}
+
+// RefreshAuthrToken 获取(刷新)授权公众号或小程序的接口调用凭据(令牌)
+func (ctx *Context) RefreshAuthrToken(appid, refreshToken string) (*AuthrAccessToken, error) {
+	cat, err := ctx.GetComponentAccessToken()
+	if err != nil {
+		return nil, err
+	}
+
+	req := map[string]string{
+		"component_appid":          ctx.AppID,
+		"authorizer_appid":         appid,
+		"authorizer_refresh_token": refreshToken,
+	}
+	uri := fmt.Sprintf(refreshTokenURL, cat)
+	body, err := util.PostJSON(uri, req)
+	if err != nil {
+		return nil, err
+	}
+
+	ret := &AuthrAccessToken{}
+	if err := json.Unmarshal(body, ret); err != nil {
+		return nil, err
+	}
+
+	authrTokenKey := "authorizer_access_token_" + appid
+	ctx.Cache.Set(authrTokenKey, ret.AccessToken, time.Minute*80)
+
+	return ret, nil
+}
+
+// GetAuthrAccessToken 获取授权方AccessToken
+func (ctx *Context) GetAuthrAccessToken(appid string) (string, error) {
+	authrTokenKey := "authorizer_access_token_" + appid
+	val := ctx.Cache.Get(authrTokenKey)
+	if val == nil {
+		return "", fmt.Errorf("cannot get authorizer %s access token", appid)
+	}
+	return val.(string), nil
+}
+
+// AuthorizerInfo 授权方详细信息
+type AuthorizerInfo struct {
+	NickName        string `json:"nick_name"`
+	HeadImg         string `json:"head_img"`
+	ServiceTypeInfo ID     `json:"service_type_info"`
+	VerifyTypeInfo  ID     `json:"verify_type_info"`
+	UserName        string `json:"user_name"`
+	PrincipalName   string `json:"principal_name"`
+	BusinessInfo    struct {
+		OpenStore string `json:"open_store"`
+		OpenScan  string `json:"open_scan"`
+		OpenPay   string `json:"open_pay"`
+		OpenCard  string `json:"open_card"`
+		OpenShake string `json:"open_shake"`
+	}
+	Alias     string `json:"alias"`
+	QrcodeURL string `json:"qrcode_url"`
+}
+
+// GetAuthrInfo 获取授权方的帐号基本信息
+func (ctx *Context) GetAuthrInfo(appid string) (*AuthorizerInfo, *AuthBaseInfo, error) {
+	cat, err := ctx.GetComponentAccessToken()
+	if err != nil {
+		return nil, nil, err
+	}
+
+	req := map[string]string{
+		"component_appid":  ctx.AppID,
+		"authorizer_appid": appid,
+	}
+
+	uri := fmt.Sprintf(getComponentInfoURL, cat)
+	body, err := util.PostJSON(uri, req)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var ret struct {
+		AuthorizerInfo    *AuthorizerInfo `json:"authorizer_info"`
+		AuthorizationInfo *AuthBaseInfo   `json:"authorization_info"`
+	}
+	if err := json.Unmarshal(body, &ret); err != nil {
+		return nil, nil, err
+	}
+
+	return ret.AuthorizerInfo, ret.AuthorizationInfo, nil
+}

+ 19 - 0
context/component_test.go

@@ -0,0 +1,19 @@
+package context
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+var testdata = `{"authorizer_info":{"nick_name":"就爱浪","head_img":"http:\/\/wx.qlogo.cn\/mmopen\/xPKCxELaaj6hiaTZGv19oQPBJibb7hBoKmNOjQibCNOUycE8iaBhiaHOA6eC8hadQSAUZTuHUJl4qCIbCQGjSWialicfzWh4mdxuejY\/0","service_type_info":{"id":1},"verify_type_info":{"id":-1},"user_name":"gh_dcdbaa6f1687","alias":"ckeyer","qrcode_url":"http:\/\/mmbiz.qpic.cn\/mmbiz_jpg\/FribWCoIzQbAX7R1PQ8iaxGonqKp0doYD2ibhC0uhx11LrRcblASiazsbQJTJ4icQnMzfH7G0SUPuKbibTA8Cs4uk5WQ\/0","business_info":{"open_pay":0,"open_shake":0,"open_scan":0,"open_card":0,"open_store":0},"idc":1,"principal_name":"个人","signature":"不折腾会死。"},"authorization_info":{"authorizer_appid":"yyyyy","authorizer_refresh_token":"xxxx","func_info":[{"funcscope_category":{"id":1}},{"funcscope_category":{"id":15}},{"funcscope_category":{"id":4}},{"funcscope_category":{"id":7}},{"funcscope_category":{"id":2}},{"funcscope_category":{"id":3}},{"funcscope_category":{"id":11}},{"funcscope_category":{"id":6}},{"funcscope_category":{"id":5}},{"funcscope_category":{"id":8}},{"funcscope_category":{"id":13}},{"funcscope_category":{"id":9}},{"funcscope_category":{"id":12}},{"funcscope_category":{"id":22}},{"funcscope_category":{"id":23}},{"funcscope_category":{"id":24},"confirm_info":{"need_confirm":0,"already_confirm":0,"can_confirm":0}},{"funcscope_category":{"id":26}},{"funcscope_category":{"id":27},"confirm_info":{"need_confirm":0,"already_confirm":0,"can_confirm":0}},{"funcscope_category":{"id":33},"confirm_info":{"need_confirm":0,"already_confirm":0,"can_confirm":0}},{"funcscope_category":{"id":35}}]}}`
+
+// TestDecode
+func TestDecode(t *testing.T) {
+	var ret struct {
+		AuthorizerInfo    *AuthorizerInfo `json:"authorizer_info"`
+		AuthorizationInfo *AuthBaseInfo   `json:"authorization_info"`
+	}
+	json.Unmarshal([]byte(testdata), &ret)
+	t.Logf("%+v", ret.AuthorizerInfo)
+	t.Logf("%+v", ret.AuthorizationInfo)
+}

+ 3 - 0
context/context.go

@@ -27,6 +27,9 @@ type Context struct {
 
 	//jsAPITicket 读写锁 同一个AppID一个
 	jsAPITicketLock *sync.RWMutex
+
+	//accessTokenFunc 自定义获取 access token 的方法
+	accessTokenFunc GetAccessTokenFunc
 }
 
 // Query returns the keyed url query value if it exists

+ 1 - 1
material/material.go

@@ -63,7 +63,7 @@ func (material *Material) AddNews(articles []*Article) (mediaID string, err erro
 	uri := fmt.Sprintf("%s?access_token=%s", addNewsURL, accessToken)
 	responseBytes, err := util.PostJSON(uri, req)
 	var res resArticles
-	err = json.Unmarshal(responseBytes, res)
+	err = json.Unmarshal(responseBytes, &res)
 	if err != nil {
 		return
 	}

+ 305 - 0
miniprogram/analysis.go

@@ -0,0 +1,305 @@
+package miniprogram
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/silenceper/wechat/util"
+)
+
+const (
+	// 获取用户访问小程序日留存
+	getAnalysisDailyRetainURL = "https://api.weixin.qq.com/datacube/getweanalysisappiddailyretaininfo?access_token=%s"
+	// 获取用户访问小程序月留存
+	getAnalysisMonthlyRetainURL = "https://api.weixin.qq.com/datacube/getweanalysisappidmonthlyretaininfo?access_token=%s"
+	// 获取用户访问小程序周留存
+	getAnalysisWeeklyRetainURL = "https://api.weixin.qq.com/datacube/getweanalysisappidweeklyretaininfo?access_token=%s"
+	// 获取用户访问小程序数据概况
+	getAnalysisDailySummaryURL = "https://api.weixin.qq.com/datacube/getweanalysisappiddailysummarytrend?access_token=%s"
+	// 获取用户访问小程序数据日趋势
+	getAnalysisDailyVisitTrendURL = "https://api.weixin.qq.com/datacube/getweanalysisappiddailyvisittrend?access_token=%s"
+	// 获取用户访问小程序数据月趋势
+	getAnalysisMonthlyVisitTrendURL = "https://api.weixin.qq.com/datacube/getweanalysisappidmonthlyvisittrend?access_token=%s"
+	// 获取用户访问小程序数据周趋势
+	getAnalysisWeeklyVisitTrendURL = "https://api.weixin.qq.com/datacube/getweanalysisappidweeklyvisittrend?access_token=%s"
+	// 获取小程序新增或活跃用户的画像分布数据
+	getAnalysisUserPortraitURL = "https://api.weixin.qq.com/datacube/getweanalysisappiduserportrait?access_token=%s"
+	// 获取用户小程序访问分布数据
+	getAnalysisVisitDistributionURL = "https://api.weixin.qq.com/datacube/getweanalysisappidvisitdistribution?access_token=%s"
+	// 访问页面
+	getAnalysisVisitPageURL = "https://api.weixin.qq.com/datacube/getweanalysisappidvisitpage?access_token=%s"
+)
+
+// fetchData 拉取统计数据
+func (wxa *MiniProgram) fetchData(urlStr string, body interface{}) (response []byte, err error) {
+	var accessToken string
+	accessToken, err = wxa.GetAccessToken()
+	if err != nil {
+		return
+	}
+	urlStr = fmt.Sprintf(urlStr, accessToken)
+	response, err = util.PostJSON(urlStr, body)
+	return
+}
+
+// AnalysisRetainItem 留存项结构
+type AnalysisRetainItem struct {
+	Key   int `json:"key"`   // 标识,0开始表示当天,1表示1甜后,以此类推
+	Value int `json:"value"` // key对应日期的新增用户数/活跃用户数(key=0时)或留存用户数(k>0时)
+}
+
+// ResAnalysisRetain 小程序留存数据返回
+type ResAnalysisRetain struct {
+	util.CommonError
+	RefDate    string               `json:"ref_date"`     // 日期
+	VisitUVNew []AnalysisRetainItem `json:"visit_uv_new"` // 新增用户留存
+	VisitUV    []AnalysisRetainItem `json:"visit_uv"`     // 活跃用户留存
+}
+
+// getAnalysisRetain 获取用户访问小程序留存数据(日、月、周)
+func (wxa *MiniProgram) getAnalysisRetain(urlStr string, beginDate, endDate string) (result ResAnalysisRetain, err error) {
+	body := map[string]string{
+		"begin_date": beginDate,
+		"end_date":   endDate,
+	}
+	response, err := wxa.fetchData(urlStr, body)
+	if err != nil {
+		return
+	}
+	err = json.Unmarshal(response, &result)
+	if err != nil {
+		return
+	}
+	if result.ErrCode != 0 {
+		err = fmt.Errorf("getAnalysisRetain error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg)
+		return
+	}
+	return
+}
+
+// GetAnalysisDailyRetain 获取用户访问小程序日留存
+func (wxa *MiniProgram) GetAnalysisDailyRetain(beginDate, endDate string) (result ResAnalysisRetain, err error) {
+	return wxa.getAnalysisRetain(getAnalysisDailyRetainURL, beginDate, endDate)
+}
+
+// GetAnalysisMonthlyRetain 获取用户访问小程序月留存
+func (wxa *MiniProgram) GetAnalysisMonthlyRetain(beginDate, endDate string) (result ResAnalysisRetain, err error) {
+	return wxa.getAnalysisRetain(getAnalysisMonthlyRetainURL, beginDate, endDate)
+}
+
+// GetAnalysisWeeklyRetain 获取用户访问小程序周留存
+func (wxa *MiniProgram) GetAnalysisWeeklyRetain(beginDate, endDate string) (result ResAnalysisRetain, err error) {
+	return wxa.getAnalysisRetain(getAnalysisWeeklyRetainURL, beginDate, endDate)
+}
+
+// ResAnalysisDailySummary 小程序访问数据概况
+type ResAnalysisDailySummary struct {
+	util.CommonError
+	List []struct {
+		RefDate    string `json:"ref_date"`    // 日期
+		VisitTotal int    `json:"visit_total"` // 累计用户数
+		SharePV    int    `json:"share_pv"`    // 转发次数
+		ShareUV    int    `json:"share_uv"`    // 转发人数
+	} `json:"list"`
+}
+
+// GetAnalysisDailySummary 获取用户访问小程序数据概况
+func (wxa *MiniProgram) GetAnalysisDailySummary(beginDate, endDate string) (result ResAnalysisDailySummary, err error) {
+	body := map[string]string{
+		"begin_date": beginDate,
+		"end_date":   endDate,
+	}
+	response, err := wxa.fetchData(getAnalysisDailySummaryURL, body)
+	if err != nil {
+		return
+	}
+	fmt.Println(string(response))
+	err = json.Unmarshal(response, &result)
+	if err != nil {
+		return
+	}
+	if result.ErrCode != 0 {
+		err = fmt.Errorf("GetAnalysisDailySummary error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg)
+		return
+	}
+	return
+}
+
+// ResAnalysisVisitTrend 小程序访问数据趋势(日、月、周)
+type ResAnalysisVisitTrend struct {
+	util.CommonError
+	List []struct {
+		RefDate         string  `json:"ref_date"`          // 日期
+		SessionCnt      int     `json:"session_cnt"`       // 打开次数
+		VisitPV         int     `json:"visit_pv"`          // 访问次数
+		VisitUV         int     `json:"visit_uv"`          // 访问人数
+		VisitUVNew      int     `json:"visit_uv_new"`      // 新用户数
+		StayTimeUV      float64 `json:"stay_time_uv"`      // 人均停留时长
+		StayTimeSession float64 `json:"stay_time_session"` // 次均停留时常
+		VisitDepth      float64 `json:"visit_depth"`       // 平均访问深度
+	} `json:"list"`
+}
+
+// getAnalysisRetain 获取小程序访问数据趋势(日、月、周)
+func (wxa *MiniProgram) getAnalysisVisitTrend(urlStr string, beginDate, endDate string) (result ResAnalysisVisitTrend, err error) {
+	body := map[string]string{
+		"begin_date": beginDate,
+		"end_date":   endDate,
+	}
+	response, err := wxa.fetchData(urlStr, body)
+	if err != nil {
+		return
+	}
+	err = json.Unmarshal(response, &result)
+	if err != nil {
+		return
+	}
+	if result.ErrCode != 0 {
+		err = fmt.Errorf("getAnalysisVisitTrend error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg)
+		return
+	}
+	return
+}
+
+// GetAnalysisDailyVisitTrend 获取用户访问小程序数据日趋势
+func (wxa *MiniProgram) GetAnalysisDailyVisitTrend(beginDate, endDate string) (result ResAnalysisVisitTrend, err error) {
+	return wxa.getAnalysisVisitTrend(getAnalysisDailyVisitTrendURL, beginDate, endDate)
+}
+
+// GetAnalysisMonthlyVisitTrend 获取用户访问小程序数据月趋势
+func (wxa *MiniProgram) GetAnalysisMonthlyVisitTrend(beginDate, endDate string) (result ResAnalysisVisitTrend, err error) {
+	return wxa.getAnalysisVisitTrend(getAnalysisMonthlyVisitTrendURL, beginDate, endDate)
+}
+
+// GetAnalysisWeeklyVisitTrend 获取用户访问小程序数据周趋势
+func (wxa *MiniProgram) GetAnalysisWeeklyVisitTrend(beginDate, endDate string) (result ResAnalysisVisitTrend, err error) {
+	return wxa.getAnalysisVisitTrend(getAnalysisWeeklyVisitTrendURL, beginDate, endDate)
+}
+
+// UserPortraitItem 用户画像项目
+type UserPortraitItem struct {
+	ID                  int    `json:"id"`                     // 属性值id
+	Name                string `json:"name"`                   // 属性值名称
+	AccessSourceVisitUV int    `json:"access_source_visit_uv"` // 该场景访问uv
+}
+
+// UserPortrait 用户画像
+type UserPortrait struct {
+	Index     int                `json:"index"`     // 分布类型
+	Province  []UserPortraitItem `json:"province"`  // 省份,如北京、广东等
+	City      []UserPortraitItem `json:"city"`      // 城市,如北京、广州等
+	Genders   []UserPortraitItem `json:"genders"`   // 性别,包括男、女、未知
+	Platforms []UserPortraitItem `json:"platforms"` // 终端类型,包括iPhone, android, 其他
+	Devices   []UserPortraitItem `json:"devices"`   // 机型,如苹果iPhone 6, OPPO R9等
+	Ages      []UserPortraitItem `json:"ages"`      // 年龄,包括17岁以下、18-24对等区间
+}
+
+// ResAnalysisUserPortrait 小程序新增或活跃用户的画像分布数据返回
+type ResAnalysisUserPortrait struct {
+	util.CommonError
+	RefDate    string       `json:"ref_date"`     // 日期
+	VisitUVNew UserPortrait `json:"visit_uv_new"` // 新用户画像
+	VisitUV    UserPortrait `json:"visit_uv"`     // 活跃用户画像
+}
+
+// GetAnalysisUserPortrait 获取小程序新增或活跃用户的画像分布数据
+func (wxa *MiniProgram) GetAnalysisUserPortrait(beginDate, endDate string) (result ResAnalysisUserPortrait, err error) {
+	body := map[string]string{
+		"begin_date": beginDate,
+		"end_date":   endDate,
+	}
+	response, err := wxa.fetchData(getAnalysisUserPortraitURL, body)
+	if err != nil {
+		return
+	}
+	err = json.Unmarshal(response, &result)
+	if err != nil {
+		return
+	}
+	if result.ErrCode != 0 {
+		err = fmt.Errorf("GetAnalysisUserPortrait error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg)
+		return
+	}
+	return
+}
+
+// VisitDistributionIndexItem 访问分数数据结构
+type VisitDistributionIndexItem struct {
+	Key                 int `json:"key"`                    // 场景id
+	Value               int `json:"value"`                  // 该场景id访问pv
+	AccessSourceVisitUV int `json:"access_source_visit_uv"` // 该场景id访问uv
+}
+
+// VisitDistributionIndex 访问分布单分布类型数据
+type VisitDistributionIndex struct {
+	Index    string                       `json:"index"`     // 分布类型
+	ItemList []VisitDistributionIndexItem `json:"item_list"` // 分布数据列表
+}
+
+// ResAnalysisVisitDistribution 小程序访问分布数据返回
+type ResAnalysisVisitDistribution struct {
+	util.CommonError
+	RefDate string                   `json:"ref_date"` // 日期
+	List    []VisitDistributionIndex `json:"list"`     // 数据列表
+}
+
+// GetAnalysisVisitDistribution 获取用户小程序访问分布数据
+func (wxa *MiniProgram) GetAnalysisVisitDistribution(beginDate, endDate string) (result ResAnalysisVisitDistribution, err error) {
+	body := map[string]string{
+		"begin_date": beginDate,
+		"end_date":   endDate,
+	}
+	response, err := wxa.fetchData(getAnalysisVisitDistributionURL, body)
+	if err != nil {
+		return
+	}
+	err = json.Unmarshal(response, &result)
+	if err != nil {
+		return
+	}
+	if result.ErrCode != 0 {
+		err = fmt.Errorf("GetAnalysisVisitDistribution error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg)
+		return
+	}
+	return
+}
+
+// VisitPageItem 访问单个页面的数据结构
+type VisitPageItem struct {
+	PagePath       string  `json:"page_path"`        // 页面路径
+	PageVisitPV    int     `json:"page_visit_pv"`    // 访问次数
+	PageVisitUV    int     `json:"page_visit_uv"`    // 访问人数
+	PageStaytimePV float64 `json:"page_staytime_pv"` // 次均停留时常
+	EntrypagePV    int     `json:"entrypage_pv"`     // 进入页次数
+	ExitpagePV     int     `json:"exitpage_pv"`      // 退出页次数
+	PageSharePV    int     `json:"page_share_pv"`    // 转发次数
+	PageShareUV    int     `json:"page_share_uv"`    // 转发人数
+}
+
+// ResAnalysisVisitPage 访问小程序页面访问数据返回
+type ResAnalysisVisitPage struct {
+	util.CommonError
+	RefDate string          `json:"ref_date"` // 日期
+	List    []VisitPageItem `json:"list"`     // 数据列表
+}
+
+// GetAnalysisVisitPage 获取小程序页面访问数据
+func (wxa *MiniProgram) GetAnalysisVisitPage(beginDate, endDate string) (result ResAnalysisVisitPage, err error) {
+	body := map[string]string{
+		"begin_date": beginDate,
+		"end_date":   endDate,
+	}
+	response, err := wxa.fetchData(getAnalysisVisitPageURL, body)
+	if err != nil {
+		return
+	}
+	err = json.Unmarshal(response, &result)
+	if err != nil {
+		return
+	}
+	if result.ErrCode != 0 {
+		err = fmt.Errorf("GetAnalysisVisitPage error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg)
+		return
+	}
+	return
+}

+ 93 - 0
miniprogram/decrypt.go

@@ -0,0 +1,93 @@
+package miniprogram
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+)
+
+var (
+	// ErrAppIDNotMatch appid不匹配
+	ErrAppIDNotMatch = errors.New("app id not match")
+	// ErrInvalidBlockSize block size不合法
+	ErrInvalidBlockSize = errors.New("invalid block size")
+	// ErrInvalidPKCS7Data PKCS7数据不合法
+	ErrInvalidPKCS7Data = errors.New("invalid PKCS7 data")
+	// ErrInvalidPKCS7Padding 输入padding失败
+	ErrInvalidPKCS7Padding = errors.New("invalid padding on input")
+)
+
+// UserInfo 用户信息
+type UserInfo struct {
+	OpenID    string `json:"openId"`
+	UnionID   string `json:"unionId"`
+	NickName  string `json:"nickName"`
+	Gender    int    `json:"gender"`
+	City      string `json:"city"`
+	Province  string `json:"province"`
+	Country   string `json:"country"`
+	AvatarURL string `json:"avatarUrl"`
+	Language  string `json:"language"`
+	Watermark struct {
+		Timestamp int64  `json:"timestamp"`
+		AppID     string `json:"appid"`
+	} `json:"watermark"`
+}
+
+// pkcs7Unpad returns slice of the original data without padding
+func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
+	if blockSize <= 0 {
+		return nil, ErrInvalidBlockSize
+	}
+	if len(data)%blockSize != 0 || len(data) == 0 {
+		return nil, ErrInvalidPKCS7Data
+	}
+	c := data[len(data)-1]
+	n := int(c)
+	if n == 0 || n > len(data) {
+		return nil, ErrInvalidPKCS7Padding
+	}
+	for i := 0; i < n; i++ {
+		if data[len(data)-n+i] != c {
+			return nil, ErrInvalidPKCS7Padding
+		}
+	}
+	return data[:len(data)-n], nil
+}
+
+// Decrypt 解密数据
+func (wxa *MiniProgram) Decrypt(sessionKey, encryptedData, iv string) (*UserInfo, error) {
+	aesKey, err := base64.StdEncoding.DecodeString(sessionKey)
+	if err != nil {
+		return nil, err
+	}
+	cipherText, err := base64.StdEncoding.DecodeString(encryptedData)
+	if err != nil {
+		return nil, err
+	}
+	ivBytes, err := base64.StdEncoding.DecodeString(iv)
+	if err != nil {
+		return nil, err
+	}
+	block, err := aes.NewCipher(aesKey)
+	if err != nil {
+		return nil, err
+	}
+	mode := cipher.NewCBCDecrypter(block, ivBytes)
+	mode.CryptBlocks(cipherText, cipherText)
+	cipherText, err = pkcs7Unpad(cipherText, block.BlockSize())
+	if err != nil {
+		return nil, err
+	}
+	var userInfo UserInfo
+	err = json.Unmarshal(cipherText, &userInfo)
+	if err != nil {
+		return nil, err
+	}
+	if userInfo.Watermark.AppID != wxa.AppID {
+		return nil, ErrAppIDNotMatch
+	}
+	return &userInfo, nil
+}

+ 17 - 0
miniprogram/miniprogram.go

@@ -0,0 +1,17 @@
+package miniprogram
+
+import (
+	"github.com/silenceper/wechat/context"
+)
+
+// MiniProgram struct extends context
+type MiniProgram struct {
+	*context.Context
+}
+
+// NewMiniProgram 实例化小程序接口
+func NewMiniProgram(context *context.Context) *MiniProgram {
+	miniProgram := new(MiniProgram)
+	miniProgram.Context = context
+	return miniProgram
+}

+ 91 - 0
miniprogram/qrcode.go

@@ -0,0 +1,91 @@
+package miniprogram
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/silenceper/wechat/util"
+)
+
+const (
+	createWXAQRCodeURL   = "https://api.weixin.qq.com/cgi-bin/wxaapp/createwxaqrcode?access_token=%s"
+	getWXACodeURL        = "https://api.weixin.qq.com/wxa/getwxacode?access_token=%s"
+	getWXACodeUnlimitURL = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=%s"
+)
+
+// QRCoder 小程序码参数
+type QRCoder struct {
+	// page 必须是已经发布的小程序存在的页面,根路径前不要填加 /,不能携带参数(参数请放在scene字段里),如果不填写这个字段,默认跳主页面
+	Page string `json:"page,omitempty"`
+	// path 扫码进入的小程序页面路径
+	Path string `json:"path,omitempty"`
+	// width 图片宽度
+	Width int `json:"width,omitempty"`
+	// scene 最大32个可见字符,只支持数字,大小写英文以及部分特殊字符:!#$&'()*+,/:;=?@-._~,其它字符请自行编码为合法字符(因不支持%,中文无法使用 urlencode 处理,请使用其他编码方式)
+	Scene string `json:"scene,omitempty"`
+	// autoColor 自动配置线条颜色,如果颜色依然是黑色,则说明不建议配置主色调
+	AutoColor bool `json:"auto_color,omitempty"`
+	// lineColor AutoColor 为 false 时生效,使用 rgb 设置颜色 例如 {"r":"xxx","g":"xxx","b":"xxx"},十进制表示
+	LineColor Color `json:"line_color,omitempty"`
+	// isHyaline 是否需要透明底色
+	IsHyaline bool `json:"is_hyaline,omitempty"`
+}
+
+// Color QRCode color
+type Color struct {
+	R string `json:"r"`
+	G string `json:"g"`
+	B string `json:"b"`
+}
+
+// fetchCode 请求并返回二维码二进制数据
+func (wxa *MiniProgram) fetchCode(urlStr string, body interface{}) (response []byte, err error) {
+	var accessToken string
+	accessToken, err = wxa.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	urlStr = fmt.Sprintf(urlStr, accessToken)
+	var contentType string
+	response, contentType, err = util.PostJSONWithRespContentType(urlStr, body)
+	if err != nil {
+		return
+	}
+	if strings.HasPrefix(contentType, "application/json") {
+		// 返回错误信息
+		var result util.CommonError
+		err = json.Unmarshal(response, &result)
+		if err == nil && result.ErrCode != 0 {
+			err = fmt.Errorf("fetchCode error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg)
+			return nil, err
+		}
+	} else if contentType == "image/jpeg" {
+		// 返回文件
+		return response, nil
+	} else {
+		err = fmt.Errorf("fetchCode error : unknown response content type - %v", contentType)
+		return nil, err
+	}
+
+	return
+}
+
+// CreateWXAQRCode 获取小程序二维码,适用于需要的码数量较少的业务场景
+// 文档地址: https://developers.weixin.qq.com/miniprogram/dev/api/createWXAQRCode.html
+func (wxa *MiniProgram) CreateWXAQRCode(coderParams QRCoder) (response []byte, err error) {
+	return wxa.fetchCode(createWXAQRCodeURL, coderParams)
+}
+
+// GetWXACode 获取小程序码,适用于需要的码数量较少的业务场景
+// 文档地址: https://developers.weixin.qq.com/miniprogram/dev/api/getWXACode.html
+func (wxa *MiniProgram) GetWXACode(coderParams QRCoder) (response []byte, err error) {
+	return wxa.fetchCode(getWXACodeURL, coderParams)
+}
+
+// GetWXACodeUnlimit 获取小程序码,适用于需要的码数量极多的业务场景
+// 文档地址: https://developers.weixin.qq.com/miniprogram/dev/api/getWXACodeUnlimit.html
+func (wxa *MiniProgram) GetWXACodeUnlimit(coderParams QRCoder) (response []byte, err error) {
+	return wxa.fetchCode(getWXACodeUnlimitURL, coderParams)
+}

+ 40 - 0
miniprogram/sns.go

@@ -0,0 +1,40 @@
+package miniprogram
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/silenceper/wechat/util"
+)
+
+const (
+	code2SessionURL = "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code"
+)
+
+// ResCode2Session 登录凭证校验的返回结果
+type ResCode2Session struct {
+	util.CommonError
+
+	OpenID     string `json:"openid"`      // 用户唯一标识
+	SessionKey string `json:"session_key"` // 会话密钥
+	UnionID    string `json:"unionid"`     // 用户在开放平台的唯一标识符,在满足UnionID下发条件的情况下会返回
+}
+
+// Code2Session 登录凭证校验
+func (wxa *MiniProgram) Code2Session(jsCode string) (result ResCode2Session, err error) {
+	urlStr := fmt.Sprintf(code2SessionURL, wxa.AppID, wxa.AppSecret, jsCode)
+	var response []byte
+	response, err = util.HTTPGet(urlStr)
+	if err != nil {
+		return
+	}
+	err = json.Unmarshal(response, &result)
+	if err != nil {
+		return
+	}
+	if result.ErrCode != 0 {
+		err = fmt.Errorf("Code2Session error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg)
+		return
+	}
+	return
+}

+ 16 - 5
oauth/oauth.go

@@ -11,11 +11,12 @@ import (
 )
 
 const (
-	redirectOauthURL      = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s#wechat_redirect"
-	accessTokenURL        = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code"
-	refreshAccessTokenURL = "https://api.weixin.qq.com/sns/oauth2/refresh_token?appid=%s&grant_type=refresh_token&refresh_token=%s"
-	userInfoURL           = "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s&lang=zh_CN"
-	checkAccessTokenURL   = "https://api.weixin.qq.com/sns/auth?access_token=%s&openid=%s"
+	redirectOauthURL       = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s#wechat_redirect"
+	webAppRedirectOauthURL = "https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s#wechat_redirect"
+	accessTokenURL         = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code"
+	refreshAccessTokenURL  = "https://api.weixin.qq.com/sns/oauth2/refresh_token?appid=%s&grant_type=refresh_token&refresh_token=%s"
+	userInfoURL            = "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s&lang=zh_CN"
+	checkAccessTokenURL    = "https://api.weixin.qq.com/sns/auth?access_token=%s&openid=%s"
 )
 
 //Oauth 保存用户授权信息
@@ -37,6 +38,12 @@ func (oauth *Oauth) GetRedirectURL(redirectURI, scope, state string) (string, er
 	return fmt.Sprintf(redirectOauthURL, oauth.AppID, urlStr, scope, state), nil
 }
 
+//GetWebAppRedirectURL 获取网页应用跳转的url地址
+func (oauth *Oauth) GetWebAppRedirectURL(redirectURI, scope, state string) (string, error) {
+	urlStr := url.QueryEscape(redirectURI)
+	return fmt.Sprintf(webAppRedirectOauthURL, oauth.AppID, urlStr, scope, state), nil
+}
+
 //Redirect 跳转到网页授权
 func (oauth *Oauth) Redirect(writer http.ResponseWriter, req *http.Request, redirectURI, scope, state string) error {
 	location, err := oauth.GetRedirectURL(redirectURI, scope, state)
@@ -56,6 +63,10 @@ type ResAccessToken struct {
 	RefreshToken string `json:"refresh_token"`
 	OpenID       string `json:"openid"`
 	Scope        string `json:"scope"`
+
+	// UnionID 只有在用户将公众号绑定到微信开放平台帐号后,才会出现该字段。
+	// 公众号文档 https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140842
+	UnionID string `json:"unionid"`
 }
 
 // GetUserAccessToken 通过网页授权的code 换取access_token(区别于context中的access_token)

+ 8 - 8
pay/pay.go

@@ -93,16 +93,16 @@ func (pcf *Pay) PrePayOrder(p *Params) (payOrder PreOrder, err error) {
 	param["appid"] = pcf.AppID
 	param["body"] = p.Body
 	param["mch_id"] = pcf.PayMchID
-	param["nonce_str"] =nonceStr
-	param["notify_url"] =pcf.PayNotifyURL
-	param["out_trade_no"] =p.OutTradeNo
-	param["spbill_create_ip"] =p.CreateIP
-	param["total_fee"] =p.TotalFee
-	param["trade_type"] =p.TradeType
+	param["nonce_str"] = nonceStr
+	param["notify_url"] = pcf.PayNotifyURL
+	param["out_trade_no"] = p.OutTradeNo
+	param["spbill_create_ip"] = p.CreateIP
+	param["total_fee"] = p.TotalFee
+	param["trade_type"] = p.TradeType
 	param["openid"] = p.OpenID
 
-	bizKey := "&key="+pcf.PayKey
-	str := orderParam(param,bizKey)
+	bizKey := "&key=" + pcf.PayKey
+	str := orderParam(param, bizKey)
 	sign := util.MD5Sum(str)
 	request := payRequest{
 		AppID:          pcf.AppID,

+ 109 - 0
pay/refund.go

@@ -0,0 +1,109 @@
+package pay
+
+import (
+	"encoding/xml"
+	"fmt"
+
+	"github.com/silenceper/wechat/util"
+)
+
+var refundGateway = "https://api.mch.weixin.qq.com/secapi/pay/refund"
+
+//RefundParams 调用参数
+type RefundParams struct {
+	TransactionID string
+	OutRefundNo   string
+	TotalFee      string
+	RefundFee     string
+	RefundDesc    string
+	RootCa        string //ca证书
+}
+
+//refundRequest 接口请求参数
+type refundRequest struct {
+	AppID         string `xml:"appid"`
+	MchID         string `xml:"mch_id"`
+	NonceStr      string `xml:"nonce_str"`
+	Sign          string `xml:"sign"`
+	SignType      string `xml:"sign_type,omitempty"`
+	TransactionID string `xml:"transaction_id"`
+	OutRefundNo   string `xml:"out_refund_no"`
+	TotalFee      string `xml:"total_fee"`
+	RefundFee     string `xml:"refund_fee"`
+	RefundDesc    string `xml:"refund_desc,omitempty"`
+	//NotifyUrl     string `xml:"notify_url,omitempty"`
+}
+
+//RefundResponse 接口返回
+type RefundResponse struct {
+	ReturnCode          string `xml:"return_code"`
+	ReturnMsg           string `xml:"return_msg"`
+	AppID               string `xml:"appid,omitempty"`
+	MchID               string `xml:"mch_id,omitempty"`
+	NonceStr            string `xml:"nonce_str,omitempty"`
+	Sign                string `xml:"sign,omitempty"`
+	ResultCode          string `xml:"result_code,omitempty"`
+	ErrCode             string `xml:"err_code,omitempty"`
+	ErrCodeDes          string `xml:"err_code_des,omitempty"`
+	TransactionID       string `xml:"transaction_id,omitempty"`
+	OutTradeNo          string `xml:"out_trade_no,omitempty"`
+	OutRefundNo         string `xml:"out_refund_no,omitempty"`
+	RefundID            string `xml:"refund_id,omitempty"`
+	RefundFee           string `xml:"refund_fee,omitempty"`
+	SettlementRefundFee string `xml:"settlement_refund_fee,omitempty"`
+	TotalFee            string `xml:"total_fee,omitempty"`
+	SettlementTotalFee  string `xml:"settlement_total_fee,omitempty"`
+	FeeType             string `xml:"fee_type,omitempty"`
+	CashFee             string `xml:"cash_fee,omitempty"`
+	CashFeeType         string `xml:"cash_fee_type,omitempty"`
+}
+
+//Refund 退款申请
+func (pcf *Pay) Refund(p *RefundParams) (rsp RefundResponse, err error) {
+	nonceStr := util.RandomStr(32)
+	param := make(map[string]interface{})
+	param["appid"] = pcf.AppID
+	param["mch_id"] = pcf.PayMchID
+	param["nonce_str"] = nonceStr
+	param["out_refund_no"] = p.OutRefundNo
+	param["refund_desc"] = p.RefundDesc
+	param["refund_fee"] = p.RefundFee
+	param["total_fee"] = p.TotalFee
+	param["sign_type"] = "MD5"
+	param["transaction_id"] = p.TransactionID
+
+	bizKey := "&key=" + pcf.PayKey
+	str := orderParam(param, bizKey)
+	sign := util.MD5Sum(str)
+	request := refundRequest{
+		AppID:         pcf.AppID,
+		MchID:         pcf.PayMchID,
+		NonceStr:      nonceStr,
+		Sign:          sign,
+		SignType:      "MD5",
+		TransactionID: p.TransactionID,
+		OutRefundNo:   p.OutRefundNo,
+		TotalFee:      p.TotalFee,
+		RefundFee:     p.RefundFee,
+		RefundDesc:    p.RefundDesc,
+	}
+	rawRet, err := util.PostXMLWithTLS(refundGateway, request, p.RootCa, pcf.PayMchID)
+	if err != nil {
+		return
+	}
+	err = xml.Unmarshal(rawRet, &rsp)
+	if err != nil {
+		return
+	}
+	if rsp.ReturnCode == "SUCCESS" {
+		if rsp.ResultCode == "SUCCESS" {
+			err = nil
+			return
+		}
+		err = fmt.Errorf("refund error, errcode=%s,errmsg=%s", rsp.ErrCode, rsp.ErrCodeDes)
+		return
+	}
+	err = fmt.Errorf("[msg : xmlUnmarshalError] [rawReturn : %s] [params : %s] [sign : %s]",
+		string(rawRet), str, sign)
+	return
+}

+ 122 - 0
qr/qr.go

@@ -0,0 +1,122 @@
+package qr
+
+import (
+	"encoding/json"
+	"fmt"
+	"reflect"
+	"time"
+
+	"github.com/silenceper/wechat/context"
+	"github.com/silenceper/wechat/util"
+)
+
+const (
+	qrCreateURL = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=%s"
+	getQRImgURL = "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=%s"
+)
+
+const (
+	actionID  = "QR_SCENE"
+	actionStr = "QR_STR_SCENE"
+
+	actionLimitID  = "QR_LIMIT_SCENE"
+	actionLimitStr = "QR_LIMIT_STR_SCENE"
+)
+
+// QR 二维码
+type QR struct {
+	*context.Context
+}
+
+//NewQR 二维码实例
+func NewQR(context *context.Context) *QR {
+	q := new(QR)
+	q.Context = context
+	return q
+}
+
+// Request 临时二维码
+type Request struct {
+	ExpireSeconds int64  `json:"expire_seconds,omitempty"`
+	ActionName    string `json:"action_name"`
+	ActionInfo    struct {
+		Scene struct {
+			SceneStr string `json:"scene_str,omitempty"`
+			SceneID  int    `json:"scene_id,omitempty"`
+		} `json:"scene"`
+	} `json:"action_info"`
+}
+
+// Ticket 二维码ticket
+type Ticket struct {
+	util.CommonError `json:",inline"`
+	Ticket           string `json:"ticket"`
+	ExpireSeconds    int64  `json:"expire_seconds"`
+	URL              string `json:"url"`
+}
+
+// GetQRTicket 获取二维码 Ticket
+func (q *QR) GetQRTicket(tq *Request) (t *Ticket, err error) {
+	accessToken, err := q.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	uri := fmt.Sprintf(qrCreateURL, accessToken)
+	response, err := util.PostJSON(uri, tq)
+	if err != nil {
+		err = fmt.Errorf("get qr ticket failed, %s", err)
+		return
+	}
+
+	t = new(Ticket)
+	err = json.Unmarshal(response, &t)
+	if err != nil {
+		return
+	}
+
+	return
+}
+
+// ShowQRCode 通过ticket换取二维码
+func ShowQRCode(tk *Ticket) string {
+	return fmt.Sprintf(getQRImgURL, tk.Ticket)
+}
+
+// NewTmpQrRequest 新建临时二维码请求实例
+func NewTmpQrRequest(exp time.Duration, scene interface{}) *Request {
+	tq := &Request{
+		ExpireSeconds: int64(exp.Seconds()),
+	}
+	switch reflect.ValueOf(scene).Kind() {
+	case reflect.String:
+		tq.ActionName = actionStr
+		tq.ActionInfo.Scene.SceneStr = scene.(string)
+	case reflect.Int, reflect.Int8, reflect.Int16,
+		reflect.Int32, reflect.Int64,
+		reflect.Uint, reflect.Uint8, reflect.Uint16,
+		reflect.Uint32, reflect.Uint64:
+		tq.ActionName = actionID
+		tq.ActionInfo.Scene.SceneID = scene.(int)
+	}
+
+	return tq
+}
+
+// NewLimitQrRequest 新建永久二维码请求实例
+func NewLimitQrRequest(scene interface{}) *Request {
+	tq := &Request{}
+	switch reflect.ValueOf(scene).Kind() {
+	case reflect.String:
+		tq.ActionName = actionLimitStr
+		tq.ActionInfo.Scene.SceneStr = scene.(string)
+	case reflect.Int, reflect.Int8, reflect.Int16,
+		reflect.Int32, reflect.Int64,
+		reflect.Uint, reflect.Uint8, reflect.Uint16,
+		reflect.Uint32, reflect.Uint64:
+		tq.ActionName = actionLimitID
+		tq.ActionInfo.Scene.SceneID = scene.(int)
+	}
+
+	return tq
+}

+ 64 - 39
user/user.go

@@ -2,8 +2,8 @@ package user
 
 import (
 	"encoding/json"
-	"errors"
 	"fmt"
+	"net/url"
 
 	"github.com/silenceper/wechat/context"
 	"github.com/silenceper/wechat/util"
@@ -12,7 +12,7 @@ import (
 const (
 	userInfoURL     = "https://api.weixin.qq.com/cgi-bin/user/info?access_token=%s&openid=%s&lang=zh_CN"
 	updateRemarkURL = "https://api.weixin.qq.com/cgi-bin/user/info/updateremark?access_token=%s"
-	batchGetUserInfoURL = "https://api.weixin.qq.com/cgi-bin/user/info/batchget?access_token=%s"
+	userListURL     = "https://api.weixin.qq.com/cgi-bin/user/get"
 )
 
 //User 用户管理
@@ -31,29 +31,35 @@ func NewUser(context *context.Context) *User {
 type Info struct {
 	util.CommonError
 
-	Subscribe     int32   `json:"subscribe"`
-	OpenID        string  `json:"openid"`
-	Nickname      string  `json:"nickname"`
-	Sex           int32   `json:"sex"`
-	City          string  `json:"city"`
-	Country       string  `json:"country"`
-	Province      string  `json:"province"`
-	Language      string  `json:"language"`
-	Headimgurl    string  `json:"headimgurl"`
-	SubscribeTime int32   `json:"subscribe_time"`
-	UnionID       string  `json:"unionid"`
-	Remark        string  `json:"remark"`
-	GroupID       int32   `json:"groupid"`
-	TagidList     []int32 `json:"tagid_list"`
+	Subscribe      int32   `json:"subscribe"`
+	OpenID         string  `json:"openid"`
+	Nickname       string  `json:"nickname"`
+	Sex            int32   `json:"sex"`
+	City           string  `json:"city"`
+	Country        string  `json:"country"`
+	Province       string  `json:"province"`
+	Language       string  `json:"language"`
+	Headimgurl     string  `json:"headimgurl"`
+	SubscribeTime  int32   `json:"subscribe_time"`
+	UnionID        string  `json:"unionid"`
+	Remark         string  `json:"remark"`
+	GroupID        int32   `json:"groupid"`
+	TagidList      []int32 `json:"tagid_list"`
+	SubscribeScene string  `json:"subscribe_scene"`
+	QrScene        int     `json:"qr_scene"`
+	QrSceneStr     string  `json:"qr_scene_str"`
 }
 
-// BatchUserQuery 待查询的用户列表
-type BatchUserQuery struct {
-	OpenID 		string		`json:"openid"`
-	Lang 		string		`json:"lang"`
+// OpenidList 用户列表
+type OpenidList struct {
+	Total int `json:"total"`
+	Count int `json:"count"`
+	Data  struct {
+		OpenIDs []string `json:"openid"`
+	} `json:"data"`
+	NextOpenID string `json:"next_openid"`
 }
 
-
 //GetUserInfo 获取用户基本信息
 func (user *User) GetUserInfo(openID string) (userInfo *Info, err error) {
 	var accessToken string
@@ -98,32 +104,51 @@ func (user *User) UpdateRemark(openID, remark string) (err error) {
 	return util.DecodeWithCommonError(response, "UpdateRemark")
 }
 
-// BatchGetUser 批量获取用户基本信息
-func (user *User) BatchGetUser(batchUserQuery ... *BatchUserQuery)([]*Info, error){
-	if len(batchUserQuery)>100{
-		return nil, errors.New("最多支持一次拉取100条")
-	}
-	var accessToken string
+// ListUserOpenIDs 返回用户列表
+func (user *User) ListUserOpenIDs(nextOpenid ...string) (*OpenidList, error) {
 	accessToken, err := user.GetAccessToken()
 	if err != nil {
 		return nil, err
 	}
 
-	requestMap := make(map[string]interface{})
-	requestMap["user_list"] = batchUserQuery
-	uri := fmt.Sprintf(batchGetUserInfoURL, accessToken)
-	response, err := util.PostJSON(uri, requestMap)
+	uri, _ := url.Parse(userListURL)
+	q := uri.Query()
+	q.Set("access_token", accessToken)
+	if len(nextOpenid) > 0 && nextOpenid[0] != "" {
+		q.Set("next_openid", nextOpenid[0])
+	}
+	uri.RawQuery = q.Encode()
+
+	response, err := util.HTTPGet(uri.String())
 	if err != nil {
 		return nil, err
 	}
-	//	batchUserQueryResponse 批量查询返回值
-	type batchUserQueryResponse struct {
-		List    []*Info		`json:"user_info_list"`
-	}
-	userList := &batchUserQueryResponse{}
-	err = json.Unmarshal(response, userList)
+
+	userlist := new(OpenidList)
+	err = json.Unmarshal(response, userlist)
 	if err != nil {
 		return nil, err
 	}
-	return userList.List, nil
-}
+
+	return userlist, nil
+}
+
+// ListAllUserOpenIDs 返回所有用户OpenID列表
+func (user *User) ListAllUserOpenIDs() ([]string, error) {
+	nextOpenid := ""
+	openids := []string{}
+	count := 0
+	for {
+		ul, err := user.ListUserOpenIDs(nextOpenid)
+		if err != nil {
+			return nil, err
+		}
+		openids = append(openids, ul.Data.OpenIDs...)
+		count += ul.Count
+		if ul.Total > count {
+			nextOpenid = ul.NextOpenID
+		} else {
+			return openids, nil
+		}
+	}
+}

+ 95 - 0
util/http.go

@@ -2,11 +2,15 @@ package util
 
 import (
 	"bytes"
+	"crypto/tls"
 	"encoding/json"
+	"encoding/pem"
 	"encoding/xml"
 	"fmt"
+	"golang.org/x/crypto/pkcs12"
 	"io"
 	"io/ioutil"
+	"log"
 	"mime/multipart"
 	"net/http"
 	"os"
@@ -50,6 +54,32 @@ func PostJSON(uri string, obj interface{}) ([]byte, error) {
 	return ioutil.ReadAll(response.Body)
 }
 
+// PostJSONWithRespContentType post json数据请求,且返回数据类型
+func PostJSONWithRespContentType(uri string, obj interface{}) ([]byte, string, error) {
+	jsonData, err := json.Marshal(obj)
+	if err != nil {
+		return nil, "", err
+	}
+
+	jsonData = bytes.Replace(jsonData, []byte("\\u003c"), []byte("<"), -1)
+	jsonData = bytes.Replace(jsonData, []byte("\\u003e"), []byte(">"), -1)
+	jsonData = bytes.Replace(jsonData, []byte("\\u0026"), []byte("&"), -1)
+
+	body := bytes.NewBuffer(jsonData)
+	response, err := http.Post(uri, "application/json;charset=utf-8", body)
+	if err != nil {
+		return nil, "", err
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusOK {
+		return nil, "", fmt.Errorf("http get error : uri=%v , statusCode=%v", uri, response.StatusCode)
+	}
+	responseData, err := ioutil.ReadAll(response.Body)
+	contentType := response.Header.Get("Content-Type")
+	return responseData, contentType, err
+}
+
 //PostFile 上传文件
 func PostFile(fieldname, filename, uri string) ([]byte, error) {
 	fields := []MultipartFormField{
@@ -141,3 +171,68 @@ func PostXML(uri string, obj interface{}) ([]byte, error) {
 	}
 	return ioutil.ReadAll(response.Body)
 }
+
+//httpWithTLS CA证书
+func httpWithTLS(rootCa, key string) (*http.Client, error) {
+	var client *http.Client
+	certData, err := ioutil.ReadFile(rootCa)
+	if err != nil {
+		return nil, fmt.Errorf("unable to find cert path=%s, error=%v", rootCa, err)
+	}
+	cert := pkcs12ToPem(certData, key)
+	config := &tls.Config{
+		Certificates: []tls.Certificate{cert},
+	}
+	tr := &http.Transport{
+		TLSClientConfig:    config,
+		DisableCompression: true,
+	}
+	client = &http.Client{Transport: tr}
+	return client, nil
+}
+
+//pkcs12ToPem 将Pkcs12转成Pem
+func pkcs12ToPem(p12 []byte, password string) tls.Certificate {
+	blocks, err := pkcs12.ToPEM(p12, password)
+	defer func() {
+		if x := recover(); x != nil {
+			log.Print(x)
+		}
+	}()
+	if err != nil {
+		panic(err)
+	}
+	var pemData []byte
+	for _, b := range blocks {
+		pemData = append(pemData, pem.EncodeToMemory(b)...)
+	}
+	cert, err := tls.X509KeyPair(pemData, pemData)
+	if err != nil {
+		panic(err)
+	}
+	return cert
+}
+
+//PostXMLWithTLS perform a HTTP/POST request with XML body and TLS
+func PostXMLWithTLS(uri string, obj interface{}, ca, key string) ([]byte, error) {
+	xmlData, err := xml.Marshal(obj)
+	if err != nil {
+		return nil, err
+	}
+
+	body := bytes.NewBuffer(xmlData)
+	client, err := httpWithTLS(ca, key)
+	if err != nil {
+		return nil, err
+	}
+	response, err := client.Post(uri, "application/xml;charset=utf-8", body)
+	if err != nil {
+		return nil, err
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("http code error : uri=%v , statusCode=%v", uri, response.StatusCode)
+	}
+	return ioutil.ReadAll(response.Body)
+}

+ 24 - 0
vendor/vendor.json

@@ -80,6 +80,18 @@
 			"revision": "8ee79997227bf9b34611aee7946ae64735e6fd93",
 			"revisionTime": "2016-11-17T03:31:26Z"
 		},
+		{
+			"checksumSHA1": "w3QCCIYHgZzIXQ+xTl7oLfFrXHs=",
+			"path": "github.com/gomodule/redigo/internal",
+			"revision": "2cd21d9966bf7ff9ae091419744f0b3fb0fecace",
+			"revisionTime": "2018-06-27T14:45:07Z"
+		},
+		{
+			"checksumSHA1": "To/N5YA/FD0Rrs6r2OOmHXgxYwI=",
+			"path": "github.com/gomodule/redigo/redis",
+			"revision": "2cd21d9966bf7ff9ae091419744f0b3fb0fecace",
+			"revisionTime": "2018-06-27T14:45:07Z"
+		},
 		{
 			"checksumSHA1": "b0T0Hzd+zYk+OCDTFMps+jwa/nY=",
 			"path": "github.com/manucorporat/sse",
@@ -92,6 +104,18 @@
 			"revision": "30a891c33c7cde7b02a981314b4228ec99380cca",
 			"revisionTime": "2016-11-23T14:36:37Z"
 		},
+		{
+			"checksumSHA1": "PJY7uCr3UnX4/Mf/RoWnbieSZ8o=",
+			"path": "golang.org/x/crypto/pkcs12",
+			"revision": "9f005a07e0d31d45e6656d241bb5c0f2efd4bc94",
+			"revisionTime": "2017-09-21T17:41:56Z"
+		},
+		{
+			"checksumSHA1": "iVJcif9M9uefvvqHCNR9VQrjc/s=",
+			"path": "golang.org/x/crypto/pkcs12/internal/rc2",
+			"revision": "9f005a07e0d31d45e6656d241bb5c0f2efd4bc94",
+			"revisionTime": "2017-09-21T17:41:56Z"
+		},
 		{
 			"checksumSHA1": "pancewZW3HwGvpDwfH5Imrbadc4=",
 			"path": "golang.org/x/net/context",

+ 12 - 0
wechat.go

@@ -9,8 +9,10 @@ import (
 	"github.com/silenceper/wechat/js"
 	"github.com/silenceper/wechat/material"
 	"github.com/silenceper/wechat/menu"
+	"github.com/silenceper/wechat/miniprogram"
 	"github.com/silenceper/wechat/oauth"
 	"github.com/silenceper/wechat/pay"
+	"github.com/silenceper/wechat/qr"
 	"github.com/silenceper/wechat/server"
 	"github.com/silenceper/wechat/template"
 	"github.com/silenceper/wechat/user"
@@ -99,3 +101,13 @@ func (wc *Wechat) GetTemplate() *template.Template {
 func (wc *Wechat) GetPay() *pay.Pay {
 	return pay.NewPay(wc.Context)
 }
+
+// GetQR 返回二维码的实例
+func (wc *Wechat) GetQR() *qr.QR {
+	return qr.NewQR(wc.Context)
+}
+
+// GetMiniProgram 获取小程序的实例
+func (wc *Wechat) GetMiniProgram() *miniprogram.MiniProgram {
+	return miniprogram.NewMiniProgram(wc.Context)
+}