Files
2026-11-11 16:58:30 +08:00

317 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package wechatmini
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// =============================================================
// 微信小程序服务端 API
// 1. GET /sns/jscode2session code → openid / unionid / session_key
// 2. POST /wxa/business/getuserphonenumber phone_code → 手机号(需 access_token)
// =============================================================
const apiBase = "https://api.weixin.qq.com"
var httpClient = &http.Client{Timeout: 10 * time.Second}
// APIError 微信接口业务错误(HTTP 200 但 errcode 非 0)
type APIError struct {
API string
Code int
Msg string
}
func (e *APIError) Error() string {
return fmt.Sprintf("%s 失败:errcode=%d errmsg=%s", e.API, e.Code, e.Msg)
}
// Explain 返回常见 errcode 的中文排查建议;未知错误码返回空串
func (e *APIError) Explain() string {
switch e.Code {
case 40029:
return "code 无效或已过期:请重新调用 wx.login / uni.login 获取 code 后立即重试"
case 40163:
return "code 已被使用:同一次 code 只能兑换一次,请重新获取"
case 40013:
return "AppID 无效:请核对小程序后台「开发 → 开发管理」中的 AppID"
case 40125:
return "AppSecret 错误:请在小程序后台重置后复制新的 AppSecret 填入本页"
case 40164, 61004:
return "服务器出口 IP 不在白名单:请在小程序后台「开发管理 → 开发设置 → IP白名单」加入本服务器出口 IP"
case 40226:
return "该小程序未开通手机号快速验证能力:请在「开发管理 → 接口设置」中开通"
case 45009:
return "接口调用频次超限:请稍后重试"
}
return ""
}
// ExplainError 提取中文排查建议;无可用建议时返回 ("", false)
func ExplainError(err error) (string, bool) {
if ae, ok := err.(*APIError); ok {
if hint := ae.Explain(); hint != "" {
return hint, true
}
}
return "", false
}
// ============================ code 换 openid ============================
// Session jscode2session 返回结果
type Session struct {
OpenID string `json:"openid"`
UnionID string `json:"unionid"`
SessionKey string `json:"session_key"`
}
// Code2Session 用 wx.login / uni.login 拿到的 code 换 openid(+ unionid)
func Code2Session(cfg *Config, code string) (*Session, error) {
if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
return nil, ErrNotConfigured
}
api := fmt.Sprintf("%s/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
apiBase, url.QueryEscape(cfg.AppID), url.QueryEscape(cfg.AppSecret), url.QueryEscape(code))
var resp struct {
OpenID string `json:"openid"`
UnionID string `json:"unionid"`
SessionKey string `json:"session_key"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := httpGetJSON(api, &resp); err != nil {
return nil, err
}
if resp.OpenID == "" {
return nil, &APIError{API: "code 换取 openid", Code: resp.ErrCode, Msg: resp.ErrMsg}
}
return &Session{OpenID: resp.OpenID, UnionID: resp.UnionID, SessionKey: resp.SessionKey}, nil
}
// ============================ 手机号快速验证 ============================
// PhoneNumber getuserphonenumber 返回结果
type PhoneNumber struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
}
// GetPhoneNumber 用 getPhoneNumber 事件返回的动态 code 换真实手机号。
// 仅企业主体小程序可用;未开通该能力时微信返回 40226。
func GetPhoneNumber(cfg *Config, phoneCode string) (*PhoneNumber, error) {
if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
return nil, ErrNotConfigured
}
token, err := getAccessToken(cfg)
if err != nil {
return nil, err
}
api := fmt.Sprintf("%s/wxa/business/getuserphonenumber?access_token=%s", apiBase, url.QueryEscape(token))
payload := map[string]interface{}{"code": phoneCode}
var resp struct {
PhoneInfo struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
} `json:"phone_info"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := httpPostJSON(api, payload, &resp); err != nil {
return nil, err
}
if resp.ErrCode != 0 || resp.PhoneInfo.PhoneNumber == "" {
return nil, &APIError{API: "获取手机号", Code: resp.ErrCode, Msg: resp.ErrMsg}
}
return &PhoneNumber{
PhoneNumber: resp.PhoneInfo.PhoneNumber,
PurePhoneNumber: resp.PhoneInfo.PurePhoneNumber,
CountryCode: resp.PhoneInfo.CountryCode,
}, nil
}
// ============================ 小程序码 ============================
// CreateUnlimitedQRCode 生成小程序码(wxa/getwxacodeunlimited,数量无限制),
// 返回图片 base64 data URL,可直接用于 <img src>。
//
// scene 会话标识,扫码后作为 options.scene 传给目标页面,1-32 个字符
// page 小程序页面路径(如 pages/register/confirm),必须已发布,不带前导 /
// envVersion release(正式版,默认)/ trial(体验版)/ develop(开发版)
func CreateUnlimitedQRCode(cfg *Config, scene, page, envVersion string) (string, error) {
if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
return "", ErrNotConfigured
}
page = strings.TrimPrefix(strings.TrimSpace(page), "/")
if page == "" {
return "", errors.New("未配置小程序码页面路径")
}
scene = strings.TrimSpace(scene)
if scene == "" || len(scene) > 32 {
return "", errors.New("小程序码 scene 无效(需 1-32 个字符)")
}
envVersion = strings.TrimSpace(envVersion)
if envVersion == "" {
envVersion = "release"
}
token, err := getAccessToken(cfg)
if err != nil {
return "", err
}
payload := map[string]interface{}{
"scene": scene,
"page": page,
"env_version": envVersion,
"width": 280,
}
// 体验版 / 开发版下该页面尚未发布到正式版,必须关闭路径校验,
// 否则微信返回 40066 invalid url(正式版可保留默认校验)
if envVersion != "release" {
payload["check_path"] = false
}
api := fmt.Sprintf("%s/wxa/getwxacodeunlimited?access_token=%s", apiBase, url.QueryEscape(token))
body, contentType, err := httpPostBinary(api, payload)
if err != nil {
return "", err
}
// 失败时微信仍返回 200,但响应体是 JSON 错误而非图片
if strings.HasPrefix(strings.TrimSpace(string(body)), "{") {
var resp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
_ = json.Unmarshal(body, &resp)
return "", &APIError{API: "生成小程序码", Code: resp.ErrCode, Msg: resp.ErrMsg}
}
if contentType == "" {
contentType = "image/jpeg"
}
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(body), nil
}
// ============================ access_token 缓存 ============================
type tokenEntry struct {
appID string
token string
expiresAt time.Time
}
var (
tokenMu sync.Mutex
tokenCache tokenEntry
tokenMinTTL = 5 * time.Minute
)
// GetAccessToken 获取小程序 access_token(内存缓存,提前 5 分钟刷新)
func GetAccessToken(cfg *Config) (string, error) { return getAccessToken(cfg) }
func getAccessToken(cfg *Config) (string, error) {
tokenMu.Lock()
if tokenCache.appID == cfg.AppID && tokenCache.token != "" && time.Now().Before(tokenCache.expiresAt) {
t := tokenCache.token
tokenMu.Unlock()
return t, nil
}
tokenMu.Unlock()
api := fmt.Sprintf("%s/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
apiBase, url.QueryEscape(cfg.AppID), url.QueryEscape(cfg.AppSecret))
var resp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := httpGetJSON(api, &resp); err != nil {
return "", err
}
if resp.AccessToken == "" {
return "", &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg}
}
ttl := time.Duration(resp.ExpiresIn) * time.Second
if ttl <= tokenMinTTL {
ttl = time.Hour
}
tokenMu.Lock()
tokenCache = tokenEntry{appID: cfg.AppID, token: resp.AccessToken, expiresAt: time.Now().Add(ttl - tokenMinTTL)}
tokenMu.Unlock()
return resp.AccessToken, nil
}
// ============================ HTTP 辅助 ============================
func httpGetJSON(api string, out interface{}) error {
resp, err := httpClient.Get(api)
if err != nil {
return fmt.Errorf("请求微信接口失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取微信响应失败: %w", err)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200))
}
return nil
}
func httpPostJSON(api string, payload interface{}, out interface{}) error {
bs, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(api, "application/json; charset=utf-8", bytes.NewReader(bs))
if err != nil {
return fmt.Errorf("请求微信接口失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取微信响应失败: %w", err)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200))
}
return nil
}
// httpPostBinary POST JSON 并返回原始响应体与 Content-Type(小程序码接口返回图片流)
func httpPostBinary(api string, payload interface{}) ([]byte, string, error) {
bs, err := json.Marshal(payload)
if err != nil {
return nil, "", err
}
resp, err := httpClient.Post(api, "application/json; charset=utf-8", bytes.NewReader(bs))
if err != nil {
return nil, "", fmt.Errorf("请求微信接口失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("读取微信响应失败: %w", err)
}
return body, resp.Header.Get("Content-Type"), nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}