From 09994b5aba5224c0f711f49960ffae9878d8e3e4 Mon Sep 17 00:00:00 2001 From: jiangyong27 Date: Sun, 13 Aug 2023 21:24:54 +0800 Subject: [PATCH] bx --- base/wechat/cache/cache.go | 11 + base/wechat/cache/memcache.go | 57 ++++ base/wechat/cache/memcache_test.go | 28 ++ base/wechat/cache/memory.go | 74 +++++ base/wechat/cache/redis.go | 109 ++++++++ base/wechat/cache/redis_test.go | 32 +++ base/wechat/context/access_token.go | 85 ++++++ base/wechat/context/access_token_test.go | 30 +++ base/wechat/context/component_access_token.go | 221 +++++++++++++++ base/wechat/context/component_test.go | 19 ++ base/wechat/context/context.go | 58 ++++ base/wechat/context/qy_access_token.go | 76 ++++++ base/wechat/context/render.go | 43 +++ base/wechat/message/customer_message.go | 160 +++++++++++ base/wechat/message/image.go | 17 ++ base/wechat/message/message.go | 228 ++++++++++++++++ base/wechat/message/music.go | 24 ++ base/wechat/message/news.go | 35 +++ base/wechat/message/ransfer_customer.go | 24 ++ base/wechat/message/reply.go | 15 ++ base/wechat/message/template.go | 74 +++++ base/wechat/message/text.go | 14 + base/wechat/message/video.go | 21 ++ base/wechat/message/voice.go | 17 ++ base/wechat/readme.txt | 1 + base/wechat/server/server.go | 244 +++++++++++++++++ base/wechat/util/crypto.go | 199 ++++++++++++++ base/wechat/util/error.go | 51 ++++ base/wechat/util/http.go | 252 ++++++++++++++++++ base/wechat/util/signature.go | 18 ++ base/wechat/util/signature_test.go | 11 + base/wechat/util/string.go | 18 ++ base/wechat/util/time.go | 8 + base/wechat/wechat.go | 59 ++++ common/config/config.go | 2 + common/config/const.go | 10 + common/dao/approval_refund.go | 66 +++++ common/model/approval_refund.go | 20 ++ common/weixin/checkin.go | 9 - common/weixin/qypay.go | 2 +- common/weixin/{params.go => qypay_params.go} | 0 common/weixin/qyweixin.go | 170 +++--------- common/weixin/qyweixin_agent.go | 94 +++++++ common/weixin/qyweixin_approve.go | 96 +++++++ common/weixin/qyweixin_checkin.go | 128 +++++++++ common/weixin/qyweixin_util.go | 18 ++ conf/server.conf.dev | 2 + conf/server.conf.prod | 3 + go.mod | 28 ++ server/controller/base.go | 50 ++++ server/controller/qyweixin.go | 22 ++ server/server.go | 22 +- server/service/qyweixin_approve.go | 137 ++++++++++ server/service/qyweixin_approve_test.go | 39 +++ worker/autopay.go | 2 +- worker/checkin.go | 2 +- 56 files changed, 3109 insertions(+), 146 deletions(-) create mode 100644 base/wechat/cache/cache.go create mode 100644 base/wechat/cache/memcache.go create mode 100644 base/wechat/cache/memcache_test.go create mode 100644 base/wechat/cache/memory.go create mode 100644 base/wechat/cache/redis.go create mode 100644 base/wechat/cache/redis_test.go create mode 100644 base/wechat/context/access_token.go create mode 100644 base/wechat/context/access_token_test.go create mode 100644 base/wechat/context/component_access_token.go create mode 100644 base/wechat/context/component_test.go create mode 100644 base/wechat/context/context.go create mode 100644 base/wechat/context/qy_access_token.go create mode 100644 base/wechat/context/render.go create mode 100644 base/wechat/message/customer_message.go create mode 100644 base/wechat/message/image.go create mode 100644 base/wechat/message/message.go create mode 100644 base/wechat/message/music.go create mode 100644 base/wechat/message/news.go create mode 100644 base/wechat/message/ransfer_customer.go create mode 100644 base/wechat/message/reply.go create mode 100644 base/wechat/message/template.go create mode 100644 base/wechat/message/text.go create mode 100644 base/wechat/message/video.go create mode 100644 base/wechat/message/voice.go create mode 100644 base/wechat/readme.txt create mode 100644 base/wechat/server/server.go create mode 100644 base/wechat/util/crypto.go create mode 100644 base/wechat/util/error.go create mode 100644 base/wechat/util/http.go create mode 100644 base/wechat/util/signature.go create mode 100644 base/wechat/util/signature_test.go create mode 100644 base/wechat/util/string.go create mode 100644 base/wechat/util/time.go create mode 100644 base/wechat/wechat.go create mode 100644 common/config/const.go create mode 100644 common/dao/approval_refund.go create mode 100644 common/model/approval_refund.go delete mode 100644 common/weixin/checkin.go rename common/weixin/{params.go => qypay_params.go} (100%) create mode 100644 common/weixin/qyweixin_agent.go create mode 100644 common/weixin/qyweixin_approve.go create mode 100644 common/weixin/qyweixin_checkin.go create mode 100644 common/weixin/qyweixin_util.go create mode 100644 server/controller/base.go create mode 100644 server/controller/qyweixin.go create mode 100644 server/service/qyweixin_approve.go create mode 100644 server/service/qyweixin_approve_test.go diff --git a/base/wechat/cache/cache.go b/base/wechat/cache/cache.go new file mode 100644 index 0000000..d144b8f --- /dev/null +++ b/base/wechat/cache/cache.go @@ -0,0 +1,11 @@ +package cache + +import "time" + +//Cache interface +type Cache interface { + Get(key string) interface{} + Set(key string, val interface{}, timeout time.Duration) error + IsExist(key string) bool + Delete(key string) error +} diff --git a/base/wechat/cache/memcache.go b/base/wechat/cache/memcache.go new file mode 100644 index 0000000..3dee1bc --- /dev/null +++ b/base/wechat/cache/memcache.go @@ -0,0 +1,57 @@ +package cache + +import ( + "encoding/json" + "time" + + "github.com/bradfitz/gomemcache/memcache" +) + +//Memcache struct contains *memcache.Client +type Memcache struct { + conn *memcache.Client +} + +//NewMemcache create new memcache +func NewMemcache(server ...string) *Memcache { + mc := memcache.New(server...) + return &Memcache{mc} +} + +//Get return cached value +func (mem *Memcache) Get(key string) interface{} { + var err error + var item *memcache.Item + if item, err = mem.conn.Get(key); err != nil { + return nil + } + var result interface{} + if err = json.Unmarshal(item.Value, &result); err != nil { + return nil + } + return result +} + +// IsExist check value exists in memcache. +func (mem *Memcache) IsExist(key string) bool { + if _, err := mem.conn.Get(key); err != nil { + return false + } + return true +} + +//Set cached value with key and expire time. +func (mem *Memcache) Set(key string, val interface{}, timeout time.Duration) (err error) { + var data []byte + if data, err = json.Marshal(val); err != nil { + return err + } + + item := &memcache.Item{Key: key, Value: data, Expiration: int32(timeout / time.Second)} + return mem.conn.Set(item) +} + +//Delete delete value in memcache. +func (mem *Memcache) Delete(key string) error { + return mem.conn.Delete(key) +} diff --git a/base/wechat/cache/memcache_test.go b/base/wechat/cache/memcache_test.go new file mode 100644 index 0000000..6b4ea02 --- /dev/null +++ b/base/wechat/cache/memcache_test.go @@ -0,0 +1,28 @@ +package cache + +import ( + "testing" + "time" +) + +func TestMemcache(t *testing.T) { + mem := NewMemcache("127.0.0.1:11211") + var err error + timeoutDuration := 10 * time.Second + if err = mem.Set("username", "silenceper", timeoutDuration); err != nil { + t.Error("set Error", err) + } + + if !mem.IsExist("username") { + t.Error("IsExist Error") + } + + name := mem.Get("username").(string) + if name != "silenceper" { + t.Error("get Error") + } + + if err = mem.Delete("username"); err != nil { + t.Errorf("delete Error , err=%v", err) + } +} diff --git a/base/wechat/cache/memory.go b/base/wechat/cache/memory.go new file mode 100644 index 0000000..5bf115b --- /dev/null +++ b/base/wechat/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 +} diff --git a/base/wechat/cache/redis.go b/base/wechat/cache/redis.go new file mode 100644 index 0000000..7440b9e --- /dev/null +++ b/base/wechat/cache/redis.go @@ -0,0 +1,109 @@ +package cache + +import ( + "encoding/json" + "time" + + "github.com/gomodule/redigo/redis" +) + +//Redis redis cache +type Redis struct { + conn *redis.Pool +} + +//RedisOpts redis 连接属性 +type RedisOpts struct { + Host string `yml:"host" json:"host"` + Password string `yml:"password" json:"password"` + Database int `yml:"database" json:"database"` + MaxIdle int `yml:"max_idle" json:"max_idle"` + MaxActive int `yml:"max_active" json:"max_active"` + IdleTimeout int32 `yml:"idle_timeout" json:"idle_timeout"` //second +} + +//NewRedis 实例化 +func NewRedis(opts *RedisOpts) *Redis { + pool := &redis.Pool{ + MaxActive: opts.MaxActive, + MaxIdle: opts.MaxIdle, + IdleTimeout: time.Second * time.Duration(opts.IdleTimeout), + Dial: func() (redis.Conn, error) { + return redis.Dial("tcp", opts.Host, + redis.DialDatabase(opts.Database), + redis.DialPassword(opts.Password), + ) + }, + TestOnBorrow: func(conn redis.Conn, t time.Time) error { + if time.Since(t) < time.Minute { + return nil + } + _, err := conn.Do("PING") + return err + }, + } + return &Redis{pool} +} + +//SetConn 设置conn +func (r *Redis) SetConn(conn *redis.Pool) { + r.conn = conn +} + +//Get 获取一个值 +func (r *Redis) Get(key string) interface{} { + conn := r.conn.Get() + defer conn.Close() + + var data []byte + var err error + if data, err = redis.Bytes(conn.Do("GET", key)); err != nil { + return nil + } + var reply interface{} + if err = json.Unmarshal(data, &reply); err != nil { + return nil + } + + return reply +} + +//Set 设置一个值 +func (r *Redis) Set(key string, val interface{}, timeout time.Duration) (err error) { + conn := r.conn.Get() + defer conn.Close() + + var data []byte + if data, err = json.Marshal(val); err != nil { + return + } + + _, err = conn.Do("SETEX", key, int64(timeout/time.Second), data) + + return +} + +//IsExist 判断key是否存在 +func (r *Redis) IsExist(key string) bool { + conn := r.conn.Get() + defer conn.Close() + + a, _ := conn.Do("EXISTS", key) + i := a.(int64) + if i > 0 { + return true + } + return false +} + +//Delete 删除 +func (r *Redis) Delete(key string) error { + conn := r.conn.Get() + defer conn.Close() + + if _, err := conn.Do("DEL", key); err != nil { + return err + } + + return nil +} diff --git a/base/wechat/cache/redis_test.go b/base/wechat/cache/redis_test.go new file mode 100644 index 0000000..3ced1d3 --- /dev/null +++ b/base/wechat/cache/redis_test.go @@ -0,0 +1,32 @@ +package cache + +import ( + "testing" + "time" +) + +func TestRedis(t *testing.T) { + opts := &RedisOpts{ + Host: "127.0.0.1:6379", + } + redis := NewRedis(opts) + var err error + timeoutDuration := 1 * time.Second + + if err = redis.Set("username", "silenceper", timeoutDuration); err != nil { + t.Error("set Error", err) + } + + if !redis.IsExist("username") { + t.Error("IsExist Error") + } + + name := redis.Get("username").(string) + if name != "silenceper" { + t.Error("get Error") + } + + if err = redis.Delete("username"); err != nil { + t.Errorf("delete Error , err=%v", err) + } +} diff --git a/base/wechat/context/access_token.go b/base/wechat/context/access_token.go new file mode 100644 index 0000000..cc65883 --- /dev/null +++ b/base/wechat/context/access_token.go @@ -0,0 +1,85 @@ +package context + +import ( + "encoding/json" + "fmt" + "sync" + "time" + + "enterprise/base/wechat/util" +) + +const ( + //AccessTokenURL 获取access_token的接口 + AccessTokenURL = "https://api.weixin.qq.com/cgi-bin/token" +) + +//ResAccessToken struct +type ResAccessToken struct { + util.CommonError + + AccessToken string `json:"access_token"` + 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 { + accessToken = val.(string) + return + } + + //从微信服务器获取 + var resAccessToken ResAccessToken + resAccessToken, err = ctx.GetAccessTokenFromServer() + if err != nil { + return + } + + accessToken = resAccessToken.AccessToken + return +} + +//GetAccessTokenFromServer 强制从微信服务器获取token +func (ctx *Context) GetAccessTokenFromServer() (resAccessToken ResAccessToken, err error) { + url := fmt.Sprintf("%s?grant_type=client_credential&appid=%s&secret=%s", AccessTokenURL, ctx.AppID, ctx.AppSecret) + var body []byte + body, err = util.HTTPGet(url) + if err != nil { + return + } + err = json.Unmarshal(body, &resAccessToken) + if err != nil { + return + } + if resAccessToken.ErrMsg != "" { + err = fmt.Errorf("get access_token error : errcode=%v , errormsg=%v", resAccessToken.ErrCode, resAccessToken.ErrMsg) + return + } + + accessTokenCacheKey := fmt.Sprintf("access_token_%s", ctx.AppID) + expires := resAccessToken.ExpiresIn - 1500 + err = ctx.Cache.Set(accessTokenCacheKey, resAccessToken.AccessToken, time.Duration(expires)*time.Second) + return +} diff --git a/base/wechat/context/access_token_test.go b/base/wechat/context/access_token_test.go new file mode 100644 index 0000000..fdae218 --- /dev/null +++ b/base/wechat/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") + } +} diff --git a/base/wechat/context/component_access_token.go b/base/wechat/context/component_access_token.go new file mode 100644 index 0000000..e40aea4 --- /dev/null +++ b/base/wechat/context/component_access_token.go @@ -0,0 +1,221 @@ +package context + +import ( + "encoding/json" + "fmt" + "time" + + "enterprise/base/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 +} diff --git a/base/wechat/context/component_test.go b/base/wechat/context/component_test.go new file mode 100644 index 0000000..5bf087d --- /dev/null +++ b/base/wechat/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) +} diff --git a/base/wechat/context/context.go b/base/wechat/context/context.go new file mode 100644 index 0000000..b673c88 --- /dev/null +++ b/base/wechat/context/context.go @@ -0,0 +1,58 @@ +package context + +import ( + "net/http" + "sync" + + "enterprise/base/wechat/cache" +) + +// Context struct +type Context struct { + AppID string + AppSecret string + Token string + EncodingAESKey string + PayMchID string + PayNotifyURL string + PayKey string + + Cache cache.Cache + + Writer http.ResponseWriter + Request *http.Request + + //accessTokenLock 读写锁 同一个AppID一个 + accessTokenLock *sync.RWMutex + + //jsAPITicket 读写锁 同一个AppID一个 + jsAPITicketLock *sync.RWMutex + + //accessTokenFunc 自定义获取 access token 的方法 + accessTokenFunc GetAccessTokenFunc +} + +// Query returns the keyed url query value if it exists +func (ctx *Context) Query(key string) string { + value, _ := ctx.GetQuery(key) + return value +} + +// GetQuery is like Query(), it returns the keyed url query value +func (ctx *Context) GetQuery(key string) (string, bool) { + req := ctx.Request + if values, ok := req.URL.Query()[key]; ok && len(values) > 0 { + return values[0], true + } + return "", false +} + +// SetJsAPITicketLock 设置jsAPITicket的lock +func (ctx *Context) SetJsAPITicketLock(lock *sync.RWMutex) { + ctx.jsAPITicketLock = lock +} + +// GetJsAPITicketLock 获取jsAPITicket 的lock +func (ctx *Context) GetJsAPITicketLock() *sync.RWMutex { + return ctx.jsAPITicketLock +} diff --git a/base/wechat/context/qy_access_token.go b/base/wechat/context/qy_access_token.go new file mode 100644 index 0000000..9128ffd --- /dev/null +++ b/base/wechat/context/qy_access_token.go @@ -0,0 +1,76 @@ +package context + +import ( + "encoding/json" + "fmt" + "log" + "sync" + "time" + + "enterprise/base/wechat/util" +) + +const ( + //qyAccessTokenURL 获取access_token的接口 + qyAccessTokenURL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s" +) + +//ResQyAccessToken struct +type ResQyAccessToken struct { + util.CommonError + + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` +} + +//SetQyAccessTokenLock 设置读写锁(一个appID一个读写锁) +func (ctx *Context) SetQyAccessTokenLock(l *sync.RWMutex) { + ctx.accessTokenLock = l +} + +//GetQyAccessToken 获取access_token +func (ctx *Context) GetQyAccessToken() (accessToken string, err error) { + ctx.accessTokenLock.Lock() + defer ctx.accessTokenLock.Unlock() + + accessTokenCacheKey := fmt.Sprintf("qy_access_token_%s", ctx.AppID) + val := ctx.Cache.Get(accessTokenCacheKey) + if val != nil { + accessToken = val.(string) + return + } + + //从微信服务器获取 + var resQyAccessToken ResQyAccessToken + resQyAccessToken, err = ctx.GetQyAccessTokenFromServer() + if err != nil { + return + } + + accessToken = resQyAccessToken.AccessToken + return +} + +//GetQyAccessTokenFromServer 强制从微信服务器获取token +func (ctx *Context) GetQyAccessTokenFromServer() (resQyAccessToken ResQyAccessToken, err error) { + log.Printf("GetQyAccessTokenFromServer") + url := fmt.Sprintf(qyAccessTokenURL, ctx.AppID, ctx.AppSecret) + var body []byte + body, err = util.HTTPGet(url) + if err != nil { + return + } + err = json.Unmarshal(body, &resQyAccessToken) + if err != nil { + return + } + if resQyAccessToken.ErrCode != 0 { + err = fmt.Errorf("get qy_access_token error : errcode=%v , errormsg=%v", resQyAccessToken.ErrCode, resQyAccessToken.ErrMsg) + return + } + + qyAccessTokenCacheKey := fmt.Sprintf("qy_access_token_%s", ctx.AppID) + expires := resQyAccessToken.ExpiresIn - 1500 + err = ctx.Cache.Set(qyAccessTokenCacheKey, resQyAccessToken.AccessToken, time.Duration(expires)*time.Second) + return +} diff --git a/base/wechat/context/render.go b/base/wechat/context/render.go new file mode 100644 index 0000000..e2fb825 --- /dev/null +++ b/base/wechat/context/render.go @@ -0,0 +1,43 @@ +package context + +import ( + "encoding/xml" + "net/http" +) + +var xmlContentType = []string{"application/xml; charset=utf-8"} +var plainContentType = []string{"text/plain; charset=utf-8"} + +//Render render from bytes +func (ctx *Context) Render(bytes []byte) { + //debug + //fmt.Println("response msg = ", string(bytes)) + ctx.Writer.WriteHeader(200) + _, err := ctx.Writer.Write(bytes) + if err != nil { + panic(err) + } +} + +//String render from string +func (ctx *Context) String(str string) { + writeContextType(ctx.Writer, plainContentType) + ctx.Render([]byte(str)) +} + +//XML render to xml +func (ctx *Context) XML(obj interface{}) { + writeContextType(ctx.Writer, xmlContentType) + bytes, err := xml.Marshal(obj) + if err != nil { + panic(err) + } + ctx.Render(bytes) +} + +func writeContextType(w http.ResponseWriter, value []string) { + header := w.Header() + if val := header["Content-Type"]; len(val) == 0 { + header["Content-Type"] = value + } +} diff --git a/base/wechat/message/customer_message.go b/base/wechat/message/customer_message.go new file mode 100644 index 0000000..c8cdd6b --- /dev/null +++ b/base/wechat/message/customer_message.go @@ -0,0 +1,160 @@ +package message + +import ( + "encoding/json" + "enterprise/base/wechat/context" + "enterprise/base/wechat/util" + "fmt" +) + +const ( + customerSendMessage = "https://api.weixin.qq.com/cgi-bin/message/custom/send" +) + +//Manager 消息管理者,可以发送消息 +type Manager struct { + *context.Context +} + +//NewMessageManager 实例化消息管理者 +func NewMessageManager(context *context.Context) *Manager { + return &Manager{ + context, + } +} + +//CustomerMessage 客服消息 +type CustomerMessage struct { + ToUser string `json:"touser"` //接受者OpenID + Msgtype MsgType `json:"msgtype"` //客服消息类型 + Text *MediaText `json:"text,omitempty"` //可选 + Image *MediaResource `json:"image,omitempty"` //可选 + Voice *MediaResource `json:"voice,omitempty"` //可选 + Video *MediaVideo `json:"video,omitempty"` //可选 + Music *MediaMusic `json:"music,omitempty"` //可选 + News *MediaNews `json:"news,omitempty"` //可选 + Mpnews *MediaResource `json:"mpnews,omitempty"` //可选 + Wxcard *MediaWxcard `json:"wxcard,omitempty"` //可选 + Msgmenu *MediaMsgmenu `json:"msgmenu,omitempty"` //可选 + Miniprogrampage *MediaMiniprogrampage `json:"miniprogrampage,omitempty"` //可选 +} + +//NewCustomerTextMessage 文本消息结构体构造方法 +func NewCustomerTextMessage(toUser, text string) *CustomerMessage { + return &CustomerMessage{ + ToUser: toUser, + Msgtype: MsgTypeText, + Text: &MediaText{ + text, + }, + } +} + +//NewCustomerImgMessage 图片消息的构造方法 +func NewCustomerImgMessage(toUser, mediaID string) *CustomerMessage { + return &CustomerMessage{ + ToUser: toUser, + Msgtype: MsgTypeImage, + Image: &MediaResource{ + mediaID, + }, + } +} + +//NewCustomerVoiceMessage 语音消息的构造方法 +func NewCustomerVoiceMessage(toUser, mediaID string) *CustomerMessage { + return &CustomerMessage{ + ToUser: toUser, + Msgtype: MsgTypeVoice, + Voice: &MediaResource{ + mediaID, + }, + } +} + +//MediaText 文本消息的文字 +type MediaText struct { + Content string `json:"content"` +} + +//MediaResource 消息使用的永久素材id +type MediaResource struct { + MediaID string `json:"media_id"` +} + +//MediaVideo 视频消息包含的内容 +type MediaVideo struct { + MediaID string `json:"media_id"` + ThumbMediaID string `json:"thumb_media_id"` + Title string `json:"title"` + Description string `json:"description"` +} + +//MediaMusic 音乐消息包括的内容 +type MediaMusic struct { + Title string `json:"title"` + Description string `json:"description"` + Musicurl string `json:"musicurl"` + Hqmusicurl string `json:"hqmusicurl"` + ThumbMediaID string `json:"thumb_media_id"` +} + +//MediaNews 图文消息的内容 +type MediaNews struct { + Articles []MediaArticles `json:"articles"` +} + +//MediaArticles 图文消息的内容的文章列表中的单独一条 +type MediaArticles struct { + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Picurl string `json:"picurl"` +} + +//MediaMsgmenu 菜单消息的内容 +type MediaMsgmenu struct { + HeadContent string `json:"head_content"` + List []MsgmenuItem `json:"list"` + TailContent string `json:"tail_content"` +} + +//MsgmenuItem 菜单消息的菜单按钮 +type MsgmenuItem struct { + ID string `json:"id"` + Content string `json:"content"` +} + +//MediaWxcard 卡券的id +type MediaWxcard struct { + CardID string `json:"card_id"` +} + +//MediaMiniprogrampage 小程序消息 +type MediaMiniprogrampage struct { + Title string `json:"title"` + Appid string `json:"appid"` + Pagepath string `json:"pagepath"` + ThumbMediaID string `json:"thumb_media_id"` +} + +//Send 发送客服消息 +func (manager *Manager) Send(msg *CustomerMessage) error { + accessToken, err := manager.Context.GetAccessToken() + if err != nil { + return err + } + uri := fmt.Sprintf("%s?access_token=%s", customerSendMessage, accessToken) + response, err := util.PostJSON(uri, msg) + var result util.CommonError + err = json.Unmarshal(response, &result) + if err != nil { + return err + } + if result.ErrCode != 0 { + err = fmt.Errorf("customer msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return err + } + + return nil +} diff --git a/base/wechat/message/image.go b/base/wechat/message/image.go new file mode 100644 index 0000000..93e6bc0 --- /dev/null +++ b/base/wechat/message/image.go @@ -0,0 +1,17 @@ +package message + +//Image 图片消息 +type Image struct { + CommonToken + + Image struct { + MediaID string `xml:"MediaId"` + } `xml:"Image"` +} + +//NewImage 回复图片消息 +func NewImage(mediaID string) *Image { + image := new(Image) + image.Image.MediaID = mediaID + return image +} diff --git a/base/wechat/message/message.go b/base/wechat/message/message.go new file mode 100644 index 0000000..577fdbf --- /dev/null +++ b/base/wechat/message/message.go @@ -0,0 +1,228 @@ +package message + +import ( + "encoding/xml" +) + +// MsgType 基本消息类型 +type MsgType string + +// EventType 事件类型 +type EventType string + +// InfoType 第三方平台授权事件类型 +type InfoType string + +const ( + //MsgTypeText 表示文本消息 + MsgTypeText MsgType = "text" + //MsgTypeImage 表示图片消息 + MsgTypeImage = "image" + //MsgTypeVoice 表示语音消息 + MsgTypeVoice = "voice" + //MsgTypeVideo 表示视频消息 + MsgTypeVideo = "video" + //MsgTypeShortVideo 表示短视频消息[限接收] + MsgTypeShortVideo = "shortvideo" + //MsgTypeLocation 表示坐标消息[限接收] + MsgTypeLocation = "location" + //MsgTypeLink 表示链接消息[限接收] + MsgTypeLink = "link" + //MsgTypeMusic 表示音乐消息[限回复] + MsgTypeMusic = "music" + //MsgTypeNews 表示图文消息[限回复] + MsgTypeNews = "news" + //MsgTypeTransfer 表示消息消息转发到客服 + MsgTypeTransfer = "transfer_customer_service" + //MsgTypeEvent 表示事件推送消息 + MsgTypeEvent = "event" +) + +const ( + //EventSubscribe 订阅 + EventSubscribe EventType = "subscribe" + //EventUnsubscribe 取消订阅 + EventUnsubscribe = "unsubscribe" + //EventScan 用户已经关注公众号,则微信会将带场景值扫描事件推送给开发者 + EventScan = "SCAN" + //EventLocation 上报地理位置事件 + EventLocation = "LOCATION" + //EventClick 点击菜单拉取消息时的事件推送 + EventClick = "CLICK" + //EventView 点击菜单跳转链接时的事件推送 + EventView = "VIEW" + //EventScancodePush 扫码推事件的事件推送 + EventScancodePush = "scancode_push" + //EventScancodeWaitmsg 扫码推事件且弹出“消息接收中”提示框的事件推送 + EventScancodeWaitmsg = "scancode_waitmsg" + //EventPicSysphoto 弹出系统拍照发图的事件推送 + EventPicSysphoto = "pic_sysphoto" + //EventPicPhotoOrAlbum 弹出拍照或者相册发图的事件推送 + EventPicPhotoOrAlbum = "pic_photo_or_album" + //EventPicWeixin 弹出微信相册发图器的事件推送 + EventPicWeixin = "pic_weixin" + //EventLocationSelect 弹出地理位置选择器的事件推送 + EventLocationSelect = "location_select" + //EventTemplateSendJobFinish 发送模板消息推送通知 + EventTemplateSendJobFinish = "TEMPLATESENDJOBFINISH" + //EventWxaMediaCheck 异步校验图片/音频是否含有违法违规内容推送事件 + EventWxaMediaCheck = "wxa_media_check" +) + +const ( + // InfoTypeVerifyTicket 返回ticket + InfoTypeVerifyTicket InfoType = "component_verify_ticket" + // InfoTypeAuthorized 授权 + InfoTypeAuthorized = "authorized" + // InfoTypeUnauthorized 取消授权 + InfoTypeUnauthorized = "unauthorized" + // InfoTypeUpdateAuthorized 更新授权 + InfoTypeUpdateAuthorized = "updateauthorized" +) + +//MixMessage 存放所有微信发送过来的消息和事件 +type MixMessage struct { + CommonToken + + //基本消息 + MsgID int64 `xml:"MsgId"` + Content string `xml:"Content"` + Recognition string `xml:"Recognition"` + PicURL string `xml:"PicUrl"` + MediaID string `xml:"MediaId"` + Format string `xml:"Format"` + ThumbMediaID string `xml:"ThumbMediaId"` + LocationX float64 `xml:"Location_X"` + LocationY float64 `xml:"Location_Y"` + Scale float64 `xml:"Scale"` + Label string `xml:"Label"` + Title string `xml:"Title"` + Description string `xml:"Description"` + URL string `xml:"Url"` + + //事件相关 + Event EventType `xml:"Event"` + EventKey string `xml:"EventKey"` + Ticket string `xml:"Ticket"` + Latitude string `xml:"Latitude"` + Longitude string `xml:"Longitude"` + Precision string `xml:"Precision"` + MenuID string `xml:"MenuId"` + Status string `xml:"Status"` + SessionFrom string `xml:"SessionFrom"` + + ScanCodeInfo struct { + ScanType string `xml:"ScanType"` + ScanResult string `xml:"ScanResult"` + } `xml:"ScanCodeInfo"` + + ApprovalInfo struct { + SpNo string `xml:"SpNo"` + SpName string `xml:"SpName"` + SpStatus int `xml:"SpStatus"` + TemplateId string `xml:"TemplateId"` + ApplyTime int64 `xml:"ApplyTime"` + Applyer struct { + UserId string `xml:"UserId"` + } `xml:"Applyer"` + } `xml:"ApprovalInfo"` + + SendPicsInfo struct { + Count int32 `xml:"Count"` + PicList []EventPic `xml:"PicList>item"` + } `xml:"SendPicsInfo"` + + SendLocationInfo struct { + LocationX float64 `xml:"Location_X"` + LocationY float64 `xml:"Location_Y"` + Scale float64 `xml:"Scale"` + Label string `xml:"Label"` + Poiname string `xml:"Poiname"` + } + + // 第三方平台相关 + InfoType InfoType `xml:"InfoType"` + AppID string `xml:"AppId"` + ComponentVerifyTicket string `xml:"ComponentVerifyTicket"` + AuthorizerAppid string `xml:"AuthorizerAppid"` + AuthorizationCode string `xml:"AuthorizationCode"` + AuthorizationCodeExpiredTime int64 `xml:"AuthorizationCodeExpiredTime"` + PreAuthCode string `xml:"PreAuthCode"` + + // 卡券相关 + CardID string `xml:"CardId"` + RefuseReason string `xml:"RefuseReason"` + IsGiveByFriend int32 `xml:"IsGiveByFriend"` + FriendUserName string `xml:"FriendUserName"` + UserCardCode string `xml:"UserCardCode"` + OldUserCardCode string `xml:"OldUserCardCode"` + OuterStr string `xml:"OuterStr"` + IsRestoreMemberCard int32 `xml:"IsRestoreMemberCard"` + UnionID string `xml:"UnionId"` + + // 内容审核相关 + IsRisky bool `xml:"isrisky"` + ExtraInfoJSON string `xml:"extra_info_json"` + TraceID string `xml:"trace_id"` + StatusCode int `xml:"status_code"` +} + +//EventPic 发图事件推送 +type EventPic struct { + PicMd5Sum string `xml:"PicMd5Sum"` +} + +//EncryptedXMLMsg 安全模式下的消息体 +type EncryptedXMLMsg struct { + XMLName struct{} `xml:"xml" json:"-"` + ToUserName string `xml:"ToUserName" json:"ToUserName"` + EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` +} + +//ResponseEncryptedXMLMsg 需要返回的消息体 +type ResponseEncryptedXMLMsg struct { + XMLName struct{} `xml:"xml" json:"-"` + EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` + MsgSignature string `xml:"MsgSignature" json:"MsgSignature"` + Timestamp int64 `xml:"TimeStamp" json:"TimeStamp"` + Nonce string `xml:"Nonce" json:"Nonce"` +} + +// CDATA 使用该类型,在序列化为 xml 文本时文本会被解析器忽略 +type CDATA string + +// MarshalXML 实现自己的序列化方法 +func (c CDATA) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return e.EncodeElement(struct { + string `xml:",cdata"` + }{string(c)}, start) +} + +// CommonToken 消息中通用的结构 +type CommonToken struct { + XMLName xml.Name `xml:"xml"` + ToUserName CDATA `xml:"ToUserName"` + FromUserName CDATA `xml:"FromUserName"` + CreateTime int64 `xml:"CreateTime"` + MsgType MsgType `xml:"MsgType"` +} + +//SetToUserName set ToUserName +func (msg *CommonToken) SetToUserName(toUserName CDATA) { + msg.ToUserName = toUserName +} + +//SetFromUserName set FromUserName +func (msg *CommonToken) SetFromUserName(fromUserName CDATA) { + msg.FromUserName = fromUserName +} + +//SetCreateTime set createTime +func (msg *CommonToken) SetCreateTime(createTime int64) { + msg.CreateTime = createTime +} + +//SetMsgType set MsgType +func (msg *CommonToken) SetMsgType(msgType MsgType) { + msg.MsgType = msgType +} diff --git a/base/wechat/message/music.go b/base/wechat/message/music.go new file mode 100644 index 0000000..3e010ed --- /dev/null +++ b/base/wechat/message/music.go @@ -0,0 +1,24 @@ +package message + +//Music 音乐消息 +type Music struct { + CommonToken + + Music struct { + Title string `xml:"Title" ` + Description string `xml:"Description" ` + MusicURL string `xml:"MusicUrl" ` + HQMusicURL string `xml:"HQMusicUrl" ` + ThumbMediaID string `xml:"ThumbMediaId"` + } `xml:"Music"` +} + +//NewMusic 回复音乐消息 +func NewMusic(title, description, musicURL, hQMusicURL, thumbMediaID string) *Music { + music := new(Music) + music.Music.Title = title + music.Music.Description = description + music.Music.MusicURL = musicURL + music.Music.ThumbMediaID = thumbMediaID + return music +} diff --git a/base/wechat/message/news.go b/base/wechat/message/news.go new file mode 100644 index 0000000..ee28b0c --- /dev/null +++ b/base/wechat/message/news.go @@ -0,0 +1,35 @@ +package message + +//News 图文消息 +type News struct { + CommonToken + + ArticleCount int `xml:"ArticleCount"` + Articles []*Article `xml:"Articles>item,omitempty"` +} + +//NewNews 初始化图文消息 +func NewNews(articles []*Article) *News { + news := new(News) + news.ArticleCount = len(articles) + news.Articles = articles + return news +} + +//Article 单篇文章 +type Article struct { + Title string `xml:"Title,omitempty"` + Description string `xml:"Description,omitempty"` + PicURL string `xml:"PicUrl,omitempty"` + URL string `xml:"Url,omitempty"` +} + +//NewArticle 初始化文章 +func NewArticle(title, description, picURL, url string) *Article { + article := new(Article) + article.Title = title + article.Description = description + article.PicURL = picURL + article.URL = url + return article +} diff --git a/base/wechat/message/ransfer_customer.go b/base/wechat/message/ransfer_customer.go new file mode 100644 index 0000000..e502cc0 --- /dev/null +++ b/base/wechat/message/ransfer_customer.go @@ -0,0 +1,24 @@ +package message + +//TransferCustomer 转发客服消息 +type TransferCustomer struct { + CommonToken + + TransInfo *TransInfo `xml:"TransInfo,omitempty"` +} + +//TransInfo 转发到指定客服 +type TransInfo struct { + KfAccount string `xml:"KfAccount"` +} + +//NewTransferCustomer 实例化 +func NewTransferCustomer(KfAccount string) *TransferCustomer { + tc := new(TransferCustomer) + if KfAccount != "" { + transInfo := new(TransInfo) + transInfo.KfAccount = KfAccount + tc.TransInfo = transInfo + } + return tc +} diff --git a/base/wechat/message/reply.go b/base/wechat/message/reply.go new file mode 100644 index 0000000..53592f0 --- /dev/null +++ b/base/wechat/message/reply.go @@ -0,0 +1,15 @@ +package message + +import "errors" + +//ErrInvalidReply 无效的回复 +var ErrInvalidReply = errors.New("无效的回复消息") + +//ErrUnsupportReply 不支持的回复类型 +var ErrUnsupportReply = errors.New("不支持的回复消息") + +//Reply 消息回复 +type Reply struct { + MsgType MsgType + MsgData interface{} +} diff --git a/base/wechat/message/template.go b/base/wechat/message/template.go new file mode 100644 index 0000000..57a7179 --- /dev/null +++ b/base/wechat/message/template.go @@ -0,0 +1,74 @@ +package message + +import ( + "encoding/json" + "fmt" + + "enterprise/base/wechat/context" + "enterprise/base/wechat/util" +) + +const ( + templateSendURL = "https://api.weixin.qq.com/cgi-bin/message/template/send" +) + +//Template 模板消息 +type Template struct { + *context.Context +} + +//NewTemplate 实例化 +func NewTemplate(context *context.Context) *Template { + tpl := new(Template) + tpl.Context = context + return tpl +} + +//Message 发送的模板消息内容 +type Message struct { + ToUser string `json:"touser"` // 必须, 接受者OpenID + TemplateID string `json:"template_id"` // 必须, 模版ID + URL string `json:"url,omitempty"` // 可选, 用户点击后跳转的URL, 该URL必须处于开发者在公众平台网站中设置的域中 + Color string `json:"color,omitempty"` // 可选, 整个消息的颜色, 可以不设置 + Data map[string]*DataItem `json:"data"` // 必须, 模板数据 + + MiniProgram struct { + AppID string `json:"appid"` //所需跳转到的小程序appid(该小程序appid必须与发模板消息的公众号是绑定关联关系) + PagePath string `json:"pagepath"` //所需跳转到小程序的具体页面路径,支持带参数,(示例index?foo=bar) + } `json:"miniprogram"` //可选,跳转至小程序地址 +} + +//DataItem 模版内某个 .DATA 的值 +type DataItem struct { + Value string `json:"value"` + Color string `json:"color,omitempty"` +} + +type resTemplateSend struct { + util.CommonError + + MsgID int64 `json:"msgid"` +} + +//Send 发送模板消息 +func (tpl *Template) Send(msg *Message) (msgID int64, err error) { + var accessToken string + accessToken, err = tpl.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", templateSendURL, accessToken) + response, err := util.PostJSON(uri, msg) + + var result resTemplateSend + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + msgID = result.MsgID + return +} diff --git a/base/wechat/message/text.go b/base/wechat/message/text.go new file mode 100644 index 0000000..88ac19a --- /dev/null +++ b/base/wechat/message/text.go @@ -0,0 +1,14 @@ +package message + +//Text 文本消息 +type Text struct { + CommonToken + Content CDATA `xml:"Content"` +} + +//NewText 初始化文本消息 +func NewText(content string) *Text { + text := new(Text) + text.Content = CDATA(content) + return text +} diff --git a/base/wechat/message/video.go b/base/wechat/message/video.go new file mode 100644 index 0000000..a082065 --- /dev/null +++ b/base/wechat/message/video.go @@ -0,0 +1,21 @@ +package message + +//Video 视频消息 +type Video struct { + CommonToken + + Video struct { + MediaID string `xml:"MediaId"` + Title string `xml:"Title,omitempty"` + Description string `xml:"Description,omitempty"` + } `xml:"Video"` +} + +//NewVideo 回复图片消息 +func NewVideo(mediaID, title, description string) *Video { + video := new(Video) + video.Video.MediaID = mediaID + video.Video.Title = title + video.Video.Description = description + return video +} diff --git a/base/wechat/message/voice.go b/base/wechat/message/voice.go new file mode 100644 index 0000000..d76985c --- /dev/null +++ b/base/wechat/message/voice.go @@ -0,0 +1,17 @@ +package message + +//Voice 语音消息 +type Voice struct { + CommonToken + + Voice struct { + MediaID string `xml:"MediaId"` + } `xml:"Voice"` +} + +//NewVoice 回复语音消息 +func NewVoice(mediaID string) *Voice { + voice := new(Voice) + voice.Voice.MediaID = mediaID + return voice +} diff --git a/base/wechat/readme.txt b/base/wechat/readme.txt new file mode 100644 index 0000000..66c1f2c --- /dev/null +++ b/base/wechat/readme.txt @@ -0,0 +1 @@ +主要用到企业微信审批回调功能,开源版本没有ApprovalInfo相关字段,所以复制过来手动添加 \ No newline at end of file diff --git a/base/wechat/server/server.go b/base/wechat/server/server.go new file mode 100644 index 0000000..cc0a8b2 --- /dev/null +++ b/base/wechat/server/server.go @@ -0,0 +1,244 @@ +package server + +import ( + "encoding/xml" + "errors" + "fmt" + "io/ioutil" + "reflect" + "runtime/debug" + "strconv" + + "enterprise/base/wechat/context" + "enterprise/base/wechat/message" + "enterprise/base/wechat/util" +) + +//Server struct +type Server struct { + *context.Context + + debug bool + + openID string + + messageHandler func(message.MixMessage) *message.Reply + + requestRawXMLMsg []byte + requestMsg message.MixMessage + responseRawXMLMsg []byte + responseMsg interface{} + + isSafeMode bool + random []byte + nonce string + timestamp int64 +} + +//NewServer init +func NewServer(context *context.Context) *Server { + srv := new(Server) + srv.Context = context + return srv +} + +// SetDebug set debug field +func (srv *Server) SetDebug(debug bool) { + srv.debug = debug +} + +//Serve 处理微信的请求消息 +func (srv *Server) Serve() error { + if !srv.Validate() { + return fmt.Errorf("请求校验失败") + } + + echostr, exists := srv.GetQuery("echostr") + if exists { + srv.String(echostr) + return nil + } + + response, err := srv.handleRequest() + if err != nil { + return err + } + + //debug + if srv.debug { + fmt.Println("request msg = ", string(srv.requestRawXMLMsg)) + } + + return srv.buildResponse(response) +} + +//Validate 校验请求是否合法 +func (srv *Server) Validate() bool { + if srv.debug { + return true + } + timestamp := srv.Query("timestamp") + nonce := srv.Query("nonce") + signature := srv.Query("signature") + return signature == util.Signature(srv.Token, timestamp, nonce) +} + +//HandleRequest 处理微信的请求 +func (srv *Server) handleRequest() (reply *message.Reply, err error) { + //set isSafeMode + srv.isSafeMode = false + encryptType := srv.Query("encrypt_type") + if encryptType == "aes" { + srv.isSafeMode = true + } + + //set openID + srv.openID = srv.Query("openid") + + var msg interface{} + msg, err = srv.getMessage() + if err != nil { + return + } + mixMessage, success := msg.(message.MixMessage) + if !success { + err = errors.New("消息类型转换失败") + } + srv.requestMsg = mixMessage + reply = srv.messageHandler(mixMessage) + return +} + +//GetOpenID return openID +func (srv *Server) GetOpenID() string { + return srv.openID +} + +//getMessage 解析微信返回的消息 +func (srv *Server) getMessage() (interface{}, error) { + var rawXMLMsgBytes []byte + var err error + if srv.isSafeMode { + var encryptedXMLMsg message.EncryptedXMLMsg + if err := xml.NewDecoder(srv.Request.Body).Decode(&encryptedXMLMsg); err != nil { + return nil, fmt.Errorf("从body中解析xml失败,err=%v", err) + } + + //验证消息签名 + timestamp := srv.Query("timestamp") + srv.timestamp, err = strconv.ParseInt(timestamp, 10, 32) + if err != nil { + return nil, err + } + nonce := srv.Query("nonce") + srv.nonce = nonce + msgSignature := srv.Query("msg_signature") + msgSignatureGen := util.Signature(srv.Token, timestamp, nonce, encryptedXMLMsg.EncryptedMsg) + if msgSignature != msgSignatureGen { + return nil, fmt.Errorf("消息不合法,验证签名失败") + } + + //解密 + srv.random, rawXMLMsgBytes, err = util.DecryptMsg(srv.AppID, encryptedXMLMsg.EncryptedMsg, srv.EncodingAESKey) + if err != nil { + return nil, fmt.Errorf("消息解密失败, err=%v", err) + } + } else { + rawXMLMsgBytes, err = ioutil.ReadAll(srv.Request.Body) + if err != nil { + return nil, fmt.Errorf("从body中解析xml失败, err=%v", err) + } + } + + srv.requestRawXMLMsg = rawXMLMsgBytes + + return srv.parseRequestMessage(rawXMLMsgBytes) +} + +func (srv *Server) parseRequestMessage(rawXMLMsgBytes []byte) (msg message.MixMessage, err error) { + msg = message.MixMessage{} + err = xml.Unmarshal(rawXMLMsgBytes, &msg) + return +} + +//SetMessageHandler 设置用户自定义的回调方法 +func (srv *Server) SetMessageHandler(handler func(message.MixMessage) *message.Reply) { + srv.messageHandler = handler +} + +func (srv *Server) buildResponse(reply *message.Reply) (err error) { + defer func() { + if e := recover(); e != nil { + err = fmt.Errorf("panic error: %v\n%s", e, debug.Stack()) + } + }() + if reply == nil { + //do nothing + return nil + } + msgType := reply.MsgType + switch msgType { + case message.MsgTypeText: + case message.MsgTypeImage: + case message.MsgTypeVoice: + case message.MsgTypeVideo: + case message.MsgTypeMusic: + case message.MsgTypeNews: + case message.MsgTypeTransfer: + default: + err = message.ErrUnsupportReply + return + } + + msgData := reply.MsgData + value := reflect.ValueOf(msgData) + //msgData must be a ptr + kind := value.Kind().String() + if "ptr" != kind { + return message.ErrUnsupportReply + } + + params := make([]reflect.Value, 1) + params[0] = reflect.ValueOf(srv.requestMsg.FromUserName) + value.MethodByName("SetToUserName").Call(params) + + params[0] = reflect.ValueOf(srv.requestMsg.ToUserName) + value.MethodByName("SetFromUserName").Call(params) + + params[0] = reflect.ValueOf(msgType) + value.MethodByName("SetMsgType").Call(params) + + params[0] = reflect.ValueOf(util.GetCurrTs()) + value.MethodByName("SetCreateTime").Call(params) + + srv.responseMsg = msgData + srv.responseRawXMLMsg, err = xml.Marshal(msgData) + return +} + +//Send 将自定义的消息发送 +func (srv *Server) Send() (err error) { + replyMsg := srv.responseMsg + if srv.isSafeMode { + //安全模式下对消息进行加密 + var encryptedMsg []byte + encryptedMsg, err = util.EncryptMsg(srv.random, srv.responseRawXMLMsg, srv.AppID, srv.EncodingAESKey) + if err != nil { + return + } + //TODO 如果获取不到timestamp nonce 则自己生成 + timestamp := srv.timestamp + timestampStr := strconv.FormatInt(timestamp, 10) + msgSignature := util.Signature(srv.Token, timestampStr, srv.nonce, string(encryptedMsg)) + replyMsg = message.ResponseEncryptedXMLMsg{ + EncryptedMsg: string(encryptedMsg), + MsgSignature: msgSignature, + Timestamp: timestamp, + Nonce: srv.nonce, + } + } + if replyMsg != nil { + srv.XML(replyMsg) + } + return +} diff --git a/base/wechat/util/crypto.go b/base/wechat/util/crypto.go new file mode 100644 index 0000000..ce21e36 --- /dev/null +++ b/base/wechat/util/crypto.go @@ -0,0 +1,199 @@ +package util + +import ( + "bufio" + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "fmt" +) + +//EncryptMsg 加密消息 +func EncryptMsg(random, rawXMLMsg []byte, appID, aesKey string) (encrtptMsg []byte, err error) { + defer func() { + if e := recover(); e != nil { + err = fmt.Errorf("panic error: err=%v", e) + return + } + }() + var key []byte + key, err = aesKeyDecode(aesKey) + if err != nil { + panic(err) + } + ciphertext := AESEncryptMsg(random, rawXMLMsg, appID, key) + encrtptMsg = []byte(base64.StdEncoding.EncodeToString(ciphertext)) + return +} + +//AESEncryptMsg ciphertext = AES_Encrypt[random(16B) + msg_len(4B) + rawXMLMsg + appId] +//参考:github.com/chanxuehong/wechat.v2 +func AESEncryptMsg(random, rawXMLMsg []byte, appID string, aesKey []byte) (ciphertext []byte) { + const ( + BlockSize = 32 // PKCS#7 + BlockMask = BlockSize - 1 // BLOCK_SIZE 为 2^n 时, 可以用 mask 获取针对 BLOCK_SIZE 的余数 + ) + + appIDOffset := 20 + len(rawXMLMsg) + contentLen := appIDOffset + len(appID) + amountToPad := BlockSize - contentLen&BlockMask + plaintextLen := contentLen + amountToPad + + plaintext := make([]byte, plaintextLen) + + // 拼接 + copy(plaintext[:16], random) + encodeNetworkByteOrder(plaintext[16:20], uint32(len(rawXMLMsg))) + copy(plaintext[20:], rawXMLMsg) + copy(plaintext[appIDOffset:], appID) + + // PKCS#7 补位 + for i := contentLen; i < plaintextLen; i++ { + plaintext[i] = byte(amountToPad) + } + + // 加密 + block, err := aes.NewCipher(aesKey[:]) + if err != nil { + panic(err) + } + mode := cipher.NewCBCEncrypter(block, aesKey[:16]) + mode.CryptBlocks(plaintext, plaintext) + + ciphertext = plaintext + return +} + +//DecryptMsg 消息解密 +func DecryptMsg(appID, encryptedMsg, aesKey string) (random, rawMsgXMLBytes []byte, err error) { + defer func() { + if e := recover(); e != nil { + err = fmt.Errorf("panic error: err=%v", e) + return + } + }() + var encryptedMsgBytes, key, getAppIDBytes []byte + encryptedMsgBytes, err = base64.StdEncoding.DecodeString(encryptedMsg) + if err != nil { + return + } + key, err = aesKeyDecode(aesKey) + if err != nil { + panic(err) + } + random, rawMsgXMLBytes, getAppIDBytes, err = AESDecryptMsg(encryptedMsgBytes, key) + if err != nil { + err = fmt.Errorf("消息解密失败,%v", err) + return + } + if appID != string(getAppIDBytes) { + err = fmt.Errorf("消息解密校验APPID失败") + return + } + return +} + +func aesKeyDecode(encodedAESKey string) (key []byte, err error) { + if len(encodedAESKey) != 43 { + err = fmt.Errorf("the length of encodedAESKey must be equal to 43") + return + } + key, err = base64.StdEncoding.DecodeString(encodedAESKey + "=") + if err != nil { + return + } + if len(key) != 32 { + err = fmt.Errorf("encodingAESKey invalid") + return + } + return +} + +// AESDecryptMsg ciphertext = AES_Encrypt[random(16B) + msg_len(4B) + rawXMLMsg + appId] +//参考:github.com/chanxuehong/wechat.v2 +func AESDecryptMsg(ciphertext []byte, aesKey []byte) (random, rawXMLMsg, appID []byte, err error) { + const ( + BlockSize = 32 // PKCS#7 + BlockMask = BlockSize - 1 // BLOCK_SIZE 为 2^n 时, 可以用 mask 获取针对 BLOCK_SIZE 的余数 + ) + + if len(ciphertext) < BlockSize { + err = fmt.Errorf("the length of ciphertext too short: %d", len(ciphertext)) + return + } + if len(ciphertext)&BlockMask != 0 { + err = fmt.Errorf("ciphertext is not a multiple of the block size, the length is %d", len(ciphertext)) + return + } + + plaintext := make([]byte, len(ciphertext)) // len(plaintext) >= BLOCK_SIZE + + // 解密 + block, err := aes.NewCipher(aesKey) + if err != nil { + panic(err) + } + mode := cipher.NewCBCDecrypter(block, aesKey[:16]) + mode.CryptBlocks(plaintext, ciphertext) + + // PKCS#7 去除补位 + amountToPad := int(plaintext[len(plaintext)-1]) + if amountToPad < 1 || amountToPad > BlockSize { + err = fmt.Errorf("the amount to pad is incorrect: %d", amountToPad) + return + } + plaintext = plaintext[:len(plaintext)-amountToPad] + + // 反拼接 + // len(plaintext) == 16+4+len(rawXMLMsg)+len(appId) + if len(plaintext) <= 20 { + err = fmt.Errorf("plaintext too short, the length is %d", len(plaintext)) + return + } + rawXMLMsgLen := int(decodeNetworkByteOrder(plaintext[16:20])) + if rawXMLMsgLen < 0 { + err = fmt.Errorf("incorrect msg length: %d", rawXMLMsgLen) + return + } + appIDOffset := 20 + rawXMLMsgLen + if len(plaintext) <= appIDOffset { + err = fmt.Errorf("msg length too large: %d", rawXMLMsgLen) + return + } + + random = plaintext[:16:20] + rawXMLMsg = plaintext[20:appIDOffset:appIDOffset] + appID = plaintext[appIDOffset:] + return +} + +// 把整数 n 格式化成 4 字节的网络字节序 +func encodeNetworkByteOrder(orderBytes []byte, n uint32) { + orderBytes[0] = byte(n >> 24) + orderBytes[1] = byte(n >> 16) + orderBytes[2] = byte(n >> 8) + orderBytes[3] = byte(n) +} + +// 从 4 字节的网络字节序里解析出整数 +func decodeNetworkByteOrder(orderBytes []byte) (n uint32) { + return uint32(orderBytes[0])<<24 | + uint32(orderBytes[1])<<16 | + uint32(orderBytes[2])<<8 | + uint32(orderBytes[3]) +} + +// MD5Sum 计算 32 位长度的 MD5 sum +func MD5Sum(txt string) (sum string) { + h := md5.New() + buf := bufio.NewWriterSize(h, 128) + buf.WriteString(txt) + buf.Flush() + sign := make([]byte, hex.EncodedLen(h.Size())) + hex.Encode(sign, h.Sum(nil)) + sum = string(bytes.ToUpper(sign)) + return +} diff --git a/base/wechat/util/error.go b/base/wechat/util/error.go new file mode 100644 index 0000000..b971c47 --- /dev/null +++ b/base/wechat/util/error.go @@ -0,0 +1,51 @@ +package util + +import ( + "encoding/json" + "fmt" + "reflect" +) + +// CommonError 微信返回的通用错误json +type CommonError struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// DecodeWithCommonError 将返回值按照CommonError解析 +func DecodeWithCommonError(response []byte, apiName string) (err error) { + var commError CommonError + err = json.Unmarshal(response, &commError) + if err != nil { + return + } + if commError.ErrCode != 0 { + return fmt.Errorf("%s Error , errcode=%d , errmsg=%s", apiName, commError.ErrCode, commError.ErrMsg) + } + return nil +} + +// DecodeWithError 将返回值按照解析 +func DecodeWithError(response []byte, obj interface{}, apiName string) error { + err := json.Unmarshal(response, obj) + if err != nil { + return fmt.Errorf("json Unmarshal Error, err=%v", err) + } + responseObj := reflect.ValueOf(obj) + if !responseObj.IsValid() { + return fmt.Errorf("obj is invalid") + } + commonError := responseObj.Elem().FieldByName("CommonError") + if !commonError.IsValid() || commonError.Kind() != reflect.Struct { + return fmt.Errorf("commonError is invalid or not struct") + } + errCode := commonError.FieldByName("ErrCode") + errMsg := commonError.FieldByName("ErrMsg") + if !errCode.IsValid() || !errMsg.IsValid() { + return fmt.Errorf("errcode or errmsg is invalid") + } + if errCode.Int() != 0 { + return fmt.Errorf("%s Error , errcode=%d , errmsg=%s", apiName, errCode.Int(), errMsg.String()) + } + return nil +} diff --git a/base/wechat/util/http.go b/base/wechat/util/http.go new file mode 100644 index 0000000..44b4028 --- /dev/null +++ b/base/wechat/util/http.go @@ -0,0 +1,252 @@ +package util + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "encoding/pem" + "encoding/xml" + "fmt" + "io" + "io/ioutil" + "log" + "mime/multipart" + "net/http" + "os" + + "golang.org/x/crypto/pkcs12" +) + +//HTTPGet get 请求 +func HTTPGet(uri string) ([]byte, error) { + response, err := http.Get(uri) + 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) + } + return ioutil.ReadAll(response.Body) +} + +//HTTPPost post 请求 +func HTTPPost(uri string, data string) ([]byte, error) { + body := bytes.NewBuffer([]byte(data)) + response, err := http.Post(uri, "", 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) + } + return ioutil.ReadAll(response.Body) +} + +//PostJSON post json 数据请求 +func PostJSON(uri string, obj interface{}) ([]byte, 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) + } + 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{ + { + IsFile: true, + Fieldname: fieldname, + Filename: filename, + }, + } + return PostMultipartForm(fields, uri) +} + +//MultipartFormField 保存文件或其他字段信息 +type MultipartFormField struct { + IsFile bool + Fieldname string + Value []byte + Filename string +} + +//PostMultipartForm 上传文件或其他多个字段 +func PostMultipartForm(fields []MultipartFormField, uri string) (respBody []byte, err error) { + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + + for _, field := range fields { + if field.IsFile { + fileWriter, e := bodyWriter.CreateFormFile(field.Fieldname, field.Filename) + if e != nil { + err = fmt.Errorf("error writing to buffer , err=%v", e) + return + } + + fh, e := os.Open(field.Filename) + if e != nil { + err = fmt.Errorf("error opening file , err=%v", e) + return + } + defer fh.Close() + + if _, err = io.Copy(fileWriter, fh); err != nil { + return + } + } else { + partWriter, e := bodyWriter.CreateFormField(field.Fieldname) + if e != nil { + err = e + return + } + valueReader := bytes.NewReader(field.Value) + if _, err = io.Copy(partWriter, valueReader); err != nil { + return + } + } + } + + contentType := bodyWriter.FormDataContentType() + bodyWriter.Close() + + resp, e := http.Post(uri, contentType, bodyBuf) + if e != nil { + err = e + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, err + } + respBody, err = ioutil.ReadAll(resp.Body) + return +} + +//PostXML perform a HTTP/POST request with XML body +func PostXML(uri string, obj interface{}) ([]byte, error) { + xmlData, err := xml.Marshal(obj) + if err != nil { + return nil, err + } + + body := bytes.NewBuffer(xmlData) + response, err := http.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) +} + +//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) +} diff --git a/base/wechat/util/signature.go b/base/wechat/util/signature.go new file mode 100644 index 0000000..a65ee23 --- /dev/null +++ b/base/wechat/util/signature.go @@ -0,0 +1,18 @@ +package util + +import ( + "crypto/sha1" + "fmt" + "io" + "sort" +) + +//Signature sha1签名 +func Signature(params ...string) string { + sort.Strings(params) + h := sha1.New() + for _, s := range params { + io.WriteString(h, s) + } + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/base/wechat/util/signature_test.go b/base/wechat/util/signature_test.go new file mode 100644 index 0000000..9aa2f7f --- /dev/null +++ b/base/wechat/util/signature_test.go @@ -0,0 +1,11 @@ +package util + +import "testing" + +func TestSignature(t *testing.T) { + //abc sig + abc := "a9993e364706816aba3e25717850c26c9cd0d89d" + if abc != Signature("a", "b", "c") { + t.Error("test Signature Error") + } +} diff --git a/base/wechat/util/string.go b/base/wechat/util/string.go new file mode 100644 index 0000000..62b5c13 --- /dev/null +++ b/base/wechat/util/string.go @@ -0,0 +1,18 @@ +package util + +import ( + "math/rand" + "time" +) + +//RandomStr 随机生成字符串 +func RandomStr(length int) string { + str := "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + bytes := []byte(str) + result := []byte{} + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for i := 0; i < length; i++ { + result = append(result, bytes[r.Intn(len(bytes))]) + } + return string(result) +} diff --git a/base/wechat/util/time.go b/base/wechat/util/time.go new file mode 100644 index 0000000..024839c --- /dev/null +++ b/base/wechat/util/time.go @@ -0,0 +1,8 @@ +package util + +import "time" + +//GetCurrTs return current timestamps +func GetCurrTs() int64 { + return time.Now().Unix() +} diff --git a/base/wechat/wechat.go b/base/wechat/wechat.go new file mode 100644 index 0000000..31da402 --- /dev/null +++ b/base/wechat/wechat.go @@ -0,0 +1,59 @@ +package wechat + +import ( + "net/http" + "sync" + + "enterprise/base/wechat/cache" + "enterprise/base/wechat/context" + "enterprise/base/wechat/server" +) + +// Wechat struct +type Wechat struct { + Context *context.Context +} + +// Config for user +type Config struct { + AppID string + AppSecret string + Token string + EncodingAESKey string + PayMchID string //支付 - 商户 ID + PayNotifyURL string //支付 - 接受微信支付结果通知的接口地址 + PayKey string //支付 - 商户后台设置的支付 key + Cache cache.Cache +} + +// NewWechat init +func NewWechat(cfg *Config) *Wechat { + context := new(context.Context) + copyConfigToContext(cfg, context) + return &Wechat{context} +} + +func copyConfigToContext(cfg *Config, context *context.Context) { + context.AppID = cfg.AppID + context.AppSecret = cfg.AppSecret + context.Token = cfg.Token + context.EncodingAESKey = cfg.EncodingAESKey + context.PayMchID = cfg.PayMchID + context.PayKey = cfg.PayKey + context.PayNotifyURL = cfg.PayNotifyURL + context.Cache = cfg.Cache + context.SetAccessTokenLock(new(sync.RWMutex)) + context.SetJsAPITicketLock(new(sync.RWMutex)) +} + +// GetServer 消息管理 +func (wc *Wechat) GetServer(req *http.Request, writer http.ResponseWriter) *server.Server { + wc.Context.Request = req + wc.Context.Writer = writer + return server.NewServer(wc.Context) +} + +//GetAccessToken 获取access_token +func (wc *Wechat) GetAccessToken() (string, error) { + return wc.Context.GetAccessToken() +} diff --git a/common/config/config.go b/common/config/config.go index c275943..c59aec4 100644 --- a/common/config/config.go +++ b/common/config/config.go @@ -50,6 +50,8 @@ type QyWeixin struct { HrSecret string `toml:"hr_secret"` PaySecret string `toml:"pay_secret"` PayAgent string `toml:"pay_agent"` + ApproveAgent string `toml:"approve_agent"` + ApproveSecret string `toml:"approve_secret"` } type WxPay struct { diff --git a/common/config/const.go b/common/config/const.go new file mode 100644 index 0000000..2aab886 --- /dev/null +++ b/common/config/const.go @@ -0,0 +1,10 @@ +package config + +var ( + QyWeixinAgentToken = "yx7fOykWQxbWdkN9bVn7W72zRd" + QyWeixinAgentAesKey = "RyiFPNVQRVMvbfwitAgE27O3pwc2dzqaIx4QuyqeSLv" +) + +var ( + ContextParam = "context_params" +) diff --git a/common/dao/approval_refund.go b/common/dao/approval_refund.go new file mode 100644 index 0000000..779836b --- /dev/null +++ b/common/dao/approval_refund.go @@ -0,0 +1,66 @@ +package dao + +import ( + "enterprise/common/model" + "gorm.io/gorm" + "time" +) + +type ApprovalRefundDao struct { +} + +func NewApprovalRefundDao() *ApprovalRefundDao { + return &ApprovalRefundDao{} +} + +func (d *ApprovalRefundDao) TableName() string { + return "approval_refund" +} + +func (d *ApprovalRefundDao) Create(o *model.ApprovalRefund) (int64, error) { + o.CreateTime = time.Now().Unix() + res := GetDB().Table(d.TableName()).Create(o) + return o.Id, res.Error +} + +func (d *ApprovalRefundDao) Update(o *model.ApprovalRefund) error { + o.UpdateTime = time.Now().Unix() + tx := GetDB().Table(d.TableName()) + res := tx.Save(o) + return res.Error +} + +func (d *ApprovalRefundDao) Delete(id int64) error { + res := GetDB().Table(d.TableName()).Delete(&model.ApprovalRefund{}, id) + return res.Error +} + +func (d *ApprovalRefundDao) Get(id int64) (*model.ApprovalRefund, error) { + var u model.ApprovalRefund + tx := GetDB().Table(d.TableName()) + tx = tx.Where("id = ?", id) + res := tx.First(&u) + if res.Error == gorm.ErrRecordNotFound { + return nil, nil + } + + if res.Error != nil { + return nil, res.Error + } + return &u, nil +} + +func (d *ApprovalRefundDao) GetBySpNo(spNo string) (*model.ApprovalRefund, error) { + var u model.ApprovalRefund + tx := GetDB().Table(d.TableName()) + tx = tx.Where("sp_no = ?", spNo) + res := tx.First(&u) + if res.Error == gorm.ErrRecordNotFound { + return nil, nil + } + + if res.Error != nil { + return nil, res.Error + } + return &u, nil +} diff --git a/common/model/approval_refund.go b/common/model/approval_refund.go new file mode 100644 index 0000000..d820fe5 --- /dev/null +++ b/common/model/approval_refund.go @@ -0,0 +1,20 @@ +package model + +var ( + ApprovalRefundStatusCreated = 1 + ApprovalRefundStatusPayed = 2 +) + +type ApprovalRefund struct { + Id int64 + Username string + SpNo string + RefundType string + RefundDate string + RefundAmount float64 + RefundRemark string + ApplyTime int64 + Status int + CreateTime int64 + UpdateTime int64 +} diff --git a/common/weixin/checkin.go b/common/weixin/checkin.go deleted file mode 100644 index d5fd5d2..0000000 --- a/common/weixin/checkin.go +++ /dev/null @@ -1,9 +0,0 @@ -package weixin - -type UserCheckIn struct { - UserId string - Exception string - Rawdata string - StartTime int64 - EndTime int64 -} diff --git a/common/weixin/qypay.go b/common/weixin/qypay.go index c23394f..e093f51 100644 --- a/common/weixin/qypay.go +++ b/common/weixin/qypay.go @@ -47,7 +47,7 @@ func NewQyPay() *QyPay { } pay.tlsClient = client - pay.qyClient = NewQyWeixin(cfg.QyWeixin.Corpid, cfg.QyWeixin.HrSecret) + pay.qyClient = NewQyWeixin(cfg.QyWeixin.Corpid, cfg.QyWeixin.HrSecret, cfg.QyWeixin.HrAgent) return pay } diff --git a/common/weixin/params.go b/common/weixin/qypay_params.go similarity index 100% rename from common/weixin/params.go rename to common/weixin/qypay_params.go diff --git a/common/weixin/qyweixin.go b/common/weixin/qyweixin.go index e5b0010..ad3704e 100644 --- a/common/weixin/qyweixin.go +++ b/common/weixin/qyweixin.go @@ -6,11 +6,7 @@ import ( "errors" "fmt" log "github.com/sirupsen/logrus" - "github.com/smbrave/goutil" "github.com/spf13/cast" - "gorm.io/gorm/utils" - "math" - "strings" "time" ) @@ -22,148 +18,34 @@ var ( ) type QyWeixin struct { - corpId string - secret string - token string + CorpId string + Secret string + Agent string + Token string tokenExpire int64 } -func NewQyWeixin(corpId, secret string) *QyWeixin { +func NewQyWeixin(corpId, secret, agent string) *QyWeixin { return &QyWeixin{ - corpId: corpId, - secret: secret, - tokenExpire: 0, + CorpId: corpId, + Secret: secret, + Agent: agent, } } -func (q *QyWeixin) refreshToken() error { +func (q *QyWeixin) GetToken() string { if time.Now().Unix() <= q.tokenExpire-600 { - return nil + return q.Token } - - reqUrl := fmt.Sprintf("%s?corpid=%s&corpsecret=%s", urlGetToken, q.corpId, q.secret) - rspBody, err := butil.HttpGet(reqUrl, nil) - if err != nil { - log.Errorf("http url[%s] error :%s", reqUrl, err.Error()) - return err - } - result := make(map[string]interface{}) - if err := json.Unmarshal(rspBody, &result); err != nil { - log.Errorf("http url[%s] result[%s] error :%s", reqUrl, string(rspBody), err.Error()) - return err - } - if cast.ToInt(result["errcode"]) != 0 { - log.Errorf("http url[%s] result[%s] error ", reqUrl, string(rspBody)) - return errors.New(string(rspBody)) - } - - q.token = cast.ToString(result["access_token"]) - q.tokenExpire = time.Now().Unix() + cast.ToInt64(result["expires_in"]) - return nil -} - -func (q *QyWeixin) GetCheckinEmployee(groupIds []string) ([]string, error) { - if err := q.refreshToken(); err != nil { - return nil, err - } - - reqUrl := fmt.Sprintf("%s?access_token=%s", urlGetCheckinRlue, q.token) - rspBody, err := butil.HttpPostJson(reqUrl, nil, []byte("{}")) - if err != nil { - return nil, err - } - result := make(map[string]interface{}) - if err := json.Unmarshal(rspBody, &result); err != nil { - log.Errorf("http url[%s] result[%s] error :%s", reqUrl, string(rspBody), err.Error()) - return nil, err - } - if cast.ToInt(result["errcode"]) != 0 { - log.Errorf("http url[%s] result[%s] error ", reqUrl, string(rspBody)) - return nil, errors.New(string(rspBody)) - } - - resultUser := make([]string, 0) - groups := cast.ToSlice(result["group"]) - for _, group := range groups { - g := cast.ToStringMap(group) - if !utils.Contains(groupIds, cast.ToString(g["groupid"])) { - continue - } - ranges := cast.ToStringMap(g["range"]) - userid := cast.ToStringSlice(ranges["userid"]) - resultUser = append(resultUser, userid...) - } - return resultUser, nil -} - -func (q *QyWeixin) GetCheckinData(day, userId string) (*UserCheckIn, error) { - if err := q.refreshToken(); err != nil { - return nil, err - } - dayTime, _ := time.ParseInLocation("2006-01-02", day, time.Local) - - reqData := make(map[string]interface{}) - reqData["opencheckindatatype"] = 1 - reqData["starttime"] = dayTime.Unix() - reqData["endtime"] = dayTime.Unix() + 86400 - reqData["useridlist"] = []string{userId} - reqUrl := fmt.Sprintf("%s?access_token=%s", urlGetCheckinData, q.token) - rspBody, err := butil.HttpPostJson(reqUrl, nil, []byte(goutil.EncodeJSON(reqData))) - if err != nil { - return nil, err - } - result := make(map[string]interface{}) - if err := json.Unmarshal(rspBody, &result); err != nil { - log.Errorf("http url[%s] result[%s] error :%s", reqUrl, string(rspBody), err.Error()) - return nil, err - } - if cast.ToInt(result["errcode"]) != 0 { - log.Errorf("http url[%s] result[%s] error ", reqUrl, string(rspBody)) - return nil, errors.New(string(rspBody)) - } - - checkindatas := cast.ToSlice(result["checkindata"]) - userData := new(UserCheckIn) - userData.UserId = userId - exception := make([]string, 0) - isException := false - startTime := int64(math.MaxInt64) - endTime := int64(0) - for _, checkdata := range checkindatas { - c := cast.ToStringMap(checkdata) - exceptionType := cast.ToString(c["exception_type"]) - checkinTime := cast.ToInt64(c["checkin_time"]) - if exceptionType != "" { - isException = true - } - - exception = append(exception, exceptionType) - if checkinTime < startTime { - startTime = checkinTime - } - if checkinTime > endTime { - endTime = checkinTime - } - } - if startTime != int64(math.MaxInt64) { - userData.StartTime = startTime - } - userData.EndTime = endTime - userData.Rawdata = goutil.EncodeJSON(checkindatas) - if isException { - userData.Exception = strings.Join(exception, ",") - } - if userData.EndTime == 0 && userData.StartTime == 0 { - return nil, nil - } - return userData, nil + q.refreshToken() + return q.Token } func (q *QyWeixin) GetOpenid(userid string) (string, error) { if err := q.refreshToken(); err != nil { return "", err } - reqUrl := fmt.Sprintf("%s?access_token=%s", urlConvertOpenid, q.token) + reqUrl := fmt.Sprintf("%s?access_token=%s", urlConvertOpenid, q.GetToken()) rspBody, err := butil.HttpPostJson(reqUrl, nil, []byte(fmt.Sprintf(`{"userid" : "%s"}`, userid))) if err != nil { log.Errorf("httpPost url[%s] error :%s", reqUrl, err.Error()) @@ -180,3 +62,29 @@ func (q *QyWeixin) GetOpenid(userid string) (string, error) { } return cast.ToString(result["openid"]), nil } + +func (q *QyWeixin) refreshToken() error { + if time.Now().Unix() <= q.tokenExpire-600 { + return nil + } + + reqUrl := fmt.Sprintf("%s?corpid=%s&corpsecret=%s", urlGetToken, q.CorpId, q.Secret) + rspBody, err := butil.HttpGet(reqUrl, nil) + if err != nil { + log.Errorf("http url[%s] error :%s", reqUrl, err.Error()) + return err + } + result := make(map[string]interface{}) + if err := json.Unmarshal(rspBody, &result); err != nil { + log.Errorf("http url[%s] result[%s] error :%s", reqUrl, string(rspBody), err.Error()) + return err + } + if cast.ToInt(result["errcode"]) != 0 { + log.Errorf("http url[%s] result[%s] error ", reqUrl, string(rspBody)) + return errors.New(string(rspBody)) + } + + q.Token = cast.ToString(result["access_token"]) + q.tokenExpire = time.Now().Unix() + cast.ToInt64(result["expires_in"]) + return nil +} diff --git a/common/weixin/qyweixin_agent.go b/common/weixin/qyweixin_agent.go new file mode 100644 index 0000000..b0ecc92 --- /dev/null +++ b/common/weixin/qyweixin_agent.go @@ -0,0 +1,94 @@ +package weixin + +import ( + "enterprise/base/wechat" + "enterprise/base/wechat/cache" + "enterprise/base/wechat/message" + wutil "enterprise/base/wechat/util" + "enterprise/common/config" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + "net/http" + "strings" +) + +var ( + wechatCache cache.Cache = cache.NewMemory() +) + +type QyWeixinAgentConfig struct { + CorpId string + Secret string + Agent string + Replay func(message.MixMessage) *message.Reply +} + +type QyWeixinAgent struct { + QyWeixin + Token string + AesKey string + config *QyWeixinAgentConfig + replay func(message.MixMessage) *message.Reply +} + +func NewQyWeixinAgent(cfg *QyWeixinAgentConfig) *QyWeixinAgent { + return &QyWeixinAgent{ + config: cfg, + Token: config.QyWeixinAgentToken, + AesKey: config.QyWeixinAgentAesKey, + QyWeixin: QyWeixin{ + CorpId: cfg.CorpId, + Secret: cfg.Secret, + Agent: cfg.Agent, + }, + } +} + +func (q *QyWeixinAgent) Request(ctx *gin.Context) { + + //配置微信参数 + wechatConfig := &wechat.Config{ + AppID: q.config.CorpId, + AppSecret: q.config.Secret, + Token: q.Token, + EncodingAESKey: q.AesKey, + Cache: wechatCache, + } + + // 首次配置 + if strings.ToUpper(ctx.Request.Method) == http.MethodGet { + sign := wutil.Signature(ctx.Query("timestamp"), ctx.Query("echostr"), + ctx.Query("nonce"), wechatConfig.Token) + if sign != ctx.Query("msg_signature") { + log.Errorf("sign error forcheck config") + return + } + + _, resp, err := wutil.DecryptMsg(wechatConfig.AppID, ctx.Query("echostr"), wechatConfig.EncodingAESKey) + if err != nil { + log.Errorf("DecryptMsg failed! error:%s", err.Error()) + return + } + ctx.Data(http.StatusOK, "Content-type: text/plain", resp) + return + } + + // 2.响应消息 + wc := wechat.NewWechat(wechatConfig) + ctx.Request.URL.RawQuery += "&encrypt_type=aes" + server := wc.GetServer(ctx.Request, ctx.Writer) + + server.SetMessageHandler(q.config.Replay) + + server.SetDebug(true) + err := server.Serve() + if err != nil { + log.Errorf("qiye weixin Service err:%s", err.Error()) + return + } + err = server.Send() + if err != nil { + log.Errorf("qiye weixin Send err:%s", err.Error()) + return + } +} diff --git a/common/weixin/qyweixin_approve.go b/common/weixin/qyweixin_approve.go new file mode 100644 index 0000000..ce34c6d --- /dev/null +++ b/common/weixin/qyweixin_approve.go @@ -0,0 +1,96 @@ +package weixin + +import ( + "encoding/json" + butil "enterprise/base/util" + "fmt" + log "github.com/sirupsen/logrus" +) + +type Applyer struct { + Userid string `json:"userid"` + Partyid string `json:"partyid"` +} + +type Option struct { + Key string `json:"key"` + Value []struct { + Text string `json:"text"` + Lang string `json:"lang"` + } `json:"value"` +} + +type Selector struct { + Type string `json:"type"` + Options []*Option `json:"options"` +} + +type ApplyValue struct { + Text string `json:"text"` + Selector *Selector `json:"selector"` + Children []interface{} `json:"children"` + Date struct { + Type string `json:"type"` + Timestamp string `json:"s_timestamp"` + } `json:"date"` + NewMoney string `json:"new_Money"` + Files []struct { + FileId string `json:"file_id"` + } `json:"files"` +} + +type ApplyContent struct { + Control string `json:"control"` + Title []struct { + Text string `json:"text"` + Lang string `json:"lang"` + } `json:"title"` + Value *ApplyValue `json:"value"` +} + +type ApproveDetail struct { + SpNo string `json:"sp_no"` + SpName string `json:"sp_name"` + SpStatus int `json:"sp_status"` + TemplateID string `json:"template_id"` + ApplyTime int64 `json:"apply_time"` + Applyer *Applyer `json:"applyer"` + ApplyData struct { + Contents []*ApplyContent `json:"contents"` + } `json:"apply_data"` +} + +type ApproveDetailRsp struct { + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + Info *ApproveDetail `json:"info"` +} + +type QyWeixinApprove struct { + QyWeixin +} + +func NewQyWeixinApprove(corpId, secret, agent string) *QyWeixinApprove { + return &QyWeixinApprove{ + QyWeixin: QyWeixin{ + CorpId: corpId, + Secret: secret, + Agent: agent, + }, + } +} + +func (q *QyWeixinApprove) GetDetail(spNo string) (*ApproveDetail, error) { + reqUrl := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail?access_token=%s", q.GetToken()) + reqParam := fmt.Sprintf(`{"sp_no":"%s"}`, spNo) + rspBody, err := butil.HttpPostJson(reqUrl, nil, []byte(reqParam)) + if err != nil { + return nil, err + } + var rsp ApproveDetailRsp + if err := json.Unmarshal(rspBody, &rsp); err != nil { + log.Errorf("get body[%s] json error :%s", string(rspBody), err.Error()) + return nil, err + } + return rsp.Info, nil +} diff --git a/common/weixin/qyweixin_checkin.go b/common/weixin/qyweixin_checkin.go new file mode 100644 index 0000000..f900706 --- /dev/null +++ b/common/weixin/qyweixin_checkin.go @@ -0,0 +1,128 @@ +package weixin + +import ( + "encoding/json" + butil "enterprise/base/util" + "errors" + "fmt" + log "github.com/sirupsen/logrus" + "github.com/smbrave/goutil" + "github.com/spf13/cast" + "gorm.io/gorm/utils" + "math" + "strings" + "time" +) + +type UserCheckIn struct { + UserId string + Exception string + Rawdata string + StartTime int64 + EndTime int64 +} + +type QyWeixinCheckin struct { + QyWeixin +} + +func NewQyWeixinCheckin(corpId, secret, agent string) *QyWeixinCheckin { + return &QyWeixinCheckin{ + QyWeixin: QyWeixin{ + CorpId: corpId, + Secret: secret, + Agent: agent, + }, + } +} + +func (q *QyWeixinCheckin) GetCheckinEmployee(groupIds []string) ([]string, error) { + reqUrl := fmt.Sprintf("%s?access_token=%s", urlGetCheckinRlue, q.GetToken()) + rspBody, err := butil.HttpPostJson(reqUrl, nil, []byte("{}")) + if err != nil { + return nil, err + } + result := make(map[string]interface{}) + if err := json.Unmarshal(rspBody, &result); err != nil { + log.Errorf("http url[%s] result[%s] error :%s", reqUrl, string(rspBody), err.Error()) + return nil, err + } + if cast.ToInt(result["errcode"]) != 0 { + log.Errorf("http url[%s] result[%s] error ", reqUrl, string(rspBody)) + return nil, errors.New(string(rspBody)) + } + + resultUser := make([]string, 0) + groups := cast.ToSlice(result["group"]) + for _, group := range groups { + g := cast.ToStringMap(group) + if !utils.Contains(groupIds, cast.ToString(g["groupid"])) { + continue + } + ranges := cast.ToStringMap(g["range"]) + userid := cast.ToStringSlice(ranges["userid"]) + resultUser = append(resultUser, userid...) + } + return resultUser, nil +} + +func (q *QyWeixinCheckin) GetCheckinData(day, userId string) (*UserCheckIn, error) { + + dayTime, _ := time.ParseInLocation("2006-01-02", day, time.Local) + + reqData := make(map[string]interface{}) + reqData["opencheckindatatype"] = 1 + reqData["starttime"] = dayTime.Unix() + reqData["endtime"] = dayTime.Unix() + 86400 + reqData["useridlist"] = []string{userId} + reqUrl := fmt.Sprintf("%s?access_token=%s", urlGetCheckinData, q.GetToken()) + rspBody, err := butil.HttpPostJson(reqUrl, nil, []byte(goutil.EncodeJSON(reqData))) + if err != nil { + return nil, err + } + result := make(map[string]interface{}) + if err := json.Unmarshal(rspBody, &result); err != nil { + log.Errorf("http url[%s] result[%s] error :%s", reqUrl, string(rspBody), err.Error()) + return nil, err + } + if cast.ToInt(result["errcode"]) != 0 { + log.Errorf("http url[%s] result[%s] error ", reqUrl, string(rspBody)) + return nil, errors.New(string(rspBody)) + } + + checkindatas := cast.ToSlice(result["checkindata"]) + userData := new(UserCheckIn) + userData.UserId = userId + exception := make([]string, 0) + isException := false + startTime := int64(math.MaxInt64) + endTime := int64(0) + for _, checkdata := range checkindatas { + c := cast.ToStringMap(checkdata) + exceptionType := cast.ToString(c["exception_type"]) + checkinTime := cast.ToInt64(c["checkin_time"]) + if exceptionType != "" { + isException = true + } + + exception = append(exception, exceptionType) + if checkinTime < startTime { + startTime = checkinTime + } + if checkinTime > endTime { + endTime = checkinTime + } + } + if startTime != int64(math.MaxInt64) { + userData.StartTime = startTime + } + userData.EndTime = endTime + userData.Rawdata = goutil.EncodeJSON(checkindatas) + if isException { + userData.Exception = strings.Join(exception, ",") + } + if userData.EndTime == 0 && userData.StartTime == 0 { + return nil, nil + } + return userData, nil +} diff --git a/common/weixin/qyweixin_util.go b/common/weixin/qyweixin_util.go new file mode 100644 index 0000000..205ca64 --- /dev/null +++ b/common/weixin/qyweixin_util.go @@ -0,0 +1,18 @@ +package weixin + +import ( + "crypto/sha1" + "fmt" + "io" + "sort" +) + +//Signature sha1签名 +func Signature(params ...string) string { + sort.Strings(params) + h := sha1.New() + for _, s := range params { + io.WriteString(h, s) + } + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/conf/server.conf.dev b/conf/server.conf.dev index db96aed..60b7d47 100644 --- a/conf/server.conf.dev +++ b/conf/server.conf.dev @@ -22,6 +22,8 @@ hr_agent = "3010185" hr_secret = "Ko2UQWZPbdM9N1snukp_1CT_3J7CcReyPAzl3ww2xoo" enterprise_agent = "1000009" enterprise_secret = "oMB24UhKe50-XPTg7vhnwoTuhEXaq5XeiHPAUtF4hOs" +approve_secret = "xJOClC5V2pPon1azgrAzf5kq1TB72xZ3ScR7O5G3lQo" +approve_agent = "3010040" checkin_agent = "3010011" checkin_secret = "6ljYNGt4DonZLmr9SCtgkTlOvtqmsOchBrTWwGl_GpU" checkin_group = "1,2" diff --git a/conf/server.conf.prod b/conf/server.conf.prod index bb05f9d..8c6bd6d 100644 --- a/conf/server.conf.prod +++ b/conf/server.conf.prod @@ -21,6 +21,8 @@ hr_agent = "3010185" hr_secret = "Ko2UQWZPbdM9N1snukp_1CT_3J7CcReyPAzl3ww2xoo" enterprise_agent = "1000009" enterprise_secret = "oMB24UhKe50-XPTg7vhnwoTuhEXaq5XeiHPAUtF4hOs" +approve_agent = "3010040" +approve_secret = "xJOClC5V2pPon1azgrAzf5kq1TB72xZ3ScR7O5G3lQo" checkin_agent = "3010011" checkin_secret = "6ljYNGt4DonZLmr9SCtgkTlOvtqmsOchBrTWwGl_GpU" checkin_group = "1,2" @@ -29,6 +31,7 @@ checkin_pay_thresold = 11 pay_secret = "JCGsxntR4E7wrEEQvWGr8_wdKtRlw48n-W6zd8lbwc4" pay_agent = "3010046" + [wxpay] pay_key_pem = "conf/wxpay/apiclient_key.pem" pay_cert_pem = "conf/wxpay/apiclient_cert.pem" diff --git a/go.mod b/go.mod index ff8a587..0823916 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,13 @@ go 1.18 require ( github.com/ArtisanCloud/PowerWeChat/v3 v3.0.56 + github.com/gin-gonic/gin v1.9.1 github.com/go-co-op/gocron v1.31.0 + github.com/gogap/errors v0.0.0-20210818113853-edfbba0ddea9 github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible github.com/mitchellh/mapstructure v1.5.0 github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 + github.com/silenceper/wechat v1.2.6 github.com/sirupsen/logrus v1.9.3 github.com/smbrave/goutil v0.0.0-20230602040814-2643c72c2849 github.com/spf13/cast v1.5.1 @@ -20,16 +23,35 @@ require ( require ( github.com/ArtisanCloud/PowerLibs/v3 v3.0.12 // indirect github.com/ArtisanCloud/PowerSocialite/v3 v3.0.6 // indirect + github.com/bradfitz/gomemcache v0.0.0-20160117192205-fb1f79c6b65a // indirect + github.com/bytedance/sonic v1.9.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect + github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gogap/stack v0.0.0-20150131034635-fef68dddd4f8 // indirect + github.com/gomodule/redigo v1.8.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect github.com/lestrrat-go/strftime v1.0.6 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -39,11 +61,17 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.8.0 // indirect go.uber.org/zap v1.21.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.9.0 // indirect + golang.org/x/net v0.10.0 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/server/controller/base.go b/server/controller/base.go new file mode 100644 index 0000000..ad76b43 --- /dev/null +++ b/server/controller/base.go @@ -0,0 +1,50 @@ +package controller + +import ( + "enterprise/common/config" + "fmt" + "github.com/gin-gonic/gin" + "github.com/gogap/errors" + log "github.com/sirupsen/logrus" + "github.com/smbrave/goutil" + "net/http" + "runtime" + "time" +) + +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` +} + +type Base struct { +} + +func (c *Base) Recovery(ctx *gin.Context) { + defer func() { + if err := recover(); err != nil { + var rsp Response + if e, ok := err.(errors.ErrCode); ok { + rsp.Code = int(e.Code()) + rsp.Message = e.Error() + ctx.JSON(http.StatusOK, rsp) + log.Errorf("[%s][%s][%s] %s", ctx.Request.Method, ctx.Request.URL.Path, ctx.Request.URL.RawQuery, e.Error()) + } else { + var buf [2 << 10]byte + stack := string(buf[:runtime.Stack(buf[:], true)]) + log.Errorf("[%s][%s][%s] Internal ERROR:::%v stack:%s", ctx.Request.Method, ctx.Request.URL.Path, ctx.Request.URL.RawQuery, err, stack) + rsp.Code = -1 + rsp.Message = fmt.Sprintf("%v", err) + ctx.JSON(http.StatusOK, rsp) + } + ctx.Abort() + } + }() + start := time.Now() + ctx.Next() + + log.Infof("[%s][%s] cost[%s] query[%s] params[%s]", + ctx.Request.Method, ctx.Request.URL.Path, time.Since(start), + ctx.Request.URL.RawQuery, goutil.EncodeJSON(ctx.Keys[config.ContextParam])) +} diff --git a/server/controller/qyweixin.go b/server/controller/qyweixin.go new file mode 100644 index 0000000..7f662cd --- /dev/null +++ b/server/controller/qyweixin.go @@ -0,0 +1,22 @@ +package controller + +import ( + "enterprise/common/config" + "enterprise/common/weixin" + "enterprise/server/service" + "github.com/gin-gonic/gin" +) + +type QyWeixin struct { +} + +func (q *QyWeixin) Approve(ctx *gin.Context) { + cfg := config.GetConfig() + agent := weixin.NewQyWeixinAgent(&weixin.QyWeixinAgentConfig{ + CorpId: cfg.QyWeixin.Corpid, + Secret: cfg.QyWeixin.ApproveSecret, + Agent: cfg.QyWeixin.ApproveAgent, + Replay: (&service.Approve{}).Reply, + }) + agent.Request(ctx) +} diff --git a/server/server.go b/server/server.go index e2beb28..02389af 100644 --- a/server/server.go +++ b/server/server.go @@ -1,6 +1,22 @@ package server -func Start() error { - select {} - return nil +import ( + "enterprise/common/config" + "enterprise/server/controller" + "github.com/gin-gonic/gin" +) + +func initRoutge(engine *gin.Engine) { + qyweixin := new(controller.QyWeixin) + base := new(controller.Base) + apiGroup := engine.Group("/api") + apiGroup.Use(base.Recovery) + apiGroup.Any("/qyweixin/approve", qyweixin.Approve) +} + +func Start() error { + cfg := config.GetConfig() + engine := gin.New() + initRoutge(engine) + return engine.Run(cfg.Server.Address) } diff --git a/server/service/qyweixin_approve.go b/server/service/qyweixin_approve.go new file mode 100644 index 0000000..d3c237e --- /dev/null +++ b/server/service/qyweixin_approve.go @@ -0,0 +1,137 @@ +package service + +import ( + "enterprise/base/wechat/message" + "enterprise/common/config" + "enterprise/common/dao" + "enterprise/common/model" + "enterprise/common/weixin" + "fmt" + log "github.com/sirupsen/logrus" + "github.com/smbrave/goutil" + "github.com/spf13/cast" + "strings" + "time" +) + +var ( + SpStatusCreated = 1 + SpStatusPassed = 2 + SpStatusRefused = 3 + SpStatusCanceled = 4 +) + +type Approve struct { + approveClient *weixin.QyWeixinApprove +} + +func (b *Approve) Reply(msg message.MixMessage) *message.Reply { + cfg := config.GetConfig() + b.approveClient = weixin.NewQyWeixinApprove(cfg.QyWeixin.Corpid, cfg.QyWeixin.ApproveSecret, cfg.QyWeixin.ApproveAgent) + go b.handle(&msg) + return &message.Reply{message.MsgTypeText, message.NewText("")} +} + +func (a *Approve) handle(msg *message.MixMessage) { + msgType := msg.MsgType + + if msgType == message.MsgTypeEvent { + event := strings.ToUpper(string(msg.Event)) + if event == message.EventClick { + a.handleClick(msg) + } else if event == "SYS_APPROVAL_CHANGE" { + a.handleApprovalChange(msg) + } + } else if msgType == message.MsgTypeText { + a.handleText(msg) + } + + log.Infof(goutil.EncodeJSONIndent(msg)) +} + +func (a *Approve) handleApprovalChange(msg *message.MixMessage) { + spStatus := msg.ApprovalInfo.SpStatus + spNo := msg.ApprovalInfo.SpNo + spName := msg.ApprovalInfo.SpName + if spStatus != SpStatusPassed { + //return + } + if spName == "费用报销" { + a.handleRefund(spNo) + } else if spName == "请假" { + a.handleHoliday(spNo) + } +} + +func (a *Approve) handleHoliday(spNo string) { + +} + +func (a *Approve) handleRefund(spNo string) { + detail, err := a.approveClient.GetDetail(spNo) + if err != nil { + log.Errorf("get spn detail error :%s", err.Error()) + return + } + + var refund model.ApprovalRefund + refund.Username = detail.Applyer.Userid + refund.SpNo = detail.SpNo + refund.ApplyTime = detail.ApplyTime + refund.Status = model.ApprovalRefundStatusCreated + for _, content := range detail.ApplyData.Contents { + key := content.Title[0].Text + var value string + if content.Control == "Selector" { + value = content.Value.Selector.Options[0].Value[0].Text + } else if content.Control == "Text" || content.Control == "Textarea" { + value = content.Value.Text + } else if content.Control == "Date" { + value = content.Value.Date.Timestamp + } else if content.Control == "Money" { + value = content.Value.NewMoney + } else if content.Control == "File" { + value = content.Value.Files[0].FileId + } + if key == "报销类型" { + refund.RefundType = value + } else if key == "发生时间" { + refund.RefundDate = time.Unix(cast.ToInt64(value), 0).Format("2006-01-02") + } else if key == "报销费用" { + refund.RefundAmount = cast.ToFloat64(value) + } else if key == "报销说明" { + refund.RefundRemark = value + } + } + if _, err := dao.NewApprovalRefundDao().Create(&refund); err != nil { + log.Errorf("db error :%s", err.Error()) + return + } + + // 支付费用 + var req weixin.RedMoneyReq + req.BillNo = fmt.Sprintf("BX%s", refund.SpNo) + req.Title = fmt.Sprintf("【%s】报销", refund.RefundType) + req.Userid = refund.Username + req.TotalAmount = int64(100 * refund.RefundAmount) + if err := weixin.NewQyPay().PayRedMoney(&req); err != nil { + log.Errorf("pay error :%s", err.Error()) + return + } + refund.Status = model.ApprovalRefundStatusPayed + if err := dao.NewApprovalRefundDao().Update(&refund); err != nil { + log.Errorf("db error :%s", err.Error()) + } +} + +func (a *Approve) handleText(msg *message.MixMessage) { + +} + +func (a *Approve) handleClick(msg *message.MixMessage) { + +} + +func (a *Approve) sendText(message string) { + +} diff --git a/server/service/qyweixin_approve_test.go b/server/service/qyweixin_approve_test.go new file mode 100644 index 0000000..3c0f46b --- /dev/null +++ b/server/service/qyweixin_approve_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "enterprise/common/weixin" + "fmt" + log "github.com/sirupsen/logrus" + "testing" +) + +func TestApprove_Reply(t *testing.T) { + + approveClient := weixin.NewQyWeixinApprove("ww43c49db2e88a17f8", "xJOClC5V2pPon1azgrAzf5kq1TB72xZ3ScR7O5G3lQo", "3010040") + fmt.Println("1") + detail, err := approveClient.GetDetail("202308130004") + fmt.Println("1") + if err != nil { + log.Errorf("error:%s", err.Error()) + } + fmt.Println("1") + + for _, content := range detail.ApplyData.Contents { + + var value string + if content.Control == "Selector" { + value = content.Value.Selector.Options[0].Value[0].Text + } else if content.Control == "Text" || content.Control == "Textarea" { + value = content.Value.Text + } else if content.Control == "Date" { + value = content.Value.Date.Timestamp + } else if content.Control == "Money" { + value = content.Value.NewMoney + } else if content.Control == "File" { + value = content.Value.Files[0].FileId + } + fmt.Println(content.Title[0].Text, value) + + } + fmt.Println("2") +} diff --git a/worker/autopay.go b/worker/autopay.go index 81f57a4..fcd901e 100644 --- a/worker/autopay.go +++ b/worker/autopay.go @@ -113,7 +113,7 @@ func autoPayMoney(checkin *model.Checkin, checkinType string, payMoney int64) er req.TotalAmount = payMoney req.Title = checkinType - req.BillNo = fmt.Sprintf("QY%s%s", time.Now().Format("20060102150405"), butil.RandomStr(6)) + req.BillNo = fmt.Sprintf("DK%s%s", time.Now().Format("20060102150405"), butil.RandomStr(6)) req.Userid = checkin.Username if err := weixin.NewQyPay().PayRedMoney(&req); err != nil { log.Errorf("pay red money error :%s", err.Error()) diff --git a/worker/checkin.go b/worker/checkin.go index 8325b5c..2878d45 100644 --- a/worker/checkin.go +++ b/worker/checkin.go @@ -11,7 +11,7 @@ import ( func SyncCheckin(day string) error { cfg := config.GetConfig() - qyw := weixin.NewQyWeixin(cfg.QyWeixin.Corpid, cfg.QyWeixin.CheckinSecret) + qyw := weixin.NewQyWeixinCheckin(cfg.QyWeixin.Corpid, cfg.QyWeixin.CheckinSecret, cfg.QyWeixin.CheckinAgent) users, err := qyw.GetCheckinEmployee(strings.Split(cfg.QyWeixin.CheckinGroup, ",")) if err != nil { return err