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