Files
wxapp-guantong/backend/internal/httpapi/wechat_phone.go
T
2026-09-22 22:51:19 +08:00

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)
}