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 }