package wechatmp import ( "bytes" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/beego/beego/v2/core/logs" ) // ============================================================= // 微信公众平台服务端 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 "" } // ============================ 出口 IP 探测(40164 IP 白名单排查用) ============================ var ( outboundIPMu sync.Mutex outboundIPCache string outboundIPAt time.Time ) // outboundIPTTL 出口 IP 缓存时长(探测失败也按此负缓存,避免每次请求都外网探测) const outboundIPTTL = 5 * time.Minute // ipDetectServices 出口 IP 探测服务,按顺序尝试,任一返回 IPv4 即采用 var ipDetectServices = []string{"https://ifconfig.me/ip", "https://api.ipify.org", "https://myip.ipip.net"} var ipv4Re = regexp.MustCompile(`\d{1,3}(?:\.\d{1,3}){3}`) // OutboundIP 返回本服务器访问公网时的出口 IP(微信后台「IP白名单」需填入该 IP)。 // 结果内存缓存 5 分钟;服务器无法访问探测服务时返回空串,不影响主流程。 func OutboundIP() string { outboundIPMu.Lock() if time.Since(outboundIPAt) < outboundIPTTL { ip := outboundIPCache outboundIPMu.Unlock() return ip } outboundIPMu.Unlock() ip := detectOutboundIP() outboundIPMu.Lock() outboundIPCache = ip outboundIPAt = time.Now() outboundIPMu.Unlock() return ip } func detectOutboundIP() string { client := &http.Client{Timeout: 3 * time.Second} for _, svc := range ipDetectServices { resp, err := client.Get(svc) if err != nil { continue } body, rerr := io.ReadAll(io.LimitReader(resp.Body, 256)) resp.Body.Close() if rerr != nil { continue } if ip := firstIPv4(string(body)); ip != "" { return ip } } return "" } // firstIPv4 取文本中第一个合法 IPv4(形如 1.2.3.4),无则返回空串。 func firstIPv4(s string) string { m := ipv4Re.FindString(s) if m == "" || net.ParseIP(m) == nil { return "" } return m } // ErrMsgIPs 从微信 errmsg 中提取 IP(如 "invalid ip 1.2.3.4 ipv6 ::ffff:1.2.3.4")。 func ErrMsgIPs(errmsg string) []string { var out []string seen := make(map[string]bool) for _, m := range ipv4Re.FindAllString(errmsg, -1) { if net.ParseIP(m) == nil || seen[m] { continue } seen[m] = true out = append(out, m) } return out } // logAPIError 记录微信接口错误详情: // errcode / errmsg / 微信提示的 IP / 本服务器出口 IP / 排查建议只落服务端日志。 // 注册页、登录页等匿名可访问页面不应回显这些信息(暴露服务器出口 IP 与内部错误)。 func logAPIError(scene string, err error) { if err == nil { return } detail := err.Error() var ae *APIError if errors.As(err, &ae) && ae != nil { if hint := ae.Explain(); hint != "" { detail += ";" + hint } if ips := ErrMsgIPs(ae.Msg); len(ips) > 0 { detail += ";微信提示的 IP:" + strings.Join(ips, ", ") } } if ip := OutboundIP(); ip != "" { detail += ";本服务器出口 IP:" + ip } logs.Warning("[wechat-mp] %s 失败:%s", scene, detail) } // 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 == "" { apiErr := &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg} logAPIError("获取 access_token", apiErr) return "", apiErr } 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 == "" { apiErr := &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg} logAPIError("创建带参二维码", apiErr) return "", apiErr } 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] + "..." }