132 lines
4.4 KiB
Go
132 lines
4.4 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
type WeChatProvider interface {
|
||
|
|
OpenID(context.Context, string) (string, error)
|
||
|
|
Phone(context.Context, string) (string, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// AppSecret and provider tokens stay server-side. Provider errors must not
|
||
|
|
// include the request URL: WeChat places credentials in query parameters.
|
||
|
|
type weChatClient struct {
|
||
|
|
appID, secret, base string
|
||
|
|
http *http.Client
|
||
|
|
mu sync.Mutex
|
||
|
|
token string
|
||
|
|
expires time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
func newWeChatClient(appID, secret string) *weChatClient {
|
||
|
|
return &weChatClient{appID: appID, secret: secret, base: "https://api.weixin.qq.com", http: &http.Client{Timeout: 6 * time.Second}}
|
||
|
|
}
|
||
|
|
func (c *weChatClient) call(ctx context.Context, path string, query url.Values, body any, out any) error {
|
||
|
|
method := http.MethodGet
|
||
|
|
var reader io.Reader
|
||
|
|
if body != nil {
|
||
|
|
raw, err := json.Marshal(body)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
reader = bytes.NewReader(raw)
|
||
|
|
method = http.MethodPost
|
||
|
|
}
|
||
|
|
u := c.base + path
|
||
|
|
if len(query) > 0 {
|
||
|
|
u += "?" + query.Encode()
|
||
|
|
}
|
||
|
|
req, err := http.NewRequestWithContext(ctx, method, u, reader)
|
||
|
|
if err != nil {
|
||
|
|
return fail(503, "WECHAT_UNAVAILABLE", "微信服务暂不可用,请稍后重试")
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
res, err := c.http.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return fail(503, "WECHAT_UNAVAILABLE", "微信服务暂不可用,请稍后重试")
|
||
|
|
}
|
||
|
|
defer res.Body.Close()
|
||
|
|
if res.StatusCode != 200 || json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(out) != nil {
|
||
|
|
return fail(503, "WECHAT_UNAVAILABLE", "微信服务响应异常,请稍后重试")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
func (c *weChatClient) OpenID(ctx context.Context, code string) (string, error) {
|
||
|
|
var out struct {
|
||
|
|
OpenID string `json:"openid"`
|
||
|
|
ErrCode int `json:"errcode"`
|
||
|
|
}
|
||
|
|
err := c.call(ctx, "/sns/jscode2session", url.Values{"appid": {c.appID}, "secret": {c.secret}, "js_code": {code}, "grant_type": {"authorization_code"}}, nil, &out)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
if out.ErrCode != 0 || out.OpenID == "" || len(out.OpenID) > 128 {
|
||
|
|
return "", fail(401, "WECHAT_LOGIN_FAILED", "微信登录已失效,请重新点击登录")
|
||
|
|
}
|
||
|
|
return out.OpenID, nil
|
||
|
|
}
|
||
|
|
func (c *weChatClient) accessToken(ctx context.Context, invalid string) (string, error) {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
if c.token != "" && c.token != invalid && time.Now().Before(c.expires) {
|
||
|
|
return c.token, nil
|
||
|
|
}
|
||
|
|
var out struct {
|
||
|
|
Token string `json:"access_token"`
|
||
|
|
Expires int `json:"expires_in"`
|
||
|
|
ErrCode int `json:"errcode"`
|
||
|
|
}
|
||
|
|
err := c.call(ctx, "/cgi-bin/stable_token", nil, map[string]any{"grant_type": "client_credential", "appid": c.appID, "secret": c.secret, "force_refresh": invalid != ""}, &out)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
if out.ErrCode != 0 || out.Token == "" || out.Expires <= 60 {
|
||
|
|
return "", fail(503, "WECHAT_CONFIG_ERROR", "微信登录尚未配置完成,请使用手机号登录")
|
||
|
|
}
|
||
|
|
c.token = out.Token
|
||
|
|
c.expires = time.Now().Add(time.Duration(out.Expires-60) * time.Second)
|
||
|
|
return c.token, nil
|
||
|
|
}
|
||
|
|
func (c *weChatClient) Phone(ctx context.Context, code string) (string, error) {
|
||
|
|
invalid := ""
|
||
|
|
for i := 0; i < 2; i++ {
|
||
|
|
token, err := c.accessToken(ctx, invalid)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
var out struct {
|
||
|
|
ErrCode int `json:"errcode"`
|
||
|
|
Info struct {
|
||
|
|
Number string `json:"purePhoneNumber"`
|
||
|
|
Country string `json:"countryCode"`
|
||
|
|
Watermark struct {
|
||
|
|
AppID string `json:"appid"`
|
||
|
|
} `json:"watermark"`
|
||
|
|
} `json:"phone_info"`
|
||
|
|
}
|
||
|
|
if err = c.call(ctx, "/wxa/business/getuserphonenumber", url.Values{"access_token": {token}}, map[string]string{"code": code}, &out); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
if out.ErrCode == 40001 || out.ErrCode == 40014 || out.ErrCode == 42001 {
|
||
|
|
invalid = token
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if out.ErrCode == 48001 || out.ErrCode == 48004 || out.ErrCode == 200003 {
|
||
|
|
return "", fail(503, "WECHAT_PHONE_UNAVAILABLE", "小程序尚未开通手机号授权,请使用手机号登录")
|
||
|
|
}
|
||
|
|
if out.ErrCode != 0 || out.Info.Watermark.AppID != c.appID || !phonePattern.MatchString("+"+out.Info.Country+out.Info.Number) {
|
||
|
|
return "", fail(400, "WECHAT_PHONE_INVALID", "手机号授权失败,请重新授权或使用密码登录")
|
||
|
|
}
|
||
|
|
return "+" + out.Info.Country + out.Info.Number, nil
|
||
|
|
}
|
||
|
|
return "", fail(503, "WECHAT_UNAVAILABLE", "微信服务暂不可用,请稍后重试")
|
||
|
|
}
|