package wechatmp import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" ) // ============================================================= // 微信公众平台服务端 API 调用(access_token / 带参二维码 / 模板消息 / 订阅通知 / 用户信息) // ============================================================= const apiBase = "https://api.weixin.qq.com" var httpClient = &http.Client{Timeout: 10 * time.Second} // APIError 微信接口业务错误(HTTP 200 但 errcode 非 0)。 // Error() 输出保留 errcode / errmsg 原始信息,便于日志检索与前端展示。 type APIError struct { API string // 调用的接口(如「获取 access_token」) 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 40013: return "AppID 无效:请核对微信后台「设置与开发 → 基本配置」中的 AppID" case 40125: return "AppSecret 错误:请在微信后台重置后复制新的 AppSecret 填入本页" case 40164, 61004: return "服务器出口 IP 不在白名单:请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)" case 45009: return "接口调用频次超限:请稍后重试" case 48001: return "接口未获授权:需认证服务号,或该账号未开通此接口能力" } return "" } // ExplainError 提取微信接口错误的中文排查建议;无可用建议时返回 ("", false)。 func ExplainError(err error) (string, bool) { var ae *APIError if errors.As(err, &ae) { if hint := ae.Explain(); hint != "" { return hint, true } } return "", false } // GetAccessToken 获取 access_token(内存缓存,提前 5 分钟刷新) func GetAccessToken(cfg *Config) (string, error) { if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" { return "", fmt.Errorf("AppID / AppSecret 未配置") } 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 } // CreateQRCode 创建带参数临时二维码(字符串场景值),返回二维码图片地址。 // scene 会通过「关注事件的 EventKey(qrscene_)/ 扫码事件的 EventKey()」回传。 func CreateQRCode(cfg *Config, scene string, expireSeconds int) (string, error) { token, err := GetAccessToken(cfg) if err != nil { return "", err } if expireSeconds <= 0 || expireSeconds > 2592000 { expireSeconds = 300 } payload := map[string]interface{}{ "expire_seconds": expireSeconds, "action_name": "QR_STR_SCENE", "action_info": map[string]interface{}{ "scene": map[string]string{"scene_str": scene}, }, } api := fmt.Sprintf("%s/cgi-bin/qrcode/create?access_token=%s", apiBase, url.QueryEscape(token)) var resp struct { Ticket string `json:"ticket"` ExpireSeconds int `json:"expire_seconds"` URL string `json:"url"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := httpPostJSON(api, payload, &resp); err != nil { return "", err } if resp.Ticket == "" { return "", &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg} } return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(resp.Ticket), nil } // SendTemplateMessage 发送模板消息(需认证服务号 + 模板ID) // data 键为模板字段名(first / keyword1 / remark ...),值为文本内容。 func SendTemplateMessage(cfg *Config, openid, jumpURL string, data map[string]string) error { if cfg.TemplateID == "" { return fmt.Errorf("未配置模板消息 ID(认证后在公众号后台申请)") } token, err := GetAccessToken(cfg) if err != nil { return err } fields := make(map[string]map[string]string, len(data)) for k, v := range data { fields[k] = map[string]string{"value": v} } payload := map[string]interface{}{ "touser": openid, "template_id": cfg.TemplateID, "data": fields, } if jumpURL != "" { payload["url"] = jumpURL } api := fmt.Sprintf("%s/cgi-bin/message/template/send?access_token=%s", apiBase, url.QueryEscape(token)) var resp struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` MsgID int64 `json:"msgid"` } if err := httpPostJSON(api, payload, &resp); err != nil { return err } if resp.ErrCode != 0 { return &APIError{API: "发送模板消息", Code: resp.ErrCode, Msg: resp.ErrMsg} } return nil } // SendSubscribeMessage 发送订阅通知(一次性订阅,需用户先在页面完成订阅授权) func SendSubscribeMessage(cfg *Config, openid, templateID, page string, data map[string]string) error { if templateID == "" { return fmt.Errorf("未配置订阅通知模板 ID") } token, err := GetAccessToken(cfg) if err != nil { return err } fields := make(map[string]map[string]string, len(data)) for k, v := range data { fields[k] = map[string]string{"value": v} } payload := map[string]interface{}{ "touser": openid, "template_id": templateID, "data": fields, } if page != "" { payload["page"] = page } api := fmt.Sprintf("%s/cgi-bin/message/subscribe/bizsend?access_token=%s", apiBase, url.QueryEscape(token)) var resp struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := httpPostJSON(api, payload, &resp); err != nil { return err } if resp.ErrCode != 0 { return &APIError{API: "发送订阅通知", Code: resp.ErrCode, Msg: resp.ErrMsg} } return nil } // UserInfo 用户基本信息(昵称/头像等;未认证公众号可能接口受限,调用失败由上层忽略) type UserInfo struct { OpenID string `json:"openid"` Nickname string `json:"nickname"` Sex int `json:"sex"` City string `json:"city"` Province string `json:"province"` Country string `json:"country"` HeadImgURL string `json:"headimgurl"` Subscribe int `json:"subscribe"` } // FetchUserInfo 拉取用户基本信息(失败不算错,由调用方容错) func FetchUserInfo(cfg *Config, openid string) (*UserInfo, error) { token, err := GetAccessToken(cfg) if err != nil { return nil, err } api := fmt.Sprintf("%s/cgi-bin/user/info?access_token=%s&openid=%s&lang=zh_CN", apiBase, url.QueryEscape(token), url.QueryEscape(openid)) var resp struct { UserInfo ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := httpGetJSON(api, &resp); err != nil { return nil, err } if resp.ErrCode != 0 { return nil, &APIError{API: "获取用户信息", Code: resp.ErrCode, Msg: resp.ErrMsg} } return &resp.UserInfo, 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] + "..." }