Files

132 lines
4.4 KiB
Go
Raw Permalink Normal View History

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", "微信服务暂不可用,请稍后重试")
}