package wechatmini import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "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 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 } // ============================ 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 } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }