Files
2026-09-16 18:06:18 +08:00

283 lines
8.8 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 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_<scene>)/ 扫码事件的 EventKey(<scene>)」回传。
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] + "..."
}