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,可直接用于 。 // // 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] + "..." }