283 lines
8.8 KiB
Go
283 lines
8.8 KiB
Go
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] + "..."
|
||
}
|