From 408a941ccf21c605280fcf71709a35fc153ee7f1 Mon Sep 17 00:00:00 2001 From: lab Date: Thu, 28 Oct 2021 00:59:04 +0800 Subject: [PATCH] feat: add clientapi: mp.oauth2 --- clientapi/clientapi.go | 1 + clientapi/mp/oauth2/clientcredential.go | 11 ++ clientapi/mp/oauth2/oauth2.go | 137 +++++++++++++++++++ clientapi/mp/oauth2/token.go | 14 ++ clientapi/mp/oauth2/userinfo.go | 18 +++ clientapi/request/errcode.go | 168 ++++++++++++++++++++++++ clientapi/request/errors.go | 25 ++++ clientapi/request/request.go | 95 ++++++++++++++ 8 files changed, 469 insertions(+) create mode 100644 clientapi/clientapi.go create mode 100644 clientapi/mp/oauth2/clientcredential.go create mode 100644 clientapi/mp/oauth2/oauth2.go create mode 100644 clientapi/mp/oauth2/token.go create mode 100644 clientapi/mp/oauth2/userinfo.go create mode 100644 clientapi/request/errcode.go create mode 100644 clientapi/request/errors.go create mode 100644 clientapi/request/request.go diff --git a/clientapi/clientapi.go b/clientapi/clientapi.go new file mode 100644 index 0000000..7f77339 --- /dev/null +++ b/clientapi/clientapi.go @@ -0,0 +1 @@ +package clientapi diff --git a/clientapi/mp/oauth2/clientcredential.go b/clientapi/mp/oauth2/clientcredential.go new file mode 100644 index 0000000..61ef191 --- /dev/null +++ b/clientapi/mp/oauth2/clientcredential.go @@ -0,0 +1,11 @@ +package oauth2 + +import ( + "git.esin.io/lab/weixin/clientapi/request" +) + +type ClientCredential struct { + request.Error + AccessToken string `json:"access_token"` + ExpiresIn int32 `json:"expires_in"` +} diff --git a/clientapi/mp/oauth2/oauth2.go b/clientapi/mp/oauth2/oauth2.go new file mode 100644 index 0000000..cb765af --- /dev/null +++ b/clientapi/mp/oauth2/oauth2.go @@ -0,0 +1,137 @@ +package oauth2 + +import ( + "context" + "net/url" + + "git.esin.io/lab/weixin/clientapi/request" +) + +type Oauth2Client struct { + appId string + appSecret string +} + +type GetCodeURLRequest struct { + RedirectURL, State, Scope string +} + +func (c Oauth2Client) GetCodeURL(redirectURL, state, scope string) string { + endpoint := url.URL{ + Scheme: "https", + Host: "open.weixin.qq.com", + Path: "/connect/oauth2/authorize", + RawQuery: url.Values{ + "appid": {c.appId}, + "redirect_uri": {redirectURL}, + "response_type": {"code"}, + "scope": {scope}, + "state": {state}, + }.Encode(), + Fragment: "wechat_redirect", + } + return endpoint.String() +} + +func (c Oauth2Client) ExchangeToken(ctx context.Context, code string) (*Token, error) { + req := request.New( + "/sns/oauth2/access_token", + url.Values{ + "grant_type": {"authorization_code"}, + "appid": {c.appId}, + "secret": {c.appSecret}, + "code": {code}, + }) + var resp Token + if err := req.Get(ctx, &resp); err != nil { + return nil, err + } + + if err := resp.Err(); err != nil { + return nil, err + } + + return &resp, nil +} + +func (c Oauth2Client) GetUserinfo(ctx context.Context, accessToken, openid string) (*Userinfo, error) { + req := request.New( + "/sns/userinfo", + url.Values{ + "access_token": {accessToken}, + "openid": {openid}, + "lang": {"zh_CN"}, + }, + ) + var resp Userinfo + if err := req.Get(ctx, &resp); err != nil { + return nil, err + } + + if err := resp.Err(); err != nil { + return nil, err + } + return &resp, nil +} + +func (c Oauth2Client) RefreshToken(ctx context.Context, refreshToken, openid string) (*Token, error) { + req := request.New( + "/sns/oauth2/refresh_token", + url.Values{ + "grant_type": {"refresh_token"}, + "appid": {c.appId}, + "refresh_token": {refreshToken}, + }, + ) + + var resp Token + if err := req.Get(ctx, &resp); err != nil { + return nil, err + } + + if err := resp.Err(); err != nil { + return nil, err + } + return &resp, nil +} + +func (c Oauth2Client) ValidateToken(ctx context.Context, accessToken, openid string) error { + req := request.New( + "/sns/auth", + url.Values{ + "access_token": {accessToken}, + "openid": {openid}, + }, + ) + + var resp request.Error + if err := req.Get(ctx, &resp); err != nil { + return err + } + + if err := resp.Err(); err != nil { + return err + } + return nil +} + +func (c Oauth2Client) GetClientCredential(ctx context.Context) (*ClientCredential, error) { + req := request.New( + "/cgi-bin/token", + url.Values{ + "grant_type": {"client_credential"}, + "appid": {c.appId}, + "secret": {c.appSecret}, + }, + ) + + var resp ClientCredential + if err := req.Get(ctx, &resp); err != nil { + return nil, err + } + if err := resp.Err(); err != nil { + return nil, err + } + + return &resp, nil +} diff --git a/clientapi/mp/oauth2/token.go b/clientapi/mp/oauth2/token.go new file mode 100644 index 0000000..3bd4b2a --- /dev/null +++ b/clientapi/mp/oauth2/token.go @@ -0,0 +1,14 @@ +package oauth2 + +import ( + "git.esin.io/lab/weixin/clientapi/request" +) + +type Token struct { + request.Error + AccessToken string `json:"access_token"` //获取到的凭证 + ExpiresIn int32 `json:"expires_in"` //凭证有效时间,单位:秒 + RefreshToken string `json:"refresh_token"` //有效期为30天,当失效之后,需要用户重新授 + OpenID string `json:"openid"` + Scope string `json:"scope"` +} diff --git a/clientapi/mp/oauth2/userinfo.go b/clientapi/mp/oauth2/userinfo.go new file mode 100644 index 0000000..535582f --- /dev/null +++ b/clientapi/mp/oauth2/userinfo.go @@ -0,0 +1,18 @@ +package oauth2 + +import ( + "git.esin.io/lab/weixin/clientapi/request" +) + +type Userinfo struct { + request.Error + OpenID string `json:"openid"` + NickName string `json:"nickname"` + Sex int8 `json:"sex"` + Province string `json:"province"` + City string `json:"city"` + Country string `json:"country"` + HeadImgURL string `json:"headimgurl"` + Privilege []string `json:"privilege"` + UnionID string `json:"unionid"` +} diff --git a/clientapi/request/errcode.go b/clientapi/request/errcode.go new file mode 100644 index 0000000..6203a7c --- /dev/null +++ b/clientapi/request/errcode.go @@ -0,0 +1,168 @@ +package request + +var ErrCodeText = map[int32]string{ + -1: "系统繁忙,此时请开发者稍候再试", + 0: "请求成功", + 40001: "获取 access_token 时 AppSecret 错误,或者 access_token 无效。请开发者认真比对 AppSecret 的正确性,或查看是否正在为恰当的公众号调用接口", + 40002: "不合法的凭证类型", + 40003: "不合法的 OpenID ,请开发者确认 OpenID (该用户)是否已关注公众号,或是否是其他公众号的 OpenID", + 40004: "不合法的媒体文件类型", + 40005: "不合法的文件类型", + 40006: "不合法的文件大小", + 40007: "不合法的媒体文件 id", + 40008: "不合法的消息类型", + 40009: "不合法的图片文件大小", + 40010: "不合法的语音文件大小", + 40011: "不合法的视频文件大小", + 40012: "不合法的缩略图文件大小", + 40013: "不合法的 AppID ,请开发者检查 AppID 的正确性,避免异常字符,注意大小写", + 40014: "不合法的 access_token ,请开发者认真比对 access_token 的有效性(如是否过期),或查看是否正在为恰当的公众号调用接口", + 40015: "不合法的菜单类型", + 40016: "不合法的按钮个数", + 40017: "不合法的按钮类型", + 40018: "不合法的按钮名字长度", + 40019: "不合法的按钮 KEY 长度", + 40020: "不合法的按钮 URL 长度", + 40021: "不合法的菜单版本号", + 40022: "不合法的子菜单级数", + 40023: "不合法的子菜单按钮个数", + 40024: "不合法的子菜单按钮类型", + 40025: "不合法的子菜单按钮名字长度", + 40026: "不合法的子菜单按钮 KEY 长度", + 40027: "不合法的子菜单按钮 URL 长度", + 40028: "不合法的自定义菜单使用用户", + 40029: "无效的 oauth_code", + 40030: "不合法的 refresh_token", + 40031: "不合法的 openid 列表", + 40032: "不合法的 openid 列表长度", + 40033: "不合法的请求字符,不能包含 \\uxxxx 格式的字符", + 40035: "不合法的参数", + 40038: "不合法的请求格式", + 40039: "不合法的 URL 长度", + 40048: "无效的url", + 40050: "不合法的分组 id", + 40051: "分组名字不合法", + 40060: "删除单篇图文时,指定的 article_idx 不合法", + 40117: "分组名字不合法", + 40118: "media_id 大小不合法", + 40119: "button 类型错误", + 40120: "子 button 类型错误", + 40121: "不合法的 media_id 类型", + 40125: "无效的appsecret", + 40132: "微信号不合法", + 40137: "不支持的图片格式", + 40155: "请勿添加其他公众号的主页链接", + 40163: "oauth_code已使用", + 41001: "缺少 access_token 参数", + 41002: "缺少 appid 参数", + 41003: "缺少 refresh_token 参数", + 41004: "缺少 secret 参数", + 41005: "缺少多媒体文件数据", + 41006: "缺少 media_id 参数", + 41007: "缺少子菜单数据", + 41008: "缺少 oauth code", + 41009: "缺少 openid", + 42001: "access_token 超时,请检查 access_token 的有效期,请参考基础支持 - 获取 access_token 中,对 access_token 的详细机制说明", + 42002: "refresh_token 超时", + 42003: "oauth_code 超时", + 42007: "用户修改微信密码, accesstoken 和 refreshtoken 失效,需要重新授权", + 43001: "需要 GET 请求", + 43002: "需要 POST 请求", + 43003: "需要 HTTPS 请求", + 43004: "需要接收者关注", + 43005: "需要好友关系", + 43019: "需要将接收者从黑名单中移除", + 44001: "多媒体文件为空", + 44002: "POST 的数据包为空", + 44003: "图文消息内容为空", + 44004: "文本消息内容为空", + 45001: "多媒体文件大小超过限制", + 45002: "消息内容超过限制", + 45003: "标题字段超过限制", + 45004: "描述字段超过限制", + 45005: "链接字段超过限制", + 45006: "图片链接字段超过限制", + 45007: "语音播放时间超过限制", + 45008: "图文消息超过限制", + 45009: "接口调用超过限制", + 45010: "创建菜单个数超过限制", + 45011: "API 调用太频繁,请稍候再试", + 45015: "回复时间超过限制", + 45016: "系统分组,不允许修改", + 45017: "分组名字过长", + 45018: "分组数量超过上限", + 45047: "客服接口下行条数超过上限", + 45064: "创建菜单包含未关联的小程序", + 45065: "相同 clientmsgid 已存在群发记录,返回数据中带有已存在的群发任务的 msgid", + 45066: "相同 clientmsgid 重试速度过快,请间隔1分钟重试", + 45067: "clientmsgid 长度超过限制", + 46001: "不存在媒体数据", + 46002: "不存在的菜单版本", + 46003: "不存在的菜单数据", + 46004: "不存在的用户", + 47001: "解析 JSON/XML 内容错误", + 48001: "api 功能未授权,请确认公众号已获得该接口,可以在公众平台官网 - 开发者中心页中查看接口权限", + 48002: "粉丝拒收消息(粉丝在公众号选项中,关闭了 “ 接收消息 ” )", + 48004: "api 接口被封禁,请登录 mp.weixin.qq.com 查看详情", + 48005: "api 禁止删除被自动回复和自定义菜单引用的素材", + 48006: "api 禁止清零调用次数,因为清零次数达到上限", + 48008: "没有该类型消息的发送权限", + 50001: "用户未授权该 api", + 50002: "用户受限,可能是违规后接口被封禁", + 50005: "用户未关注公众号", + 61451: "参数错误 (invalid parameter)", + 61452: "无效客服账号 (invalid kf_account)", + 61453: "客服帐号已存在 (kf_account exsited)", + 61454: "客服帐号名长度超过限制 ( 仅允许 10 个英文字符,不包括 @ 及 @ 后的公众号的微信号 )(invalid   kf_acount length)", + 61455: "客服帐号名包含非法字符 ( 仅允许英文 + 数字 )(illegal character in     kf_account)", + 61456: "客服帐号个数超过限制 (10 个客服账号 )(kf_account count exceeded)", + 61457: "无效头像文件类型 (invalid   file type)", + 61450: "系统错误 (system error)", + 61500: "日期格式错误", + 63001: "部分参数为空", + 63002: "无效的签名", + 65301: "不存在此 menuid 对应的个性化菜单", + 65302: "没有相应的用户", + 65303: "没有默认菜单,不能创建个性化菜单", + 65304: "MatchRule 信息为空", + 65305: "个性化菜单数量受限", + 65306: "不支持个性化菜单的帐号", + 65307: "个性化菜单信息为空", + 65308: "包含没有响应类型的 button", + 65309: "个性化菜单开关处于关闭状态", + 65310: "填写了省份或城市信息,国家信息不能为空", + 65311: "填写了城市信息,省份信息不能为空", + 65312: "不合法的国家信息", + 65313: "不合法的省份信息", + 65314: "不合法的城市信息", + 65316: "该公众号的菜单设置了过多的域名外跳(最多跳转到 3 个域名的链接)", + 65317: "不合法的 URL", + 87009: "无效的签名", + 9001001: "POST 数据参数不合法", + 9001002: "远端服务不可用", + 9001003: "Ticket 不合法", + 9001004: "获取摇周边用户信息失败", + 9001005: "获取商户信息失败", + 9001006: "获取 OpenID 失败", + 9001007: "上传文件缺失", + 9001008: "上传素材的文件类型不合法", + 9001009: "上传素材的文件尺寸不合法", + 9001010: "上传失败", + 9001020: "帐号不合法", + 9001021: "已有设备激活率低于 50% ,不能新增设备", + 9001022: "设备申请数不合法,必须为大于 0 的数字", + 9001023: "已存在审核中的设备 ID 申请", + 9001024: "一次查询设备 ID 数量不能超过 50", + 9001025: "设备 ID 不合法", + 9001026: "页面 ID 不合法", + 9001027: "页面参数不合法", + 9001028: "一次删除页面 ID 数量不能超过 10", + 9001029: "页面已应用在设备中,请先解除应用关系再删除", + 9001030: "一次查询页面 ID 数量不能超过 50", + 9001031: "时间区间不合法", + 9001032: "保存设备与页面的绑定关系参数错误", + 9001033: "门店 ID 不合法", + 9001034: "设备备注信息过长", + 9001035: "设备申请参数不合法", + 9001036: "查询起始值 begin 不合法", +} diff --git a/clientapi/request/errors.go b/clientapi/request/errors.go new file mode 100644 index 0000000..e26db96 --- /dev/null +++ b/clientapi/request/errors.go @@ -0,0 +1,25 @@ +package request + +import "fmt" + +type Error struct { + Code int32 `json:"errcode"` + Msg string `json:"errmsg"` +} + +func (err Error) IsZero() bool { + // return reflect.DeepEqual(err, Error{}) || reflect.DeepEqual(err, Error{Code: 0, Msg: "ok"}) + return err.Code == 0 +} + +func (err Error) Error() string { + text := ErrCodeText[err.Code] + return fmt.Sprintf("%d:%s, %s", err.Code, err.Msg, text) +} + +func (err Error) Err() error { + if err.Code == 0 { + return nil + } + return err +} diff --git a/clientapi/request/request.go b/clientapi/request/request.go new file mode 100644 index 0000000..e5d0b6f --- /dev/null +++ b/clientapi/request/request.go @@ -0,0 +1,95 @@ +package request + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" +) + +const ( + defaultHost = "api.weixin.qq.com" +) + +type Option func(*Request) + +type Request struct { + Host string + Path string + Params url.Values + Fragment string +} + +func WithHost(host string) Option { + return func(r *Request) { + r.Host = host + } +} + +func WithFragment(fragment string) Option { + return func(r *Request) { + r.Fragment = fragment + } +} + +func New(path string, params url.Values, opts ...Option) *Request { + r := Request{ + Host: defaultHost, + Path: path, + Params: params, + } + + for _, opt := range opts { + opt(&r) + } + return &r +} + +func (r Request) Endpoint() string { + u := url.URL{ + Scheme: "https", + Host: r.Host, + Path: r.Path, + RawQuery: r.Params.Encode(), + Fragment: r.Fragment, + } + return u.String() +} + +func (r Request) Get(ctx context.Context, result interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.Endpoint(), nil) + if err != nil { + return nil + } + return r.SendRequest(req, result) +} + +func (r Request) Post(ctx context.Context, data interface{}, result interface{}) error { + dataBytes, err := json.Marshal(data) + if err != nil { + return err + } + body := bytes.NewReader(dataBytes) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.Endpoint(), body) + if err != nil { + return nil + } + return r.SendRequest(req, result) +} + +func (r Request) SendRequest(req *http.Request, result interface{}) error { + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + return json.Unmarshal(body, &result) +}