196 lines
5.9 KiB
Go
196 lines
5.9 KiB
Go
package pkg
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"sync"
|
||
"time"
|
||
|
||
"net/http"
|
||
|
||
"errors"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
)
|
||
|
||
type WeChatLoginResponse struct {
|
||
OpenID string `json:"openid"` // 用户唯一标识
|
||
SessionKey string `json:"session_key"` // 会话密钥
|
||
UnionID string `json:"unionid"` // 用户在开放平台的唯一标识(如果绑定了开放平台才有)
|
||
Errcode int `json:"errcode"` // 错误码,0为成功
|
||
Errmsg string `json:"errmsg"` // 错误信息
|
||
}
|
||
|
||
func GetOpenID(appID, appSecret, code string) (openid string, err error) {
|
||
if os.Getenv("env") == "unit_test" {
|
||
return "test_123456", nil
|
||
}
|
||
// 1. 构建请求微信接口的 URL
|
||
url := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
|
||
appID, appSecret, code)
|
||
|
||
// 2. 创建 HTTP 客户端(设置超时,避免阻塞)
|
||
client := &http.Client{
|
||
Timeout: 5 * time.Second,
|
||
// 在某些网络受限的环境(如本地测试跳过证书验证,生产环境建议去掉)
|
||
Transport: &http.Transport{
|
||
TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, // 生产环境建议设为 false
|
||
},
|
||
}
|
||
|
||
// 3. 发起 GET 请求
|
||
resp, err := client.Get(url)
|
||
if err != nil {
|
||
return "", fmt.Errorf("请求微信服务器失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 4. 读取返回的 Body
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", fmt.Errorf("读取微信响应失败: %w", err)
|
||
}
|
||
|
||
// 5. 解析 JSON 数据
|
||
var wechatResp WeChatLoginResponse
|
||
err = json.Unmarshal(body, &wechatResp)
|
||
if err != nil {
|
||
return "", fmt.Errorf("解析微信响应 JSON 失败: %s, 原始数据: %s", err.Error(), string(body))
|
||
}
|
||
|
||
// 6. 检查微信接口返回的错误码
|
||
if wechatResp.Errcode != 0 {
|
||
// 这里可以根据不同的错误码做特殊处理,例如 code 无效、过期等
|
||
return "", fmt.Errorf("微信接口返回错误: code=%d, msg=%s", wechatResp.Errcode, wechatResp.Errmsg)
|
||
}
|
||
|
||
// 7. 检查 OpenID 是否为空(理论上不会,但防御性编程)
|
||
if wechatResp.OpenID == "" {
|
||
return "", errors.New("微信返回的 OpenID 为空")
|
||
}
|
||
|
||
// 8. 返回 OpenID
|
||
return wechatResp.OpenID, nil
|
||
}
|
||
|
||
type AccessTokenResponse struct {
|
||
AccessToken string `json:"access_token"`
|
||
ExpiresIn int `json:"expires_in"`
|
||
Errcode int `json:"errcode"`
|
||
Errmsg string `json:"errmsg"`
|
||
}
|
||
|
||
var (
|
||
tokenMutex sync.Mutex
|
||
cacheKey = "wx:access_token"
|
||
)
|
||
|
||
// GetAccessToken 获取 access_token,带本地缓存
|
||
func GetAccessToken(ctx context.Context, appID, appSecret string, rdb *redis.Client) (string, error) {
|
||
if rdb == nil {
|
||
return "", errors.New("缓存工具未提供")
|
||
}
|
||
|
||
cacheToken := rdb.Get(ctx, cacheKey).Val()
|
||
if cacheToken != "" {
|
||
return cacheToken, nil
|
||
}
|
||
tokenMutex.Lock()
|
||
defer tokenMutex.Unlock()
|
||
|
||
// 请求微信接口获取新的 access_token
|
||
url := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", appID, appSecret)
|
||
|
||
client := &http.Client{Timeout: 5 * time.Second}
|
||
resp, err := client.Get(url)
|
||
if err != nil {
|
||
return "", fmt.Errorf("请求 access_token 失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
var tokenRes AccessTokenResponse
|
||
if err := json.Unmarshal(body, &tokenRes); err != nil {
|
||
return "", fmt.Errorf("解析 access_token 响应失败: %w", err)
|
||
}
|
||
|
||
if tokenRes.Errcode != 0 {
|
||
return "", fmt.Errorf("获取 access_token 失败: code=%d, msg=%s", tokenRes.Errcode, tokenRes.Errmsg)
|
||
}
|
||
|
||
// 缓存 token,提前5分钟过期,避免边界情况
|
||
|
||
rdb.Set(ctx, cacheKey, tokenRes.AccessToken, time.Duration(tokenRes.ExpiresIn-300)*time.Second)
|
||
return tokenRes.AccessToken, nil
|
||
}
|
||
|
||
// PhoneInfo 定义手机号信息的结构体,与微信官方文档对齐 [citation:3][citation:8]
|
||
type PhoneInfo struct {
|
||
PhoneNumber string `json:"phoneNumber"` // 用户绑定的手机号(国外手机号会有区号)
|
||
PurePhoneNumber string `json:"purePhoneNumber"` // 没有区号的手机号
|
||
CountryCode string `json:"countryCode"` // 区号
|
||
Watermark struct {
|
||
Timestamp int64 `json:"timestamp"`
|
||
Appid string `json:"appid"`
|
||
} `json:"watermark"`
|
||
}
|
||
|
||
// PhoneInfoResponse 定义微信接口返回的完整结构
|
||
type PhoneInfoResponse struct {
|
||
Errcode int `json:"errcode"`
|
||
Errmsg string `json:"errmsg"`
|
||
PhoneInfo PhoneInfo `json:"phone_info"`
|
||
}
|
||
|
||
// GetPhoneNumber 通过手机号 code 获取用户手机号
|
||
// 参数:
|
||
// - appID: 小程序的 AppID
|
||
// - appSecret: 小程序的 AppSecret
|
||
// - phoneCode: 前端通过 getPhoneNumber 获取的 code
|
||
//
|
||
// 返回:
|
||
// - *PhoneInfo: 手机号信息
|
||
// - error: 错误信息
|
||
func GetPhoneNumber(ctx context.Context, appID, appSecret, phoneCode string, rdb *redis.Client) (*PhoneInfo, error) {
|
||
// 1. 获取 access_token
|
||
accessToken, err := GetAccessToken(ctx, appID, appSecret, rdb)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 access_token 失败: %w", err)
|
||
}
|
||
|
||
// 2. 调用微信接口换取手机号 [citation:8]
|
||
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s", accessToken)
|
||
|
||
// 构建请求体
|
||
requestBody := map[string]string{
|
||
"code": phoneCode,
|
||
}
|
||
jsonBody, _ := json.Marshal(requestBody)
|
||
|
||
client := &http.Client{Timeout: 5 * time.Second}
|
||
resp, err := client.Post(url, "application/json", bytes.NewReader(jsonBody))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("请求手机号接口失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
|
||
var phoneResp PhoneInfoResponse
|
||
if err := json.Unmarshal(body, &phoneResp); err != nil {
|
||
return nil, fmt.Errorf("解析手机号响应失败: %s", string(body))
|
||
}
|
||
|
||
// 3. 检查微信接口返回的错误码 [citation:8]
|
||
if phoneResp.Errcode != 0 {
|
||
return nil, fmt.Errorf("微信接口返回错误: code=%d, msg=%s", phoneResp.Errcode, phoneResp.Errmsg)
|
||
}
|
||
|
||
return &phoneResp.PhoneInfo, nil
|
||
}
|