201 lines
5.3 KiB
Go
201 lines
5.3 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config"
|
|
)
|
|
|
|
const defaultWeChatAPIBase = "https://api.weixin.qq.com"
|
|
|
|
type wechatPhoneClient struct {
|
|
appID string
|
|
appSecret string
|
|
apiBase string
|
|
client *http.Client
|
|
|
|
mu sync.Mutex
|
|
cachedAccessToken string
|
|
accessTokenExp time.Time
|
|
}
|
|
|
|
type missingWeChatConfigError struct {
|
|
missing []string
|
|
}
|
|
|
|
func (e missingWeChatConfigError) Error() string {
|
|
return "missing WeChat app configuration: set " + strings.Join(e.missing, ", ")
|
|
}
|
|
|
|
type wechatAccessTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
ErrCode int64 `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
|
|
type wechatPhoneResponse struct {
|
|
ErrCode int64 `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
PhoneInfo struct {
|
|
PhoneNumber string `json:"phoneNumber"`
|
|
PurePhoneNumber string `json:"purePhoneNumber"`
|
|
CountryCode string `json:"countryCode"`
|
|
} `json:"phone_info"`
|
|
}
|
|
|
|
func newWeChatPhoneClient(cfg config.Config) *wechatPhoneClient {
|
|
apiBase := strings.TrimRight(strings.TrimSpace(cfg.WeChatAPIBase), "/")
|
|
if apiBase == "" {
|
|
apiBase = defaultWeChatAPIBase
|
|
}
|
|
return &wechatPhoneClient{
|
|
appID: strings.TrimSpace(cfg.WeChatAppID),
|
|
appSecret: strings.TrimSpace(cfg.WeChatAppSecret),
|
|
apiBase: apiBase,
|
|
client: &http.Client{
|
|
Timeout: 8 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *wechatPhoneClient) PhoneNumber(ctx context.Context, phoneCode string) (string, error) {
|
|
phoneCode = strings.TrimSpace(phoneCode)
|
|
if phoneCode == "" {
|
|
return "", errors.New("phoneCode is required")
|
|
}
|
|
token, err := c.accessToken(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
payload, err := json.Marshal(map[string]string{"code": phoneCode})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
endpoint := c.apiURL("/wxa/business/getuserphonenumber")
|
|
query := endpoint.Query()
|
|
query.Set("access_token", token)
|
|
endpoint.RawQuery = query.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("request WeChat phone API: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("WeChat phone API returned HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var data wechatPhoneResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
return "", fmt.Errorf("decode WeChat phone response: %w", err)
|
|
}
|
|
if data.ErrCode != 0 {
|
|
return "", fmt.Errorf("WeChat phone API error %d: %s", data.ErrCode, data.ErrMsg)
|
|
}
|
|
phone := strings.TrimSpace(data.PhoneInfo.PhoneNumber)
|
|
if phone == "" {
|
|
phone = strings.TrimSpace(data.PhoneInfo.PurePhoneNumber)
|
|
}
|
|
if phone == "" {
|
|
return "", errors.New("WeChat phone API returned empty phone number")
|
|
}
|
|
return phone, nil
|
|
}
|
|
|
|
func (c *wechatPhoneClient) accessToken(ctx context.Context) (string, error) {
|
|
if err := c.validateConfig(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.cachedAccessToken != "" && time.Now().Before(c.accessTokenExp) {
|
|
return c.cachedAccessToken, nil
|
|
}
|
|
|
|
endpoint := c.apiURL("/cgi-bin/token")
|
|
query := endpoint.Query()
|
|
query.Set("grant_type", "client_credential")
|
|
query.Set("appid", c.appID)
|
|
query.Set("secret", c.appSecret)
|
|
endpoint.RawQuery = query.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("request WeChat access_token API: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("WeChat access_token API returned HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var data wechatAccessTokenResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
return "", fmt.Errorf("decode WeChat access_token response: %w", err)
|
|
}
|
|
if data.ErrCode != 0 {
|
|
return "", fmt.Errorf("WeChat access_token API error %d: %s", data.ErrCode, data.ErrMsg)
|
|
}
|
|
if strings.TrimSpace(data.AccessToken) == "" {
|
|
return "", errors.New("WeChat access_token API returned empty access_token")
|
|
}
|
|
c.cachedAccessToken = strings.TrimSpace(data.AccessToken)
|
|
c.accessTokenExp = tokenExpiry(data.ExpiresIn)
|
|
return c.cachedAccessToken, nil
|
|
}
|
|
|
|
func (c *wechatPhoneClient) validateConfig() error {
|
|
missing := make([]string, 0, 2)
|
|
if c.appID == "" {
|
|
missing = append(missing, "GUAHAO_WECHAT_APPID")
|
|
}
|
|
if c.appSecret == "" {
|
|
missing = append(missing, "GUAHAO_WECHAT_SECRET")
|
|
}
|
|
if len(missing) > 0 {
|
|
return missingWeChatConfigError{missing: missing}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *wechatPhoneClient) apiURL(path string) url.URL {
|
|
base, err := url.Parse(c.apiBase)
|
|
if err != nil || base.Scheme == "" || base.Host == "" {
|
|
base, _ = url.Parse(defaultWeChatAPIBase)
|
|
}
|
|
base.Path = strings.TrimRight(base.Path, "/") + path
|
|
base.RawQuery = ""
|
|
return *base
|
|
}
|
|
|
|
func tokenExpiry(expiresIn int64) time.Time {
|
|
if expiresIn <= 0 {
|
|
expiresIn = 3600
|
|
}
|
|
refreshSkew := int64(300)
|
|
if expiresIn <= refreshSkew*2 {
|
|
refreshSkew = expiresIn / 2
|
|
}
|
|
return time.Now().Add(time.Duration(expiresIn-refreshSkew) * time.Second)
|
|
}
|